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

+1 -5
View File
@@ -15,8 +15,6 @@ keystore.properties
.cxx/
# Testing
/docs/cert-sweep.sh
extraction/
/cloned-repos
docs-internal/
@@ -31,6 +29,4 @@ CLAUDE.md
# Other
archive/
# Side project notes, kept out of the repo
backseat-jury.md
.wrangler/
+21
View File
@@ -74,6 +74,27 @@ mode.
adb commands: one removes the audio dialog for good, and the other keeps notifications
visible while audio plays.
## Usage notes
Gearslip can send short notes that show which cars it works in. They stay off unless you
turn them on, in setup or in Settings.
- **When they go out.** When you open Gearslip, at most once a day, and after each drive.
Nothing runs in the background.
- **What they carry.** The Gearslip version, plus whatever you tick: the Android version
and whether the phone runs GrapheneOS, the phone model, whether a car connected that
day, and each car's head unit, model, year, screen size, and connection result.
Settings shows the exact note.
- **What they leave out.** Your name, accounts, location, contacts, messages, and
anything you type or play.
- **How phones are counted.** A random number counts each phone once. The server keeps
only a salted hash of it.
- **Where they go.** To a Cloudflare Worker, whose code is in
[stats-worker/](stats-worker/). Cloudflare sees your IP address in delivering a note,
as it would for any website; the Worker doesn't store it.
Turn the notes off, and they stop at once.
## Building
```
+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))
}
}
+3 -1
View File
@@ -21,4 +21,6 @@ kotlin.code.style=official
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
android.dependency.useConstraints=false
android.dependency.useConstraints=false
# Where opted-in phones send their daily usage note (stats-worker/). Blank sends nothing.
gearslipStatsUrl=https://gearslip-stats.gearslip.workers.dev
+3
View File
@@ -0,0 +1,3 @@
.dev.vars
.wrangler/
node_modules/
+52
View File
@@ -0,0 +1,52 @@
-- One row per phone per day: the first note that day counts, later ones update it.
CREATE TABLE IF NOT EXISTS pings (
day TEXT NOT NULL, -- YYYY-MM-DD, UTC
uid TEXT NOT NULL, -- salted hash of the phone's random install ID
version TEXT NOT NULL,
code INTEGER,
build TEXT,
android TEXT, -- these four are from older builds; details now live in phones
os TEXT,
phone TEXT,
car_today INTEGER,
PRIMARY KEY (day, uid)
);
-- Each car a phone saw that day, and how the session ended.
CREATE TABLE IF NOT EXISTS cars (
day TEXT NOT NULL,
uid TEXT NOT NULL,
name TEXT, car TEXT, year TEXT, make TEXT, model TEXT,
protocol TEXT,
result TEXT,
screen TEXT, -- usable pixels, e.g. 1280x720
dpi INTEGER,
PRIMARY KEY (day, uid, name, car, year, protocol, result)
);
CREATE INDEX IF NOT EXISTS pings_uid ON pings (uid);
-- GitHub's running download count for each release's APK, saved once a day.
CREATE TABLE IF NOT EXISTS downloads (
day TEXT NOT NULL,
tag TEXT NOT NULL,
count INTEGER NOT NULL,
PRIMARY KEY (day, tag)
);
-- Which releases are pre-releases, so the dashboard can pick out the latest full release.
CREATE TABLE IF NOT EXISTS releases (
tag TEXT PRIMARY KEY,
prerelease INTEGER NOT NULL,
published TEXT
);
-- Each phone's latest details. Phones send them only when they change (and once a month), so
-- a field a note leaves out keeps its last value.
CREATE TABLE IF NOT EXISTS phones (
uid TEXT PRIMARY KEY,
android TEXT,
os TEXT,
phone TEXT,
updated TEXT
);
+293
View File
@@ -0,0 +1,293 @@
// Gearslip usage notes: opt-in phones POST one short note a day, and a private dashboard counts
// them. No IP address is stored, and the phone's random ID is kept only as a salted hash.
const MAX_BODY = 8 * 1024
const MAX_CARS = 10
const short = (v, n = 80) => (typeof v === 'string' ? v.slice(0, n) : null)
// The tables as schema.sql has them, plus the columns added since, so a database made by an
// older version catches up on its own. Each runs once per Worker instance; an ALTER whose
// column already exists fails, and that's fine.
const SCHEMA = [
`CREATE TABLE IF NOT EXISTS pings (day TEXT NOT NULL, uid TEXT NOT NULL, version TEXT NOT NULL,
code INTEGER, build TEXT, android TEXT, os TEXT, phone TEXT, car_today INTEGER, PRIMARY KEY (day, uid))`,
`CREATE TABLE IF NOT EXISTS cars (day TEXT NOT NULL, uid TEXT NOT NULL, name TEXT, car TEXT, year TEXT,
make TEXT, model TEXT, protocol TEXT, result TEXT, screen TEXT, dpi INTEGER,
PRIMARY KEY (day, uid, name, car, year, protocol, result))`,
`CREATE INDEX IF NOT EXISTS pings_uid ON pings (uid)`,
`CREATE TABLE IF NOT EXISTS downloads (day TEXT NOT NULL, tag TEXT NOT NULL, count INTEGER NOT NULL, PRIMARY KEY (day, tag))`,
`CREATE TABLE IF NOT EXISTS releases (tag TEXT PRIMARY KEY, prerelease INTEGER NOT NULL, published TEXT)`,
// Each phone's latest details. Phones send them only when they change, so a field a note
// leaves out keeps its last value.
`CREATE TABLE IF NOT EXISTS phones (uid TEXT PRIMARY KEY, android TEXT, os TEXT, phone TEXT, updated TEXT)`,
]
const ADDED_COLUMNS = [
`ALTER TABLE pings ADD COLUMN os TEXT`,
`ALTER TABLE cars ADD COLUMN screen TEXT`,
`ALTER TABLE cars ADD COLUMN dpi INTEGER`,
// Older builds sent details in every note: carry the latest of those over once.
`INSERT OR IGNORE INTO phones (uid, android, os, phone, updated)
SELECT uid, android, os, phone, day FROM pings p
WHERE day = (SELECT MAX(day) FROM pings q WHERE q.uid = p.uid)
AND (android IS NOT NULL OR phone IS NOT NULL)`,
]
let schemaReady = null
function ensureSchema(env) {
schemaReady ??= (async () => {
for (const sql of SCHEMA) await env.DB.prepare(sql).run()
for (const sql of ADDED_COLUMNS) await env.DB.prepare(sql).run().catch(() => {})
})().catch((e) => { schemaReady = null; throw e })
return schemaReady
}
export default {
async fetch(request, env) {
await ensureSchema(env)
const url = new URL(request.url)
if (request.method === 'POST' && url.pathname === '/ping') return ping(request, env)
if (request.method === 'GET' && (url.pathname === '/' || url.pathname === '/api/summary')) {
if (!env.DASHBOARD_KEY || url.searchParams.get('key') !== env.DASHBOARD_KEY) {
return new Response('Not found', { status: 404 })
}
// The daily trigger normally saves today's counts; this covers a missed or late one.
await snapshotDownloads(env, false).catch((e) => console.log('github:', e.message))
const summary = await summarize(env)
if (url.pathname === '/api/summary') return Response.json(summary)
return new Response(dashboard(summary), { headers: { 'content-type': 'text/html; charset=utf-8' } })
}
return new Response('Not found', { status: 404 })
},
async scheduled(event, env) {
await ensureSchema(env)
await snapshotDownloads(env, true)
},
}
// Saves each release's APK download count for today. GitHub only keeps a running total, so
// downloads per day come from the difference between days.
async function snapshotDownloads(env, force) {
if (!env.GITHUB_REPO) return
const day = today()
// Today's counts and the release list both have to be there; either missing means fetch again.
if (!force && (await env.DB.prepare(`SELECT 1 WHERE EXISTS (SELECT 1 FROM downloads WHERE day = ?)
AND EXISTS (SELECT 1 FROM releases)`).bind(day).first())) return
const headers = { 'user-agent': 'gearslip-stats', accept: 'application/vnd.github+json' }
if (env.GITHUB_TOKEN) headers.authorization = `Bearer ${env.GITHUB_TOKEN}`
const res = await fetch(`https://api.github.com/repos/${env.GITHUB_REPO}/releases?per_page=100`, { headers })
if (!res.ok) throw new Error(`GitHub answered ${res.status}`)
const statements = (await res.json()).filter((release) => !release.draft).flatMap((release) => {
const count = release.assets.filter((a) => a.name.endsWith('.apk')).reduce((n, a) => n + a.download_count, 0)
return [
env.DB.prepare('INSERT OR REPLACE INTO downloads (day, tag, count) VALUES (?, ?, ?)').bind(day, release.tag_name, count),
env.DB.prepare('INSERT OR REPLACE INTO releases (tag, prerelease, published) VALUES (?, ?, ?)')
.bind(release.tag_name, release.prerelease ? 1 : 0, release.published_at),
]
})
if (statements.length) await env.DB.batch(statements)
}
const today = () => new Date().toISOString().slice(0, 10)
// Tags are "v0.1.10-alpha" or "0.0.05-alpha"; the app reports "0.1.10-alpha".
const versionOf = (tag) => tag.replace(/^v/, '')
async function ping(request, env) {
const text = await request.text()
if (text.length > MAX_BODY) return new Response('Too large', { status: 413 })
let note
try {
note = JSON.parse(text)
} catch {
return new Response('Bad JSON', { status: 400 })
}
const id = short(note.id, 64)
const version = short(note.version, 32)
if (!id || !version) return new Response('Missing id or version', { status: 400 })
const uid = await hash(`${env.SALT}:${id}`)
const day = today()
const android = short(note.android, 16), os = short(note.os, 16), phone = short(note.phone)
const statements = [
env.DB.prepare(
`INSERT INTO pings (day, uid, version, code, build) VALUES (?, ?, ?, ?, ?)
ON CONFLICT (day, uid) DO UPDATE SET version = excluded.version, code = excluded.code, build = excluded.build`,
).bind(day, uid, version, Number.isInteger(note.code) ? note.code : null, short(note.build, 16)),
]
if (android || os || phone) {
statements.push(
env.DB.prepare(
`INSERT INTO phones (uid, android, os, phone, updated) VALUES (?, ?, ?, ?, ?)
ON CONFLICT (uid) DO UPDATE SET android = COALESCE(excluded.android, android),
os = COALESCE(excluded.os, os), phone = COALESCE(excluded.phone, phone), updated = excluded.updated`,
).bind(uid, android, os, phone, day),
)
}
for (const car of Array.isArray(note.cars) ? note.cars.slice(0, MAX_CARS) : []) {
statements.push(
env.DB.prepare(
`INSERT OR IGNORE INTO cars (day, uid, name, car, year, make, model, protocol, result, screen, dpi)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).bind(day, uid, short(car.name) ?? '', short(car.car) ?? '', short(car.year, 8) ?? '',
short(car.make) ?? '', short(car.model) ?? '', short(car.protocol, 8) ?? '', short(car.result) ?? '',
short(car.screen, 16), Number.isInteger(car.dpi) ? car.dpi : null),
)
}
await env.DB.batch(statements)
return new Response(null, { status: 204 })
}
async function hash(text) {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
return [...new Uint8Array(digest)].slice(0, 16).map((b) => b.toString(16).padStart(2, '0')).join('')
}
async function summarize(env) {
const all = async (sql) => (await env.DB.prepare(sql).all()).results
const one = async (sql) => (await env.DB.prepare(sql).first()) ?? {}
const latest = await all(`SELECT d.tag, d.count, r.prerelease, r.published FROM downloads d
LEFT JOIN releases r ON r.tag = d.tag
WHERE d.day = (SELECT MAX(day) FROM downloads)`)
const seen = Object.fromEntries((await all(`SELECT version, COUNT(DISTINCT uid) AS n FROM pings
WHERE build = 'release' GROUP BY version`)).map((r) => [r.version, r.n]))
// The newest full release, the one most people are sent to; pre-releases don't count.
const newest = await one(`SELECT tag FROM releases WHERE prerelease = 0 ORDER BY published DESC LIMIT 1`)
const latestRelease = newest.tag ? { tag: newest.tag, downloads: latest.find((r) => r.tag === newest.tag)?.count ?? 0 } : null
const releases = latest.map((r) => ({
tag: r.tag,
kind: r.prerelease ? 'Pre-release' : r.prerelease === 0 ? 'Release' : '',
published: (r.published ?? '').slice(0, 10),
downloads: r.count,
phones: seen[versionOf(r.tag)] ?? 0,
}))
.map((r) => ({ ...r, share: r.phones && r.downloads ? `${Math.round((100 * r.phones) / r.downloads)}%` : '' }))
.sort((a, b) => b.published.localeCompare(a.published))
// Only releases that have sent anything can say what share of people send; older ones predate the notes.
const counted = releases.filter((r) => r.phones > 0)
const countedDownloads = counted.reduce((n, r) => n + r.downloads, 0)
const countedPhones = counted.reduce((n, r) => n + r.phones, 0)
const totals = await all(`SELECT day, SUM(count) AS total FROM downloads GROUP BY day ORDER BY day`)
const newPhones = Object.fromEntries((await all(`SELECT first_day AS day, COUNT(*) AS n FROM
(SELECT uid, MIN(day) AS first_day FROM pings WHERE build = 'release' GROUP BY uid)
GROUP BY first_day`)).map((r) => [r.day, r.n]))
const perDay = totals.slice(1).map((r, i) => ({ day: r.day, downloads: r.total - totals[i].total, new_phones: newPhones[r.day] ?? 0 }))
return {
latestRelease,
releases,
totalDownloads: releases.reduce((n, r) => n + r.downloads, 0),
share: countedDownloads ? Math.round((100 * countedPhones) / countedDownloads) : null,
perDay: perDay.slice(-60).reverse(),
daily: await all(`SELECT p.day, COUNT(*) AS phones,
SUM(EXISTS (SELECT 1 FROM cars c WHERE c.uid = p.uid AND c.day = p.day
AND c.result LIKE 'connected%')) AS in_a_car,
SUM(p.day = f.first_day) AS new_phones
FROM pings p JOIN (SELECT uid, MIN(day) AS first_day FROM pings GROUP BY uid) f
ON f.uid = p.uid
WHERE p.day >= date('now', '-60 days') GROUP BY p.day ORDER BY p.day`),
// Android runs no code at uninstall, so a phone that stopped sending is the nearest sign.
new30: (await one(`SELECT COUNT(*) AS n FROM (SELECT uid, MIN(day) AS first_day FROM pings GROUP BY uid)
WHERE first_day >= date('now', '-30 days')`)).n ?? 0,
quiet: (await one(`SELECT COUNT(*) AS n FROM (SELECT uid, MAX(day) AS last_day FROM pings GROUP BY uid)
WHERE last_day < date('now', '-14 days')`)).n ?? 0,
everyone: (await one(`SELECT COUNT(DISTINCT uid) AS n FROM pings`)).n ?? 0,
active7: (await one(`SELECT COUNT(DISTINCT uid) AS n FROM pings WHERE day >= date('now', '-7 days')`)).n ?? 0,
active30: (await one(`SELECT COUNT(DISTINCT uid) AS n FROM pings WHERE day >= date('now', '-30 days')`)).n ?? 0,
driving30: (await one(`SELECT COUNT(DISTINCT uid) AS n FROM cars
WHERE result LIKE 'connected%' AND day >= date('now', '-30 days')`)).n ?? 0,
versions: await all(`SELECT version, COUNT(DISTINCT uid) AS phones FROM pings
WHERE day >= date('now', '-7 days') GROUP BY version ORDER BY phones DESC`),
// Details come from each phone's latest, counted for phones that sent anything in 30 days.
android: await all(`SELECT android, COUNT(*) AS phones FROM phones
WHERE android IS NOT NULL AND uid IN (SELECT uid FROM pings WHERE day >= date('now', '-30 days'))
GROUP BY android ORDER BY phones DESC`),
screens: await all(`SELECT screen, COUNT(DISTINCT uid) AS phones FROM cars
WHERE screen IS NOT NULL GROUP BY screen ORDER BY phones DESC`),
os: await all(`SELECT os, COUNT(*) AS phones FROM phones
WHERE os IS NOT NULL AND uid IN (SELECT uid FROM pings WHERE day >= date('now', '-30 days'))
GROUP BY os ORDER BY phones DESC`),
phones: await all(`SELECT phone, COUNT(*) AS phones FROM phones
WHERE phone IS NOT NULL AND uid IN (SELECT uid FROM pings WHERE day >= date('now', '-30 days'))
GROUP BY phone ORDER BY phones DESC LIMIT 30`),
cars: await all(`SELECT name, car, year, make, model, protocol,
GROUP_CONCAT(DISTINCT screen || CASE WHEN dpi THEN ' @ ' || dpi || ' dpi' ELSE '' END) AS screen,
SUM(result = 'connected') AS connected,
SUM(result != 'connected') AS failed,
GROUP_CONCAT(DISTINCT CASE WHEN result != 'connected' THEN result END) AS failures,
COUNT(DISTINCT uid) AS phones, MAX(day) AS last_seen
FROM cars GROUP BY name, car, year, make, model, protocol
ORDER BY phones DESC, connected DESC LIMIT 200`),
}
}
const esc = (v) => String(v ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c])
function table(rows, columns) {
if (!rows.length) return '<p class="muted">Nothing yet.</p>'
const head = columns.map(([, label]) => `<th>${esc(label)}</th>`).join('')
const body = rows.map((r) => `<tr>${columns.map(([key]) => `<td>${esc(r[key])}</td>`).join('')}</tr>`).join('')
return `<div class="scroll"><table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`
}
function chart(daily) {
if (!daily.length) return '<p class="muted">No notes yet.</p>'
const w = 600, h = 160, max = Math.max(...daily.map((d) => d.phones), 1), bw = w / daily.length
const bars = daily.map((d, i) => {
const ph = (d.phones / max) * (h - 20), ch = ((d.in_a_car ?? 0) / max) * (h - 20)
return `<rect x="${i * bw + 1}" y="${h - ph}" width="${Math.max(bw - 2, 1)}" height="${ph}" class="all"><title>${esc(d.day)}: ${d.phones} phones</title></rect>` +
`<rect x="${i * bw + 1}" y="${h - ch}" width="${Math.max(bw - 2, 1)}" height="${ch}" class="car"><title>${esc(d.day)}: ${d.in_a_car ?? 0} in a car</title></rect>`
}).join('')
return `<svg viewBox="0 0 ${w} ${h}" role="img" aria-label="Phones per day">${bars}</svg>
<p class="muted"><span class="key all"></span> phones that day <span class="key car"></span> of those, in a car</p>`
}
function dashboard(s) {
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"><title>Gearslip usage</title>
<style>
:root { --bg:#fafafa; --fg:#1a1a1a; --muted:#666; --line:#ddd; --all:#9db4d6; --car:#2e7d32; }
@media (prefers-color-scheme: dark) { :root { --bg:#121212; --fg:#eee; --muted:#999; --line:#333; --all:#3d5a80; --car:#66bb6a; } }
body { background:var(--bg); color:var(--fg); font:16px/1.5 system-ui, sans-serif; margin:0 auto; padding:16px; max-width:960px; }
h1 { font-size:1.5rem; margin:0 0 4px; } h2 { font-size:1.1rem; margin:28px 0 8px; }
.muted { color:var(--muted); font-size:.9rem; }
.stats { display:grid; grid-template-columns:repeat(auto-fit, minmax(150px, 1fr)); gap:12px; }
.stat.big { margin-bottom:12px; padding:20px; } .stat.big b { font-size:3.2rem; line-height:1.1; }
.stat { border:1px solid var(--line); border-radius:12px; padding:12px; } .stat b { display:block; font-size:1.8rem; }
svg { width:100%; height:auto; } rect.all { fill:var(--all); } rect.car { fill:var(--car); }
.key { display:inline-block; width:10px; height:10px; border-radius:2px; margin:0 4px 0 12px; }
.key.all { background:var(--all); } .key.car { background:var(--car); }
.scroll { overflow-x:auto; } table { border-collapse:collapse; width:100%; font-size:.9rem; }
th, td { text-align:left; padding:6px 8px; border-bottom:1px solid var(--line); white-space:nowrap; }
</style></head><body>
<h1>Gearslip usage</h1><p class="muted">From phones that opted in to daily notes.</p>
${s.latestRelease ? `<div class="stat big"><b>${s.latestRelease.downloads}</b>downloads of ${esc(s.latestRelease.tag)}, the latest release</div>` : ''}
<div class="stats">
<div class="stat"><b>${s.active7}</b>phones, last 7 days</div>
<div class="stat"><b>${s.active30}</b>phones, last 30 days</div>
<div class="stat"><b>${s.driving30}</b>used in a car, last 30 days</div>
<div class="stat"><b>${s.new30}</b>new phones, last 30 days</div>
<div class="stat"><b>${s.quiet}</b>gone quiet (nothing in 14 days)</div>
<div class="stat"><b>${s.everyone}</b>phones ever</div>
</div>
<p class="muted">Android tells an app nothing when it's uninstalled, so "gone quiet" is the nearest
sign: phones that sent notes before and stopped. Turning the notes off looks the same.</p>
<h2>Downloads and phones</h2>
<div class="stats">
<div class="stat"><b>${s.totalDownloads}</b>APK downloads on GitHub, all releases</div>
<div class="stat"><b>${s.share == null ? '–' : s.share + '%'}</b>of downloads send notes</div>
</div>
<p class="muted">The share counts only releases that have sent at least one note, and only release builds.
A download isn't always a new person: updating means downloading again, and some people download
twice. So read it as a rough guide, and multiply the phones above by about ${s.share ? Math.round(100 / s.share) : '–'} to estimate everyone.</p>
${table(s.releases, [['tag', 'Release'], ['kind', 'Type'], ['published', 'Published'], ['downloads', 'Downloads'], ['phones', 'Phones sending'], ['share', 'Share']])}
<h2>Downloads per day</h2>${s.perDay.length ? table(s.perDay, [['day', 'Day'], ['downloads', 'Downloads'], ['new_phones', 'New phones']])
: '<p class="muted">Counts are saved once a day, so this fills in from tomorrow.</p>'}
<h2>Phones per day</h2>${chart(s.daily)}
<h2>New phones per day</h2>${table(s.daily.filter((d) => d.new_phones > 0).slice().reverse(), [['day', 'Day'], ['new_phones', 'New phones']])}
<h2>Cars</h2>${table(s.cars, [['name', 'Head unit'], ['car', 'Car'], ['year', 'Year'], ['make', 'Maker'], ['model', 'Unit model'],
['protocol', 'Protocol'], ['screen', 'Screen'], ['connected', 'Connected'], ['failed', 'Failed'], ['failures', 'Why it failed'], ['phones', 'Phones'], ['last_seen', 'Last seen']])}
<h2>Car screens</h2>${table(s.screens, [['screen', 'Usable size'], ['phones', 'Phones']])}
<h2>Versions, last 7 days</h2>${table(s.versions, [['version', 'Version'], ['phones', 'Phones']])}
<h2>Phones</h2>${table(s.phones, [['phone', 'Phone'], ['phones', 'Count']])}
<h2>GrapheneOS, last 30 days</h2>${table(s.os, [['os', 'System'], ['phones', 'Phones']])}
<h2>Android</h2>${table(s.android, [['android', 'Android'], ['phones', 'Count']])}
</body></html>`
}
+23
View File
@@ -0,0 +1,23 @@
# Gearslip's opt-in usage notes. Deploy with `npx wrangler deploy` from this folder.
# Setup, once:
# npx wrangler d1 create gearslip-stats (paste the id it prints below)
# npx wrangler d1 execute gearslip-stats --remote --file schema.sql
# npx wrangler secret put SALT (any long random string; keep it)
# npx wrangler secret put DASHBOARD_KEY (the key for the dashboard link)
name = "gearslip-stats"
main = "src/index.js"
compatibility_date = "2026-10-01"
[[d1_databases]]
binding = "DB"
database_name = "gearslip-stats"
database_id = "201521a7-0b4a-4fff-ab11-d575b821528a"
# The public repo whose release downloads the dashboard compares against. If GitHub starts
# refusing (60 requests an hour without a token), add one: npx wrangler secret put GITHUB_TOKEN
[vars]
GITHUB_REPO = "Seb3thehacker/gearslip"
# Save the download counts once a day, so the dashboard can show downloads per day.
[triggers]
crons = ["17 6 * * *"]