Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
105 lines
6.2 KiB
Markdown
105 lines
6.2 KiB
Markdown
# Updating the car app over the air
|
|
|
|
How a new car build reaches the head unit without the Bluetooth-settings detour, and what to
|
|
check when it does not.
|
|
|
|
```
|
|
build-car ─▶ publish-car ─▶ forge pre-release (private repo, git.stinnesbeck.com/nils/mg4)
|
|
│ read with FORGE_TOKEN (read-only, server Secret)
|
|
▼
|
|
car server /v1/app/releases, /v1/app/assets/…
|
|
│ the car's own paired token
|
|
▼
|
|
app: Settings → Check for updates → SHA-256 → install
|
|
```
|
|
|
|
The repository stays private and the forge token stays on the server: the car asks its own
|
|
server, with its own token, and never holds a forge credential. The server only relays
|
|
bytes. Every check that decides whether code gets installed is in the app.
|
|
|
|
## Publishing a release
|
|
|
|
1. **Raise the version** in `app/build.gradle` (`versionCode` must increase; `versionName`
|
|
is three-part, e.g. `2.16.0`). The car build reports it as `2.16.0-unstable`.
|
|
2. **Build:** `nix develop`, then `build-car`. It signs with the AOSP platform key the head
|
|
unit trusts, checks the signer, and writes `build/car/NilsMG4-unstable-<version>.apk` and
|
|
its `.sha256`.
|
|
3. **Publish:** `publish-car`. It creates the pre-release `car-<version>` on the current
|
|
commit and attaches the APK and the `.sha256`. It needs a forge token that may write the
|
|
repository's releases, in `~/.config/nilsmg4/forge-token` (or `$FORGE_TOKEN`); the token is
|
|
only sent in a header, never printed. It refuses a version that is already published.
|
|
|
|
By hand, the same: in the forge's web UI create a release with tag `car-<version>`, tick
|
|
**pre-release**, and attach both files with exactly their names.
|
|
|
|
## What the updater accepts
|
|
|
|
The app asks the car server for the releases (`GET /v1/app/releases`) and walks every one,
|
|
keeping the highest version:
|
|
|
|
| Requirement | Where it is enforced | Otherwise |
|
|
|---|---|---|
|
|
| Marked **pre-release**, not a draft | `selectUpdate` | Skipped |
|
|
| Asset name contains `unstable` and ends `-<version>.apk` | `ASSET_VERSION` regex | Ignored |
|
|
| Version strictly higher than installed | `isNewer()` | "Up to date" |
|
|
| Every URL https and **exactly** the configured server's host | `isAllowedUrl()` | Refused |
|
|
| A matching `.sha256` (or `SHA256SUMS`) asset, and the APK's bytes match it | `fetchExpectedSha256()`, `download()` | Refused, file deleted |
|
|
| The archive is this app's package | `archiveIsThisPackage()` | Refused |
|
|
| Signed with the **same certificate** as the installed app | Android's installer | Not installed |
|
|
|
|
The version comes from the **asset file name**, not the tag.
|
|
|
|
## Installing
|
|
|
|
Press **Check for updates** on the Settings tab. The app checks, downloads the APK into
|
|
**Downloads** (with progress), verifies its SHA-256, and hands it to a `PackageInstaller`
|
|
session. As the system user it may install without asking; if the platform wants a
|
|
confirmation, the confirmation screen opens. When a session cannot start at all, the file
|
|
manager opens in Downloads (or, where the head unit has none, open the APK there with the
|
|
Files app). The verified APK stays in Downloads either way.
|
|
|
|
At start the app only checks, and shows "Update available" when there is one. It never
|
|
downloads or installs on its own, and the upload service never checks: installing restarts
|
|
the app, which must not happen mid-drive on its own.
|
|
|
|
## Installing from a USB stick
|
|
|
|
For development, without the forge: copy `NilsMG4-unstable-<version>.apk` **and** its `.sha256`
|
|
from `build/car/` to the top folder of a USB stick, plug it in, and press **Install from USB
|
|
stick** on the Settings tab. The app takes the newest such file that is newer than the installed
|
|
version, checks it against the `.sha256`, checks the package, and installs it with the same
|
|
installer session as an over-the-air update. The car build only; the stable build has no button.
|
|
|
|
On the Android 9 emulator (car build, system user) this installed without asking, from the
|
|
storage's top folder; on the car the stick's top folder is used the same way.
|
|
|
|
## When it says something else
|
|
|
|
| Message | Meaning |
|
|
|---|---|
|
|
| `Up to date (2.16.0-unstable)` | No published pre-release is newer. Check the pre-release tick and the asset name. |
|
|
| `Set up the server first (Server tab): updates come through it` | The app has no server URL and token. |
|
|
| `Update check failed` | The server could not be reached, or answered with an error: 404 when updates are off on the server (no `FORGE_TOKEN`), 502 when the forge refused the server's token. |
|
|
| `Update refused: no SHA-256 verification` | The release has no matching `.sha256`, or it is unreadable. |
|
|
| `Download or SHA-256 verification failed (…)` | The download broke off, or the bytes do not match the published hash. |
|
|
| `Update refused: the downloaded file is not this app` | The archive does not parse, or it is another package. |
|
|
| `Installing 2.16.0… the app restarts when it is done` | The session was committed; the result follows as its own toast. |
|
|
| `Installation failed: INSTALL_FAILED_… The APK is in Downloads.` | The package manager's own words. A signature error means the APK was not signed by `build-car`. |
|
|
| `… is in Downloads — tap the file to install` | A session could not start; install from Downloads by hand. |
|
|
|
|
On the car's Logs tab, or with a cable: `adb logcat -s EVABRP.Update OtaUpdater`.
|
|
|
|
## Why a PackageInstaller session, not `pm install`
|
|
|
|
Upstream first shelled out to `/system/bin/pm install -r` with the APK in the app's private
|
|
cache. That fails on this head unit: `pm` hands the path to the package manager service,
|
|
which opens it from its own process and SELinux context, where an app's private cache is not
|
|
readable — and the failure surfaced as a signature error it never was. A session takes no
|
|
path: the app opens the archive itself and streams it in. The approach comes from
|
|
[merthankaraman/DriveHub_Dort](https://github.com/merthankaraman/DriveHub_Dort), which hit
|
|
the same wall on the same hardware. Upstream later fell back to "download to Downloads and
|
|
tap it"; this fork tries the session first and keeps that as the fallback.
|
|
|
|
**Not yet tried on this car:** whether the session installs silently as the system user, or
|
|
asks for a confirmation.
|