[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 <damontecres@gmail.com>
This commit is contained in:
Justin Caveda 2026-01-14 16:06:22 -06:00 committed by GitHub
parent c4e64c3367
commit d725821011
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1921 additions and 52 deletions

View file

@ -294,4 +294,5 @@ dependencies {
testImplementation(libs.mockk.android)
testImplementation(libs.mockk.agent)
testImplementation(libs.robolectric)
}

View file

@ -24,6 +24,13 @@
android:name="android.hardware.microphone"
android:required="false" />
<!-- Required for Android 11+ to query voice recognition availability -->
<queries>
<intent>
<action android:name="android.speech.action.RECOGNIZE_SPEECH" />
</intent>
</queries>
<application
android:allowBackup="true"
android:banner="@mipmap/ic_banner"

View file

@ -0,0 +1,442 @@
package com.github.damontecres.wholphin.ui.components
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.speech.RecognitionListener
import android.speech.RecognizerIntent
import android.speech.SpeechRecognizer
import androidx.core.content.ContextCompat
import com.github.damontecres.wholphin.R
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.android.asCoroutineDispatcher
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber
import java.io.Closeable
import javax.inject.Inject
import javax.inject.Singleton
private const val RMS_DB_MIN = -2.0f
private const val RMS_DB_MAX = 10.0f
private const val MAX_RESULTS = 1
private const val LISTENING_TIMEOUT_MS = 8000L
private const val RECOGNIZER_RECREATE_DELAY_MS = 150L
private val ERROR_TO_RESOURCE_MAP =
mapOf(
SpeechRecognizer.ERROR_AUDIO to R.string.voice_error_audio,
SpeechRecognizer.ERROR_CLIENT to R.string.voice_error_client,
SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS to R.string.voice_error_permissions,
SpeechRecognizer.ERROR_NETWORK to R.string.voice_error_network,
SpeechRecognizer.ERROR_NETWORK_TIMEOUT to R.string.voice_error_network_timeout,
SpeechRecognizer.ERROR_NO_MATCH to R.string.voice_error_no_match,
SpeechRecognizer.ERROR_RECOGNIZER_BUSY to R.string.voice_error_busy,
SpeechRecognizer.ERROR_SERVER to R.string.voice_error_server,
SpeechRecognizer.ERROR_SPEECH_TIMEOUT to R.string.voice_error_speech_timeout,
SpeechRecognizer.ERROR_TOO_MANY_REQUESTS to R.string.voice_error_busy,
)
private val RETRYABLE_ERRORS =
setOf(
SpeechRecognizer.ERROR_NETWORK,
SpeechRecognizer.ERROR_NETWORK_TIMEOUT,
SpeechRecognizer.ERROR_SERVER,
SpeechRecognizer.ERROR_NO_MATCH,
SpeechRecognizer.ERROR_SPEECH_TIMEOUT,
)
private fun normalizeRmsDb(rmsdB: Float) = ((rmsdB - RMS_DB_MIN) / (RMS_DB_MAX - RMS_DB_MIN)).coerceIn(0f, 1f)
sealed interface VoiceInputState {
data object Idle : VoiceInputState
/** Recognizer is being initialized, not yet ready for speech. */
data object Starting : VoiceInputState
data object Listening : VoiceInputState
data object Processing : VoiceInputState
data class Result(
val text: String,
) : VoiceInputState
data class Error(
val messageResId: Int,
val isRetryable: Boolean,
) : VoiceInputState
}
@Singleton
class VoiceInputManager
@Inject
constructor(
@ApplicationContext private val context: Context,
) : Closeable {
private val handler = Handler(Looper.getMainLooper())
private val mainDispatcher = provideMainDispatcher()
private val scope = CoroutineScope(mainDispatcher + SupervisorJob())
private val mutex = Mutex()
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
private val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
private val audioFocusListener =
AudioManager.OnAudioFocusChangeListener { focusChange ->
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>(VoiceInputState.Idle)
val state: StateFlow<VoiceInputState> = _state.asStateFlow()
private val _soundLevel = MutableStateFlow(0f)
val soundLevel: StateFlow<Float> = _soundLevel.asStateFlow()
private val _partialResult = MutableStateFlow("")
val partialResult: StateFlow<String> = _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
}
}

View file

@ -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<String, String> {
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),
)
}
}
}

