From c3c47f51595fe10a77df489a62fc9013b2f747d4 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Tue, 27 Oct 2026 00:18:31 +0100 Subject: [PATCH] App 2.40.1: an update always reaches the installer screen The car refuses the installer session, and only a refusal thrown at once opened the installer screen; one that came back through the session's result ended in a toast, so the update downloaded and then nothing seemed to happen. Now any result but success or the driver's cancel opens the installer screen with the verified APK, as does a confirmation that cannot be shown, and a session silent for 20 s is abandoned for it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6 --- OTA.md | 7 ++ SPECS.md | 12 ++++ app/build.gradle | 4 +- .../abrp/OtaInstallResultReceiver.java | 54 +++++++++++++-- .../java/com/evsuite/abrp/OtaUpdater.java | 65 +++++++++++++++---- 5 files changed, 121 insertions(+), 21 deletions(-) diff --git a/OTA.md b/OTA.md index 6db7e9f..ed89038 100644 --- a/OTA.md +++ b/OTA.md @@ -110,3 +110,10 @@ That screen needs a `file://` URI on the car build: a read grant on our FileProv when it comes from the system user (`not exported from UID 1000`), and the installer reports the unreadable file as "There was a problem parsing the package". On the emulator the session still installs silently. + +The refusal does not always come at once: the platform may also answer a moment later, through +the session's result (`OtaInstallResultReceiver`). Since 2.40.1 that answer opens the installer +screen too, as does a confirmation the platform asks for but cannot show; only **Cancel** on that +screen ends the update there. And if the session says nothing at all for 20 s, the app abandons +it and opens the installer screen. Before 2.40.1 a late refusal ended in a short message only: +the update downloaded and then nothing seemed to happen. diff --git a/SPECS.md b/SPECS.md index 095d613..56aaf39 100644 --- a/SPECS.md +++ b/SPECS.md @@ -138,6 +138,18 @@ 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-27 — App 2.40.1: **an update that downloaded but never reached the installer** (owner: + "sometimes … downloads the update but then doesn't start the installer"). The car refuses the + installer session ("Not allowed to install non-system apps on internal storage"); only a + refusal thrown at once led to the installer screen. A refusal that came back through the + session's result was a toast and nothing else. Now any answer but success, the confirmation and + the driver's own cancel opens the installer screen with the verified APK (path and session id + ride on the result intent; `goAsync` for the copy), a confirmation that cannot be shown does + too, and a session silent for 20 s is abandoned for it. Emulator, with a throwaway build whose + session bytes were damaged (refused later, status 4 `INSTALL_PARSE_FAILED_NOT_APK`) and which + read the update from Downloads: the installer screen opened by itself, Install, now 2.40.1; the + update log shows every step. The 20 s watch is untested (no way to make a session stay silent). + Not tried on the car. OTA.md updated. - 2026-10-27 — App 2.40.0 published (`car-2.40.0`). - 2026-10-27 — App 2.40.0: **Grid with map straight from above, full height** (owner: 2.39.2 "does not look right"; wanted the map over the whole height and fully top-down). The map is diff --git a/app/build.gradle b/app/build.gradle index d7a75cd..501827a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 145 - versionName "2.40.0" + versionCode 146 + versionName "2.40.1" // 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/unstable/java/com/evsuite/abrp/OtaInstallResultReceiver.java b/app/src/unstable/java/com/evsuite/abrp/OtaInstallResultReceiver.java index 53f4b05..3daf1e8 100644 --- a/app/src/unstable/java/com/evsuite/abrp/OtaInstallResultReceiver.java +++ b/app/src/unstable/java/com/evsuite/abrp/OtaInstallResultReceiver.java @@ -16,10 +16,17 @@ import android.widget.Toast; * STATUS_PENDING_USER_ACTION is the case worth understanding: the platform is saying it will * install, but wants the driver to confirm. That happens when the app is not granted a silent * install, and the only correct response is to launch the confirmation activity it hands us. + * + * Any other failure — on the car the session is refused, "Not allowed to install non-system + * apps on internal storage", and that answer can come here rather than as an exception — opens + * Android's installer screen with the verified APK, as {@link OtaUpdater#install} does for an + * immediate refusal. Only a cancel by the driver ends there. */ public final class OtaInstallResultReceiver extends BroadcastReceiver { static final String ACTION_INSTALL_RESULT = "com.stinnesbeck.nilsmg4.OTA_INSTALL_RESULT"; + static final String EXTRA_APK = "com.stinnesbeck.nilsmg4.extra.APK"; + static final String EXTRA_SESSION = "com.stinnesbeck.nilsmg4.extra.SESSION"; private static final String TAG = "EVABRP.Update"; @@ -31,12 +38,16 @@ public final class OtaInstallResultReceiver extends BroadcastReceiver { Log.i(TAG, "OTA install result: status=" + status + " msg=" + message); UpdateLog.record(context, "Install result: status " + status + (message != null ? ", " + message : "")); + int session = intent.getIntExtra(EXTRA_SESSION, -1); + if (session >= 0) OtaUpdater.noteAnswer(session); + String apkPath = intent.getStringExtra(EXTRA_APK); switch (status) { case PackageInstaller.STATUS_PENDING_USER_ACTION: Intent confirm = intent.getParcelableExtra(Intent.EXTRA_INTENT); if (confirm == null) { - toast(context, context.getString(R.string.ota_install_failed, "no confirm intent")); + UpdateLog.record(context, "No confirmation screen offered"); + openInstallerScreen(context, apkPath, "no confirm intent"); return; } confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); @@ -44,23 +55,52 @@ public final class OtaInstallResultReceiver extends BroadcastReceiver { context.startActivity(confirm); } catch (Throwable t) { Log.e(TAG, "OTA confirm UI failed: " + t.getMessage()); - toast(context, context.getString(R.string.ota_install_failed, - t.getClass().getSimpleName())); + UpdateLog.record(context, "Confirmation screen failed: " + t.getClass().getSimpleName()); + openInstallerScreen(context, apkPath, t.getClass().getSimpleName()); } break; case PackageInstaller.STATUS_SUCCESS: toast(context, context.getString(R.string.ota_install_success)); break; + case PackageInstaller.STATUS_FAILURE_ABORTED: + // The driver cancelled: nothing opens again. + toast(context, context.getString(R.string.ota_install_failed, + message != null ? message : "cancelled")); + break; default: // The status message is the package manager's own words — INSTALL_FAILED_* - // and friends. Surfaced verbatim, because guessing at it is what made the - // previous failure unreadable. - toast(context, context.getString(R.string.ota_install_failed, - message != null ? message : "status " + status)); + // and friends — kept verbatim in the update log, because guessing at it is what + // made an earlier failure unreadable. Then the installer screen, which the car + // allows where it refuses the session. + openInstallerScreen(context, apkPath, message != null ? message : "status " + status); break; } } + /** + * Opens Android's installer screen for the APK at [apkPath] off the main thread (it is + * copied to Downloads first); a toast with [why] when there is none or it cannot open. + */ + private void openInstallerScreen(Context context, String apkPath, String why) { + java.io.File apk = apkPath == null ? null : new java.io.File(apkPath); + if (apk == null || !apk.isFile()) { + toast(context, context.getString(R.string.ota_install_failed, why)); + return; + } + PendingResult pending = goAsync(); + Context app = context.getApplicationContext(); + new Thread(() -> { + try { + if (!OtaUpdater.openInstallerScreen(app, apk)) { + new android.os.Handler(android.os.Looper.getMainLooper()).post(() -> + toast(app, app.getString(R.string.ota_install_failed, why))); + } + } finally { + pending.finish(); + } + }, "ota-fallback").start(); + } + private static void toast(Context context, String text) { Toast.makeText(context.getApplicationContext(), text, Toast.LENGTH_LONG).show(); } diff --git a/app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java b/app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java index 5cee62e..2510bbe 100644 --- a/app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java +++ b/app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java @@ -464,23 +464,59 @@ final class OtaUpdater { static InstallResult install(Context context, File apk) { if (!archiveIsThisPackage(context, apk)) return InstallResult.UNREADABLE_ARCHIVE; try { - commitSession(context, apk); + int sessionId = commitSession(context, apk); + watchForAnswer(context, sessionId, apk); 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; - } + return openInstallerScreen(context, apk) ? InstallResult.INSTALL_UI_STARTED : InstallResult.SESSION_FAILED; } } + /** The installer screen for [apk], as the fallback; false if even that could not open. */ + static boolean openInstallerScreen(Context context, File apk) { + try { + launchInstallerActivity(context, apk); + UpdateLog.record(context, "Opened the system installer instead"); + return true; + } catch (Throwable fallback) { + Log.w(TAG, "OTA install UI failed", fallback); + UpdateLog.record(context, "System installer failed too: " + fallback); + return false; + } + } + + /** How long a committed session may stay silent before the installer screen is opened. */ + private static final long ANSWER_WAIT_MS = 20_000L; + /** Sessions the platform has answered (any status), so the watch knows to stand down. */ + private static final java.util.Set answered = + java.util.Collections.synchronizedSet(new java.util.HashSet<>()); + + /** The platform said something about [sessionId]; called by {@link OtaInstallResultReceiver}. */ + static void noteAnswer(int sessionId) { + answered.add(sessionId); + } + + /** + * A committed session normally answers within a second or two: installed (the app + * restarts), refused, or "confirm on screen". If it says nothing at all, the update would + * end silently after its download, so the installer screen is opened instead. + */ + private static void watchForAnswer(Context context, int sessionId, File apk) { + Context app = context.getApplicationContext(); + new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(() -> { + if (answered.contains(sessionId)) return; + UpdateLog.record(app, "No answer from the installer after " + ANSWER_WAIT_MS / 1000 + " s"); + try { + app.getPackageManager().getPackageInstaller().abandonSession(sessionId); + } catch (Throwable ignored) { + // already gone + } + new Thread(() -> openInstallerScreen(app, apk), "ota-fallback").start(); + }, ANSWER_WAIT_MS); + } + /** * True if the archive parses and declares our own package name. * @@ -510,7 +546,8 @@ final class OtaUpdater { } } - private static void commitSession(Context context, File apk) throws Exception { + /** Commits [apk] to a new installer session and returns its id. */ + private static int commitSession(Context context, File apk) throws Exception { PackageInstaller installer = context.getPackageManager().getPackageInstaller(); PackageInstaller.SessionParams params = new PackageInstaller.SessionParams( PackageInstaller.SessionParams.MODE_FULL_INSTALL); @@ -533,7 +570,10 @@ final class OtaUpdater { } Intent callback = new Intent(context, OtaInstallResultReceiver.class) .setAction(OtaInstallResultReceiver.ACTION_INSTALL_RESULT) - .setPackage(context.getPackageName()); + .setPackage(context.getPackageName()) + // Where the verified APK is, for the installer screen if the session fails. + .putExtra(OtaInstallResultReceiver.EXTRA_APK, apk.getAbsolutePath()) + .putExtra(OtaInstallResultReceiver.EXTRA_SESSION, sessionId); // MUTABLE: the platform fills in EXTRA_STATUS and, when it wants the driver to // confirm, EXTRA_INTENT. An immutable PendingIntent would arrive empty. PendingIntent pending = PendingIntent.getBroadcast(context, sessionId, callback, @@ -543,6 +583,7 @@ final class OtaUpdater { session.commit(pending.getIntentSender()); committed = true; Log.i(TAG, "OTA session " + sessionId + " committed (" + apk.length() + " bytes)"); + return sessionId; } finally { // Abandon failed staging, but always close our local handle after a commit too. if (!committed) {