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