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
This commit is contained in:
@@ -1,164 +1,93 @@
|
||||
# Publishing an OTA release
|
||||
# Updating the car app over the air
|
||||
|
||||
How to ship an update the car will install by itself. Written for whoever does this
|
||||
next — probably me, having forgotten the details.
|
||||
How a new car build reaches the head unit without the Bluetooth-settings detour, and what to
|
||||
check when it does not.
|
||||
|
||||
## First upgrade from 2.2.3 or older
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
Install the platform-signed 2.2.7 APK manually once if the car still runs 2.2.3 or older.
|
||||
Those builds use the old `pm install` path; publishing a newer APK does not repair the
|
||||
installer already running on the car. Builds from 2.2.4 use `PackageInstaller` sessions.
|
||||
Keep the unstable package ID, system shared UID and signing certificate when upgrading.
|
||||
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.
|
||||
|
||||
The 2.2.5 build removes window automation. It also distinguishes failed release checks
|
||||
from a successful check with no newer release. On 2026-09-29, GitHub still published only
|
||||
2.2.3 at the last live check. The local 2.2.7 build includes these fixes and the redesigned
|
||||
UI; it becomes available to OTA only after publication as a pre-release asset named
|
||||
`FatihsMG4-unstable-2.2.7.apk`, together with
|
||||
`FatihsMG4-unstable-2.2.7.apk.sha256`.
|
||||
## Publishing a release
|
||||
|
||||
The vehicle artifact uses `com.stinnesbeck.nilsmg4.unstable` (upstream: `com.evsuite.abrp.unstable`),
|
||||
`android.uid.system`, and the
|
||||
DriveHub_Kamera platform key. Its certificate SHA-256 must be
|
||||
`c8a2e9bccf597c2fb6dc66bee293fc13f2fc47ec77bc6b2b0d52c11f51192ab8`.
|
||||
The stable build used for a Samsung UI preview is a separate package without OTA or the
|
||||
vehicle's shared UID; do not use that preview APK for the MG4 upgrade.
|
||||
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.
|
||||
|
||||
## What the updater actually looks for
|
||||
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.
|
||||
|
||||
> **Switched off in Nils' MG4.** `RELEASES_API` points at `updates.invalid`, so every check
|
||||
> fails. To re-enable it, point it at this repository's releases and add the host to
|
||||
> `ALLOWED_HOSTS` (see `SPECS.md`). The steps below are upstream's GitHub process.
|
||||
## What the updater accepts
|
||||
|
||||
`OtaUpdater.check()` reads the `RELEASES_API` URL (upstream:
|
||||
`https://api.github.com/repos/fatihdonmezdev/FatihsMG4/releases`) and walks **every** entry, keeping the highest version rather than the first match. A
|
||||
release is a candidate only if all of these hold:
|
||||
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 | What happens otherwise |
|
||||
| Requirement | Where it is enforced | Otherwise |
|
||||
|---|---|---|
|
||||
| Marked **pre-release** | `release.optBoolean("prerelease")` | Skipped entirely |
|
||||
| Asset name contains `unstable` | `name.contains("unstable")` | Asset ignored |
|
||||
| Asset name ends `-<version>.apk` | `ASSET_VERSION` regex | No version, asset ignored |
|
||||
| Version strictly higher than installed | `isNewer()` | Nothing to do, reports "Up to date" |
|
||||
| Download host on the allowlist | `isAllowedUrl()` | Refused and logged |
|
||||
| APK signed with the **same certificate** as the running app | Android `PackageInstaller` | Installation is rejected by the platform |
|
||||
| Matching `.sha256` asset or `SHA256SUMS` entry | `fetchExpectedSha256()` | Update ignored/refused |
|
||||
| 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 last one is the one that bites. The car runs a platform-signed build; an APK signed
|
||||
with a debug key — or anyone else's key — is downloaded, rejected and deleted. Sign every
|
||||
release with the same platform key or the update silently never lands.
|
||||
The version comes from the **asset file name**, not the tag.
|
||||
|
||||
The version comes from the **asset filename**, not the git tag. The tag can stay
|
||||
`unstable` forever and be moved; the filename is what identifies a build.
|
||||
## Installing
|
||||
|
||||
## Release checklist
|
||||
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.
|
||||
|
||||
1. **Raise the version.** `app/build.gradle`, `defaultConfig`:
|
||||
|
||||
```gradle
|
||||
versionCode 24 // must increase — Android orders updates by this, not by name
|
||||
versionName "2.2.7"
|
||||
```
|
||||
|
||||
The unstable flavour appends its own suffix, so `2.2.7` becomes `2.2.7-unstable`.
|
||||
Check what the installed build reports before picking a number: the app's About
|
||||
dialog shows it, and `isNewer` compares numerically segment by segment.
|
||||
|
||||
2. **Build.**
|
||||
|
||||
```bash
|
||||
./gradlew :app:assembleUnstableDebug
|
||||
```
|
||||
|
||||
3. **Sign with the platform key.** Options come *before* the input APK — apksigner
|
||||
rejects the other order.
|
||||
|
||||
```bash
|
||||
BT=~/Android/Sdk/build-tools/35.0.0
|
||||
# Local copies from DriveHub_Kamera/tools; private and gitignored.
|
||||
KEYS="$PWD/tools"
|
||||
|
||||
"$BT/zipalign" -f 4 \
|
||||
app/build/outputs/apk/unstable/debug/app-unstable-debug.apk aligned.apk
|
||||
|
||||
"$BT/apksigner" sign \
|
||||
--key "$KEYS/platform.pk8" \
|
||||
--cert "$KEYS/platform.x509.pem" \
|
||||
--out NilsMG4-unstable-2.2.7.apk \
|
||||
aligned.apk
|
||||
|
||||
rm aligned.apk
|
||||
```
|
||||
|
||||
The output filename **is** the version the updater reads. Name it
|
||||
`NilsMG4-unstable-<version>.apk` (`build-car` does steps 2 to 4 and names it so), then create its required integrity sidecar:
|
||||
|
||||
```bash
|
||||
shasum -a 256 NilsMG4-unstable-2.2.7.apk \
|
||||
> NilsMG4-unstable-2.2.7.apk.sha256
|
||||
```
|
||||
|
||||
4. **Verify the signer** before uploading. It must say `CN=Android`:
|
||||
|
||||
```bash
|
||||
"$BT/apksigner" verify --print-certs NilsMG4-unstable-2.2.7.apk
|
||||
```
|
||||
|
||||
5. **Publish.** On github.com/fatihdonmezdev/FatihsMG4 → Releases → Draft a new release:
|
||||
- tag: `unstable` (reuse it; the rolling tag is the design)
|
||||
- **tick "Set as a pre-release"** ← the whole thing is inert without this
|
||||
- attach the signed APK and its `.sha256` sidecar
|
||||
|
||||
6. **Install it.** Open the app on the head unit and press the refresh button in the top
|
||||
bar. The dialog shows download progress, verifies SHA-256, then reports that the
|
||||
PackageInstaller session has started.
|
||||
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.14.2-unstable)` | No release beat the installed version. Check the pre-release tick and the asset filename. |
|
||||
| `Update refused: no SHA-256 verification` | The APK has no matching sidecar, or the sidecar is invalid or unreachable. |
|
||||
| `Download or SHA-256 verification failed (…)` | The download failed, or its bytes do not match the published hash. |
|
||||
| `Update check failed` | The check threw. `adb logcat -s UpdateHook OtaUpdater` if a cable is available; otherwise the in-app Logs tab. |
|
||||
| `Update check already in progress` | A check is still running; wait for its result. |
|
||||
| `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. |
|
||||
|
||||
## How the install works, and why it is not `pm install`
|
||||
On the car's Logs tab, or with a cable: `adb logcat -s EVABRP.Update OtaUpdater`.
|
||||
|
||||
`OtaUpdater.install()` used to shell out to `/system/bin/pm install -r`. On this head unit
|
||||
that fails every time, and the reason is worth writing down because the symptom pointed
|
||||
somewhere else entirely.
|
||||
## Why a PackageInstaller session, not `pm install`
|
||||
|
||||
The downloaded APK lives in `context.getCacheDir()`. `pm` does not install the file itself
|
||||
— it passes the path to the package manager service, which opens it from *its* process and
|
||||
SELinux context, where an app's private cache is not readable. The install failed, and
|
||||
because `install()` returned a bare boolean, the caller reported the only failure it knew
|
||||
how to name: `Kurulum reddedildi (imza uyuşmuyor?)` ("installation refused (signature mismatch?)"). A correctly signed build, refused with
|
||||
a signature error it had not earned. The `pm` output that would have said so was captured
|
||||
and thrown away.
|
||||
|
||||
It now uses the platform `PackageInstaller`: we open the archive ourselves and stream it
|
||||
into a session, so no second process ever has to read our cache. The approach is taken from
|
||||
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.
|
||||
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.
|
||||
|
||||
Two consequences for anyone reading a failure:
|
||||
|
||||
- **Committing a session is not installing.** `commit()` is asynchronous. The refresh button
|
||||
can only report that an install *started*; the verdict arrives at
|
||||
`OtaInstallResultReceiver` and appears as its own toast.
|
||||
- **`STATUS_PENDING_USER_ACTION` is not an error.** The platform is asking the driver to
|
||||
confirm. The receiver launches the confirmation activity the platform hands it.
|
||||
|
||||
Still unverified on the car: whether this build installs silently or raises that
|
||||
confirmation. Either way it installs, which the old path did not.
|
||||
|
||||
## Why installation is manual
|
||||
|
||||
`UpdateHook.checkInBackground(this)` performs an availability-only check at app start and
|
||||
shows a notification toast when a complete APK + hash pair exists. It never downloads or
|
||||
installs. The refresh button is the only trigger for download, verification and installation.
|
||||
|
||||
The upload service does **not** check for updates on its tick. Installing a new APK
|
||||
restarts the process, and doing that mid-drive would interrupt the telemetry the service
|
||||
exists to send.
|
||||
**Not yet tried on this car:** whether the session installs silently as the system user, or
|
||||
asks for a confirmation.
|
||||
|
||||
Reference in New Issue
Block a user