This commit is contained in:
4 files changed
+173
-27
No files matched your search
@@ -13,6 +13,12 @@
|
|||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.speech.action.RECOGNIZE_SPEECH" />
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".HermesControlApp"
|
android:name=".HermesControlApp"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ package com.m57.hermescontrol.ui.chat
|
|||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.provider.OpenableColumns
|
import android.provider.OpenableColumns
|
||||||
import android.speech.RecognizerIntent
|
import android.speech.RecognizerIntent
|
||||||
import android.speech.SpeechRecognizer
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
@@ -343,21 +343,18 @@ fun ChatScreen(
|
|||||||
) { granted ->
|
) { granted ->
|
||||||
ExternalActivityLifecycleGuard.externalActivityReturned()
|
ExternalActivityLifecycleGuard.externalActivityReturned()
|
||||||
if (granted) {
|
if (granted) {
|
||||||
if (SpeechRecognizer.isRecognitionAvailable(context)) {
|
if (SpeechInputHelper.isSpeechInputAvailable(context)) {
|
||||||
val intent =
|
val intent = SpeechInputHelper.createSpeechIntent(micListeningPrompt)
|
||||||
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
|
||||||
putExtra(
|
|
||||||
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
|
|
||||||
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM,
|
|
||||||
)
|
|
||||||
putExtra(
|
|
||||||
RecognizerIntent.EXTRA_PROMPT,
|
|
||||||
micListeningPrompt,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
isListening = true
|
isListening = true
|
||||||
launchExternalActivity {
|
launchExternalActivity {
|
||||||
speechLauncher.launch(intent)
|
try {
|
||||||
|
speechLauncher.launch(intent)
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
isListening = false
|
||||||
|
scrollScope.launch {
|
||||||
|
snackbarHostState.showSnackbar(sttNotAvailableMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
scrollScope.launch {
|
scrollScope.launch {
|
||||||
@@ -781,21 +778,18 @@ fun ChatScreen(
|
|||||||
Manifest.permission.RECORD_AUDIO,
|
Manifest.permission.RECORD_AUDIO,
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
) {
|
) {
|
||||||
if (SpeechRecognizer.isRecognitionAvailable(context)) {
|
if (SpeechInputHelper.isSpeechInputAvailable(context)) {
|
||||||
val intent =
|
val intent = SpeechInputHelper.createSpeechIntent(micListeningPrompt)
|
||||||
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
|
||||||
putExtra(
|
|
||||||
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
|
|
||||||
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM,
|
|
||||||
)
|
|
||||||
putExtra(
|
|
||||||
RecognizerIntent.EXTRA_PROMPT,
|
|
||||||
micListeningPrompt,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
isListening = true
|
isListening = true
|
||||||
launchExternalActivity {
|
launchExternalActivity {
|
||||||
speechLauncher.launch(intent)
|
try {
|
||||||
|
speechLauncher.launch(intent)
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
isListening = false
|
||||||
|
scrollScope.launch {
|
||||||
|
snackbarHostState.showSnackbar(sttNotAvailableMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
scrollScope.launch {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user