VoiceSearchDialog.kt
Each line, with the release that last changed it.
| 0.5.0 | 1 | package dev.stave.ui.components |
| 2 | ||
| 3 | import android.content.Context | |
| 4 | import android.content.Intent | |
| 5 | import android.speech.RecognizerIntent | |
| 6 | import androidx.activity.compose.BackHandler | |
| 7 | import androidx.activity.compose.rememberLauncherForActivityResult | |
| 8 | import androidx.activity.result.ActivityResult | |
| 9 | import androidx.activity.result.contract.ActivityResultContracts | |
| 10 | import androidx.compose.foundation.background | |
| 11 | import androidx.compose.foundation.clickable | |
| 12 | import androidx.compose.foundation.interaction.MutableInteractionSource | |
| 13 | import androidx.compose.foundation.interaction.collectIsFocusedAsState | |
| 14 | import androidx.compose.foundation.layout.Arrangement | |
| 15 | import androidx.compose.foundation.layout.Box | |
| 16 | import androidx.compose.foundation.layout.Column | |
| 17 | import androidx.compose.foundation.layout.Row | |
| 18 | import androidx.compose.foundation.layout.Spacer | |
| 19 | import androidx.compose.foundation.layout.fillMaxSize | |
| 20 | import androidx.compose.foundation.layout.fillMaxWidth | |
| 21 | import androidx.compose.foundation.layout.height | |
| 22 | import androidx.compose.foundation.layout.padding | |
| 23 | import androidx.compose.foundation.layout.widthIn | |
| 24 | import androidx.compose.foundation.shape.RoundedCornerShape | |
| 25 | import androidx.compose.runtime.Composable | |
| 26 | import androidx.compose.runtime.LaunchedEffect | |
| 27 | import androidx.compose.runtime.getValue | |
| 28 | import androidx.compose.runtime.mutableStateOf | |
| 29 | import androidx.compose.runtime.remember | |
| 30 | import androidx.compose.runtime.setValue | |
| 31 | import androidx.compose.ui.Alignment | |
| 32 | import androidx.compose.ui.Modifier | |
| 33 | import androidx.compose.ui.draw.clip | |
| 34 | import androidx.compose.ui.focus.FocusRequester | |
| 35 | import androidx.compose.ui.focus.focusRequester | |
| 36 | import androidx.compose.ui.graphics.Color | |
| 37 | import androidx.compose.ui.input.key.Key | |
| 38 | import androidx.compose.ui.input.key.KeyEventType | |
| 39 | import androidx.compose.ui.input.key.key | |
| 40 | import androidx.compose.ui.input.key.onPreviewKeyEvent | |
| 41 | import androidx.compose.ui.input.key.type | |
| 42 | import androidx.compose.ui.platform.LocalContext | |
| 43 | import androidx.compose.ui.text.style.TextAlign | |
| 44 | import androidx.compose.ui.unit.dp | |
| 45 | import androidx.tv.material3.MaterialTheme | |
| 46 | import androidx.tv.material3.Text | |
| 47 | import dev.stave.ui.theme.AccentAmber | |
| 48 | import dev.stave.ui.theme.OnAmber | |
| 49 | import dev.stave.ui.theme.OnMuted | |
| 50 | import dev.stave.ui.theme.OnPrimary | |
| 51 | import dev.stave.ui.theme.SurfaceRaised | |
| 52 | import dev.stave.ui.theme.withInterDigits | |
| 53 | ||
| 54 | /** | |
| 55 | * Voice search dialog — system-intent path. | |
| 56 | * | |
| 57 | * Earlier iterations used the in-process [android.speech.SpeechRecognizer] | |
| 58 | * with our own listening UI. That path required RECORD_AUDIO permission, | |
| 59 | * required an on-device or third-party recognizer service to be installed, | |
| 60 | * and was brittle on TV — particularly Shield, where the in-process binding | |
| 61 | * would either fail outright or hang silently. | |
| 62 | * | |
| 63 | * This implementation replaces all of that with the system | |
| 64 | * [RecognizerIntent.ACTION_RECOGNIZE_SPEECH] activity intent — exactly the | |
| 65 | * code path the system keyboard's mic button uses. The system shows its own | |
| 66 | * listening UI; capture happens in the system's process, not ours; we get | |
| 67 | * back a transcribed string via the activity result. RECORD_AUDIO is no | |
| 68 | * longer required for this path. | |
| 69 | * | |
| 70 | * UI lifecycle: | |
| 71 | * 1. On first composition, fire the intent via | |
| 72 | * [rememberLauncherForActivityResult]. While the system UI is presenting, | |
| 73 | * we render a thin scrim (the user mostly only sees this in the rare | |
| 74 | * case where the system UI takes time to draw). | |
| 75 | * 2. On intent return: extract [RecognizerIntent.EXTRA_RESULTS], take the | |
| 76 | * first non-blank entry, call [onResult] with it. Empty / cancelled → | |
| 77 | * [onDismiss]. | |
| 78 | * 3. If no recognizer is installed, the launcher returns immediately with | |
| 79 | * a generic failure; we render the [UnavailableCard] so the user knows | |
| 80 | * to install a recognizer (typically the "Google" app on Android TV) | |
| 81 | * rather than wondering why the dialog flickered. | |
| 82 | * | |
| 83 | * Privacy / offline guarantees: this path uses whatever recognizer the system | |
| 84 | * picks. On modern Android TV with Google's recognizer that may be on-device; | |
| 85 | * on older devices it may go to network. The previous "guaranteed offline" | |
| 86 | * promise from the in-process path no longer holds — but that path didn't | |
| 87 | * actually deliver in practice (no on-device model + no working third-party | |
| 88 | * service = nothing recognized). The trade-off is reliability over privacy | |
| 89 | * here, matching what the user actually experiences elsewhere on the device. | |
| 90 | */ | |
| 91 | @Composable | |
| 92 | fun VoiceSearchDialog( | |
| 93 | onResult: (String) -> Unit, | |
| 94 | onDismiss: () -> Unit, | |
| 95 | ) { | |
| 96 | val context = LocalContext.current | |
| 97 | var failed by remember { mutableStateOf(false) } | |
| 98 | var launched by remember { mutableStateOf(false) } | |
| 99 | ||
| 100 | val launcher = rememberLauncherForActivityResult( | |
| 101 | contract = ActivityResultContracts.StartActivityForResult(), | |
| 102 | ) { result: ActivityResult -> | |
| 103 | // Result handling. EXTRA_RESULTS holds an ArrayList<String> of candidate | |
| 104 | // transcriptions ordered by confidence. We take the first non-blank one. | |
| 105 | val data: Intent? = result.data | |
| 106 | val matches = data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS) | |
| 107 | val top = matches?.firstOrNull { it.isNotBlank() }?.trim() | |
| 108 | if (!top.isNullOrBlank()) { | |
| 109 | onResult(top) | |
| 110 | } else { | |
| 111 | // Cancelled, no match, or recognizer dialog dismissed without speech. | |
| 112 | // Treat all as "user gave up" — close the dialog cleanly. | |
| 113 | onDismiss() | |
| 114 | } | |
| 115 | } | |
| 116 | ||
| 117 | // Fire the intent exactly once. LaunchedEffect with Unit so a recomposition | |
| 118 | // doesn't re-launch (which would stack a second system dialog on top of the | |
| 119 | // first on devices that handle the intent slowly). If the intent can't be | |
| 120 | // resolved at all, surface the unavailable state so the user gets a real | |
| 121 | // explanation instead of the dialog silently doing nothing. | |
| 122 | LaunchedEffect(Unit) { | |
| 123 | if (launched) return@LaunchedEffect | |
| 124 | launched = true | |
| 125 | val intent = buildRecognizerIntent(context) | |
| 126 | val canResolve = intent.resolveActivity(context.packageManager) != null | |
| 127 | if (!canResolve) { | |
| 128 | failed = true | |
| 129 | return@LaunchedEffect | |
| 130 | } | |
| 131 | runCatching { launcher.launch(intent) } | |
| 132 | .onFailure { failed = true } | |
| 133 | } | |
| 134 | ||
| 135 | BackHandler(onBack = onDismiss) | |
| 136 | ||
| 137 | if (failed) { | |
| 138 | UnavailableCard(onDismiss = onDismiss) | |
| 139 | } else { | |
| 140 | // Thin scrim only — the system's own dialog is layered on top so we | |
| 141 | // shouldn't put any chrome here that competes with it. Black at low | |
| 142 | // alpha so the underlying screen dims slightly during the brief | |
| 143 | // window before the system UI takes over. | |
| 144 | Box( | |
| 145 | Modifier | |
| 146 | .fillMaxSize() | |
| 147 | .background(Color.Black.copy(alpha = 0.5f)) | |
| 148 | .clickable( | |
| 149 | interactionSource = remember { MutableInteractionSource() }, | |
| 150 | indication = null, | |
| 151 | onClick = { /* swallow */ }, | |
| 152 | ), | |
| 153 | ) | |
| 154 | } | |
| 155 | } | |
| 156 | ||
| 157 | /** | |
| 158 | * Build the recognizer intent used by [VoiceSearchDialog] and any future | |
| 159 | * caller that wants the same configuration. | |
| 160 | * | |
| 161 | * - LANGUAGE_MODEL_FREE_FORM — full sentences, not short commands. | |
| 162 | * - EXTRA_PROMPT — what the system shows above its listening UI. | |
| 163 | * - EXTRA_CALLING_PACKAGE — some recognizer implementations gate on this. | |
| 164 | * - EXTRA_PREFER_OFFLINE — hint only; the system decides. | |
| 165 | * | |
| 166 | * EXTRA_MAX_RESULTS is left at the default (1). We always take the top result. | |
| 167 | */ | |
| 168 | private fun buildRecognizerIntent(context: Context): Intent = | |
| 169 | Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { | |
| 170 | putExtra( | |
| 171 | RecognizerIntent.EXTRA_LANGUAGE_MODEL, | |
| 172 | RecognizerIntent.LANGUAGE_MODEL_FREE_FORM, | |
| 173 | ) | |
| 174 | putExtra(RecognizerIntent.EXTRA_PROMPT, "Search your library") | |
| 175 | putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, context.packageName) | |
| 176 | putExtra(RecognizerIntent.EXTRA_PREFER_OFFLINE, true) | |
| 177 | } | |
| 178 | ||
| 179 | /** | |
| 180 | * Returns true iff the system can resolve [RecognizerIntent.ACTION_RECOGNIZE_SPEECH]. | |
| 181 | * | |
| 182 | * Used by the keyboard owner (BrowseScreen) to decide whether to render the | |
| 183 | * mic key at all. Cheap — a single PackageManager query — and accurate for | |
| 184 | * this path: if resolveActivity returns null, the launcher.launch above will | |
| 185 | * fail too. Compares favorably to the old SpeechRecognizer.isRecognitionAvailable | |
| 186 | * call which often disagreed with reality on TV builds. | |
| 187 | */ | |
| 188 | fun voiceSearchAvailable(context: Context): Boolean = | |
| 189 | Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).resolveActivity(context.packageManager) != null | |
| 190 | ||
| 191 | @Composable | |
| 192 | private fun UnavailableCard(onDismiss: () -> Unit) { | |
| 193 | BackHandler(onBack = onDismiss) | |
| 194 | Box( | |
| 195 | Modifier | |
| 196 | .fillMaxSize() | |
| 197 | .background(Color.Black.copy(alpha = 0.75f)), | |
| 198 | contentAlignment = Alignment.Center, | |
| 199 | ) { | |
| 200 | Column( | |
| 201 | Modifier | |
| 202 | .fillMaxWidth(0.92f) | |
| 203 | .widthIn(max = 600.dp) | |
| 204 | .clip(RoundedCornerShape(16.dp)) | |
| 205 | .background(SurfaceRaised) | |
| 206 | .padding(horizontal = 32.dp, vertical = 28.dp), | |
| 207 | horizontalAlignment = Alignment.CenterHorizontally, | |
| 208 | ) { | |
| 209 | Text( | |
| 210 | "Voice unavailable", | |
| 211 | style = MaterialTheme.typography.titleLarge, | |
| 212 | color = OnPrimary, | |
| 213 | ) | |
| 214 | Spacer(Modifier.height(12.dp)) | |
| 215 | Text( | |
| 216 | "No speech recognizer is installed on this device. " + | |
| 217 | "Install Google or another voice-input app to use voice search.", | |
| 218 | style = MaterialTheme.typography.bodyMedium, | |
| 219 | color = OnMuted, | |
| 220 | textAlign = TextAlign.Center, | |
| 221 | ) | |
| 222 | Spacer(Modifier.height(24.dp)) | |
| 223 | Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { | |
| 224 | DialogButton("Close", onDismiss, initialFocus = true) | |
| 225 | } | |
| 226 | } | |
| 227 | } | |
| 228 | } | |
| 229 | ||
| 230 | @Composable | |
| 231 | private fun DialogButton( | |
| 232 | label: String, | |
| 233 | onClick: () -> Unit, | |
| 234 | initialFocus: Boolean, | |
| 235 | ) { | |
| 236 | val focusRequester = remember { FocusRequester() } | |
| 237 | val interaction = remember { MutableInteractionSource() } | |
| 238 | val focused by interaction.collectIsFocusedAsState() | |
| 239 | LaunchedEffect(initialFocus) { | |
| 240 | if (initialFocus) runCatching { focusRequester.requestFocus() } | |
| 241 | } | |
| 242 | Box( | |
| 243 | Modifier | |
| 244 | .clip(RoundedCornerShape(4.dp)) | |
| 245 | .background(if (focused) AccentAmber else SurfaceRaised) | |
| 246 | .focusRequester(focusRequester) | |
| 247 | .onPreviewKeyEvent { ev -> | |
| 248 | if (ev.type == KeyEventType.KeyDown && | |
| 249 | (ev.key == Key.Enter || | |
| 250 | ev.key == Key.NumPadEnter || | |
| 251 | ev.key == Key.DirectionCenter) | |
| 252 | ) { | |
| 253 | onClick() | |
| 254 | true | |
| 255 | } else false | |
| 256 | } | |
| 257 | .clickable(interaction, indication = null) { onClick() } | |
| 258 | .padding(horizontal = 24.dp, vertical = 12.dp), | |
| 259 | ) { | |
| 260 | Text( | |
| 261 | label, | |
| 262 | style = MaterialTheme.typography.labelLarge, | |
| 263 | color = if (focused) OnAmber else OnPrimary, | |
| 264 | ) | |
| 265 | } | |
| 266 | } |


