From 17534d5784f6267f795058b3e2ad9c76bc7db279 Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Tue, 6 Oct 2026 00:06:06 -0700 Subject: [PATCH 01/11] update checker: finds new releases, downloads and installs them Checks GitHub's latest release once a day when the app opens and after a drive. A new version shows as a card on Home, a notification after the drive, and a card on the car screen at plug-in while parked. Downloads are checked against GitHub's SHA-256 and never install while the car is connected. Update notifications can be turned off in Settings. --- gearslip/build.gradle.kts | 3 +- gearslip/src/main/AndroidManifest.xml | 1 + .../gearslip/GearslipActivity.kt | 1 + .../seb3thehacker/gearslip/SessionReport.kt | 1 + .../app/seb3thehacker/gearslip/car/CarUi.kt | 2 + .../seb3thehacker/gearslip/ui/HomeScreen.kt | 1 + .../gearslip/ui/SettingsScreen.kt | 6 +- .../seb3thehacker/gearslip/ui/UpdateViews.kt | 146 +++++++ .../gearslip/update/UpdateChecker.kt | 374 ++++++++++++++++++ gearslip/src/main/res/xml/share_paths.xml | 3 +- 10 files changed, 535 insertions(+), 3 deletions(-) create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UpdateViews.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/update/UpdateChecker.kt diff --git a/gearslip/build.gradle.kts b/gearslip/build.gradle.kts index 0f8635e..6eed1ad 100644 --- a/gearslip/build.gradle.kts +++ b/gearslip/build.gradle.kts @@ -33,7 +33,8 @@ android { minSdk = 31 targetSdk = 37 versionCode = 8 - versionName = "0.2.00-alpha" + // -PgearslipVersionName=0.1.99-test makes a build that thinks it's older, to try the updater. + versionName = project.findProperty("gearslipVersionName")?.toString() ?: "0.2.00-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. diff --git a/gearslip/src/main/AndroidManifest.xml b/gearslip/src/main/AndroidManifest.xml index 4dc1ede..fe7b03f 100644 --- a/gearslip/src/main/AndroidManifest.xml +++ b/gearslip/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ + + + From 25b8bbb1a54231784bea7c85cdbaa01a69942191 Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Tue, 6 Oct 2026 23:33:18 -0700 Subject: [PATCH 02/11] keep the stats server out of the app repo --- .gitignore | 3 +- stats-worker/.gitignore | 3 - stats-worker/schema.sql | 52 ------- stats-worker/src/index.js | 299 ------------------------------------- stats-worker/wrangler.toml | 23 --- 5 files changed, 2 insertions(+), 378 deletions(-) delete mode 100644 stats-worker/.gitignore delete mode 100644 stats-worker/schema.sql delete mode 100644 stats-worker/src/index.js delete mode 100644 stats-worker/wrangler.toml diff --git a/.gitignore b/.gitignore index fdd2771..d72c470 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,5 @@ CLAUDE.md # Other archive/ -.wrangler/ \ No newline at end of file +.wrangler/ +stats-worker/ diff --git a/stats-worker/.gitignore b/stats-worker/.gitignore deleted file mode 100644 index 18433e3..0000000 --- a/stats-worker/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -.dev.vars -.wrangler/ -node_modules/ diff --git a/stats-worker/schema.sql b/stats-worker/schema.sql deleted file mode 100644 index b04466d..0000000 --- a/stats-worker/schema.sql +++ /dev/null @@ -1,52 +0,0 @@ --- 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 -); diff --git a/stats-worker/src/index.js b/stats-worker/src/index.js deleted file mode 100644 index 6d34d12..0000000 --- a/stats-worker/src/index.js +++ /dev/null @@ -1,299 +0,0 @@ -// 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 }) - } - // Opening the dashboard refreshes today's counts, at most every few minutes, so it matches - // GitHub instead of showing the morning's numbers. GitHub allows 60 requests an hour. - const stale = Date.now() - lastFetched > REFRESH_MS - await snapshotDownloads(env, stale).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. -let lastFetched = 0 -const REFRESH_MS = 5 * 60 * 1000 - -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) - lastFetched = Date.now() -} - -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]) - -function table(rows, columns) { - if (!rows.length) return '

Nothing yet.

' - const head = columns.map(([, label]) => `${esc(label)}`).join('') - const body = rows.map((r) => `${columns.map(([key]) => `${esc(r[key])}`).join('')}`).join('') - return `
${head}${body}
` -} - -function chart(daily) { - if (!daily.length) return '

No notes yet.

' - 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 `${esc(d.day)}: ${d.phones} phones` + - `${esc(d.day)}: ${d.in_a_car ?? 0} in a car` - }).join('') - return `${bars} -

phones that day of those, in a car

` -} - -function dashboard(s) { - return ` -Gearslip usage - -

Gearslip usage

From phones that opted in to daily notes.

-${s.latestRelease ? `
${s.latestRelease.downloads}downloads of ${esc(s.latestRelease.tag)}, the latest release
` : ''} -
-
${s.active7}phones, last 7 days
-
${s.active30}phones, last 30 days
-
${s.driving30}used in a car, last 30 days
-
${s.new30}new phones, last 30 days
-
${s.quiet}gone quiet (nothing in 14 days)
-
${s.everyone}phones ever
-
-

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.

-

Downloads and phones

-
-
${s.totalDownloads}APK downloads on GitHub, all releases
-
${s.share == null ? '–' : s.share + '%'}of downloads send notes
-
-

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.

-${table(s.releases, [['tag', 'Release'], ['kind', 'Type'], ['published', 'Published'], ['downloads', 'Downloads'], ['phones', 'Phones sending'], ['share', 'Share']])} -

Downloads per day

${s.perDay.length ? table(s.perDay, [['day', 'Day'], ['downloads', 'Downloads'], ['new_phones', 'New phones']]) - : '

Counts are saved once a day, so this fills in from tomorrow.

'} -

Phones per day

${chart(s.daily)} -

New phones per day

${table(s.daily.filter((d) => d.new_phones > 0).slice().reverse(), [['day', 'Day'], ['new_phones', 'New phones']])} -

Cars

${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']])} -

Car screens

${table(s.screens, [['screen', 'Usable size'], ['phones', 'Phones']])} -

Versions, last 7 days

${table(s.versions, [['version', 'Version'], ['phones', 'Phones']])} -

Phones

${table(s.phones, [['phone', 'Phone'], ['phones', 'Count']])} -

GrapheneOS, last 30 days

${table(s.os, [['os', 'System'], ['phones', 'Phones']])} -

Android

${table(s.android, [['android', 'Android'], ['phones', 'Count']])} -` -} diff --git a/stats-worker/wrangler.toml b/stats-worker/wrangler.toml deleted file mode 100644 index e80c185..0000000 --- a/stats-worker/wrangler.toml +++ /dev/null @@ -1,23 +0,0 @@ -# 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 * * *"] From 1af5eae8e763e13e7857c6ec0563a5c74dc5a99b Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Tue, 6 Oct 2026 15:26:45 -0700 Subject: [PATCH 03/11] usage notes switch on the Home screen --- .../seb3thehacker/gearslip/ui/HomeScreen.kt | 1 + .../seb3thehacker/gearslip/ui/UpdateViews.kt | 2 ++ .../gearslip/ui/UsageStatsChoices.kt | 21 +++++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt index b8fe08d..0680697 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt @@ -92,6 +92,7 @@ fun HomeScreen( StatusCard(status) Spacer(Modifier.height(16.dp)) UpdateCard() + UsageNotesHomeCard() } // Sits at the bottom, where a thumb reaches it. diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UpdateViews.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UpdateViews.kt index 03b71ab..a5b23e5 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UpdateViews.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UpdateViews.kt @@ -96,6 +96,8 @@ fun UpdateCard() { } } } + // Room before whatever Home shows next; nothing when the card is hidden. + Spacer(Modifier.height(16.dp)) } /** The Settings row: checks on tap, and carries on to download and install once something is found. */ diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UsageStatsChoices.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UsageStatsChoices.kt index 70fbc61..da00891 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UsageStatsChoices.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UsageStatsChoices.kt @@ -152,3 +152,24 @@ private fun Choice(label: String, checked: Boolean, onChange: (Boolean) -> Unit) Checkbox(checked = checked, onCheckedChange = null, modifier = Modifier.padding(vertical = 8.dp)) } } + +/** Usage notes on Home, always: the switch, and once it's on, what to share. */ +@Composable +internal fun UsageNotesHomeCard(modifier: Modifier = Modifier) { + Column(modifier) { + Text( + USAGE_STATS_TITLE, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 16.dp, bottom = 4.dp), + ) + Text( + "Share a short note after each drive, so the next driver knows whether Gearslip works " + + "in their car. No name, account, or location.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 12.dp), + ) + SettingsCard { UsageStatsRows() } + } +} From dfebae93b389bb2c596b95c5b6c9290e8a7b7b6e Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Tue, 6 Oct 2026 15:26:45 -0700 Subject: [PATCH 04/11] higher video bitrate, scaled to the car's screen 800x480 at 30fps gets 8 Mbps instead of 4, and larger screens scale from there. The encoder now declares the H.264 level each size needs, and the bitrate stays within that level's limit. --- .../app/seb3thehacker/gearslip/VideoSource.kt | 45 ++++++++++++++++--- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/VideoSource.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/VideoSource.kt index 8a7aa81..02b74f4 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/VideoSource.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/VideoSource.kt @@ -45,7 +45,8 @@ class VideoSource( private var frameIndex = 0L fun start() { - val bitRate = (width.toLong() * height * frameRate * BITS_PER_PIXEL).toInt().coerceIn(MIN_BIT_RATE, MAX_BIT_RATE) + val level = levelFor(width, height, frameRate) + val bitRate = defaultBitRate(width, height, frameRate, level) fun format(tuned: Boolean) = MediaFormat.createVideoFormat(MIME, width, height).apply { setInteger( MediaFormat.KEY_COLOR_FORMAT, @@ -74,7 +75,7 @@ class VideoSource( setFloat(MediaFormat.KEY_MAX_FPS_TO_ENCODER, frameRate.toFloat()) } setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline) - setInteger(MediaFormat.KEY_LEVEL, MediaCodecInfo.CodecProfileLevel.AVCLevel31) + setInteger(MediaFormat.KEY_LEVEL, level.codecLevel) if (tuned) { // Hints only: a car screen wants each frame now, not the best frame a moment later. setInteger(MediaFormat.KEY_BITRATE_MODE, MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR) @@ -98,7 +99,7 @@ class VideoSource( codec = encoder running = true - log.i("encoder started: ${width}x$height @ ${frameRate}fps, ${bitRate / 1000} kbps, H.264 Baseline, mode=$mode") + log.i("encoder started: ${width}x$height @ ${frameRate}fps, ${bitRate / 1000} kbps, H.264 Baseline level ${level.label}, mode=$mode") worker = Thread { runCatching { pump(encoder) } @@ -223,10 +224,40 @@ class VideoSource( const val TIMEOUT_US = 10_000L const val SURFACE_TIMEOUT_US = 100_000L - /** Enough for sharp text and map lines; 800x480 at 30fps comes out at 4 Mbps. */ - const val BITS_PER_PIXEL = 0.3 - const val MIN_BIT_RATE = 4_000_000 - const val MAX_BIT_RATE = 12_000_000 + /** + * 800x480 at 30fps gets 8 Mbps: about the most that size can show, enough that a moving + * video in the browser stays sharp. Bigger screens get more, but less than in proportion, + * since large frames compress better: bitrate grows with pixels^0.75. 60fps gets 1.5x, + * because frames that close together are mostly alike. + */ + const val BASE_BIT_RATE = 8_000_000.0 + const val BASE_PIXELS = 800.0 * 480 + /** No car's decoder is asked for more than this, whatever its level allows. */ + const val MAX_BIT_RATE = 25_000_000 + + /** + * The H.264 levels a car screen can need, smallest first: macroblocks a second each + * decodes, and the Baseline bitrate it must handle. The encoder declares the smallest + * that fits, and the bitrate stays within it, so a car decoder isn't asked for more than + * the level it was told about. + */ + enum class Level(val label: String, val codecLevel: Int, val macroblocksPerSecond: Int, val maxBitRate: Int) { + L31("3.1", MediaCodecInfo.CodecProfileLevel.AVCLevel31, 108_000, 14_000_000), + L32("3.2", MediaCodecInfo.CodecProfileLevel.AVCLevel32, 216_000, 20_000_000), + L4("4", MediaCodecInfo.CodecProfileLevel.AVCLevel4, 245_760, 20_000_000), + L42("4.2", MediaCodecInfo.CodecProfileLevel.AVCLevel42, 522_240, 50_000_000), + } + + fun levelFor(width: Int, height: Int, frameRate: Int): Level { + val perSecond = ((width + 15) / 16) * ((height + 15) / 16) * frameRate + return Level.entries.firstOrNull { perSecond <= it.macroblocksPerSecond } ?: Level.L42 + } + + fun defaultBitRate(width: Int, height: Int, frameRate: Int, level: Level): Int { + val size = Math.pow(width.toDouble() * height / BASE_PIXELS, 0.75) + val rate = if (frameRate > 30) 1.5 else 1.0 + return (BASE_BIT_RATE * size * rate).toInt().coerceAtMost(minOf(level.maxBitRate, MAX_BIT_RATE)) + } const val I_FRAME_INTERVAL_S = 5 private const val REPEAT_AFTER_US = 100_000L From c57abecc62e3949c0cd3f0a087ca16d21e43737e Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Tue, 6 Oct 2026 16:27:37 -0700 Subject: [PATCH 05/11] send song data to car screens --- .../seb3thehacker/gearslip/GearslipRunner.kt | 18 +++ .../app/seb3thehacker/gearslip/Protobuf.kt | 3 + .../gearslip/ServiceDiscovery.kt | 10 ++ .../seb3thehacker/gearslip/car/CarServices.kt | 3 + .../gearslip/media/CarMediaStatus.kt | 119 ++++++++++++++++++ 5 files changed, 153 insertions(+) create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMediaStatus.kt diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt index 9dbb6aa..720f112 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt @@ -3,6 +3,7 @@ package app.seb3thehacker.gearslip import android.os.Build import app.seb3thehacker.gearslip.audio.AudioLink import app.seb3thehacker.gearslip.audio.CarMic +import app.seb3thehacker.gearslip.media.CarMediaStatus import app.seb3thehacker.gearslip.car.CarAssistant import app.seb3thehacker.gearslip.car.CarEnvironment import app.seb3thehacker.gearslip.car.CarKeys @@ -68,6 +69,7 @@ class GearslipRunner( @Volatile private var inputChannelId: Int = -1 @Volatile private var inputKeycodes: List = emptyList() @Volatile private var sensorChannelId: Int = -1 + @Volatile private var mediaStatus: CarMediaStatus? = null private var sensorTypes: List = emptyList() @Volatile private var audioLink: AudioLink? = null private var audioMessagesSeen = 0 @@ -141,6 +143,8 @@ class GearslipRunner( audioLink = null CarSensors.clear() CarMic.current = null + mediaStatus?.stop() + mediaStatus = null lastSensorNight = null CarEnvironment.setSensorNight(null) // back to the clock until a car says otherwise projection?.onProjectionStopped() @@ -176,6 +180,8 @@ class GearslipRunner( onInputMessage(messageId, body) } else if (frame.channel == sensorChannelId) { onSensorMessage(messageId, body) + } else if (frame.channel == mediaStatus?.channelId) { + mediaStatus?.onMessage(messageId, body) } else if (frame.channel == CarMic.current?.channelId) { CarMic.current?.onMessage(messageId, body) } else if (frame.channel == audioLink?.channelId) { @@ -417,6 +423,7 @@ class GearslipRunner( startAudioChannel(serviceDiscoveryResponse) startSensorChannel(serviceDiscoveryResponse) startMicChannel(serviceDiscoveryResponse) + startMediaStatusChannel(serviceDiscoveryResponse) val input = ServiceDiscovery.findInputService(serviceDiscoveryResponse, video.displayId) if (input == null) { @@ -478,6 +485,17 @@ class GearslipRunner( openChannel(mic.serviceId) } + /** What's playing, for the car's own screens: the cluster, the media source page. */ + private fun startMediaStatusChannel(serviceDiscoveryResponse: ByteArray) { + val channel = ServiceDiscovery.findMediaStatusChannel(serviceDiscoveryResponse) ?: run { + log.i("no media status channel - the car's own screens won't show what's playing") + return + } + log.i("media status channel: $channel") + mediaStatus = CarMediaStatus(channel) { id, body -> send(id, body, encrypted = true, channel = channel) } + openChannel(channel) + } + /** * Every sensor type the head unit advertised, subscribed to at once - there is no screen * to pick and choose yet, this is meant to show everything reachable. [CarSensors] answers diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/Protobuf.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/Protobuf.kt index e253147..c651284 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/Protobuf.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/Protobuf.kt @@ -31,6 +31,9 @@ object Protobuf { return varint(((field shl 3) or 2).toLong()) + varint(bytes.size.toLong()) + bytes } + fun bytesField(field: Int, bytes: ByteArray): ByteArray = + varint(((field shl 3) or 2).toLong()) + varint(bytes.size.toLong()) + bytes + fun varintField(field: Int, value: Long): ByteArray = varint((field shl 3).toLong()) + varint(value) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt index 3cf57e6..686956f 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt @@ -110,6 +110,16 @@ object ServiceDiscovery { return media.firstOrNull { it.codecType == CODEC_PCM } ?: media.firstOrNull() } + /** + * The channel the car takes "now playing" on (MediaPlaybackStatusService, Service field 9), + * which feeds its cluster and media screens. It carries no settings. Null when the car has none. + */ + fun findMediaStatusChannel(response: ByteArray): Int? = + Wire.allBytes(Wire.fields(response), 1).firstNotNullOfOrNull { channel -> + val service = Wire.fields(channel) + if (service.any { it.number == 9 }) Wire.varint(service, 1)?.toInt() else null + } + /** * The car's microphone, from MediaSourceService (Service field 5): * MediaSourceService { MediaCodecType available_type = 1; AudioConfiguration audio_config = 2; diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt index cb8e114..3deb6de 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt @@ -33,6 +33,9 @@ object CarServices { val nav: CarAppConnection by lazy { CarAppConnection(requireNotNull(context)) } val media: CarMedia by lazy { CarMedia(requireNotNull(context)) } + /** [media], or null before the car screen has set things up: for code outside the car UI. */ + fun mediaOrNull(): CarMedia? = if (context != null) media else null + /** * A second, independent connection for templated apps that aren't navigation apps - Spotify's * own Car App Library service is the first of these. Kept apart from [nav] so opening one diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMediaStatus.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMediaStatus.kt new file mode 100644 index 0000000..0c1088a --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMediaStatus.kt @@ -0,0 +1,119 @@ +package app.seb3thehacker.gearslip.media + +import android.graphics.Bitmap +import android.media.session.PlaybackState +import app.seb3thehacker.gearslip.GearslipLog +import app.seb3thehacker.gearslip.Protobuf +import app.seb3thehacker.gearslip.car.CarServices +import java.io.ByteArrayOutputStream + +/** + * Tells the car what's playing, so its own screens show it: the instrument cluster, the media + * source screen, the steering wheel display. Android Auto does the same over the car's + * MediaPlaybackStatusService (Service field 9): + * + * -> MediaPlaybackMetadata { song = 1; artist = 2; album = 3; album_art = 4; duration_seconds = 6 } + * -> MediaPlaybackStatus { state = 1 (1 stopped, 2 playing, 3 paused); media_source = 2; + * playback_seconds = 3; shuffle = 4; repeat = 5; repeat_one = 6 } + * + * Read once a second from the media app Gearslip is driving. The track goes only when it changes; + * the status goes every second while playing, so the car's position counts along. + */ +class CarMediaStatus( + val channelId: Int, + private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit, +) { + @Volatile private var running = false + private var sentTrack: String? = null + private var sentState = -1 + private var inputsLogged = 0 + + fun onMessage(messageId: Int, body: ByteArray) { + when (messageId) { + MSG_CHANNEL_OPEN_RESPONSE -> { + val status = Protobuf.readInt32Field(body, 1) ?: 0 + GearslipLog.i("media status: <- ChannelOpenResponse: status=$status") + if (status == 0) start() + } + // The car's own buttons for the media source (play, next...). Logged to learn what + // this car sends; the steering wheel keys already arrive on the input channel. + MSG_INPUT -> if (inputsLogged++ < 10) GearslipLog.i("media status: <- input from the car:\n" + Protobuf.describe(body)) + else -> GearslipLog.i("media status: <- unhandled message id=$messageId (${body.size} bytes)") + } + } + + private fun start() { + if (running) return + running = true + Thread({ + while (running) { + runCatching { tick() }.onFailure { GearslipLog.w("media status: ${it.message}") } + Thread.sleep(TICK_MS) + } + }, "gearslip-media-status").start() + } + + fun stop() { + running = false + } + + private fun tick() { + val now = CarServices.mediaOrNull()?.now?.value + val active = now != null && now.isActive + val state = when { + !active -> STATE_STOPPED + now!!.state == PlaybackState.STATE_PAUSED -> STATE_PAUSED + else -> STATE_PLAYING + } + if (active) sendTrack(now!!) + // Nothing to say while stopped, beyond saying so once. + if (state == STATE_STOPPED && sentState == STATE_STOPPED) return + if (state == STATE_PAUSED && sentState == STATE_PAUSED) return + val source = CarServices.mediaApp.value?.label ?: "Gearslip" + val seconds = if (active) (now!!.currentPosition() / 1000).coerceAtLeast(0) else 0 + sendOnChannel( + MSG_STATUS, + Protobuf.varintField(1, state.toLong()) + + Protobuf.stringField(2, source) + + Protobuf.varintField(3, seconds) + + Protobuf.varintField(4, 0L) + + Protobuf.varintField(5, 0L) + + Protobuf.varintField(6, 0L), + ) + if (state != sentState) GearslipLog.i("media status: -> ${STATE_NAMES[state]} ($source)") + sentState = state + } + + private fun sendTrack(now: NowPlaying) { + val key = "${now.title}\u0000${now.artist}\u0000${now.album}\u0000${now.durationMs}\u0000${now.art?.generationId}" + if (key == sentTrack) return + sentTrack = key + var body = Protobuf.stringField(1, now.title) + + Protobuf.stringField(2, now.artist) + + Protobuf.stringField(3, now.album) + now.art?.let { art -> artBytes(art)?.let { body += Protobuf.bytesField(4, it) } } + if (now.durationMs > 0) body += Protobuf.varintField(6, now.durationMs / 1000) + sendOnChannel(MSG_METADATA, body) + GearslipLog.i("media status: -> track \"${now.title}\" by ${now.artist.ifBlank { "?" }}") + } + + /** Album art as a small PNG: car screens show it at thumbnail size, and the link is shared with video. */ + private fun artBytes(art: Bitmap): ByteArray? = runCatching { + val scale = minOf(1f, ART_PX.toFloat() / maxOf(art.width, art.height)) + val small = if (scale < 1f) Bitmap.createScaledBitmap(art, (art.width * scale).toInt(), (art.height * scale).toInt(), true) else art + ByteArrayOutputStream().also { small.compress(Bitmap.CompressFormat.PNG, 100, it) }.toByteArray() + }.getOrNull() + + companion object { + private const val TICK_MS = 1_000L + private const val ART_PX = 256 + private const val MSG_CHANNEL_OPEN_RESPONSE = 8 + private const val MSG_STATUS = 32769 + private const val MSG_INPUT = 32770 + private const val MSG_METADATA = 32771 + private const val STATE_STOPPED = 1 + private const val STATE_PLAYING = 2 + private const val STATE_PAUSED = 3 + private val STATE_NAMES = mapOf(1 to "stopped", 2 to "playing", 3 to "paused") + } +} From dd3b845c7f3e439ff41b44526f95e21ab9592672 Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Tue, 6 Oct 2026 16:52:52 -0700 Subject: [PATCH 06/11] stream directions to car directly --- .../seb3thehacker/gearslip/GearslipRunner.kt | 18 ++ .../gearslip/ServiceDiscovery.kt | 9 +- .../seb3thehacker/gearslip/car/CarServices.kt | 3 + .../seb3thehacker/gearslip/car/ClusterNav.kt | 196 ++++++++++++++++++ .../gearslip/host/CarAppConnection.kt | 13 +- 5 files changed, 235 insertions(+), 4 deletions(-) create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/car/ClusterNav.kt diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt index 720f112..34a9cbf 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt @@ -4,6 +4,7 @@ import android.os.Build import app.seb3thehacker.gearslip.audio.AudioLink import app.seb3thehacker.gearslip.audio.CarMic import app.seb3thehacker.gearslip.media.CarMediaStatus +import app.seb3thehacker.gearslip.car.ClusterNav import app.seb3thehacker.gearslip.car.CarAssistant import app.seb3thehacker.gearslip.car.CarEnvironment import app.seb3thehacker.gearslip.car.CarKeys @@ -70,6 +71,7 @@ class GearslipRunner( @Volatile private var inputKeycodes: List = emptyList() @Volatile private var sensorChannelId: Int = -1 @Volatile private var mediaStatus: CarMediaStatus? = null + @Volatile private var clusterNav: ClusterNav? = null private var sensorTypes: List = emptyList() @Volatile private var audioLink: AudioLink? = null private var audioMessagesSeen = 0 @@ -145,6 +147,8 @@ class GearslipRunner( CarMic.current = null mediaStatus?.stop() mediaStatus = null + clusterNav?.stop() + clusterNav = null lastSensorNight = null CarEnvironment.setSensorNight(null) // back to the clock until a car says otherwise projection?.onProjectionStopped() @@ -180,6 +184,8 @@ class GearslipRunner( onInputMessage(messageId, body) } else if (frame.channel == sensorChannelId) { onSensorMessage(messageId, body) + } else if (frame.channel == clusterNav?.channelId) { + clusterNav?.onMessage(messageId, body) } else if (frame.channel == mediaStatus?.channelId) { mediaStatus?.onMessage(messageId, body) } else if (frame.channel == CarMic.current?.channelId) { @@ -424,6 +430,7 @@ class GearslipRunner( startSensorChannel(serviceDiscoveryResponse) startMicChannel(serviceDiscoveryResponse) startMediaStatusChannel(serviceDiscoveryResponse) + startClusterNavChannel(serviceDiscoveryResponse) val input = ServiceDiscovery.findInputService(serviceDiscoveryResponse, video.displayId) if (input == null) { @@ -496,6 +503,17 @@ class GearslipRunner( openChannel(channel) } + /** Turn-by-turn for the instrument cluster: the next turn, its road, and how far. */ + private fun startClusterNavChannel(serviceDiscoveryResponse: ByteArray) { + val channel = ServiceDiscovery.findNavStatusChannel(serviceDiscoveryResponse) ?: run { + log.i("no navigation status channel - the cluster won't show turns") + return + } + log.i("navigation status channel: $channel") + clusterNav = ClusterNav(channel) { id, body -> send(id, body, encrypted = true, channel = channel) } + openChannel(channel) + } + /** * Every sensor type the head unit advertised, subscribed to at once - there is no screen * to pick and choose yet, this is meant to show everything reachable. [CarSensors] answers diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt index 686956f..2aad847 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt @@ -114,10 +114,15 @@ object ServiceDiscovery { * The channel the car takes "now playing" on (MediaPlaybackStatusService, Service field 9), * which feeds its cluster and media screens. It carries no settings. Null when the car has none. */ - fun findMediaStatusChannel(response: ByteArray): Int? = + fun findMediaStatusChannel(response: ByteArray): Int? = findChannelWith(response, 9) + + /** The instrument cluster's turn-by-turn channel (NavigationStatusService, Service field 8). */ + fun findNavStatusChannel(response: ByteArray): Int? = findChannelWith(response, 8) + + private fun findChannelWith(response: ByteArray, serviceField: Int): Int? = Wire.allBytes(Wire.fields(response), 1).firstNotNullOfOrNull { channel -> val service = Wire.fields(channel) - if (service.any { it.number == 9 }) Wire.varint(service, 1)?.toInt() else null + if (service.any { it.number == serviceField }) Wire.varint(service, 1)?.toInt() else null } /** diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt index 3deb6de..cee0059 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt @@ -33,6 +33,9 @@ object CarServices { val nav: CarAppConnection by lazy { CarAppConnection(requireNotNull(context)) } val media: CarMedia by lazy { CarMedia(requireNotNull(context)) } + /** [nav], or null before the car screen has set things up. */ + fun navOrNull(): CarAppConnection? = if (context != null) nav else null + /** [media], or null before the car screen has set things up: for code outside the car UI. */ fun mediaOrNull(): CarMedia? = if (context != null) media else null diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/ClusterNav.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/ClusterNav.kt new file mode 100644 index 0000000..fda6c4c --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/ClusterNav.kt @@ -0,0 +1,196 @@ +package app.seb3thehacker.gearslip.car + +import androidx.car.app.model.CarText +import androidx.car.app.model.Distance +import androidx.car.app.navigation.model.Maneuver +import androidx.car.app.navigation.model.NavigationTemplate +import androidx.car.app.navigation.model.RoutingInfo +import androidx.car.app.navigation.model.Step +import app.seb3thehacker.gearslip.GearslipLog +import app.seb3thehacker.gearslip.Protobuf + +/** + * The next turn on the car's instrument cluster, over its NavigationStatusService (Service + * field 8), the way Android Auto feeds it: + * + * -> NavigationStatus { status = 1 (1 active, 2 inactive, 3 rerouting) } + * -> NavigationNextTurnEvent { road = 1; turn_side = 2; event = 3; turn_number = 5; turn_angle = 6 } + * -> NavigationNextTurnDistanceEvent { distance_meters = 1; time_to_turn_seconds = 2; + * display_distance_e3 = 3; display_distance_unit = 4 } + * + * Newer cars also take a NavigationState message; the deprecated pair above is what a 2018 + * Uconnect understands. The turn comes from the map app's trip (what the Car App Library hands + * to clusters), or from its routing card when it sends no trip. + */ +class ClusterNav( + val channelId: Int, + private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit, +) { + @Volatile private var running = false + private var sentStatus = -1 + private var sentTurn: String? = null + private var sentDistance: String? = null + + fun onMessage(messageId: Int, body: ByteArray) { + when (messageId) { + MSG_CHANNEL_OPEN_RESPONSE -> { + val status = Protobuf.readInt32Field(body, 1) ?: 0 + GearslipLog.i("cluster: <- ChannelOpenResponse: status=$status") + if (status == 0) start() + } + // The car saying when it wants turns. Sending all along is harmless, so these are only logged. + MSG_START -> GearslipLog.i("cluster: <- start") + MSG_STOP -> GearslipLog.i("cluster: <- stop") + else -> GearslipLog.i("cluster: <- unhandled message id=$messageId (${body.size} bytes)") + } + } + + private fun start() { + if (running) return + running = true + Thread({ + while (running) { + runCatching { tick() }.onFailure { GearslipLog.w("cluster: ${it.message}") } + Thread.sleep(TICK_MS) + } + }, "gearslip-cluster").start() + } + + fun stop() { + running = false + } + + /** One turn as the car wants it, from whichever source the map app fills. */ + private class Turn(val step: Step, val distance: Distance?, val seconds: Long?, val rerouting: Boolean) + + private fun current(): Turn? { + val nav = CarServices.navOrNull() ?: return null + nav.trip.value?.let { trip -> + val step = trip.steps.firstOrNull() ?: return@let + val estimate = trip.stepTravelEstimates.firstOrNull() + return Turn(step, estimate?.remainingDistance, estimate?.remainingTimeSeconds?.takeIf { it >= 0 }, trip.isLoading) + } + val info = (nav.template.value as? NavigationTemplate)?.navigationInfo as? RoutingInfo ?: return null + val step = info.currentStep ?: return null + return Turn(step, info.currentDistance, null, info.isLoading) + } + + private fun tick() { + val turn = current() + val status = when { + turn == null -> STATUS_INACTIVE + turn.rerouting -> STATUS_REROUTING + else -> STATUS_ACTIVE + } + if (status != sentStatus) { + sendOnChannel(MSG_STATUS, Protobuf.varintField(1, status.toLong())) + GearslipLog.i("cluster: -> ${STATUS_NAMES[status]}") + sentStatus = status + if (status == STATUS_INACTIVE) { sentTurn = null; sentDistance = null } + } + if (turn == null || status == STATUS_REROUTING) return + sendTurn(turn.step) + turn.distance?.let { sendDistance(it, turn.seconds) } + } + + private fun sendTurn(step: Step) { + val maneuver = step.maneuver + val (event, side) = maneuver?.let { eventFor(it.type) } ?: (EVENT_UNKNOWN to SIDE_UNSPECIFIED) + val road = step.road.text().ifBlank { step.cue.text() } + val key = "$road|$event|$side|${maneuver?.roundaboutExitNumber}" + if (key == sentTurn) return + sentTurn = key + var body = Protobuf.stringField(1, road) + + Protobuf.varintField(2, side.toLong()) + + Protobuf.varintField(3, event.toLong()) + maneuver?.roundaboutExitNumber?.takeIf { it > 0 }?.let { body += Protobuf.varintField(5, it.toLong()) } + maneuver?.roundaboutExitAngle?.takeIf { it > 0 }?.let { body += Protobuf.varintField(6, it.toLong()) } + sendOnChannel(MSG_TURN, body) + GearslipLog.i("cluster: -> turn event=$event side=$side road=\"$road\"") + } + + /** + * The Car App Library's distance units use the same numbers as the protocol's (1 metres ... + * 7 yards), so the app's own display choice passes straight through. + */ + private fun sendDistance(distance: Distance, seconds: Long?) { + val meters = toMeters(distance) + val key = "${distance.displayDistance}|${distance.displayUnit}|${seconds ?: -1}" + if (key == sentDistance) return + sentDistance = key + sendOnChannel( + MSG_DISTANCE, + Protobuf.varintField(1, meters.toLong()) + + Protobuf.varintField(2, (seconds ?: 0L).coerceAtLeast(0)) + + Protobuf.varintField(3, (distance.displayDistance * 1000).toLong()) + + Protobuf.varintField(4, distance.displayUnit.toLong()), + ) + } + + private fun toMeters(d: Distance): Int = (d.displayDistance * when (d.displayUnit) { + Distance.UNIT_KILOMETERS, Distance.UNIT_KILOMETERS_P1 -> 1000.0 + Distance.UNIT_MILES, Distance.UNIT_MILES_P1 -> 1609.344 + Distance.UNIT_FEET -> 0.3048 + Distance.UNIT_YARDS -> 0.9144 + else -> 1.0 + }).toInt() + + private fun CarText?.text() = this?.toString().orEmpty() + + companion object { + private const val TICK_MS = 500L + private const val MSG_CHANNEL_OPEN_RESPONSE = 8 + private const val MSG_START = 32769 + private const val MSG_STOP = 32770 + private const val MSG_STATUS = 32771 + private const val MSG_TURN = 32772 + private const val MSG_DISTANCE = 32773 + + private const val STATUS_ACTIVE = 1 + private const val STATUS_INACTIVE = 2 + private const val STATUS_REROUTING = 3 + private val STATUS_NAMES = mapOf(1 to "navigating", 2 to "not navigating", 3 to "rerouting") + + private const val SIDE_LEFT = 1 + private const val SIDE_RIGHT = 2 + private const val SIDE_UNSPECIFIED = 3 + + private const val EVENT_UNKNOWN = 0 + + /** The car's turn icon and side for a Car App Library manoeuvre. */ + fun eventFor(type: Int): Pair = when (type) { + Maneuver.TYPE_DEPART -> 1 to SIDE_UNSPECIFIED + Maneuver.TYPE_NAME_CHANGE -> 2 to SIDE_UNSPECIFIED + Maneuver.TYPE_KEEP_LEFT, Maneuver.TYPE_TURN_SLIGHT_LEFT -> 3 to SIDE_LEFT + Maneuver.TYPE_KEEP_RIGHT, Maneuver.TYPE_TURN_SLIGHT_RIGHT -> 3 to SIDE_RIGHT + Maneuver.TYPE_TURN_NORMAL_LEFT -> 4 to SIDE_LEFT + Maneuver.TYPE_TURN_NORMAL_RIGHT -> 4 to SIDE_RIGHT + Maneuver.TYPE_TURN_SHARP_LEFT -> 5 to SIDE_LEFT + Maneuver.TYPE_TURN_SHARP_RIGHT -> 5 to SIDE_RIGHT + Maneuver.TYPE_U_TURN_LEFT -> 6 to SIDE_LEFT + Maneuver.TYPE_U_TURN_RIGHT -> 6 to SIDE_RIGHT + Maneuver.TYPE_ON_RAMP_SLIGHT_LEFT, Maneuver.TYPE_ON_RAMP_NORMAL_LEFT, Maneuver.TYPE_ON_RAMP_SHARP_LEFT, + Maneuver.TYPE_ON_RAMP_U_TURN_LEFT -> 7 to SIDE_LEFT + Maneuver.TYPE_ON_RAMP_SLIGHT_RIGHT, Maneuver.TYPE_ON_RAMP_NORMAL_RIGHT, Maneuver.TYPE_ON_RAMP_SHARP_RIGHT, + Maneuver.TYPE_ON_RAMP_U_TURN_RIGHT -> 7 to SIDE_RIGHT + Maneuver.TYPE_OFF_RAMP_SLIGHT_LEFT, Maneuver.TYPE_OFF_RAMP_NORMAL_LEFT -> 8 to SIDE_LEFT + Maneuver.TYPE_OFF_RAMP_SLIGHT_RIGHT, Maneuver.TYPE_OFF_RAMP_NORMAL_RIGHT -> 8 to SIDE_RIGHT + Maneuver.TYPE_FORK_LEFT -> 9 to SIDE_LEFT + Maneuver.TYPE_FORK_RIGHT -> 9 to SIDE_RIGHT + Maneuver.TYPE_MERGE_LEFT -> 10 to SIDE_LEFT + Maneuver.TYPE_MERGE_RIGHT -> 10 to SIDE_RIGHT + Maneuver.TYPE_MERGE_SIDE_UNSPECIFIED -> 10 to SIDE_UNSPECIFIED + Maneuver.TYPE_ROUNDABOUT_ENTER_CW, Maneuver.TYPE_ROUNDABOUT_ENTER_CCW -> 11 to SIDE_UNSPECIFIED + Maneuver.TYPE_ROUNDABOUT_EXIT_CW, Maneuver.TYPE_ROUNDABOUT_EXIT_CCW -> 12 to SIDE_UNSPECIFIED + Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW, Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW_WITH_ANGLE, + Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW, Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW_WITH_ANGLE -> 13 to SIDE_UNSPECIFIED + Maneuver.TYPE_STRAIGHT -> 14 to SIDE_UNSPECIFIED + Maneuver.TYPE_FERRY_BOAT, Maneuver.TYPE_FERRY_BOAT_LEFT, Maneuver.TYPE_FERRY_BOAT_RIGHT -> 16 to SIDE_UNSPECIFIED + Maneuver.TYPE_FERRY_TRAIN, Maneuver.TYPE_FERRY_TRAIN_LEFT, Maneuver.TYPE_FERRY_TRAIN_RIGHT -> 17 to SIDE_UNSPECIFIED + Maneuver.TYPE_DESTINATION, Maneuver.TYPE_DESTINATION_STRAIGHT -> 19 to SIDE_UNSPECIFIED + Maneuver.TYPE_DESTINATION_LEFT -> 19 to SIDE_LEFT + Maneuver.TYPE_DESTINATION_RIGHT -> 19 to SIDE_RIGHT + else -> EVENT_UNKNOWN to SIDE_UNSPECIFIED + } + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt index a332887..d13c3e8 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt @@ -587,10 +587,19 @@ class CarAppConnection(private val context: Context) { override fun openMicrophone(request: Bundleable?): Bundleable? = null } + private val _trip = MutableStateFlow(null) + /** The route as the app tells clusters about it: steps and how far to each. Null when not navigating. */ + val trip: StateFlow = _trip.asStateFlow() + private val navigationHost = object : INavigationHost.Stub() { override fun navigationStarted() = GearslipLog.i("host: navigation started") - override fun navigationEnded() = GearslipLog.i("host: navigation ended") - override fun updateTrip(trip: Bundleable?) = Unit + override fun navigationEnded() { + GearslipLog.i("host: navigation ended") + _trip.value = null + } + override fun updateTrip(trip: Bundleable?) { + _trip.value = runCatching { trip?.get() as? androidx.car.app.navigation.model.Trip }.getOrNull() + } } /** From be981b0899a372c72b08dacc7baef41b48835166 Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Wed, 7 Oct 2026 00:10:28 -0700 Subject: [PATCH 07/11] start car audio as soon as the head unit answers setup --- .../seb3thehacker/gearslip/audio/AudioLink.kt | 45 +++++++++++-------- 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/audio/AudioLink.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/audio/AudioLink.kt index 46fdeb9..d904be6 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/audio/AudioLink.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/audio/AudioLink.kt @@ -60,14 +60,14 @@ class AudioLink( } /** - * The 2018 Uconnect answers our Setup with a two-byte message we cannot identify (id 255, no - * body) rather than the AVChannelSetupResponse the video channel gets, so the sink never - * reaches READY and no audio is ever sent. + * The 2018 Uconnect and a Dacia ULC 4.5 both answer our Setup with a two-byte message we + * cannot identify (id 255, no body) rather than the AVChannelSetupResponse the video channel + * gets, so the sink never reaches READY. [MSG_SETUP_ANSWER] takes that as the go-ahead. * - * Service discovery already told us everything that message would have: the sink is PCM at - * 48000Hz stereo and it has exactly one configuration. So rather than wait forever for a - * message that is not coming, assume the advertised configuration and carry on. If that guess - * is wrong the head unit simply will not ack, which the write path already reports. + * Service discovery already told us everything a Config would have: the sink's rate, channels + * and its one configuration. So for a car that answers nothing at all, wait a moment and then + * assume the advertised configuration. If that guess is wrong the head unit simply will not + * ack, which the write path already reports. */ private fun armConfigFallback() { val timer = Thread({ @@ -76,19 +76,25 @@ class AudioLink( } catch (e: InterruptedException) { return@Thread } - if (ready) return@Thread - GearslipLog.w( - "audio: no usable Config after ${CONFIG_WAIT_MS}ms - falling back to the sink the " + - "head unit advertised (${sampleRate}Hz x$channels, config_index=0, max_unacked=$maxUnacked)", - ) - configIndex = 0 - ready = true - CarAudio.attach(this) + useAdvertisedSink("no usable Config after ${CONFIG_WAIT_MS}ms") }, "gearslip-audio-config").apply { isDaemon = true } configTimer = timer timer.start() } + @Synchronized + private fun useAdvertisedSink(why: String) { + if (ready) return + configTimer?.interrupt() + GearslipLog.i( + "audio: $why - using the sink the head unit advertised " + + "(${sampleRate}Hz x$channels, config_index=0, max_unacked=$maxUnacked)", + ) + configIndex = 0 + ready = true + CarAudio.attach(this) + } + fun onMessage(messageId: Int, body: ByteArray) { when (messageId) { MSG_CHANNEL_OPEN_RESPONSE -> opened(Protobuf.readInt32Field(body, 1) ?: 0) @@ -123,8 +129,10 @@ class AudioLink( } } - // Whatever the head unit answers Setup with, we need its bytes to identify it. - else -> { + // Whatever else the head unit answers Setup with, we need its bytes to identify it. + else -> if (messageId == MSG_SETUP_ANSWER && body.isEmpty()) { + useAdvertisedSink("<- empty Setup answer (id $messageId)") + } else { GearslipLog.i("audio: <- unhandled message id=$messageId (${body.size} bytes)") GearslipLog.hex(" audio msg", body, limit = 64) } @@ -243,6 +251,7 @@ class AudioLink( private const val MSG_STOP = 32770 private const val MSG_CONFIG = 32771 private const val MSG_ACK = 32772 + private const val MSG_SETUP_ANSWER = 255 private const val STATUS_READY = 2 private const val FOCUS_GAIN = 1 private const val FOCUS_RELEASE = 4 @@ -251,7 +260,7 @@ class AudioLink( private const val SESSION_ID = 2 private const val STALL_MS = 2_000L private const val CHUNK_LOG_EVERY = 200 // about every four seconds - private const val CONFIG_WAIT_MS = 2_500L + private const val CONFIG_WAIT_MS = 1_000L private val FOCUS_STATES = mapOf( 1 to "GAIN", 2 to "GAIN_TRANSIENT", 3 to "LOSS", 4 to "LOSS_TRANSIENT", From 55320c4d98d91ec9c6b7d832e053b288f2b29fd3 Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Wed, 7 Oct 2026 16:37:40 -0700 Subject: [PATCH 08/11] example data --- .../seb3thehacker/gearslip/GearslipRunner.kt | 18 ++-- .../seb3thehacker/gearslip/SessionReport.kt | 92 +++++++++++++++++-- .../gearslip/stats/UsageStats.kt | 82 ++++++++++++++++- .../gearslip/ui/UsageStatsChoices.kt | 13 ++- 4 files changed, 181 insertions(+), 24 deletions(-) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt index 34a9cbf..26c945d 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt @@ -237,7 +237,7 @@ class GearslipRunner( } log.w("<- ByeByeRequest: reason=$reason ($name) in state $state") log.hex(" byebye raw", body) - SessionReport.fail(SessionReport.Category.BYEBYE, "ByeByeRequest reason=$reason ($name)", state.name) + SessionReport.fail(SessionReport.Category.BYEBYE, "ByeByeRequest reason=$reason ($name)", state.name, "bye $reason") send(MSG_BYEBYE_RESPONSE, ByteArray(0), encrypted = true) log.i("-> ByeByeResponse") } @@ -316,7 +316,7 @@ class GearslipRunner( sendServiceDiscoveryRequest() } -2 -> { - SessionReport.fail(SessionReport.Category.CERTIFICATE, "AuthComplete status -2 (certificate error)", state.name) + SessionReport.fail(SessionReport.Category.CERTIFICATE, "AuthComplete status -2 (certificate error)", state.name, "auth -2") log.verdict( "NOT VIABLE (STATUS_CERTIFICATE_ERROR)", "The head unit explicitly rejected the phone's self-signed certificate. " + @@ -330,7 +330,7 @@ class GearslipRunner( state = State.DONE } -3 -> { - SessionReport.fail(SessionReport.Category.AUTH, "AuthComplete status -3 (authentication failure)", state.name) + SessionReport.fail(SessionReport.Category.AUTH, "AuthComplete status -3 (authentication failure)", state.name, "auth -3") log.verdict( "NOT VIABLE (STATUS_AUTHENTICATION_FAILURE)", "Authentication rejected. Broader than a pure certificate error, but the " + @@ -343,7 +343,7 @@ class GearslipRunner( state = State.DONE } else -> { - SessionReport.fail(SessionReport.Category.AUTH, "AuthComplete status $status (unexpected)", state.name) + SessionReport.fail(SessionReport.Category.AUTH, "AuthComplete status $status (unexpected)", state.name, "auth $status") log.verdict( "INCONCLUSIVE (AuthComplete status=$status)", "Unexpected status. Check MessageStatus.proto in aasdk for the meaning " + @@ -411,7 +411,7 @@ class GearslipRunner( val video = ServiceDiscovery.findVideoService(serviceDiscoveryResponse) if (video == null) { log.w("no video service in the discovery response - cannot start Phase A") - SessionReport.fail(SessionReport.Category.VIDEO, "head unit advertised no video service", state.name) + SessionReport.fail(SessionReport.Category.VIDEO, "head unit advertised no video service", state.name, "no video service") return } videoChannelId = video.serviceId @@ -1101,10 +1101,10 @@ class GearslipRunner( private fun reportDisconnect() { when (state) { - State.WAIT_VERSION -> SessionReport.fail(SessionReport.Category.NO_HANDSHAKE, "no VersionRequest arrived", state.name) - State.TLS_HANDSHAKE -> SessionReport.fail(SessionReport.Category.TLS, "head unit dropped the link during TLS", state.name) - State.WAIT_AUTH -> SessionReport.fail(SessionReport.Category.AUTH, "dropped after TLS, before AuthComplete", state.name) - State.WAIT_SDR -> SessionReport.fail(SessionReport.Category.SERVICE_DISCOVERY, "accepted, then dropped before service discovery", state.name) + State.WAIT_VERSION -> SessionReport.fail(SessionReport.Category.NO_HANDSHAKE, "no VersionRequest arrived", state.name, "closed") + State.TLS_HANDSHAKE -> SessionReport.fail(SessionReport.Category.TLS, "head unit dropped the link during TLS", state.name, "closed") + State.WAIT_AUTH -> SessionReport.fail(SessionReport.Category.AUTH, "dropped after TLS, before AuthComplete", state.name, "closed") + State.WAIT_SDR -> SessionReport.fail(SessionReport.Category.SERVICE_DISCOVERY, "accepted, then dropped before service discovery", state.name, "closed") State.DONE -> {} } when (state) { diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/SessionReport.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/SessionReport.kt index a40f0b3..5f0ea85 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/SessionReport.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/SessionReport.kt @@ -29,12 +29,30 @@ object SessionReport { UNKNOWN("unclassified"), } + /** + * How far a session got, in the order a connection is built. The usage note's "Where + * connections fail" share sends the furthest one reached, so a failure says where it broke. + */ + enum class Stage(val key: String) { + USB("usb"), // the link opened; the car may have said nothing + VERSIONS("versions"), // the car said which protocol it speaks + TLS("tls"), // the secure link is up + ACCEPTED("accepted"), // the car accepted the certificate + CHANNELS("channels"), // the car listed its channels and said who it is + VIDEO("video"), // the car's screen is set up for Gearslip + STREAMING("streaming"), // video frames are reaching the car + } + private var appVersion = "?" private var appCode = 0L @Volatile private var startedAt = 0L @Volatile var category = Category.NONE; private set @Volatile private var failure = "" + @Volatile var stage = Stage.USB; private set + /** What the car or the phone said when it broke: a TLS alert, an auth status, a ByeBye reason, an errno. */ + @Volatile private var failCode = "" + @Volatile private var failedAfter = 0L @Volatile private var protocolVersion = "" @Volatile private var certSource = "" @Volatile private var certIssuer = "" @@ -62,6 +80,7 @@ object SessionReport { startedAt = System.currentTimeMillis() category = Category.NONE failure = ""; protocolVersion = ""; certSource = ""; certIssuer = "" + stage = Stage.USB; failCode = ""; failedAfter = 0 tlsProtocol = ""; tlsCipher = ""; authStatus = null headUnitInfo = null carScreen = ""; carDpi = 0 @@ -69,34 +88,87 @@ object SessionReport { printed = false } - fun protocolVersion(major: Int, minor: Int) { protocolVersion = "$major.$minor" } + /** Moves the session's furthest stage on; never back. */ + private fun reached(next: Stage) { if (next > stage) stage = next } + + fun protocolVersion(major: Int, minor: Int) { protocolVersion = "$major.$minor"; reached(Stage.VERSIONS) } fun certificate(source: String, issuer: String) { certSource = source; certIssuer = issuer } - fun tls(protocol: String, cipher: String) { tlsProtocol = protocol; tlsCipher = cipher } - fun auth(status: Int) { authStatus = status } + fun tls(protocol: String, cipher: String) { tlsProtocol = protocol; tlsCipher = cipher; reached(Stage.TLS) } + fun auth(status: Int) { authStatus = status; if (status == 0) reached(Stage.ACCEPTED) } fun headUnit(info: String) { headUnit = info } /** Who the car said it is, for the opt-in usage note; null until service discovery. */ @Volatile var headUnitInfo: ServiceDiscovery.HeadUnitInfo? = null + set(value) { field = value; if (value != null) reached(Stage.CHANNELS) } /** The car screen's usable size once its margins are cropped, and its density; blank before video starts. */ @Volatile var carScreen = ""; private set @Volatile var carDpi = 0; private set - fun carScreen(size: String, dpi: Int) { carScreen = size; carDpi = dpi } + fun carScreen(size: String, dpi: Int) { carScreen = size; carDpi = dpi; reached(Stage.VIDEO) } fun profile(name: String?) { profile = name ?: "none matched" } fun video(summary: String) { video = summary } fun audio(summary: String) { audio = summary } - fun stream(summary: String) { lastStream = summary } + fun stream(summary: String) { lastStream = summary; reached(Stage.STREAMING) } - /** Records the first failure only: later ones are usually fallout from it. */ - fun fail(category: Category, detail: String, state: String = "") { + /** + * Records the first failure only: later ones are usually fallout from it. [code] is the short + * thing the car or the phone said, like "auth -3" or "bye 1", for the usage note. + */ + fun fail(category: Category, detail: String, state: String = "", code: String = "") { if (this.category != Category.NONE) return this.category = category failure = detail lastState = state + failCode = code + failedAfter = if (startedAt == 0L) 0 else (System.currentTimeMillis() - startedAt) / 1000 } fun fail(category: Category, detail: String, t: Throwable, state: String = "") = - fail(category, "$detail (${GearslipLog.rootCause(t)})", state) + fail(category, "$detail (${GearslipLog.rootCause(t)})", state, codeOf(t)) + + /** + * A short code from an exception, never its whole message: the TLS alert's name, the errno + * of a USB failure, or else the exception's class. + */ + private fun codeOf(t: Throwable): String { + var root = t + while (root.cause != null && root.cause !== root) root = root.cause!! + val text = "${t.message} ${root.message}" + Regex("(?i)alert[ _:]*\\(?([a-z_ ]{3,40}?)\\)?(?:\\s|$|[,;.])").find(text)?.let { + return "alert ${it.groupValues[1].trim().lowercase().replace(' ', '_')}" + } + Regex("\\b(E[A-Z]{2,12})\\b").find(text)?.let { return it.groupValues[1] } + return root.javaClass.simpleName.take(40) + } + + /** + * One session for the usage note's "Where connections fail" share: how far it got, how and + * when it ended, and with which kind of certificate. Counts and codes only, nothing personal. + */ + fun sessionNote(): org.json.JSONObject { + val seconds = if (startedAt == 0L) 0 else (System.currentTimeMillis() - startedAt) / 1000 + val source = certSource.lowercase() + return org.json.JSONObject() + .put("stage", stage.key) + .put("ended", category.name.lowercase()) + .put("code", failCode) + .put("failed_at", if (category == Category.NONE) 0 else rounded(failedAfter)) + .put("seconds", rounded(seconds)) + .put("cert", when { + "imported" in source -> "imported" + "downloaded" in source -> "downloaded" + "self" in source -> "self-signed" + source.isEmpty() -> "" + else -> "other" + }) + } + + /** Seconds rounded so a drive's length can't be matched to anything: 5 s, then minutes, then 5 minutes. */ + private fun rounded(s: Long): Long = when { + s < 60 -> (s + 2) / 5 * 5 + s < 3600 -> (s + 30) / 60 * 60 + else -> (s + 150) / 300 * 300 + } /** The report as plain text, for the log file and for anything that gets sent. */ fun render(): String { @@ -145,7 +217,9 @@ object SessionReport { category == Category.NONE -> "ended before video" else -> category.label } - runCatching { UsageStats.recordSession(context, headUnitInfo, protocolVersion, outcome, carScreen, carDpi) } + runCatching { + UsageStats.recordSession(context, headUnitInfo, protocolVersion, outcome, carScreen, carDpi, sessionNote()) + } runCatching { app.seb3thehacker.gearslip.update.UpdateChecker.onDriveEnded(context) } } GearslipLog.flush() diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/stats/UsageStats.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/stats/UsageStats.kt index d03b176..8afbae2 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/stats/UsageStats.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/stats/UsageStats.kt @@ -32,6 +32,8 @@ object UsageStats { val android: Boolean = true, val phone: Boolean = true, val cars: Boolean = true, + /** How each car session went: how far it got, how and when it ended. Off until ticked. */ + val failures: Boolean = false, ) private const val PREFS = "usage_stats" @@ -49,6 +51,10 @@ object UsageStats { /** The daily background job of earlier builds, cancelled wherever it's still scheduled. */ private const val OLD_JOB_ID = 4_207 private const val MAX_CARS = 10 + /** Each session for the "Where connections fail" share, waiting for the next note. */ + private const val KEY_SESSIONS = "sessions" + private const val KEY_LAST_SESSIONS = "last_sessions" + private const val MAX_SESSIONS = 20 private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) @@ -69,6 +75,7 @@ object UsageStats { android = p.getBoolean("share_android", true), phone = p.getBoolean("share_phone", true), cars = p.getBoolean("share_cars", true), + failures = p.getBoolean("share_failures", false), ) } @@ -79,10 +86,13 @@ object UsageStats { .putBoolean("share_android", choices.android) .putBoolean("share_phone", choices.phone) .putBoolean("share_cars", choices.cars) + .putBoolean("share_failures", choices.failures) .apply() // Turning it on sends nothing yet: the driver may still be ticking boxes. The first note // waits for [choicesClosed]. - if (!enabled) prefs(context).edit().remove(KEY_CARS).remove(KEY_LAST_CARS).remove(KEY_SENT_DAY).apply() + if (!enabled) prefs(context).edit().remove(KEY_CARS).remove(KEY_LAST_CARS).remove(KEY_SENT_DAY) + .remove(KEY_SESSIONS).remove(KEY_LAST_SESSIONS).apply() + if (!choices.failures) prefs(context).edit().remove(KEY_SESSIONS).remove(KEY_LAST_SESSIONS).apply() } /** The driver left the screen with the usage note choices: send today's note if it's due. */ @@ -90,10 +100,15 @@ object UsageStats { if (prefs(context).getString(KEY_SENT_DAY, null) != LocalDate.now().toString()) sendSoon(context) } - /** Gearslip was opened: send today's note unless one already went out. */ + /** + * Gearslip was opened: send today's note unless one already went out, or sooner if a session + * is still waiting, like one that ended in a crash before its note could go. + */ fun onAppOpened(context: Context) { context.getSystemService(JobScheduler::class.java)?.cancel(OLD_JOB_ID) - if (prefs(context).getString(KEY_SENT_DAY, null) != LocalDate.now().toString()) sendSoon(context) + val p = prefs(context) + val waiting = p.getString(KEY_CARS, "[]") != "[]" || p.getString(KEY_SESSIONS, "[]") != "[]" + if (waiting || p.getString(KEY_SENT_DAY, null) != LocalDate.now().toString()) sendSoon(context) } private val sending = AtomicBoolean(false) @@ -134,13 +149,35 @@ object UsageStats { outcome: String, screen: String = "", dpi: Int = 0, + session: JSONObject? = null, ) { if (!enabled(context)) return val p = prefs(context) - synchronized(carsLock) { addCar(p, info, protocol, outcome, screen, dpi) } + synchronized(carsLock) { + addCar(p, info, protocol, outcome, screen, dpi) + if (session != null && choices(context).failures) addSession(p, info, protocol, session) + } sendSoon(context) } + private fun addSession( + p: android.content.SharedPreferences, + info: ServiceDiscovery.HeadUnitInfo?, + protocol: String, + session: JSONObject, + ) { + val sessions = runCatching { JSONArray(p.getString(KEY_SESSIONS, "[]")) }.getOrDefault(JSONArray()) + if (sessions.length() >= MAX_SESSIONS) return + // The same car fields as the cars list, so the dashboard can line the two up. + session.put("name", info?.headUnitName.orEmpty()) + .put("car", info?.carModel.orEmpty()) + .put("year", info?.carYear.orEmpty()) + .put("protocol", protocol) + sessions.put(session) + // Written at once: after a crash the app is about to die, and a deferred write would be lost. + p.edit().putString(KEY_SESSIONS, sessions.toString()).commit() + } + private fun addCar( p: android.content.SharedPreferences, info: ServiceDiscovery.HeadUnitInfo?, @@ -162,7 +199,7 @@ object UsageStats { // The same car and outcome twice in a day is one fact, not two. val seen = (0 until cars.length()).any { cars.optJSONObject(it)?.toString() == car.toString() } if (!seen && cars.length() < MAX_CARS) cars.put(car) - p.edit().putString(KEY_CARS, cars.toString()).apply() + p.edit().putString(KEY_CARS, cars.toString()).commit() } // GrapheneOS reports the same build fields as stock Pixel firmware; its own apps give it away. @@ -203,9 +240,33 @@ object UsageStats { } note.put("cars", cars) } + if (c.failures) { + var sessions = runCatching { JSONArray(p.getString(KEY_SESSIONS, "[]")) }.getOrDefault(JSONArray()) + if (everything && sessions.length() == 0) { + sessions = runCatching { JSONArray(p.getString(KEY_LAST_SESSIONS, "[]")) }.getOrDefault(JSONArray()) + } + note.put("sessions", sessions) + } return note } + /** + * A made-up session, for "What gets sent" to show before the first real one: a 25-minute + * drive that ended with the car switched off. + */ + val exampleSession: JSONObject + get() = JSONObject() + .put("stage", "streaming") + .put("ended", "byebye") + .put("code", "bye 1") + .put("failed_at", 1500) + .put("seconds", 1500) + .put("cert", "downloaded") + .put("name", "Head unit name") + .put("car", "Car model") + .put("year", "2021") + .put("protocol", "1.5") + /** Sends the note now; false when it didn't get through. */ private fun send(context: Context): Boolean { if (!sends || !enabled(context)) return true @@ -213,6 +274,7 @@ object UsageStats { val p = prefs(context) val note = synchronized(carsLock) { preview(context) } val sentCars = note.optJSONArray("cars")?.toString() + val sentSessions = note.optJSONArray("sessions")?.toString() val sentDetails = if (note.has("android") || note.has("phone")) details(context, choices(context)).toString() else null val body = note.toString().toByteArray() return runCatching { @@ -234,6 +296,16 @@ object UsageStats { if (sentDetails != null) edit.putString(KEY_SENT_DETAILS, sentDetails).putString(KEY_DETAILS_DAY, LocalDate.now().toString()) if (sentCars != null && sentCars != "[]") edit.putString(KEY_LAST_CARS, sentCars) if (sentCars == null || p.getString(KEY_CARS, "[]") == sentCars) edit.remove(KEY_CARS) + if (sentSessions != null && sentSessions != "[]") edit.putString(KEY_LAST_SESSIONS, sentSessions) + if (sentSessions == null || p.getString(KEY_SESSIONS, "[]") == sentSessions) { + edit.remove(KEY_SESSIONS) + } else if (sentSessions != "[]") { + // A session ended mid-send: keep only the ones this note didn't carry. + val now = JSONArray(p.getString(KEY_SESSIONS, "[]")) + val sent = JSONArray(sentSessions).length() + val left = JSONArray().also { for (i in sent until now.length()) it.put(now.get(i)) } + edit.putString(KEY_SESSIONS, left.toString()) + } edit.apply() } GearslipLog.i("stats: note sent") diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UsageStatsChoices.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UsageStatsChoices.kt index da00891..36a04eb 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UsageStatsChoices.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UsageStatsChoices.kt @@ -106,6 +106,10 @@ internal fun ColumnScope.UsageStatsRows() { choices = choices.copy(cars = it) save() } + Choice("How each drive went: how far the connection got, how long it lasted, and how it ended, crashes included", choices.failures) { + choices = choices.copy(failures = it) + save() + } SettingsDivider() SettingsRow( "What gets sent", @@ -122,7 +126,14 @@ internal fun ColumnScope.UsageStatsRows() { ) AnimatedVisibility(noteOpen) { // Rebuilt from the saved choices each time one changes, so it always matches. - val note = remember(choices) { UsageStats.preview(context, everything = true).toString(2) } + val note = remember(choices) { + val preview = UsageStats.preview(context, everything = true) + // Until the first drive the list is empty, so show what one entry looks like. + val example = if (preview.optJSONArray("sessions")?.length() == 0) { + "\n\nExample, not sent: one drive adds this to \"sessions\"\n" + UsageStats.exampleSession.toString(2) + } else "" + preview.toString(2) + example + } Surface( color = MaterialTheme.colorScheme.surfaceContainerLowest, shape = MaterialTheme.shapes.medium, From c212c2e2d1925b2905398003300e564b9d758f29 Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Wed, 7 Oct 2026 23:50:47 -0700 Subject: [PATCH 09/11] usage notes changes, audio changes, video quality increase. --- .../seb3thehacker/gearslip/ui/GearslipApp.kt | 10 ++- .../seb3thehacker/gearslip/ui/HomeScreen.kt | 5 +- .../gearslip/ui/SettingsScreen.kt | 14 ++- .../gearslip/ui/UsageStatsChoices.kt | 89 ++++++++++++------- 4 files changed, 83 insertions(+), 35 deletions(-) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt index 1a23888..4d06629 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt @@ -60,6 +60,8 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) { }, ) } + // Home's usage notes row opens Settings at that section rather than at the top. + var settingsAtUsageNotes by rememberSaveable { mutableStateOf(false) } BackHandler(enabled = screen != Screen.HOME) { screen = if (screen == Screen.RECOMMENDED_APPS) Screen.SETTINGS else Screen.HOME } @@ -95,18 +97,20 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) { when (screen) { Screen.HOME -> HomeScreen( onOpenLogs = { screen = Screen.LOGS }, - onOpenSettings = { screen = Screen.SETTINGS }, + onOpenSettings = { settingsAtUsageNotes = false; screen = Screen.SETTINGS }, onOpenCarPreview = { screen = Screen.CAR_PREVIEW }, onOpenHelp = { screen = Screen.HELP }, + onOpenUsageNotes = { settingsAtUsageNotes = true; screen = Screen.SETTINGS }, onDisconnect = onDisconnect, ) Screen.WHATS_NEW -> WhatsNewScreen(onBack = { screen = Screen.SETTINGS }) Screen.LOGS -> LogsScreen(onBack = { screen = Screen.HOME }) Screen.SETTINGS -> SettingsScreen( onBack = { screen = Screen.HOME }, - onOpenRecommendedApps = { screen = Screen.RECOMMENDED_APPS }, - onOpenWhatsNew = { screen = Screen.WHATS_NEW }, + onOpenRecommendedApps = { settingsAtUsageNotes = false; screen = Screen.RECOMMENDED_APPS }, + onOpenWhatsNew = { settingsAtUsageNotes = false; screen = Screen.WHATS_NEW }, onReplayTutorial = { screen = Screen.SETUP_GUIDE }, + atUsageNotes = settingsAtUsageNotes, ) Screen.RECOMMENDED_APPS -> RecommendedAppsScreen(onBack = { screen = Screen.SETTINGS }) Screen.CAR_PREVIEW -> CarPreviewScreen(onBack = { screen = Screen.HOME }) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt index 0680697..4040fc3 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt @@ -48,6 +48,7 @@ fun HomeScreen( onOpenSettings: () -> Unit, onOpenCarPreview: () -> Unit, onOpenHelp: () -> Unit, + onOpenUsageNotes: () -> Unit, onDisconnect: () -> Unit, ) { val status by SessionStatus.state.collectAsStateWithLifecycle() @@ -92,7 +93,6 @@ fun HomeScreen( StatusCard(status) Spacer(Modifier.height(16.dp)) UpdateCard() - UsageNotesHomeCard() } // Sits at the bottom, where a thumb reaches it. @@ -106,6 +106,9 @@ fun HomeScreen( Spacer(Modifier.height(16.dp)) } + UsageNotesHomeCard(onOpen = onOpenUsageNotes) + Spacer(Modifier.height(16.dp)) + Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.extraLarge) { Column { if (!live) { diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt index 2b4045e..f397fbf 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt @@ -57,6 +57,9 @@ import app.seb3thehacker.gearslip.BuildConfig import app.seb3thehacker.gearslip.CertProvider import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshotFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext @OptIn(ExperimentalMaterial3Api::class) @@ -66,7 +69,16 @@ fun SettingsScreen( onOpenRecommendedApps: () -> Unit, onOpenWhatsNew: () -> Unit, onReplayTutorial: () -> Unit, + atUsageNotes: Boolean = false, ) { + val scroll = rememberScrollState() + // Opened from Home's usage notes row: usage notes is the last section, so start at the end. + if (atUsageNotes) { + LaunchedEffect(Unit) { + snapshotFlow { scroll.maxValue }.first { it > 0 && it < Int.MAX_VALUE } + scroll.scrollTo(scroll.maxValue) + } + } Scaffold( containerColor = MaterialTheme.colorScheme.background, topBar = { @@ -85,7 +97,7 @@ fun SettingsScreen( .fillMaxSize() .padding(padding) .padding(horizontal = 24.dp) - .verticalScroll(rememberScrollState()), + .verticalScroll(scroll), verticalArrangement = Arrangement.spacedBy(24.dp), ) { Spacer(Modifier.height(0.dp)) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UsageStatsChoices.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UsageStatsChoices.kt index 36a04eb..612b5da 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UsageStatsChoices.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UsageStatsChoices.kt @@ -1,6 +1,7 @@ package app.seb3thehacker.gearslip.ui import androidx.compose.animation.AnimatedVisibility +import android.content.Context import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope @@ -9,6 +10,8 @@ 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.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp @@ -83,11 +86,7 @@ internal fun ColumnScope.UsageStatsRows() { SettingsRow( "Send usage notes", Modifier.toggleable(on, role = Role.Switch) { on = it; save() }, - subtitle = when { - BuildConfig.DEBUG -> "Dev build: nothing is ever sent" - BuildConfig.STATS_URL.isBlank() -> "This build has no stats server, so it sends nothing" - else -> null - }, + subtitle = buildNote(), icon = Icons.Filled.Send, trailing = { Switch(checked = on, onCheckedChange = null) }, ) @@ -126,14 +125,7 @@ internal fun ColumnScope.UsageStatsRows() { ) AnimatedVisibility(noteOpen) { // Rebuilt from the saved choices each time one changes, so it always matches. - 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 - } + val note = remember(choices) { notePreview(context) } Surface( color = MaterialTheme.colorScheme.surfaceContainerLowest, shape = MaterialTheme.shapes.medium, @@ -164,23 +156,60 @@ private fun Choice(label: String, checked: Boolean, onChange: (Boolean) -> Unit) } } -/** Usage notes on Home, always: the switch, and once it's on, what to share. */ +/** The note as it would go out now, with an example drive until a real one is in it. */ +private fun notePreview(context: Context): String { + 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 "" + return preview.toString(2) + example +} + +/** Why this build sends nothing, when it doesn't; null for a normal release. */ +private fun buildNote(): String? = when { + BuildConfig.DEBUG -> "Dev build: nothing is ever sent" + BuildConfig.STATS_URL.isBlank() -> "This build has no stats server, so it sends nothing" + else -> null +} + +/** + * Usage notes on Home, one row with one action either way. While they're off, the row is the + * switch, so opting in is a single tap. Once they're on, it shrinks to a quiet line that opens + * Settings at its usage notes section, so a driver already sharing isn't pitched again. + */ @Composable -internal fun UsageNotesHomeCard(modifier: Modifier = Modifier) { - Column(modifier) { - Text( - USAGE_STATS_TITLE, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 16.dp, bottom = 4.dp), - ) - Text( - "Share a short note after each drive, so the next driver knows whether Gearslip works " + - "in their car. No name, account, or location.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 12.dp), - ) - SettingsCard { UsageStatsRows() } +internal fun UsageNotesHomeCard(onOpen: () -> Unit, modifier: Modifier = Modifier) { + val context = LocalContext.current + var on by remember { mutableStateOf(UsageStats.enabled(context)) } + + SettingsCard(modifier) { + if (on) { + SettingsRow( + "Sharing usage notes", + Modifier.clickable(onClick = onOpen), + subtitle = buildNote(), + icon = Icons.Filled.Check, + trailing = { + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + ) + } else { + SettingsRow( + USAGE_STATS_TITLE, + Modifier.toggleable(false, role = Role.Switch) { + UsageStats.set(context, true, UsageStats.choices(context)) + on = true + }, + subtitle = buildNote() ?: ("A short note after each drive tells the next driver " + + "whether their car works. No name, account, or location"), + icon = Icons.Filled.Send, + trailing = { Switch(checked = false, onCheckedChange = null) }, + ) + } } } From 40086e061b4ccbf63758745e56477e77347e549d Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Thu, 8 Oct 2026 00:06:34 -0700 Subject: [PATCH 10/11] audio pause when unplugged, theme tweak --- .../gearslip/GearslipActivity.kt | 2 ++ .../seb3thehacker/gearslip/car/CarServices.kt | 26 +++++++++++++++++++ .../gearslip/car/theme/GsHoldMarker.kt | 5 +++- 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt index d9a3efe..41010b5 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt @@ -338,6 +338,8 @@ class GearslipActivity : ComponentActivity(), Projection { } override fun onProjectionStopped() { + // Before the car UI goes, while the media connection is still up. + app.seb3thehacker.gearslip.car.CarServices.pausePhoneMedia() runOnUiThread { PhoneMirror.requestStop() app.seb3thehacker.gearslip.car.CarKeys.focusSink = null diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt index cee0059..5f02ea4 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt @@ -200,6 +200,32 @@ object CarServices { return playingPackages.firstNotNullOfOrNull { pkg -> catalog.firstOrNull { it.component.packageName == pkg } } } + /** + * The car went away mid-song: pause it, as Android Auto does, so it doesn't carry on out of + * the phone's speaker. Unplugging a USB accessory isn't "audio becoming noisy" the way + * headphones are, so the apps won't pause themselves. Every playing session is paused, not + * just the one Gearslip opened: anything the car was playing was going through the car. + */ + fun pausePhoneMedia() { + val ctx = context ?: return + val manager = ctx.getSystemService(MediaSessionManager::class.java) + val listener = ComponentName(ctx, GearslipNotificationListener::class.java) + val playing = try { + manager.getActiveSessions(listener).filter { it.playbackState?.state == PlaybackState.STATE_PLAYING } + } catch (e: SecurityException) { + null + } + if (playing == null) { + // No notification access: the app Gearslip itself is driving is all it can reach. + mediaOrNull()?.let { if (it.now.value.playing) it.togglePlay() } + return + } + playing.forEach { runCatching { it.transportControls.pause() } } + if (playing.isNotEmpty()) { + app.seb3thehacker.gearslip.GearslipLog.i("media: paused ${playing.joinToString { it.packageName }} as the car left") + } + } + /** The car went away: nothing should keep running for a screen that no longer exists. */ fun shutdown() { context?.let { app.seb3thehacker.gearslip.notify.CarMotion.stop(it) } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsHoldMarker.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsHoldMarker.kt index 44cafd3..7488389 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsHoldMarker.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/theme/GsHoldMarker.kt @@ -50,6 +50,8 @@ enum class HoldMarkerStyle { * colour, so the peel's flap matches whatever it sits on. Round and pill-shaped controls get the * dot whatever the theme says: a circle has no corner to peel. */ +private const val SHOW_HOLD_DOT = false + @Composable internal fun BoxScope.GsHoldMarker(style: HoldMarkerStyle, container: Color, shape: Shape, press: InteractionSource) { if (style == HoldMarkerStyle.NONE) return @@ -68,8 +70,9 @@ internal fun BoxScope.GsHoldMarker(style: HoldMarkerStyle, container: Color, sha val side = min(size.width, size.height) val r = (shape as? CornerBasedShape)?.topEnd?.toPx(size, this) ?: 0f val round = r >= side * 0.4f + // The dot is off until it's redesigned: flat themes and round controls show nothing. if (style == HoldMarkerStyle.DOT || round) { - drawHoldDot(r, side, ink) + if (SHOW_HOLD_DOT) drawHoldDot(r, side, ink) } else { // Drawn for the bottom-right corner, then flipped up to the top-right. scale(1f, -1f, pivot = center) { From 516f6c7f9497ea124f819a029375d6115bbc32f5 Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Thu, 8 Oct 2026 00:27:54 -0700 Subject: [PATCH 11/11] ask once to share after the first drive that works, cache the cert for settings --- gearslip/src/main/AndroidManifest.xml | 5 + .../seb3thehacker/gearslip/SessionReport.kt | 3 + .../gearslip/stats/UsageStats.kt | 3 + .../gearslip/stats/WorkedPrompt.kt | 179 ++++++++++++++++++ .../seb3thehacker/gearslip/ui/CertSummary.kt | 11 +- .../app/seb3thehacker/gearslip/ui/Confetti.kt | 88 +++++++++ .../seb3thehacker/gearslip/ui/GearslipApp.kt | 4 + .../seb3thehacker/gearslip/ui/HomeScreen.kt | 132 +++++++------ .../gearslip/ui/SettingsScreen.kt | 3 +- .../gearslip/ui/UsageStatsChoices.kt | 55 ++++++ 10 files changed, 424 insertions(+), 59 deletions(-) create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/stats/WorkedPrompt.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/ui/Confetti.kt diff --git a/gearslip/src/main/AndroidManifest.xml b/gearslip/src/main/AndroidManifest.xml index fe7b03f..50dfbdb 100644 --- a/gearslip/src/main/AndroidManifest.xml +++ b/gearslip/src/main/AndroidManifest.xml @@ -160,6 +160,11 @@ android:name=".notify.TestMessageReceiver" android:exported="false" /> + + + (null) + + /** The car's name while the ask is waiting for an answer, otherwise null. */ + fun pending(context: Context): StateFlow { + if (!loaded) { + loaded = true + flow.value = saved(context)?.let(::carName) + } + return flow + } + + /** A drive that reached the car's screen ended cleanly. Asks, if it's never asked before. */ + fun onDriveWorked(context: Context, info: ServiceDiscovery.HeadUnitInfo?, protocol: String, screen: String, dpi: Int) { + val p = prefs(context) + if (UsageStats.enabled(context) || p.getBoolean(KEY_DONE, false) || p.contains(KEY_CAR)) return + 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("screen", screen) + .put("dpi", dpi) + p.edit().putString(KEY_CAR, car.toString()).apply() + loaded = true + flow.value = carName(car) + notify(context, carName(car)) + } + + /** + * True once a driver turns sharing on from the ask, until Home has thrown its confetti. It + * waits for Home: a Share tapped in the notification celebrates the next time Gearslip opens. + */ + private val celebrateFlow = MutableStateFlow(false) + val celebrate: StateFlow = celebrateFlow + + fun celebrated() { celebrateFlow.value = false } + + /** The driver answered, here or on Home. Either way it's never asked again. */ + fun answer(context: Context, share: Boolean) { + if (share) { + // Turning sharing on lands in [onSharingOn], which clears the ask. + UsageStats.set(context, true) + return + } + clear(context) + GearslipLog.i("worked prompt: not now") + } + + /** + * Usage notes were just turned on, from the ask or anywhere else. With the ask still waiting, + * that answers it, and the car it was about goes in the first note. + */ + internal fun onSharingOn(context: Context) { + val car = saved(context) ?: return + clear(context) + celebrateFlow.value = true + GearslipLog.i("worked prompt: usage notes turned on") + val info = ServiceDiscovery.HeadUnitInfo( + car.optString("name"), car.optString("car"), car.optString("year"), + car.optString("make"), car.optString("model"), + ) + UsageStats.recordSession( + context, info, car.optString("protocol"), "connected", + car.optString("screen"), car.optInt("dpi"), + ) + } + + private fun clear(context: Context) { + prefs(context).edit().remove(KEY_CAR).putBoolean(KEY_DONE, true).apply() + loaded = true + flow.value = null + NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID) + } + + private fun saved(context: Context): JSONObject? = + prefs(context).getString(KEY_CAR, null)?.let { runCatching { JSONObject(it) }.getOrNull() } + + /** "your 2019 Clio", "your Renault Media Nav", or "your car" when it didn't say. */ + private fun carName(car: JSONObject): String { + val model = car.optString("car").trim() + val year = car.optString("year").trim().takeIf { it.isNotEmpty() && it != "0" } + val unit = "${car.optString("make")} ${car.optString("model")}".trim() + return when { + model.isNotEmpty() -> "your " + listOfNotNull(year, model).joinToString(" ") + unit.isNotEmpty() -> "your $unit" + else -> "your car" + } + } + + private fun notify(context: Context, car: String) { + if (context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) return + context.getSystemService(NotificationManager::class.java).createNotificationChannel( + NotificationChannel(CHANNEL, "After a drive", NotificationManager.IMPORTANCE_DEFAULT), + ) + val open = PendingIntent.getActivity( + context, NOTIFICATION_ID, + Intent(context, GearslipActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP), + PendingIntent.FLAG_IMMUTABLE, + ) + val text = "Share that so the next driver knows it works? No name, account, or location." + val notification = NotificationCompat.Builder(context, CHANNEL) + .setSmallIcon(android.R.drawable.ic_menu_send) + .setContentTitle(TITLE_START + car) + .setContentText(text) + .setStyle(NotificationCompat.BigTextStyle().bigText(text)) + .setContentIntent(open) + .setAutoCancel(true) + .addAction(0, "Not now", broadcast(context, ACTION_NOT_NOW)) + .addAction(0, "Share", broadcast(context, ACTION_SHARE)) + .build() + runCatching { NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, notification) } + .onSuccess { GearslipLog.i("worked prompt: asked after the first good drive") } + .onFailure { GearslipLog.w("worked prompt: could not post the notification (${it.message})") } + } + + private fun broadcast(context: Context, action: String): PendingIntent = + PendingIntent.getBroadcast( + context, action.hashCode(), + Intent(context, WorkedPromptReceiver::class.java).setAction(action), + PendingIntent.FLAG_IMMUTABLE, + ) + + internal fun onAction(context: Context, intent: Intent) { + when (intent.action) { + ACTION_SHARE -> answer(context, share = true) + ACTION_NOT_NOW -> answer(context, share = false) + } + } + + const val TITLE_START = "Gearslip worked with " +} + +/** The notification's Share and Not now buttons land here. */ +class WorkedPromptReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) = WorkedPrompt.onAction(context, intent) +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CertSummary.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CertSummary.kt index f06c7f9..48716ca 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CertSummary.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CertSummary.kt @@ -14,8 +14,17 @@ data class CertSummary( val validUntil: String?, ) { companion object { + /** + * The last [read], so Settings draws the certificate row at once instead of a second + * later, which shifted everything under it. Null until the first read. + */ + @Volatile var cached: CertSummary? = null + private set + /** Blocking (parses a key store): call from a background dispatcher. */ - fun read(context: Context): CertSummary { + fun read(context: Context): CertSummary = readNow(context).also { cached = it } + + private fun readNow(context: Context): CertSummary { val identity = CertProvider.loadSupplied(context) ?: return CertSummary( CertProvider.Kind.SELF_SIGNED, diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/Confetti.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/Confetti.kt new file mode 100644 index 0000000..61ce7e4 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/Confetti.kt @@ -0,0 +1,88 @@ +package app.seb3thehacker.gearslip.ui + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.rotate +import kotlin.math.sin +import kotlin.random.Random + +private class Piece( + val x: Float, // across the screen, 0..1 + val delay: Float, // seconds before it starts falling + val speed: Float, // screen heights per second + val sway: Float, // how far it drifts side to side, in screen widths + val swaySpeed: Float, + val spin: Float, // degrees per second + val width: Float, // dp + val height: Float, + val color: Color, +) + +/** + * Confetti falling from the top of the screen once, for about three seconds. Draws over whatever + * is under it and takes no touches; [onDone] fires when the last piece has left the screen. + */ +@Composable +internal fun ConfettiFall(onDone: () -> Unit, modifier: Modifier = Modifier) { + val scheme = MaterialTheme.colorScheme + val colors = listOf( + scheme.primary, scheme.tertiary, scheme.secondary, + Color(0xFFFFC107), Color(0xFFE91E63), Color(0xFF4CAF50), Color(0xFF2196F3), + ) + val pieces = remember { + List(PIECES) { + Piece( + x = Random.nextFloat(), + delay = Random.nextFloat() * 0.9f, + speed = 0.35f + Random.nextFloat() * 0.35f, + sway = 0.01f + Random.nextFloat() * 0.03f, + swaySpeed = 2f + Random.nextFloat() * 4f, + spin = (Random.nextFloat() - 0.5f) * 720f, + width = 4f + Random.nextFloat() * 4f, + height = 8f + Random.nextFloat() * 6f, + color = colors[Random.nextInt(colors.size)], + ) + } + } + var t by remember { mutableFloatStateOf(0f) } + LaunchedEffect(Unit) { + val start = withFrameNanos { it } + // The slowest piece, starting last, clears the screen by this time. + val end = pieces.maxOf { it.delay + 1.1f / it.speed } + while (t < end) { + withFrameNanos { t = (it - start) / 1e9f } + } + onDone() + } + Canvas(modifier.fillMaxSize()) { + val density = density + for (p in pieces) { + val age = t - p.delay + if (age < 0f) continue + val y = (age * p.speed - 0.05f) * size.height + if (y > size.height + 40f) continue + val x = (p.x + sin(age * p.swaySpeed) * p.sway) * size.width + rotate(age * p.spin, pivot = Offset(x, y)) { + drawRect( + p.color, + topLeft = Offset(x - p.width * density / 2, y - p.height * density / 2), + size = Size(p.width * density, p.height * density), + ) + } + } + } +} + +private const val PIECES = 140 diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt index 4d06629..9c5a3db 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt @@ -77,6 +77,10 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) { // The guide asks for this now, but anyone who finished it before that never saw the page. val notificationPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {} + // Reads the certificate once in the background, so Settings has it ready when opened. + LaunchedEffect(Unit) { + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Default) { runCatching { CertSummary.read(context) } } + } LaunchedEffect(Unit) { if (!needsSetupGuide && context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt index 4040fc3..bb3a79f 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt @@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip.ui import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -31,6 +32,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector @@ -41,6 +43,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.seb3thehacker.gearslip.BuildConfig import app.seb3thehacker.gearslip.SessionStatus import app.seb3thehacker.gearslip.notify.TestMessage +import app.seb3thehacker.gearslip.stats.WorkedPrompt @Composable fun HomeScreen( @@ -52,81 +55,96 @@ fun HomeScreen( onDisconnect: () -> Unit, ) { val status by SessionStatus.state.collectAsStateWithLifecycle() + val context = LocalContext.current + val workedCar by remember { WorkedPrompt.pending(context) }.collectAsStateWithLifecycle() + val live = status.phase == SessionStatus.Phase.CONNECTING || + status.phase == SessionStatus.Phase.PROJECTING - Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding -> - Column( - Modifier - .fillMaxSize() - .padding(padding) - .padding(horizontal = 24.dp), - ) { + val celebrate by WorkedPrompt.celebrate.collectAsStateWithLifecycle() + Box { + Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding -> Column( Modifier - .weight(1f) - .verticalScroll(rememberScrollState()), + .fillMaxSize() + .padding(padding) + .padding(horizontal = 24.dp), ) { - Spacer(Modifier.height(24.dp)) - Row( - Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, + Column( + Modifier + .weight(1f) + .verticalScroll(rememberScrollState()), ) { - Text( - "Gearslip", - style = MaterialTheme.typography.displayLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onBackground, - ) - IconButton( - onClick = onOpenSettings, - modifier = Modifier.size(56.dp), + Spacer(Modifier.height(24.dp)) + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, ) { - Icon(Icons.Filled.Settings, contentDescription = "Settings", modifier = Modifier.size(40.dp)) + Text( + "Gearslip", + style = MaterialTheme.typography.displayLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground, + ) + IconButton( + onClick = onOpenSettings, + modifier = Modifier.size(56.dp), + ) { + Icon(Icons.Filled.Settings, contentDescription = "Settings", modifier = Modifier.size(40.dp)) + } } - } - Text( - "v${LocalContext.current.appVersionName()}", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(16.dp)) - StatusCard(status) - Spacer(Modifier.height(16.dp)) - UpdateCard() - } - - // Sits at the bottom, where a thumb reaches it. - Column(Modifier.padding(vertical = 16.dp)) { - val live = status.phase == SessionStatus.Phase.CONNECTING || - status.phase == SessionStatus.Phase.PROJECTING - if (live) { - Button(onClick = onDisconnect, modifier = Modifier.fillMaxWidth().height(56.dp)) { - Text("Disconnect") + Text( + "v${LocalContext.current.appVersionName()}", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + StatusCard(status) + // The first drive that worked asks once to share it, right under the result. + val car = workedCar + if (car != null && !live) { + Spacer(Modifier.height(16.dp)) + WorkedCard(car, onSeeWhatsSent = onOpenUsageNotes) } Spacer(Modifier.height(16.dp)) + UpdateCard() } - UsageNotesHomeCard(onOpen = onOpenUsageNotes) - Spacer(Modifier.height(16.dp)) - - Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.extraLarge) { - Column { - if (!live) { - ActionRow(Icons.Filled.PlayArrow, "Car preview", onOpenCarPreview) - RowDivider() + // Sits at the bottom, where a thumb reaches it. + Column(Modifier.padding(vertical = 16.dp)) { + if (live) { + Button(onClick = onDisconnect, modifier = Modifier.fillMaxWidth().height(56.dp)) { + Text("Disconnect") } - ActionRow(Icons.Filled.List, "Live logs", onOpenLogs) - RowDivider() - ActionRow(Icons.Filled.Info, "Connection help", onOpenHelp) - if (BuildConfig.DEBUG) { - val context = LocalContext.current + Spacer(Modifier.height(16.dp)) + } + + // Not while the card above is asking the same thing. + if (workedCar == null || live) { + UsageNotesHomeCard(onOpen = onOpenUsageNotes) + Spacer(Modifier.height(16.dp)) + } + + Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.extraLarge) { + Column { + if (!live) { + ActionRow(Icons.Filled.PlayArrow, "Car preview", onOpenCarPreview) + RowDivider() + } + ActionRow(Icons.Filled.List, "Live logs", onOpenLogs) RowDivider() - ActionRow(Icons.Filled.Send, "Send a test message") { TestMessage.post(context) } + ActionRow(Icons.Filled.Info, "Connection help", onOpenHelp) + if (BuildConfig.DEBUG) { + RowDivider() + ActionRow(Icons.Filled.Send, "Send a test message") { TestMessage.post(context) } + } } } } } } + // Sharing just went on from the "it worked" ask: a little thanks. + if (celebrate) ConfettiFall(onDone = WorkedPrompt::celebrated) } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt index f397fbf..0761ba8 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt @@ -181,7 +181,8 @@ private fun CertificateSection() { val scope = rememberCoroutineScope() var refresh by remember { mutableIntStateOf(0) } - val summary by produceState(null, refresh) { + // The cached summary shows at once; the read still runs, in case the cert changed since. + val summary by produceState(CertSummary.cached, refresh) { value = withContext(Dispatchers.Default) { CertSummary.read(context) } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UsageStatsChoices.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UsageStatsChoices.kt index 612b5da..49346f6 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UsageStatsChoices.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UsageStatsChoices.kt @@ -5,6 +5,13 @@ import android.content.Context import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.TextButton +import app.seb3thehacker.gearslip.stats.WorkedPrompt import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -173,6 +180,54 @@ private fun buildNote(): String? = when { else -> null } +/** + * The one-time ask after the first drive that worked, in the status card's place on Home. Share + * turns on usage notes like the switch does; the link shows exactly what that sends first. + */ +@Composable +internal fun WorkedCard(car: String, onSeeWhatsSent: () -> Unit, modifier: Modifier = Modifier) { + val context = LocalContext.current + val scheme = MaterialTheme.colorScheme + Surface( + modifier, + color = scheme.primaryContainer, + contentColor = scheme.onPrimaryContainer, + shape = MaterialTheme.shapes.extraLarge, + ) { + Column(Modifier.fillMaxWidth().padding(20.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.Check, contentDescription = null) + Text( + WorkedPrompt.TITLE_START + car, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(start = 12.dp), + ) + } + Text( + "Share that so the next driver knows it works? No name, account, or location.", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 8.dp), + ) + buildNote()?.let { + Text(it, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 4.dp)) + } + Row( + Modifier.fillMaxWidth().padding(top = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + TextButton(onClick = onSeeWhatsSent, contentPadding = PaddingValues(horizontal = 0.dp)) { + Text("What gets sent") + } + Spacer(Modifier.weight(1f)) + OutlinedButton(onClick = { WorkedPrompt.answer(context, share = false) }) { Text("Not now") } + Button(onClick = { WorkedPrompt.answer(context, share = true) }) { Text("Share") } + } + } + } +} + /** * Usage notes on Home, one row with one action either way. While they're off, the row is the * switch, so opting in is a single tap. Once they're on, it shrinks to a quiet line that opens