Files
mg4/OTA.md
T
nilsandClaude Opus 5.5 e5549dddb0 App 2.16.0, server 2.6.0: over-the-air updates from our own repository
The car updates itself again, from this fork's own releases, while the
repository stays private:

- publish-car (flake) creates the pre-release car-<version> on the
  forge and attaches the APK and its .sha256. It needs a forge token
  that may write releases, from ~/.config/nilsmg4/forge-token or
  $FORGE_TOKEN; it is sent in a header and never printed.
- The server relays releases and attachments (/v1/app/releases,
  /v1/app/assets/...), read with a read-only FORGE_TOKEN from its
  Secret, GitHub-shaped with download URLs back at itself. Redirects
  away from the forge are not followed; off (404) without a token.
- The app asks its server with its own paired token, allows exactly
  the server's host, downloads the APK itself instead of through the
  DownloadManager (so the token stays in the app), verifies SHA-256 and
  package name, and tries a PackageInstaller session (it may install
  silently as the system user) before falling back to Downloads.

OTA.md is rewritten for this flow; the k8s and server READMEs explain
the new token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 18:08:08 +02:00

5.6 KiB

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: refresh button → check → 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 the refresh button in the app's top bar. 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.

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, 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.