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" }