diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 1fe12882..8da078e3 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -13,6 +13,12 @@
+
+
+
+
+
+
ExternalActivityLifecycleGuard.externalActivityReturned()
if (granted) {
- if (SpeechRecognizer.isRecognitionAvailable(context)) {
- val intent =
- Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
- putExtra(
- RecognizerIntent.EXTRA_LANGUAGE_MODEL,
- RecognizerIntent.LANGUAGE_MODEL_FREE_FORM,
- )
- putExtra(
- RecognizerIntent.EXTRA_PROMPT,
- micListeningPrompt,
- )
- }
+ if (SpeechInputHelper.isSpeechInputAvailable(context)) {
+ val intent = SpeechInputHelper.createSpeechIntent(micListeningPrompt)
isListening = true
launchExternalActivity {
- speechLauncher.launch(intent)
+ try {
+ speechLauncher.launch(intent)
+ } catch (_: ActivityNotFoundException) {
+ isListening = false
+ scrollScope.launch {
+ snackbarHostState.showSnackbar(sttNotAvailableMsg)
+ }
+ }
}
} else {
scrollScope.launch {
@@ -781,21 +778,18 @@ fun ChatScreen(
Manifest.permission.RECORD_AUDIO,
) == PackageManager.PERMISSION_GRANTED
) {
- if (SpeechRecognizer.isRecognitionAvailable(context)) {
- val intent =
- Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
- putExtra(
- RecognizerIntent.EXTRA_LANGUAGE_MODEL,
- RecognizerIntent.LANGUAGE_MODEL_FREE_FORM,
- )
- putExtra(
- RecognizerIntent.EXTRA_PROMPT,
- micListeningPrompt,
- )
- }
+ if (SpeechInputHelper.isSpeechInputAvailable(context)) {
+ val intent = SpeechInputHelper.createSpeechIntent(micListeningPrompt)
isListening = true
launchExternalActivity {
- speechLauncher.launch(intent)
+ try {
+ speechLauncher.launch(intent)
+ } catch (_: ActivityNotFoundException) {
+ isListening = false
+ scrollScope.launch {
+ snackbarHostState.showSnackbar(sttNotAvailableMsg)
+ }
+ }
}
} else {
scrollScope.launch {
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/SpeechInputHelper.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/SpeechInputHelper.kt
new file mode 100644
index 00000000..ea88fb1b
--- /dev/null
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/SpeechInputHelper.kt
@@ -0,0 +1,54 @@
+package com.m57.hermescontrol.ui.chat
+
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Build
+import android.speech.RecognizerIntent
+import android.speech.SpeechRecognizer
+
+object SpeechInputHelper {
+ /**
+ * Checks if speech recognition is available on this device through any registered provider,
+ * including standard Android [SpeechRecognizer] services or offline/third-party apps
+ * that handle [RecognizerIntent.ACTION_RECOGNIZE_SPEECH] (e.g. FUTO Voice Input).
+ */
+ fun isSpeechInputAvailable(
+ context: Context,
+ isRecognizerServiceAvailable: (Context) -> Boolean = { ctx ->
+ runCatching { SpeechRecognizer.isRecognitionAvailable(ctx) }.getOrDefault(false)
+ },
+ ): Boolean {
+ val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH)
+ val activities =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ runCatching {
+ context.packageManager.queryIntentActivities(
+ intent,
+ PackageManager.ResolveInfoFlags.of(0L),
+ )
+ }.getOrDefault(emptyList())
+ } else {
+ @Suppress("DEPRECATION")
+ runCatching {
+ context.packageManager.queryIntentActivities(intent, 0)
+ }.getOrDefault(emptyList())
+ }
+ return activities.isNotEmpty() || isRecognizerServiceAvailable(context)
+ }
+
+ /**
+ * Builds the standard speech recognition intent with language model and prompt.
+ */
+ fun createSpeechIntent(prompt: String): Intent =
+ Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
+ putExtra(
+ RecognizerIntent.EXTRA_LANGUAGE_MODEL,
+ RecognizerIntent.LANGUAGE_MODEL_FREE_FORM,
+ )
+ putExtra(
+ RecognizerIntent.EXTRA_PROMPT,
+ prompt,
+ )
+ }
+}
diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/SpeechInputHelperTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/SpeechInputHelperTest.kt
new file mode 100644
index 00000000..c6e5dee8
--- /dev/null
+++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/SpeechInputHelperTest.kt
@@ -0,0 +1,92 @@
+package com.m57.hermescontrol.ui.chat
+
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.content.pm.ResolveInfo
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkConstructor
+import io.mockk.unmockkConstructor
+import org.junit.After
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+class SpeechInputHelperTest {
+ @Before
+ fun setUp() {
+ mockkConstructor(Intent::class)
+ every { anyConstructed().setAction(any()) } answers { self as Intent }
+ every { anyConstructed().putExtra(any(), any()) } answers { self as Intent }
+ }
+
+ @After
+ fun tearDown() {
+ unmockkConstructor(Intent::class)
+ }
+
+ @Test
+ fun isSpeechInputAvailable_returnsTrue_whenPackageManagerFindsActivities() {
+ val context = mockk()
+ val packageManager = mockk()
+ val resolveInfo = mockk()
+
+ every { context.packageManager } returns packageManager
+ every {
+ packageManager.queryIntentActivities(any(), any())
+ } returns listOf(resolveInfo)
+
+ val result =
+ SpeechInputHelper.isSpeechInputAvailable(
+ context = context,
+ isRecognizerServiceAvailable = { false },
+ )
+ assertTrue(result)
+ }
+
+ @Test
+ fun isSpeechInputAvailable_returnsTrue_whenRecognitionServiceAvailableEvenIfNoActivities() {
+ val context = mockk()
+ val packageManager = mockk()
+
+ every { context.packageManager } returns packageManager
+ every {
+ packageManager.queryIntentActivities(any(), any())
+ } returns emptyList()
+
+ val result =
+ SpeechInputHelper.isSpeechInputAvailable(
+ context = context,
+ isRecognizerServiceAvailable = { true },
+ )
+ assertTrue(result)
+ }
+
+ @Test
+ fun isSpeechInputAvailable_returnsFalse_whenNoActivitiesAndNoRecognitionService() {
+ val context = mockk()
+ val packageManager = mockk()
+
+ every { context.packageManager } returns packageManager
+ every {
+ packageManager.queryIntentActivities(any(), any())
+ } returns emptyList()
+
+ val result =
+ SpeechInputHelper.isSpeechInputAvailable(
+ context = context,
+ isRecognizerServiceAvailable = { false },
+ )
+ assertFalse(result)
+ }
+
+ @Test
+ fun createSpeechIntent_constructsIntentWithActionAndPrompt() {
+ val prompt = "Listening..."
+ val intent = SpeechInputHelper.createSpeechIntent(prompt)
+ assertNotNull(intent)
+ }
+}