Skip to content

Commit b0f5325

Browse files
authored
Merge pull request #5 from algolia/feat/separateVoiceInput
Separate voice input from presenting fragment
2 parents 2a80d3c + ebaab79 commit b0f5325

File tree

11 files changed

+285
-227
lines changed

11 files changed

+285
-227
lines changed

app/build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,6 @@ dependencies {
2727
// implementation "com.algolia.instantsearch-android:voice:1.+"
2828
implementation project(":instantsearch.voice")
2929

30-
implementation 'com.android.support:appcompat-v7:28.0.0-alpha1'
30+
implementation 'com.android.support:appcompat-v7:28.0.0-rc01'
3131
implementation 'com.android.support.constraint:constraint-layout:1.1.2'
3232
}

app/src/main/java/com/algolia/instantsearch/voice/demo/MainActivity.kt

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,31 @@
11
package com.algolia.instantsearch.voice.demo
22

33
import android.os.Bundle
4+
import android.support.v7.app.AlertDialog
45
import android.support.v7.app.AppCompatActivity
56
import com.algolia.instantsearch.voice.Voice
67
import com.algolia.instantsearch.voice.ui.PermissionDialogFragment
78
import com.algolia.instantsearch.voice.ui.VoiceDialogFragment
9+
import com.algolia.instantsearch.voice.VoiceInput
810
import kotlinx.android.synthetic.main.activity_main.*
911

