From 42a67bffa989b15fa7f0597d08eb91f3b56d7c9b Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sun, 28 Jun 2026 00:47:24 +0300 Subject: [PATCH] feat(#194): add Speech-to-Text voice dictation support in chat (#413) * feat(#194): add Speech-to-Text voice dictation support in chat * fix(#194): resolve android lint warnings and update compose UI test * fix(#194): remove invalid assertDoesNotExist import --- .../hermescontrol/ui/chat/ChatScreenTest.kt | 9 +- app/src/main/AndroidManifest.xml | 1 + .../m57/hermescontrol/ui/chat/ChatScreen.kt | 194 ++++++++++++++++-- app/src/main/res/values/strings.xml | 4 + 4 files changed, 190 insertions(+), 18 deletions(-) diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatScreenTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatScreenTest.kt index c9d4655b..81b02d89 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatScreenTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/ChatScreenTest.kt @@ -64,10 +64,13 @@ class ChatScreenTest { ) } - // Verify the send button is displayed - composeTestRule.onNodeWithTag("send_button").assertIsDisplayed() + // Verify the mic button is displayed initially and send button is not + composeTestRule.onNodeWithTag("mic_button").assertIsDisplayed() + composeTestRule.onNodeWithTag("send_button").assertDoesNotExist() - // Verify the input field accepts text + // Verify entering text switches mic button to send button composeTestRule.onNodeWithTag("chat_input").performTextInput("Hello Hermes") + composeTestRule.onNodeWithTag("send_button").assertIsDisplayed() + composeTestRule.onNodeWithTag("mic_button").assertDoesNotExist() } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8b996e97..bfade3cc 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ + (null) } val snackbarHostState = remember { SnackbarHostState() } val isDark = isSystemInDarkTheme() val context = LocalContext.current + val micListeningPrompt = stringResource(R.string.chat_mic_listening) + val sttNotAvailableMsg = stringResource(R.string.stt_not_available) + val sttPermissionDeniedMsg = stringResource(R.string.stt_permission_denied) + + // Speech-to-text recognition launcher (issue #194) + val speechLauncher = + rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { result -> + isListening = false + if (result.resultCode == Activity.RESULT_OK) { + val spokenText = + result.data + ?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS) + ?.firstOrNull() + .orEmpty() + if (spokenText.isNotBlank()) { + inputText = + if (inputText.isBlank()) { + spokenText + } else { + "$inputText $spokenText" + } + } + } + } + + // Mic permission launcher + val micPermissionLauncher = + rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + 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, + ) + } + isListening = true + speechLauncher.launch(intent) + } else { + scrollScope.launch { + snackbarHostState.showSnackbar(sttNotAvailableMsg) + } + } + } else { + scrollScope.launch { + snackbarHostState.showSnackbar(sttPermissionDeniedMsg) + } + } + } + // Lifecycle effects, permissions, session switching, auto-scroll, errors ChatLifecycleEffects( sessionId = sessionId, @@ -294,6 +365,39 @@ fun ChatScreen( } } }, + onMicTap = { + if (isListening) { + isListening = false + } else if ( + ContextCompat.checkSelfPermission( + context, + 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, + ) + } + isListening = true + speechLauncher.launch(intent) + } else { + scrollScope.launch { + snackbarHostState.showSnackbar(sttNotAvailableMsg) + } + } + } else { + micPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + }, + isListening = isListening, isAgentTyping = state.isAgentTyping, isConnected = state.isConnected, commandCatalog = state.commandCatalog, @@ -365,6 +469,8 @@ private fun ChatInputBar( inputText: String, onInputChange: (String) -> Unit, onSend: () -> Unit, + onMicTap: () -> Unit, + isListening: Boolean, isAgentTyping: Boolean, isConnected: Boolean, commandCatalog: CommandCatalog, @@ -495,21 +601,79 @@ private fun ChatInputBar( Spacer(modifier = Modifier.width(6.dp)) - // Send button — always visible, enabled for slash commands mid-turn - FilledTonalButton( - onClick = onSend, - enabled = canSend, - modifier = - Modifier - .size(40.dp) - .testTag("send_button"), - shape = CircleShape, - contentPadding = PaddingValues(0.dp), - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Send, - contentDescription = stringResource(R.string.chat_send_desc), - ) + // Mic / Stop / Send button — swaps based on input state + AnimatedContent( + targetState = + when { + isListening -> "listening" + inputText.isBlank() -> "mic" + else -> "send" + }, + transitionSpec = { + (scaleIn(initialScale = 0.8f) + fadeIn()) + .togetherWith(scaleOut(targetScale = 0.8f) + fadeOut()) + }, + label = "mic_send_toggle", + ) { buttonState -> + when (buttonState) { + "mic" -> { + FilledTonalButton( + onClick = onMicTap, + enabled = isConnected, + modifier = + Modifier + .size(40.dp) + .testTag("mic_button"), + shape = CircleShape, + contentPadding = PaddingValues(0.dp), + ) { + Icon( + imageVector = Icons.Default.Mic, + contentDescription = stringResource(R.string.chat_mic_desc), + ) + } + } + + "listening" -> { + FilledTonalButton( + onClick = onMicTap, + modifier = + Modifier + .size(40.dp) + .testTag("mic_stop_button"), + shape = CircleShape, + contentPadding = PaddingValues(0.dp), + colors = + ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.error, + ), + ) { + Icon( + imageVector = Icons.Default.Stop, + contentDescription = "Stop listening", + tint = MaterialTheme.colorScheme.onError, + ) + } + } + + else -> { + FilledTonalButton( + onClick = onSend, + enabled = canSend, + modifier = + Modifier + .size(40.dp) + .testTag("send_button"), + shape = CircleShape, + contentPadding = PaddingValues(0.dp), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = stringResource(R.string.chat_send_desc), + ) + } + } + } } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 70180a00..cd29de45 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3,6 +3,10 @@ Send + Voice input + Listening… + Microphone permission is required for voice input + Speech recognition is not available on this device Clarification Needed Tap an option to copy to input, or long-press to send directly. Response