From 55320c4d98d91ec9c6b7d832e053b288f2b29fd3 Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Wed, 7 Oct 2026 16:37:40 -0700 Subject: [PATCH] example data --- .../seb3thehacker/gearslip/GearslipRunner.kt | 18 ++-- .../seb3thehacker/gearslip/SessionReport.kt | 92 +++++++++++++++++-- .../gearslip/stats/UsageStats.kt | 82 ++++++++++++++++- .../gearslip/ui/UsageStatsChoices.kt | 13 ++- 4 files changed, 181 insertions(+), 24 deletions(-) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt index 34a9cbf..26c945d 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt @@ -237,7 +237,7 @@ class GearslipRunner( } log.w("<- ByeByeRequest: reason=$reason ($name) in state $state") log.hex(" byebye raw", body) - SessionReport.fail(SessionReport.Category.BYEBYE, "ByeByeRequest reason=$reason ($name)", state.name) + SessionReport.fail(SessionReport.Category.BYEBYE, "ByeByeRequest reason=$reason ($name)", state.name, "bye $reason") send(MSG_BYEBYE_RESPONSE, ByteArray(0), encrypted = true) log.i("-> ByeByeResponse") } @@ -316,7 +316,7 @@ class GearslipRunner( sendServiceDiscoveryRequest() } -2 -> { - SessionReport.fail(SessionReport.Category.CERTIFICATE, "AuthComplete status -2 (certificate error)", state.name) + SessionReport.fail(SessionReport.Category.CERTIFICATE, "AuthComplete status -2 (certificate error)", state.name, "auth -2") log.verdict( "NOT VIABLE (STATUS_CERTIFICATE_ERROR)", "The head unit explicitly rejected the phone's self-signed certificate. " + @@ -330,7 +330,7 @@ class GearslipRunner( state = State.DONE } -3 -> { - SessionReport.fail(SessionReport.Category.AUTH, "AuthComplete status -3 (authentication failure)", state.name) + SessionReport.fail(SessionReport.Category.AUTH, "AuthComplete status -3 (authentication failure)", state.name, "auth -3") log.verdict( "NOT VIABLE (STATUS_AUTHENTICATION_FAILURE)", "Authentication rejected. Broader than a pure certificate error, but the " + @@ -343,7 +343,7 @@ class GearslipRunner( state = State.DONE } else -> { - SessionReport.fail(SessionReport.Category.AUTH, "AuthComplete status $status (unexpected)", state.name) + SessionReport.fail(SessionReport.Category.AUTH, "AuthComplete status $status (unexpected)", state.name, "auth $status") log.verdict( "INCONCLUSIVE (AuthComplete status=$status)", "Unexpected status. Check MessageStatus.proto in aasdk for the meaning " + @@ -411,7 +411,7 @@ class GearslipRunner( val video = ServiceDiscovery.findVideoService(serviceDiscoveryResponse) if (video == null) { log.w("no video service in the discovery response - cannot start Phase A") - SessionReport.fail(SessionReport.Category.VIDEO, "head unit advertised no video service", state.name) + SessionReport.fail(SessionReport.Category.VIDEO, "head unit advertised no video service", state.name, "no video service") return } videoChannelId = video.serviceId @@ -1101,10 +1101,10 @@ class GearslipRunner( private fun reportDisconnect() { when (state) { - State.WAIT_VERSION -> SessionReport.fail(SessionReport.Category.NO_HANDSHAKE, "no VersionRequest arrived", state.name) - State.TLS_HANDSHAKE -> SessionReport.fail(SessionReport.Category.TLS, "head unit dropped the link during TLS", state.name) - State.WAIT_AUTH -> SessionReport.fail(SessionReport.Category.AUTH, "dropped after TLS, before AuthComplete", state.name) - State.WAIT_SDR -> SessionReport.fail(SessionReport.Category.SERVICE_DISCOVERY, "accepted, then dropped before service discovery", state.name) + State.WAIT_VERSION -> SessionReport.fail(SessionReport.Category.NO_HANDSHAKE, "no VersionRequest arrived", state.name, "closed") + State.TLS_HANDSHAKE -> SessionReport.fail(SessionReport.Category.TLS, "head unit dropped the link during TLS", state.name, "closed") + State.WAIT_AUTH -> SessionReport.fail(SessionReport.Category.AUTH, "dropped after TLS, before AuthComplete", state.name, "closed") + State.WAIT_SDR -> SessionReport.fail(SessionReport.Category.SERVICE_DISCOVERY, "accepted, then dropped before service discovery", state.name, "closed") State.DONE -> {} } when (state) { diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/SessionReport.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/SessionReport.kt index a40f0b3..5f0ea85 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/SessionReport.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/SessionReport.kt @@ -29,12 +29,30 @@ object SessionReport { UNKNOWN("unclassified"), } + /** + * How far a session got, in the order a connection is built. The usage note's "Where + * connections fail" share sends the furthest one reached, so a failure says where it broke. + */ + enum class Stage(val key: String) { + USB("usb"), // the link opened; the car may have said nothing + VERSIONS("versions"), // the car said which protocol it speaks + TLS("tls"), // the secure link is up + ACCEPTED("accepted"), // the car accepted the certificate + CHANNELS("channels"), // the car listed its channels and said who it is + VIDEO("video"), // the car's screen is set up for Gearslip + STREAMING("streaming"), // video frames are reaching the car + } + private var appVersion = "?" private var appCode = 0L @Volatile private var startedAt = 0L @Volatile var category = Category.NONE; private set @Volatile private var failure = "" + @Volatile var stage = Stage.USB; private set + /** What the car or the phone said when it broke: a TLS alert, an auth status, a ByeBye reason, an errno. */ + @Volatile private var failCode = "" + @Volatile private var failedAfter = 0L @Volatile private var protocolVersion = "" @Volatile private var certSource = "" @Volatile private var certIssuer = "" @@ -62,6 +80,7 @@ object SessionReport { startedAt = System.currentTimeMillis() category = Category.NONE failure = ""; protocolVersion = ""; certSource = ""; certIssuer = "" + stage = Stage.USB; failCode = ""; failedAfter = 0 tlsProtocol = ""; tlsCipher = ""; authStatus = null headUnitInfo = null carScreen = ""; carDpi = 0 @@ -69,34 +88,87 @@ object SessionReport { printed = false } - fun protocolVersion(major: Int, minor: Int) { protocolVersion = "$major.$minor" } + /** Moves the session's furthest stage on; never back. */ + private fun reached(next: Stage) { if (next > stage) stage = next } + + fun protocolVersion(major: Int, minor: Int) { protocolVersion = "$major.$minor"; reached(Stage.VERSIONS) } fun certificate(source: String, issuer: String) { certSource = source; certIssuer = issuer } - fun tls(protocol: String, cipher: String) { tlsProtocol = protocol; tlsCipher = cipher } - fun auth(status: Int) { authStatus = status } + fun tls(protocol: String, cipher: String) { tlsProtocol = protocol; tlsCipher = cipher; reached(Stage.TLS) } + fun auth(status: Int) { authStatus = status; if (status == 0) reached(Stage.ACCEPTED) } fun headUnit(info: String) { headUnit = info } /** Who the car said it is, for the opt-in usage note; null until service discovery. */ @Volatile var headUnitInfo: ServiceDiscovery.HeadUnitInfo? = null + set(value) { field = value; if (value != null) reached(Stage.CHANNELS) } /** The car screen's usable size once its margins are cropped, and its density; blank before video starts. */ @Volatile var carScreen = ""; private set @Volatile var carDpi = 0; private set - fun carScreen(size: String, dpi: Int) { carScreen = size; carDpi = dpi } + fun carScreen(size: String, dpi: Int) { carScreen = size; carDpi = dpi; reached(Stage.VIDEO) } fun profile(name: String?) { profile = name ?: "none matched" } fun video(summary: String) { video = summary } fun audio(summary: String) { audio = summary } - fun stream(summary: String) { lastStream = summary } + fun stream(summary: String) { lastStream = summary; reached(Stage.STREAMING) } - /** Records the first failure only: later ones are usually fallout from it. */ - fun fail(category: Category, detail: String, state: String = "") { + /** + * Records the first failure only: later ones are usually fallout from it. [code] is the short + * thing the car or the phone said, like "auth -3" or "bye 1", for the usage note. + */ + fun fail(category: Category, detail: String, state: String = "", code: String = "") { if (this.category != Category.NONE) return this.category = category failure = detail lastState = state + failCode = code + failedAfter = if (startedAt == 0L) 0 else (System.currentTimeMillis() - startedAt) / 1000 } fun fail(category: Category, detail: String, t: Throwable, state: String = "") = - fail(category, "$detail (${GearslipLog.rootCause(t)})", state) + fail(category, "$detail (${GearslipLog.rootCause(t)})", state, codeOf(t)) + + /** + * A short code from an exception, never its whole message: the TLS alert's name, the errno + * of a USB failure, or else the exception's class. + */ + private fun codeOf(t: Throwable): String { + var root = t + while (root.cause != null && root.cause !== root) root = root.cause!! + val text = "${t.message} ${root.message}" + Regex("(?i)alert[ _:]*\\(?([a-z_ ]{3,40}?)\\)?(?:\\s|$|[,;.])").find(text)?.let { + return "alert ${it.groupValues[1].trim().lowercase().replace(' ', '_')}" + } + Regex("\\b(E[A-Z]{2,12})\\b").find(text)?.let { return it.groupValues[1] } + return root.javaClass.simpleName.take(40) + } + + /** + * One session for the usage note's "Where connections fail" share: how far it got, how and + * when it ended, and with which kind of certificate. Counts and codes only, nothing personal. + */ + fun sessionNote(): org.json.JSONObject { + val seconds = if (startedAt == 0L) 0 else (System.currentTimeMillis() - startedAt) / 1000 + val source = certSource.lowercase() + return org.json.JSONObject() + .put("stage", stage.key) + .put("ended", category.name.lowercase()) + .put("code", failCode) + .put("failed_at", if (category == Category.NONE) 0 else rounded(failedAfter)) + .put("seconds", rounded(seconds)) + .put("cert", when { + "imported" in source -> "imported" + "downloaded" in source -> "downloaded" + "self" in source -> "self-signed" + source.isEmpty() -> "" + else -> "other" + }) + } + + /** Seconds rounded so a drive's length can't be matched to anything: 5 s, then minutes, then 5 minutes. */ + private fun rounded(s: Long): Long = when { + s < 60 -> (s + 2) / 5 * 5 + s < 3600 -> (s + 30) / 60 * 60 + else -> (s + 150) / 300 * 300 + } /** The report as plain text, for the log file and for anything that gets sent. */ fun render(): String { @@ -145,7 +217,9 @@ object SessionReport { category == Category.NONE -> "ended before video" else -> category.label } - runCatching { UsageStats.recordSession(context, headUnitInfo, protocolVersion, outcome, carScreen, carDpi) } + runCatching { + UsageStats.recordSession(context, headUnitInfo, protocolVersion, outcome, carScreen, carDpi, sessionNote()) + } runCatching { app.seb3thehacker.gearslip.update.UpdateChecker.onDriveEnded(context) } } GearslipLog.flush() diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/stats/UsageStats.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/stats/UsageStats.kt index d03b176..8afbae2 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/stats/UsageStats.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/stats/UsageStats.kt @@ -32,6 +32,8 @@ object UsageStats { val android: Boolean = true, val phone: Boolean = true, val cars: Boolean = true, + /** How each car session went: how far it got, how and when it ended. Off until ticked. */ + val failures: Boolean = false, ) private const val PREFS = "usage_stats" @@ -49,6 +51,10 @@ object UsageStats { /** The daily background job of earlier builds, cancelled wherever it's still scheduled. */ private const val OLD_JOB_ID = 4_207 private const val MAX_CARS = 10 + /** Each session for the "Where connections fail" share, waiting for the next note. */ + private const val KEY_SESSIONS = "sessions" + private const val KEY_LAST_SESSIONS = "last_sessions" + private const val MAX_SESSIONS = 20 private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) @@ -69,6 +75,7 @@ object UsageStats { android = p.getBoolean("share_android", true), phone = p.getBoolean("share_phone", true), cars = p.getBoolean("share_cars", true), + failures = p.getBoolean("share_failures", false), ) } @@ -79,10 +86,13 @@ object UsageStats { .putBoolean("share_android", choices.android) .putBoolean("share_phone", choices.phone) .putBoolean("share_cars", choices.cars) + .putBoolean("share_failures", choices.failures) .apply() // Turning it on sends nothing yet: the driver may still be ticking boxes. The first note // waits for [choicesClosed]. - if (!enabled) prefs(context).edit().remove(KEY_CARS).remove(KEY_LAST_CARS).remove(KEY_SENT_DAY).apply() + 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() } /** The driver left the screen with the usage note choices: send today's note if it's due. */ @@ -90,10 +100,15 @@ object UsageStats { if (prefs(context).getString(KEY_SENT_DAY, null) != LocalDate.now().toString()) sendSoon(context) } - /** Gearslip was opened: send today's note unless one already went out. */ + /** + * Gearslip was opened: send today's note unless one already went out, or sooner if a session + * is still waiting, like one that ended in a crash before its note could go. + */ fun onAppOpened(context: Context) { context.getSystemService(JobScheduler::class.java)?.cancel(OLD_JOB_ID) - if (prefs(context).getString(KEY_SENT_DAY, null) != LocalDate.now().toString()) sendSoon(context) + val p = prefs(context) + val waiting = p.getString(KEY_CARS, "[]") != "[]" || p.getString(KEY_SESSIONS, "[]") != "[]" + if (waiting || p.getString(KEY_SENT_DAY, null) != LocalDate.now().toString()) sendSoon(context) } private val sending = AtomicBoolean(false) @@ -134,13 +149,35 @@ object UsageStats { outcome: String, screen: String = "", dpi: Int = 0, + session: JSONObject? = null, ) { if (!enabled(context)) return val p = prefs(context) - synchronized(carsLock) { addCar(p, info, protocol, outcome, screen, dpi) } + synchronized(carsLock) { + addCar(p, info, protocol, outcome, screen, dpi) + if (session != null && choices(context).failures) addSession(p, info, protocol, session) + } sendSoon(context) } + private fun addSession( + p: android.content.SharedPreferences, + info: ServiceDiscovery.HeadUnitInfo?, + protocol: String, + session: JSONObject, + ) { + val sessions = runCatching { JSONArray(p.getString(KEY_SESSIONS, "[]")) }.getOrDefault(JSONArray()) + if (sessions.length() >= MAX_SESSIONS) return + // The same car fields as the cars list, so the dashboard can line the two up. + session.put("name", info?.headUnitName.orEmpty()) + .put("car", info?.carModel.orEmpty()) + .put("year", info?.carYear.orEmpty()) + .put("protocol", protocol) + sessions.put(session) + // Written at once: after a crash the app is about to die, and a deferred write would be lost. + p.edit().putString(KEY_SESSIONS, sessions.toString()).commit() + } + private fun addCar( p: android.content.SharedPreferences, info: ServiceDiscovery.HeadUnitInfo?, @@ -162,7 +199,7 @@ object UsageStats { // The same car and outcome twice in a day is one fact, not two. val seen = (0 until cars.length()).any { cars.optJSONObject(it)?.toString() == car.toString() } if (!seen && cars.length() < MAX_CARS) cars.put(car) - p.edit().putString(KEY_CARS, cars.toString()).apply() + p.edit().putString(KEY_CARS, cars.toString()).commit() } // GrapheneOS reports the same build fields as stock Pixel firmware; its own apps give it away. @@ -203,9 +240,33 @@ object UsageStats { } note.put("cars", cars) } + if (c.failures) { + var sessions = runCatching { JSONArray(p.getString(KEY_SESSIONS, "[]")) }.getOrDefault(JSONArray()) + if (everything && sessions.length() == 0) { + sessions = runCatching { JSONArray(p.getString(KEY_LAST_SESSIONS, "[]")) }.getOrDefault(JSONArray()) + } + note.put("sessions", sessions) + } return note } + /** + * A made-up session, for "What gets sent" to show before the first real one: a 25-minute + * drive that ended with the car switched off. + */ + val exampleSession: JSONObject + get() = JSONObject() + .put("stage", "streaming") + .put("ended", "byebye") + .put("code", "bye 1") + .put("failed_at", 1500) + .put("seconds", 1500) + .put("cert", "downloaded") + .put("name", "Head unit name") + .put("car", "Car model") + .put("year", "2021") + .put("protocol", "1.5") + /** Sends the note now; false when it didn't get through. */ private fun send(context: Context): Boolean { if (!sends || !enabled(context)) return true @@ -213,6 +274,7 @@ object UsageStats { val p = prefs(context) val note = synchronized(carsLock) { preview(context) } val sentCars = note.optJSONArray("cars")?.toString() + val sentSessions = note.optJSONArray("sessions")?.toString() val sentDetails = if (note.has("android") || note.has("phone")) details(context, choices(context)).toString() else null val body = note.toString().toByteArray() return runCatching { @@ -234,6 +296,16 @@ object UsageStats { if (sentDetails != null) edit.putString(KEY_SENT_DETAILS, sentDetails).putString(KEY_DETAILS_DAY, LocalDate.now().toString()) if (sentCars != null && sentCars != "[]") edit.putString(KEY_LAST_CARS, sentCars) if (sentCars == null || p.getString(KEY_CARS, "[]") == sentCars) edit.remove(KEY_CARS) + if (sentSessions != null && sentSessions != "[]") edit.putString(KEY_LAST_SESSIONS, sentSessions) + if (sentSessions == null || p.getString(KEY_SESSIONS, "[]") == sentSessions) { + edit.remove(KEY_SESSIONS) + } else if (sentSessions != "[]") { + // A session ended mid-send: keep only the ones this note didn't carry. + val now = JSONArray(p.getString(KEY_SESSIONS, "[]")) + val sent = JSONArray(sentSessions).length() + val left = JSONArray().also { for (i in sent until now.length()) it.put(now.get(i)) } + edit.putString(KEY_SESSIONS, left.toString()) + } edit.apply() } GearslipLog.i("stats: note sent") 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 da00891..36a04eb 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UsageStatsChoices.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UsageStatsChoices.kt @@ -106,6 +106,10 @@ internal fun ColumnScope.UsageStatsRows() { choices = choices.copy(cars = it) save() } + Choice("How each drive went: how far the connection got, how long it lasted, and how it ended, crashes included", choices.failures) { + choices = choices.copy(failures = it) + save() + } SettingsDivider() SettingsRow( "What gets sent", @@ -122,7 +126,14 @@ internal fun ColumnScope.UsageStatsRows() { ) AnimatedVisibility(noteOpen) { // Rebuilt from the saved choices each time one changes, so it always matches. - val note = remember(choices) { UsageStats.preview(context, everything = true).toString(2) } + val note = remember(choices) { + val preview = UsageStats.preview(context, everything = true) + // Until the first drive the list is empty, so show what one entry looks like. + val example = if (preview.optJSONArray("sessions")?.length() == 0) { + "\n\nExample, not sent: one drive adds this to \"sessions\"\n" + UsageStats.exampleSession.toString(2) + } else "" + preview.toString(2) + example + } Surface( color = MaterialTheme.colorScheme.surfaceContainerLowest, shape = MaterialTheme.shapes.medium,