VoiceSearchDialog.kt
266 lines · 11.2 KB
Changed in: 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 | } |


