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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
This commit is contained in:
2026-10-27 00:18:31 +01:00
co-authored by Claude Opus 5.5
parent 9ef058cb44
commit c3c47f5159
5 changed files with 121 additions and 21 deletions
+7
View File
@@ -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 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 unreadable file as "There was a problem parsing the package". On the emulator the session still
installs silently. 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.
+12
View File
@@ -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 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` 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). (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 published (`car-2.40.0`).
- 2026-10-27 — App 2.40.0: **Grid with map straight from above, full height** (owner: 2.39.2 - 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 "does not look right"; wanted the map over the whole height and fully top-down). The map is
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId "com.stinnesbeck.nilsmg4" applicationId "com.stinnesbeck.nilsmg4"
minSdk 27 minSdk 27
targetSdk 36 targetSdk 36
versionCode 145 versionCode 146
versionName "2.40.0" versionName "2.40.1"
// Nothing about the server ships in the APK: its URL and ingest token are entered in // 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. // the app's Server tab (ServerConfig), and the database is only reachable by the server.
@@ -16,10 +16,17 @@ import android.widget.Toast;
* STATUS_PENDING_USER_ACTION is the case worth understanding: the platform is saying it will * 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, 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. * 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 { public final class OtaInstallResultReceiver extends BroadcastReceiver {
static final String ACTION_INSTALL_RESULT = "com.stinnesbeck.nilsmg4.OTA_INSTALL_RESULT"; 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"; 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); Log.i(TAG, "OTA install result: status=" + status + " msg=" + message);
UpdateLog.record(context, "Install result: status " + status UpdateLog.record(context, "Install result: status " + status
+ (message != null ? ", " + message : "")); + (message != null ? ", " + message : ""));
int session = intent.getIntExtra(EXTRA_SESSION, -1);
if (session >= 0) OtaUpdater.noteAnswer(session);
String apkPath = intent.getStringExtra(EXTRA_APK);
switch (status) { switch (status) {
case PackageInstaller.STATUS_PENDING_USER_ACTION: case PackageInstaller.STATUS_PENDING_USER_ACTION:
Intent confirm = intent.getParcelableExtra(Intent.EXTRA_INTENT); Intent confirm = intent.getParcelableExtra(Intent.EXTRA_INTENT);
if (confirm == null) { 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; return;
} }
confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
@@ -44,23 +55,52 @@ public final class OtaInstallResultReceiver extends BroadcastReceiver {
context.startActivity(confirm); context.startActivity(confirm);
} catch (Throwable t) { } catch (Throwable t) {
Log.e(TAG, "OTA confirm UI failed: " + t.getMessage()); Log.e(TAG, "OTA confirm UI failed: " + t.getMessage());
toast(context, context.getString(R.string.ota_install_failed, UpdateLog.record(context, "Confirmation screen failed: " + t.getClass().getSimpleName());
t.getClass().getSimpleName())); openInstallerScreen(context, apkPath, t.getClass().getSimpleName());
} }
break; break;
case PackageInstaller.STATUS_SUCCESS: case PackageInstaller.STATUS_SUCCESS:
toast(context, context.getString(R.string.ota_install_success)); toast(context, context.getString(R.string.ota_install_success));
break; 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: default:
// The status message is the package manager's own words — INSTALL_FAILED_* // The status message is the package manager's own words — INSTALL_FAILED_*
// and friends. Surfaced verbatim, because guessing at it is what made the // and friends — kept verbatim in the update log, because guessing at it is what
// previous failure unreadable. // made an earlier failure unreadable. Then the installer screen, which the car
toast(context, context.getString(R.string.ota_install_failed, // allows where it refuses the session.
message != null ? message : "status " + status)); openInstallerScreen(context, apkPath, message != null ? message : "status " + status);
break; 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) { private static void toast(Context context, String text) {
Toast.makeText(context.getApplicationContext(), text, Toast.LENGTH_LONG).show(); Toast.makeText(context.getApplicationContext(), text, Toast.LENGTH_LONG).show();
} }
@@ -464,21 +464,57 @@ final class OtaUpdater {
static InstallResult install(Context context, File apk) { static InstallResult install(Context context, File apk) {
if (!archiveIsThisPackage(context, apk)) return InstallResult.UNREADABLE_ARCHIVE; if (!archiveIsThisPackage(context, apk)) return InstallResult.UNREADABLE_ARCHIVE;
try { try {
commitSession(context, apk); int sessionId = commitSession(context, apk);
watchForAnswer(context, sessionId, apk);
return InstallResult.SESSION_STARTED; return InstallResult.SESSION_STARTED;
} catch (Throwable t) { } catch (Throwable t) {
Log.w(TAG, "OTA session install failed; trying system install UI", t); Log.w(TAG, "OTA session install failed; trying system install UI", t);
UpdateLog.record(context, "Installer session failed: " + t); UpdateLog.record(context, "Installer session failed: " + t);
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 { try {
launchInstallerActivity(context, apk); launchInstallerActivity(context, apk);
UpdateLog.record(context, "Opened the system installer instead"); UpdateLog.record(context, "Opened the system installer instead");
return InstallResult.INSTALL_UI_STARTED; return true;
} catch (Throwable fallback) { } catch (Throwable fallback) {
Log.w(TAG, "OTA install UI failed", fallback); Log.w(TAG, "OTA install UI failed", fallback);
UpdateLog.record(context, "System installer failed too: " + fallback); UpdateLog.record(context, "System installer failed too: " + fallback);
return InstallResult.SESSION_FAILED; 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<Integer> 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);
} }
/** /**
@@ -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 installer = context.getPackageManager().getPackageInstaller();
PackageInstaller.SessionParams params = new PackageInstaller.SessionParams( PackageInstaller.SessionParams params = new PackageInstaller.SessionParams(
PackageInstaller.SessionParams.MODE_FULL_INSTALL); PackageInstaller.SessionParams.MODE_FULL_INSTALL);
@@ -533,7 +570,10 @@ final class OtaUpdater {
} }
Intent callback = new Intent(context, OtaInstallResultReceiver.class) Intent callback = new Intent(context, OtaInstallResultReceiver.class)
.setAction(OtaInstallResultReceiver.ACTION_INSTALL_RESULT) .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 // MUTABLE: the platform fills in EXTRA_STATUS and, when it wants the driver to
// confirm, EXTRA_INTENT. An immutable PendingIntent would arrive empty. // confirm, EXTRA_INTENT. An immutable PendingIntent would arrive empty.
PendingIntent pending = PendingIntent.getBroadcast(context, sessionId, callback, PendingIntent pending = PendingIntent.getBroadcast(context, sessionId, callback,
@@ -543,6 +583,7 @@ final class OtaUpdater {
session.commit(pending.getIntentSender()); session.commit(pending.getIntentSender());
committed = true; committed = true;
Log.i(TAG, "OTA session " + sessionId + " committed (" + apk.length() + " bytes)"); Log.i(TAG, "OTA session " + sessionId + " committed (" + apk.length() + " bytes)");
return sessionId;
} finally { } finally {
// Abandon failed staging, but always close our local handle after a commit too. // Abandon failed staging, but always close our local handle after a commit too.
if (!committed) { if (!committed) {