View file

@ -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>(SearchResult.NoQuery)
val series = MutableLiveData<SearchResult>(SearchResult.NoQuery)
val episodes = MutableLiveData<SearchResult>(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<String?>(null) }
LifecycleResumeEffect(Unit) {
onPauseOrDispose {
viewModel.voiceInputManager.stopListening()
}
}
fun triggerImmediateSearch(searchQuery: String) {
immediateSearchQuery = searchQuery
searchClicked = true
viewModel.search(searchQuery)
}
LaunchedEffect(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) {
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,

View file

@ -45,6 +45,7 @@
<string name="fa_ellipsis_vertical" translatable="false">&#xf142;</string>
<string name="fa_filter" translatable="false">&#xf0b0;</string>
<string name="fa_sliders" translatable="false">&#xf1de;</string>
<string name="fa_microphone" translatable="false">&#xf130;</string>
<string name="fa_download" translatable="false">&#xf019;</string>
<string name="fa_clock" translatable="false">&#xf017;</string>
<string name="fa_bell" translatable="false">&#xf0f3;</string>

View file

@ -105,6 +105,24 @@
<string name="search_and_download"><![CDATA[Search & Download]]></string>
<string name="search">Search</string>
<string name="searching">Searching…</string>
<string name="voice_search">Voice search</string>
<string name="voice_starting">Starting</string>
<string name="voice_search_prompt">Speak to search</string>
<string name="processing">Processing</string>
<string name="press_back_to_cancel">Press back to cancel</string>
<string name="voice_error_audio">Audio recording error</string>
<string name="voice_error_client">Voice recognition error</string>
<string name="voice_error_permissions">Microphone permission required</string>
<string name="voice_error_network">Network error</string>
<string name="voice_error_network_timeout">Network timeout</string>
<string name="voice_error_no_match">No speech recognized</string>
<string name="voice_error_busy">Voice recognition busy</string>
<string name="voice_error_server">Server error</string>
<string name="voice_error_speech_timeout">No speech detected</string>
<string name="voice_error_unknown">Unknown error</string>
<string name="voice_error_start_failed">Failed to start voice recognition</string>
<string name="voice_error_timeout">Voice recognition timed out</string>
<string name="retry">Retry</string>
<string name="select_server">Select Server</string>
<string name="select_user">Select User</string>
<string name="show_debug_info">Show debug info</string>

View file

@ -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<RecognitionListener>
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<AudioFocusRequest>()) } 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<Intent>()) }
}
@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<SpeechRecognizer>(relaxed = true)
val secondRecognizer = mockk<SpeechRecognizer>(relaxed = true)
val firstListenerSlot = slot<RecognitionListener>()
val secondListenerSlot = slot<RecognitionListener>()
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<AudioFocusRequest>()) } 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<AudioFocusRequest>()) } 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<AudioFocusRequest>()) } 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<AudioFocusRequest>()) }
}
// ========== Helper Functions ==========
private fun createResultsBundle(text: String): Bundle =
Bundle().apply {
putStringArrayList(
SpeechRecognizer.RESULTS_RECOGNITION,
arrayListOf(text),
)
}
}
private typealias CapturingSlot<T> = io.mockk.CapturingSlot<T>

View file

@ -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<RecognitionListener>
// We need to capture the OnAudioFocusChangeListener passed to AudioFocusRequest
private val focusRequestSlot = slot<AudioFocusRequest>()
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<Network>()
val mockCapabilities = mockk<NetworkCapabilities>()
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)
}
}

View file

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