From d1def0f266d653711de7e50452eb480e2792509c Mon Sep 17 00:00:00 2001 From: dirwin31 <83434411+dirwin31@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:11:57 -0700 Subject: [PATCH] Smooth Install Flow V1 --- openpilot/starpilot/galaxy/server.py | 4 +- .../galaxy/tests/test_android_auto.mjs | 91 ++-- openpilot/starpilot/galaxy/web/css/galaxy.css | 108 +++++ .../starpilot/galaxy/web/js/android-auto.js | 428 ++++++++++++++---- .../system/android_auto/apk_identity.py | 53 ++- .../android_auto/tests/test_apk_identity.py | 32 ++ 6 files changed, 568 insertions(+), 148 deletions(-) diff --git a/openpilot/starpilot/galaxy/server.py b/openpilot/starpilot/galaxy/server.py index 5fa277fea3..293a367fad 100644 --- a/openpilot/starpilot/galaxy/server.py +++ b/openpilot/starpilot/galaxy/server.py @@ -2134,9 +2134,7 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe if not setup_state['bluetoothEnabled']: self.json(409, {'error': 'Turn on Bluetooth before pairing the car'}) return - if setup_state['identity'].get('installed') is not True: - self.json(409, {'error': 'Verify your Android Auto package before pairing'}) - return + # Bluetooth pairing and the Wi-Fi bootstrap need no identity; only projection does. source = None try: source = aa_source_registry().mint(identity) diff --git a/openpilot/starpilot/galaxy/tests/test_android_auto.mjs b/openpilot/starpilot/galaxy/tests/test_android_auto.mjs index fd9bf2f6a5..3f924461c2 100644 --- a/openpilot/starpilot/galaxy/tests/test_android_auto.mjs +++ b/openpilot/starpilot/galaxy/tests/test_android_auto.mjs @@ -1,6 +1,7 @@ import assert from "node:assert/strict" import { readFileSync } from "node:fs" -import { AndroidAutoFeed, AndroidAutoPage, validPairingStatus, validPromptInput, uploadPackage } from "../web/js/android-auto.js" +import { AndroidAutoFeed, AndroidAutoPage, validPairingStatus, validPromptInput, uploadPackage, + expiryInfo, packagePreflight, importProblem, installChecks } from "../web/js/android-auto.js" const setup = { enabled: true, bluetoothEnabled: true, parked: true, installReady: true, serviceReady: true, identity: { installed: true, message: "Package ready" }, @@ -66,7 +67,8 @@ assert.deepEqual(feed.receivers, [{ address: "AA:BB:CC:DD:EE:FF", name: "Saved c assert.equal(await feed.control("select_receiver", { address: "AA:BB:CC:DD:EE:FF" }), true) assert.deepEqual(JSON.parse(calls.find(([path]) => path.endsWith("/control"))[1].body), { action: "select_receiver", address: "AA:BB:CC:DD:EE:FF" }) -assert.match(AndroidAutoPage.template, /Start Projection/) +assert.match(AndroidAutoPage.template, /Connect to Your Car/) +assert.match(AndroidAutoPage.template, />Disconnect path.endsWith("/enable"))[1].body), { enabled: false }) @@ -201,6 +203,7 @@ const { compile } = await import('../web/vendor/vue/vue.esm-browser.js') const render = compile(AndroidAutoPage.template, { decodeEntities: value => value }) const vm = page(setup) vm.mode = 'local' +vm.installOpen = true // the package picker lives in the install sheet const tree = render(vm, []) const tags = [] function visit(node) { @@ -211,34 +214,68 @@ function visit(node) { visit(tree) assert(!tags.includes('template'), 'setup cannot live inside an inert template element') assert(tags.includes('input') && tags.includes('button'), 'setup renders package selection and actions') -for (const [field, reason] of [['installReady', /display|encoder/], ['enabled', /Enable/], +for (const [field, reason] of [['installReady', /display|encoder/], ['serviceReady', /service/], ['bluetoothEnabled', /Bluetooth/], ['parked', /Park/]]) { const blocked = page({ ...setup, [field]: false }) assert.match(blocked.pairingReason, reason) } assert.equal(page(setup).pairingReason, '') +// Pairing needs no package; only projection does, and Connect opens the installer instead. +const noPackage = page({ ...setup, identity: { installed: false } }) +assert.equal(noPackage.pairingReason, '') +noPackage.selected = { address: "AA:BB:CC:DD:EE:FF", name: "Car" } +noPackage.control = () => assert.fail("projection must not start without a package") +assert.equal(noPackage.connect(), false) +assert.equal(noPackage.installOpen, true) +// Turned off is no blocker either: Find My Car enables Android Auto, then pairs once the service is up. +const off = page({ ...setup, enabled: false, serviceReady: false, identity: { installed: false } }) +assert.equal(off.pairingReason, '') +off.feed = { async setEnabled(value) { assert.equal(value, true); return true }, action: async () => assert.fail("paired before the service was up") } +assert.equal(await off.startPairing(), false) +assert.equal(off.pairWhenReady, true) -// The legacy parked field carries the server's connectivity admission. Effective -// offroad permits setup while the car is powered; no browser ignition veto applies. -const poweredCalls = [] -let admitted = false -const powered = new AndroidAutoFeed({ publish: () => {}, later: () => 1, cancelTimer: () => {}, - fetcher: async (path, options) => { - poweredCalls.push([path, options]) - return response(path.endsWith("/setup") ? { ...setup, parked: admitted } : - path.endsWith("/status") ? { pairing: { active: false, receiver: null, prompt: null, approved: false }, selectedReceiver: null } : {}) - }, - uploader: async (path) => { poweredCalls.push([path]); return response({ ok: true }) }, -}) -await powered.start() -assert.equal(await powered.setEnabled(true), false) -assert.equal(await powered.action("./api/android-auto/pairing"), false) -admitted = true -await powered.refresh() -assert.equal(await powered.setEnabled(true), true) -assert.equal(await powered.upload({ size: 32 }), true) -assert.equal(await powered.action("./api/android-auto/pairing"), true) -assert(poweredCalls.some(([path]) => path.endsWith("/upload"))) -assert.match(AndroidAutoPage.template, /offroad mode or Park/) -assert.match(page({ ...setup, parked: false }).pairingReason, /offroad mode or Park/) -powered.stop() +// Step 1 status: expiry wording by remaining days, and the expired date from the certificate. +assert.deepEqual(expiryInfo({ installed: true, expires: "2027-10-12T00:00:00+00:00", days_left: 372 }, "en-US"), + { level: "ready", label: "Ready · Valid until Oct 2027" }) +assert.deepEqual(expiryInfo({ installed: true, expires: "2027-04-12T00:00:00+00:00", days_left: 9 }, "en-US"), + { level: "soon", label: "Expires in 9 days (Apr 12)" }) +assert.deepEqual(expiryInfo({ installed: false, expired: true, expires: "2026-04-12T00:00:00+00:00" }, "en-US"), + { level: "expired", label: "Expired on Apr 12, 2026" }) +assert.equal(expiryInfo({ installed: false, error: "unusable" }).level, "broken") +assert.equal(expiryInfo({ installed: false }).level, "none") + +// Wrong downloads are caught by name before the upload; the real APKMirror file name passes. +assert.equal(packagePreflight({ name: "com.google.android.projection.gearhead_17.6.663454-release-2025-apkmirror.com.apk" }), "") +assert.match(packagePreflight({ name: "APKMirror Installer (Official)_1.6.apk" }), /Installer/) +assert.match(packagePreflight({ name: "com.google.android.gms_24.apk" }), /Play Services/) +assert.match(packagePreflight({ name: "android-auto.zip" }), /not an Android package/) +const wrongPage = page(setup) +wrongPage.choosePackage({ target: { files: [{ name: "APKMirror Installer.apk", size: 10 }] } }) +assert.match(wrongPage.uploadReason, /Installer/) + +// Server rejection codes become friendly messages and a failed checklist row. +assert.match(importProblem({ state: "failed", code: "WRONG_APP" }, "Maps.apk"), /Wrong app selected \(Maps\.apk\)/) +assert.match(importProblem({ state: "failed", code: "WRONG_APP" }, "APKMirror Installer.apk"), /Installer/) +assert.match(importProblem({ state: "failed", code: "EXPIRED", expires: "2026-04-12T00:00:00+00:00" }, "", "en-US"), /expired on Apr 12, 2026/) +assert.match(importProblem({ state: "failed", code: "CORRUPT" }), /incomplete or damaged/) +assert.match(importProblem({ state: "failed", code: "UNSUPPORTED_VERSION" }), /17\.6\.663454-release/) +assert.equal(importProblem({ state: "running" }), "") +const statuses = (job) => installChecks(job, "en-US").map((check) => check.status) +assert.deepEqual(statuses({ state: "running", stage: "searching" }), ["done", "active", "pending", "pending"]) +assert.deepEqual(statuses({ state: "running", stage: "installing" }), ["done", "done", "done", "active"]) +assert.deepEqual(statuses({ state: "failed", stage: "searching", code: "WRONG_APP" }), ["done", "failed", "pending", "pending"]) +assert.deepEqual(statuses({ state: "failed", stage: "verifying", code: "EXPIRED" }), ["done", "done", "done", "failed"]) +const done = installChecks({ state: "done", stage: "done", expires: "2027-10-12T00:00:00+00:00" }, "en-US") +assert.deepEqual(done.map((check) => check.status), ["done", "done", "done", "done"]) +assert.equal(done[3].label, "Certificate valid until October 2027") + +// The install sheet follows only the import this page started, not an older result. +const sheet = page({ ...setup, import: { state: "failed", code: "WRONG_APP", started: 1 } }) +assert.equal(sheet.installState, "choose") +sheet.installAttempted = true; sheet.installBaseline = 1 +assert.equal(sheet.installState, "choose") +sheet.setup.import = { state: "running", stage: "reading", started: 2 } +assert.equal(sheet.installState, "checking") +sheet.setup.import = { state: "done", stage: "done", started: 2 } +assert.equal(sheet.installState, "done") +console.log("Android Auto: expiry badges, package preflight, friendly rejections and install checklist passed") diff --git a/openpilot/starpilot/galaxy/web/css/galaxy.css b/openpilot/starpilot/galaxy/web/css/galaxy.css index a951f00f04..ad661f468f 100644 --- a/openpilot/starpilot/galaxy/web/css/galaxy.css +++ b/openpilot/starpilot/galaxy/web/css/galaxy.css @@ -1288,3 +1288,111 @@ input[type="checkbox"]:disabled { opacity: .4; cursor: not-allowed; } .gx-summary dt { color: var(--text-muted); font-size: var(--fs-xs); line-height: 1.35; overflow-wrap: anywhere; } .gx-summary dd { order: -1; margin: 0 0 var(--sp-1); font-size: var(--fs-lg); font-weight: var(--fw-bold); color: var(--primary); overflow-wrap: anywhere; } .gx-model-selection { margin: 0; padding: 0 var(--sp-4) var(--sp-3); font-size: var(--fs-sm); overflow-wrap: anywhere; } + +/* Android Auto: three-step setup */ +.gx-aa { max-width: 760px; } +.gx-aa .gx-muted { color: var(--text-muted); } +.gx-aa__hero p { font-size: var(--fs-base); } +.gx-aa__steps { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--sp-4); } +.gx-aa__steps > .gx-card { margin-bottom: 0; } +.gx-aa-step { padding: var(--sp-5); display: grid; gap: var(--sp-4); min-width: 0; } +.gx-aa-step__head { display: flex; align-items: flex-start; gap: var(--sp-4); min-width: 0; } +.gx-aa-step__head h3 { margin: 2px 0 var(--sp-1); font-size: var(--fs-lg); } +.gx-aa-step__head p { margin: 0; color: var(--text-muted); line-height: 1.45; } +.gx-aa-step__num { flex: 0 0 auto; display: grid; place-items: center; width: 36px; height: 36px; border-radius: var(--radius-full); + background: var(--primary-container); color: var(--on-primary-container); font-weight: var(--fw-bold); } +.gx-aa-step--done .gx-aa-step__num { background: var(--success); color: var(--background); } +.gx-aa-step__body { display: grid; gap: var(--sp-3); min-width: 0; padding-left: calc(36px + var(--sp-4)); } +.gx-aa-step__body > p { margin: 0; line-height: 1.45; } +.gx-aa-step__body > .gx-driving__actions { margin-block: 0; } +.gx-aa-status { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-3); } +.gx-aa-status__actions { display: flex; flex-wrap: wrap; gap: var(--sp-2); margin-left: auto; } +.gx-aa-pill { display: inline-flex; align-items: center; gap: var(--sp-2); width: fit-content; max-width: 100%; margin: 0; padding: 6px 14px; + border-radius: var(--radius-full); font-size: var(--fs-sm); font-weight: var(--fw-medium); overflow-wrap: anywhere; + background: var(--surface-container-high); color: var(--on-surface-variant); border: 1px solid var(--outline); } +.gx-aa-pill::before { content: ""; flex: 0 0 auto; width: 8px; height: 8px; border-radius: 50%; background: currentColor; } +.gx-aa-pill:has(> i)::before { display: none; } +.gx-aa-pill--ready { color: var(--success); border-color: color-mix(in srgb, var(--success) 40%, transparent); background: color-mix(in srgb, var(--success) 12%, transparent); } +.gx-aa-pill--soon { color: var(--warning); border-color: color-mix(in srgb, var(--warning) 40%, transparent); background: color-mix(in srgb, var(--warning) 12%, transparent); } +.gx-aa-pill--expired, .gx-aa-pill--broken { color: var(--error); border-color: color-mix(in srgb, var(--error) 40%, transparent); background: color-mix(in srgb, var(--error) 12%, transparent); } +.gx-aa-list { list-style: none; margin: 0; padding: 0; display: grid; border: 1px solid var(--outline); border-radius: var(--radius-md); overflow: hidden; } +.gx-aa-list > .gx-aa-row + .gx-aa-row { border-top: 1px solid var(--outline); } +.gx-aa-row { display: flex; align-items: center; gap: var(--sp-3); min-width: 0; margin: 0; padding: var(--sp-3) var(--sp-4); } +.gx-aa-list .gx-aa-row { background: var(--surface-container-low); } +.gx-aa-row > i { flex: 0 0 auto; font-size: 1.3rem; color: var(--primary); } +.gx-aa-row__text { flex: 1 1 auto; display: grid; gap: 2px; min-width: 0; text-align: left; } +.gx-aa-row__text strong { overflow-wrap: anywhere; } +.gx-aa-row__text small { color: var(--text-muted); } +.gx-aa-row > .gx-btn, .gx-aa-row > .gx-aa-pill { flex: 0 0 auto; } +.gx-aa-ok { color: var(--success); } +.gx-aa-bad { color: var(--error); } +.gx-aa-spinner { flex: 0 0 auto; width: 16px; height: 16px; border-radius: 50%; border: 2px solid var(--outline); border-top-color: var(--primary); animation: gx-aa-spin .9s linear infinite; } +@keyframes gx-aa-spin { to { transform: rotate(360deg); } } +.gx-aa-prompt { display: grid; gap: var(--sp-2); padding: var(--sp-4); border-radius: var(--radius-md); background: var(--primary-container); min-width: 0; } +.gx-aa-prompt p { margin: 0; } +.gx-aa-prompt__code { font-size: 1.6rem; letter-spacing: .12em; font-variant-numeric: tabular-nums; } +.gx-aa-cta { width: 100%; min-height: 52px; font-size: var(--fs-base); overflow-wrap: anywhere; } +.gx-aa-switch { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: var(--sp-3) var(--sp-4); border: 1px solid var(--outline); + border-radius: var(--radius-md); background: var(--surface-container-low); color: var(--on-surface); font: inherit; cursor: pointer; } +.gx-aa-switch:disabled { opacity: .5; cursor: not-allowed; } +.gx-aa-switch:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; } +.gx-aa-switch__track { flex: 0 0 auto; position: relative; width: 50px; height: 30px; border-radius: var(--radius-full); background: var(--outline); transition: background-color var(--motion-fast); } +.gx-aa-switch__thumb { position: absolute; top: 3px; left: 3px; width: 24px; height: 24px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0, 0, 0, .3); transition: transform var(--motion-base); } +.gx-aa-switch[aria-checked="true"] .gx-aa-switch__track { background: var(--success); } +.gx-aa-switch[aria-checked="true"] .gx-aa-switch__thumb { transform: translateX(20px); } +.gx-aa-wiki { border-top: 1px solid var(--outline); padding-top: var(--sp-3); color: var(--on-surface-variant); line-height: 1.5; } +.gx-aa-wiki > summary { cursor: pointer; color: var(--primary); font-weight: var(--fw-medium); list-style: none; } +.gx-aa-wiki > summary::-webkit-details-marker { display: none; } +.gx-aa-wiki > summary::before { content: "›"; display: inline-block; width: 1em; transition: transform var(--motion-fast); } +.gx-aa-wiki[open] > summary::before { transform: rotate(90deg); } +.gx-aa-wiki > summary:focus-visible, .gx-aa-advanced > summary:focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; border-radius: var(--radius-sm); } +.gx-aa-wiki p, .gx-aa-wiki ol { margin: var(--sp-3) 0 0; } +.gx-aa-wiki ol { padding-left: 1.3em; display: grid; gap: var(--sp-1); } +.gx-aa-advanced { margin-top: var(--sp-4); padding: var(--sp-4) var(--sp-5); } +.gx-aa-advanced > summary { cursor: pointer; font-weight: var(--fw-medium); color: var(--on-surface-variant); } +.gx-aa-advanced__body { display: grid; gap: var(--sp-2); padding-top: var(--sp-3); overflow-wrap: anywhere; } +.gx-aa-advanced__body p { margin: 0; } +.gx-aa-sheet__dialog { position: relative; } +.gx-aa-sheet__dialog:focus { outline: none; } +.gx-aa-sheet__dialog h3 { margin: 0 0 var(--sp-1); font-size: var(--fs-xl); } +.gx-aa-sheet__dialog p { margin: 0; } +.gx-aa-sheet__close { position: absolute; top: var(--sp-3); right: var(--sp-3); width: var(--touch-target); height: var(--touch-target); border: 0; border-radius: 50%; + background: var(--surface-container-low); color: var(--on-surface-variant); cursor: pointer; } +.gx-aa-sheet__steps { list-style: none; counter-reset: aa; margin: 0; padding: 0; display: grid; gap: var(--sp-4); } +.gx-aa-sheet__steps > li { counter-increment: aa; position: relative; display: grid; gap: var(--sp-2); justify-items: start; min-width: 0; padding-left: 40px; transition: opacity var(--motion-fast); } +.gx-aa-sheet__steps > li::before { content: counter(aa); position: absolute; left: 0; top: 0; display: grid; place-items: center; width: 28px; height: 28px; + border-radius: 50%; background: var(--primary-container); color: var(--on-primary-container); font-weight: var(--fw-bold); font-size: var(--fs-sm); } +.gx-aa-sheet__steps small { color: var(--text-muted); line-height: 1.45; } +.gx-aa-sheet__step--muted { opacity: .55; } +.gx-aa-file { position: relative; } +.gx-aa-file input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; } +.gx-aa-file:focus-within { outline: 2px solid var(--primary); outline-offset: 2px; } +.gx-aa-file--disabled { opacity: .4; pointer-events: none; } +.gx-aa-sheet__steps small.gx-aa-picked { color: var(--on-surface); overflow-wrap: anywhere; } +.gx-aa-sheet__steps small.gx-aa-bad { color: var(--error); } +.gx-aa-progress { display: grid; gap: var(--sp-1); width: 100%; } +.gx-aa-progress progress { width: 100%; accent-color: var(--primary); } +.gx-aa-checks { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--sp-2); width: 100%; } +.gx-aa-checks li { display: flex; align-items: center; gap: var(--sp-2); color: var(--text-muted); } +.gx-aa-checks li::before { flex: 0 0 auto; display: grid; place-items: center; width: 20px; height: 20px; border-radius: 50%; font-size: 12px; font-weight: var(--fw-bold); + content: ""; border: 2px solid var(--outline); box-sizing: border-box; } +.gx-aa-checks .gx-aa-check--done, .gx-aa-checks .gx-aa-check--active { color: var(--on-surface); } +.gx-aa-checks .gx-aa-check--done::before { content: "✓"; border: 0; background: var(--success); color: var(--background); } +.gx-aa-checks .gx-aa-check--active::before { border-top-color: var(--primary); animation: gx-aa-spin .9s linear infinite; } +.gx-aa-checks .gx-aa-check--failed { color: var(--error); } +.gx-aa-checks .gx-aa-check--failed::before { content: "✕"; border: 0; background: var(--error); color: var(--on-error); } +.gx-aa-problem { display: grid; gap: var(--sp-3); justify-items: start; padding: var(--sp-3) var(--sp-4); border-radius: var(--radius-md); + background: color-mix(in srgb, var(--error) 10%, transparent); color: var(--on-surface); width: 100%; box-sizing: border-box; } +.gx-aa-sheet__done { display: grid; justify-items: center; gap: var(--sp-3); text-align: center; padding-block: var(--sp-3); } +.gx-aa-sheet__done > i { font-size: 3.5rem; } +.gx-aa-sheet__done .gx-aa-checks { width: auto; text-align: left; } +@media (prefers-reduced-motion: reduce) { .gx-aa-spinner, .gx-aa-checks .gx-aa-check--active::before { animation: none; } .gx-aa-switch__thumb { transition: none; } } +@media (max-width: 600px) { + .gx-aa-step { padding: var(--sp-4); } + .gx-aa-step__body { padding-left: 0; } + .gx-aa-status__actions { margin-left: 0; width: 100%; } + .gx-aa-status__actions .gx-btn { flex: 1 1 auto; } + .gx-aa-row { flex-wrap: wrap; } +} +.gx-card.gx-aa-sheet__dialog { background: var(--background); } +.gx-aa-step__body > p:has(> .gx-aa-spinner) { display: flex; align-items: center; gap: var(--sp-2); } diff --git a/openpilot/starpilot/galaxy/web/js/android-auto.js b/openpilot/starpilot/galaxy/web/js/android-auto.js index 5b00bbb707..55b28f94bf 100644 --- a/openpilot/starpilot/galaxy/web/js/android-auto.js +++ b/openpilot/starpilot/galaxy/web/js/android-auto.js @@ -290,28 +290,125 @@ export class AndroidAutoFeed { } } +export const APKMIRROR_URL = "https://www.apkmirror.com/apk/google-inc/android-auto/" +const KNOWN_GOOD_VERSION = "17.6.663454-release" +const STAGES = ["reading", "searching", "decrypting", "verifying", "installing", "done"] +const CHECKS = [ + { stage: "reading", codes: ["NOT_PACKAGE", "CORRUPT", "NO_CODE", "TOO_LARGE"], label: "Android package opened" }, + { stage: "searching", codes: ["WRONG_APP", "UNVERIFIED"], label: "Google Automotive Link certificate found" }, + { stage: "decrypting", codes: ["UNSUPPORTED_VERSION"], label: "Security key unlocked" }, + { stage: "verifying", codes: ["EXPIRED", "NOT_YET_VALID"], label: "Certificate is valid" }, +] + +// Certificate dates are UTC instants; showing them in UTC keeps "valid until" on the day the car checks. +export function formatDate(iso, options = { month: "short", day: "numeric", year: "numeric" }, locale = undefined) { + const date = new Date(iso) + return typeof iso !== "string" || Number.isNaN(date.getTime()) ? "" : date.toLocaleDateString(locale, { timeZone: "UTC", ...options }) +} + +export function expiryInfo(identity, locale = undefined) { + if (identity?.installed) { + const days = Number.isSafeInteger(identity.days_left) ? identity.days_left : null + if (days !== null && days <= 14) { + const day = formatDate(identity.expires, { month: "short", day: "numeric" }, locale) + const left = days <= 0 ? "Expires within a day" : `Expires in ${days} day${days === 1 ? "" : "s"}` + return { level: "soon", label: day ? `${left} (${day})` : left } + } + const until = formatDate(identity.expires, { month: "short", year: "numeric" }, locale) + return { level: "ready", label: until ? `Ready · Valid until ${until}` : "Ready" } + } + if (identity?.expired) { + const on = formatDate(identity.expires, undefined, locale) + return { level: "expired", label: on ? `Expired on ${on}` : "Expired" } + } + if (identity?.error) return { level: "broken", label: "Needs reinstalling" } + return { level: "none", label: "Not set up" } +} + +// Name-only checks that catch the common wrong downloads before a long upload. +// APKMirror names the real app "…-apkmirror.com.apk", so "apkmirror" alone is fine. +export function packagePreflight(file) { + const name = typeof file?.name === "string" ? file.name : "" + if (/installer|apkmirror\.helper/i.test(name)) { + return "This is the APKMirror Installer app, not Android Auto. Go back and tap Download APK on the Android Auto page itself." + } + if (/com\.google\.android\.gms|play[ ._-]?services/i.test(name)) { + return "This is Google Play Services, not Android Auto. Go back and download Android Auto by Google LLC." + } + if (name && !/\.(apk|xapk|apkm)$/i.test(name)) return "That’s not an Android package. Choose the .apk, .xapk, or .apkm file you downloaded." + return "" +} + +export function importProblem(job, fileName = "", locale = undefined) { + if (job?.state !== "failed") return "" + switch (job.code) { + case "NOT_PACKAGE": case "CORRUPT": + return "This file looks incomplete or damaged. Download it again and wait for the download to finish." + case "NO_CODE": return "This file is only part of the app. Download the full Android Auto APK, or the XAPK/APKM bundle." + case "WRONG_APP": + return packagePreflight({ name: fileName }) || + `Wrong app selected${fileName ? ` (${fileName})` : ""}. Choose Android Auto by Google LLC.` + case "UNVERIFIED": return "This package isn’t signed for Android Auto. The file must be the official Android Auto app from Google." + case "EXPIRED": { + const on = formatDate(job.expires, undefined, locale) + return `This Android Auto version expired${on ? ` on ${on}` : ""}. Download a newer stable release.` + } + case "NOT_YET_VALID": return "This version isn’t valid yet. Check that the comma’s date and time are correct." + case "UNSUPPORTED_VERSION": + return `This version stores its key differently. Download version ${KNOWN_GOOD_VERSION}, or another release known to work.` + case "TOO_LARGE": return "This file is too large to be the Android Auto app." + case "CANCELLED": return "Setup stopped because Android Auto was turned off. Try again." + default: return job.error || "Something went wrong. Try again." + } +} + +export function installChecks(job, locale = undefined) { + const at = job?.state === "done" ? STAGES.length : STAGES.indexOf(job?.stage) + const ends = CHECKS.map((_, index) => STAGES.indexOf(index + 1 < CHECKS.length ? CHECKS[index + 1].stage : "done")) + let failed = -1 + if (job?.state === "failed") { + failed = CHECKS.findIndex((check) => check.codes.includes(job.code)) + if (failed < 0) failed = Math.max(0, ends.findIndex((end) => at < end)) + } + return CHECKS.map((check, index) => { + const label = index === CHECKS.length - 1 && job?.state === "done" && formatDate(job.expires, undefined, locale) ? + `Certificate valid until ${formatDate(job.expires, { month: "long", year: "numeric" }, locale)}` : check.label + const status = failed >= 0 ? (index < failed ? "done" : index === failed ? "failed" : "pending") : + at >= ends[index] ? "done" : at >= STAGES.indexOf(check.stage) ? "active" : "pending" + return { label, status } + }) +} + export const AndroidAutoPage = { components: { GxIconButton, GxNotice }, props: { mode: { type: String, required: true }, localAccess: { type: Boolean, required: true }, unauthorized: { type: Function, required: true } }, data: () => ({ setup: null, pairing: null, selected: null, runtime: null, receivers: [], endReason: "", busy: false, error: "", - pairValue: "", packageFile: null, uploadProgress: null }), + pairValue: "", packageFile: null, uploadProgress: null, installOpen: false, installAttempted: false, installBaseline: null, + installFileName: "", apkmirror: APKMIRROR_URL, pairWhenReady: false }), mounted() { this.feed = new AndroidAutoFeed({ publish: (value) => Object.assign(this, value), unauthorized: this.unauthorized }) - this.visibility = () => document.hidden ? this.feed.stop(true) : this.feed.start() + this.visibility = () => document.hidden ? this.feed.stop(true) : this.begin() document.addEventListener("visibilitychange", this.visibility) - if (!document.hidden) this.feed.start() + if (!document.hidden) this.begin() }, beforeUnmount() { document.removeEventListener("visibilitychange", this.visibility); this.feed?.stop(true) }, watch: { - mode() { if (!document.hidden) this.feed?.start(); else this.feed?.stop(true) }, - localAccess() { if (!document.hidden) this.feed?.start(); else this.feed?.stop(true) }, + mode() { if (!document.hidden) this.begin(); else this.feed?.stop(true) }, + localAccess() { if (!document.hidden) this.begin(); else this.feed?.stop(true) }, 'pairing.prompt.id'() { this.pairValue = "" }, + 'pairing.active'(active, was) { if (was && !active) this.loadReceivers() }, + // Find My Car on a fresh device turns Android Auto on first, then searches once the service is up. + 'setup.serviceReady'(ready) { if (ready && this.pairWhenReady) { this.pairWhenReady = false; this.startPairing() } }, }, computed: { canRespond() { return validPromptInput(this.pairing?.prompt, this.pairValue) }, + expiry() { return expiryInfo(this.setup?.identity) }, + supportReady() { return !!(this.setup?.enabled && this.setup.identity.installed) }, packageError() { if (!this.packageFile) return "" + const preflight = packagePreflight(this.packageFile) + if (preflight) return preflight if (!Number.isSafeInteger(this.packageFile.size) || this.packageFile.size <= 0) return "The selected file is empty or its size cannot be read." if (this.setup && this.packageFile.size > this.setup.maxUploadBytes) return "The selected package exceeds the maximum size shown below." return "" @@ -319,29 +416,46 @@ export const AndroidAutoPage = { uploadReason() { if (!this.packageFile) return "Choose your Android Auto package." if (this.packageError) return this.packageError - if (!this.setup?.parked) return "Use offroad mode or Park to upload your package." + if (!this.setup?.parked) return "Use offroad mode or Park to install Android Auto support." if (!this.setup.enabled && !this.setup.installReady) return "The Android Auto display and encoder must be installed before uploading." - if (this.setup.import?.state === "running") return "Wait for the current package verification to finish." + if (this.setup.import?.state === "running") return "Wait for the current package check to finish." if (this.busy) return "Waiting for Android Auto setup to respond…" return "" }, + installState() { + const job = this.setup?.import + const current = this.installAttempted && job && job.started !== this.installBaseline + if (this.uploadProgress || this.installAttempted && this.busy && !current) return "uploading" + if (job?.state === "running") return "checking" + if (!current) return "choose" + if (job.state === "done" && this.setup.identity.installed) return "done" + return job.state === "failed" ? "failed" : "choose" + }, + checks() { return installChecks(this.setup?.import) }, + problem() { return importProblem(this.setup?.import, this.installFileName) }, hasPackage() { return !!(this.setup?.identity.installed || this.setup?.identity.expired || this.setup?.identity.error) }, removeReason() { if (!this.setup?.parked) return "Use offroad mode or Park to delete the package." if (!this.setup.enabled) return "Enable Android Auto to delete the package." - if (this.setup.import?.state === "running") return "Wait for the current package verification to finish." - if (this.runtime?.running) return "Stop projection before deleting the package." + if (this.setup.import?.state === "running") return "Wait for the current package check to finish." + if (this.runtime?.running) return "Disconnect from your car before deleting the package." return "" }, + // Bluetooth pairing and the car's Wi-Fi handoff need no Android Auto package; only projection does. pairingReason() { if (!this.setup) return "Load setup status first." if (!this.setup.installReady) return "This build is missing the Android Auto display or encoder." - if (!this.setup.enabled) return "Enable Android Auto in Prepare." - if (!this.setup.serviceReady) return "Waiting for the Android Auto service to start." + if (this.setup.enabled && !this.setup.serviceReady) return "Waiting for the Android Auto service to start." if (!this.setup.bluetoothEnabled) return "Turn on Bluetooth on the Bluetooth page." if (!this.setup.parked) return "Use offroad mode or Park before starting pairing." - if (!this.setup.identity.installed) return "Upload and verify your Android Auto package in Prepare." - if (this.runtime?.running) return "Stop projection before searching or pairing." + if (this.runtime?.running) return "Disconnect from your car before pairing." + return "" + }, + connectReason() { + if (!this.selected) return "Pair your car in step 2 first." + if (!this.setup?.enabled) return "Turn on Android Auto Support in step 1." + if (!this.setup.serviceReady) return "Waiting for the Android Auto service to start." + if (this.pairing?.active) return "Finish or cancel pairing first." return "" }, waitingForCar() { return this.pairing?.active && !this.pairing?.receiver && !this.pairing?.prompt && !this.pairing?.approved && @@ -350,10 +464,15 @@ export const AndroidAutoPage = { !!this.pairing?.prompt || ["pairing", "connecting"].includes(this.pairing?.state) || !!this.runtime?.running }, }, methods: { + async begin() { + await this.feed.start() + if (this.setup?.enabled && this.setup.serviceReady && !this.pairing?.active) this.loadReceivers() + }, refresh() { return this.feed.refresh() }, setEnabled(enabled) { return this.feed.setEnabled(enabled) }, startPairing() { if (this.pairingReason || this.runtime?.running) return false + if (!this.setup.enabled) return this.feed.setEnabled(true).then((enabled) => { this.pairWhenReady = enabled; return false }) return this.feed.action("./api/android-auto/pairing") }, selectDevice(address) { return this.feed.selectDevice(address) }, @@ -364,123 +483,234 @@ export const AndroidAutoPage = { const value = accepted && ["pin", "passkey"].includes(prompt.kind) ? this.pairValue : "" return this.feed.action("./api/android-auto/pairing/response", { prompt_id: prompt.id, accepted, value }) }, + openInstall() { + Object.assign(this, { installOpen: true, installAttempted: false, packageFile: null }) + this.$nextTick?.(() => this.$refs.installDialog?.focus()) + }, + closeInstall() { this.installOpen = false }, + chooseAgain() { Object.assign(this, { installAttempted: false, packageFile: null }) }, choosePackage(event) { this.packageFile = event.target.files?.[0] || null }, async upload() { if (this.uploadReason) return false const file = this.packageFile - if (!this.setup.enabled && !await this.feed.setEnabled(true)) return false + Object.assign(this, { installAttempted: true, installBaseline: this.setup.import?.started ?? null, installFileName: file.name || "" }) + if (!this.setup.enabled && !await this.feed.setEnabled(true)) { this.installAttempted = false; return false } return this.feed.upload(file) }, removePackage() { if (this.removeReason) return false - if (!window.confirm("Delete the Android Auto package and its certificate from this device? Projection will not start until you upload a package again.")) return false + if (!window.confirm("Delete the Android Auto package and its certificate from this device? Projection will not start until you install it again.")) return false return this.feed.removePackage() }, + connect() { + if (this.connectReason) return false + if (!this.setup.identity.installed) { this.openInstall(); return false } + return this.control("start") + }, + formatDate(iso) { return formatDate(iso) }, loadReceivers() { return this.feed.loadReceivers() }, control(action, extra = {}) { return this.feed.control(action, extra) }, }, template: ` -
-

