From d725821011dad6d99186507006200e393b6aaded Mon Sep 17 00:00:00 2001 From: Justin Caveda Date: Wed, 14 Jan 2026 16:06:22 -0600 Subject: [PATCH] [FEAT] Add voicesearch button to search page (#637) ## Description This PR is a rewrite of a previously submitted PR for this same feature. It implements native Voice Search for Android TV using Android's SpeechRecognizer API. Key Changes New Files: - VoiceInputManager.kt - Handles SpeechRecognizer lifecycle, permission management, and audio level normalization - VoiceSearchButton.kt - Composable button with audio-reactive pulse and full-screen listening dialog UI/UX: - Audio-reactive microphone button that pulses based on input volume - Full-screen listening overlay with animated states: - Listening - Pulsing bubble with ripple rings, shows partial transcription - Processing - Animated dots while waiting for final result - Error - Red bubble with localized error message, auto-dismisses after 3s ### Related issues Resolves https://github.com/damontecres/Wholphin/issues/515 ### AI/LLM usage The initial draft of this change from the prior PR was created with the assistance of Claude Code, which was heavily leaned on for the overlay. I have taken the initial draft, gone through it, and re-implemented by hand to clean it up, narrow the scope (AI loves to touch and change things unrelated to what you're working on) and make it more readable. AI was used in the last stage to assist with finding any logic errors/bugs I may have missed (by looking through code and also by looking over ADB logs from testing on Nvidia Shield).. AI was also used to create the tests found in TestVoiceInputManager.kt. Lastly, I fed the diffs to an AI to help summarize the changes for this PR in an easy to read manner. --------- Co-authored-by: Damontecres --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 7 + .../ui/components/VoiceInputManager.kt | 442 ++++++++++ .../ui/components/VoiceSearchButton.kt | 414 ++++++++++ .../wholphin/ui/main/SearchPage.kt | 188 +++-- app/src/main/res/values/fa_strings.xml | 1 + app/src/main/res/values/strings.xml | 18 + .../wholphin/test/TestVoiceInputManager.kt | 768 ++++++++++++++++++ .../test/TestVoiceInputManagerAutoFocus.kt | 132 +++ gradle/libs.versions.toml | 2 + 10 files changed, 1921 insertions(+), 52 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/VoiceInputManager.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/VoiceSearchButton.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/test/TestVoiceInputManager.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/test/TestVoiceInputManagerAutoFocus.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7e572ff5..9e858044 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -294,4 +294,5 @@ dependencies { testImplementation(libs.mockk.android) testImplementation(libs.mockk.agent) + testImplementation(libs.robolectric) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cb130157..ccc0f74e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -24,6 +24,13 @@ android:name="android.hardware.microphone" android:required="false" /> + + + + + + + + when (focusChange) { + AudioManager.AUDIOFOCUS_LOSS -> { + Timber.d("Permanent audio focus loss. Stopping listening.") + stopListening() + } + + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { + Timber.d("Transient audio focus loss. Ignoring to allow SpeechRecognizer to work.") + } + + else -> { + Timber.d("Audio focus change: $focusChange") + } + } + } + + private val audioFocusRequest: AudioFocusRequest? by lazy { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + AudioFocusRequest + .Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE) + .setOnAudioFocusChangeListener(audioFocusListener, handler) + .build() + } else { + null + } + } + + @Suppress("DEPRECATION") + private fun requestAudioFocusCompat(): Int = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + audioFocusRequest?.let { audioManager.requestAudioFocus(it) } + ?: AudioManager.AUDIOFOCUS_REQUEST_GRANTED + } else { + audioManager.requestAudioFocus( + audioFocusListener, + AudioManager.STREAM_MUSIC, + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE, + ) + } + + @Suppress("DEPRECATION") + private fun abandonAudioFocusCompat() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + audioFocusRequest?.let { audioManager.abandonAudioFocusRequest(it) } + } else { + audioManager.abandonAudioFocus(audioFocusListener) + } + } + + private val _state = MutableStateFlow(VoiceInputState.Idle) + val state: StateFlow = _state.asStateFlow() + + private val _soundLevel = MutableStateFlow(0f) + val soundLevel: StateFlow = _soundLevel.asStateFlow() + + private val _partialResult = MutableStateFlow("") + val partialResult: StateFlow = _partialResult.asStateFlow() + + val isAvailable = SpeechRecognizer.isRecognitionAvailable(context) + val hasPermission: Boolean + get() = + ContextCompat.checkSelfPermission( + context, + Manifest.permission.RECORD_AUDIO, + ) == PackageManager.PERMISSION_GRANTED + + private var recognizer: SpeechRecognizer? = null + private var timeoutJob: Job? = null + + private fun provideMainDispatcher(): CoroutineDispatcher = + try { + Dispatchers.Main.immediate + } catch (_: IllegalStateException) { + // Fallback for unit tests where Main dispatcher is not installed + handler.asCoroutineDispatcher() + } + + private val recognitionIntent by lazy { + Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { + putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) + putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true) + putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, MAX_RESULTS) + } + } + + private fun isNetworkAvailable(): Boolean { + val network = connectivityManager.activeNetwork ?: return false + val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false + return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + } + + fun startListening() { + scope.launch { + mutex.withLock { + val currentState = _state.value + if (currentState is VoiceInputState.Starting || currentState is VoiceInputState.Listening) { + return@withLock + } + + val hadRecognizer = recognizer != null + if (hadRecognizer) { + destroyRecognizer() + } + + if (!isNetworkAvailable()) { + handler.post { + _state.value = + VoiceInputState.Error( + messageResId = R.string.voice_error_network, + isRetryable = true, + ) + } + return@withLock + } + + val focusResult = requestAudioFocusCompat() + if (focusResult != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + handler.post { + _state.value = + VoiceInputState.Error( + messageResId = R.string.voice_error_audio, + isRetryable = true, + ) + } + return@withLock + } + + cancelTimeout() + handler.post { + _partialResult.value = "" + _soundLevel.value = 0f + _state.value = VoiceInputState.Starting + } + + // Give the OS time to release the mic before recreating when replacing an old recognizer + if (hadRecognizer) { + delay(RECOGNIZER_RECREATE_DELAY_MS) + } + + val newRecognizer = SpeechRecognizer.createSpeechRecognizer(context) + recognizer = newRecognizer + newRecognizer.setRecognitionListener(createRecognitionListener(newRecognizer)) + + try { + newRecognizer.startListening(recognitionIntent) + } catch (e: Exception) { + Timber.e(e, "Failed to start speech recognition") + destroyRecognizer() + cancelTimeout() + handler.post { + _state.value = + VoiceInputState.Error( + messageResId = R.string.voice_error_start_failed, + isRetryable = true, + ) + } + } + } + } + } + + fun stopListening() { + scope.launch { + mutex.withLock { + cancelTimeout() + close() + } + } + } + + private fun cancelTimeout() { + timeoutJob?.cancel() + timeoutJob = null + } + + private fun startTimeout() { + cancelTimeout() + timeoutJob = + scope.launch { + delay(LISTENING_TIMEOUT_MS) + mutex.withLock { + if (_state.value is VoiceInputState.Listening && recognizer != null) { + val partial = _partialResult.value + destroyRecognizer() + handler.post { + _soundLevel.value = 0f + _partialResult.value = "" + _state.value = + if (partial.isNotBlank()) { + VoiceInputState.Result(partial) + } else { + VoiceInputState.Error( + messageResId = R.string.voice_error_timeout, + isRetryable = true, + ) + } + } + } + } + } + } + + fun acknowledge() { + handler.post { _state.value = VoiceInputState.Idle } + } + + fun onPermissionGranted() = startListening() + + fun onPermissionDenied() { + Timber.w("RECORD_AUDIO permission denied") + handler.post { + _state.value = + VoiceInputState.Error( + messageResId = R.string.voice_error_permissions, + isRetryable = false, + ) + } + } + + private fun destroyRecognizer() { + abandonAudioFocusCompat() + // Null out FIRST to invalidate callbacks before cancel() can trigger them + val rec = recognizer + recognizer = null + rec?.let { + try { + it.cancel() + it.destroy() + } catch (e: Exception) { + Timber.w(e, "Error destroying speech recognizer") + } + } + } + + override fun close() { + destroyRecognizer() + handler.post { + _soundLevel.value = 0f + _partialResult.value = "" + _state.value = VoiceInputState.Idle + } + } + + private fun createRecognitionListener(activeRecognizer: SpeechRecognizer) = + object : RecognitionListener { + // Guard against callbacks from zombie recognizers + private fun isValid() = recognizer === activeRecognizer + + override fun onReadyForSpeech(params: Bundle?) { + if (!isValid()) return + handler.post { _state.value = VoiceInputState.Listening } + startTimeout() + } + + override fun onBeginningOfSpeech() { + if (!isValid()) return + } + + override fun onRmsChanged(rmsdB: Float) { + if (!isValid()) return + handler.post { _soundLevel.value = normalizeRmsDb(rmsdB) } + } + + override fun onBufferReceived(buffer: ByteArray?) = Unit + + override fun onEndOfSpeech() { + if (!isValid()) return + cancelTimeout() + handler.post { _state.value = VoiceInputState.Processing } + } + + override fun onError(error: Int) { + if (!isValid()) return + Timber.e("Voice recognition error code: $error") + cancelTimeout() + destroyRecognizer() + + if (error == SpeechRecognizer.ERROR_TOO_MANY_REQUESTS) { + handler.post { + _state.value = + VoiceInputState.Error( + messageResId = ERROR_TO_RESOURCE_MAP[error] ?: R.string.voice_error_unknown, + isRetryable = false, + ) + _soundLevel.value = 0f + _partialResult.value = "" + } + return + } + handler.post { + _state.value = + VoiceInputState.Error( + messageResId = ERROR_TO_RESOURCE_MAP[error] ?: R.string.voice_error_unknown, + isRetryable = error in RETRYABLE_ERRORS, + ) + _soundLevel.value = 0f + _partialResult.value = "" + } + } + + override fun onResults(results: Bundle?) { + if (!isValid()) return + cancelTimeout() + val spokenText = + results + ?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION) + ?.firstOrNull() + handler.post { + _state.value = + if (!spokenText.isNullOrBlank()) { + VoiceInputState.Result(spokenText) + } else { + VoiceInputState.Error( + messageResId = R.string.voice_error_no_match, + isRetryable = true, + ) + } + _soundLevel.value = 0f + } + } + + override fun onPartialResults(partialResults: Bundle?) { + if (!isValid()) return + partialResults + ?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION) + ?.firstOrNull() + ?.takeIf { it.isNotBlank() } + ?.let { handler.post { _partialResult.value = it } } + } + + override fun onEvent( + eventType: Int, + params: Bundle?, + ) = Unit + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VoiceSearchButton.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VoiceSearchButton.kt new file mode 100644 index 00000000..3042ac06 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VoiceSearchButton.kt @@ -0,0 +1,414 @@ +package com.github.damontecres.wholphin.ui.components + +import android.Manifest +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSizeIn +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.tv.material3.ButtonDefaults +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.OutlinedButton +import androidx.tv.material3.OutlinedButtonDefaults +import androidx.tv.material3.Text +import androidx.tv.material3.surfaceColorAtElevation +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.FontAwesome +import kotlinx.coroutines.delay + +private const val ERROR_AUTO_DISMISS_DELAY_MS = 3000L +private const val SOUND_LEVEL_SCALE_FACTOR = 0.15f +private val BUBBLE_SIZE = 160.dp +private val MIC_ICON_FONT_SIZE = 56.sp +private val BUTTON_ICON_FONT_SIZE = 20.sp +private val CONTENT_SPACING = 48.dp +private val HORIZONTAL_PADDING = 64.dp +private val DISMISS_HINT_BOTTOM_PADDING = 32.dp +private const val HINT_TEXT_ALPHA = 0.5f +private const val SOUND_LEVEL_ANIM_MS = 100 +private const val BASE_PULSE_ANIM_MS = 800 +private const val RIPPLE_ANIM_MS = 1500 +private const val DOTS_ANIM_MS = 1200 +private const val RIPPLE_CANVAS_SCALE = 1.8f +private const val MAX_RIPPLE_EXPANSION = 0.35f +private val RIPPLE_STROKE_WIDTH = 2.dp +private const val RIPPLE_MAX_ALPHA = 0.4f + +private fun VoiceInputState.shouldShowOverlay() = + this is VoiceInputState.Starting || + this is VoiceInputState.Listening || + this is VoiceInputState.Processing || + this is VoiceInputState.Error + +@Composable +fun VoiceSearchButton( + onSpeechResult: (String) -> Unit, + voiceInputManager: VoiceInputManager?, + modifier: Modifier = Modifier, +) { + if (voiceInputManager == null || !voiceInputManager.isAvailable) return + + val state by voiceInputManager.state.collectAsState() + val soundLevel by voiceInputManager.soundLevel.collectAsState() + val partialResult by voiceInputManager.partialResult.collectAsState() + + LaunchedEffect(state) { + val currentState = state + when (currentState) { + is VoiceInputState.Result -> { + onSpeechResult(currentState.text) + // Small delay to allow focus to be restored before dialog dismisses + delay(50) + voiceInputManager.acknowledge() + } + + is VoiceInputState.Error -> { + if (!currentState.isRetryable) { + delay(ERROR_AUTO_DISMISS_DELAY_MS) + voiceInputManager.acknowledge() + } + } + + else -> {} + } + } + + val permissionLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { isGranted -> + if (isGranted) { + voiceInputManager.onPermissionGranted() + } else { + voiceInputManager.onPermissionDenied() + } + } + + if (state.shouldShowOverlay()) { + val errorState = state as? VoiceInputState.Error + val errorMessage = errorState?.messageResId?.let { stringResource(it) } + VoiceSearchOverlay( + soundLevel = soundLevel, + partialResult = partialResult, + isStarting = state is VoiceInputState.Starting, + isProcessing = state is VoiceInputState.Processing, + errorMessage = errorMessage, + isRetryable = errorState?.isRetryable == true, + onRetry = { voiceInputManager.startListening() }, + onDismiss = { voiceInputManager.stopListening() }, + ) + } + + Button( + onClick = { + when (state) { + is VoiceInputState.Starting, + is VoiceInputState.Listening, + -> { + voiceInputManager.stopListening() + } + + else -> { + if (voiceInputManager.hasPermission) { + voiceInputManager.startListening() + } else { + permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + } + } + }, + modifier = + modifier.requiredSizeIn( + minWidth = MinButtonSize, + minHeight = MinButtonSize, + maxWidth = MinButtonSize, + maxHeight = MinButtonSize, + ), + contentPadding = PaddingValues(0.dp), + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + val voiceSearchDesc = stringResource(R.string.voice_search) + Text( + text = stringResource(R.string.fa_microphone), + fontFamily = FontAwesome, + fontSize = BUTTON_ICON_FONT_SIZE, + textAlign = TextAlign.Center, + modifier = Modifier.semantics { contentDescription = voiceSearchDesc }, + ) + } + } +} + +@Composable +private fun VoiceRippleRings( + rippleProgress: Float, + bubbleSize: androidx.compose.ui.unit.Dp, + color: Color, + modifier: Modifier = Modifier, +) { + val density = LocalDensity.current + val rippleStroke = + remember(density) { + Stroke(width = with(density) { RIPPLE_STROKE_WIDTH.toPx() }) + } + + Canvas(modifier = modifier.size(bubbleSize * RIPPLE_CANVAS_SCALE)) { + val canvasCenter = center + val baseRadius = bubbleSize.toPx() / 2 + val maxExpansion = bubbleSize.toPx() * MAX_RIPPLE_EXPANSION + + for (i in 0..2) { + val ringProgress = (rippleProgress + (i * 0.33f)) % 1f + val ringRadius = baseRadius + (ringProgress * maxExpansion) + val ringAlpha = (1f - ringProgress) * RIPPLE_MAX_ALPHA + drawCircle( + color = color.copy(alpha = ringAlpha), + radius = ringRadius, + center = canvasCenter, + style = rippleStroke, + ) + } + } +} + +private fun getStatusText( + errorMessage: String?, + partialResult: String, + isStarting: Boolean, + isProcessing: Boolean, + startingText: String, + processingText: String, + listeningText: String, + dotCount: Int, +): Pair { + val dots = ".".repeat(dotCount) + return when { + errorMessage != null -> errorMessage to errorMessage + isProcessing -> (processingText + dots) to processingText + isStarting -> (startingText + dots) to startingText + partialResult.isNotBlank() -> partialResult to partialResult + else -> (listeningText + dots) to listeningText + } +} + +@Composable +private fun VoiceSearchOverlay( + soundLevel: Float, + partialResult: String, + isStarting: Boolean, + isProcessing: Boolean, + errorMessage: String?, + isRetryable: Boolean, + onRetry: () -> Unit, + onDismiss: () -> Unit, +) { + val primaryColor = MaterialTheme.colorScheme.primary + val onPrimaryColor = MaterialTheme.colorScheme.onPrimary + val errorColor = MaterialTheme.colorScheme.error + + val animatedSoundLevel by animateFloatAsState( + targetValue = soundLevel, + animationSpec = tween(durationMillis = SOUND_LEVEL_ANIM_MS), + label = "soundLevel", + ) + + val infiniteTransition = rememberInfiniteTransition(label = "pulse") + val basePulse by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 1.05f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = BASE_PULSE_ANIM_MS), + repeatMode = RepeatMode.Reverse, + ), + label = "basePulse", + ) + + // Only animate ripples when actively listening (not starting, processing, or in error) + val shouldAnimateRipples = !isStarting && !isProcessing && errorMessage == null + val rippleProgress by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = if (shouldAnimateRipples) 1f else 0f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = RIPPLE_ANIM_MS), + repeatMode = RepeatMode.Restart, + ), + label = "ripple", + ) + + val dotAnimation by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 4f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = DOTS_ANIM_MS), + repeatMode = RepeatMode.Restart, + ), + label = "dots", + ) + + val bubbleScale = basePulse + (animatedSoundLevel * SOUND_LEVEL_SCALE_FACTOR) + + val statusFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + statusFocusRequester.requestFocus() + } + + Dialog( + onDismissRequest = onDismiss, + properties = + DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true, + usePlatformDefaultWidth = false, + ), + ) { + Box( + modifier = + Modifier + .fillMaxWidth(0.85f) + .wrapContentHeight() + .padding(vertical = 48.dp) + .clip(MaterialTheme.shapes.large) + .background(MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)), + contentAlignment = Alignment.Center, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(CONTENT_SPACING), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = HORIZONTAL_PADDING), + ) { + Box(contentAlignment = Alignment.Center) { + val rippleAlpha = if (shouldAnimateRipples) 1f else 0f + Box(modifier = Modifier.graphicsLayer { alpha = rippleAlpha }) { + VoiceRippleRings( + rippleProgress = rippleProgress, + bubbleSize = BUBBLE_SIZE, + color = primaryColor, + ) + } + + Box( + modifier = + Modifier + .size(BUBBLE_SIZE) + .graphicsLayer { + scaleX = bubbleScale + scaleY = bubbleScale + }.clip(CircleShape) + .background(if (errorMessage != null) errorColor else primaryColor), + contentAlignment = Alignment.Center, + ) { + val voiceSearchDesc = stringResource(R.string.voice_search) + Text( + text = stringResource(R.string.fa_microphone), + fontFamily = FontAwesome, + fontSize = MIC_ICON_FONT_SIZE, + color = onPrimaryColor, + textAlign = TextAlign.Center, + modifier = Modifier.semantics { contentDescription = voiceSearchDesc }, + ) + } + } + + val startingText = stringResource(R.string.voice_starting) + val processingText = stringResource(R.string.processing) + val listeningText = stringResource(R.string.voice_search_prompt) + val (statusText, accessibilityDescription) = + getStatusText( + errorMessage = errorMessage, + partialResult = partialResult, + isStarting = isStarting, + isProcessing = isProcessing, + startingText = startingText, + processingText = processingText, + listeningText = listeningText, + dotCount = dotAnimation.toInt(), + ) + + Column( + modifier = Modifier.weight(1f).focusRequester(statusFocusRequester), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = statusText, + style = MaterialTheme.typography.headlineMedium, + color = if (errorMessage != null) errorColor else Color.White, + modifier = Modifier.semantics { contentDescription = accessibilityDescription }, + ) + + if (errorMessage != null && isRetryable) { + OutlinedButton( + onClick = onRetry, + modifier = Modifier.padding(top = 16.dp), + shape = ButtonDefaults.shape(RoundedCornerShape(50)), + colors = + OutlinedButtonDefaults.colors( + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Text( + text = stringResource(R.string.retry), + style = MaterialTheme.typography.labelLarge, + ) + } + } + } + } + + Text( + text = stringResource(R.string.press_back_to_cancel), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = HINT_TEXT_ALPHA), + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding(bottom = DISMISS_HINT_BOTTOM_PADDING), + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt index 4ca9f2c3..b5839aad 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt @@ -2,12 +2,11 @@ package com.github.damontecres.wholphin.ui.main import androidx.activity.compose.BackHandler import androidx.compose.foundation.focusGroup -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn @@ -25,7 +24,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController @@ -34,6 +40,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.viewModelScope import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text @@ -51,11 +58,13 @@ import com.github.damontecres.wholphin.ui.cards.EpisodeCard import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.SeasonCard import com.github.damontecres.wholphin.ui.components.SearchEditTextBox +import com.github.damontecres.wholphin.ui.components.VoiceInputManager +import com.github.damontecres.wholphin.ui.components.VoiceSearchButton import com.github.damontecres.wholphin.ui.data.RowColumn -import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.onMain import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.tryRequestFocus @@ -81,7 +90,12 @@ class SearchViewModel val api: ApiClient, val navigationManager: NavigationManager, private val seerrService: SeerrService, + val voiceInputManager: VoiceInputManager, ) : ViewModel() { + val voiceState = voiceInputManager.state + val soundLevel = voiceInputManager.soundLevel + val partialResult = voiceInputManager.partialResult + val movies = MutableLiveData(SearchResult.NoQuery) val series = MutableLiveData(SearchResult.NoQuery) val episodes = MutableLiveData(SearchResult.NoQuery) @@ -158,6 +172,10 @@ class SearchViewModel } } + init { + addCloseable(voiceInputManager) + } + fun getHints(query: String) { // TODO // api.searchApi.getSearchHints() @@ -182,12 +200,16 @@ sealed interface SearchResult { ) : SearchResult } -private const val MOVIE_ROW = 0 +private const val SEARCH_ROW = 0 +private const val MOVIE_ROW = SEARCH_ROW + 1 private const val COLLECTION_ROW = MOVIE_ROW + 1 private const val SERIES_ROW = COLLECTION_ROW + 1 private const val EPISODE_ROW = SERIES_ROW + 1 private const val SEERR_ROW = EPISODE_ROW + 1 +/** Delay for focus to settle after voice search dialog dismisses. */ +private const val VOICE_RESULT_FOCUS_DELAY_MS = 350L + @Composable fun SearchPage( userPreferences: UserPreferences, @@ -205,26 +227,59 @@ fun SearchPage( // val query = rememberTextFieldState() var query by rememberSaveable { mutableStateOf("") } - val focusRequester = remember { FocusRequester() } + val focusRequesters = remember { List(SEERR_ROW + 1) { FocusRequester() } } - var position by rememberPosition() + var position by rememberPosition(0, 0) var searchClicked by rememberSaveable { mutableStateOf(false) } + var immediateSearchQuery by rememberSaveable { mutableStateOf(null) } + + LifecycleResumeEffect(Unit) { + onPauseOrDispose { + viewModel.voiceInputManager.stopListening() + } + } + + fun triggerImmediateSearch(searchQuery: String) { + immediateSearchQuery = searchQuery + searchClicked = true + viewModel.search(searchQuery) + } LaunchedEffect(query) { - delay(750L) - viewModel.search(query) + when { + immediateSearchQuery == query -> { + immediateSearchQuery = null + } + + else -> { + delay(750L) + viewModel.search(query) + } + } } LaunchedEffect(Unit) { - focusRequester.tryRequestFocus() + focusRequesters.getOrNull(position.row)?.tryRequestFocus() } val onClickItem = { index: Int, item: BaseItem -> viewModel.navigationManager.navigateTo(item.destination()) } - LaunchedEffect(searchClicked, movies, collections, series, episodes) { - if (searchClicked) { - if (listOf(movies, collections, series, episodes).any { it is SearchResult.Success }) { - focusManager.moveFocus(FocusDirection.Next) - searchClicked = false + + LaunchedEffect(searchClicked, movies, collections, series, episodes, seerrResults) { + if (!searchClicked) return@LaunchedEffect + + withContext(Dispatchers.IO) { + // Want to focus on the first successful row after all of the ones before it are finished searching + val results = listOf(movies, collections, series, episodes, seerrResults) + val firstSuccess = + results.indexOfFirst { it is SearchResult.Success || it is SearchResult.SuccessSeerr } + if (firstSuccess >= 0) { + val anyBeforeSearching = + results.subList(0, firstSuccess).any { it is SearchResult.Searching } + if (!anyBeforeSearching) { + // 0-th row is the search bar + position = RowColumn(firstSuccess + 1, 0) + onMain { focusRequesters[firstSuccess + 1].tryRequestFocus() } + } } } } @@ -239,27 +294,67 @@ fun SearchPage( contentAlignment = Alignment.Center, modifier = Modifier.fillMaxWidth(), ) { - val interactionSource = remember { MutableInteractionSource() } - val focused by interactionSource.collectIsFocusedAsState() - BackHandler(focused) { - keyboardController?.hide() - focusManager.moveFocus(FocusDirection.Next) + var isSearchActive by remember { mutableStateOf(false) } + var isTextFieldFocused by remember { mutableStateOf(false) } + val textFieldFocusRequester = remember { FocusRequester() } + + BackHandler(isTextFieldFocused) { + when { + isSearchActive -> { + isSearchActive = false + keyboardController?.hide() + } + + else -> { + focusManager.moveFocus(FocusDirection.Next) + } + } } - SearchEditTextBox( - value = query, - onValueChange = { query = it }, - onSearchClick = { - viewModel.search(query) - searchClicked = true - }, + + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .ifElse( - position.row < MOVIE_ROW, - Modifier.focusRequester(focusRequester), - ), - interactionSource = interactionSource, - ) + .focusGroup() + .focusRestorer(textFieldFocusRequester) + .focusRequester(focusRequesters[SEARCH_ROW]), + ) { + VoiceSearchButton( + onSpeechResult = { spokenText -> + query = spokenText + triggerImmediateSearch(spokenText) + }, + voiceInputManager = viewModel.voiceInputManager, + ) + + SearchEditTextBox( + value = query, + onValueChange = { + isSearchActive = true + query = it + }, + onSearchClick = { triggerImmediateSearch(query) }, + readOnly = !isSearchActive, + modifier = + Modifier + .focusRequester(textFieldFocusRequester) + .onFocusChanged { state -> + isTextFieldFocused = state.isFocused + if (!state.isFocused) isSearchActive = false + }.onPreviewKeyEvent { event -> + val isActivationKey = + event.key in listOf(Key.DirectionCenter, Key.Enter) + if (event.type == KeyEventType.KeyUp && isActivationKey && !isSearchActive) { + isSearchActive = true + keyboardController?.show() + true + } else { + false + } + }, + ) + } } } searchResultRow( @@ -267,7 +362,7 @@ fun SearchPage( result = movies, rowIndex = MOVIE_ROW, position = position, - focusRequester = focusRequester, + focusRequester = focusRequesters[MOVIE_ROW], onClickItem = onClickItem, onClickPosition = { position = it }, modifier = Modifier.fillMaxWidth(), @@ -277,7 +372,7 @@ fun SearchPage( result = collections, rowIndex = COLLECTION_ROW, position = position, - focusRequester = focusRequester, + focusRequester = focusRequesters[COLLECTION_ROW], onClickItem = onClickItem, onClickPosition = { position = it }, modifier = Modifier.fillMaxWidth(), @@ -287,7 +382,7 @@ fun SearchPage( result = series, rowIndex = SERIES_ROW, position = position, - focusRequester = focusRequester, + focusRequester = focusRequesters[SERIES_ROW], onClickItem = onClickItem, onClickPosition = { position = it }, modifier = Modifier.fillMaxWidth(), @@ -297,7 +392,7 @@ fun SearchPage( result = episodes, rowIndex = EPISODE_ROW, position = position, - focusRequester = focusRequester, + focusRequester = focusRequesters[EPISODE_ROW], onClickItem = onClickItem, onClickPosition = { position = it }, modifier = Modifier.fillMaxWidth(), @@ -310,13 +405,7 @@ fun SearchPage( }, onLongClick = onLongClick, imageHeight = 140.dp, - modifier = - mod - .padding(horizontal = 8.dp) - .ifElse( - position.row == EPISODE_ROW && position.column == index, - Modifier.focusRequester(focusRequester), - ), + modifier = mod.padding(horizontal = 8.dp), ) }, ) @@ -325,7 +414,7 @@ fun SearchPage( result = seerrResults, rowIndex = SEERR_ROW, position = position, - focusRequester = focusRequester, + focusRequester = focusRequesters[SEERR_ROW], onClickItem = { _, _ -> // no-op }, @@ -372,12 +461,7 @@ fun LazyListScope.searchResultRow( }, onLongClick = onLongClick, imageHeight = Cards.height2x3, - modifier = - mod - .ifElse( - position.row == rowIndex && position.column == index, - Modifier.focusRequester(focusRequester), - ), + modifier = mod, ) }, ) { @@ -417,7 +501,7 @@ fun LazyListScope.searchResultRow( items = r.items, onClickItem = onClickItem, onLongClickItem = { _, _ -> }, - modifier = modifier, + modifier = modifier.focusRequester(focusRequester), cardContent = cardContent, ) } @@ -439,7 +523,7 @@ fun LazyListScope.searchResultRow( onClickDiscover?.invoke(index, item) }, onLongClickItem = { _, _ -> }, - modifier = modifier, + modifier = modifier.focusRequester(focusRequester), cardContent = { index: Int, item: DiscoverItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> DiscoverItemCard( item = item, diff --git a/app/src/main/res/values/fa_strings.xml b/app/src/main/res/values/fa_strings.xml index d247de8c..ae2a4ae1 100644 --- a/app/src/main/res/values/fa_strings.xml +++ b/app/src/main/res/values/fa_strings.xml @@ -45,6 +45,7 @@ + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 82bf3210..4d0b5441 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -105,6 +105,24 @@ Search Searching… + Voice search + Starting + Speak to search + Processing + Press back to cancel + Audio recording error + Voice recognition error + Microphone permission required + Network error + Network timeout + No speech recognized + Voice recognition busy + Server error + No speech detected + Unknown error + Failed to start voice recognition + Voice recognition timed out + Retry Select Server Select User Show debug info diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestVoiceInputManager.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestVoiceInputManager.kt new file mode 100644 index 00000000..2c3648a2 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestVoiceInputManager.kt @@ -0,0 +1,768 @@ +package com.github.damontecres.wholphin.test + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.media.AudioFocusRequest +import android.media.AudioManager +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.os.Bundle +import android.os.Looper +import android.speech.RecognitionListener +import android.speech.SpeechRecognizer +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.components.VoiceInputManager +import com.github.damontecres.wholphin.ui.components.VoiceInputState +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.unmockkStatic +import io.mockk.verify +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** + * Unit tests for [VoiceInputManager] state machine logic. + * + * Uses Robolectric to provide Android framework classes (Intent, Bundle) + * and Mockk to mock [SpeechRecognizer] and simulate recognition callbacks + * without requiring a real microphone or emulator. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [28]) +class TestVoiceInputManager { + private lateinit var activity: Activity + private lateinit var speechRecognizer: SpeechRecognizer + private lateinit var listenerSlot: CapturingSlot + private lateinit var manager: VoiceInputManager + private lateinit var audioManager: AudioManager + private lateinit var connectivityManager: ConnectivityManager + private lateinit var network: Network + private lateinit var networkCapabilities: NetworkCapabilities + + private val capturedListener: RecognitionListener + get() = listenerSlot.captured + + private fun idleMainLooper() = shadowOf(Looper.getMainLooper()).idle() + + @Before + fun setup() { + // Mock Activity + activity = mockk(relaxed = true) + + // Mock AudioManager + audioManager = mockk(relaxed = true) + every { audioManager.requestAudioFocus(any()) } returns AudioManager.AUDIOFOCUS_REQUEST_GRANTED + every { activity.getSystemService(Context.AUDIO_SERVICE) } returns audioManager + + // Mock ConnectivityManager with network available by default + connectivityManager = mockk(relaxed = true) + network = mockk() + networkCapabilities = mockk() + every { connectivityManager.activeNetwork } returns network + every { connectivityManager.getNetworkCapabilities(network) } returns networkCapabilities + every { networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } returns true + every { activity.getSystemService(Context.CONNECTIVITY_SERVICE) } returns connectivityManager + + // Mock SpeechRecognizer instance + speechRecognizer = mockk(relaxed = true) + listenerSlot = slot() + + // Capture the RecognitionListener when setRecognitionListener is called + every { speechRecognizer.setRecognitionListener(capture(listenerSlot)) } just Runs + + // Mock static factory method + mockkStatic(SpeechRecognizer::class) + every { SpeechRecognizer.createSpeechRecognizer(activity) } returns speechRecognizer + every { SpeechRecognizer.isRecognitionAvailable(activity) } returns true + + // Create the manager under test + manager = VoiceInputManager(activity) + } + + @After + fun teardown() { + unmockkStatic(SpeechRecognizer::class) + } + + // ========== Test Case 1: Initial State ========== + + @Test + fun `initial state is Idle`() { + assertEquals(VoiceInputState.Idle, manager.state.value) + } + + @Test + fun `initial soundLevel is zero`() { + assertEquals(0f, manager.soundLevel.value) + } + + @Test + fun `initial partialResult is empty`() { + assertEquals("", manager.partialResult.value) + } + + // ========== Test Case 2: Start Listening ========== + + @Test + fun `startListening transitions state to Starting`() { + manager.startListening() + idleMainLooper() + + assertEquals(VoiceInputState.Starting, manager.state.value) + } + + @Test + fun `onReadyForSpeech transitions state to Listening`() { + manager.startListening() + idleMainLooper() + capturedListener.onReadyForSpeech(null) + idleMainLooper() + + assertEquals(VoiceInputState.Listening, manager.state.value) + } + + @Test + fun `startListening creates SpeechRecognizer`() { + manager.startListening() + idleMainLooper() + + verify { SpeechRecognizer.createSpeechRecognizer(activity) } + } + + @Test + fun `startListening sets recognition listener`() { + manager.startListening() + idleMainLooper() + + verify { speechRecognizer.setRecognitionListener(any()) } + assertTrue(listenerSlot.isCaptured) + } + + @Test + fun `startListening calls recognizer startListening`() { + manager.startListening() + idleMainLooper() + + verify { speechRecognizer.startListening(any()) } + } + + @Test + fun `startListening resets partialResult`() { + manager.startListening() + idleMainLooper() + capturedListener.onReadyForSpeech(null) + idleMainLooper() + capturedListener.onPartialResults(createResultsBundle("partial")) + idleMainLooper() + manager.stopListening() + idleMainLooper() + + manager.startListening() + idleMainLooper() + + assertEquals("", manager.partialResult.value) + } + + @Test + fun `startListening is ignored when already listening`() { + manager.startListening() + idleMainLooper() + capturedListener.onReadyForSpeech(null) + idleMainLooper() + manager.startListening() // Should be ignored + idleMainLooper() + + // Only one recognizer should be created + verify(exactly = 1) { SpeechRecognizer.createSpeechRecognizer(activity) } + } + + @Test + fun `startListening is ignored when in Starting state`() { + manager.startListening() + idleMainLooper() + assertEquals(VoiceInputState.Starting, manager.state.value) + + manager.startListening() // Should be ignored + idleMainLooper() + + // Only one recognizer should be created + verify(exactly = 1) { SpeechRecognizer.createSpeechRecognizer(activity) } + } + + // ========== Test Case 3: Permission Denied ========== + + @Test + fun `onPermissionDenied transitions to Error state`() { + manager.onPermissionDenied() + idleMainLooper() + + assertTrue(manager.state.value is VoiceInputState.Error) + } + + @Test + fun `onPermissionDenied sets correct error resource`() { + manager.onPermissionDenied() + idleMainLooper() + + val errorState = manager.state.value as VoiceInputState.Error + assertEquals(R.string.voice_error_permissions, errorState.messageResId) + } + + // ========== Test Case 4: Result Success ========== + + @Test + fun `onResults transitions to Result state with correct text`() { + manager.startListening() + idleMainLooper() + + capturedListener.onResults(createResultsBundle("hello world")) + idleMainLooper() + + assertTrue(manager.state.value is VoiceInputState.Result) + assertEquals("hello world", (manager.state.value as VoiceInputState.Result).text) + } + + @Test + fun `onResults resets soundLevel to zero`() { + manager.startListening() + idleMainLooper() + capturedListener.onRmsChanged(5f) + idleMainLooper() + + capturedListener.onResults(createResultsBundle("test")) + idleMainLooper() + + assertEquals(0f, manager.soundLevel.value) + } + + @Test + fun `onResults with empty text transitions to Error state`() { + manager.startListening() + idleMainLooper() + + capturedListener.onResults(createResultsBundle("")) + idleMainLooper() + + assertTrue(manager.state.value is VoiceInputState.Error) + assertEquals(R.string.voice_error_no_match, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `onResults with null bundle transitions to Error state`() { + manager.startListening() + idleMainLooper() + + capturedListener.onResults(null) + idleMainLooper() + + assertTrue(manager.state.value is VoiceInputState.Error) + assertEquals(R.string.voice_error_no_match, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `onResults with blank text transitions to Error state`() { + manager.startListening() + idleMainLooper() + + capturedListener.onResults(createResultsBundle(" ")) + idleMainLooper() + + assertTrue(manager.state.value is VoiceInputState.Error) + } + + // ========== Test Case 5: Error Handling ========== + + @Test + fun `onError transitions to Error state`() { + manager.startListening() + idleMainLooper() + + capturedListener.onError(SpeechRecognizer.ERROR_NETWORK) + idleMainLooper() + + assertTrue(manager.state.value is VoiceInputState.Error) + } + + @Test + fun `onError maps ERROR_NETWORK to correct resource`() { + manager.startListening() + idleMainLooper() + + capturedListener.onError(SpeechRecognizer.ERROR_NETWORK) + idleMainLooper() + + assertEquals(R.string.voice_error_network, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `onError maps ERROR_NO_MATCH to correct resource`() { + manager.startListening() + idleMainLooper() + + capturedListener.onError(SpeechRecognizer.ERROR_NO_MATCH) + idleMainLooper() + + assertEquals(R.string.voice_error_no_match, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `onError maps ERROR_AUDIO to correct resource`() { + manager.startListening() + idleMainLooper() + + capturedListener.onError(SpeechRecognizer.ERROR_AUDIO) + idleMainLooper() + + assertEquals(R.string.voice_error_audio, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `onError maps ERROR_SERVER to correct resource`() { + manager.startListening() + idleMainLooper() + + capturedListener.onError(SpeechRecognizer.ERROR_SERVER) + idleMainLooper() + + assertEquals(R.string.voice_error_server, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `onError maps ERROR_CLIENT to correct resource`() { + manager.startListening() + idleMainLooper() + + capturedListener.onError(SpeechRecognizer.ERROR_CLIENT) + idleMainLooper() + + assertEquals(R.string.voice_error_client, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `onError maps ERROR_SPEECH_TIMEOUT to correct resource`() { + manager.startListening() + idleMainLooper() + + capturedListener.onError(SpeechRecognizer.ERROR_SPEECH_TIMEOUT) + idleMainLooper() + + assertEquals(R.string.voice_error_speech_timeout, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `onError ERROR_SPEECH_TIMEOUT is retryable`() { + manager.startListening() + idleMainLooper() + + capturedListener.onError(SpeechRecognizer.ERROR_SPEECH_TIMEOUT) + idleMainLooper() + + assertTrue((manager.state.value as VoiceInputState.Error).isRetryable) + } + + @Test + fun `onError maps ERROR_NETWORK_TIMEOUT to correct resource`() { + manager.startListening() + idleMainLooper() + + capturedListener.onError(SpeechRecognizer.ERROR_NETWORK_TIMEOUT) + idleMainLooper() + + assertEquals(R.string.voice_error_network_timeout, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `onError maps ERROR_RECOGNIZER_BUSY to correct resource after retry exhausted`() { + manager.startListening() + idleMainLooper() + + // First BUSY triggers auto-retry + capturedListener.onError(SpeechRecognizer.ERROR_RECOGNIZER_BUSY) + shadowOf(Looper.getMainLooper()).idleFor(java.time.Duration.ofMillis(350)) + + // Second BUSY exhausts retry count and shows error + capturedListener.onError(SpeechRecognizer.ERROR_RECOGNIZER_BUSY) + idleMainLooper() + + assertEquals(R.string.voice_error_busy, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `onError maps ERROR_INSUFFICIENT_PERMISSIONS to correct resource`() { + manager.startListening() + idleMainLooper() + + capturedListener.onError(SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS) + idleMainLooper() + + assertEquals(R.string.voice_error_permissions, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `onError maps unknown error to unknown resource`() { + manager.startListening() + idleMainLooper() + + capturedListener.onError(999) // Unknown error code + idleMainLooper() + + assertEquals(R.string.voice_error_unknown, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `onError resets soundLevel to zero`() { + manager.startListening() + idleMainLooper() + capturedListener.onRmsChanged(5f) + idleMainLooper() + + capturedListener.onError(SpeechRecognizer.ERROR_NETWORK) + idleMainLooper() + + assertEquals(0f, manager.soundLevel.value) + } + + // ========== Test Case 6: Cleanup/Lifecycle ========== + + @Test + fun `cleanup resets state to Idle`() { + manager.startListening() + idleMainLooper() + + manager.close() + idleMainLooper() + + assertEquals(VoiceInputState.Idle, manager.state.value) + } + + @Test + fun `cleanup calls cancel on recognizer`() { + manager.startListening() + idleMainLooper() + + manager.close() + + verify { speechRecognizer.cancel() } + } + + @Test + fun `cleanup calls destroy on recognizer`() { + manager.startListening() + idleMainLooper() + + manager.close() + + verify { speechRecognizer.destroy() } + } + + @Test + fun `cleanup resets soundLevel to zero`() { + manager.startListening() + idleMainLooper() + capturedListener.onRmsChanged(5f) + idleMainLooper() + + manager.close() + idleMainLooper() + + assertEquals(0f, manager.soundLevel.value) + } + + @Test + fun `cleanup resets partialResult to empty`() { + manager.startListening() + idleMainLooper() + capturedListener.onPartialResults(createResultsBundle("partial")) + idleMainLooper() + + manager.close() + idleMainLooper() + + assertEquals("", manager.partialResult.value) + } + + @Test + fun `stopListening triggers cleanup`() { + manager.startListening() + idleMainLooper() + + manager.stopListening() + idleMainLooper() + + assertEquals(VoiceInputState.Idle, manager.state.value) + verify { speechRecognizer.cancel() } + verify { speechRecognizer.destroy() } + } + + // ========== Additional Coverage ========== + + @Test + fun `onEndOfSpeech transitions to Processing state`() { + manager.startListening() + idleMainLooper() + + capturedListener.onEndOfSpeech() + idleMainLooper() + + assertEquals(VoiceInputState.Processing, manager.state.value) + } + + @Test + fun `onRmsChanged updates soundLevel with normalized value`() { + manager.startListening() + idleMainLooper() + + // RMS normalization: (rmsdB - (-2)) / (10 - (-2)) clamped to [0, 1] + // For rmsdB = 4: (4 - (-2)) / 12 = 6/12 = 0.5 + capturedListener.onRmsChanged(4f) + idleMainLooper() + + assertEquals(0.5f, manager.soundLevel.value, 0.01f) + } + + @Test + fun `onRmsChanged clamps high values to 1`() { + manager.startListening() + idleMainLooper() + + capturedListener.onRmsChanged(20f) // Above max + idleMainLooper() + + assertEquals(1f, manager.soundLevel.value) + } + + @Test + fun `onRmsChanged clamps low values to 0`() { + manager.startListening() + idleMainLooper() + + capturedListener.onRmsChanged(-10f) // Below min + idleMainLooper() + + assertEquals(0f, manager.soundLevel.value) + } + + @Test + fun `onPartialResults updates partialResult`() { + manager.startListening() + idleMainLooper() + + capturedListener.onPartialResults(createResultsBundle("hello")) + idleMainLooper() + + assertEquals("hello", manager.partialResult.value) + } + + @Test + fun `onPartialResults ignores blank text`() { + manager.startListening() + idleMainLooper() + capturedListener.onPartialResults(createResultsBundle("first")) + idleMainLooper() + + capturedListener.onPartialResults(createResultsBundle(" ")) + idleMainLooper() + + assertEquals("first", manager.partialResult.value) + } + + @Test + fun `acknowledge resets state to Idle`() { + manager.startListening() + idleMainLooper() + capturedListener.onResults(createResultsBundle("test")) + idleMainLooper() + + manager.acknowledge() + idleMainLooper() + + assertEquals(VoiceInputState.Idle, manager.state.value) + } + + @Test + fun `acknowledge works from Error state`() { + manager.onPermissionDenied() + idleMainLooper() + + manager.acknowledge() + idleMainLooper() + + assertEquals(VoiceInputState.Idle, manager.state.value) + } + + @Test + fun `onPermissionGranted calls startListening`() { + manager.onPermissionGranted() + idleMainLooper() + + assertEquals(VoiceInputState.Starting, manager.state.value) + verify { SpeechRecognizer.createSpeechRecognizer(activity) } + } + + @Test + fun `callbacks from previous recognizer are ignored after cleanup`() { + // Create two different mock recognizers to simulate real behavior + val firstRecognizer = mockk(relaxed = true) + val secondRecognizer = mockk(relaxed = true) + val firstListenerSlot = slot() + val secondListenerSlot = slot() + + every { firstRecognizer.setRecognitionListener(capture(firstListenerSlot)) } just Runs + every { secondRecognizer.setRecognitionListener(capture(secondListenerSlot)) } just Runs + + // Return different recognizers for each call + every { SpeechRecognizer.createSpeechRecognizer(activity) } returnsMany listOf(firstRecognizer, secondRecognizer) + + manager.startListening() + idleMainLooper() + val firstListener = firstListenerSlot.captured + + manager.close() + idleMainLooper() + manager.startListening() + idleMainLooper() + + // Simulate callback from the old (zombie) recognizer + firstListener.onResults(createResultsBundle("zombie result")) + idleMainLooper() + + // State should remain Starting (from the second startListening), not Result + assertEquals(VoiceInputState.Starting, manager.state.value) + } + + @Test + fun `isAvailable returns mocked value`() { + assertTrue(manager.isAvailable) + } + + // ========== Test Case: Network Fast-Fail ========== + + @Test + fun `startListening fails immediately when no network`() { + every { connectivityManager.activeNetwork } returns null + + manager.startListening() + idleMainLooper() + + assertTrue(manager.state.value is VoiceInputState.Error) + assertEquals(R.string.voice_error_network, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `startListening fails immediately when no network capabilities`() { + every { connectivityManager.getNetworkCapabilities(network) } returns null + + manager.startListening() + idleMainLooper() + + assertTrue(manager.state.value is VoiceInputState.Error) + assertEquals(R.string.voice_error_network, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `startListening fails immediately when no internet capability`() { + every { networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } returns false + + manager.startListening() + idleMainLooper() + + assertTrue(manager.state.value is VoiceInputState.Error) + assertEquals(R.string.voice_error_network, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `network error from fast-fail is retryable`() { + every { connectivityManager.activeNetwork } returns null + + manager.startListening() + idleMainLooper() + + assertTrue((manager.state.value as VoiceInputState.Error).isRetryable) + } + + @Test + fun `startListening does not create recognizer when no network`() { + every { connectivityManager.activeNetwork } returns null + + manager.startListening() + idleMainLooper() + + verify(exactly = 0) { SpeechRecognizer.createSpeechRecognizer(any()) } + } + + // ========== Test Case: Audio Focus ========== + + @Test + fun `startListening fails when audio focus not granted`() { + every { audioManager.requestAudioFocus(any()) } returns AudioManager.AUDIOFOCUS_REQUEST_FAILED + + manager.startListening() + idleMainLooper() + + assertTrue(manager.state.value is VoiceInputState.Error) + assertEquals(R.string.voice_error_audio, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `audio focus error is retryable`() { + every { audioManager.requestAudioFocus(any()) } returns AudioManager.AUDIOFOCUS_REQUEST_FAILED + + manager.startListening() + idleMainLooper() + + assertTrue((manager.state.value as VoiceInputState.Error).isRetryable) + } + + @Test + fun `startListening does not create recognizer when audio focus denied`() { + every { audioManager.requestAudioFocus(any()) } returns AudioManager.AUDIOFOCUS_REQUEST_FAILED + + manager.startListening() + idleMainLooper() + + verify(exactly = 0) { SpeechRecognizer.createSpeechRecognizer(any()) } + } + + @Test + fun `audio focus is abandoned when recognizer is destroyed`() { + manager.startListening() + idleMainLooper() + + manager.close() + + verify { audioManager.abandonAudioFocusRequest(any()) } + } + + @Test + fun `startListening requests audio focus`() { + manager.startListening() + idleMainLooper() + + verify { audioManager.requestAudioFocus(any()) } + } + + // ========== Helper Functions ========== + + private fun createResultsBundle(text: String): Bundle = + Bundle().apply { + putStringArrayList( + SpeechRecognizer.RESULTS_RECOGNITION, + arrayListOf(text), + ) + } +} + +private typealias CapturingSlot = io.mockk.CapturingSlot diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestVoiceInputManagerAutoFocus.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestVoiceInputManagerAutoFocus.kt new file mode 100644 index 00000000..c046eea2 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestVoiceInputManagerAutoFocus.kt @@ -0,0 +1,132 @@ +package com.github.damontecres.wholphin.test + +import android.app.Activity +import android.content.Context +import android.media.AudioFocusRequest +import android.media.AudioManager +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.os.Looper +import android.speech.RecognitionListener +import android.speech.SpeechRecognizer +import com.github.damontecres.wholphin.ui.components.VoiceInputManager +import com.github.damontecres.wholphin.ui.components.VoiceInputState +import io.mockk.CapturingSlot +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.unmockkStatic +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [28]) +class TestVoiceInputManagerAutoFocus { + private lateinit var activity: Activity + private lateinit var speechRecognizer: SpeechRecognizer + private lateinit var manager: VoiceInputManager + private lateinit var audioManager: AudioManager + private lateinit var connectivityManager: ConnectivityManager + private lateinit var listenerSlot: CapturingSlot + + // We need to capture the OnAudioFocusChangeListener passed to AudioFocusRequest + private val focusRequestSlot = slot() + + private val capturedListener: RecognitionListener + get() = listenerSlot.captured + + private fun idleMainLooper() = shadowOf(Looper.getMainLooper()).idle() + + @Before + fun setup() { + activity = mockk(relaxed = true) + audioManager = mockk(relaxed = true) + connectivityManager = mockk(relaxed = true) + + // Mock network availability + val mockNetwork = mockk() + val mockCapabilities = mockk() + every { connectivityManager.activeNetwork } returns mockNetwork + every { connectivityManager.getNetworkCapabilities(mockNetwork) } returns mockCapabilities + every { mockCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } returns true + + // Capture the AudioFocusRequest built by the specific line in VoiceInputManager + every { audioManager.requestAudioFocus(capture(focusRequestSlot)) } returns AudioManager.AUDIOFOCUS_REQUEST_GRANTED + every { activity.getSystemService(Context.AUDIO_SERVICE) } returns audioManager + every { activity.getSystemService(Context.CONNECTIVITY_SERVICE) } returns connectivityManager + + speechRecognizer = mockk(relaxed = true) + listenerSlot = slot() + every { speechRecognizer.setRecognitionListener(capture(listenerSlot)) } just Runs + + mockkStatic(SpeechRecognizer::class) + every { SpeechRecognizer.createSpeechRecognizer(activity) } returns speechRecognizer + every { SpeechRecognizer.isRecognitionAvailable(activity) } returns true + + manager = VoiceInputManager(activity) + } + + @After + fun teardown() { + unmockkStatic(SpeechRecognizer::class) + } + + @Test + fun `transient audio focus loss is ignored`() { + // Start listening + manager.startListening() + idleMainLooper() + capturedListener.onReadyForSpeech(null) + idleMainLooper() + assertEquals(VoiceInputState.Listening, manager.state.value) + + // Verify requestAudioFocus was called + assertTrue(focusRequestSlot.isCaptured) + + // Get listener via reflection since AudioFocusRequest doesn't expose it + val field = VoiceInputManager::class.java.getDeclaredField("audioFocusListener") + field.isAccessible = true + val listener = field.get(manager) as AudioManager.OnAudioFocusChangeListener + + // Invoke onAudioFocusChange directly on the listener + listener.onAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS_TRANSIENT) + + idleMainLooper() + + // Assert state is STILL Listening (Logic correctly ignores it) + assertEquals(VoiceInputState.Listening, manager.state.value) + } + + @Test + fun `permanent audio focus loss stops listening`() { + // Start listening + manager.startListening() + idleMainLooper() + capturedListener.onReadyForSpeech(null) + idleMainLooper() + + // Get listener via reflection since AudioFocusRequest doesn't expose it + val field = VoiceInputManager::class.java.getDeclaredField("audioFocusListener") + field.isAccessible = true + val listener = field.get(manager) as AudioManager.OnAudioFocusChangeListener + + // Invoke onAudioFocusChange directly on the listener + listener.onAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS) + + idleMainLooper() + + // Assert state is NOW Idle (Logic correctly stops) + assertEquals(VoiceInputState.Idle, manager.state.value) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c1b0676f..ae490672 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,7 @@ coreKtx = "1.17.0" appcompat = "1.7.1" composeBom = "2025.12.01" mockk = "1.14.7" +robolectric = "4.14.1" multiplatformMarkdownRenderer = "0.39.0" okhttpBom = "5.3.2" programguide = "1.6.0" @@ -79,6 +80,7 @@ hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", ve kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" } mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" } mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } multiplatform-markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "multiplatformMarkdownRenderer" } multiplatform-markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "multiplatformMarkdownRenderer" } okhttp = { module = "com.squareup.okhttp3:okhttp" }