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
250 lines
12 KiB
Java
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";
|
|
}
|
|
}
|