Each check writes down the networks Android sees, the server's name lookup,
how a failure happened and how many reports wait, and on a failure whether
the internet answers at all, to tell a SIM without data from a server that is
down. The report goes to a USB stick and, once online, the server. No address
or token is written. Tried on the emulator. Not tried on the car.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
The car refuses the installer session, and only a refusal thrown at
once opened the installer screen; one that came back through the
session's result ended in a toast, so the update downloaded and then
nothing seemed to happen. Now any result but success or the driver's
cancel opens the installer screen with the verified APK, as does a
confirmation that cannot be shown, and a session silent for 20 s is
abandoned for it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
The car refuses the silent session for a non-system app; the fallback
installer screen could not read our FileProvider URI because grants
from the system user are refused. The car build now hands it a file
URI to the verified copy in Downloads.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
Read-only diagnostic for CarPlay's resolution: window, SurfaceFlinger
and display state captured while CarPlay is in front, display-related
properties without identifiers, Allgo's APK and libraries and
CarPlay-named system files, written to the app's folder on the stick.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
Settings -> App -> "Install from USB stick" (car build only). It looks
in the top folder of every mounted volume (found from the app's own
folder on it, since the head unit has no file picker) for
NilsMG4-unstable-<version>.apk, takes the newest one newer than the
installed version, checks it against its .sha256 sidecar and the
package name, and installs it with the same PackageInstaller session
as the over-the-air update. The stable build gets a stub and no button.
Tested on the emulator: 2.18.0 installed a 2.18.1 from the storage's
top folder without a confirmation, as the system user, so the OTA
install step should not need one either.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
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
- applicationId com.evsuite.abrp -> com.stinnesbeck.nilsmg4 (the car
build is .unstable); the Java namespace is unchanged. Installs next to
upstream's app rather than over it.
- APK names NilsMG4-*.apk, debug builds included; build-car follows.
- OTA install-result action, cache/download names, User-Agent and log
tags follow the new name.
- About credits fatihdonmezdev / FatihsMG4.
- SPECS.md journals what changed and what is still to do, starting
with pointing the About link and QR code at git.stinnesbeck.com/nils/mg4.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Changes the launcher label, header brand caption, About title, logo
description and OTA download labels. Package ID, APK file names, log
tags and the repository link are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The updater stays in place, but its releases URL now uses the .invalid
TLD (RFC 2606), so checks fail instead of offering upstream's Turkish
builds over this one. Swap in a fork's releases API to re-enable it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Translates string resources, hardcoded layout text and user-facing
strings in Java sources (toasts, dialogs, status labels, upload log).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The refresh button found 2.2.3, downloaded it, and reported
"Kurulum reddedildi (imza uyuşmuyor?)" for a build signed with exactly the same
platform certificate as the one already running. The signature was never the
problem.
install() shelled out to `pm install -r` with a path inside the app's private
cache. pm does not read the file — it hands the path to the package manager
service, which opens it from its own process and SELinux context, where that
directory is not readable. The install failed for a reason nobody could see:
install() returned a bare boolean, and UpdateHook mapped every false to the one
message it had. The pm output that would have named the real error was captured
into a StringBuilder and discarded.
A PackageInstaller session takes no path. We open the archive ourselves, as the
app, and stream it into the session, so no second process ever has to reach our
cache. Approach taken from merthankaraman/DriveHub_Dort, which hit the same wall
on the same head unit.
Consequences worth knowing when reading a future failure:
- Committing a session is asynchronous, so the refresh button can only say an
install started. OtaInstallResultReceiver has the verdict, and surfaces the
package manager's own INSTALL_FAILED_* text rather than guessing.
- STATUS_PENDING_USER_ACTION is not an error. The receiver launches the
confirmation activity the platform supplies.
- The PendingIntent must be MUTABLE or it arrives with no status extras.
install() now returns an enum, so a certificate mismatch, an unparseable or
wrong-package archive, and a failed session each say what they are.
Also checks the archive's package name against ours, which was a genuine gap:
the platform key signs every app on this unit, so a certificate match alone
never meant the APK was this app.
The `pm install -r` exit-code test goes with the shell-out it covered. Nothing
in the session path is exercisable off-device.
REQUEST_INSTALL_PACKAGES and the receiver are declared in the unstable manifest
only — the stable channel has no updater at all, and its merged manifest was
checked to confirm it gains neither.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
OtaUpdater was complete and unreachable: upstream's UpdateHook returned false
and had an empty checkInBackground, so nothing ever called the downloader or
the installer. That seam was suspended for an audit of the published suite;
this fork ships its own builds from its own repository, signed with the
platform key the head unit already trusts, so it is live here.
The release URL and the About link now point at this repository. An APK from
anywhere else is signed with a different key, and OtaUpdater.install checks
the archive's certificate against the running app's before calling
pm install -r — a mismatch is deleted rather than installed.
The refresh button sits outside the tab row, next to About, so it cannot read
as a sixth page. It disables itself for the duration: the check, the download
and the install are one background pass, and a second press would start a
second download of the same APK. The result goes to a toast because the check
is reachable from every page and no one page owns the answer.
The stable flavour gains a matching no-op overload. It still contains no
updater at all — the class is absent from that APK rather than disabled — and
the overload exists only so the shared UI compiles in both flavours.
Co-Authored-By: Claude <noreply@anthropic.com>
A fork of malys/EVABRPUploader (itself a fork of Leon Kernan's ABRP_Uploader)
carrying the changes that make it work on this car. History starts here: the
tree is what matters, and the upstream projects are where the earlier work
belongs.
EVHardware is vendored rather than tracked as a submodule, so one clone
builds.
Telemetry. SOC, battery power and range read as unavailable on SWI69 because
the vendor fallbacks were gated to SWI68 alone, while the property ids they
use are the ones this car publishes — the gates now admit SWI69. Tyre
pressure, the odometer and charge status come from the vendor block too,
since the generic properties for all three are declared here and never
publish. Tyre pressure is confirmed on the vehicle; the other two are not
yet.
Vehicle page. Every signal the car answers, in a table. One that it does not
publish shows a dash: an unimplemented VHAL property returns zero, and
"0 °C" would read as a measurement.
Wi-Fi page. Joins a network the unit already has saved, so a phone hotspot
does not need a trip into Settings on every drive. Tried on the upload tick
and from a button, skipped while already associated. This leans on the unit
being API 28 and the build being platform-signed — getConfiguredNetworks and
enableNetwork are closed to ordinary apps from API 29 — and fails quietly
where that does not hold.
The OTA updater points at this repository. An APK from upstream is signed
with a different key, which Android will not install over a platform-signed
build.
Co-Authored-By: Claude <noreply@anthropic.com>