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 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 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-.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"; } }