example data

This commit is contained in:
Seb3thehacker committed 2026-10-07 16:37:40 -07:00
1 parent be981b0899
commit 55320c4d98
4 files changed
+181 -24

No files matched your search

@@ -237,7 +237,7 @@ class GearslipRunner(
} }
log.w("<- ByeByeRequest: reason=$reason ($name) in state $state") log.w("<- ByeByeRequest: reason=$reason ($name) in state $state")
log.hex(" byebye raw", body) 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) send(MSG_BYEBYE_RESPONSE, ByteArray(0), encrypted = true)
log.i("-> ByeByeResponse") log.i("-> ByeByeResponse")
} }
@@ -316,7 +316,7 @@ class GearslipRunner(
sendServiceDiscoveryRequest() sendServiceDiscoveryRequest()
} }
-2 -> { -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( log.verdict(
"NOT VIABLE (STATUS_CERTIFICATE_ERROR)", "NOT VIABLE (STATUS_CERTIFICATE_ERROR)",
"The head unit explicitly rejected the phone's self-signed certificate. " + "The head unit explicitly rejected the phone's self-signed certificate. " +
@@ -330,7 +330,7 @@ class GearslipRunner(
state = State.DONE state = State.DONE
} }
-3 -> { -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( log.verdict(
"NOT VIABLE (STATUS_AUTHENTICATION_FAILURE)", "NOT VIABLE (STATUS_AUTHENTICATION_FAILURE)",
"Authentication rejected. Broader than a pure certificate error, but the " + "Authentication rejected. Broader than a pure certificate error, but the " +
@@ -343,7 +343,7 @@ class GearslipRunner(
state = State.DONE state = State.DONE
} }
else -> { 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( log.verdict(
"INCONCLUSIVE (AuthComplete status=$status)", "INCONCLUSIVE (AuthComplete status=$status)",
"Unexpected status. Check MessageStatus.proto in aasdk for the meaning " + "Unexpected status. Check MessageStatus.proto in aasdk for the meaning " +
@@ -411,7 +411,7 @@ class GearslipRunner(
val video = ServiceDiscovery.findVideoService(serviceDiscoveryResponse) val video = ServiceDiscovery.findVideoService(serviceDiscoveryResponse)
if (video == null) { if (video == null) {
log.w("no video service in the discovery response - cannot start Phase A") 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 return
} }
videoChannelId = video.serviceId videoChannelId = video.serviceId
@@ -1101,10 +1101,10 @@ class GearslipRunner(
private fun reportDisconnect() { private fun reportDisconnect() {
when (state) { when (state) {
State.WAIT_VERSION -> SessionReport.fail(SessionReport.Category.NO_HANDSHAKE, "no VersionRequest arrived", 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) 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) 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) State.WAIT_SDR -> SessionReport.fail(SessionReport.Category.SERVICE_DISCOVERY, "accepted, then dropped before service discovery", state.name, "closed")
State.DONE -> {} State.DONE -> {}
} }
when (state) { when (state) {
@@ -29,12 +29,30 @@ object SessionReport {
UNKNOWN("unclassified"), 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 appVersion = "?"
private var appCode = 0L private var appCode = 0L
@Volatile private var startedAt = 0L @Volatile private var startedAt = 0L
@Volatile var category = Category.NONE; private set @Volatile var category = Category.NONE; private set
@Volatile private var failure = "" @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 protocolVersion = ""
@Volatile private var certSource = "" @Volatile private var certSource = ""
@Volatile private var certIssuer = "" @Volatile private var certIssuer = ""
@@ -62,6 +80,7 @@ object SessionReport {
startedAt = System.currentTimeMillis() startedAt = System.currentTimeMillis()
category = Category.NONE category = Category.NONE
failure = ""; protocolVersion = ""; certSource = ""; certIssuer = "" failure = ""; protocolVersion = ""; certSource = ""; certIssuer = ""
stage = Stage.USB; failCode = ""; failedAfter = 0
tlsProtocol = ""; tlsCipher = ""; authStatus = null tlsProtocol = ""; tlsCipher = ""; authStatus = null
headUnitInfo = null headUnitInfo = null
carScreen = ""; carDpi = 0 carScreen = ""; carDpi = 0
@@ -69,34 +88,87 @@ object SessionReport {
printed = false 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 certificate(source: String, issuer: String) { certSource = source; certIssuer = issuer }
fun tls(protocol: String, cipher: String) { tlsProtocol = protocol; tlsCipher = cipher } fun tls(protocol: String, cipher: String) { tlsProtocol = protocol; tlsCipher = cipher; reached(Stage.TLS) }
fun auth(status: Int) { authStatus = status } fun auth(status: Int) { authStatus = status; if (status == 0) reached(Stage.ACCEPTED) }
fun headUnit(info: String) { headUnit = info } fun headUnit(info: String) { headUnit = info }
/** Who the car said it is, for the opt-in usage note; null until service discovery. */ /** Who the car said it is, for the opt-in usage note; null until service discovery. */
@Volatile var headUnitInfo: ServiceDiscovery.HeadUnitInfo? = null @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. */ /** 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 carScreen = ""; private set
@Volatile var carDpi = 0; 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 profile(name: String?) { profile = name ?: "none matched" }
fun video(summary: String) { video = summary } fun video(summary: String) { video = summary }
fun audio(summary: String) { audio = 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 if (this.category != Category.NONE) return
this.category = category this.category = category
failure = detail failure = detail
lastState = state lastState = state
failCode = code
failedAfter = if (startedAt == 0L) 0 else (System.currentTimeMillis() - startedAt) / 1000
} }
fun fail(category: Category, detail: String, t: Throwable, state: String = "") = 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. */ /** The report as plain text, for the log file and for anything that gets sent. */
fun render(): String { fun render(): String {
@@ -145,7 +217,9 @@ object SessionReport {
category == Category.NONE -> "ended before video" category == Category.NONE -> "ended before video"
else -> category.label 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) } runCatching { app.seb3thehacker.gearslip.update.UpdateChecker.onDriveEnded(context) }
} }
GearslipLog.flush() GearslipLog.flush()
@@ -32,6 +32,8 @@ object UsageStats {
val android: Boolean = true, val android: Boolean = true,
val phone: Boolean = true, val phone: Boolean = true,
val cars: 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" 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. */ /** The daily background job of earlier builds, cancelled wherever it's still scheduled. */
private const val OLD_JOB_ID = 4_207 private const val OLD_JOB_ID = 4_207
private const val MAX_CARS = 10 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) private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@@ -69,6 +75,7 @@ object UsageStats {
android = p.getBoolean("share_android", true), android = p.getBoolean("share_android", true),
phone = p.getBoolean("share_phone", true), phone = p.getBoolean("share_phone", true),
cars = p.getBoolean("share_cars", 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_android", choices.android)
.putBoolean("share_phone", choices.phone) .putBoolean("share_phone", choices.phone)
.putBoolean("share_cars", choices.cars) .putBoolean("share_cars", choices.cars)
.putBoolean("share_failures", choices.failures)
.apply() .apply()
// Turning it on sends nothing yet: the driver may still be ticking boxes. The first note // Turning it on sends nothing yet: the driver may still be ticking boxes. The first note
// waits for [choicesClosed]. // 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. */ /** 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) 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) { fun onAppOpened(context: Context) {
context.getSystemService(JobScheduler::class.java)?.cancel(OLD_JOB_ID) 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) private val sending = AtomicBoolean(false)
@@ -134,13 +149,35 @@ object UsageStats {
outcome: String, outcome: String,
screen: String = "", screen: String = "",
dpi: Int = 0, dpi: Int = 0,
session: JSONObject? = null,
) { ) {
if (!enabled(context)) return if (!enabled(context)) return
val p = prefs(context) 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) 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( private fun addCar(
p: android.content.SharedPreferences, p: android.content.SharedPreferences,
info: ServiceDiscovery.HeadUnitInfo?, info: ServiceDiscovery.HeadUnitInfo?,
@@ -162,7 +199,7 @@ object UsageStats {
// The same car and outcome twice in a day is one fact, not two. // 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() } val seen = (0 until cars.length()).any { cars.optJSONObject(it)?.toString() == car.toString() }
if (!seen && cars.length() < MAX_CARS) cars.put(car) 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. // 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) 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 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. */ /** Sends the note now; false when it didn't get through. */
private fun send(context: Context): Boolean { private fun send(context: Context): Boolean {
if (!sends || !enabled(context)) return true if (!sends || !enabled(context)) return true
@@ -213,6 +274,7 @@ object UsageStats {
val p = prefs(context) val p = prefs(context)
val note = synchronized(carsLock) { preview(context) } val note = synchronized(carsLock) { preview(context) }
val sentCars = note.optJSONArray("cars")?.toString() 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 sentDetails = if (note.has("android") || note.has("phone")) details(context, choices(context)).toString() else null
val body = note.toString().toByteArray() val body = note.toString().toByteArray()
return runCatching { 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 (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 && sentCars != "[]") edit.putString(KEY_LAST_CARS, sentCars)
if (sentCars == null || p.getString(KEY_CARS, "[]") == sentCars) edit.remove(KEY_CARS) 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() edit.apply()
} }
GearslipLog.i("stats: note sent") GearslipLog.i("stats: note sent")
@@ -106,6 +106,10 @@ internal fun ColumnScope.UsageStatsRows() {
choices = choices.copy(cars = it) choices = choices.copy(cars = it)
save() 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() SettingsDivider()
SettingsRow( SettingsRow(
"What gets sent", "What gets sent",
@@ -122,7 +126,14 @@ internal fun ColumnScope.UsageStatsRows() {
) )
AnimatedVisibility(noteOpen) { AnimatedVisibility(noteOpen) {
// Rebuilt from the saved choices each time one changes, so it always matches. // 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( Surface(
color = MaterialTheme.colorScheme.surfaceContainerLowest, color = MaterialTheme.colorScheme.surfaceContainerLowest,
shape = MaterialTheme.shapes.medium, shape = MaterialTheme.shapes.medium,