opt-in usage notes and a stats dashboard

Release builds ask in setup and Settings whether to send a short note when the app is opened and after each drive: the version, plus the Android version, phone model and cars people tick. Nothing runs in the background. Dev builds always send. The Cloudflare Worker in stats-worker/ stores the notes and serves a private dashboard with GitHub downloads alongside.
This commit is contained in:
Seb3thehacker committed 2026-10-05 01:21:16 -07:00
1 parent b6bd6497b2
commit 5a3bbe82a0
13 files changed
+812 -6

No files matched your search

+3
View File
@@ -35,6 +35,9 @@ android {
versionCode = 7
versionName = "0.1.10-alpha"
manifestPlaceholders["appLabel"] = "Gearslip"
// Where opted-in phones send their daily usage note: the stats-worker/ Worker's address,
// set as gearslipStatsUrl in gradle.properties. Left empty, nothing is ever sent.
buildConfigField("String", "STATS_URL", "\"${project.findProperty("gearslipStatsUrl") ?: ""}\"")
}
signingConfigs {
+2
View File
@@ -74,6 +74,8 @@
LAUNCHER query below to find, so the setup guide's Android Auto check needs this to see
whether the package is installed at all. -->
<package android:name="com.google.android.projection.gearhead" />
<!-- Every GrapheneOS install has this; the opt-in usage note uses it to say "GrapheneOS". -->
<package android:name="app.grapheneos.setupwizard" />
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
@@ -27,6 +27,7 @@ import app.seb3thehacker.gearslip.car.CarEnvironment
import app.seb3thehacker.gearslip.car.CarSettings
import app.seb3thehacker.gearslip.car.CarUi
import app.seb3thehacker.gearslip.car.Prefetch
import app.seb3thehacker.gearslip.stats.UsageStats
import app.seb3thehacker.gearslip.ui.GearslipApp
import app.seb3thehacker.gearslip.ui.GearslipTheme
import java.io.FileInputStream
@@ -150,6 +151,7 @@ class GearslipActivity : ComponentActivity(), Projection {
override fun onStart() {
super.onStart()
Prefetch.warm(this)
UsageStats.onAppOpened(this)
}
override fun onNewIntent(intent: Intent) {
@@ -1,6 +1,7 @@
package app.seb3thehacker.gearslip
import android.content.Context
import app.seb3thehacker.gearslip.stats.UsageStats
import android.os.Build
/**
@@ -48,7 +49,10 @@ object SessionReport {
@Volatile private var lastState = ""
private var printed = false
private var appContext: Context? = null
fun init(context: Context) {
appContext = context.applicationContext
val info = runCatching { context.packageManager.getPackageInfo(context.packageName, 0) }.getOrNull()
appVersion = info?.versionName ?: "?"
appCode = info?.longVersionCode ?: 0
@@ -59,6 +63,8 @@ object SessionReport {
category = Category.NONE
failure = ""; protocolVersion = ""; certSource = ""; certIssuer = ""
tlsProtocol = ""; tlsCipher = ""; authStatus = null
headUnitInfo = null
carScreen = ""; carDpi = 0
headUnit = ""; profile = ""; video = ""; lastStream = ""; lastState = ""
printed = false
}
@@ -68,6 +74,14 @@ object SessionReport {
fun tls(protocol: String, cipher: String) { tlsProtocol = protocol; tlsCipher = cipher }
fun auth(status: Int) { authStatus = status }
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
/** 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 profile(name: String?) { profile = name ?: "none matched" }
fun video(summary: String) { video = summary }
fun audio(summary: String) { audio = summary }
@@ -121,6 +135,18 @@ object SessionReport {
if (printed || startedAt == 0L) return
printed = true
render().lines().forEach { GearslipLog.i(it) }
appContext?.let { context ->
// Video started means the car showed Gearslip: that's a working car, and unplugging or
// switching the car off at the end shows up as a USB or ByeBye "failure" that isn't one.
val reached = carScreen.isNotEmpty()
val outcome = when {
reached && category in setOf(Category.NONE, Category.USB, Category.BYEBYE) -> "connected"
reached -> "connected, then ${category.label}"
category == Category.NONE -> "ended before video"
else -> category.label
}
runCatching { UsageStats.recordSession(context, headUnitInfo, protocolVersion, outcome, carScreen, carDpi) }
}
GearslipLog.flush()
}
@@ -0,0 +1,235 @@
package app.seb3thehacker.gearslip.stats
import android.app.job.JobScheduler
import android.content.Context
import android.os.Build
import app.seb3thehacker.gearslip.BuildConfig
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.ServiceDiscovery
import org.json.JSONArray
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import java.time.LocalDate
import java.util.UUID
import java.util.concurrent.atomic.AtomicBoolean
/**
* Opt-in usage counts: a short note to the stats Worker (stats-worker/ in the repo). Off until the
* driver turns it on in setup or Settings. Only the app version is always sent; everything else
* is one of the driver's [Choices]. A random install ID counts a phone once; nothing ties it to a
* person, and the Worker keeps no IP addresses.
*
* Notes go out only while Gearslip is in use: the first time the app is opened each day, and
* after every car session. Nothing runs in the background, so a phone that stops using Gearslip
* goes quiet. A note that fails waits for the next one. With no [BuildConfig.STATS_URL] set at
* build time, nothing is sent.
*/
object UsageStats {
/** What the driver agreed to share beyond the app version. */
data class Choices(
val android: Boolean = true,
val phone: Boolean = true,
val cars: Boolean = true,
)
private const val PREFS = "usage_stats"
private const val KEY_ENABLED = "enabled"
private const val KEY_ASKED = "asked"
private const val KEY_ID = "install_id"
/** The phone details the server last got, and when, so they go again only on a change. */
private const val KEY_SENT_DETAILS = "sent_details"
private const val KEY_DETAILS_DAY = "details_day"
private const val DETAILS_REFRESH_DAYS = 30L
private const val KEY_CARS = "cars"
private const val KEY_SENT_DAY = "sent_day"
/** 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
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
/**
* Dev builds are the developer's own, so they always send everything and never ask: no switch
* in Settings, no page in setup. Only release builds offer the choice.
*/
val choosable: Boolean get() = !BuildConfig.DEBUG
fun enabled(context: Context): Boolean = !choosable || prefs(context).getBoolean(KEY_ENABLED, false)
/** Whether the driver has answered the question in setup, either way. */
fun asked(context: Context): Boolean = !choosable || prefs(context).getBoolean(KEY_ASKED, false)
fun choices(context: Context): Choices {
if (!choosable) return Choices()
val p = prefs(context)
return Choices(
android = p.getBoolean("share_android", true),
phone = p.getBoolean("share_phone", true),
cars = p.getBoolean("share_cars", true),
)
}
fun set(context: Context, enabled: Boolean, choices: Choices = choices(context)) {
prefs(context).edit()
.putBoolean(KEY_ENABLED, enabled)
.putBoolean(KEY_ASKED, true)
.putBoolean("share_android", choices.android)
.putBoolean("share_phone", choices.phone)
.putBoolean("share_cars", choices.cars)
.apply()
if (enabled) sendSoon(context)
else prefs(context).edit().remove(KEY_CARS).remove(KEY_SENT_DAY).apply()
}
/** Gearslip was opened: send today's note unless one already went out. */
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)
}
private val sending = AtomicBoolean(false)
@Volatile private var sendAgain = false
/** Guards the saved car list, which a session's end and a finished send both rewrite. */
private val carsLock = Any()
/**
* Sends off the calling thread. A call while a note is in flight sends once more after it, so
* a car recorded mid-send still goes out.
*/
private fun sendSoon(context: Context) {
if (!enabled(context)) return
if (!sending.compareAndSet(false, true)) { sendAgain = true; return }
val app = context.applicationContext
Thread({
try {
do { sendAgain = false } while (send(app) && sendAgain)
} finally {
sending.set(false)
}
}, "gearslip-stats").start()
}
private fun installId(context: Context): String {
val p = prefs(context)
return p.getString(KEY_ID, null) ?: UUID.randomUUID().toString().also { p.edit().putString(KEY_ID, it).apply() }
}
/**
* A car session ended. [info] is null when the car hung up before it said who it is, for
* instance after rejecting the certificate; the protocol version and [outcome] still say a lot.
*/
fun recordSession(
context: Context,
info: ServiceDiscovery.HeadUnitInfo?,
protocol: String,
outcome: String,
screen: String = "",
dpi: Int = 0,
) {
if (!enabled(context)) return
val p = prefs(context)
synchronized(carsLock) { addCar(p, info, protocol, outcome, screen, dpi) }
sendSoon(context)
}
private fun addCar(
p: android.content.SharedPreferences,
info: ServiceDiscovery.HeadUnitInfo?,
protocol: String,
outcome: String,
screen: String,
dpi: Int,
) {
val cars = runCatching { JSONArray(p.getString(KEY_CARS, "[]")) }.getOrDefault(JSONArray())
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("result", outcome)
if (screen.isNotEmpty()) car.put("screen", screen).put("dpi", dpi)
// 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()
}
// GrapheneOS reports the same build fields as stock Pixel firmware; its own apps give it away.
private fun isGrapheneOs(context: Context) =
runCatching { context.packageManager.getPackageInfo("app.grapheneos.setupwizard", 0) }.isSuccess
private fun details(context: Context, c: Choices) = JSONObject().apply {
if (c.android) put("android", Build.VERSION.RELEASE).put("os", if (isGrapheneOs(context)) "GrapheneOS" else "other")
if (c.phone) put("phone", "${Build.MANUFACTURER} ${Build.MODEL}")
}
/**
* What a note carries. With [everything], every field the driver shares, as Settings shows it;
* otherwise exactly what the next note would send, which leaves out unchanged phone details.
*/
fun preview(context: Context, everything: Boolean = false): JSONObject {
val c = choices(context)
val p = prefs(context)
val note = JSONObject()
.put("id", installId(context))
.put("version", BuildConfig.VERSION_NAME)
.put("code", BuildConfig.VERSION_CODE)
.put("build", if (BuildConfig.DEBUG) "dev" else "release")
// Phone details change rarely, so they ride along only when they differ from what the
// server last got, or once a month in case it lost them. Unticked ones are left out, and
// the server keeps the last value it had.
val details = details(context, c)
val stale = p.getString(KEY_DETAILS_DAY, null)?.let { LocalDate.parse(it).plusDays(DETAILS_REFRESH_DAYS) <= LocalDate.now() } ?: true
if (details.length() > 0 && (everything || stale || details.toString() != p.getString(KEY_SENT_DETAILS, null))) {
details.keys().forEach { note.put(it, details.get(it)) }
}
if (c.cars) note.put("cars", runCatching { JSONArray(p.getString(KEY_CARS, "[]")) }.getOrDefault(JSONArray()))
return note
}
/** Sends the note now; false when it didn't get through. */
private fun send(context: Context): Boolean {
if (!enabled(context)) return true
val endpoint = BuildConfig.STATS_URL
if (endpoint.isBlank()) return true
val p = prefs(context)
val note = synchronized(carsLock) { preview(context) }
val sentCars = note.optJSONArray("cars")?.toString()
val sentDetails = if (note.has("android") || note.has("phone")) details(context, choices(context)).toString() else null
val body = note.toString().toByteArray()
return runCatching {
val conn = (URL("$endpoint/ping").openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
doOutput = true
connectTimeout = 15_000
readTimeout = 15_000
setRequestProperty("Content-Type", "application/json")
}
conn.outputStream.use { it.write(body) }
val code = conn.responseCode
conn.disconnect()
if (code in 200..299) {
// Sent: the cars start over, unless a session ended meanwhile and added one, which
// stays for the next note.
synchronized(carsLock) {
val edit = p.edit().putString(KEY_SENT_DAY, LocalDate.now().toString())
if (sentDetails != null) edit.putString(KEY_SENT_DETAILS, sentDetails).putString(KEY_DETAILS_DAY, LocalDate.now().toString())
if (sentCars == null || p.getString(KEY_CARS, "[]") == sentCars) edit.remove(KEY_CARS)
edit.apply()
}
GearslipLog.i("stats: note sent")
true
} else {
GearslipLog.w("stats: the server answered $code")
false
}
}.getOrElse {
GearslipLog.w("stats: could not send: ${it.message}")
false
}
}
}
@@ -0,0 +1,148 @@
package app.seb3thehacker.gearslip.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.Send
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.BuildConfig
import app.seb3thehacker.gearslip.stats.UsageStats
internal const val USAGE_STATS_TITLE = "Help show which cars work"
private const val INTRO_START =
"Gearslip can't tell which cars it works in unless drivers say so. Turn this on, and Gearslip " +
"sends a short note when you open it and after each drive, never in the background. The " +
"note carries the Gearslip version and whatever you tick below. "
private const val INTRO_PRIVACY =
"It holds no name, account, or location, only a random number so your phone counts once. " +
"Cloudflare carries the note, and the server keeps no IP address."
private const val INTRO_END =
" Nothing helps the project more: these notes tell the next driver whether their car " +
"will work before they install."
/** The pitch for the usage notes, with the privacy promise in bold so it isn't missed. */
internal val USAGE_STATS_INTRO: AnnotatedString = buildAnnotatedString {
append(INTRO_START)
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(INTRO_PRIVACY) }
append(INTRO_END)
}
/** The usage notes card for the setup guide: the same rows Settings shows, minus the note. */
@Composable
internal fun UsageStatsChoices(modifier: Modifier = Modifier) {
SettingsCard(modifier) { UsageStatsRows(showNote = false) }
}
/**
* The on switch, what to share, and (with [showNote]) the note itself, saved as they change.
* Rows only: the caller puts them in a card.
*/
@Composable
internal fun ColumnScope.UsageStatsRows(showNote: Boolean) {
val context = LocalContext.current
var on by remember { mutableStateOf(UsageStats.enabled(context)) }
var choices by remember { mutableStateOf(UsageStats.choices(context)) }
var noteOpen by remember { mutableStateOf(false) }
fun save() = UsageStats.set(context, on, choices)
SettingsRow(
"Send usage notes",
Modifier.toggleable(on, role = Role.Switch) { on = it; save() },
subtitle = if (BuildConfig.STATS_URL.isBlank()) "This build has no stats server, so it sends nothing" else null,
icon = Icons.Filled.Send,
trailing = { Switch(checked = on, onCheckedChange = null) },
)
AnimatedVisibility(on) {
Column {
SettingsDivider()
Text(
"Also share",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 56.dp, end = 16.dp, top = 12.dp, bottom = 4.dp),
)
Choice("Android version, and whether it's GrapheneOS", choices.android) { choices = choices.copy(android = it); save() }
Choice("Phone model", choices.phone) { choices = choices.copy(phone = it); save() }
Choice("Each car's head unit, model, year, and screen size, and whether it connected", choices.cars) {
choices = choices.copy(cars = it)
save()
}
if (showNote) {
SettingsDivider()
SettingsRow(
"What gets sent",
Modifier.clickable { noteOpen = !noteOpen },
subtitle = "The Android version and phone model go only when they change, and once a month",
icon = Icons.Filled.Info,
trailing = {
Icon(
if (noteOpen) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown,
contentDescription = if (noteOpen) "Hide the note" else "Show the note",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
)
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) }
Surface(
color = MaterialTheme.colorScheme.surfaceContainerLowest,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
) {
Text(
note,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
modifier = Modifier.padding(12.dp),
)
}
}
}
}
}
}
/** A share choice: the label lines up with the rows' titles, the checkbox sits at the end. */
@Composable
private fun Choice(label: String, checked: Boolean, onChange: (Boolean) -> Unit) {
Row(
Modifier.fillMaxWidth().toggleable(checked, role = Role.Checkbox, onValueChange = onChange)
.padding(start = 56.dp, end = 16.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f).padding(end = 12.dp))
Checkbox(checked = checked, onCheckedChange = null, modifier = Modifier.padding(vertical = 8.dp))
}
}