From 17534d5784f6267f795058b3e2ad9c76bc7db279 Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Tue, 6 Oct 2026 00:06:06 -0700 Subject: [PATCH 01/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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/32] 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 From 74330db01c4fc32a438e47c99c06b8734868d85a Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Thu, 8 Oct 2026 10:35:30 -0700 Subject: [PATCH 12/32] readme: say most cars work, and why some newer ones don't --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 65d23c3..cef9f8d 100644 --- a/README.md +++ b/README.md @@ -63,8 +63,8 @@ mode. - **Some apps refuse Gearslip.** Spotify, Waze, and Google Messages use a version of Google's car library that accepts only Android Auto itself. Spotify still plays through Gearslip's own player. -- **Gearslip works best with cars from before 2020.** Some newer head units reject its - certificate. +- **Most cars work.** Starting in 2020, some cars added newer security standards that + reject Gearslip's certificate, and Gearslip can't run in those cars. - **Calls use the car's Bluetooth,** as they do in Android Auto. Pair the phone with the car to hear calls through its speakers. - **Audio reaches the car by capturing playback, not by mirroring the screen.** Android From a22d7936d4e07d1c34b095a9a179e548394efcbb Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Thu, 8 Oct 2026 10:37:06 -0700 Subject: [PATCH 13/32] readme: move which cars work to the top --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index cef9f8d..7715588 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,11 @@ An Android Auto alternative built for everyone. [Get it on Obtainium](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://github.com/Seb3thehacker/gearslip) +## Which cars work + +Most cars work. Starting in 2020, some cars added newer security standards that reject +Gearslip's certificate, and Gearslip can't run in those cars. + ## What Gearslip is Gearslip is an Android Auto client for the phone. It speaks the Android Auto protocol @@ -63,8 +68,6 @@ mode. - **Some apps refuse Gearslip.** Spotify, Waze, and Google Messages use a version of Google's car library that accepts only Android Auto itself. Spotify still plays through Gearslip's own player. -- **Most cars work.** Starting in 2020, some cars added newer security standards that - reject Gearslip's certificate, and Gearslip can't run in those cars. - **Calls use the car's Bluetooth,** as they do in Android Auto. Pair the phone with the car to hear calls through its speakers. - **Audio reaches the car by capturing playback, not by mirroring the screen.** Android From 246f571a86e6a6b7f20e05e651eae12257317685 Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Thu, 8 Oct 2026 10:37:53 -0700 Subject: [PATCH 14/32] readme: around 2020 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7715588..b496660 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ An Android Auto alternative built for everyone. ## Which cars work -Most cars work. Starting in 2020, some cars added newer security standards that reject +Most cars work. Starting around 2020, some cars added newer security standards that reject Gearslip's certificate, and Gearslip can't run in those cars. ## What Gearslip is From fd658d4e575954ab6f85edf96b1dae3cacb78908 Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Thu, 8 Oct 2026 15:27:48 -0700 Subject: [PATCH 15/32] certificate: say where it comes from, offer import alongside download, keep cert files out of the repo --- .gitignore | 7 +++++ .../gearslip/ui/CertSetupScreen.kt | 30 ++++++++++--------- .../gearslip/ui/SettingsScreen.kt | 7 +++-- 3 files changed, 27 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index d72c470..00a51a8 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,10 @@ CLAUDE.md archive/ .wrangler/ stats-worker/ + +# Certificates and keys never go in the repo; the app downloads or imports them on the phone. +*.p12 +*.pfx +*.pem +*.crt +*.key diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CertSetupScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CertSetupScreen.kt index 870d7b6..37a2ad2 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CertSetupScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CertSetupScreen.kt @@ -60,7 +60,6 @@ fun CertSetupScreen(onDone: () -> Unit) { val scope = rememberCoroutineScope() var downloadState by remember { mutableStateOf(DownloadState.Idle) } - var showImport by remember { mutableStateOf(false) } var pendingUri by remember { mutableStateOf(null) } var manualProgress by remember { mutableStateOf(false) } @@ -91,7 +90,7 @@ fun CertSetupScreen(onDone: () -> Unit) { if (installed) { "Cars accept only Google-issued certificates, and you have one loaded." } else { - "Cars accept only Google-issued certificates. Download one to connect." + "Cars accept only Google-issued certificates. Gearslip doesn't include one, so choose where yours comes from." }, style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center, @@ -131,10 +130,11 @@ fun CertSetupScreen(onDone: () -> Unit) { }, modifier = Modifier.fillMaxWidth().height(56.dp), ) { - Text("Download certificate") + Text("Download from aasdk") } Text( - "Works with most cars built before 2020. Newer cars reject it.", + "The open-source aasdk project publishes this certificate at github.com/opencardev/aasdk. " + + "Most cars accept it; some from around 2020 on don't.", style = MaterialTheme.typography.bodySmall, textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -177,16 +177,18 @@ fun CertSetupScreen(onDone: () -> Unit) { Spacer(Modifier.height(16.dp)) - if (showImport) { - OutlinedButton( - onClick = { importLauncher.launch(arrayOf("*/*")) }, - modifier = Modifier.fillMaxWidth().height(52.dp), - ) { Text("Import your own .p12 cert") } - Spacer(Modifier.height(16.dp)) - } else { - TextButton(onClick = { showImport = true }) { Text("Import your own certificate instead") } - Spacer(Modifier.height(8.dp)) - } + OutlinedButton( + onClick = { importLauncher.launch(arrayOf("*/*")) }, + modifier = Modifier.fillMaxWidth().height(52.dp), + ) { Text("Import my own file") } + Text( + "A .p12 file with its private key, from wherever you choose.", + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) + Spacer(Modifier.height(16.dp)) OutlinedButton( onClick = { 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 0761ba8..2d8ef0b 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt @@ -225,7 +225,7 @@ private fun CertificateSection() { } SettingsDivider() SettingsRow( - if (downloading) "Downloading…" else "Download certificate", + if (downloading) "Downloading…" else "Download from aasdk", Modifier.clickable(enabled = !downloading) { downloading = true scope.launch { @@ -239,13 +239,14 @@ private fun CertificateSection() { } } }, + subtitle = "The certificate the open-source aasdk project publishes on GitHub. Gearslip doesn't include one", icon = Icons.Filled.Refresh, ) SettingsDivider() SettingsRow( - "Import a certificate", + "Import my own file", Modifier.clickable { picker.launch(arrayOf("*/*")) }, - subtitle = "A .p12 file with its private key", + subtitle = "A .p12 file with its private key, from wherever you choose", icon = Icons.Filled.Add, ) val removable = when (s?.kind) { From 4a6305052d59901658f8f82f0374ebde5ab1fc15 Mon Sep 17 00:00:00 2001 From: Iscle Date: Fri, 9 Oct 2026 20:50:43 +0200 Subject: [PATCH 16/32] Recognize media template apps and group them under Music --- .../src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt | 2 ++ .../main/java/app/seb3thehacker/gearslip/host/CarAppCatalog.kt | 2 ++ 2 files changed, 4 insertions(+) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt index 6669c07..5a4e7d0 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt @@ -187,6 +187,8 @@ private fun groupOf(entry: Entry, mediaPackages: Set): AppGroup { return when (app.kind) { "calling" -> AppGroup.PHONE "navigation", "poi", "parking", "charging" -> AppGroup.MAPS + // Template-only media apps may have no legacy browser/player tile to identify them. + "media" -> AppGroup.MUSIC "messaging" -> AppGroup.MESSAGING "settings" -> AppGroup.SETTINGS else -> AppGroup.OTHER diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppCatalog.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppCatalog.kt index a9b2ca6..31ad0db 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppCatalog.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppCatalog.kt @@ -78,5 +78,7 @@ object CarAppCatalog { CarAppService.CATEGORY_SETTINGS_APP, CarAppService.CATEGORY_IOT_APP, CarAppService.CATEGORY_WEATHER_APP, + // Newer apps declare MEDIA, but our AndroidX 1.7 dependency has no constant for it. + "androidx.car.app.category.MEDIA", ) } From 4c2c8c6dda71ce7bf1456d6cecc5190011e3c719 Mon Sep 17 00:00:00 2001 From: Iscle Date: Fri, 9 Oct 2026 20:58:12 +0200 Subject: [PATCH 17/32] Show app toasts on the projected car display --- .../gearslip/car/CarToastOverlay.kt | 55 +++++++++++++ .../seb3thehacker/gearslip/car/CarToasts.kt | 33 ++++++++ .../app/seb3thehacker/gearslip/car/CarUi.kt | 1 + .../gearslip/host/CarAppConnection.kt | 25 +++++- .../gearslip/car/CarToastsTest.kt | 80 +++++++++++++++++++ 5 files changed, 190 insertions(+), 4 deletions(-) create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToastOverlay.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToasts.kt create mode 100644 gearslip/src/test/java/app/seb3thehacker/gearslip/car/CarToastsTest.kt diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToastOverlay.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToastOverlay.kt new file mode 100644 index 0000000..c2dab2a --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToastOverlay.kt @@ -0,0 +1,55 @@ +package app.seb3thehacker.gearslip.car + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalAccessibilityManager +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay + +/** Draw inside the encoded car display; a normal Android Toast would appear on the phone. */ +@Composable +internal fun CarToastOverlay(modifier: Modifier = Modifier) { + val current by CarToasts.message.collectAsState() + val message = current ?: return + val accessibility = LocalAccessibilityManager.current + LaunchedEffect(message, accessibility) { + val timeout = accessibility?.calculateRecommendedTimeoutMillis( + message.durationMs, containsIcons = false, containsText = true, containsControls = false, + ) ?: message.durationMs + delay(timeout) + CarToasts.dismiss(message) + } + + // A non-interactive overlay leaves the map's touch handling and focus intact. + Box( + modifier + .padding(horizontal = 24.dp, vertical = 16.dp) + .widthIn(max = 560.dp) + .clip(MaterialTheme.shapes.medium) + .background(MaterialTheme.colorScheme.inverseSurface) + .semantics { liveRegion = LiveRegionMode.Polite }, + ) { + Text( + message.text, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), + color = MaterialTheme.colorScheme.inverseOnSurface, + style = MaterialTheme.typography.bodyLarge, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToasts.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToasts.kt new file mode 100644 index 0000000..fc8d569 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToasts.kt @@ -0,0 +1,33 @@ +package app.seb3thehacker.gearslip.car + +import androidx.annotation.MainThread +import androidx.car.app.CarToast +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** One toast across navigation and browse apps; newer requests replace stale messages. */ +@MainThread +internal object CarToasts { + // Identity matters: repeating identical text must restart its display timer. + class Message(val owner: Any, val text: String, val durationMs: Long) + + private val current = MutableStateFlow(null) + val message: StateFlow = current.asStateFlow() + + fun show(owner: Any, text: String, duration: Int) { + if (text.isBlank()) return + val durationMs = if (duration == CarToast.LENGTH_LONG) 3_500L else 2_000L + current.value = Message(owner, text, durationMs) + } + + fun dismiss(message: Message) { + // An old timer must not dismiss a newer toast, even if its text is identical. + if (current.value === message) current.value = null + } + + fun clear(owner: Any) { + // Disconnecting the map must not clear a toast belonging to the browse app. + if (current.value?.owner === owner) current.value = null + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt index 03baae2..a0f0ebf 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt @@ -224,6 +224,7 @@ fun CarUi() { } } } + CarToastOverlay(Modifier.align(Alignment.BottomCenter)) PopupTimer() // The dashboard is already listing them, so it needs no popup. NotificationPopup( 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..d414053 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt @@ -1,6 +1,7 @@ package app.seb3thehacker.gearslip.host import app.seb3thehacker.gearslip.car.CarEnvironment +import app.seb3thehacker.gearslip.car.CarToasts import android.content.ComponentName import android.content.Context import android.content.Intent @@ -80,6 +81,8 @@ class CarAppConnection(private val context: Context) { val template: StateFlow = _template.asStateFlow() private val main = Handler(Looper.getMainLooper()) + /** Captured by each host Binder so callbacks queued by a replaced app cannot show a toast. */ + private var toastOwner = Any() private var carApp: ICarApp? = null private var appManager: IAppManager? = null @@ -215,6 +218,7 @@ class CarAppConnection(private val context: Context) { fun disconnect() = disconnect(keepLocation = false) private fun disconnect(keepLocation: Boolean) { + clearToasts() lent?.let { sendSurfaceDestroyed(it) } lent = null val carApp = this.carApp @@ -285,7 +289,7 @@ class CarAppConnection(private val context: Context) { ?: Intent(Intent.ACTION_MAIN).setComponent(component) pendingDestination = null call("onAppCreate", onValue = { onCreated() }) { - app.onAppCreate(carHost, intent, carConfiguration(), it) + app.onAppCreate(createCarHost(toastOwner), intent, carConfiguration(), it) } } @@ -558,14 +562,19 @@ class CarAppConnection(private val context: Context) { // --- host interfaces the app calls back into ----------------------------------------- - private val appHost = object : IAppHost.Stub() { + private fun createAppHost(owner: Any) = object : IAppHost.Stub() { override fun invalidate() { GearslipLog.i("host: app invalidated its template") main.post { requestTemplate() } } override fun showToast(text: CharSequence?, duration: Int) { - GearslipLog.i("host: toast \"$text\"") + val message = text?.toString() ?: return + main.post { + if (toastOwner !== owner) return@post + GearslipLog.i("host: toast \"$message\"") + CarToasts.show(owner, message, duration) + } } /** @@ -656,7 +665,9 @@ class CarAppConnection(private val context: Context) { override fun updateSuggestions(suggestions: Bundleable?) = Unit } - private val carHost = object : ICarHost.Stub() { + private fun createCarHost(owner: Any) = object : ICarHost.Stub() { + private val appHost = createAppHost(owner) + override fun getHost(type: String?): IBinder? = when (type) { CarContext.APP_SERVICE -> appHost.asBinder() CarContext.NAVIGATION_SERVICE -> navigationHost.asBinder() @@ -745,10 +756,16 @@ class CarAppConnection(private val context: Context) { } private fun fail(phase: Phase, detail: String) { + clearToasts() GearslipLog.e("host: $detail") update { it.copy(phase = phase, detail = detail) } } + private fun clearToasts() { + CarToasts.clear(toastOwner) + toastOwner = Any() + } + private fun update(block: (Status) -> Status) { _status.value = block(_status.value) } diff --git a/gearslip/src/test/java/app/seb3thehacker/gearslip/car/CarToastsTest.kt b/gearslip/src/test/java/app/seb3thehacker/gearslip/car/CarToastsTest.kt new file mode 100644 index 0000000..a25fef1 --- /dev/null +++ b/gearslip/src/test/java/app/seb3thehacker/gearslip/car/CarToastsTest.kt @@ -0,0 +1,80 @@ +package app.seb3thehacker.gearslip.car + +import androidx.car.app.CarToast +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Before +import org.junit.Test + +class CarToastsTest { + @Before + @After + fun clearToast() { + CarToasts.message.value?.let(CarToasts::dismiss) + } + + @Test + fun expiredToastCannotDismissItsReplacement() { + val owner = Any() + CarToasts.show(owner, "First", CarToast.LENGTH_SHORT) + val first = requireNotNull(CarToasts.message.value) + CarToasts.show(owner, "Second", CarToast.LENGTH_LONG) + val second = requireNotNull(CarToasts.message.value) + + CarToasts.dismiss(first) + assertSame(second, CarToasts.message.value) + CarToasts.dismiss(second) + assertNull(CarToasts.message.value) + } + + @Test + fun identicalMessagesStillRestartTheDisplayTimer() { + val owner = Any() + CarToasts.show(owner, "Saved", CarToast.LENGTH_SHORT) + val first = requireNotNull(CarToasts.message.value) + CarToasts.show(owner, "Saved", CarToast.LENGTH_SHORT) + val repeated = requireNotNull(CarToasts.message.value) + + assertNotSame(first, repeated) + CarToasts.dismiss(first) + assertSame(repeated, CarToasts.message.value) + } + + @Test + fun disconnectOnlyClearsItsOwnToast() { + val nav = Any() + val browse = Any() + CarToasts.show(nav, "Route saved", CarToast.LENGTH_SHORT) + CarToasts.show(browse, "Added to playlist", CarToast.LENGTH_SHORT) + val latest = CarToasts.message.value + + CarToasts.clear(nav) + assertSame(latest, CarToasts.message.value) + CarToasts.clear(browse) + assertNull(CarToasts.message.value) + } + + @Test + fun blankRequestsDoNotReplaceAVisibleMessage() { + val owner = Any() + CarToasts.show(owner, "Saved", CarToast.LENGTH_SHORT) + val visible = CarToasts.message.value + + CarToasts.show(owner, " \n\t", CarToast.LENGTH_LONG) + assertSame(visible, CarToasts.message.value) + } + + @Test + fun unknownDurationsFallBackToShortRatherThanStayingVisible() { + val owner = Any() + CarToasts.show(owner, "Short", CarToast.LENGTH_SHORT) + assertEquals(2_000L, CarToasts.message.value?.durationMs) + CarToasts.show(owner, "Long", CarToast.LENGTH_LONG) + assertEquals(3_500L, CarToasts.message.value?.durationMs) + CarToasts.show(owner, "Unknown", Int.MAX_VALUE) + assertEquals(2_000L, CarToasts.message.value?.durationMs) + } +} From 92d7940f56ee233e6eda33d1d5cec2408b689dde Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Fri, 9 Oct 2026 13:50:40 -0700 Subject: [PATCH 18/32] car apps: let hosted apps start their own location service, fixing MapQuest Since Android 14, an app bound by a visible host may start a foreground service only if the host binds it with BIND_ALLOW_ACTIVITY_STARTS. Without it, MapQuest crashed as navigation began, and Flitsmeister and Vela ran their services without location. MapQuest now gets the working check. --- .../app/seb3thehacker/gearslip/host/CarAppConnection.kt | 7 ++++++- .../main/java/app/seb3thehacker/gearslip/host/KnownApps.kt | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) 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 d13c3e8..1521f64 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt @@ -9,6 +9,7 @@ import android.content.res.Configuration import android.graphics.Rect import android.location.Location import android.net.Uri +import android.os.Build import android.os.Handler import android.os.IBinder import android.os.Looper @@ -139,7 +140,11 @@ class CarAppConnection(private val context: Context) { // Replaced by another connect (or a disconnect) while waiting on the location service. if (binding !== connection) return@bind var bindError: Throwable? = null - val flags = Context.BIND_AUTO_CREATE or LocationKeepAlive.BIND_INCLUDE_CAPABILITIES + // BIND_ALLOW_ACTIVITY_STARTS lets the app start its own location service while + // Gearslip's car window is showing. Since Android 14 a bound app only inherits that + // from a visible host that opts in, and MapQuest crashes as navigation starts without it. + val flags = Context.BIND_AUTO_CREATE or LocationKeepAlive.BIND_INCLUDE_CAPABILITIES or + (if (Build.VERSION.SDK_INT >= 34) Context.BIND_ALLOW_ACTIVITY_STARTS else 0) val bound = runCatching { // Without BIND_FOREGROUND_SERVICE, an app bound by a foreground service (which is // all Gearslip is once the phone screen leaves it) only reaches "important diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/KnownApps.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/KnownApps.kt index 79caa60..7e03a49 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/KnownApps.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/KnownApps.kt @@ -18,6 +18,7 @@ object KnownApps { "com.vivi.vivimusic", // Vivi Music: browse, play, seek, works well "com.metrolist.music", // Metrolist: browse, play, works "nl.flitsmeister", // Flitsmeister: map, routing, alerts - works well + "com.mapquest.android.ace", // MapQuest: map, routing, navigation ) fun works(packageName: String): Boolean = packageName in working From 179a51ea2510b4e1713c6bfa969a00879f848bd3 Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Fri, 9 Oct 2026 13:34:32 -0700 Subject: [PATCH 19/32] car toasts: match the message popup and stay up longer A driver only glances at the screen, so short toasts now last 4 seconds and long ones 7. The card uses the popup's colours, which read better over a map. --- .../app/seb3thehacker/gearslip/car/CarToastOverlay.kt | 9 +++++---- .../java/app/seb3thehacker/gearslip/car/CarToasts.kt | 3 ++- .../java/app/seb3thehacker/gearslip/car/CarToastsTest.kt | 6 +++--- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToastOverlay.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToastOverlay.kt index c2dab2a..7416e01 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToastOverlay.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToastOverlay.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -39,15 +40,15 @@ internal fun CarToastOverlay(modifier: Modifier = Modifier) { modifier .padding(horizontal = 24.dp, vertical = 16.dp) .widthIn(max = 560.dp) - .clip(MaterialTheme.shapes.medium) - .background(MaterialTheme.colorScheme.inverseSurface) + .clip(RoundedCornerShape(20.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) .semantics { liveRegion = LiveRegionMode.Polite }, ) { Text( message.text, modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), - color = MaterialTheme.colorScheme.inverseOnSurface, - style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleMedium, maxLines = 3, overflow = TextOverflow.Ellipsis, ) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToasts.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToasts.kt index fc8d569..84b162d 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToasts.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToasts.kt @@ -17,7 +17,8 @@ internal object CarToasts { fun show(owner: Any, text: String, duration: Int) { if (text.isBlank()) return - val durationMs = if (duration == CarToast.LENGTH_LONG) 3_500L else 2_000L + // Longer than a phone toast: the driver only glances at the screen now and then. + val durationMs = if (duration == CarToast.LENGTH_LONG) 7_000L else 4_000L current.value = Message(owner, text, durationMs) } diff --git a/gearslip/src/test/java/app/seb3thehacker/gearslip/car/CarToastsTest.kt b/gearslip/src/test/java/app/seb3thehacker/gearslip/car/CarToastsTest.kt index a25fef1..f523929 100644 --- a/gearslip/src/test/java/app/seb3thehacker/gearslip/car/CarToastsTest.kt +++ b/gearslip/src/test/java/app/seb3thehacker/gearslip/car/CarToastsTest.kt @@ -71,10 +71,10 @@ class CarToastsTest { fun unknownDurationsFallBackToShortRatherThanStayingVisible() { val owner = Any() CarToasts.show(owner, "Short", CarToast.LENGTH_SHORT) - assertEquals(2_000L, CarToasts.message.value?.durationMs) + assertEquals(4_000L, CarToasts.message.value?.durationMs) CarToasts.show(owner, "Long", CarToast.LENGTH_LONG) - assertEquals(3_500L, CarToasts.message.value?.durationMs) + assertEquals(7_000L, CarToasts.message.value?.durationMs) CarToasts.show(owner, "Unknown", Int.MAX_VALUE) - assertEquals(2_000L, CarToasts.message.value?.durationMs) + assertEquals(4_000L, CarToasts.message.value?.durationMs) } } From 75dfaa3013f3cc9cd7b96ed730bd73bf007f84f1 Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Fri, 9 Oct 2026 15:26:48 -0700 Subject: [PATCH 20/32] car icons: drop app tints that can't be seen, mark MAPS.ME and MapQuest as working MAPS.ME tints its map buttons dark grey for dark mode, which vanished on Gearslip's dark buttons. A tint below 3:1 contrast now falls back to the normal text colour, as Android Auto does. --- .../seb3thehacker/gearslip/car/TemplateParts.kt | 17 ++++++++++++++++- .../seb3thehacker/gearslip/host/KnownApps.kt | 2 +- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt index 0d07349..91303ca 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.material3.LocalContentColor import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.foundation.Image import kotlinx.coroutines.Dispatchers @@ -468,7 +469,10 @@ internal fun CarGlyph(icon: CarIcon?, modifier: Modifier = Modifier, standard: I val white = remember(bitmap) { bitmap.isPlainWhite() } // Untinted white artwork is a template-app habit from dark surfaces; it is coloured like // text so it stays readable on a light one. - val tint = icon?.tint?.color(dark, content) ?: content.takeIf { white } + // An app's own tint is kept only if it can be seen: MAPS.ME asks for dark grey on dark + // buttons. Android Auto drops colours that fail contrast the same way. + val tint = icon?.tint?.color(dark, content)?.takeIf { it.readableOn(content) } + ?: content.takeIf { white || icon?.tint != null } Image( bitmap, contentDescription = null, modifier = modifier, colorFilter = tint?.let { ColorFilter.tint(it) }, @@ -661,6 +665,17 @@ internal fun RowItem(row: CarRow, selection: RowSelection? = null, large: Boolea /** A row's place in a single-choice list: whether it is the chosen one, and what picking it does. */ internal class RowSelection(val selected: Boolean, val onPick: () -> Unit) +/** + * Whether this colour stands out against the background that [content] was chosen for: at least + * 3:1, the WCAG minimum for icons. The background is taken as the opposite of the content colour. + */ +private fun Color.readableOn(content: Color): Boolean { + val background = if (content.luminanceIsDark()) Color.White else Color(0xFF121212) + val a = luminance() + 0.05f + val b = background.luminance() + 0.05f + return maxOf(a, b) / minOf(a, b) >= 3f +} + internal fun Color.luminanceIsDark(): Boolean = (red * 0.299f + green * 0.587f + blue * 0.114f) < 0.5f diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/KnownApps.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/KnownApps.kt index 7e03a49..74289ee 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/KnownApps.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/KnownApps.kt @@ -19,6 +19,7 @@ object KnownApps { "com.metrolist.music", // Metrolist: browse, play, works "nl.flitsmeister", // Flitsmeister: map, routing, alerts - works well "com.mapquest.android.ace", // MapQuest: map, routing, navigation + "com.mapswithme.maps.pro", // MAPS.ME: map, search, place pages, navigation ) fun works(packageName: String): Boolean = packageName in working @@ -72,7 +73,6 @@ object KnownApps { */ private val partial = setOf( "app.vela", // Vela Maps: connects and draws, but rough enough not to call it working yet - "com.mapquest.android.ace", // MapQuest: map, search and routing run, but not cleanly "com.spotify.music", // Spotify: plays through Gearslip's player, but the app has to be opened on the phone first "deezer.android.app", // Deezer: the same - it has to be opened on the phone first ) From 23f12884da0b693b807013b7c8b41ce4c3bf89f1 Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Fri, 9 Oct 2026 15:26:48 -0700 Subject: [PATCH 21/32] car toasts: take turns instead of replacing each other A new toast waits until the one on screen has had 2 seconds. Repeats restart the visible toast, and a burst keeps only the newest three waiting. --- .../gearslip/car/CarToastOverlay.kt | 8 +++- .../seb3thehacker/gearslip/car/CarToasts.kt | 39 +++++++++++++++++-- .../gearslip/car/CarToastsTest.kt | 37 ++++++++++++++++-- 3 files changed, 75 insertions(+), 9 deletions(-) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToastOverlay.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToastOverlay.kt index 7416e01..fb877e7 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToastOverlay.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToastOverlay.kt @@ -20,6 +20,8 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull /** Draw inside the encoded car display; a normal Android Toast would appear on the phone. */ @Composable @@ -31,7 +33,11 @@ internal fun CarToastOverlay(modifier: Modifier = Modifier) { val timeout = accessibility?.calculateRecommendedTimeoutMillis( message.durationMs, containsIcons = false, containsText = true, containsControls = false, ) ?: message.durationMs - delay(timeout) + // The full time, unless another toast is waiting: then it gives way after the minimum. + delay(minOf(timeout, CarToasts.MIN_SHOWN_MS)) + withTimeoutOrNull((timeout - CarToasts.MIN_SHOWN_MS).coerceAtLeast(0)) { + CarToasts.waiting.first { it > 0 } + } CarToasts.dismiss(message) } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToasts.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToasts.kt index 84b162d..6d3e300 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToasts.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarToasts.kt @@ -6,29 +6,60 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -/** One toast across navigation and browse apps; newer requests replace stale messages. */ +/** + * One toast at a time across navigation and browse apps. A newer one waits its turn instead of + * replacing the one on screen, which gets at least [MIN_SHOWN_MS] before it gives way. + */ @MainThread internal object CarToasts { // Identity matters: repeating identical text must restart its display timer. class Message(val owner: Any, val text: String, val durationMs: Long) + /** Long enough to read a short toast at a glance; a waiting toast cuts the rest short. */ + const val MIN_SHOWN_MS = 2_000L + + /** A burst beyond this many is stale by the time it would show; the oldest waiting go first. */ + private const val MAX_WAITING = 3 + private val current = MutableStateFlow(null) val message: StateFlow = current.asStateFlow() + private val queue = ArrayDeque() + private val _waiting = MutableStateFlow(0) + val waiting: StateFlow = _waiting.asStateFlow() + fun show(owner: Any, text: String, duration: Int) { if (text.isBlank()) return // Longer than a phone toast: the driver only glances at the screen now and then. val durationMs = if (duration == CarToast.LENGTH_LONG) 7_000L else 4_000L - current.value = Message(owner, text, durationMs) + val next = Message(owner, text, durationMs) + val shown = current.value + when { + shown == null -> current.value = next + // The same words again restart the one on screen rather than queueing a copy. + shown.text == text -> current.value = next + queue.lastOrNull()?.text == text -> Unit + else -> { + queue.addLast(next) + while (queue.size > MAX_WAITING) queue.removeFirst() + } + } + _waiting.value = queue.size } fun dismiss(message: Message) { // An old timer must not dismiss a newer toast, even if its text is identical. - if (current.value === message) current.value = null + if (current.value === message) advance() } fun clear(owner: Any) { // Disconnecting the map must not clear a toast belonging to the browse app. - if (current.value?.owner === owner) current.value = null + queue.removeAll { it.owner === owner } + if (current.value?.owner === owner) advance() else _waiting.value = queue.size + } + + private fun advance() { + current.value = queue.removeFirstOrNull() + _waiting.value = queue.size } } diff --git a/gearslip/src/test/java/app/seb3thehacker/gearslip/car/CarToastsTest.kt b/gearslip/src/test/java/app/seb3thehacker/gearslip/car/CarToastsTest.kt index f523929..05c8c90 100644 --- a/gearslip/src/test/java/app/seb3thehacker/gearslip/car/CarToastsTest.kt +++ b/gearslip/src/test/java/app/seb3thehacker/gearslip/car/CarToastsTest.kt @@ -13,15 +13,30 @@ class CarToastsTest { @Before @After fun clearToast() { - CarToasts.message.value?.let(CarToasts::dismiss) + while (true) CarToasts.message.value?.let(CarToasts::dismiss) ?: break } @Test - fun expiredToastCannotDismissItsReplacement() { + fun aNewToastWaitsForTheOneOnScreen() { val owner = Any() CarToasts.show(owner, "First", CarToast.LENGTH_SHORT) val first = requireNotNull(CarToasts.message.value) CarToasts.show(owner, "Second", CarToast.LENGTH_LONG) + + assertSame(first, CarToasts.message.value) + assertEquals(1, CarToasts.waiting.value) + CarToasts.dismiss(first) + assertEquals("Second", CarToasts.message.value?.text) + assertEquals(0, CarToasts.waiting.value) + } + + @Test + fun expiredTimerCannotDismissItsReplacement() { + val owner = Any() + CarToasts.show(owner, "First", CarToast.LENGTH_SHORT) + val first = requireNotNull(CarToasts.message.value) + CarToasts.show(owner, "Second", CarToast.LENGTH_LONG) + CarToasts.dismiss(first) val second = requireNotNull(CarToasts.message.value) CarToasts.dismiss(first) @@ -39,6 +54,7 @@ class CarToastsTest { val repeated = requireNotNull(CarToasts.message.value) assertNotSame(first, repeated) + assertEquals(0, CarToasts.waiting.value) CarToasts.dismiss(first) assertSame(repeated, CarToasts.message.value) } @@ -49,14 +65,24 @@ class CarToastsTest { val browse = Any() CarToasts.show(nav, "Route saved", CarToast.LENGTH_SHORT) CarToasts.show(browse, "Added to playlist", CarToast.LENGTH_SHORT) - val latest = CarToasts.message.value CarToasts.clear(nav) - assertSame(latest, CarToasts.message.value) + assertEquals("Added to playlist", CarToasts.message.value?.text) CarToasts.clear(browse) assertNull(CarToasts.message.value) } + @Test + fun aBurstKeepsOnlyTheNewestFewWaiting() { + val owner = Any() + listOf("1", "2", "3", "4", "5", "6").forEach { CarToasts.show(owner, it, CarToast.LENGTH_SHORT) } + + assertEquals("1", CarToasts.message.value?.text) + assertEquals(3, CarToasts.waiting.value) + CarToasts.message.value?.let(CarToasts::dismiss) + assertEquals("4", CarToasts.message.value?.text) + } + @Test fun blankRequestsDoNotReplaceAVisibleMessage() { val owner = Any() @@ -65,6 +91,7 @@ class CarToastsTest { CarToasts.show(owner, " \n\t", CarToast.LENGTH_LONG) assertSame(visible, CarToasts.message.value) + assertEquals(0, CarToasts.waiting.value) } @Test @@ -72,8 +99,10 @@ class CarToastsTest { val owner = Any() CarToasts.show(owner, "Short", CarToast.LENGTH_SHORT) assertEquals(4_000L, CarToasts.message.value?.durationMs) + CarToasts.message.value?.let(CarToasts::dismiss) CarToasts.show(owner, "Long", CarToast.LENGTH_LONG) assertEquals(7_000L, CarToasts.message.value?.durationMs) + CarToasts.message.value?.let(CarToasts::dismiss) CarToasts.show(owner, "Unknown", Int.MAX_VALUE) assertEquals(4_000L, CarToasts.message.value?.durationMs) } From 239be1256bf1f3ea3a9a9335e62089549e77388e Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Fri, 9 Oct 2026 15:26:48 -0700 Subject: [PATCH 22/32] place pages: name on top, actions as buttons, call a place's number Panes shaped like a place page (MAPS.ME's) show the name and kind first, short tappable rows as buttons, and the details compact below. A phone number gets a Call button that asks first with the dialer's confirmation. Other panes draw as before. --- .../app/seb3thehacker/gearslip/car/CarUi.kt | 1 + .../gearslip/car/PhoneDialerScreen.kt | 26 ++++ .../gearslip/car/TemplateParts.kt | 128 +++++++++++++++++- 3 files changed, 154 insertions(+), 1 deletion(-) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt index 6bdf7c0..c21f6e2 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt @@ -227,6 +227,7 @@ fun CarUi() { } } CarToastOverlay(Modifier.align(Alignment.BottomCenter)) + CallPromptOverlay() PopupTimer() // The dashboard is already listing them, so it needs no popup. NotificationPopup( diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneDialerScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneDialerScreen.kt index cb21b59..a7ba655 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneDialerScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneDialerScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState @@ -89,6 +90,31 @@ internal fun placeCall(context: Context, number: String) { } } +/** A call another car screen wants to place - a number on a place page - waiting on the driver. */ +internal object CallPrompt { + val pending = kotlinx.coroutines.flow.MutableStateFlow(null) + + fun ask(name: String, number: String) { + pending.value = Contact(name, number) + } +} + +/** The dialer's own confirmation, drawn over the whole car screen for [CallPrompt]. */ +@Composable +internal fun CallPromptOverlay() { + val context = LocalContext.current + val contact by CallPrompt.pending.collectAsState() + val c = contact ?: return + ConfirmCallOverlay( + c, + onConfirm = { + CallPrompt.pending.value = null + placeCall(context, c.number) + }, + onDismiss = { CallPrompt.pending.value = null }, + ) +} + private const val TAB_CONTACTS = 0 private const val TAB_KEYPAD = 1 diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt index 91303ca..cee1973 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt @@ -30,6 +30,7 @@ import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Call import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Warning @@ -591,16 +592,141 @@ internal fun PaneRows(pane: Pane?, modifier: Modifier = Modifier, autoStart: Boo // Some apps pad a pane with a row of blank strings; drawn, it is just a gap. val rows = pane?.rows.orEmpty().filterNot { it.isBlank() } val actions = pane?.actions.orEmpty().filter { it.isDrawable() } + val card = PlaceCard.of(rows) Column(modifier) { - rows.forEach { RowItem(it, large = true) } + card.heading?.let { PlaceHeading(it) } + if (card.buttons.isNotEmpty()) RowButtons(card.buttons, Modifier.padding(horizontal = 14.dp, vertical = 6.dp)) if (autoStart && actions.size == 1) { AutoStartButton(actions.single(), Modifier.padding(horizontal = 14.dp, vertical = 10.dp)) } else { ActionRow(actions, Modifier.padding(horizontal = 14.dp, vertical = 10.dp), large = true) } + if (card.heading == null && card.buttons.isEmpty()) { + card.details.forEach { RowItem(it, large = true) } + } else { + card.details.forEach { DetailRow(it, card.heading?.title?.text().orEmpty()) } + } } } +/** + * A pane split the way a place page reads: a name on top, the things to do with it as buttons, + * then the facts. Apps send all three as plain rows, so the split is worked out from the rows' + * shape alone. A pane that doesn't fit the shape - all of it tappable, like a menu - gets no + * heading and no buttons, and draws exactly as a plain list of rows. + */ +private class PlaceCard(val heading: CarRow?, val buttons: List, val details: List) { + companion object { + fun of(rows: List): PlaceCard { + // A short tappable row with an icon and nothing under it is a button in all but name: + // MAPS.ME's "Let's go" and "Save". + val buttons = rows.filter { + it.onClickDelegate != null && it.toggle == null && it.image != null && + it.texts.orEmpty().isEmpty() && it.title.text().length <= 24 + }.takeIf { it.size in 1..4 && it.size < rows.size }.orEmpty() + // A first row that can't be tapped and has no icon is the place's name and kind. + val heading = rows.firstOrNull()?.takeIf { + buttons.isNotEmpty() && it.onClickDelegate == null && it.image == null && it.toggle == null + } + return PlaceCard(heading, buttons, rows - buttons.toSet() - listOfNotNull(heading).toSet()) + } + } +} + +@Composable +private fun PlaceHeading(row: CarRow) { + Column(Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 6.dp)) { + Text(row.title.text(), style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis) + row.texts.orEmpty().take(2).forEach { + Text(it.text(), style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } +} + +/** Button-shaped rows side by side, the first one filled as the thing most drivers came to do. */ +@Composable +private fun RowButtons(rows: List, modifier: Modifier) { + val scheme = MaterialTheme.colorScheme + Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + rows.forEachIndexed { i, row -> + GsIconBox( + onClick = { row.onClickDelegate.click("row") }, + modifier = Modifier.weight(1f), + colors = if (i == 0) GsColors(scheme.primary, scheme.onPrimary) else GsColors(scheme.surfaceContainerHighest, scheme.onSurface), + enabled = row.isEnabled, + shape = RoundedCornerShape(24.dp), + ) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + CarGlyph(row.image, Modifier.size(24.dp)) + Spacer(Modifier.width(8.dp)) + Text(row.title.text(), style = ChromeType.body, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } +} + +/** + * One fact about a place, tighter than a list row: a small label over its value, or just the + * value when the row has no label. A phone number gets a Call button that asks first. + */ +@Composable +private fun DetailRow(row: CarRow, placeName: String) { + val context = LocalContext.current + val texts = row.texts.orEmpty().map { it.text() }.filter { it.isNotBlank() } + val label = row.title.text().takeIf { texts.isNotEmpty() } + val values = texts.ifEmpty { listOf(row.title.text()) } + val number = values.firstOrNull { it.isPhoneNumber() } + val canCall = number != null && + context.checkSelfPermission(android.Manifest.permission.CALL_PHONE) == android.content.pm.PackageManager.PERMISSION_GRANTED + Row( + Modifier + .fillMaxWidth() + .let { if (row.onClickDelegate != null && row.isEnabled) it.clickable { row.onClickDelegate.click("row") } else it } + .padding(horizontal = 14.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + row.image?.let { + CarGlyph(it, Modifier.size(24.dp)) + Spacer(Modifier.width(12.dp)) + } + Column(Modifier.weight(1f)) { + label?.let { + Text(it, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) + } + values.take(3).forEach { + Text(it, style = ChromeType.body, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + if (canCall) { + Spacer(Modifier.width(8.dp)) + GsIconBox( + onClick = { CallPrompt.ask(placeName.ifBlank { number!! }, number!!) }, + colors = GsColors(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.onPrimary), + shape = RoundedCornerShape(24.dp), + ) { + Row(Modifier.padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) { + androidx.compose.material3.Icon(Icons.Filled.Call, null, Modifier.size(20.dp)) + Spacer(Modifier.width(6.dp)) + Text("Call", style = ChromeType.body) + } + } + } + ActionRow(row.actions.orEmpty()) + } +} + +/** A line that is only a phone number: digits with the usual separators, 7 to 15 digits long. */ +private fun String.isPhoneNumber(): Boolean { + val t = trim() + if (!Regex("""^\+?[0-9 ().\-]+$""").matches(t)) return false + return t.count { it.isDigit() } in 7..15 +} + @Composable internal fun RowItem(row: CarRow, selection: RowSelection? = null, large: Boolean = false) { val toggle = row.toggle From 8743e992d78f0e613bf3a95d9f4b3f0986f1cb5e Mon Sep 17 00:00:00 2001 From: Iscle Date: Sat, 10 Oct 2026 23:34:39 +0200 Subject: [PATCH 23/32] Stop cleanly and ignore messages after handshake rejection --- .../seb3thehacker/gearslip/GearslipRunner.kt | 20 ++- .../gearslip/ConnectionLifecycleTest.kt | 136 ++++++++++++++++++ 2 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 gearslip/src/test/java/app/seb3thehacker/gearslip/ConnectionLifecycleTest.kt diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt index 26c945d..f80a860 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt @@ -49,6 +49,8 @@ class GearslipRunner( /** Waits for the certificate only when TLS is first needed; see [run]. */ private val tls: PhoneTls by lazy { identityTask.get() } + private var authenticationFailed = false + @Volatile private var stopped = false @Volatile private var state = State.WAIT_VERSION @Volatile private var lastProgress = System.currentTimeMillis() @@ -111,6 +113,7 @@ class GearslipRunner( try { while (running) { val read = input.read(chunk) + if (stopped) return if (read < 0) { log.w("head unit closed the connection (EOF) while in state $state") reportDisconnect() @@ -124,16 +127,19 @@ class GearslipRunner( } } } catch (t: Throwable) { + if (stopped) return log.e("transport failed in state $state", t) SessionReport.fail(SessionReport.Category.USB, "transport failed", t, state.name) reportDisconnect() } finally { running = false + writer.shutdownNow() log.flush() } } fun stop() { + stopped = true running = false writer.shutdownNow() CarFocus.request = null @@ -156,6 +162,8 @@ class GearslipRunner( } private fun handleFrame(frame: Frames.Frame) { + // A rejected handshake cannot consume later messages as if authentication succeeded. + if (authenticationFailed || stopped) return val payload = if (frame.encrypted) { if (!tls.handshakeComplete) { log.w("encrypted frame arrived before the handshake finished - ignoring") @@ -274,10 +282,14 @@ class GearslipRunner( } private fun onHandshake(body: ByteArray) { + if (state != State.TLS_HANDSHAKE && state != State.WAIT_AUTH) return log.i("<- EncapsulatedSSL (${body.size} bytes)") val replies = try { - tls.pumpHandshake(body) + val preparedTls = tls + if (stopped) return + preparedTls.pumpHandshake(body) } catch (t: Throwable) { + if (stopped) return log.e("TLS handshake threw", t) SessionReport.fail(SessionReport.Category.TLS, "handshake threw", t, state.name) log.verdict( @@ -290,9 +302,11 @@ class GearslipRunner( "The head unit aborted the secure handshake. It validates the phone's certificate.", ) state = State.DONE + authenticationFailed = true SessionReport.print() return } + if (stopped) return replies.forEach { send(MSG_ENCAPSULATED_SSL, it, encrypted = false) log.i("-> EncapsulatedSSL (${it.size} bytes)") @@ -305,6 +319,7 @@ class GearslipRunner( private fun onAuthComplete(body: ByteArray) { val status = Protobuf.readInt32Field(body, 1) + if (state != State.WAIT_AUTH && !(state == State.TLS_HANDSHAKE && status in listOf(-2, -3))) return log.i("<- AuthComplete: status=$status") status?.let { SessionReport.auth(it) } @@ -328,6 +343,7 @@ class GearslipRunner( "The head unit refused the phone's certificate (certificate error).", ) state = State.DONE + authenticationFailed = true } -3 -> { SessionReport.fail(SessionReport.Category.AUTH, "AuthComplete status -3 (authentication failure)", state.name, "auth -3") @@ -341,6 +357,7 @@ class GearslipRunner( "Authentication failed. The head unit does not trust the certificate in use.", ) state = State.DONE + authenticationFailed = true } else -> { SessionReport.fail(SessionReport.Category.AUTH, "AuthComplete status $status (unexpected)", state.name, "auth $status") @@ -351,6 +368,7 @@ class GearslipRunner( ) SessionStatus.failed("Unexpected response", "The head unit answered authentication with status $status.") state = State.DONE + authenticationFailed = true } } } diff --git a/gearslip/src/test/java/app/seb3thehacker/gearslip/ConnectionLifecycleTest.kt b/gearslip/src/test/java/app/seb3thehacker/gearslip/ConnectionLifecycleTest.kt new file mode 100644 index 0000000..2966b2e --- /dev/null +++ b/gearslip/src/test/java/app/seb3thehacker/gearslip/ConnectionLifecycleTest.kt @@ -0,0 +1,136 @@ +package app.seb3thehacker.gearslip + +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.InputStream +import java.nio.ByteBuffer +import java.util.concurrent.CountDownLatch +import java.util.concurrent.ExecutorService +import java.util.concurrent.TimeUnit +import javax.net.ssl.SSLContext +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ConnectionLifecycleTest { + private val version = message(1, byteArrayOf(0, 1, 0, 3)) + + private fun message(id: Int, body: ByteArray = byteArrayOf()) = Frames.build( + Frames.CHANNEL_CONTROL, false, byteArrayOf(0, id.toByte()) + body, + ) + + private fun hello(): ByteArray { + val client = SSLContext.getDefault().createSSLEngine().apply { + useClientMode = true + enabledProtocols = arrayOf("TLSv1.2") + beginHandshake() + } + val packet = ByteBuffer.allocate(client.session.packetBufferSize) + client.wrap(ByteBuffer.allocate(0), packet) + return message(3, packet.array().copyOf(packet.position())) + } + + private fun identity(): CertProvider.Identity { + val generated = SelfSignedCert.generate() + return CertProvider.Identity( + generated.keyStore, generated.certificate, SelfSignedCert.PASSWORD, "generated test identity", + ) + } + + private fun runner(input: InputStream) = GearslipRunner(input, ByteArrayOutputStream(), ::identity) + + @Test fun `rejected authentication ignores subsequent success and service discovery in the same read`() { + for (status in listOf(-2, -3)) { + runner(ByteArrayInputStream( + version + hello() + message(4, Protobuf.varintField(1, status.toLong())) + + message(4, Protobuf.varintField(1, 0)) + message(6) + version, + )).run() + assertEquals( + if (status == -2) SessionReport.Category.CERTIFICATE else SessionReport.Category.AUTH, + SessionReport.category, + ) + assertEquals(SessionStatus.Phase.FAILED, SessionStatus.state.value.phase) + assertEquals("Head unit rejected the certificate", SessionStatus.state.value.headline) + assertTrue(SessionReport.stage < SessionReport.Stage.ACCEPTED) + } + } + + @Test fun `TLS failure ignores later protocol messages`() { + val fatalAlert = message(3, byteArrayOf(21, 3, 3, 0, 2, 2, 42)) + runner(ByteArrayInputStream(version + hello() + fatalAlert + version + message(6))).run() + assertEquals(SessionReport.Category.TLS, SessionReport.category) + assertEquals("Head unit rejected the certificate", SessionStatus.state.value.headline) + assertTrue(SessionReport.stage < SessionReport.Stage.ACCEPTED) + } + + @Test fun `authentication success before TLS completes is ignored`() { + runner(ByteArrayInputStream(version + message(4, Protobuf.varintField(1, 0)))).run() + assertEquals(SessionReport.Category.TLS, SessionReport.category) + assertEquals(SessionReport.Stage.VERSIONS, SessionReport.stage) + } + + @Test fun `stop followed by EOF does not report a handshake failure`() { + stopDuringRead { -1 } + } + + @Test fun `stop followed by a read exception does not report a USB failure`() { + stopDuringRead { throw IOException("transport closed by owner") } + } + + @Test fun `stop ignores bytes returned by an in-flight read`() { + stopDuringRead { bytes -> version.copyInto(bytes); version.size } + } + + private fun stopDuringRead(result: (ByteArray) -> Int) { + lateinit var runner: GearslipRunner + val input = object : InputStream() { + override fun read(): Int = error("bulk reads only") + override fun read(bytes: ByteArray, offset: Int, length: Int): Int { + runner.stop() + return result(bytes) + } + } + runner = runner(input) + runner.run() + assertEquals(SessionReport.Category.NONE, SessionReport.category) + assertEquals(SessionStatus.Phase.DISCONNECTED, SessionStatus.state.value.phase) + } + + @Test fun `stop while preparing TLS leaves the session disconnected`() { + val releaseIdentity = CountDownLatch(1) + val runner = GearslipRunner(ByteArrayInputStream(version + hello()), ByteArrayOutputStream(), { + check(releaseIdentity.await(5, TimeUnit.SECONDS)) + identity() + }) + val reader = Thread { runner.run() } + reader.start() + try { + // The reader waits on FutureTask.get while the identity provider is held above. + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) + while (reader.state != Thread.State.WAITING && reader.isAlive && System.nanoTime() < deadline) { + reader.join(1) + } + assertEquals(Thread.State.WAITING, reader.state) + runner.stop() + } finally { + releaseIdentity.countDown() + reader.join(5_000) + } + assertTrue("reader must finish", !reader.isAlive) + assertEquals(SessionReport.Category.NONE, SessionReport.category) + assertEquals(SessionStatus.Phase.DISCONNECTED, SessionStatus.state.value.phase) + } + + @Test fun `EOF and transport failure shut down the writer`() { + val failing = object : InputStream() { + override fun read(): Int = throw IOException("test disconnect") + } + for (input in listOf(ByteArrayInputStream(version), failing)) { + val runner = runner(input) + runner.run() + val field = GearslipRunner::class.java.getDeclaredField("writer").apply { isAccessible = true } + assertTrue((field.get(runner) as ExecutorService).isShutdown) + } + } +} From e843f2adfe1cb7eb957ea361abf393d8e07295e5 Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Sat, 10 Oct 2026 17:55:36 -0700 Subject: [PATCH 24/32] car knob: move focus with the rotary knob, show where it is The knob's turns reached the car screen but never moved focus: keys handed straight to the view left it in touch mode, where buttons refuse focus. Buttons now show an accent ring while the knob is on them, map buttons wake on a knob turn instead of only on touch, and web pages get the same ring. The car's Home button opens the dashboard, Map opens the map, and Music reopens the last media app. Refs #9 --- .../seb3thehacker/gearslip/ScreenProjector.kt | 6 ++ .../app/seb3thehacker/gearslip/car/CarKeys.kt | 7 ++ .../seb3thehacker/gearslip/car/CarServices.kt | 13 +++ .../app/seb3thehacker/gearslip/car/CarUi.kt | 13 ++- .../seb3thehacker/gearslip/car/FocusRing.kt | 84 +++++++++++++++++++ .../gearslip/car/TemplateParts.kt | 6 +- .../app/seb3thehacker/gearslip/car/WebApp.kt | 22 ++++- 7 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/car/FocusRing.kt diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ScreenProjector.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ScreenProjector.kt index 7a29fc6..c9ffee5 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ScreenProjector.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ScreenProjector.kt @@ -198,6 +198,12 @@ class ScreenProjector(private val context: Context) { */ fun dispatchKey(keyCode: Int, down: Boolean) { val target = root ?: return + // Keys handed straight to the view skip the step where Android leaves touch mode, and in + // touch mode Compose's buttons refuse focus, so the knob had nothing to move between. + if (target.isInTouchMode) { + target.requestFocusFromTouch() + GearslipLog.i("keys: left touch mode for the knob/D-pad (now in touch mode: ${target.isInTouchMode})") + } // Nothing on the display takes keys until something in it holds focus. if (target.findFocus() == null) target.requestFocus() val shift = keyCode == app.seb3thehacker.gearslip.car.CarKeys.KEYCODE_SHIFT_TAB diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeys.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeys.kt index 3eeae41..ed4b4f0 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeys.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeys.kt @@ -27,6 +27,11 @@ object CarKeys { private val _screenKeys = MutableSharedFlow(extraBufferCapacity = 8) val screenKeys: SharedFlow = _screenKeys + private val _focusMoves = MutableSharedFlow(extraBufferCapacity = 1) + + /** A knob turn or D-pad press, so controls that hide when left alone can come back. */ + val focusMoves: SharedFlow = _focusMoves + /** Delivers an Android key event to the car display; set while the car UI is showing. */ @Volatile var focusSink: ((keyCode: Int, down: Boolean) -> Unit)? = null @@ -58,6 +63,7 @@ object CarKeys { */ fun onKey(keyCode: Int, down: Boolean, longPress: Boolean): Boolean { if (keyCode in FOCUS_KEYS) { + _focusMoves.tryEmit(Unit) focusSink?.invoke(keyCode, down) return true } @@ -86,6 +92,7 @@ object CarKeys { /** A turn of the rotary knob: each click moves focus one step, like Tab or Shift+Tab. */ fun onRotate(delta: Int) { val sink = focusSink ?: return + _focusMoves.tryEmit(Unit) val code = if (delta > 0) KeyEvent.KEYCODE_TAB else KEYCODE_SHIFT_TAB repeat(kotlin.math.abs(delta).coerceAtMost(MAX_ROTARY_STEPS)) { sink(code, true) 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 5f02ea4..6c6d920 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt @@ -127,6 +127,19 @@ object CarServices { if (CarSettings.pipeAudio.value) CarAudio.request() } + /** + * Reopens the media app used last, for the car's media button when nothing is open. False + * when there's none on record or it's been uninstalled since. + */ + suspend fun reopenLastMedia(): Boolean { + val ctx = context ?: return false + val last = CarSettings.lastMedia.value?.let(ComponentName::unflattenFromString) ?: return false + val app = withContext(Dispatchers.IO) { MediaCatalog.installed(ctx) } + .firstOrNull { it.component == last } ?: return false + openMedia(app) + return true + } + /** Closes the media app from the car: stops what it's playing and drops the connection. */ fun closeMedia() { if (media.now.value.playing) media.togglePlay() diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt index c21f6e2..d776d49 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt @@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip.car import androidx.compose.foundation.Image import androidx.compose.foundation.clickable +import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -120,6 +121,8 @@ fun CarUi() { controlsWake.tick() } } + // The knob counts as a touch: the map's buttons can't be found while they're faded out. + LaunchedEffect(controlsWake) { CarKeys.focusMoves.collect { controlsWake.touch() } } remember(appContext) { Prefetch.warm(appContext, force = true); CarServices.init(appContext) } DisposableEffect(Unit) { onDispose { CarServices.shutdown() } } // Ask for the phone's sound as soon as the car connects, not only when a media app opens: @@ -152,8 +155,12 @@ fun CarUi() { VoiceReply.state.value.phase != VoiceReply.Phase.IDLE -> VoiceReply.cancel() else -> navigator.back() } - CarKeys.ScreenKey.HOME, CarKeys.ScreenKey.MAP -> navigator.home() - CarKeys.ScreenKey.MEDIA -> if (CarServices.mediaApp.value != null) navigator.media() else navigator.apps() + // Home is the dashboard, over the map, so Back from it lands on the map. + CarKeys.ScreenKey.HOME -> { navigator.home(); navigator.dashboard() } + CarKeys.ScreenKey.MAP -> navigator.home() + CarKeys.ScreenKey.MEDIA -> + if (CarServices.mediaApp.value != null || CarServices.reopenLastMedia()) navigator.media() + else navigator.apps() CarKeys.ScreenKey.PHONE -> BuiltInApps.Phone.open(navigator) } } @@ -167,6 +174,7 @@ fun CarUi() { LocalGsTheme provides GsThemes.byId(uiThemeId), ) { MaterialTheme(colorScheme = colors) { + CompositionLocalProvider(LocalIndication provides FocusRingIndication(MaterialTheme.colorScheme.primary)) { Surface( Modifier .fillMaxSize() @@ -256,6 +264,7 @@ fun CarUi() { if (safetyWarningSeen) CarWhatsNew() } } + } } } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/FocusRing.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/FocusRing.kt new file mode 100644 index 0000000..bc063c5 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/FocusRing.kt @@ -0,0 +1,84 @@ +package app.seb3thehacker.gearslip.car + +import androidx.compose.foundation.IndicationNodeFactory +import androidx.compose.foundation.interaction.FocusInteraction +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.material3.ripple +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.node.DelegatableNode +import androidx.compose.ui.node.DelegatingNode +import androidx.compose.ui.node.DrawModifierNode +import androidx.compose.ui.node.invalidateDraw +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch + +/** + * What every car button shows: the usual ripple when pressed, and a ring in the accent color + * while the knob or D-pad is on it. Cars without a knob never see the ring, since nothing takes + * focus while the screen is in touch mode. + * + * It replaces the theme's ripple (LocalIndication) for the whole car UI, so any `clickable` that + * doesn't pass its own indication gets the ring for free. + */ +internal class FocusRingIndication(private val color: Color) : IndicationNodeFactory { + private val ripple = ripple() + + override fun create(interactionSource: InteractionSource): DelegatableNode = + FocusRingNode(ripple.create(interactionSource), interactionSource, color) + + override fun equals(other: Any?) = other is FocusRingIndication && other.color == color + override fun hashCode() = color.hashCode() +} + +private class FocusRingNode( + rippleNode: DelegatableNode, + private val source: InteractionSource, + private val color: Color, +) : DelegatingNode(), DrawModifierNode { + private var focused = false + + init { delegate(rippleNode) } + + override fun onAttach() { + coroutineScope.launch { + var holding = 0 + source.interactions.collect { + when (it) { + is FocusInteraction.Focus -> holding++ + is FocusInteraction.Unfocus -> holding-- + } + val now = holding > 0 + if (now != focused) { + focused = now + invalidateDraw() + } + } + } + } + + override fun ContentDrawScope.draw() { + drawContent() + if (!focused) return + // Inside the button's bounds, since most buttons clip to their shape: drawn outside, a + // rounded tile would cut its own ring off. + val width = RING_WIDTH.toPx() + val radius = minOf(CORNER.toPx(), size.minDimension / 2) + drawRoundRect( + color, + topLeft = Offset(width / 2, width / 2), + size = Size(size.width - width, size.height - width), + cornerRadius = CornerRadius(radius), + style = Stroke(width), + ) + } + + private companion object { + val RING_WIDTH = 3.dp + val CORNER = 16.dp + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt index cee1973..1111404 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt @@ -3,6 +3,7 @@ package app.seb3thehacker.gearslip.car import app.seb3thehacker.gearslip.car.theme.* import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.material3.LocalContentColor import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.luminance @@ -233,7 +234,9 @@ internal fun RightEdgeControls( // the first touch on one wakes them rather than pressing it. val awake = LocalMapControlsWake.current.awake val pan by CarServices.nav.panMode.collectAsState() - val visible = awake || pan + // Kept up while the knob is on one of them, or the ring would fade out along with the button. + var holdsFocus by remember { mutableStateOf(false) } + val visible = awake || pan || holdsFocus val alpha by androidx.compose.animation.core.animateFloatAsState( targetValue = if (visible) 1f else 0f, animationSpec = androidx.compose.animation.core.tween(if (visible) 150 else 500), @@ -242,6 +245,7 @@ internal fun RightEdgeControls( Column( modifier .alpha(alpha) + .onFocusChanged { holdsFocus = it.hasFocus } .let { base -> if (visible) base else base.pointerInput(Unit) { awaitPointerEventScope { diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WebApp.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WebApp.kt index 868fbb7..7883fc1 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WebApp.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WebApp.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.unit.dp import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue @@ -19,6 +20,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.viewinterop.AndroidView import app.seb3thehacker.gearslip.GearslipLog import org.json.JSONObject @@ -38,6 +40,7 @@ import org.json.JSONObject fun WebApp(content: String) { val navigator = LocalCarNavigator.current val darkOutside = LocalDarkOutside.current + val ring = String.format("#%06X", MaterialTheme.colorScheme.primary.toArgb() and 0xFFFFFF) var web by remember { mutableStateOf(null) } var fieldText by remember { mutableStateOf(null) } @@ -65,7 +68,7 @@ fun WebApp(content: String) { settings.domStorageEnabled = true settings.loadWithOverviewMode = true settings.useWideViewPort = true - webViewClient = LoggingClient() + webViewClient = LoggingClient(ring) addJavascriptInterface( FieldFocusBridge(onFocus = { fieldText = it }, onBlur = { fieldText = null }), "GearslipField", @@ -99,10 +102,11 @@ fun WebApp(content: String) { } } -private class LoggingClient : WebViewClient() { +private class LoggingClient(private val ring: String) : WebViewClient() { override fun onPageFinished(view: WebView?, url: String?) { GearslipLog.i("car web page loaded: $url") view?.evaluateJavascript(FOCUS_TRACKING_JS, null) + view?.evaluateJavascript(focusRingJs(ring), null) } // onPageFinished fires for error pages too, so without this a blocked load (missing @@ -170,6 +174,20 @@ private const val SUBMIT_FIELD_JS = """ })(); """ +/** + * The same ring the rest of the car UI shows, on whatever link or field the knob is on. Many + * sites hide the browser's own focus outline, which leaves a knob driver with no idea where they are. + */ +private fun focusRingJs(color: String) = """ + (function() { + if (document.getElementById('gearslip-focus-ring')) return; + var style = document.createElement('style'); + style.id = 'gearslip-focus-ring'; + style.textContent = ':focus-visible { outline: 3px solid $color !important; outline-offset: 2px !important; }'; + (document.head || document.documentElement).appendChild(style); + })(); +""" + /** Reports focus/blur on text inputs, textareas and contenteditable elements to [FieldFocusBridge]. */ private const val FOCUS_TRACKING_JS = """ (function() { From f9e38cbc45d303b1573ea6db7de5e1241e30223e Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Sun, 11 Oct 2026 00:59:36 -0700 Subject: [PATCH 25/32] icon: drop the black square behind the README icon --- docs/icon/gearslip-icon.png | Bin 11474 -> 10632 bytes docs/icon/gearslip-icon.svg | 1 - 2 files changed, 1 deletion(-) diff --git a/docs/icon/gearslip-icon.png b/docs/icon/gearslip-icon.png index 3a632e62ba237cd9e1df4c6b50652e6289798f08..69c182d9e9ceb88527d26367420b17566dce13f5 100644 GIT binary patch literal 10632 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 Z`Nm<`F|1bV8Qxk6P*>Jgs!>3O{tx>a^F{yw literal 11474 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 PRmM - From 261e6a719a095bcc65cab00fec04ea112ad9a05e Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Sun, 11 Oct 2026 11:34:06 -0700 Subject: [PATCH 26/32] car audio: choose Bluetooth or USB, offer Bluetooth after a refused screen share USB audio needs Android's screen-sharing consent every drive. A new Car audio setting on the phone and car screens picks Bluetooth instead, and the first refusal opens a dialog offering Bluetooth, asking again, or the one-time adb command. --- .../gearslip/GearslipActivity.kt | 20 ++++++++ .../seb3thehacker/gearslip/car/CarSettings.kt | 20 +++++++- .../gearslip/car/CarSettingsScreen.kt | 10 ++-- .../gearslip/ui/AudioChoiceDialog.kt | 49 +++++++++++++++++++ .../gearslip/ui/SettingsScreen.kt | 29 +++++++++++ 5 files changed, 122 insertions(+), 6 deletions(-) create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/ui/AudioChoiceDialog.kt diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt index 41010b5..b74175a 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt @@ -15,6 +15,8 @@ import android.view.Surface import android.view.WindowManager import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import android.content.res.Configuration @@ -85,9 +87,13 @@ class GearslipActivity : ComponentActivity(), Projection { } else { GearslipLog.w("audio: capture consent declined") CarAudio.denied() + if (!CarSettings.audioChoiceAsked) offerBluetooth.value = true } } + /** Raised once, after the first "no" to the capture dialog; see [AudioChoiceDialog]. */ + private val offerBluetooth = kotlinx.coroutines.flow.MutableStateFlow(false) + private val detachReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (intent?.action == UsbManager.ACTION_USB_ACCESSORY_DETACHED) { @@ -128,6 +134,20 @@ class GearslipActivity : ComponentActivity(), Projection { setContent { GearslipTheme { GearslipApp(onDisconnect = ::closeAccessory, onRequestCallScreening = ::requestCallScreeningRole) + val offer by offerBluetooth.collectAsState() + if (offer) { + fun close() { + CarSettings.markAudioChoiceAsked() + offerBluetooth.value = false + } + app.seb3thehacker.gearslip.ui.AudioChoiceDialog( + onUseBluetooth = { + CarSettings.setPipeAudio(false) + close() + }, + onKeepAsking = ::close, + ) + } } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt index 99616be..f59ddbd 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt @@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip.car import android.content.Context import app.seb3thehacker.gearslip.AppSettings +import app.seb3thehacker.gearslip.audio.CarAudio import app.seb3thehacker.gearslip.car.theme.GsTheme import app.seb3thehacker.gearslip.car.theme.GsThemes import kotlinx.coroutines.flow.MutableStateFlow @@ -27,6 +28,7 @@ object CarSettings { private const val KEY_OPEN = "car_open_on_connect" private const val KEY_NIGHT = "car_night_mode" private const val KEY_PIPE_AUDIO = "car_pipe_audio" + private const val KEY_AUDIO_CHOICE_ASKED = "car_audio_choice_asked" private const val KEY_LAST_NAV = "car_last_nav" private const val KEY_LAST_MEDIA = "car_last_media" private const val KEY_AUTOPLAY = "car_autoplay" @@ -148,10 +150,20 @@ object CarSettings { * Whether a media app's sound is captured and sent over the car link. On by default, since * sound in the car is the point of the exercise; the platform allows the capture only behind a * consent dialog, which [app.seb3thehacker.gearslip.audio.CarAudio] raises once the car has - * somewhere to play it. Turned off, the app plays through the phone's normal output instead. + * somewhere to play it. Turned off ("Bluetooth" in the settings), the app plays through the + * phone's normal output instead, and Android never asks. */ val pipeAudio: StateFlow = pipeAudioFlow + /** + * Whether the driver has already been offered Bluetooth after turning down the capture + * dialog. Offered once only: someone who chose to keep the USB route shouldn't be nagged. + */ + val audioChoiceAsked: Boolean + get() = AppSettings.getString(app, KEY_AUDIO_CHOICE_ASKED, "false") == "true" + + fun markAudioChoiceAsked() = AppSettings.putString(app, KEY_AUDIO_CHOICE_ASKED, "true") + /** * Off by default: a driver has to opt into experimental features as a whole before any of * them - the voice assistant included - show up to turn on individually. @@ -265,9 +277,15 @@ object CarSettings { AppSettings.putString(app, KEY_AUTOPLAY, on.toString()) } + /** + * Takes effect at once: switching to Bluetooth mid-drive stops the capture, and switching + * back asks for it again (or waits for the car, if none is connected). + */ fun setPipeAudio(on: Boolean) { + if (pipeAudioFlow.value == on) return pipeAudioFlow.value = on AppSettings.putString(app, KEY_PIPE_AUDIO, on.toString()) + if (on) CarAudio.request() else CarAudio.release() } fun setExperimentalFeaturesEnabled(on: Boolean) { diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt index fe1a617..6f0f53f 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt @@ -126,14 +126,14 @@ fun CarSettingsScreen() { ) { CarSettings.setAutoplay(it) } ChoiceRow( - "Media audio", - listOf(false to "Phone audio", true to "Car audio"), + "Car audio", + listOf(false to "Bluetooth", true to "USB"), pipeAudio, ) { CarSettings.setPipeAudio(it) } Text( - "Car audio sends the phone's sound to the car over USB. Android asks for permission " + - "when the car connects, and a few apps block it. Phone audio plays as the phone " + - "normally would, for example over Bluetooth to the car.", + "USB sends the phone's sound through Gearslip. Android asks to share your screen each " + + "drive, and a few apps block it. Bluetooth plays as the phone normally would, and " + + "Android never asks.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/AudioChoiceDialog.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/AudioChoiceDialog.kt new file mode 100644 index 0000000..f53ecde --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/AudioChoiceDialog.kt @@ -0,0 +1,49 @@ +package app.seb3thehacker.gearslip.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.unit.dp +import app.seb3thehacker.gearslip.BuildConfig + +/** + * Shown once, the first time the driver turns down Android's screen-sharing dialog: that dialog + * is the price of sending music over USB, and most people who refuse it would rather not see it + * again. Offers the two ways out: Bluetooth, or the one-time adb command. + */ +@Composable +fun AudioChoiceDialog(onUseBluetooth: () -> Unit, onKeepAsking: () -> Unit) { + var showCommand by remember { mutableStateOf(false) } + AlertDialog( + onDismissRequest = onKeepAsking, + title = { Text("Play car audio over Bluetooth?") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + "Android treats music sent over USB like sharing your screen, so it asks every " + + "drive. Over Bluetooth it never asks. You can change this later in Settings.", + ) + if (showCommand) { + Text( + "Or keep USB and stop the question for good: plug the phone into a " + + "computer with USB debugging on, and run this once.", + style = MaterialTheme.typography.bodyMedium, + ) + CopyableCommand("adb shell appops set ${BuildConfig.APPLICATION_ID} PROJECT_MEDIA allow") + } else { + TextButton(onClick = { showCommand = true }) { Text("Never ask, using a computer") } + } + } + }, + confirmButton = { TextButton(onClick = onUseBluetooth) { Text("Use Bluetooth") } }, + dismissButton = { TextButton(onClick = onKeepAsking) { Text("Keep asking") } }, + ) +} 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 2d8ef0b..cd8bb1f 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll @@ -33,12 +34,14 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -55,6 +58,7 @@ import androidx.compose.ui.unit.dp import app.seb3thehacker.gearslip.AppSettings import app.seb3thehacker.gearslip.BuildConfig import app.seb3thehacker.gearslip.CertProvider +import app.seb3thehacker.gearslip.car.CarSettings import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import androidx.compose.runtime.LaunchedEffect @@ -103,6 +107,7 @@ fun SettingsScreen( Spacer(Modifier.height(0.dp)) AboutSection(onOpenWhatsNew, onReplayTutorial) ConnectionSection() + CarAudioSection() if (SHOW_RECOMMENDED_APPS) AppsSection(onOpenRecommendedApps) CertificateSection() SettingsSection("Usage notes", header = USAGE_STATS_INTRO) { UsageStatsRows() } @@ -163,6 +168,30 @@ private fun ConnectionSection() { } } +/** How music reaches the car: through Gearslip over USB, or by Bluetooth as usual. */ +@Composable +private fun CarAudioSection() { + val usb by CarSettings.pipeAudio.collectAsState() + SettingsSection( + "Car audio", + footer = "Run the command in Help once, and USB stops asking.", + ) { + SettingsRow( + "USB", + Modifier.selectable(usb, role = Role.RadioButton) { CarSettings.setPipeAudio(true) }, + subtitle = "Sound goes through Gearslip. Android asks to share your screen each drive", + trailing = { RadioButton(selected = usb, onClick = null) }, + ) + SettingsDivider() + SettingsRow( + "Bluetooth", + Modifier.selectable(!usb, role = Role.RadioButton) { CarSettings.setPipeAudio(false) }, + subtitle = "Sound plays over the phone's Bluetooth link to the car. Android never asks", + trailing = { RadioButton(selected = !usb, onClick = null) }, + ) + } +} + @Composable private fun AppsSection(onOpenRecommendedApps: () -> Unit) { SettingsSection("Apps") { From 33ed2d0653b9c5975640b592a3ddde97516f967f Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Sun, 11 Oct 2026 11:34:06 -0700 Subject: [PATCH 27/32] setup: drop the GrapheneOS exploit protection step --- .../seb3thehacker/gearslip/ui/HelpScreen.kt | 4 ---- .../gearslip/ui/SetupGuideScreen.kt | 19 +------------------ 2 files changed, 1 insertion(+), 22 deletions(-) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HelpScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HelpScreen.kt index 53ecf42..c1b8bbd 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HelpScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HelpScreen.kt @@ -45,10 +45,6 @@ private class Step( private fun setupSteps(onRequestCallScreening: () -> Unit) = listOf( Step("Use a car from 2020 or earlier.", "Newer head units reject the certificate."), - Step( - "On GrapheneOS, exempt Gearslip from exploit protections.", - "Open Settings, then Apps, then Gearslip, and turn on Exploit protection compatibility mode.", - ), Step("Disable or uninstall the Android Auto app."), Step("Load a certificate.", "Open Settings, then download or import a certificate."), Step( diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SetupGuideScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SetupGuideScreen.kt index 08bd3ce..5de6a36 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SetupGuideScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SetupGuideScreen.kt @@ -206,22 +206,6 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List Unit): List Date: Sun, 11 Oct 2026 11:34:06 -0700 Subject: [PATCH 28/32] stats: name the phone's OS instead of only GrapheneOS or other Reports GrapheneOS, CalyxOS, /e/OS, iodeOS, DivestOS, LineageOS, or stock with the brand. --- gearslip/src/main/AndroidManifest.xml | 4 ++- .../gearslip/stats/UsageStats.kt | 33 ++++++++++++++++--- .../gearslip/ui/UsageStatsChoices.kt | 2 +- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/gearslip/src/main/AndroidManifest.xml b/gearslip/src/main/AndroidManifest.xml index 50dfbdb..44ba019 100644 --- a/gearslip/src/main/AndroidManifest.xml +++ b/gearslip/src/main/AndroidManifest.xml @@ -75,8 +75,10 @@ LAUNCHER query below to find, so the setup guide's Android Auto check needs this to see whether the package is installed at all. --> - + + 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 7dd629c..8c2c507 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/stats/UsageStats.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/stats/UsageStats.kt @@ -205,12 +205,37 @@ object UsageStats { p.edit().putString(KEY_CARS, cars.toString()).commit() } - // GrapheneOS reports the same build fields as stock Pixel firmware; its own apps give it away. - private fun isGrapheneOs(context: Context) = - runCatching { context.packageManager.getPackageInfo("app.grapheneos.setupwizard", 0) }.isSuccess + /** + * Which Android this is: a privacy OS by name, or "Stock" with the phone's brand. + * + * GrapheneOS reports the same build fields as stock Pixel firmware, so its own setup app gives + * it away; /e/OS is told apart from the LineageOS it's built on by its app store. The rest set + * build properties of their own. The iodéOS and DivestOS checks are best guesses from their + * build names, not confirmed on a real phone. + */ + fun osName(context: Context): String { + fun installed(pkg: String) = runCatching { context.packageManager.getPackageInfo(pkg, 0) }.isSuccess + val names = listOf("ro.build.flavor", "ro.build.display.id", "ro.lineage.version") + .joinToString(" ") { prop(it) } + " " + Build.DISPLAY + " " + Build.FINGERPRINT + return when { + installed("app.grapheneos.setupwizard") -> "GrapheneOS" + prop("ro.calyxos.version").isNotEmpty() -> "CalyxOS" + installed("foundation.e.apps") -> "/e/OS" + names.contains("iode", ignoreCase = true) -> "iodéOS" + names.contains("divest", ignoreCase = true) -> "DivestOS" + prop("ro.lineage.version").isNotEmpty() -> "LineageOS" + else -> "Stock (${Build.BRAND.replaceFirstChar { it.uppercase() }})" + } + } + + /** A system property, or "" when it isn't set or can't be read. */ + private fun prop(name: String): String = runCatching { + Class.forName("android.os.SystemProperties").getMethod("get", String::class.java) + .invoke(null, name) as String + }.getOrDefault("") private fun details(context: Context, c: Choices) = JSONObject().apply { - if (c.android) put("android", Build.VERSION.RELEASE).put("os", if (isGrapheneOs(context)) "GrapheneOS" else "other") + if (c.android) put("android", Build.VERSION.RELEASE).put("os", osName(context)) if (c.phone) put("phone", "${Build.MANUFACTURER} ${Build.MODEL}") } 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 49346f6..a6a5469 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UsageStatsChoices.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UsageStatsChoices.kt @@ -106,7 +106,7 @@ internal fun ColumnScope.UsageStatsRows() { color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(start = 56.dp, end = 16.dp, top = 12.dp, bottom = 4.dp), ) - Choice("Android version, and whether it's GrapheneOS", choices.android) { choices = choices.copy(android = it); save() } + Choice("Android version and system, such as GrapheneOS or stock Samsung", choices.android) { choices = choices.copy(android = it); save() } Choice("Phone model", choices.phone) { choices = choices.copy(phone = it); save() } Choice("Each car's head unit, model, year, and screen size, and whether it connected", choices.cars) { choices = choices.copy(cars = it) From 5721b9efb36d05a12328717c540ca4f08abd772b Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Sun, 11 Oct 2026 11:34:06 -0700 Subject: [PATCH 29/32] icon: remove unused archive images --- docs/icon/archive/gearslip-icon-rounded.png | Bin 17803 -> 0 bytes docs/icon/archive/gearslip-icon-rounded.svg | 14 -------------- docs/icon/on-device-verification.png | Bin 81317 -> 0 bytes 3 files changed, 14 deletions(-) delete mode 100644 docs/icon/archive/gearslip-icon-rounded.png delete mode 100644 docs/icon/archive/gearslip-icon-rounded.svg delete mode 100644 docs/icon/on-device-verification.png diff --git a/docs/icon/archive/gearslip-icon-rounded.png b/docs/icon/archive/gearslip-icon-rounded.png deleted file mode 100644 index ad868c9a1421fb8863affe1ec6c655c674b92b4d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17803 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 - - - - - - - - - - - - - \ No newline at end of file diff --git a/docs/icon/on-device-verification.png b/docs/icon/on-device-verification.png deleted file mode 100644 index fcc2b87e131483f3427d529722da9d87dd7a24d0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 81317 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

5*TKj7$JC3h87V+T($NyD-;o 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 C3VKEW From 580a8365eb8f80acd5b3ed2cc481fd135bb10787 Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Sun, 11 Oct 2026 11:39:22 -0700 Subject: [PATCH 30/32] car screen: stop vibrating the phone on long presses --- .../app/seb3thehacker/gearslip/car/CarUi.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt index d776d49..c72f681 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt @@ -44,6 +44,10 @@ import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.hapticfeedback.HapticFeedback +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalView import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -166,7 +170,16 @@ fun CarUi() { } } + // The car screen is drawn on the phone, so every long press or text-handle drag would buzz + // the phone in its mount. Nobody feels that while driving; it's only noise. + val view = LocalView.current + DisposableEffect(view) { + view.isHapticFeedbackEnabled = false + onDispose { } + } + CompositionLocalProvider( + LocalHapticFeedback provides NoHaptics, LocalDensity provides Density(base.density * scale, base.fontScale), LocalCarNavigator provides navigator, LocalDarkOutside provides darkOutside, @@ -892,3 +905,8 @@ private fun ChargingGlyph(modifier: Modifier = Modifier, tint: Color = MaterialT drawPath(bolt, tint) } } + +/** Stands in for the phone's vibration motor on the car screen: does nothing. */ +private object NoHaptics : HapticFeedback { + override fun performHapticFeedback(hapticFeedbackType: HapticFeedbackType) {} +} From fb6bc018d51bd7b902d84d6f0f5fe71bca333eb7 Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Sun, 11 Oct 2026 14:48:46 -0700 Subject: [PATCH 31/32] Add a contributor license agreement Contributors keep their copyright and license their work to Twink Software LLC, which keeps it available under the AGPL and may also offer it under the commercial license. CONTRIBUTING now points to it. --- CLA.md | 102 ++++++++++++++++++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 10 +++-- 2 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 CLA.md diff --git a/CLA.md b/CLA.md new file mode 100644 index 0000000..9c7c7c8 --- /dev/null +++ b/CLA.md @@ -0,0 +1,102 @@ +# Gearslip Contributor License Agreement + +Version 1.0, October 2026 + +Thank you for contributing to Gearslip. This agreement lets Twink Software LLC, the company +behind Gearslip, use your contribution. You keep the copyright in your work. You sign once, +and the agreement covers every contribution you make to Gearslip, past and future. + +## Why we ask + +Gearslip is free software under the GNU Affero General Public License, version 3 (the +"AGPL"). Twink Software LLC also sells commercial licenses to companies that cannot meet the +AGPL's terms, and that income helps pay for the project. Offering your code on those terms +needs your permission, and this agreement gives it. + +## 1. Definitions + +- **"We"** and **"us"** mean Twink Software LLC, a California limited liability company + (Secretary of State entity number B20260081700), under that name or any other, and its + successors and assigns. +- **"You"** means the person who signs this agreement. +- **"Contribution"** means any work of authorship, such as code, documentation, or images, + that you submit to Gearslip, whether before or after you sign. A submission includes a + pull request, patch, or issue attachment sent to a repository or channel we manage. It + excludes anything you mark in writing as "Not a Contribution". + +## 2. Copyright license + +You grant us, and anyone who receives software from us, a perpetual, worldwide, +non-exclusive, no-charge, royalty-free, irrevocable license to reproduce, prepare derivative +works of, publicly display, publicly perform, sublicense, and distribute your Contributions +and derivative works of them. + +This license lets us offer your Contributions under licenses other than the AGPL, including +commercial licenses. + +## 3. Patent license + +You grant us, and anyone who receives software from us, a perpetual, worldwide, +non-exclusive, no-charge, royalty-free, irrevocable patent license to make, have made, use, +offer to sell, sell, import, and otherwise transfer your Contributions. This license covers +only the patent claims you can license that your Contribution, alone or combined with +Gearslip, would infringe. + +If anyone sues claiming that Gearslip or a Contribution infringes a patent, the patent +licenses granted to that party for Gearslip end on the date the suit is filed. + +## 4. Our promise to you + +As long as we distribute a Contribution, we will also offer it under the AGPL or another +license approved by the Open Source Initiative. Your work will always stay available as +free software. + +## 5. Your promises + +You promise that: + +1. **The work is yours.** Each Contribution is your original creation, and you have the + right to grant these licenses. +2. **Your employer agrees.** If your employer has rights in what you create, you have its + permission to contribute on its behalf, or it has waived its rights in your + Contributions. +3. **You name other people's work.** If any part of a Contribution is not your own, you say + so when you submit it, and you name its source and its license. +4. **You'll tell us of changes.** If any of these promises stops being true, you will tell + us. + +## 6. No warranty + +You provide your Contributions "as is", without warranties or conditions of any kind. You do +not have to provide support for them. + +## 7. No obligation + +We are not required to use or include any Contribution. + +## 8. General + +- California law governs this agreement. +- This agreement does not change the rights you have under the AGPL in Gearslip as + released. +- We may publish a new version of this agreement. A new version applies only to + Contributions you make after you sign it; this version keeps covering the ones you made + under it. + +## Signing + +Most contributors sign on GitHub: the CLA Assistant bot comments on your first pull request, +and you sign there with one click. Your GitHub account then serves as your signature. + +To sign by hand instead, fill in the following and send it to us on Matrix. The address is +on the [Seb3thehacker GitHub profile](https://github.com/Seb3thehacker). + +Name: ______________________________ + +GitHub username: ______________________________ + +Email: ______________________________ + +Signature: ______________________________ + +Date: ______________________________ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dde7066..0484ad1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,6 +46,10 @@ voice, state things positively, cut needless words, and use the serial comma. ## License -Gearslip is licensed under AGPL-3.0, and your contributions are too. The author also sells a -commercial license, described in [NOTICE.md](NOTICE.md). By opening a pull request, you -agree that your contribution may be offered under that license as well. +Gearslip is licensed under AGPL-3.0, and your contributions are too. Twink Software LLC, which +owns Gearslip, also sells a commercial license, described in [NOTICE.md](NOTICE.md). + +Before we can merge your first pull request, you sign the +[Contributor License Agreement](CLA.md). You keep the copyright in your work, and it stays +available as free software; the agreement lets us offer it under the commercial license too. +A bot comments on your pull request with a link, and signing takes one click. From fff2db63b0970c681fdae4ecabf08aa772650013 Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Sun, 11 Oct 2026 14:48:46 -0700 Subject: [PATCH 32/32] updates: beta channel, and a switch to turn off automatic checks Beta updates counts GitHub pre-releases as updates. With Check automatically off, Gearslip contacts GitHub only when you tap Check for updates. --- .../gearslip/ui/SettingsScreen.kt | 4 +- .../seb3thehacker/gearslip/ui/UpdateViews.kt | 32 +++++-- .../gearslip/update/UpdateChecker.kt | 86 +++++++++++++------ 3 files changed, 90 insertions(+), 32 deletions(-) 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 cd8bb1f..90dab03 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt @@ -137,7 +137,9 @@ private fun AboutSection(onOpenWhatsNew: () -> Unit, onReplayTutorial: () -> Uni SettingsDivider() UpdateRow() SettingsDivider() - UpdateNotifyRow() + UpdateAutoRow() + SettingsDivider() + UpdateBetaRow() SettingsDivider() SettingsRow( "Replay the setup guide", 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 a5b23e5..147e134 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UpdateViews.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UpdateViews.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.selection.toggleable import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Build import androidx.compose.material3.Button import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme @@ -130,19 +131,38 @@ internal fun UpdateRow() { ) } -/** Whether a drive's end may notify about a new version. */ +/** Whether Gearslip checks for updates on its own. */ @Composable -internal fun UpdateNotifyRow() { +internal fun UpdateAutoRow() { val context = LocalContext.current - var on by remember { mutableStateOf(UpdateChecker.notifyEnabled(context)) } + var on by remember { mutableStateOf(UpdateChecker.autoCheckEnabled(context)) } SettingsRow( - "Update notifications", + "Check automatically", Modifier.toggleable(on, role = Role.Switch) { on = it - UpdateChecker.setNotifyEnabled(context, it) + UpdateChecker.setAutoCheckEnabled(context, it) }, - subtitle = "On the car screen when you plug in, and on the phone after a drive", + subtitle = if (BuildConfig.DEBUG) "Does nothing in dev builds. They check only when you tap Check for updates" + else if (on) "Once a day, with a note on the car screen and the phone when one is out" + else "Gearslip checks only when you tap Check for updates", icon = Icons.Filled.Notifications, trailing = { Switch(checked = on, onCheckedChange = null) }, ) } + +/** Whether pre-releases count as updates. */ +@Composable +internal fun UpdateBetaRow() { + val context = LocalContext.current + var on by remember { mutableStateOf(UpdateChecker.betaEnabled(context)) } + SettingsRow( + "Beta updates", + Modifier.toggleable(on, role = Role.Switch) { + on = it + UpdateChecker.setBetaEnabled(context, it) + }, + subtitle = "Get test versions before they're released. They may have more bugs.", + icon = Icons.Filled.Build, + trailing = { Switch(checked = on, onCheckedChange = null) }, + ) +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/update/UpdateChecker.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/update/UpdateChecker.kt index eeb304d..86a7990 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/update/UpdateChecker.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/update/UpdateChecker.kt @@ -32,8 +32,10 @@ import java.security.MessageDigest /** * Looks for a newer Gearslip on GitHub and installs it through Android's own installer. * - * Only GitHub's "latest" release counts, which leaves out pre-releases and drafts. The app checks - * at most once a day: when it opens, and when a drive ends. After a drive a new version also gets + * Only GitHub's "latest" release counts, which leaves out pre-releases and drafts, unless the + * driver opted into betas: then pre-releases count too. The app checks at most once a day: when + * it opens, and when a drive ends. With automatic checks off it never asks GitHub on its own, + * only when the driver taps Check for updates. After a drive a new version also gets * a notification, once per version, since the driver is parked and the app is likely closed. The * car screen shows the same news once per version, at plug-in and only while parked. * Nothing runs in the background otherwise, and nothing else is sent: the request is the same one @@ -62,7 +64,9 @@ object UpdateChecker { private const val PREFS = "updates" private const val KEY_CHECKED_AT = "checked_at" private const val KEY_FOUND = "found" - private const val KEY_NOTIFY = "notify" + /** Named for the notifications toggle it grew out of; it now covers every automatic check. */ + private const val KEY_AUTO = "notify" + private const val KEY_BETA = "beta" private const val KEY_NOTIFIED = "notified_version" private const val KEY_CAR_NOTIFIED = "car_notified_version" /** Lets the car screen settle after plug-in, so the card isn't lost behind the first frames. */ @@ -71,22 +75,43 @@ object UpdateChecker { private const val NOTIFICATION_ID = 4100 private const val DAY_MS = 24 * 60 * 60 * 1000L private const val LATEST = "https://api.github.com/repos/Seb3thehacker/gearslip/releases/latest" + /** Newest first, pre-releases included. Ten covers any run of betas between two releases. */ + private const val ALL = "https://api.github.com/repos/Seb3thehacker/gearslip/releases?per_page=10" /** Set when the driver tapped Install but first had to allow installs from Gearslip. */ @Volatile private var installWhenAllowed = false private fun prefs(context: Context) = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - /** Whether a drive's end may post a notification about a new version. On by default: it sends nothing. */ - fun notifyEnabled(context: Context): Boolean = prefs(context).getBoolean(KEY_NOTIFY, true) + /** + * Whether Gearslip checks on its own, and tells the driver what it finds on the phone and the + * car screen. On by default: the check sends nothing about the phone. + */ + fun autoCheckEnabled(context: Context): Boolean = prefs(context).getBoolean(KEY_AUTO, true) - fun setNotifyEnabled(context: Context, on: Boolean) { - prefs(context).edit().putBoolean(KEY_NOTIFY, on).apply() + fun setAutoCheckEnabled(context: Context, on: Boolean) { + prefs(context).edit().putBoolean(KEY_AUTO, on).apply() if (!on) NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID) } + /** Whether GitHub pre-releases count as updates. */ + fun betaEnabled(context: Context): Boolean = prefs(context).getBoolean(KEY_BETA, false) + + /** + * Switching channels forgets what the old one found: a beta found earlier shouldn't still be + * offered once betas are off. Checks again right away if automatic checks are on. + */ + fun setBetaEnabled(context: Context, on: Boolean) { + prefs(context).edit().putBoolean(KEY_BETA, on).remove(KEY_FOUND).remove(KEY_CHECKED_AT).apply() + val current = flow.value + if (current is State.Checking || current is State.Downloading) return + updatesDir(context).deleteRecursively() + flow.value = State.Idle + if (autoCheckEnabled(context) && !BuildConfig.DEBUG) check(context) + } + private fun dueForCheck(context: Context) = - !BuildConfig.DEBUG && System.currentTimeMillis() - prefs(context).getLong(KEY_CHECKED_AT, 0) >= DAY_MS + !BuildConfig.DEBUG && autoCheckEnabled(context) && System.currentTimeMillis() - prefs(context).getLong(KEY_CHECKED_AT, 0) >= DAY_MS /** Gearslip came to the front: check if a day has passed, and finish an install that waited on permission. */ fun onAppOpened(context: Context) { @@ -107,7 +132,7 @@ object UpdateChecker { * notification for a new version they haven't been told about yet. */ fun onDriveEnded(context: Context) { - if (!notifyEnabled(context)) return + if (!autoCheckEnabled(context)) return val app = context.applicationContext Thread({ val current = flow.value @@ -126,7 +151,7 @@ object UpdateChecker { * once per version. Skipped while moving; the next plug-in tries again. */ fun onCarConnected(context: Context) { - if (!notifyEnabled(context)) return + if (!autoCheckEnabled(context)) return val app = context.applicationContext Thread({ val current = flow.value @@ -197,7 +222,7 @@ object UpdateChecker { Thread({ flow.value = checkNow(app) }, "gearslip-update").start() } - private fun checkNow(app: Context): State = runCatching { fetchLatest() }.fold( + private fun checkNow(app: Context): State = runCatching { fetchNewest(betaEnabled(app)) }.fold( onSuccess = { release -> prefs(app).edit().putLong(KEY_CHECKED_AT, System.currentTimeMillis()).apply() if (release != null && isNewer(release.version, BuildConfig.VERSION_NAME)) { @@ -263,8 +288,9 @@ object UpdateChecker { ) } - private fun fetchLatest(): Release? { - val conn = (URL(LATEST).openConnection() as HttpURLConnection).apply { + /** The newest release with an APK: the "latest" one, or with [beta] the newest of any kind but drafts. */ + private fun fetchNewest(beta: Boolean): Release? { + val conn = (URL(if (beta) ALL else LATEST).openConnection() as HttpURLConnection).apply { connectTimeout = 15_000 readTimeout = 15_000 setRequestProperty("Accept", "application/vnd.github+json") @@ -273,23 +299,33 @@ object UpdateChecker { try { if (conn.responseCode == 404) return null // no stable release yet if (conn.responseCode !in 200..299) error("GitHub answered ${conn.responseCode}") - val json = JSONObject(conn.inputStream.bufferedReader().use { it.readText() }) - val assets = json.optJSONArray("assets") ?: return null - val apk = (0 until assets.length()).map { assets.getJSONObject(it) } - .firstOrNull { it.optString("name").endsWith(".apk") && !it.optString("name").contains("debug") } - ?: return null - return Release( - version = json.getString("tag_name").removePrefix("v"), - apkUrl = apk.getString("browser_download_url"), - size = apk.optLong("size", -1), - sha256 = apk.optString("digest").takeIf { it.startsWith("sha256:") }?.removePrefix("sha256:"), - pageUrl = json.optString("html_url"), - ) + val body = conn.inputStream.bufferedReader().use { it.readText() } + if (!beta) return release(JSONObject(body)) + val list = org.json.JSONArray(body) + return (0 until list.length()).map { list.getJSONObject(it) } + .filterNot { it.optBoolean("draft") } + .mapNotNull(::release) + .reduceOrNull { best, next -> if (isNewer(next.version, best.version)) next else best } } finally { conn.disconnect() } } + /** A release as GitHub lists it, or null when it has no APK attached. */ + private fun release(json: JSONObject): Release? { + val assets = json.optJSONArray("assets") ?: return null + val apk = (0 until assets.length()).map { assets.getJSONObject(it) } + .firstOrNull { it.optString("name").endsWith(".apk") && !it.optString("name").contains("debug") } + ?: return null + return Release( + version = json.getString("tag_name").removePrefix("v"), + apkUrl = apk.getString("browser_download_url"), + size = apk.optLong("size", -1), + sha256 = apk.optString("digest").takeIf { it.startsWith("sha256:") }?.removePrefix("sha256:"), + pageUrl = json.optString("html_url"), + ) + } + private fun fetchApk(release: Release, into: File) { val conn = (URL(release.apkUrl).openConnection() as HttpURLConnection).apply { connectTimeout = 15_000