Smooth Install Flow V1

This commit is contained in:
dirwin31
2026-10-05 19:11:57 -07:00
parent 5a6e17a0c8
commit d1def0f266
6 changed files with 568 additions and 148 deletions
+1 -3
View File
@@ -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)
@@ -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</)
assert.match(AndroidAutoPage.template, /Automatic Connection/)
assert.equal(await feed.setEnabled(false), true)
assert.deepEqual(JSON.parse(calls.find(([path]) => 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")
@@ -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); }
+329 -99
View File
@@ -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: `
<section class="gx-driving" aria-label="Android Auto setup">
<div class="gx-card gx-driving__intro"><h2>Android Auto</h2>
<p>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.</p></div>
<div class="gx-android-auto-setup">
<GxNotice tone="danger" v-if="error">{{ error }}</GxNotice>
<div v-if="!setup && !error" class="gx-card gx-message" role="status">{{ error ? "Setup could not be loaded." : "Checking Android Auto setup…" }} </div>
<template v-if="setup">
<div class="gx-card gx-driving__intro"><h3>1. Prepare</h3>
<p v-if="!setup.installReady" role="status">Android Auto display and encoder are not installed on this build. Projection setup is unavailable.</p>
<p v-else-if="setup.enabled && !setup.serviceReady" role="status">Starting the Android Auto service. Pairing is unavailable until it is ready.</p>
<div class="gx-driving__actions"><button class="gx-btn" :disabled="busy || !setup.parked || !setup.installReady" v-if="!setup.enabled" @click="setEnabled(true)">Enable Android Auto</button>
<button class="gx-btn gx-btn--tonal" :disabled="busy" v-else @click="setEnabled(false)">Turn Off Android Auto</button></div>
<p v-if="!setup.bluetoothEnabled">Open <a href="/bluetooth">Bluetooth</a> and turn it on before pairing.</p>
<p v-if="!setup.parked">Use offroad mode or Park to upload a package or pair your car.</p>
<p>{{ setup.identity.message || (setup.identity.installed ? 'Your Android Auto package is ready.' : 'Upload your Android Auto APK, XAPK, or APKM.') }}</p>
<details><summary>Where do I get the Android Auto package?</summary>
<ol>
<li>Open <a href="https://www.apkmirror.com/apk/google-inc/android-auto/" target="_blank" rel="noopener noreferrer">Android Auto by Google LLC on APKMirror</a>, a third-party download site.</li>
<li>Download the Android Auto app package to this phone or computer. Choose the app's APK or bundle download; you do not need the APKMirror Installer app.</li>
<li>Return here, choose the downloaded APK, XAPK, or APKM file, and use the upload button (or Enable &amp; upload). Leave bundles zipped.</li>
</ol>
<p>You do not install an Android app on the comma. Galaxy imports the information needed to connect to your car and checks that it is usable.</p>
</details>
<p v-if="setup.identity.warning" role="status">{{ setup.identity.warning }}</p>
<p v-if="setup.import?.state === 'running'" role="status">Checking your package…</p>
<GxNotice tone="danger" v-if="setup.import?.state === 'failed'">Package verification failed: {{ setup.import.error || 'Try another package.' }}</GxNotice>
<p v-if="setup.import?.state === 'done' && setup.identity.installed" role="status">Package verified and ready.</p>
<div v-if="hasPackage" class="gx-driving__actions"><button class="gx-btn gx-btn--tonal" :disabled="busy || !!removeReason" :title="removeReason" @click="removePackage">Delete Package</button></div>
<p v-if="hasPackage && removeReason && setup.import?.state !== 'running'" class="gx-muted">{{ removeReason }}</p>
<div class="gx-driving__actions"><input class="gx-field" type="file" accept=".apk,.xapk,.apkm" aria-label="Android Auto APK, XAPK, or APKM" @change="choosePackage" />
<GxIconButton v-if="setup.enabled" label="Upload package" icon="bi-upload" :disabled="!!uploadReason" @click="upload" />
<button v-else class="gx-btn" :disabled="!!uploadReason" @click="upload">Enable &amp; upload</button></div>
<p v-if="packageFile" role="status">Selected: {{ packageFile.name }} ({{ Math.ceil(packageFile.size / 1048576) }} MB).</p>
<div v-if="uploadProgress" role="status" aria-live="polite">
<progress :value="uploadProgress.loaded" :max="uploadProgress.total"></progress>
Uploading {{ Math.round(100 * uploadProgress.loaded / uploadProgress.total) }}%
</div>
<p v-if="uploadReason" :role="packageError ? 'alert' : 'status'">{{ uploadReason }}</p>
<small>Maximum package size: {{ Math.floor(setup.maxUploadBytes / 1048576) }} MB. Your package stays on this device.</small>
<section class="gx-driving gx-aa" aria-label="Android Auto setup">
<header class="gx-card gx-driving__intro gx-aa__hero">
<h2>Android Auto</h2>
<p>Stream navigation and media from StarPilot to your car’s screen. Three steps, about five minutes.</p>
</header>
<GxNotice tone="danger" v-if="error">{{ error }}</GxNotice>
<div v-if="!setup" class="gx-card gx-message" role="status">{{ error ? "Setup could not be loaded." : "Checking Android Auto setup…" }} <button class="gx-btn gx-btn--tonal" :disabled="busy" @click="refresh">Retry</button></div>
<ol v-else class="gx-aa__steps">
<li class="gx-card gx-aa-step" :class="{ 'gx-aa-step--done': supportReady }">
<div class="gx-aa-step__head">
<span class="gx-aa-step__num" aria-hidden="true"><i v-if="supportReady" class="bi bi-check-lg"></i><template v-else>1</template></span>
<div><h3>Android Auto Support</h3><p>The one-time setup that lets your comma talk to your car.</p></div>
</div>
<div class="gx-card gx-driving__intro"><h3>2. Find and Pair</h3>
<p>Using a wireless Android Auto adapter? Pair with your vehicle first, then pair with the adapter. Select the adapter to start projection.</p>
<p>Look for an adapter name such as AndroidAuto-XXXX. Keep your vehicle paired for calls.</p>
<p v-if="!setup.identity.installed">Finish package verification before pairing.</p>
<p v-else-if="!pairing?.active && selected">Saved Android Auto receiver: {{ selected.name }}. Find a car or wireless adapter in offroad mode or Park.</p>
<p v-else-if="!pairing?.active && endReason === 'canceled'">Pairing canceled. Start again when your car is ready.</p>
<p v-else-if="!pairing?.active && endReason">The pairing window ended without selecting a car. Try again in offroad mode or Park.</p>
<p v-else-if="!pairing?.active">Open your car’s phone pairing screen or put your wireless adapter in pairing mode, then find it here.</p>
<p v-else-if="waitingForCar" role="status">{{ pairing.discovering ? "Searching nearby Bluetooth devices…" : "Search is open. Choose your car or wireless adapter below." }}</p>
<p v-else-if="pairing.approved && (!pairing.state || pairing.state === 'idle')" role="status">Approved {{ pairing.receiver?.name }}. Waiting for Bluetooth pairing to finish…</p>
<div class="gx-aa-step__body">
<p v-if="!setup.installReady" class="gx-aa-pill gx-aa-pill--expired" role="status">Not available on this build — the Android Auto display and encoder are missing.</p>
<template v-else>
<div class="gx-aa-status">
<span class="gx-aa-pill" :class="'gx-aa-pill--' + expiry.level" role="status">{{ expiry.label }}<template v-if="hasPackage && !setup.enabled"> · Off</template></span>
<span v-if="setup.enabled && !setup.serviceReady" class="gx-muted" role="status">Starting…</span>
<div class="gx-aa-status__actions">
<button v-if="!setup.identity.installed" class="gx-btn" :disabled="busy || !setup.parked" @click="openInstall">{{ setup.identity.expired || setup.identity.error ? 'Reinstall' : 'Install Android Auto Support' }}</button>
<button v-else-if="expiry.level === 'soon'" class="gx-btn" :disabled="busy || !setup.parked" @click="openInstall">Update</button>
<button v-if="setup.identity.installed && !setup.enabled" class="gx-btn" :disabled="busy || !setup.parked" @click="setEnabled(true)">Turn On</button>
<button v-else-if="setup.enabled && setup.identity.installed" class="gx-btn gx-btn--tonal" :disabled="busy" @click="setEnabled(false)">Turn Off</button>
</div>
</div>
<p v-if="setup.import?.state === 'running' && !installOpen" class="gx-muted" role="status">Checking your package…</p>
<p v-if="!setup.parked" class="gx-muted">Use offroad mode or Park to make changes.</p>
</template>
<details class="gx-aa-wiki"><summary>Stuck installing? Which file to download</summary>
<ol>
<li>On APKMirror, open the newest <strong>Android Auto</strong> release by Google LLC whose name ends in <strong>‑release</strong> (not beta).</li>
<li>In the download table pick the variant for <strong>arm64-v8a</strong> or <strong>universal</strong>, with <strong>nodpi</strong>. APK, XAPK, and APKM all work; leave bundles zipped.</li>
<li>Tap the green <strong>Download APK</strong> button. You do not need the APKMirror Installer app.</li>
</ol>
<p><strong>Why is this needed?</strong> Car screens only talk to devices that present Google’s Android Auto certificate. Your comma reads it from your own copy of the app, on the device. Nothing is sent anywhere, and no Android app is installed on the comma.</p>
</details>
</div>
</li>
<li class="gx-card gx-aa-step" :class="{ 'gx-aa-step--done': !!selected && !pairing?.active }">
<div class="gx-aa-step__head">
<span class="gx-aa-step__num" aria-hidden="true"><i v-if="selected && !pairing?.active" class="bi bi-check-lg"></i><template v-else>2</template></span>
<div><h3>Pair Your Car</h3><p>Open your car’s phone pairing screen, then tap Find My Car. You can do this before step 1 is finished.</p></div>
</div>
<div class="gx-aa-step__body">
<ul v-if="!pairing?.active && receivers.length" class="gx-aa-list" aria-label="Paired cars">
<li v-for="car in receivers" :key="car.address" class="gx-aa-row">
<i class="bi bi-car-front" aria-hidden="true"></i>
<span class="gx-aa-row__text"><strong>{{ car.name }}</strong><small>{{ selected?.address === car.address ? 'Used for Android Auto' : 'Paired' }}</small></span>
<span v-if="selected?.address === car.address" class="gx-aa-pill gx-aa-pill--ready"><i class="bi bi-check-lg"></i> Selected</span>
<button v-else class="gx-btn gx-btn--tonal" :disabled="busy || !setup.parked || runtime?.running" @click="control('select_receiver', { address: car.address })">Use</button>
</li>
</ul>
<p v-else-if="!pairing?.active && selected" class="gx-aa-row"><i class="bi bi-car-front" aria-hidden="true"></i><span class="gx-aa-row__text"><strong>{{ selected.name }}</strong><small>Used for Android Auto</small></span></p>
<p v-if="!pairing?.active && endReason === 'canceled'" class="gx-muted" role="status">Pairing canceled. Start again when your car is ready.</p>
<p v-else-if="!pairing?.active && endReason" class="gx-muted" role="status">The pairing window ended without choosing a car. Try again when your car’s pairing screen is open.</p>
<p v-if="waitingForCar" role="status">{{ pairing.discovering ? "Searching for nearby cars and adapters…" : "Choose your car or wireless adapter below." }}</p>
<p v-else-if="pairing?.active && pairing.approved && (!pairing.state || pairing.state === 'idle')" role="status">Approved {{ pairing.receiver?.name }}. Finishing Bluetooth pairing…</p>
<p v-if="pairing?.state === 'pairing' && !pairing.prompt && !pairing.approved" role="status">Pairing with {{ pairing.receiver?.name || 'the selected device' }}…</p>
<p v-if="pairing?.state === 'connecting'" role="status">Connecting to {{ pairing.receiver?.name || 'the selected device' }}…</p>
<p v-if="['paired', 'connected'].includes(pairing?.state)" role="status">{{ pairing.receiver?.name || 'Device' }} {{ pairing.state === 'connected' ? 'connected' : 'paired' }} over Bluetooth.</p>
<p v-if="['paired', 'connected'].includes(pairing?.state)" role="status"><i class="bi bi-check-circle-fill gx-aa-ok"></i> {{ pairing.receiver?.name || 'Device' }} {{ pairing.state === 'connected' ? 'connected' : 'paired' }}.</p>
<GxNotice tone="danger" v-if="pairing?.error">{{ pairing.error }}</GxNotice>
<div v-if="pairing?.active" style="display:grid;gap:10px;margin:16px 0;min-width:0">
<p v-if="!pairing.devices?.length && waitingForCar" role="status">No devices found yet. Keep the car or adapter’s pairing screen open.</p>
<div v-for="device in pairing.devices || []" :key="device.address" style="display:flex;flex-wrap:wrap;align-items:center;gap:10px">
<span><strong>{{ device.name }}</strong><br />{{ device.android_auto ? 'Wireless Android Auto advertised' : 'Bluetooth device; Android Auto capability not yet advertised' }}<br />{{ device.connected ? 'Connected' : device.paired ? 'Saved Bluetooth pairing' : 'Nearby' }}</span>
<ul v-if="pairing?.active" class="gx-aa-list" aria-label="Nearby devices">
<li v-if="!pairing.devices?.length && waitingForCar" class="gx-aa-row gx-muted" role="status"><span class="gx-aa-spinner" aria-hidden="true"></span>Nothing found yet. Keep your car’s pairing screen open.</li>
<li v-for="device in pairing.devices || []" :key="device.address" class="gx-aa-row">
<i :class="device.android_auto ? 'bi bi-car-front' : 'bi bi-bluetooth'" aria-hidden="true"></i>
<span class="gx-aa-row__text"><strong>{{ device.name }}</strong><small>{{ device.android_auto ? 'Android Auto ready' : 'Bluetooth device' }} · {{ device.connected ? 'Connected' : device.paired ? 'Paired before' : 'Nearby' }}</small></span>
<button class="gx-btn gx-btn--tonal" :disabled="deviceSelectionBlocked" @click="selectDevice(device.address)">{{ device.paired ? 'Connect' : 'Pair' }}</button>
</div>
<small>Bluetooth pairing alone does not confirm Android Auto support.</small>
</div>
<div v-if="pairing?.prompt" role="status" style="display:grid;gap:10px;margin:16px 0;min-width:0"><strong>{{ pairing.receiver.name }}</strong>
</li>
</ul>
<div v-if="pairing?.prompt" class="gx-aa-prompt" role="status"><strong>{{ pairing.receiver.name }}</strong>
<p>{{ pairing.prompt.kind === 'confirmation' ? 'Check that this code matches your car, then confirm.' :
pairing.prompt.kind === 'pin' ? 'Enter the PIN shown by your car.' :
pairing.prompt.kind === 'passkey' ? 'Enter your car’s passkey.' :
pairing.prompt.kind === 'authorization' ? 'Allow this car to pair?' : 'Enter this code in your car.' }}</p>
<strong v-if="pairing.prompt.value" style="font-size:1.3rem;letter-spacing:.1em">{{ pairing.prompt.value }}</strong>
<strong v-if="pairing.prompt.value" class="gx-aa-prompt__code">{{ pairing.prompt.value }}</strong>
<input v-if="['pin','passkey'].includes(pairing.prompt.kind)" class="gx-field" type="text" maxlength="16" autocomplete="off"
:inputmode="pairing.prompt.kind === 'passkey' ? 'numeric' : 'text'" :aria-label="pairing.prompt.kind === 'pin' ? 'Car PIN' : 'Car passkey'" v-model="pairValue" />
<div v-if="!pairing.prompt.displayOnly" class="gx-driving__actions">
<button class="gx-btn gx-btn--tonal" :disabled="busy" @click="respond(false)">Reject</button>
<button class="gx-btn" :disabled="busy || !canRespond" @click="respond(true)">{{ pairing.prompt.kind === 'pin' ? 'Send PIN' : pairing.prompt.kind === 'passkey' ? 'Send Passkey' : 'Confirm' }}</button></div>
</div>
<p v-if="!pairing?.active && pairingReason" role="status">{{ pairingReason }}</p>
<p v-if="selected && !pairing?.active" class="gx-muted">For a wireless adapter, connect your paired car in Galaxy Bluetooth first. That car is saved for automatic reconnection before the adapter starts.</p>
<p v-if="runtime?.companion_name" class="gx-muted">Car: {{ runtime.companion_name }}</p>
<div class="gx-driving__actions"><button v-if="!pairing?.active" class="gx-btn" :disabled="busy || !!pairingReason" @click="startPairing">Find Car or Adapter</button>
<p v-if="pairWhenReady" class="gx-muted" role="status"><span class="gx-aa-spinner" aria-hidden="true"></span> Turning on Android Auto, then searching…</p>
<p v-else-if="!pairing?.active && pairingReason" class="gx-muted" role="status">{{ pairingReason }}</p>
<div class="gx-driving__actions">
<button v-if="!pairing?.active" class="gx-btn" :class="{ 'gx-btn--tonal': !!selected }" :disabled="busy || pairWhenReady || !!pairingReason" @click="startPairing"><i class="bi bi-search"></i> {{ selected ? 'Pair Another Car' : 'Find My Car' }}</button>
<button v-else class="gx-btn gx-btn--tonal" :disabled="busy" @click="cancelPairing">Cancel Search / Pairing</button>
</div>
</div>
<details class="gx-aa-wiki"><summary>Stuck pairing? Pairing order and wireless adapters</summary>
<p><strong>Keep this page open while pairing</strong> so the car’s PIN or code doesn’t time out.</p>
<p><strong>Car with built-in wireless Android Auto:</strong> open the car’s phone pairing screen, tap Find My Car, choose your car, then confirm the code on both screens.</p>
<p><strong>Wireless adapter</strong> (AAWireless, Motorola MA1, Carlinkit…): pair your car in <a href="/bluetooth">Bluetooth</a> first so calls keep working, then put the adapter in pairing mode and pair it here. Adapter names often look like AndroidAuto-XXXX.</p>
<p><strong>Start fresh:</strong></p>
<ol>
<li>Forget the car on the comma: <a href="/bluetooth">Bluetooth</a> → Forget.</li>
<li>Delete the comma from the car’s Bluetooth list.</li>
<li>Turn the car off and on (or unplug the adapter for 5 seconds).</li>
<li>Pair again here.</li>
</ol>
</details>
</div>
<div class="gx-card gx-driving__intro"><h3>3. Connect</h3>
<p v-if="selected">Selected Android Auto receiver: {{ selected.name }}.</p>
<p v-else>Select a paired Android Auto car before connecting.</p>
<p v-if="runtime?.state">Projection: {{ runtime.state }}<span v-if="runtime.detail"> — {{ runtime.detail }}</span>.</p>
<div class="gx-driving__actions">
<button class="gx-btn gx-btn--tonal" :disabled="busy || !setup.enabled || !setup.serviceReady || !setup.parked || pairing?.active" @click="loadReceivers">Find Paired Cars</button>
<button v-for="car in receivers" :key="car.address" class="gx-btn gx-btn--tonal"
:disabled="busy || !setup.parked || pairing?.active || runtime?.running || selected?.address === car.address"
@click="control('select_receiver', { address: car.address })">Select {{ car.name }}</button>
</div>
<div class="gx-driving__actions">
<button v-if="!runtime?.running" class="gx-btn" :disabled="busy || !selected || !setup.enabled || !setup.serviceReady || !setup.identity.installed || pairing?.active" @click="control('start')">Start Projection</button>
<button v-else class="gx-btn gx-btn--tonal" :disabled="busy" @click="control('stop')">Stop Projection</button>
<button class="gx-btn gx-btn--tonal" :disabled="busy || !setup.parked || pairing?.active"
@click="control('auto_connect', { enabled: !runtime?.auto_connect })">Automatic Connection: {{ runtime?.auto_connect ? 'On' : 'Off' }}</button>
</div>
<p>Wired USB setup is unavailable.</p>
<p>If this device's video encoder cannot start, projection stops and reports the error here.</p>
<p>Projection support still needs validation with your car and this device.</p></div>
</template>
</li>
<li class="gx-card gx-aa-step" :class="{ 'gx-aa-step--done': !!runtime?.running }">
<div class="gx-aa-step__head">
<span class="gx-aa-step__num" aria-hidden="true"><i v-if="runtime?.running" class="bi bi-check-lg"></i><template v-else>3</template></span>
<div><h3>Connect &amp; Drive</h3><p>Start streaming to your dashboard.</p></div>
</div>
<div class="gx-aa-step__body">
<p v-if="runtime?.running" class="gx-aa-pill gx-aa-pill--ready" role="status">Connected<span v-if="runtime.state"> · {{ runtime.state }}</span><span v-if="runtime.detail"> — {{ runtime.detail }}</span></p>
<p v-else-if="runtime?.state && runtime.state !== 'idle'" class="gx-muted" role="status">Projection: {{ runtime.state }}<span v-if="runtime.detail"> — {{ runtime.detail }}</span>.</p>
<button v-if="!runtime?.running" class="gx-btn gx-aa-cta" :disabled="busy || !!connectReason" @click="connect">{{ selected ? 'Connect to ' + selected.name : 'Connect to Your Car' }}</button>
<button v-else class="gx-btn gx-btn--tonal gx-aa-cta" :disabled="busy" @click="control('stop')">Disconnect</button>
<p v-if="connectReason" class="gx-muted" role="status">{{ connectReason }}</p>
<p v-else-if="!setup.identity.installed" class="gx-muted">Finish step 1 first — Connect will walk you through it.</p>
<button type="button" class="gx-aa-switch" role="switch" :aria-checked="runtime?.auto_connect ? 'true' : 'false'"
:disabled="busy || !setup.enabled || !setup.parked || pairing?.active" @click="control('auto_connect', { enabled: !runtime?.auto_connect })">
<span class="gx-aa-row__text"><strong>Automatic Connection</strong><small>Connect to your car whenever you start driving.</small></span>
<span class="gx-aa-switch__track" aria-hidden="true"><span class="gx-aa-switch__thumb"></span></span>
</button>
<p v-if="runtime?.companion_name" class="gx-muted">Car: {{ runtime.companion_name }}</p>
<details class="gx-aa-wiki"><summary>Stuck connecting? Wi-Fi and screen troubleshooting</summary>
<p><strong>How it connects:</strong> 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.</p>
<p><strong>First connection:</strong> stay in Park or offroad mode, and keep the car’s screen on.</p>
<p><strong>Black or frozen screen:</strong> tap Disconnect, wait a few seconds, then connect again. If it keeps happening, turn the car off and on.</p>
<p><strong>Car says the phone isn’t compatible:</strong> make sure wireless Android Auto (sometimes called “smartphone connection”) is turned on in the car’s settings, and that step 1 shows Ready.</p>
<p>Wired USB setup is unavailable. Use wireless Android Auto or a wireless adapter.</p>
</details>
</div>
</li>
</ol>
<details v-if="setup" class="gx-card gx-aa-advanced"><summary>Advanced Tools &amp; Diagnostics</summary>
<div class="gx-aa-advanced__body">
<p v-if="setup.identity.expires">Certificate: {{ setup.identity.expired ? 'expired' : 'valid until' }} {{ formatDate(setup.identity.expires) }}.</p>
<p v-if="setup.identity.warning" role="status">{{ setup.identity.warning }}</p>
<p v-if="setup.identity.error" role="status">{{ setup.identity.error }}</p>
<p v-if="setup.import?.state === 'failed' && !installOpen" role="status">Last package check: {{ setup.import.error || 'failed' }}</p>
<div class="gx-driving__actions">
<button class="gx-btn gx-btn--tonal" :disabled="busy" @click="refresh">Refresh</button>
<button class="gx-btn gx-btn--tonal" :disabled="busy || !setup.enabled || !setup.serviceReady || !setup.parked || pairing?.active" @click="loadReceivers">Find Paired Cars</button>
<button v-if="hasPackage" class="gx-btn gx-btn--tonal" :disabled="busy || !!removeReason" :title="removeReason" @click="removePackage">Delete Package</button>
<a class="gx-btn gx-btn--tonal" href="/logs">Session Logs</a>
<button v-if="setup.enabled" class="gx-btn gx-btn--tonal" :disabled="busy" @click="setEnabled(false)">Turn Off Android Auto</button>
</div>
<p v-if="hasPackage && removeReason && setup.import?.state !== 'running'" class="gx-muted">{{ removeReason }}</p>
<p class="gx-muted">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.</p>
</div>
</details>
<div v-if="installOpen && setup" class="gx-settings__modal gx-aa-sheet" @click.self="closeInstall" @keydown.esc="closeInstall">
<div ref="installDialog" class="gx-card gx-settings__dialog gx-aa-sheet__dialog" role="dialog" aria-modal="true" aria-labelledby="gx-aa-install-title" tabindex="-1">
<button type="button" class="gx-aa-sheet__close" aria-label="Close" @click="closeInstall"><i class="bi bi-x-lg"></i></button>
<div v-if="installState === 'done'" class="gx-aa-sheet__done">
<i class="bi bi-check-circle-fill gx-aa-ok" aria-hidden="true"></i>
<h3 id="gx-aa-install-title">You’re all set</h3>
<p>{{ expiry.label }}</p>
<ul class="gx-aa-checks"><li v-for="check in checks" :key="check.label" :class="'gx-aa-check--' + check.status">{{ check.label }}</li></ul>
<button class="gx-btn gx-aa-cta" @click="closeInstall">Done</button>
</div>
<template v-else>
<div><h3 id="gx-aa-install-title">Set Up Android Auto Support</h3>
<p class="gx-muted">A quick one-time setup that enables wireless projection with your car.</p></div>
<ol class="gx-aa-sheet__steps">
<li :class="{ 'gx-aa-sheet__step--muted': installState !== 'choose' }">
<strong>Download Android Auto</strong>
<a class="gx-btn gx-btn--tonal" :href="apkmirror" target="_blank" rel="noopener noreferrer">Open APKMirror <i class="bi bi-box-arrow-up-right"></i></a>
<small>Pick the newest <strong>‑release</strong> build for <strong>arm64-v8a</strong> or <strong>universal</strong>, then tap the green <strong>Download APK</strong> button. You don’t need the APKMirror Installer app.</small>
</li>
<li :class="{ 'gx-aa-sheet__step--muted': installState !== 'choose' }">
<strong>Choose the downloaded file</strong>
<label class="gx-btn gx-btn--tonal gx-aa-file" :class="{ 'gx-aa-file--disabled': installState !== 'choose' }">
<input type="file" accept=".apk,.xapk,.apkm" aria-label="Android Auto APK, XAPK, or APKM" :disabled="installState !== 'choose'" @change="choosePackage" />
<i class="bi bi-folder2-open"></i> {{ packageFile ? 'Choose a Different File' : 'Choose File' }}</label>
<small v-if="packageFile && !packageError" class="gx-aa-picked"><i class="bi bi-file-earmark-zip"></i> {{ packageFile.name }} · {{ Math.ceil(packageFile.size / 1048576) }} MB</small>
<small v-if="packageError" class="gx-aa-bad" role="alert">{{ packageError }}</small>
</li>
<li>
<strong>Install</strong>
<button v-if="installState === 'choose'" class="gx-btn" :disabled="!!uploadReason" @click="upload">{{ setup.enabled ? 'Install' : 'Turn On and Install' }}</button>
<small v-if="installState === 'choose' && uploadReason && packageFile && !packageError" class="gx-muted" role="status">{{ uploadReason }}</small>
<div v-if="installState === 'uploading'" role="status" aria-live="polite" class="gx-aa-progress">
<progress :value="uploadProgress?.loaded || 0" :max="uploadProgress?.total || 1"></progress>
<small>Sending to your comma… {{ uploadProgress ? Math.round(100 * uploadProgress.loaded / uploadProgress.total) : 0 }}%</small>
</div>
<ul v-if="['checking', 'failed'].includes(installState)" class="gx-aa-checks" aria-live="polite">
<li v-for="check in checks" :key="check.label" :class="'gx-aa-check--' + check.status">{{ check.label }}</li>
</ul>
<div v-if="installState === 'failed'" class="gx-aa-problem" role="alert">
<p>{{ problem }}</p>
<button class="gx-btn gx-btn--tonal" @click="chooseAgain">Choose Another File</button>
</div>
<p v-if="error && installState === 'choose' && installAttempted" class="gx-aa-bad" role="alert">{{ error }}</p>
</li>
</ol>
<small class="gx-muted">Your package stays on this comma. Maximum size {{ Math.floor(setup.maxUploadBytes / 1048576) }} MB.</small>
</template>
</div>
</div>
</section>`,
}
@@ -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)
@@ -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"]}