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) + } +}