From 516f6c7f9497ea124f819a029375d6115bbc32f5 Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Thu, 8 Oct 2026 00:27:54 -0700 Subject: [PATCH] ask once to share after the first drive that works, cache the cert for settings --- gearslip/src/main/AndroidManifest.xml | 5 + .../seb3thehacker/gearslip/SessionReport.kt | 3 + .../gearslip/stats/UsageStats.kt | 3 + .../gearslip/stats/WorkedPrompt.kt | 179 ++++++++++++++++++ .../seb3thehacker/gearslip/ui/CertSummary.kt | 11 +- .../app/seb3thehacker/gearslip/ui/Confetti.kt | 88 +++++++++ .../seb3thehacker/gearslip/ui/GearslipApp.kt | 4 + .../seb3thehacker/gearslip/ui/HomeScreen.kt | 132 +++++++------ .../gearslip/ui/SettingsScreen.kt | 3 +- .../gearslip/ui/UsageStatsChoices.kt | 55 ++++++ 10 files changed, 424 insertions(+), 59 deletions(-) create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/stats/WorkedPrompt.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/ui/Confetti.kt diff --git a/gearslip/src/main/AndroidManifest.xml b/gearslip/src/main/AndroidManifest.xml index fe7b03f..50dfbdb 100644 --- a/gearslip/src/main/AndroidManifest.xml +++ b/gearslip/src/main/AndroidManifest.xml @@ -160,6 +160,11 @@ android:name=".notify.TestMessageReceiver" android:exported="false" /> + + + (null) + + /** The car's name while the ask is waiting for an answer, otherwise null. */ + fun pending(context: Context): StateFlow { + if (!loaded) { + loaded = true + flow.value = saved(context)?.let(::carName) + } + return flow + } + + /** A drive that reached the car's screen ended cleanly. Asks, if it's never asked before. */ + fun onDriveWorked(context: Context, info: ServiceDiscovery.HeadUnitInfo?, protocol: String, screen: String, dpi: Int) { + val p = prefs(context) + if (UsageStats.enabled(context) || p.getBoolean(KEY_DONE, false) || p.contains(KEY_CAR)) return + val car = JSONObject() + .put("name", info?.headUnitName.orEmpty()) + .put("car", info?.carModel.orEmpty()) + .put("year", info?.carYear.orEmpty()) + .put("make", info?.headUnitMake.orEmpty()) + .put("model", info?.headUnitModel.orEmpty()) + .put("protocol", protocol) + .put("screen", screen) + .put("dpi", dpi) + p.edit().putString(KEY_CAR, car.toString()).apply() + loaded = true + flow.value = carName(car) + notify(context, carName(car)) + } + + /** + * True once a driver turns sharing on from the ask, until Home has thrown its confetti. It + * waits for Home: a Share tapped in the notification celebrates the next time Gearslip opens. + */ + private val celebrateFlow = MutableStateFlow(false) + val celebrate: StateFlow = celebrateFlow + + fun celebrated() { celebrateFlow.value = false } + + /** The driver answered, here or on Home. Either way it's never asked again. */ + fun answer(context: Context, share: Boolean) { + if (share) { + // Turning sharing on lands in [onSharingOn], which clears the ask. + UsageStats.set(context, true) + return + } + clear(context) + GearslipLog.i("worked prompt: not now") + } + + /** + * Usage notes were just turned on, from the ask or anywhere else. With the ask still waiting, + * that answers it, and the car it was about goes in the first note. + */ + internal fun onSharingOn(context: Context) { + val car = saved(context) ?: return + clear(context) + celebrateFlow.value = true + GearslipLog.i("worked prompt: usage notes turned on") + val info = ServiceDiscovery.HeadUnitInfo( + car.optString("name"), car.optString("car"), car.optString("year"), + car.optString("make"), car.optString("model"), + ) + UsageStats.recordSession( + context, info, car.optString("protocol"), "connected", + car.optString("screen"), car.optInt("dpi"), + ) + } + + private fun clear(context: Context) { + prefs(context).edit().remove(KEY_CAR).putBoolean(KEY_DONE, true).apply() + loaded = true + flow.value = null + NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID) + } + + private fun saved(context: Context): JSONObject? = + prefs(context).getString(KEY_CAR, null)?.let { runCatching { JSONObject(it) }.getOrNull() } + + /** "your 2019 Clio", "your Renault Media Nav", or "your car" when it didn't say. */ + private fun carName(car: JSONObject): String { + val model = car.optString("car").trim() + val year = car.optString("year").trim().takeIf { it.isNotEmpty() && it != "0" } + val unit = "${car.optString("make")} ${car.optString("model")}".trim() + return when { + model.isNotEmpty() -> "your " + listOfNotNull(year, model).joinToString(" ") + unit.isNotEmpty() -> "your $unit" + else -> "your car" + } + } + + private fun notify(context: Context, car: String) { + if (context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) return + context.getSystemService(NotificationManager::class.java).createNotificationChannel( + NotificationChannel(CHANNEL, "After a drive", NotificationManager.IMPORTANCE_DEFAULT), + ) + 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 text = "Share that so the next driver knows it works? No name, account, or location." + val notification = NotificationCompat.Builder(context, CHANNEL) + .setSmallIcon(android.R.drawable.ic_menu_send) + .setContentTitle(TITLE_START + car) + .setContentText(text) + .setStyle(NotificationCompat.BigTextStyle().bigText(text)) + .setContentIntent(open) + .setAutoCancel(true) + .addAction(0, "Not now", broadcast(context, ACTION_NOT_NOW)) + .addAction(0, "Share", broadcast(context, ACTION_SHARE)) + .build() + runCatching { NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, notification) } + .onSuccess { GearslipLog.i("worked prompt: asked after the first good drive") } + .onFailure { GearslipLog.w("worked prompt: could not post the notification (${it.message})") } + } + + private fun broadcast(context: Context, action: String): PendingIntent = + PendingIntent.getBroadcast( + context, action.hashCode(), + Intent(context, WorkedPromptReceiver::class.java).setAction(action), + PendingIntent.FLAG_IMMUTABLE, + ) + + internal fun onAction(context: Context, intent: Intent) { + when (intent.action) { + ACTION_SHARE -> answer(context, share = true) + ACTION_NOT_NOW -> answer(context, share = false) + } + } + + const val TITLE_START = "Gearslip worked with " +} + +/** The notification's Share and Not now buttons land here. */ +class WorkedPromptReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) = WorkedPrompt.onAction(context, intent) +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CertSummary.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CertSummary.kt index f06c7f9..48716ca 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CertSummary.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CertSummary.kt @@ -14,8 +14,17 @@ data class CertSummary( val validUntil: String?, ) { companion object { + /** + * The last [read], so Settings draws the certificate row at once instead of a second + * later, which shifted everything under it. Null until the first read. + */ + @Volatile var cached: CertSummary? = null + private set + /** Blocking (parses a key store): call from a background dispatcher. */ - fun read(context: Context): CertSummary { + fun read(context: Context): CertSummary = readNow(context).also { cached = it } + + private fun readNow(context: Context): CertSummary { val identity = CertProvider.loadSupplied(context) ?: return CertSummary( CertProvider.Kind.SELF_SIGNED, diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/Confetti.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/Confetti.kt new file mode 100644 index 0000000..61ce7e4 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/Confetti.kt @@ -0,0 +1,88 @@ +package app.seb3thehacker.gearslip.ui + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.rotate +import kotlin.math.sin +import kotlin.random.Random + +private class Piece( + val x: Float, // across the screen, 0..1 + val delay: Float, // seconds before it starts falling + val speed: Float, // screen heights per second + val sway: Float, // how far it drifts side to side, in screen widths + val swaySpeed: Float, + val spin: Float, // degrees per second + val width: Float, // dp + val height: Float, + val color: Color, +) + +/** + * Confetti falling from the top of the screen once, for about three seconds. Draws over whatever + * is under it and takes no touches; [onDone] fires when the last piece has left the screen. + */ +@Composable +internal fun ConfettiFall(onDone: () -> Unit, modifier: Modifier = Modifier) { + val scheme = MaterialTheme.colorScheme + val colors = listOf( + scheme.primary, scheme.tertiary, scheme.secondary, + Color(0xFFFFC107), Color(0xFFE91E63), Color(0xFF4CAF50), Color(0xFF2196F3), + ) + val pieces = remember { + List(PIECES) { + Piece( + x = Random.nextFloat(), + delay = Random.nextFloat() * 0.9f, + speed = 0.35f + Random.nextFloat() * 0.35f, + sway = 0.01f + Random.nextFloat() * 0.03f, + swaySpeed = 2f + Random.nextFloat() * 4f, + spin = (Random.nextFloat() - 0.5f) * 720f, + width = 4f + Random.nextFloat() * 4f, + height = 8f + Random.nextFloat() * 6f, + color = colors[Random.nextInt(colors.size)], + ) + } + } + var t by remember { mutableFloatStateOf(0f) } + LaunchedEffect(Unit) { + val start = withFrameNanos { it } + // The slowest piece, starting last, clears the screen by this time. + val end = pieces.maxOf { it.delay + 1.1f / it.speed } + while (t < end) { + withFrameNanos { t = (it - start) / 1e9f } + } + onDone() + } + Canvas(modifier.fillMaxSize()) { + val density = density + for (p in pieces) { + val age = t - p.delay + if (age < 0f) continue + val y = (age * p.speed - 0.05f) * size.height + if (y > size.height + 40f) continue + val x = (p.x + sin(age * p.swaySpeed) * p.sway) * size.width + rotate(age * p.spin, pivot = Offset(x, y)) { + drawRect( + p.color, + topLeft = Offset(x - p.width * density / 2, y - p.height * density / 2), + size = Size(p.width * density, p.height * density), + ) + } + } + } +} + +private const val PIECES = 140 diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt index 4d06629..9c5a3db 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt @@ -77,6 +77,10 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) { // The guide asks for this now, but anyone who finished it before that never saw the page. val notificationPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {} + // Reads the certificate once in the background, so Settings has it ready when opened. + LaunchedEffect(Unit) { + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Default) { runCatching { CertSummary.read(context) } } + } LaunchedEffect(Unit) { if (!needsSetupGuide && context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED 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 4040fc3..bb3a79f 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt @@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip.ui import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -31,6 +32,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector @@ -41,6 +43,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.seb3thehacker.gearslip.BuildConfig import app.seb3thehacker.gearslip.SessionStatus import app.seb3thehacker.gearslip.notify.TestMessage +import app.seb3thehacker.gearslip.stats.WorkedPrompt @Composable fun HomeScreen( @@ -52,81 +55,96 @@ fun HomeScreen( onDisconnect: () -> Unit, ) { val status by SessionStatus.state.collectAsStateWithLifecycle() + val context = LocalContext.current + val workedCar by remember { WorkedPrompt.pending(context) }.collectAsStateWithLifecycle() + val live = status.phase == SessionStatus.Phase.CONNECTING || + status.phase == SessionStatus.Phase.PROJECTING - Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding -> - Column( - Modifier - .fillMaxSize() - .padding(padding) - .padding(horizontal = 24.dp), - ) { + val celebrate by WorkedPrompt.celebrate.collectAsStateWithLifecycle() + Box { + Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding -> Column( Modifier - .weight(1f) - .verticalScroll(rememberScrollState()), + .fillMaxSize() + .padding(padding) + .padding(horizontal = 24.dp), ) { - Spacer(Modifier.height(24.dp)) - Row( - Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, + Column( + Modifier + .weight(1f) + .verticalScroll(rememberScrollState()), ) { - Text( - "Gearslip", - style = MaterialTheme.typography.displayLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onBackground, - ) - IconButton( - onClick = onOpenSettings, - modifier = Modifier.size(56.dp), + Spacer(Modifier.height(24.dp)) + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, ) { - Icon(Icons.Filled.Settings, contentDescription = "Settings", modifier = Modifier.size(40.dp)) + Text( + "Gearslip", + style = MaterialTheme.typography.displayLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground, + ) + IconButton( + onClick = onOpenSettings, + modifier = Modifier.size(56.dp), + ) { + Icon(Icons.Filled.Settings, contentDescription = "Settings", modifier = Modifier.size(40.dp)) + } } - } - Text( - "v${LocalContext.current.appVersionName()}", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(16.dp)) - StatusCard(status) - Spacer(Modifier.height(16.dp)) - UpdateCard() - } - - // Sits at the bottom, where a thumb reaches it. - Column(Modifier.padding(vertical = 16.dp)) { - val live = status.phase == SessionStatus.Phase.CONNECTING || - status.phase == SessionStatus.Phase.PROJECTING - if (live) { - Button(onClick = onDisconnect, modifier = Modifier.fillMaxWidth().height(56.dp)) { - Text("Disconnect") + Text( + "v${LocalContext.current.appVersionName()}", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + StatusCard(status) + // The first drive that worked asks once to share it, right under the result. + val car = workedCar + if (car != null && !live) { + Spacer(Modifier.height(16.dp)) + WorkedCard(car, onSeeWhatsSent = onOpenUsageNotes) } Spacer(Modifier.height(16.dp)) + UpdateCard() } - UsageNotesHomeCard(onOpen = onOpenUsageNotes) - Spacer(Modifier.height(16.dp)) - - Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.extraLarge) { - Column { - if (!live) { - ActionRow(Icons.Filled.PlayArrow, "Car preview", onOpenCarPreview) - RowDivider() + // Sits at the bottom, where a thumb reaches it. + Column(Modifier.padding(vertical = 16.dp)) { + if (live) { + Button(onClick = onDisconnect, modifier = Modifier.fillMaxWidth().height(56.dp)) { + Text("Disconnect") } - ActionRow(Icons.Filled.List, "Live logs", onOpenLogs) - RowDivider() - ActionRow(Icons.Filled.Info, "Connection help", onOpenHelp) - if (BuildConfig.DEBUG) { - val context = LocalContext.current + Spacer(Modifier.height(16.dp)) + } + + // Not while the card above is asking the same thing. + if (workedCar == null || live) { + UsageNotesHomeCard(onOpen = onOpenUsageNotes) + Spacer(Modifier.height(16.dp)) + } + + Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.extraLarge) { + Column { + if (!live) { + ActionRow(Icons.Filled.PlayArrow, "Car preview", onOpenCarPreview) + RowDivider() + } + ActionRow(Icons.Filled.List, "Live logs", onOpenLogs) RowDivider() - ActionRow(Icons.Filled.Send, "Send a test message") { TestMessage.post(context) } + ActionRow(Icons.Filled.Info, "Connection help", onOpenHelp) + if (BuildConfig.DEBUG) { + RowDivider() + ActionRow(Icons.Filled.Send, "Send a test message") { TestMessage.post(context) } + } } } } } } + // Sharing just went on from the "it worked" ask: a little thanks. + if (celebrate) ConfettiFall(onDone = WorkedPrompt::celebrated) } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt index f397fbf..0761ba8 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt @@ -181,7 +181,8 @@ private fun CertificateSection() { val scope = rememberCoroutineScope() var refresh by remember { mutableIntStateOf(0) } - val summary by produceState(null, refresh) { + // The cached summary shows at once; the read still runs, in case the cert changed since. + val summary by produceState(CertSummary.cached, refresh) { value = withContext(Dispatchers.Default) { CertSummary.read(context) } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UsageStatsChoices.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UsageStatsChoices.kt index 612b5da..49346f6 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UsageStatsChoices.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UsageStatsChoices.kt @@ -5,6 +5,13 @@ import android.content.Context import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.TextButton +import app.seb3thehacker.gearslip.stats.WorkedPrompt import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -173,6 +180,54 @@ private fun buildNote(): String? = when { else -> null } +/** + * The one-time ask after the first drive that worked, in the status card's place on Home. Share + * turns on usage notes like the switch does; the link shows exactly what that sends first. + */ +@Composable +internal fun WorkedCard(car: String, onSeeWhatsSent: () -> Unit, modifier: Modifier = Modifier) { + val context = LocalContext.current + val scheme = MaterialTheme.colorScheme + Surface( + modifier, + color = scheme.primaryContainer, + contentColor = scheme.onPrimaryContainer, + shape = MaterialTheme.shapes.extraLarge, + ) { + Column(Modifier.fillMaxWidth().padding(20.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.Check, contentDescription = null) + Text( + WorkedPrompt.TITLE_START + car, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(start = 12.dp), + ) + } + Text( + "Share that so the next driver knows it works? No name, account, or location.", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 8.dp), + ) + buildNote()?.let { + Text(it, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 4.dp)) + } + Row( + Modifier.fillMaxWidth().padding(top = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + TextButton(onClick = onSeeWhatsSent, contentPadding = PaddingValues(horizontal = 0.dp)) { + Text("What gets sent") + } + Spacer(Modifier.weight(1f)) + OutlinedButton(onClick = { WorkedPrompt.answer(context, share = false) }) { Text("Not now") } + Button(onClick = { WorkedPrompt.answer(context, share = true) }) { Text("Share") } + } + } + } +} + /** * Usage notes on Home, one row with one action either way. While they're off, the row is the * switch, so opting in is a single tap. Once they're on, it shrinks to a quiet line that opens