EARHORNMUSIC UPnP · Android TV · Phones · Android Auto · GNU/Linux
Release 0.5.0

VoiceSearchDialog.kt

266 lines · 11.2 KB

Changed in: 0.5.0

1package dev.stave.ui.components
2
3import android.content.Context
4import android.content.Intent
5import android.speech.RecognizerIntent
6import androidx.activity.compose.BackHandler
7import androidx.activity.compose.rememberLauncherForActivityResult
8import androidx.activity.result.ActivityResult
9import androidx.activity.result.contract.ActivityResultContracts
10import androidx.compose.foundation.background
11import androidx.compose.foundation.clickable
12import androidx.compose.foundation.interaction.MutableInteractionSource
13import androidx.compose.foundation.interaction.collectIsFocusedAsState
14import androidx.compose.foundation.layout.Arrangement
15import androidx.compose.foundation.layout.Box
16import androidx.compose.foundation.layout.Column
17import androidx.compose.foundation.layout.Row
18import androidx.compose.foundation.layout.Spacer
19import androidx.compose.foundation.layout.fillMaxSize
20import androidx.compose.foundation.layout.fillMaxWidth
21import androidx.compose.foundation.layout.height
22import androidx.compose.foundation.layout.padding
23import androidx.compose.foundation.layout.widthIn
24import androidx.compose.foundation.shape.RoundedCornerShape
25import androidx.compose.runtime.Composable
26import androidx.compose.runtime.LaunchedEffect
27import androidx.compose.runtime.getValue
28import androidx.compose.runtime.mutableStateOf
29import androidx.compose.runtime.remember
30import androidx.compose.runtime.setValue
31import androidx.compose.ui.Alignment
32import androidx.compose.ui.Modifier
33import androidx.compose.ui.draw.clip
34import androidx.compose.ui.focus.FocusRequester
35import androidx.compose.ui.focus.focusRequester
36import androidx.compose.ui.graphics.Color
37import androidx.compose.ui.input.key.Key
38import androidx.compose.ui.input.key.KeyEventType
39import androidx.compose.ui.input.key.key
40import androidx.compose.ui.input.key.onPreviewKeyEvent
41import androidx.compose.ui.input.key.type
42import androidx.compose.ui.platform.LocalContext
43import androidx.compose.ui.text.style.TextAlign
44import androidx.compose.ui.unit.dp
45import androidx.tv.material3.MaterialTheme
46import androidx.tv.material3.Text
47import dev.stave.ui.theme.AccentAmber
48import dev.stave.ui.theme.OnAmber
49import dev.stave.ui.theme.OnMuted
50import dev.stave.ui.theme.OnPrimary
51import dev.stave.ui.theme.SurfaceRaised
52import 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
92fun 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 */
168private 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 */
188fun voiceSearchAvailable(context: Context): Boolean =
189 Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).resolveActivity(context.packageManager) != null
190
191@Composable
192private 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
231private 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}