Android Auto

-

Show StarPilot on your car’s wireless Android Auto display. Enable projection, upload your Android Auto package, then find your car or wireless adapter. Setup works here over local or remote Galaxy.

-
- {{ error }} -
{{ error ? "Setup could not be loaded." : "Checking Android Auto setup…" }}
- + + +
  • +
    + +

    Connect & Drive

    Start streaming to your dashboard.

    +
    +
    +

    Connected · {{ runtime.state }} — {{ runtime.detail }}

    +

    Projection: {{ runtime.state }} — {{ runtime.detail }}.

    + + +

    {{ connectReason }}

    +

    Finish step 1 first — Connect will walk you through it.

    + +

    Car: {{ runtime.companion_name }}

    +
    Stuck connecting? Wi-Fi and screen troubleshooting +

    How it connects: after Bluetooth, the car (or adapter) creates its own hidden 5 GHz Wi-Fi network and tells the comma how to join. The picture streams over that Wi-Fi.

    +

    First connection: stay in Park or offroad mode, and keep the car’s screen on.

    +

    Black or frozen screen: tap Disconnect, wait a few seconds, then connect again. If it keeps happening, turn the car off and on.

    +

    Car says the phone isn’t compatible: make sure wireless Android Auto (sometimes called “smartphone connection”) is turned on in the car’s settings, and that step 1 shows Ready.

    +

    Wired USB setup is unavailable. Use wireless Android Auto or a wireless adapter.

    +
    +
    +
  • + + +
    Advanced Tools & Diagnostics +
    +

    Certificate: {{ setup.identity.expired ? 'expired' : 'valid until' }} {{ formatDate(setup.identity.expires) }}.

    +

    {{ setup.identity.warning }}

    +

    {{ setup.identity.error }}

    +

    Last package check: {{ setup.import.error || 'failed' }}

    +
    + + + + Session Logs + +
    +

    {{ removeReason }}

    +

    If the video encoder can’t start, projection stops and reports the error in step 3. Projection support still needs validation with your car and this device. Maximum package size: {{ Math.floor(setup.maxUploadBytes / 1048576) }} MB.

    +
    +
    + +
    +
    `, } diff --git a/openpilot/starpilot/system/android_auto/apk_identity.py b/openpilot/starpilot/system/android_auto/apk_identity.py index 27e4338325..2ebf932643 100644 --- a/openpilot/starpilot/system/android_auto/apk_identity.py +++ b/openpilot/starpilot/system/android_auto/apk_identity.py @@ -36,6 +36,7 @@ import tempfile import threading import urllib.request import zipfile +import zlib from collections.abc import Callable from datetime import UTC, datetime from pathlib import Path @@ -60,7 +61,12 @@ IMPORT_DIR = identity_store.DATA_DIR / "import" class IdentityImportError(RuntimeError): - pass + """A rejected package. ``code`` lets Galaxy explain the problem without parsing the message.""" + + def __init__(self, message: str, code: str = "FAILED", expires: str | None = None): + super().__init__(message) + self.code = code + self.expires = expires # ------------------------------------------------------------------ extraction @@ -85,22 +91,22 @@ def derive_aes_material(cert: bytes, root: bytes, mask: bytes) -> bytes: def _checked_read(archive: zipfile.ZipFile, info: zipfile.ZipInfo, limit: int) -> bytes: if info.file_size > limit: - raise IdentityImportError(f"{info.filename} is too large ({info.file_size // (1024 * 1024)} MB)") + raise IdentityImportError(f"{info.filename} is too large ({info.file_size // (1024 * 1024)} MB)", "TOO_LARGE") with archive.open(info) as handle: data = handle.read(limit + 1) if len(data) > limit: - raise IdentityImportError(f"{info.filename} is larger than it claims") + raise IdentityImportError(f"{info.filename} is larger than it claims", "CORRUPT") return data def _dex_files(path: Path) -> tuple[list[bytes], str]: """The DEX files of the Android Auto app in an APK, XAPK or APKM; returns (dexes, description).""" if path.stat().st_size > MAX_FILE_BYTES: - raise IdentityImportError("File is too large to be the Android Auto app") + raise IdentityImportError("File is too large to be the Android Auto app", "TOO_LARGE") try: outer = zipfile.ZipFile(path) except zipfile.BadZipFile as error: - raise IdentityImportError("Not an APK, XAPK or APKM file") from error + raise IdentityImportError("Not an APK, XAPK or APKM file", "NOT_PACKAGE") from error with outer: names = [info for info in outer.infolist() if DEX_NAME.fullmatch(info.filename)] if names: @@ -117,7 +123,7 @@ def _dex_files(path: Path) -> tuple[list[bytes], str]: dex = [i for i in inner.infolist() if DEX_NAME.fullmatch(i.filename)] if dex: return [_checked_read(inner, i, MAX_DEX_BYTES) for i in dex[:MAX_DEX_FILES]], f"bundle ({info.filename})" - raise IdentityImportError("No app code found; choose the Android Auto APK, XAPK, or APKM") + raise IdentityImportError("No app code found; choose the Android Auto APK, XAPK, or APKM", "NO_CODE") def _candidates(dexes: list[bytes]) -> tuple[set[bytes], list[bytes], list[bytes]]: @@ -169,7 +175,7 @@ def extract_identity(path: Path, *, root_sha256: str = GOOGLE_ROOT_SHA256, now: der_sha = {pem: hashlib.sha256(cert.public_bytes(serialization.Encoding.DER)).hexdigest() for pem, cert in certs.items()} roots = [pem for pem in certs if der_sha[pem] == root_sha256] if not roots: - raise IdentityImportError("This file does not contain the Android Auto identity; choose the Android Auto app itself") + raise IdentityImportError("This file does not contain the Android Auto identity; choose the Android Auto app itself", "WRONG_APP") root_pem = roots[0] root = certs[root_pem] leaves = [] @@ -182,7 +188,7 @@ def extract_identity(path: Path, *, root_sha256: str = GOOGLE_ROOT_SHA256, now: except Exception: continue if not leaves: - raise IdentityImportError("No phone certificate issued by Google's Automotive Link root was found") + raise IdentityImportError("No phone certificate issued by Google's Automotive Link root was found", "UNVERIFIED") progress("decrypting") for leaf_pem in leaves: @@ -204,9 +210,10 @@ def extract_identity(path: Path, *, root_sha256: str = GOOGLE_ROOT_SHA256, now: now = now or datetime.now(UTC) for name, cert in (("phone certificate", leaf), ("Google root", root)): if now < _not_before(cert): - raise IdentityImportError(f"The {name} in this app is not valid yet; check the comma's clock") + raise IdentityImportError(f"The {name} in this app is not valid yet; check the comma's clock", "NOT_YET_VALID") if now >= _not_after(cert): - raise IdentityImportError(f"The {name} in this app expired on {_not_after(cert).date()}; use a newer Android Auto version") + raise IdentityImportError(f"The {name} in this app expired on {_not_after(cert).date()}; use a newer Android Auto version", + "EXPIRED", _not_after(cert).isoformat()) pem_key = key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()) metadata = { "subject": leaf.subject.rfc4514_string(), @@ -218,7 +225,8 @@ def extract_identity(path: Path, *, root_sha256: str = GOOGLE_ROOT_SHA256, now: "key_matches": True, } return {identity_store.CERT_NAME: leaf_pem, identity_store.KEY_NAME: pem_key, identity_store.ROOT_NAME: root_pem}, metadata - raise IdentityImportError(f"This Android Auto version stores its key differently; use version {KNOWN_GOOD_VERSION} or another that works") + raise IdentityImportError(f"This Android Auto version stores its key differently; use version {KNOWN_GOOD_VERSION} or another that works", + "UNSUPPORTED_VERSION") # ------------------------------------------------------------------ install @@ -258,7 +266,11 @@ def identity_status(directory: Path | None = None) -> dict: try: ident = identity_store.load_identity(directory) except identity_store.IdentityError as error: - return {"installed": False, "expired": "expired" in str(error), "error": str(error), "message": str(error)} + status = {"installed": False, "expired": "expired" in str(error), "error": str(error), "message": str(error)} + expires = identity_store._not_after(directory / identity_store.CERT_NAME) + if expires is not None: + status["expires"] = expires.isoformat() + return status try: provenance = json.loads((directory / "provenance.json").read_text()) except (OSError, ValueError): @@ -279,24 +291,24 @@ def identity_status(directory: Path | None = None) -> dict: def download(url: str, destination: Path, progress: Callable[[int, int], None] = lambda done, total: None) -> None: if not url.lower().startswith(("https://", "http://")): - raise IdentityImportError("Enter an http(s) link to the APK, XAPK, or APKM") + raise IdentityImportError("Enter an http(s) link to the APK, XAPK, or APKM", "DOWNLOAD") request = urllib.request.Request(url, headers={"User-Agent": "StarPilot-AndroidAuto/1"}) try: with urllib.request.urlopen(request, timeout=30) as response, open(destination, "wb") as handle: total = int(response.headers.get("Content-Length") or 0) if total > MAX_FILE_BYTES: - raise IdentityImportError("That file is too large to be the Android Auto app") + raise IdentityImportError("That file is too large to be the Android Auto app", "TOO_LARGE") done = 0 while chunk := response.read(1024 * 1024): done += len(chunk) if done > MAX_FILE_BYTES: - raise IdentityImportError("That file is too large to be the Android Auto app") + raise IdentityImportError("That file is too large to be the Android Auto app", "TOO_LARGE") handle.write(chunk) progress(done, total) except IdentityImportError: raise except Exception as error: - raise IdentityImportError(f"Download failed: {error}") from error + raise IdentityImportError(f"Download failed: {error}", "DOWNLOAD") from error class ImportJob: @@ -337,7 +349,7 @@ class ImportJob: def _run(self, path: Path | None, url: str, enabled=None) -> None: def progress(**values): if enabled is not None and not enabled(): - raise IdentityImportError("Import cancelled: Android Auto is disabled") + raise IdentityImportError("Import cancelled: Android Auto is disabled", "CANCELLED") self._set(**values) source = path or self.upload_path() @@ -351,9 +363,12 @@ class ImportJob: self._set(state="done", stage="done", finished=datetime.now(UTC).timestamp(), expires=metadata["expires"], message=f"Identity installed; valid until {metadata['expires'][:10]}") except IdentityImportError as error: - self._set(state="failed", finished=datetime.now(UTC).timestamp(), error=str(error)) + self._set(state="failed", finished=datetime.now(UTC).timestamp(), error=str(error), code=error.code, + **({"expires": error.expires} if error.expires else {})) + except (zipfile.BadZipFile, EOFError, zlib.error) as error: # a truncated or damaged download + self._set(state="failed", finished=datetime.now(UTC).timestamp(), error=f"The package is damaged: {error}", code="CORRUPT") except Exception as error: - self._set(state="failed", finished=datetime.now(UTC).timestamp(), error=f"{type(error).__name__}: {error}") + self._set(state="failed", finished=datetime.now(UTC).timestamp(), error=f"{type(error).__name__}: {error}", code="FAILED") finally: try: source.unlink(missing_ok=True) diff --git a/openpilot/starpilot/system/android_auto/tests/test_apk_identity.py b/openpilot/starpilot/system/android_auto/tests/test_apk_identity.py index 2edcaaa40a..e731c9e940 100644 --- a/openpilot/starpilot/system/android_auto/tests/test_apk_identity.py +++ b/openpilot/starpilot/system/android_auto/tests/test_apk_identity.py @@ -141,6 +141,38 @@ def test_rejects_non_apk_and_oversized_code(tmp_path, ident, monkeypatch): apk_identity.extract_identity(make_apk(tmp_path, ident), root_sha256=ident["root_sha"]) +def test_rejections_carry_codes_for_galaxy(tmp_path, ident): + def code(path, **kwargs): + with pytest.raises(apk_identity.IdentityImportError) as error: + apk_identity.extract_identity(path, **kwargs) + return error.value.code, error.value.expires + + junk = tmp_path / "junk.apk" + junk.write_bytes(b"not a zip") + assert code(junk) == ("NOT_PACKAGE", None) + assert code(make_apk(tmp_path, ident)) == ("WRONG_APP", None) + assert code(make_apk(tmp_path, ident, mask=bytes(256)), root_sha256=ident["root_sha"]) == ("UNSUPPORTED_VERSION", None) + later = datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=400) + expired, expires = code(make_apk(tmp_path, ident), root_sha256=ident["root_sha"], now=later) + assert expired == "EXPIRED" and datetime.datetime.fromisoformat(expires) < later + + job = apk_identity.ImportJob(work_dir=tmp_path / "work", identity_dir=tmp_path / "identity") + job.start(path=junk) + job.thread.join(30) + assert job.status()["state"] == "failed" and job.status()["code"] == "NOT_PACKAGE" + + +def test_status_reports_date_of_expired_identity(tmp_path): + cert, root, key_pem, _ = build_identity(days=0) + directory = tmp_path / "identity" + directory.mkdir() + for name, content in {"phone-cert.pem": cert, "phone-key.pem": key_pem, "root-cert.pem": root}.items(): + (directory / name).write_bytes(content) + os.chmod(directory / "phone-key.pem", 0o600) + status = apk_identity.identity_status(directory) + assert not status["installed"] and status["expired"] and status["expires"][:10] == datetime.datetime.now(datetime.UTC).date().isoformat() + + def test_install_is_atomic_and_keeps_previous(tmp_path, ident): directory = tmp_path / "aa" / "identity" files = {"phone-cert.pem": ident["cert"], "phone-key.pem": ident["key"], "root-cert.pem": ident["root"]}