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.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) {
@@ -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()
@@ -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")
@@ -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,