From c029a0894a3d4655f04b7a5d5d743825fc6de195 Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Tue, 13 Oct 2026 16:56:56 -0700 Subject: [PATCH] voice: one button downloads Kroko and Canary in setup, with progress on Home and in a silent notification --- .../gearslip/speech/SpeechModels.kt | 82 +++++++++++++++++++ .../seb3thehacker/gearslip/ui/HomeScreen.kt | 19 +++++ .../seb3thehacker/gearslip/ui/IntroScreen.kt | 4 +- .../gearslip/ui/SetupGuideScreen.kt | 42 ++++++++++ 4 files changed, 145 insertions(+), 2 deletions(-) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/speech/SpeechModels.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/speech/SpeechModels.kt index 3546aed..07d011d 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/speech/SpeechModels.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/speech/SpeechModels.kt @@ -1,6 +1,15 @@ package app.seb3thehacker.gearslip.speech +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import app.seb3thehacker.gearslip.GearslipActivity import app.seb3thehacker.gearslip.GearslipLog import java.io.File import java.net.HttpURLConnection @@ -67,6 +76,11 @@ object SpeechModels { val files get() = listOf(encoder, decoder, joiner, "tokens.txt").filter { it.isNotEmpty() } } + /** What setup offers in one tap: Kroko for the live words, Canary for the final ones. */ + val RECOMMENDED = listOf(Model.KROKO, Model.CANARY) + + fun downloadRecommended(context: Context) = RECOMMENDED.forEach { download(context, it) } + sealed interface State { data object Missing : State data class Downloading(val percent: Int) : State @@ -77,6 +91,8 @@ object SpeechModels { private const val PREFS = "speech" private const val KEY_MODEL = "model" private const val KEY_FINAL = "final_model" + private const val CHANNEL = "voice_download" + private const val NOTIFICATION_ID = 4_312 private val worker = Executors.newSingleThreadExecutor { Thread(it, "gearslip-speech-download") } private val states = Model.entries.associateWith { MutableStateFlow(State.Missing) } @@ -106,6 +122,66 @@ object SpeechModels { fun state(model: Model): StateFlow = states.getValue(model).asStateFlow() + /** + * The models fetched since the last batch finished, so the percent covers all of them and + * doesn't jump back to 0 when the second one starts. + */ + private val batch = mutableSetOf() + private val _progress = MutableStateFlow(null) + + /** Percent through the current downloads, for Home and the notification; null when none run. */ + val progress: StateFlow = _progress.asStateFlow() + + private fun updateProgress(context: Context) { + val percent = synchronized(batch) { + if (batch.none { states.getValue(it).value is State.Downloading }) { + batch.clear() + null + } else { + val done = batch.sumOf { m -> + m.downloadMb * when (val st = states.getValue(m).value) { + is State.Downloading -> st.percent + State.Ready -> 100 + else -> 0 + } + } + done / batch.sumOf { it.downloadMb } + } + } + if (percent == _progress.value) return + _progress.value = percent + showProgress(context, percent) + } + + /** A silent notification with a progress bar, so the download is visible with the app closed. */ + private fun showProgress(context: Context, percent: Int?) { + val manager = NotificationManagerCompat.from(context) + if (percent == null) { + manager.cancel(NOTIFICATION_ID) + return + } + if (context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) return + context.getSystemService(NotificationManager::class.java).createNotificationChannel( + NotificationChannel(CHANNEL, "Voice download", NotificationManager.IMPORTANCE_LOW), + ) + val open = PendingIntent.getActivity( + context, NOTIFICATION_ID, + Intent(context, GearslipActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP), + PendingIntent.FLAG_IMMUTABLE, + ) + val notification = NotificationCompat.Builder(context, CHANNEL) + .setSmallIcon(android.R.drawable.stat_sys_download) + .setContentTitle("Downloading voice") + .setContentText("$percent%") + .setProgress(100, percent, false) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setSilent(true) + .setContentIntent(open) + .build() + runCatching { manager.notify(NOTIFICATION_ID, notification) } + } + fun dir(context: Context, model: Model) = File(File(context.filesDir, "speech"), model.id) fun isReady(context: Context, model: Model) = model.files.all { File(dir(context, model), it).isFile } @@ -128,6 +204,7 @@ object SpeechModels { cancelled = model dir(context, model).deleteRecursively() states.getValue(model).value = State.Missing + updateProgress(context.applicationContext) if (model == _selected.value || model == _final.value) SpeechEngine.unload() } @@ -137,6 +214,8 @@ object SpeechModels { val state = states.getValue(model) if (state.value is State.Downloading || state.value == State.Ready) return state.value = State.Downloading(0) + synchronized(batch) { batch.add(model) } + updateProgress(app) cancelled = null worker.execute { val target = dir(app, model) @@ -147,6 +226,7 @@ object SpeechModels { fun progress(read: Long) { if (cancelled == model) throw InterruptedException("cancelled") state.value = State.Downloading((((done + read) * 100) / total).toInt().coerceIn(0, 99)) + updateProgress(app) } if (model.plainFiles) { for (name in model.files) { @@ -176,6 +256,7 @@ object SpeechModels { check(partial.renameTo(target)) { "couldn't store the model" } GearslipLog.i("speech: downloaded ${model.label}") state.value = State.Ready + updateProgress(app) } catch (e: Exception) { partial.deleteRecursively() if (cancelled == model) { @@ -184,6 +265,7 @@ object SpeechModels { GearslipLog.w("speech: download of ${model.label} failed: ${e.message}") state.value = State.Failed(e.message ?: e.javaClass.simpleName) } + updateProgress(app) } } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt index 2f0dcad..8e228b8 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt @@ -1,5 +1,9 @@ package app.seb3thehacker.gearslip.ui +import app.seb3thehacker.gearslip.speech.SpeechModels + +import androidx.compose.material3.LinearProgressIndicator + import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -100,6 +104,7 @@ fun HomeScreen( ) Spacer(Modifier.height(16.dp)) StatusCard(status) + VoiceDownloadCard() // The first drive that worked asks once to share it, right under the result. val car = workedCar if (car != null && !live) { @@ -142,6 +147,20 @@ fun HomeScreen( } } +/** While the voice models download, how far along they are; nothing otherwise. */ +@Composable +private fun VoiceDownloadCard() { + val progress by SpeechModels.progress.collectAsStateWithLifecycle() + val percent = progress ?: return + Spacer(Modifier.height(16.dp)) + SettingsCard { + Column(Modifier.padding(horizontal = 16.dp, vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("Downloading voice, $percent%", style = MaterialTheme.typography.bodyLarge) + LinearProgressIndicator(progress = { percent / 100f }, modifier = Modifier.fillMaxWidth()) + } + } +} + @Composable private fun Arrow() = Icon( Icons.AutoMirrored.Filled.KeyboardArrowRight, diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/IntroScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/IntroScreen.kt index cd0c4f8..35a8e9d 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/IntroScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/IntroScreen.kt @@ -409,7 +409,7 @@ private fun BadgeStep( private fun VoiceStep(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUnit, key: IntroStep) { val context = LocalContext.current remember { SpeechModels.init(context) } - val needed = listOf(SpeechModels.Model.KROKO, SpeechModels.Model.CANARY) + val needed = SpeechModels.RECOMMENDED val states = needed.map { SpeechModels.state(it).collectAsState().value } val ready = states.all { it == SpeechModels.State.Ready } val label = when { @@ -423,7 +423,7 @@ private fun VoiceStep(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextUn "Tap the mic, or press the talk button on your wheel, and speak. Your phone turns speech " + "into text by itself, and nothing you say leaves it. Say \"help\" in the car for the full list.", key, - action = if (ready) null else label to { needed.forEach { SpeechModels.download(context, it) } }, + action = if (ready) null else label to { SpeechModels.downloadRecommended(context) }, outlined = true, extra = { CommandDrift(c, cq, cqSp, key) }, ) { MicIcon(c.onPrimaryC) } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SetupGuideScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SetupGuideScreen.kt index 5b51bdc..ee710f2 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SetupGuideScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SetupGuideScreen.kt @@ -1,5 +1,11 @@ package app.seb3thehacker.gearslip.ui +import app.seb3thehacker.gearslip.speech.SpeechModels + +import androidx.compose.material3.MaterialTheme + +import androidx.compose.material3.LinearProgressIndicator + import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloat @@ -301,6 +307,23 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List Unit): List "Downloading, $progress%. You can carry on; it keeps going." + states.all { it == SpeechModels.State.Ready } -> "Voice is ready." + states.any { it is SpeechModels.State.Failed } -> "The download didn't finish. Tap Download voice to try again." + else -> null + } + LaunchedEffect(Unit) { SpeechModels.init(context) } + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + progress?.let { LinearProgressIndicator(progress = { it / 100f }, modifier = Modifier.fillMaxWidth()) } + line?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) } + } +} + @Composable private fun AudioChoice() { val usb by CarSettings.pipeAudio.collectAsState()