10-
class MainActivity : AppCompatActivity(), VoiceDialogFragment.VoiceResultsListener {
12+
class MainActivity : AppCompatActivity(), VoiceInput.VoiceResultsListener {
13+
1114
override fun onCreate(savedInstanceState: Bundle?) {
1215
super.onCreate(savedInstanceState)
1316
setContentView(R.layout.activity_main)
1417

15-
button.setOnClickListener {
18+
button.setOnClickListener { _ ->
1619
if (!Voice.hasRecordPermission(this)) {
17-
PermissionDialogFragment().show(supportFragmentManager, "perm")
20+
PermissionDialogFragment().let {
21+
it.arguments = PermissionDialogFragment.buildArguments(title = "Voice Search.")
22+
it.show(supportFragmentManager, "perm")
23+
}
1824
} else {
1925
val voiceFragment = VoiceDialogFragment() //FIXME: Handle orientation changes, storing state properly
20-
voiceFragment.voiceResultsListener = this
21-
voiceFragment.languageCode = "en-US"
26+
voiceFragment.setSuggestions("Something", "Something else")
27+
voiceFragment.input.language = "en-US"
28+
voiceFragment.input.maxResults = 2
2229
voiceFragment.show(supportFragmentManager, "voice")
2330
}
2431
}

app/src/main/res/layout/activity_main.xml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@
1010
android:id="@+id/textView"
1111
android:layout_width="wrap_content"
1212
android:layout_height="wrap_content"
13-
android:text="Let's use your voice!"
13+
android:padding="8dp"
14+
android:text="@string/placeholder"
1415
android:textAppearance="@android:style/TextAppearance.Holo.Medium"
1516
app:layout_constraintBottom_toTopOf="@+id/button"
1617
app:layout_constraintLeft_toLeftOf="parent"
1718
app:layout_constraintRight_toRightOf="parent"
1819
app:layout_constraintTop_toTopOf="parent"
19-
app:layout_constraintVertical_bias="0.25" />
20+
app:layout_constraintVertical_bias="0.25"
21+
tools:text="This text could be a very long utterance by a user testing this demo app." />
2022

2123
<ImageButton
2224
android:id="@+id/button"
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
<resources>
22
<string name="app_name">VoiceDemo</string>
3+
<string name="placeholder">Let\'s use your voice!</string>
34
</resources>

build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
buildscript {
44
ext.VERSION_KOTLIN = '1.2.51'
5+
ext.VERSION_SUPPORT = '27.1.1'
56

67
repositories {
78
google()

instantsearch.voice/src/main/java/com/algolia/instantsearch/voice/Voice.kt

Lines changed: 27 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -7,44 +7,38 @@ import android.content.Context
77
import android.content.pm.PackageManager
88
import android.support.v4.app.ActivityCompat
99
import android.support.v4.content.ContextCompat
10-
import com.algolia.instantsearch.voice.ui.PermissionDialogFragment.Companion.ID_REQ_VOICE_PERM
10+
import com.algolia.instantsearch.voice.ui.PermissionDialogFragment.Companion.PermissionRequestRecordAudio
1111

1212
/** Helper functions for voice permission handling. */
13-
class Voice {
14-
companion object {
15-
/**
16-
* Gets whether the [permission results][grantResult] confirm it has been [granted][PackageManager.PERMISSION_GRANTED].
17-
*/
18-
@JvmStatic
19-
fun isPermissionGranted(grantResult: IntArray): Boolean {
20-
return grantResult[0] == PackageManager.PERMISSION_GRANTED
21-
}
13+
object Voice {
14+
/**
15+
* Gets whether the [permission results][grantResult] confirm it has been [granted][PackageManager.PERMISSION_GRANTED].
16+
*/
17+
@JvmStatic
18+
fun isPermissionGranted(grantResult: IntArray) =
19+
grantResult[0] == PackageManager.PERMISSION_GRANTED
2220

23-
/** Gets whether the [request's code][requestCode] and [results][grantResults] are valid, by respectively checking
24-
* that it matches the [request identifier][ID_REQ_VOICE_PERM] and that they are not empty.
25-
*
26-
*/
27-
@JvmStatic
28-
fun isRecordPermissionWithResults(requestCode: Int, grantResults: IntArray): Boolean {
29-
return requestCode == ID_REQ_VOICE_PERM && grantResults.isNotEmpty()
30-
}
21+
/** Gets whether the [request's code][requestCode] and [results][grantResults] are valid, by respectively checking
22+
* that it matches the [request identifier][PermissionRequestRecordAudio] and that they are not empty.
23+
*
24+
*/
25+
@JvmStatic
26+
fun isRecordPermissionWithResults(requestCode: Int, grantResults: IntArray) =
27+
requestCode == PermissionRequestRecordAudio && grantResults.isNotEmpty()
3128

32-
/**
33-
* Gets whether your application was granted the [recording permission][RECORD_AUDIO].
34-
*/
35-
@JvmStatic
36-
fun hasRecordPermission(context: Context): Boolean {
37-
return ContextCompat.checkSelfPermission(context, RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
38-
}
29+
/**
30+
* Gets whether your application was granted the [recording permission][RECORD_AUDIO].
31+
*/
32+
@JvmStatic
33+
fun hasRecordPermission(context: Context) =
34+
ContextCompat.checkSelfPermission(context, RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
3935

40-
/**
41-
* Gets whether your [activity] should show UI with rationale for requesting the [recording permission][RECORD_AUDIO].
42-
*/
43-
@JvmStatic
44-
fun shouldExplainPermission(activity: Activity): Boolean {
45-
return ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.RECORD_AUDIO)
46-
}
47-
}
36+
/**
37+
* Gets whether your [activity] should show UI with rationale for requesting the [recording permission][RECORD_AUDIO].
38+
*/
39+
@JvmStatic
40+
fun shouldExplainPermission(activity: Activity) =
41+
ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.RECORD_AUDIO)
4842
}
4943

5044
//TODO: Expose Activity extension methods instead?
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
package com.algolia.instantsearch.voice
2+
3+
import android.content.Context
4+
import android.content.Intent
5+
import android.os.Bundle
6+
import android.speech.RecognitionListener
7+
import android.speech.RecognizerIntent
8+
import android.speech.SpeechRecognizer
9+
import android.util.Log
10+
import java.util.*
11+
12+
const val ERROR_NO_LISTENER = "The VoiceDialogFragment needs a VoiceResultsListener."
13+
14+
class VoiceInput @JvmOverloads constructor(
15+
private val presenter: VoiceInputPresenter,
16+
var listener: VoiceResultsListener? = null
17+
) : RecognitionListener {
18+
19+
/** a VoiceInput can either be **`Listening`** for input, **`Paused`** until further notice,
20+
* displaying **`PartialResults`** or an **`Error`**. */
21+
enum class State {
22+
Listening,
23+
Paused,
24+
PartialResults,
25+
Error
26+
}
27+
28+
/** Optional IETF language tag (as defined by BCP 47), for example "en-US", forwarded to the [SpeechRecognizer]. */
29+
var language: String? = null
30+
/** Maximum number of voice recognition matches to return. Defaults to 1. */
31+
var maxResults: Int = 1
32+
/** Current [state][State] of the VoiceInput.*/
33+
var state = State.Listening
34+
private set
35+
private lateinit var speechRecognizer: SpeechRecognizer
36+
37+
//region Voice Recognition
38+
fun startVoiceRecognition() {
39+
state = State.Listening
40+
speechRecognizer = SpeechRecognizer.createSpeechRecognizer(presenter.getContext())!!
41+
speechRecognizer.setRecognitionListener(this)
42+
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH)
43+
.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
44+
.putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
45+
.putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, maxResults)
46+
language.let {
47+
intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE, language)
48+
}
49+
speechRecognizer.startListening(intent)
50+
51+
updateUI()
52+
}
53+
54+
fun stopVoiceRecognition() {
55+
state = State.Paused
56+
speechRecognizer.stopListening()
57+
speechRecognizer.destroy()
58+
updateUI()
59+
}
60+
61+
fun toggleVoiceRecognition() {
62+
when (state) {
63+
State.Listening, State.PartialResults -> stopVoiceRecognition()
64+
State.Error, State.Paused -> startVoiceRecognition()
65+
}
66+
}
67+
68+
// region RecognitionListener
69+
override fun onError(error: Int) {
70+
val errorText = getErrorMessage(error)
71+
Log.d(TAG, "onError: $errorText")
72+
stopVoiceRecognition()
73+
state = State.Error
74+
updateUI(errorText)
75+
}
76+
77+
override fun onResults(results: Bundle) {
78+
val matches = results.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
79+
val matchesString = buildMatchesString(matches)
80+
Log.d(TAG, "onResults:${matches!!.size}: $matchesString")
81+
82+
stopVoiceRecognition()
83+
presenter.dismiss()
84+
if (listener == null) throw IllegalStateException(ERROR_NO_LISTENER)
85+
listener?.onVoiceResults(matches)
86+
}
87+
88+
override fun onPartialResults(partialResults: Bundle) {
89+
state = State.PartialResults
90+
val matches = partialResults.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
91+
val matchesString = buildMatchesString(matches)
92+
updateUI(matchesString)
93+
Log.d(TAG, "onPartialResults:${matches!!.size}: $matchesString")
94+
}
95+
96+
// region Unused RecognitionListener methods
97+
override fun onReadyForSpeech(params: Bundle) = Unit
98+
99+
override fun onBeginningOfSpeech() = Unit
100+
101+
override fun onRmsChanged(rmsdB: Float) = Unit
102+
103+
override fun onBufferReceived(buffer: ByteArray) = Unit
104+
105+
override fun onEndOfSpeech() = Unit
106+
107+
override fun onEvent(eventType: Int, params: Bundle) = Unit
108+
109+
// endregion
110+
// endregion
111+
// endregion
112+
// region Helpers
113+
private fun buildMatchesString(matches: ArrayList<String>?): String? =
114+
matches?.fold("") { acc, it -> "$acc$it\n" }
115+
116+
private fun getErrorMessage(error: Int): String = when (error) {
117+
SpeechRecognizer.ERROR_AUDIO -> "Audio recording error."
118+
SpeechRecognizer.ERROR_CLIENT -> "Other client side errors."
119+
SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "Insufficient permissions"
120+
SpeechRecognizer.ERROR_NETWORK -> "Other network related errors."
121+
SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "Network operation timed out."
122+
SpeechRecognizer.ERROR_NO_MATCH -> "No recognition result matched."
123+
SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "RecognitionService busy."
124+
SpeechRecognizer.ERROR_SERVER -> "Server sends error status."
125+
SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "No speech input."
126+
else -> "Unknown error."
127+
}
128+
129+
private fun updateUI(message: String? = null) {
130+
when (state) {
131+
State.Listening -> presenter.displayListening(true)
132+
State.Paused -> presenter.displayListening(false)
133+
State.PartialResults -> presenter.displayResult(message, isError = false)
134+
State.Error -> presenter.displayResult(message, isError = true)
135+
}
136+
}
137+
138+
companion object {
139+
140+
const val TAG = "VoiceInput"
141+
}
142+
143+
// endregion
144+
// region Interfaces
145+
interface VoiceInputPresenter {
146+
147+
fun displayListening(isListening: Boolean)
148+
fun displayResult(text: CharSequence?, isError: Boolean)
149+
fun dismiss()
150+
fun getContext(): Context?
151+
}
152+
153+
interface VoiceResultsListener {
154+
155+
fun onVoiceResults(matches: List<String>)
156+
}
157+
//endregion
158+
}

instantsearch.voice/src/main/java/com/algolia/instantsearch/voice/ui/PermissionDialogFragment.kt

Lines changed: 43 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,31 +8,54 @@ import android.support.v4.app.ActivityCompat
88
import android.support.v4.app.DialogFragment
99

1010
class PermissionDialogFragment : DialogFragment() {
11-
var title = DEFAULT_TITLE
12-
var message = DEFAULT_MESSAGE
13-
var positiveButton = DEFAULT_POSITIVE_BUTTON
14-
var negativeButton = DEFAULT_NEGATIVE_BUTTON
1511

16-
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
17-
requireActivity().let { activity ->
18-
return AlertDialog.Builder(activity)
19-
.setTitle(title)
20-
.setMessage(message)
21-
.setPositiveButton(positiveButton) { _, _ ->
22-
ActivityCompat.requestPermissions(activity, arrayOf(RECORD_AUDIO), ID_REQ_VOICE_PERM)
23-
dismiss()
24-
}
25-
.setNegativeButton(negativeButton) { dialog, _ -> dialog.cancel() }
26-
.create()
12+
private enum class Argument {
13+
Title,
14+
Message,
15+
PositiveButton,
16+
NegativeButton
17+
}
18+
19+
override fun onCreate(savedInstanceState: Bundle?) {
20+
super.onCreate(savedInstanceState)
21+
if (arguments == null) {
22+
arguments = buildArguments()
2723
}
24+
}
2825

26+
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
27+
val bundle = arguments!!
28+
val title = bundle.getString(Argument.Title.name)
29+
val message = bundle.getString(Argument.Message.name)
30+
val positiveButton = bundle.getString(Argument.PositiveButton.name)
31+
val negativeButton = bundle.getString(Argument.NegativeButton.name)
32+
33+
return AlertDialog.Builder(requireContext())
34+
.setTitle(title)
35+
.setMessage(message)
36+
.setPositiveButton(positiveButton) { _, _ ->
37+
ActivityCompat.requestPermissions(requireActivity(), arrayOf(RECORD_AUDIO), PermissionRequestRecordAudio)
38+
dismiss()
39+
}
40+
.setNegativeButton(negativeButton) { dialog, _ -> dialog.cancel() }
41+
.create()
2942
}
3043

3144
companion object {
32-
const val ID_REQ_VOICE_PERM = 1
33-
const val DEFAULT_TITLE = "You can use voice search to find results"
34-
const val DEFAULT_MESSAGE = "Can we access your device's microphone to enable voice search?"
35-
const val DEFAULT_POSITIVE_BUTTON = "Allow microphone access"
36-
const val DEFAULT_NEGATIVE_BUTTON = "No"
45+
46+
const val PermissionRequestRecordAudio = 1
47+
48+
@JvmOverloads
49+
fun buildArguments(
50+
title: String = "You can use voice search to find results",
51+
message: String = "Can we access your device's microphone to enable voice search?",
52+
positiveButton: String = "Allow microphone access",
53+
negativeButton: String = "No"
54+
) = Bundle().also {
55+
it.putString(Argument.Title.name, title)
56+
it.putString(Argument.Message.name, message)
57+
it.putString(Argument.PositiveButton.name, positiveButton)
58+
it.putString(Argument.NegativeButton.name, negativeButton)
59+
}
3760
}
3861
}

0 commit comments

Comments
 (0)