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