ask once to share after the first drive that works, cache the cert for settings

This commit is contained in:
Seb3thehacker committed 2026-10-08 00:27:54 -07:00
1 parent 40086e061b
commit 516f6c7f94
10 files changed
+424 -59

No files matched your search

@@ -220,6 +220,9 @@ object SessionReport {
runCatching {
UsageStats.recordSession(context, headUnitInfo, protocolVersion, outcome, carScreen, carDpi, sessionNote())
}
if (outcome == "connected") runCatching {
app.seb3thehacker.gearslip.stats.WorkedPrompt.onDriveWorked(context, headUnitInfo, protocolVersion, carScreen, carDpi)
}
runCatching { app.seb3thehacker.gearslip.update.UpdateChecker.onDriveEnded(context) }
}
GearslipLog.flush()
@@ -80,6 +80,7 @@ object UsageStats {
}
fun set(context: Context, enabled: Boolean, choices: Choices = choices(context)) {
val wasEnabled = enabled(context)
prefs(context).edit()
.putBoolean(KEY_ENABLED, enabled)
.putBoolean(KEY_ASKED, true)
@@ -93,6 +94,8 @@ object UsageStats {
if (!enabled) prefs(context).edit().remove(KEY_CARS).remove(KEY_LAST_CARS).remove(KEY_SENT_DAY)
.remove(KEY_SESSIONS).remove(KEY_LAST_SESSIONS).apply()
if (!choices.failures) prefs(context).edit().remove(KEY_SESSIONS).remove(KEY_LAST_SESSIONS).apply()
// Turned on anywhere - Settings, Home, or the ask itself - answers the one-time ask.
if (enabled && !wasEnabled) WorkedPrompt.onSharingOn(context)
}
/** The driver left the screen with the usage note choices: send today's note if it's due. */
@@ -0,0 +1,179 @@
package app.seb3thehacker.gearslip.stats
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
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 app.seb3thehacker.gearslip.ServiceDiscovery
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.json.JSONObject
/**
* The one-time "Gearslip worked with your car. Share that?" ask, for drivers who don't share
* usage notes. It comes after the first drive that worked, when a driver is happiest to help, as
* a notification the moment the car leaves and as a card on Home. Answering either clears both,
* and it never comes back.
*
* Saying yes turns on the same usage notes switch as Settings, and that drive's car goes in the
* first note. Until the driver answers, the car waits here on the phone and is sent nowhere.
*/
object WorkedPrompt {
private const val PREFS = "worked_prompt"
private const val KEY_DONE = "done"
private const val KEY_CAR = "car"
private const val CHANNEL = "worked"
private const val NOTIFICATION_ID = 4_311
private const val ACTION_SHARE = "app.seb3thehacker.gearslip.WORKED_SHARE"
private const val ACTION_NOT_NOW = "app.seb3thehacker.gearslip.WORKED_NOT_NOW"
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private var loaded = false
private val flow = MutableStateFlow<String?>(null)
/** The car's name while the ask is waiting for an answer, otherwise null. */
fun pending(context: Context): StateFlow<String?> {
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<Boolean> = 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)
}
@@ -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,
@@ -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
@@ -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
@@ -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)
}
}
@@ -181,7 +181,8 @@ private fun CertificateSection() {
val scope = rememberCoroutineScope()
var refresh by remember { mutableIntStateOf(0) }
val summary by produceState<CertSummary?>(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) }
}
@@ -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