mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
voice: one button downloads Kroko and Canary in setup, with progress on Home and in a silent notification
This commit is contained in:
1 parent
eba8ac1127
commit
c029a0894a
4 files changed
+145
-2
No files matched your search
@@ -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>(State.Missing) }
|
||||
@@ -106,6 +122,66 @@ object SpeechModels {
|
||||
|
||||
fun state(model: Model): StateFlow<State> = 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<Model>()
|
||||
private val _progress = MutableStateFlow<Int?>(null)
|
||||
|
||||
/** Percent through the current downloads, for Home and the notification; null when none run. */
|
||||
val progress: StateFlow<Int?> = _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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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<Gu
|
||||
),
|
||||
)
|
||||
|
||||
// --- Voice: both recommended models in one tap, skipped once they're on the phone ---
|
||||
if (!SpeechModels.RECOMMENDED.all { SpeechModels.isReady(context, it) }) {
|
||||
add(
|
||||
GuidePage(
|
||||
title = "Voice.",
|
||||
explanation = "Ask for directions, music, or a call by voice. Your phone turns speech " +
|
||||
"into text by itself, and nothing you say leaves it. It needs " +
|
||||
"${SpeechModels.RECOMMENDED.sumOf { it.downloadMb }} MB, downloaded once. Use Wi-Fi if you can.",
|
||||
icon = { MicIcon(it) },
|
||||
optional = true,
|
||||
actionLabel = "Download voice",
|
||||
onAction = { SpeechModels.downloadRecommended(context) },
|
||||
content = { VoiceDownloadStatus() },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// --- Optional: each is skipped once its actual effect is already in place --------
|
||||
if (!hasProjectMediaOp(context)) {
|
||||
add(
|
||||
@@ -364,6 +387,25 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
|
||||
* Bluetooth or USB for the car's music, as two cards. Bluetooth comes first and is picked for a
|
||||
* driver who hasn't chosen yet: it never asks for consent, and the map app's voice reaches the car.
|
||||
*/
|
||||
/** How the voice download is going: a bar while it runs, a line once it's done or failed. */
|
||||
@Composable
|
||||
private fun VoiceDownloadStatus() {
|
||||
val context = LocalContext.current
|
||||
val progress by SpeechModels.progress.collectAsState()
|
||||
val states = SpeechModels.RECOMMENDED.map { SpeechModels.state(it).collectAsState().value }
|
||||
val line = when {
|
||||
progress != null -> "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()
|
||||
|
||||
Reference in new issue
Block a user