commit 4327f281b85f9643e53187551cd8b1ebe00e95bd truex <truex@equwal.com> 2026-09-21 04:33:20 -0700 Fix voice typing refused with every grant in place, and the assistant setup Voice typing: the speech service of a normal app answered error 9 on the Viwoods reader. The firmware does not count that service as in use, so the while-in-use microphone grant of the speech app did not apply. DictationActivity now falls back to the ACTION_RECOGNIZE_SPEECH screen of the same app, takes the words from its result, and remembers the route for that app. noHistory is gone from the activity, because an activity with noHistory gets no result. DictationRouteTest holds the error that caused the defect. Assistant setup: the request intent of the assistant role closes at once, because Android has no dialog for that role. The app opens ACTION_VOICE_INPUT_SETTINGS.
CHANGELOG.md | 13 ++++++++ app/src/main/AndroidManifest.xml | 1 - .../java/dev/equwal/assistkey/channel/Channels.kt | 6 ++-- .../java/dev/equwal/assistkey/voice/Dictation.kt | 25 +++++++++++++++ .../equwal/assistkey/voice/DictationActivity.kt | 36 +++++++++++++++++++++- .../equwal/assistkey/voice/DictationRouteTest.kt | 26 ++++++++++++++++ 6 files changed, 103 insertions(+), 4 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md index ccc6cf5..80a1a9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## Unreleased + +- **Fix: voice typing said "The microphone is not allowed" although it was + allowed.** Android gives the microphone to an app only while that app is in + use. The Viwoods firmware does not count a speech app as in use while it + listens in the background for another app, so Whisper was refused (error + 9). Rebind now goes to the speech screen of the speech app, where that app + is in use, takes the words from its result, and remembers the route. + Regression test: `DictationRouteTest`. +- **Fix: "Let Rebind get the Power hold" opened nothing.** Android has no + request dialog for the assistant role. The row now opens the settings page + "Default digital assistant app". + ## 0.0.13-alpha - 2026-09-21 Version code 13. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3727bb5..3505deb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -101,7 +101,6 @@ android:name=".voice.DictationActivity" android:exported="false" android:excludeFromRecents="true" - android:noHistory="true" android:launchMode="singleInstance" android:taskAffinity="" android:theme="@style/Theme.Invisible" /> diff --git a/app/src/main/java/dev/equwal/assistkey/channel/Channels.kt b/app/src/main/java/dev/equwal/assistkey/channel/Channels.kt index d4e8853..44ec602 100644 --- a/app/src/main/java/dev/equwal/assistkey/channel/Channels.kt +++ b/app/src/main/java/dev/equwal/assistkey/channel/Channels.kt @@ -145,8 +145,10 @@ object Channels { fun claimIntent(c: Context, ch: Channel): Intent = when (ch) { Channel.ACCESSIBILITY -> Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) - Channel.ASSISTANT -> roleRequest(c, RoleManager.ROLE_ASSISTANT) - ?: Intent(Settings.ACTION_VOICE_INPUT_SETTINGS) + // Android has no request dialog for the assistant role: the request + // intent closes at once and the user sees nothing. The settings page + // "Digital assistant app" is the only way in. + Channel.ASSISTANT -> Intent(Settings.ACTION_VOICE_INPUT_SETTINGS) Channel.WALLET -> roleRequest(c, ROLE_WALLET) ?: defaultApps() diff --git a/app/src/main/java/dev/equwal/assistkey/voice/Dictation.kt b/app/src/main/java/dev/equwal/assistkey/voice/Dictation.kt index b70829b..1af727c 100644 --- a/app/src/main/java/dev/equwal/assistkey/voice/Dictation.kt +++ b/app/src/main/java/dev/equwal/assistkey/voice/Dictation.kt @@ -85,6 +85,31 @@ object Dictation { c.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString(K_LANGUAGE, tag.trim()).apply() } + /** + * True when the speech app must listen on a screen of its own. Android + * gives the microphone to an app only while that app is in use. Some + * firmware does not count a speech app as in use while it listens in the + * background for another app. It then refuses with this error, although + * every grant is in place. On its own screen the speech app is in use. + */ + fun fallBackToScreen(error: Int, screenAvailable: Boolean): Boolean = + error == ERROR_INSUFFICIENT_PERMISSIONS && screenAvailable + + /** SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS, as a plain number for tests. */ + const val ERROR_INSUFFICIENT_PERMISSIONS = 9 + + private const val K_SCREEN_ROUTE = "screen_route" + + /** The speech app that was refused in the background before. It goes to its screen at once. */ + fun usesScreen(c: Context, engine: ComponentName?): Boolean = + engine != null && c.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(K_SCREEN_ROUTE, null) == engine.flattenToString() + + fun rememberScreen(c: Context, engine: ComponentName?) { + c.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putString(K_SCREEN_ROUTE, engine?.flattenToString()).apply() + } + fun hasMicrophone(c: Context): Boolean = c.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED diff --git a/app/src/main/java/dev/equwal/assistkey/voice/DictationActivity.kt b/app/src/main/java/dev/equwal/assistkey/voice/DictationActivity.kt index fbf931b..61a1f64 100644 --- a/app/src/main/java/dev/equwal/assistkey/voice/DictationActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/voice/DictationActivity.kt @@ -28,6 +28,7 @@ import dev.equwal.assistkey.route.ServiceHolder class DictationActivity : Activity() { private companion object { + private const val SCREEN = 1 /** Time for the window behind to be the front window again. */ const val INSERT_DELAY_MS = 300L } @@ -66,7 +67,32 @@ class DictationActivity : Activity() { override fun onResume() { super.onResume() - if (recognizer == null) listen() + if (recognizer != null || onScreen) return + if (Dictation.usesScreen(this, Dictation.engine(this)) && listenOnScreen()) return + listen() + } + + /** True while the speech app listens on a screen of its own. */ + private var onScreen = false + + /** + * The other way to listen: the speech screen of the speech app, which + * gives the words back as a result. See [Dictation.fallBackToScreen]. + */ + private fun listenOnScreen(): Boolean { + val engine = Dictation.engine(this) ?: return false + val ask = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).setPackage(engine.packageName) + .putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) + Dictation.language(this).takeIf { it.isNotEmpty() }?.let { ask.putExtra(RecognizerIntent.EXTRA_LANGUAGE, it) } + if (ask.resolveActivity(packageManager) == null) return false + return runCatching { startActivityForResult(ask, SCREEN) }.isSuccess.also { onScreen = it } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode != SCREEN) return + onScreen = false + done(data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)?.firstOrNull()) } override fun onDestroy() { @@ -103,6 +129,14 @@ class DictationActivity : Activity() { override fun onError(error: Int) { Log.i("AssistKey", "speech recognition error " + error) + val engine = Dictation.engine(this@DictationActivity) + if (Dictation.fallBackToScreen(error, screenAvailable = engine != null)) { + close() + if (listenOnScreen()) { + Dictation.rememberScreen(this@DictationActivity, engine) + return + } + } Dictation.toast( this@DictationActivity, when (error) { diff --git a/app/src/test/java/dev/equwal/assistkey/voice/DictationRouteTest.kt b/app/src/test/java/dev/equwal/assistkey/voice/DictationRouteTest.kt new file mode 100644 index 0000000..343ff66 --- /dev/null +++ b/app/src/test/java/dev/equwal/assistkey/voice/DictationRouteTest.kt @@ -0,0 +1,26 @@ +package dev.equwal.assistkey.voice + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class DictationRouteTest { + + /** + * Regression, 0.0.13 on the Viwoods reader: Rebind and Whisper both held + * the microphone grant, and the speech service answered error 9 + * (ERROR_INSUFFICIENT_PERMISSIONS), because the firmware did not count the + * background speech service as in use. The app said "not allowed" and + * stopped. It must go to the speech screen of the speech app. + */ + @Test fun `error 9 with a speech screen goes to that screen`() { + assertTrue(Dictation.fallBackToScreen(error = 9, screenAvailable = true)) + } + + @Test fun `no speech screen, or another error, does not`() { + assertFalse(Dictation.fallBackToScreen(error = 9, screenAvailable = false)) + listOf(1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13).forEach { + assertFalse(Dictation.fallBackToScreen(error = it, screenAvailable = true)) + } + } +}