app/src/main/java/dev/equwal/assistkey/voice/DictationActivity.kt (7788 bytes)
1 package dev.equwal.assistkey.voice 2 3 import android.app.Activity 4 import android.content.Intent 5 import android.graphics.Color 6 import android.os.Bundle 7 import android.os.Handler 8 import android.os.Looper 9 import android.speech.RecognitionListener 10 import android.speech.RecognizerIntent 11 import android.speech.SpeechRecognizer 12 import android.util.Log 13 import android.util.TypedValue 14 import android.view.Gravity 15 import android.view.WindowManager 16 import android.widget.FrameLayout 17 import android.widget.TextView 18 import dev.equwal.assistkey.route.ServiceHolder 19 20 /** 21 * Listens, and hands the words to [TextInsert]. 22 * 23 * The activity exists so that this app is in front while the microphone is in 24 * use; Android allows nothing else. It is see-through, and its window cannot 25 * take focus or touches, so the text field behind it keeps the cursor and the 26 * keyboard. The only thing it draws is a one-line label. 27 */ 28 class DictationActivity : Activity() { 29 30 private companion object { 31 private const val SCREEN = 1 32 /** Time for the window behind to be the front window again. */ 33 const val INSERT_DELAY_MS = 300L 34 } 35 36 private var recognizer: SpeechRecognizer? = null 37 private lateinit var label: TextView 38 39 override fun onCreate(savedInstanceState: Bundle?) { 40 super.onCreate(savedInstanceState) 41 window.addFlags( 42 WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or 43 WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or 44 WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL 45 ) 46 label = TextView(this).apply { 47 text = "Listening..." 48 setTextColor(Color.WHITE) 49 setBackgroundColor(Color.BLACK) 50 setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f) 51 val p = (16 * resources.displayMetrics.density).toInt() 52 setPadding(p, p * 3, p, p) 53 } 54 setContentView( 55 FrameLayout(this).apply { 56 addView( 57 label, 58 FrameLayout.LayoutParams( 59 FrameLayout.LayoutParams.MATCH_PARENT, 60 FrameLayout.LayoutParams.WRAP_CONTENT, 61 Gravity.TOP 62 ) 63 ) 64 } 65 ) 66 } 67 68 override fun onResume() { 69 super.onResume() 70 if (recognizer != null || onScreen) return 71 if (Dictation.usesScreen(this, Dictation.engine(this)) && listenOnScreen()) return 72 listen() 73 } 74 75 /** True while the speech app listens on a screen of its own. */ 76 private var onScreen = false 77 78 /** 79 * The other way to listen: the speech screen of the speech app, which 80 * gives the words back as a result. See [Dictation.fallBackToScreen]. 81 */ 82 private fun listenOnScreen(): Boolean { 83 val engine = Dictation.engine(this) ?: return false 84 val ask = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).setPackage(engine.packageName) 85 .putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) 86 Dictation.language(this).takeIf { it.isNotEmpty() }?.let { ask.putExtra(RecognizerIntent.EXTRA_LANGUAGE, it) } 87 if (ask.resolveActivity(packageManager) == null) return false 88 return runCatching { startActivityForResult(ask, SCREEN) }.isSuccess.also { onScreen = it } 89 } 90 91 override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { 92 super.onActivityResult(requestCode, resultCode, data) 93 if (requestCode != SCREEN) return 94 onScreen = false 95 done(data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)?.firstOrNull()) 96 } 97 98 override fun onDestroy() { 99 close() 100 super.onDestroy() 101 } 102 103 private fun listen() { 104 val r = runCatching { 105 Dictation.engine(this)?.let { SpeechRecognizer.createSpeechRecognizer(this, it) } 106 ?: SpeechRecognizer.createSpeechRecognizer(this) 107 }.getOrNull() 108 if (r == null) { 109 Dictation.toast(this, "The speech app did not start") 110 return done(null) 111 } 112 recognizer = r 113 Dictation.stop = { r.stopListening() } 114 r.setRecognitionListener(listener) 115 r.startListening( 116 Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { 117 putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) 118 putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true) 119 putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, packageName) 120 Dictation.language(this@DictationActivity).takeIf { it.isNotEmpty() } 121 ?.let { putExtra(RecognizerIntent.EXTRA_LANGUAGE, it) } 122 } 123 ) 124 } 125 126 private val listener = object : RecognitionListener { 127 override fun onResults(results: Bundle?) = 128 done(results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)?.firstOrNull()) 129 130 override fun onError(error: Int) { 131 Log.i("AssistKey", "speech recognition error " + error) 132 val engine = Dictation.engine(this@DictationActivity) 133 if (Dictation.fallBackToScreen(error, screenAvailable = engine != null)) { 134 close() 135 if (listenOnScreen()) { 136 Dictation.rememberScreen(this@DictationActivity, engine) 137 return 138 } 139 } 140 Dictation.toast( 141 this@DictationActivity, 142 when (error) { 143 SpeechRecognizer.ERROR_NO_MATCH, SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "Heard nothing" 144 SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "The microphone is not allowed" 145 SpeechRecognizer.ERROR_NETWORK, SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> 146 "The speech app has no connection" 147 else -> "Speech failed (" + error + ")" 148 } 149 ) 150 done(null) 151 } 152 153 override fun onPartialResults(partial: Bundle?) { 154 partial?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)?.firstOrNull() 155 ?.takeIf { it.isNotBlank() }?.let { label.text = it } 156 } 157 158 override fun onEndOfSpeech() { label.text = "Working..." } 159 override fun onReadyForSpeech(params: Bundle?) = Unit 160 override fun onBeginningOfSpeech() = Unit 161 override fun onRmsChanged(rmsdB: Float) = Unit 162 override fun onBufferReceived(buffer: ByteArray?) = Unit 163 override fun onEvent(eventType: Int, params: Bundle?) = Unit 164 } 165 166 private fun close() { 167 Dictation.stop = null 168 runCatching { recognizer?.destroy() } 169 recognizer = null 170 } 171 172 /** Leaves first, then writes: the field must be the front window again. */ 173 private fun done(text: String?) { 174 Log.i("AssistKey", "voice typing: " + if (text.isNullOrBlank()) "no text" else text.length.toString() + " characters") 175 close() 176 finish() 177 @Suppress("DEPRECATION") 178 overridePendingTransition(0, 0) 179 if (text.isNullOrBlank()) return 180 val app = applicationContext 181 Handler(Looper.getMainLooper()).postDelayed({ 182 val svc = ServiceHolder.service ?: return@postDelayed 183 when (TextInsert.insert(svc, text)) { 184 TextInsert.Result.DONE -> Unit 185 TextInsert.Result.NO_FIELD -> Dictation.toast(app, "No text field has the cursor") 186 TextInsert.Result.PASSWORD -> Dictation.toast(app, "Not into a password field") 187 TextInsert.Result.REFUSED -> Dictation.toast(app, "That field did not accept the text") 188 } 189 }, INSERT_DELAY_MS) 190 } 191 }