Files
mg4/app/src/unstable/java/com/evsuite/abrp/UpdateHook.java
T
nilsandClaude Opus 5.5 e52d7f01fc App 2.43.5: update checks log what happens
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
2026-11-01 10:21:15 +01:00

250 lines
12 KiB
Java

package com.evsuite.abrp;
import android.content.Context;
import android.util.Log;
import java.io.File;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Drives {@link OtaUpdater} — UNSTABLE BUILDS ONLY.
*
* Releases are this fork's own car builds, published as pre-releases on the private forge and
* relayed by the car server (OtaUpdater); they are signed with the platform key the head unit
* already trusts.
*
* Installation goes through the platform {@link android.content.pm.PackageInstaller}, not
* {@code pm install -r}: that shelled out with a path inside our private cache, which the
* package manager service cannot read from its own process, and the generic failure was
* reported as a signature mismatch it never was. As in DriveHub_Dort, PackageInstaller is the
* authority for signing-certificate compatibility; the app verifies SHA-256 and package name
* before handing it the archive.
*
* The verified APK stays in Downloads either way. The app first offers it to a PackageInstaller
* session — as the system user it may install without asking — and only if that cannot start
* does it open the file manager there, to tap the file. Committing a session is asynchronous,
* so the message says an install has started, never that one finished;
* {@link OtaInstallResultReceiver} has the verdict.
*/
final class UpdateHook {
private static final String TAG = "EVABRP.Update";
private static final AtomicBoolean checking = new AtomicBoolean();
private UpdateHook() { }
static boolean isSupported() { return true; }
/** Result of one check, for the UI to report. */
interface Listener {
default void onProgress(int percent) { }
void onResult(String message);
}
/** Startup only checks and notifies. Download/install remains an explicit driver action. */
static void checkInBackground(Context context) {
Context app = context.getApplicationContext();
new Thread(() -> {
try {
ServerConfig config = ServerConfig.get(app);
if (!config.isConfigured()) return;
String current = app.getPackageManager()
.getPackageInfo(app.getPackageName(), 0).versionName;
OtaUpdater.Update update = OtaUpdater.check(config, current);
if (update != null && update.hashUrl != null)
new android.os.Handler(android.os.Looper.getMainLooper())
.post(() -> android.widget.Toast.makeText(app,
"Update available: " + update.versionName,
android.widget.Toast.LENGTH_LONG).show());
} catch (Throwable t) {
Log.w(TAG, "Startup update check failed: " + t.getClass().getSimpleName());
UpdateLog.record(app, "Check at start: " + UpdateDiagnosis.failure(t));
}
}, "ota-startup-check").start();
}
/**
* Checks, downloads and installs in one background pass.
*
* The whole sequence runs off the main thread because every step blocks: the release
* API call, the download, and installer staging. [listener] is called back on the main
* thread, so a UI caller can write straight to a view.
*/
static void checkInBackground(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(() -> {
long startedMs = System.currentTimeMillis();
List<String> steps = new ArrayList<>();
step(app, steps, "Check started, app " + BuildConfig.VERSION_NAME);
String message;
try {
for (String network : UpdateNetworkCheck.networks(app)) step(app, steps, network);
ServerConfig config = ServerConfig.get(app);
String host = config.isConfigured() ? OtaUpdater.allowedHost(config) : null;
if (host != null) step(app, steps, UpdateNetworkCheck.lookup(host));
message = runCheck(app, listener, main);
} catch (Throwable t) {
Log.w(TAG, "Update check threw: " + t.getClass().getSimpleName());
message = "Update check failed: " + UpdateDiagnosis.failure(t);
// Is it the server or the connection? The answer to "is my SIM out of data".
step(app, steps, UpdateNetworkCheck.internet());
} finally {
checking.set(false);
}
step(app, steps, "Check: " + message);
String waiting = LogOutbox.status(app);
if (waiting != null) step(app, steps, "Reports: " + waiting);
if (listener != null) {
final String result = message;
main.post(() -> listener.onResult(result));
}
// After the answer: without a connection this waits for the send to fail.
String name = "update-check-" + new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US)
.format(new Date(startedMs)) + ".txt";
// Only the newest waits: repeated checks offline must not push out a recording.
LogOutbox.dropWaiting(app, "update-check-");
SignalRecorder.saveCopies(app, "update_check.txt", name, steps);
}, "ota-check").start();
}
/** One step of a check: on the Logs tab now, and in the report saved when it ends. */
private static void step(Context app, List<String> steps, String what) {
UpdateLog.record(app, what);
steps.add(what);
}
/**
* 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: " + t;
} finally {
checking.set(false);
}
UpdateLog.record(app, "USB: " + message);
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 + ")";
}
UpdateLog.record(app, "USB: found " + best + " (" + best.length() + " bytes)");
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";
UpdateLog.record(app, "USB: SHA-256 matches");
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 {
String current;
try {
current = app.getPackageManager()
.getPackageInfo(app.getPackageName(), 0).versionName;
} catch (Exception e) {
return "Could not read version";
}
ServerConfig config = ServerConfig.get(app);
if (!config.isConfigured()) return "Set up the server first (Server tab): updates come through it";
OtaUpdater.Update update = OtaUpdater.check(config, current);
if (update == null) return "Up to date (" + current + ")";
Log.i(TAG, "Update available: " + update.versionName);
String expectedHash;
try {
expectedHash = OtaUpdater.fetchExpectedSha256(update, config);
} catch (IOException e) {
Log.w(TAG, "OTA hash unavailable: " + e.getClass().getSimpleName());
return "Update refused, no SHA-256 verification: " + UpdateDiagnosis.failure(e);
}
File apk = OtaUpdater.download(update, expectedHash, config, percent -> {
if (listener != null) main.post(() -> listener.onProgress(percent));
});
if (apk == null) return "Download or SHA-256 verification failed ("
+ update.versionName + "): " + OtaUpdater.downloadProblem;
switch (OtaUpdater.install(app, apk)) {
case SESSION_STARTED:
return "Installing " + update.versionName + "… the app restarts when it is done";
case INSTALL_UI_STARTED:
return "Confirm the installation of " + update.versionName + " on the screen";
case UNREADABLE_ARCHIVE:
return "Update refused: the downloaded file is not this app";
default:
break;
}
if (!OtaUpdater.openDownloads(app))
return update.versionName + " is in Downloads; open it with the Files app to install";
return update.versionName + ".apk is in Downloads — tap the file to install";
}
}