App 2.18.0: install from a USB stick

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
This commit is contained in:
2026-10-18 20:59:45 +02:00
co-authored by Claude Opus 5.5
parent 0fd024cc2e
commit 69d284f81b
11 changed files with 209 additions and 4 deletions
@@ -94,6 +94,81 @@ final class UpdateHook {
}, "ota-check").start();
}
/**
* Installs the newest car build from the top folder of a USB stick (UsbUpdate), with the
* same checks as the over-the-air update except the server. Background thread; [listener]
* is called on the main thread.
*/
static void installFromUsbInBackground(Context context, Listener listener) {
Context app = context.getApplicationContext();
android.os.Handler main = new android.os.Handler(android.os.Looper.getMainLooper());
if (!checking.compareAndSet(false, true)) {
if (listener != null) main.post(() -> listener.onResult("Update check already in progress"));
return;
}
new Thread(() -> {
String message;
try {
message = runUsbInstall(app);
} catch (Throwable t) {
Log.w(TAG, "USB install threw", t);
message = "Installing from USB failed";
} finally {
checking.set(false);
}
if (listener != null) {
final String result = message;
main.post(() -> listener.onResult(result));
}
}, "usb-install").start();
}
private static String runUsbInstall(Context app) throws Exception {
String current = app.getPackageManager().getPackageInfo(app.getPackageName(), 0).versionName;
File best = null;
String bestName = null;
for (File dir : app.getExternalFilesDirs(null)) {
File root = UsbUpdate.volumeRoot(dir);
if (root == null) continue;
String[] names = root.list();
String name = UsbUpdate.pickNewest(names, bestName == null ? current
: OtaUpdater.versionFromAssetName(bestName));
if (name != null) {
best = new File(root, name);
bestName = name;
}
}
if (best == null) {
return "No newer NilsMG4-unstable-<version>.apk on a USB stick (installed: " + current + ")";
}
File sidecar = new File(best.getPath() + ".sha256");
String expected = sidecar.isFile() && sidecar.length() < 16 * 1024
? OtaUpdater.parseExpectedSha256(new String(java.nio.file.Files.readAllBytes(sidecar.toPath()),
java.nio.charset.StandardCharsets.UTF_8), best.getName())
: null;
if (expected == null) return "Not installed: " + best.getName() + ".sha256 is missing or unreadable";
java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256");
try (java.io.InputStream in = new java.io.FileInputStream(best)) {
byte[] buffer = new byte[64 * 1024];
int count;
while ((count = in.read(buffer)) != -1) digest.update(buffer, 0, count);
}
StringBuilder hex = new StringBuilder();
for (byte b : digest.digest()) hex.append(String.format(java.util.Locale.US, "%02x", b));
if (!hex.toString().equalsIgnoreCase(expected)) return "Not installed: " + best.getName() + " does not match its SHA-256";
String version = OtaUpdater.versionFromAssetName(best.getName());
switch (OtaUpdater.install(app, best)) {
case SESSION_STARTED:
return "Installing " + version + " from USB… the app restarts when it is done";
case INSTALL_UI_STARTED:
return "Confirm the installation of " + version + " on the screen";
case UNREADABLE_ARCHIVE:
return "Not installed: " + best.getName() + " is not this app";
default:
return "Not installed: the installer could not start";
}
}
/** The blocking part. Returns a line describing what happened, for the UI. */
private static String runCheck(Context app, Listener listener,
android.os.Handler main) throws IOException {