From 51beea5ed82f75b094ddd17f9e6305399bbd13f5 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Fri, 23 Oct 2026 21:15:44 +0200 Subject: [PATCH] App 2.19.3: update log on the Logs tab Every update and install step, with the platform's own result, kept across the restart an install causes, so a failed install on the car can be read off the screen. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6 --- AGENTS.md | 4 +- SPECS.md | 10 +++ app/build.gradle | 4 +- .../com/evsuite/abrp/AbrpUploadService.java | 1 + .../java/com/evsuite/abrp/MainActivity.java | 13 +++- .../main/java/com/evsuite/abrp/UpdateLog.java | 78 +++++++++++++++++++ app/src/main/res/values/strings.xml | 1 + .../java/com/evsuite/abrp/UpdateLogTest.java | 25 ++++++ .../abrp/OtaInstallResultReceiver.java | 2 + .../java/com/evsuite/abrp/OtaUpdater.java | 7 ++ .../java/com/evsuite/abrp/UpdateHook.java | 9 ++- 11 files changed, 147 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/com/evsuite/abrp/UpdateLog.java create mode 100644 app/src/test/java/com/evsuite/abrp/UpdateLogTest.java diff --git a/AGENTS.md b/AGENTS.md index 2765fad..f5bc3f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,7 +114,9 @@ cd server && go vet ./... && go test ./... && govulncheck ./... car's platform key, so the car build installs as the system user. `adb shell wm size 1778x640` gives the car's app window (taps then use those coordinates; screenshots stay 1920×720). It has no car data (every value is "—"). While the app is open, `uiautomator dump` never sees an idle - screen; use screenshots. + screen; use screenshots. A USB stick: a FAT image (`mkfs.vfat`, files in with `mcopy`) passed as + `-sdcard ` mounts as a removable volume like the car's stick; `adb push` to + `/storage//` changes it while running. - **After each change:** a `SPECS.md` done-log entry, docs that the change affects, explicit staging, commit, push. diff --git a/SPECS.md b/SPECS.md index a6d35d4..2767da5 100644 --- a/SPECS.md +++ b/SPECS.md @@ -118,6 +118,16 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does * confirmed car facts, app structure, UI conventions, build/test/ship, documents. It replaces upstream's EVABRPUploader text. The test emulator is now in the flake: `nix run .#car-emulator` (Android 9 AOSP at 1920×720, 160 dpi; the car build installs as the system user — checked). +- 2026-10-23 — App 2.19.3: an update log on the Logs tab, above the uploads. A USB install on the + car ended in an installer message about a broken package, while the same APK installed through + the Bluetooth-settings route; the message was only a toast and logcat, so its exact words are + lost. Now every step is kept (last 10, survives the restart an install causes): file found with + its size, SHA-256 match, the package manager refusing to parse, a failed installer session with + its exception, bytes handed to the installer, the platform's result in its own words, and "Now + running " on the first start of a new version. On the emulator, with a FAT image as a + removable volume (like the car's stick), the USB install worked silently and the log showed the + whole sequence; a truncated APK with a matching hash was refused at the package check, logged. + The car's failure is not reproduced or explained yet. - 2026-10-23 — App 2.19.2: Settings → "Full screen for CarPlay (experiment)". The Logs tab showed CarPlay on the car as `com.allgo.carplay.service/.CarPlayActivity`, an ordinary activity. The switch adds that package to `immersive.full` in Android 9's `policy_control` global setting diff --git a/app/build.gradle b/app/build.gradle index d141da1..9913f79 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 101 - versionName "2.19.2" + versionCode 102 + versionName "2.19.3" // Nothing about the server ships in the APK: its URL and ingest token are entered in // the app's Server tab (ServerConfig), and the database is only reachable by the server. diff --git a/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java b/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java index 78da357..6f0f086 100644 --- a/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java +++ b/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java @@ -81,6 +81,7 @@ public class AbrpUploadService extends Service { super.onCreate(); prefs = getSharedPreferences("abrp_prefs", MODE_PRIVATE); uploadLog.clear(); + UpdateLog.noteVersion(this, BuildConfig.VERSION_NAME); scheduler = Executors.newSingleThreadScheduledExecutor(r -> { Thread t = new Thread(r, "abrp-upload"); t.setDaemon(false); diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index 047f272..7b25a6b 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -285,6 +285,7 @@ public class MainActivity extends AppCompatActivity { panes = new View[] { vehiclePane, consumptionPane, chargingPane, servicePane, serverPane, settingsPane, logPane }; + UpdateLog.noteVersion(this, BuildConfig.VERSION_NAME); bindVehiclePane(); bindConsumptionPane(); bindChargingPane(); @@ -1792,13 +1793,21 @@ public class MainActivity extends AppCompatActivity { private void refreshCallLog() { java.util.List entries = AbrpUploadService.log().recent(); + // Update steps first: after a failed install, they are what the driver came here for. + java.util.List updates = UpdateLog.lines(this); + StringBuilder sb = new StringBuilder(); + if (!updates.isEmpty()) { + sb.append(getString(R.string.update_log_title)); + for (String line : updates) sb.append('\n').append(line); + sb.append('\n'); + } if (entries.isEmpty()) { - callLogText.setText(R.string.call_log_empty); + if (sb.length() == 0) callLogText.setText(R.string.call_log_empty); + else callLogText.setText(sb.append('\n').append(getString(R.string.call_log_empty)).toString()); return; } java.text.SimpleDateFormat format = new java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.US); - StringBuilder sb = new StringBuilder(); for (UploadLog.Entry entry : entries) { if (sb.length() > 0) sb.append('\n'); sb.append(entry.success ? "OK " : "ERR ") diff --git a/app/src/main/java/com/evsuite/abrp/UpdateLog.java b/app/src/main/java/com/evsuite/abrp/UpdateLog.java new file mode 100644 index 0000000..b7cf564 --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/UpdateLog.java @@ -0,0 +1,78 @@ +package com.evsuite.abrp; + +import android.content.Context; +import android.content.SharedPreferences; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +/** + * The last {@link #MAX_LINES} steps of updating the app — checks, USB installs, and the + * platform's own words about an install — shown on the Logs tab. + * + * Kept apart from {@link UploadLog}, whose failures decide the service's health, and stored in + * preferences rather than memory: a successful install restarts the process, and the car has + * no adb, so this is the only place the driver can read why an install failed. It never holds + * a URL or a credential, only what happened. + */ +final class UpdateLog { + + static final int MAX_LINES = 10; + private static final String PREFS = "update_log"; + private static final String KEY = "lines"; + private static final String KEY_VERSION = "version"; + + private UpdateLog() {} + + static void record(Context context, String what) { + if (context == null || what == null) return; + String line = new SimpleDateFormat("dd.MM. HH:mm:ss", Locale.US).format(new Date()) + " " + what; + synchronized (UpdateLog.class) { + SharedPreferences prefs = context.getApplicationContext() + .getSharedPreferences(PREFS, Context.MODE_PRIVATE); + List lines = append(split(prefs.getString(KEY, null)), line, MAX_LINES); + prefs.edit().putString(KEY, String.join("\n", lines)).commit(); + } + } + + /** + * Records the version the app now runs, once per version: after an install, the line that + * says it worked, since the installing process does not live to say so. + */ + static void noteVersion(Context context, String version) { + if (context == null || version == null) return; + SharedPreferences prefs = context.getApplicationContext() + .getSharedPreferences(PREFS, Context.MODE_PRIVATE); + String last = prefs.getString(KEY_VERSION, null); + if (version.equals(last)) return; + prefs.edit().putString(KEY_VERSION, version).commit(); + if (last != null) record(context, "Now running " + version + " (was " + last + ")"); + } + + /** Newest first. */ + static List lines(Context context) { + SharedPreferences prefs = context.getApplicationContext() + .getSharedPreferences(PREFS, Context.MODE_PRIVATE); + return split(prefs.getString(KEY, null)); + } + + /** {@code line} in front, newlines flattened, at most {@code max} lines kept. */ + static List append(List newestFirst, String line, int max) { + List out = new ArrayList<>(); + out.add(line.replace('\n', ' ').replace('\r', ' ')); + for (String l : newestFirst) { + if (out.size() >= max) break; + out.add(l); + } + return out; + } + + private static List split(String stored) { + if (stored == null || stored.isEmpty()) return new ArrayList<>(); + return new ArrayList<>(Arrays.asList(stored.split("\n"))); + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ab7d81b..3030f4c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -171,6 +171,7 @@ Recent uploads No upload attempted yet. + Updates, newest first: Upload now Foreground apps For the CarPlay full-screen test: open CarPlay, come back here, read its name off the list. Reads only. diff --git a/app/src/test/java/com/evsuite/abrp/UpdateLogTest.java b/app/src/test/java/com/evsuite/abrp/UpdateLogTest.java new file mode 100644 index 0000000..749287c --- /dev/null +++ b/app/src/test/java/com/evsuite/abrp/UpdateLogTest.java @@ -0,0 +1,25 @@ +package com.evsuite.abrp; + +import static org.junit.Assert.assertEquals; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.Test; + +/** The update log's line handling — pure JVM. */ +public class UpdateLogTest { + + @Test + public void newestComesFirstAndTheOldestFallsOff() { + List lines = UpdateLog.append(Arrays.asList("b", "a"), "c", 2); + assertEquals(Arrays.asList("c", "b"), lines); + } + + @Test + public void aMultiLinePlatformMessageStaysOneLine() { + List lines = UpdateLog.append(Collections.emptyList(), "failed:\nINSTALL_X\r", 10); + assertEquals(Collections.singletonList("failed: INSTALL_X "), lines); + } +} diff --git a/app/src/unstable/java/com/evsuite/abrp/OtaInstallResultReceiver.java b/app/src/unstable/java/com/evsuite/abrp/OtaInstallResultReceiver.java index 2aabac1..53f4b05 100644 --- a/app/src/unstable/java/com/evsuite/abrp/OtaInstallResultReceiver.java +++ b/app/src/unstable/java/com/evsuite/abrp/OtaInstallResultReceiver.java @@ -29,6 +29,8 @@ public final class OtaInstallResultReceiver extends BroadcastReceiver { int status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE); String message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE); Log.i(TAG, "OTA install result: status=" + status + " msg=" + message); + UpdateLog.record(context, "Install result: status " + status + + (message != null ? ", " + message : "")); switch (status) { case PackageInstaller.STATUS_PENDING_USER_ACTION: diff --git a/app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java b/app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java index 528b61f..43cc9d4 100644 --- a/app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java +++ b/app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java @@ -468,11 +468,14 @@ final class OtaUpdater { return InstallResult.SESSION_STARTED; } catch (Throwable t) { Log.w(TAG, "OTA session install failed; trying system install UI", t); + UpdateLog.record(context, "Installer session failed: " + t); try { launchInstallerActivity(context, apk); + UpdateLog.record(context, "Opened the system installer instead"); return InstallResult.INSTALL_UI_STARTED; } catch (Throwable fallback) { Log.w(TAG, "OTA install UI failed", fallback); + UpdateLog.record(context, "System installer failed too: " + fallback); return InstallResult.SESSION_FAILED; } } @@ -490,6 +493,8 @@ final class OtaUpdater { .getPackageArchiveInfo(apk.getAbsolutePath(), 0); if (info == null || info.packageName == null) { Log.w(TAG, "OTA archive cannot be parsed by the package manager"); + UpdateLog.record(context, "Package manager cannot parse " + apk.getName() + + " (" + apk.length() + " bytes)"); return false; } if (!context.getPackageName().equals(info.packageName)) { @@ -533,6 +538,8 @@ final class OtaUpdater { // confirm, EXTRA_INTENT. An immutable PendingIntent would arrive empty. PendingIntent pending = PendingIntent.getBroadcast(context, sessionId, callback, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE); + // Written before commit: a successful install replaces this process at once. + UpdateLog.record(context, "Handed " + apk.length() + " bytes to the installer"); session.commit(pending.getIntentSender()); committed = true; Log.i(TAG, "OTA session " + sessionId + " committed (" + apk.length() + " bytes)"); diff --git a/app/src/unstable/java/com/evsuite/abrp/UpdateHook.java b/app/src/unstable/java/com/evsuite/abrp/UpdateHook.java index f4ba4b4..92c808f 100644 --- a/app/src/unstable/java/com/evsuite/abrp/UpdateHook.java +++ b/app/src/unstable/java/com/evsuite/abrp/UpdateHook.java @@ -83,10 +83,12 @@ final class UpdateHook { message = runCheck(app, listener, main); } catch (Throwable t) { Log.w(TAG, "Update check threw", t); - message = "Update check failed"; + // The class only: a network error's text can carry the server's address. + message = "Update check failed (" + t.getClass().getSimpleName() + ")"; } finally { checking.set(false); } + UpdateLog.record(app, "Check: " + message); if (listener != null) { final String result = message; main.post(() -> listener.onResult(result)); @@ -112,10 +114,11 @@ final class UpdateHook { message = runUsbInstall(app); } catch (Throwable t) { Log.w(TAG, "USB install threw", t); - message = "Installing from USB failed"; + 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)); @@ -141,6 +144,7 @@ final class UpdateHook { 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()), @@ -156,6 +160,7 @@ final class UpdateHook { 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: