feat(chat): support non-Google speech-to-text providers (fixes #1038) (#1042)

This commit is contained in:
M57 authored and GitHub committed 2026-09-02 12:31:42 +03:00
1 parent 4668d482ef
commit dbd7008ee3
4 files changed
+173 -27

No files matched your search

+6
View File
@@ -13,6 +13,12 @@
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<queries>
<intent>
<action android:name="android.speech.action.RECOGNIZE_SPEECH" />
</intent>
</queries>
<application
android:name=".HermesControlApp"
android:allowBackup="false"
@@ -2,12 +2,12 @@ package com.m57.hermescontrol.ui.chat
import android.Manifest
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.OpenableColumns
import android.speech.RecognizerIntent
import android.speech.SpeechRecognizer
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -343,21 +343,18 @@ fun ChatScreen(
) { granted ->
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 {
@@ -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,
)
}
}
@@ -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<Intent>().setAction(any()) } answers { self as Intent }
every { anyConstructed<Intent>().putExtra(any<String>(), any<String>()) } answers { self as Intent }
}
@After
fun tearDown() {
unmockkConstructor(Intent::class)
}
@Test
fun isSpeechInputAvailable_returnsTrue_whenPackageManagerFindsActivities() {
val context = mockk<Context>()
val packageManager = mockk<PackageManager>()
val resolveInfo = mockk<ResolveInfo>()
every { context.packageManager } returns packageManager
every {
packageManager.queryIntentActivities(any<Intent>(), any<Int>())
} returns listOf(resolveInfo)
val result =
SpeechInputHelper.isSpeechInputAvailable(
context = context,
isRecognizerServiceAvailable = { false },
)
assertTrue(result)
}
@Test
fun isSpeechInputAvailable_returnsTrue_whenRecognitionServiceAvailableEvenIfNoActivities() {
val context = mockk<Context>()
val packageManager = mockk<PackageManager>()
every { context.packageManager } returns packageManager
every {
packageManager.queryIntentActivities(any<Intent>(), any<Int>())
} returns emptyList()
val result =
SpeechInputHelper.isSpeechInputAvailable(
context = context,
isRecognizerServiceAvailable = { true },
)
assertTrue(result)
}
@Test
fun isSpeechInputAvailable_returnsFalse_whenNoActivitiesAndNoRecognitionService() {
val context = mockk<Context>()
val packageManager = mockk<PackageManager>()
every { context.packageManager } returns packageManager
every {
packageManager.queryIntentActivities(any<Intent>(), any<Int>())
} 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)
}
}