diff --git a/OTA.md b/OTA.md index cb2de3e..6a0c41f 100644 --- a/OTA.md +++ b/OTA.md @@ -62,6 +62,17 @@ At start the app only checks, and shows "Update available" when there is one. It downloads or installs on its own, and the upload service never checks: installing restarts the app, which must not happen mid-drive on its own. +## Installing from a USB stick + +For development, without the forge: copy `NilsMG4-unstable-.apk` **and** its `.sha256` +from `build/car/` to the top folder of a USB stick, plug it in, and press **Install from USB +stick** on the Settings tab. The app takes the newest such file that is newer than the installed +version, checks it against the `.sha256`, checks the package, and installs it with the same +installer session as an over-the-air update. The car build only; the stable build has no button. + +On the Android 9 emulator (car build, system user) this installed without asking, from the +storage's top folder; on the car the stick's top folder is used the same way. + ## When it says something else | Message | Meaning | diff --git a/SPECS.md b/SPECS.md index 73bd8c5..3234c6c 100644 --- a/SPECS.md +++ b/SPECS.md @@ -112,6 +112,11 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does * INGEST_TOKEN) into git and pushed it. Commit amended and force-pushed; both values regenerated in the now gitignored `k8s/secrets.yaml` (since moved to `k8s/secrets/secrets.yaml`, outside `kubectl apply -f k8s/`). Filled-in secrets live only there from now on. +- 2026-10-18 — App 2.18.0: Settings → App → "Install from USB stick" (car build only): the newest + `NilsMG4-unstable-.apk` newer than the installed one in a stick's top folder, checked + against its `.sha256` and the package, installed with the OTA installer session. Tested on the + emulator: 2.18.0 installed a 2.18.1 from the storage's top folder silently, as the system user — + so the OTA install step should not need a confirmation either. - 2026-10-18 — App 2.17.5: Server tab fits the car's window. The "Your private connection" card is gone (its text was out of date); Import from file sits with Save and Test in one row; the installation ID is shown as text, not edited, since it is derived from the head unit (a config diff --git a/app/build.gradle b/app/build.gradle index 396a0e6..1e21cdd 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 94 - versionName "2.17.5" + versionCode 95 + versionName "2.18.0" // 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/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index afffebc..4034672 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -265,6 +265,10 @@ public class MainActivity extends AppCompatActivity { .setText(getString(R.string.settings_version, BuildConfig.VERSION_NAME)); settingsPane.findViewById(R.id.settings_about_button).setOnClickListener(v -> showAbout()); settingsPane.findViewById(R.id.settings_update_button).setOnClickListener(v -> checkForUpdate(v)); + View usbButton = settingsPane.findViewById(R.id.settings_usb_button); + usbButton.setOnClickListener(v -> installFromUsb(v)); + // The stable build installs nothing itself, so it has no use for the button. + if (!UpdateHook.isSupported()) usbButton.setVisibility(View.GONE); // Unstable builds check for a newer pre-release; the stable flavor's UpdateHook is // a no-op and does not even contain the updater. @@ -408,13 +412,24 @@ public class MainActivity extends AppCompatActivity { * SHA-256 verified; the final result remains visible until dismissed. */ private void checkForUpdate(View button) { + runUpdate(button, R.string.update_checking, listener -> UpdateHook.checkInBackground(this, listener)); + } + + /** Installs the newest car build from the top folder of a USB stick (unstable builds). */ + private void installFromUsb(View button) { + runUpdate(button, R.string.update_usb_looking, listener -> UpdateHook.installFromUsbInBackground(this, listener)); + } + + /** The update dialog: progress while [start] runs, then its result. */ + private void runUpdate(View button, int startText, + java.util.function.Consumer start) { button.setEnabled(false); android.widget.LinearLayout panel = new android.widget.LinearLayout(this); panel.setOrientation(android.widget.LinearLayout.VERTICAL); int padding = (int) (24 * getResources().getDisplayMetrics().density); panel.setPadding(padding, padding, padding, padding); TextView status = new TextView(this); - status.setText(R.string.update_checking); + status.setText(startText); status.setTextAppearance(R.style.Text_EV_Body); android.widget.ProgressBar progress = new android.widget.ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal); @@ -432,7 +447,7 @@ public class MainActivity extends AppCompatActivity { .setCancelable(false) .create(); showKeepingFullScreen(dialog); - UpdateHook.checkInBackground(this, new UpdateHook.Listener() { + start.accept(new UpdateHook.Listener() { @Override public void onProgress(int percent) { if (percent < 0) { progress.setIndeterminate(true); diff --git a/app/src/main/res/layout-w1000dp/pane_settings.xml b/app/src/main/res/layout-w1000dp/pane_settings.xml index 2c54b3c..92822d7 100644 --- a/app/src/main/res/layout-w1000dp/pane_settings.xml +++ b/app/src/main/res/layout-w1000dp/pane_settings.xml @@ -48,6 +48,7 @@ + diff --git a/app/src/main/res/layout/pane_settings.xml b/app/src/main/res/layout/pane_settings.xml index eea34d5..437f3e5 100644 --- a/app/src/main/res/layout/pane_settings.xml +++ b/app/src/main/res/layout/pane_settings.xml @@ -42,6 +42,7 @@ + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0bf61cb..5ea54dc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -182,6 +182,8 @@ Installation failed: %1$s. The APK is in Downloads. Check for updates Checking for a new OTA version… + Looking on the USB stick… + Install from USB stick Software update Check for updates About Nils\' MG4 diff --git a/app/src/stable/java/com/evsuite/abrp/UpdateHook.java b/app/src/stable/java/com/evsuite/abrp/UpdateHook.java index c6745a2..67824a6 100644 --- a/app/src/stable/java/com/evsuite/abrp/UpdateHook.java +++ b/app/src/stable/java/com/evsuite/abrp/UpdateHook.java @@ -30,5 +30,10 @@ final class UpdateHook { if (listener != null) listener.onResult("This build has no automatic updates"); } + /** Answers immediately that this channel does not install updates. */ + static void installFromUsbInBackground(Context context, Listener listener) { + if (listener != null) listener.onResult("This build does not install updates"); + } + static boolean isSupported() { return false; } } diff --git a/app/src/testUnstable/java/com/evsuite/abrp/UsbUpdateTest.java b/app/src/testUnstable/java/com/evsuite/abrp/UsbUpdateTest.java new file mode 100644 index 0000000..abef740 --- /dev/null +++ b/app/src/testUnstable/java/com/evsuite/abrp/UsbUpdateTest.java @@ -0,0 +1,38 @@ +package com.evsuite.abrp; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import java.io.File; + +import org.junit.Test; + +/** Finding a car build on a USB stick — pure JVM. */ +public class UsbUpdateTest { + + @Test + public void theStickRootIsFourLevelsAboveTheAppFolder() { + assertEquals(new File("/storage/1234-ABCD"), UsbUpdate.volumeRoot( + new File("/storage/1234-ABCD/Android/data/com.stinnesbeck.nilsmg4.unstable/files"))); + assertEquals(new File("/storage/emulated/0"), UsbUpdate.volumeRoot( + new File("/storage/emulated/0/Android/data/com.stinnesbeck.nilsmg4.unstable/files"))); + assertNull(UsbUpdate.volumeRoot(new File("/data/user/0/com.stinnesbeck.nilsmg4.unstable/files"))); + assertNull(UsbUpdate.volumeRoot(null)); + } + + @Test + public void theNewestNewerCarBuildIsPicked() { + String[] names = {"NilsMG4-unstable-2.17.4.apk", "NilsMG4-unstable-2.18.0.apk", "NilsMG4-unstable-2.18.0.apk.sha256", + "NilsMG4-unstable-2.17.10.apk", "NilsMG4-stable-9.0.0.apk", "FatihsMG4-unstable-9.9.9.apk", "notes.txt"}; + assertEquals("NilsMG4-unstable-2.18.0.apk", UsbUpdate.pickNewest(names, "2.17.5-unstable")); + assertEquals("NilsMG4-unstable-2.17.10.apk", UsbUpdate.pickNewest( + new String[]{"NilsMG4-unstable-2.17.4.apk", "NilsMG4-unstable-2.17.10.apk"}, "2.17.5-unstable")); + } + + @Test + public void nothingNewerMeansNothing() { + assertNull(UsbUpdate.pickNewest(new String[]{"NilsMG4-unstable-2.17.5.apk"}, "2.17.5-unstable")); + assertNull(UsbUpdate.pickNewest(new String[0], "2.17.5-unstable")); + assertNull(UsbUpdate.pickNewest(null, "2.17.5-unstable")); + } +} diff --git a/app/src/unstable/java/com/evsuite/abrp/UpdateHook.java b/app/src/unstable/java/com/evsuite/abrp/UpdateHook.java index e57833c..f4ba4b4 100644 --- a/app/src/unstable/java/com/evsuite/abrp/UpdateHook.java +++ b/app/src/unstable/java/com/evsuite/abrp/UpdateHook.java @@ -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-.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 { diff --git a/app/src/unstable/java/com/evsuite/abrp/UsbUpdate.java b/app/src/unstable/java/com/evsuite/abrp/UsbUpdate.java new file mode 100644 index 0000000..5697180 --- /dev/null +++ b/app/src/unstable/java/com/evsuite/abrp/UsbUpdate.java @@ -0,0 +1,52 @@ +package com.evsuite.abrp; + +import java.io.File; +import java.util.Locale; + +/** + * Installing a car build from a USB stick — UNSTABLE BUILDS ONLY. For development: copy + * {@code NilsMG4-unstable-.apk} and its {@code .sha256} (both from build-car) to the top + * folder of a stick, plug it in, press the button on the Settings tab. + * + * The same checks as the over-the-air update apply, minus the server: the newest such file that + * is newer than the installed version, a matching SHA-256 sidecar, this app's package, and the + * installer's own signature check. The pure parts are here, unit-tested; UpdateHook does the IO. + */ +final class UsbUpdate { + + private UsbUpdate() { } + + /** + * The top folder of the volume an app-specific folder lives on: + * /storage/1234-ABCD/Android/data//files → /storage/1234-ABCD. Null when the path + * does not have that shape. This is how a stick is found without a file picker, which the + * head unit does not have. + */ + static File volumeRoot(File appFilesDir) { + if (appFilesDir == null) return null; + File data = appFilesDir.getParentFile() == null ? null : appFilesDir.getParentFile().getParentFile(); + File android = data == null ? null : data.getParentFile(); + if (!"files".equals(appFilesDir.getName()) || data == null || !"data".equals(data.getName()) + || android == null || !"Android".equals(android.getName())) return null; + return android.getParentFile(); + } + + /** + * The car-build APK to install among [names]: the highest version that is newer than + * [currentVersion], or null. Only names build-car writes count. + */ + static String pickNewest(String[] names, String currentVersion) { + String best = null, bestVersion = null; + if (names == null) return null; + for (String name : names) { + if (name == null || !name.toLowerCase(Locale.US).startsWith("nilsmg4-unstable-")) continue; + String version = OtaUpdater.versionFromAssetName(name); + if (version == null || !OtaUpdater.isNewer(version, currentVersion)) continue; + if (bestVersion == null || OtaUpdater.isNewer(version, bestVersion)) { + best = name; + bestVersion = version; + } + } + return best; + } +}