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:
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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,23 +464,59 @@ 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);
|
||||||
try {
|
return openInstallerScreen(context, apk) ? InstallResult.INSTALL_UI_STARTED : InstallResult.SESSION_FAILED;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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<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);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True if the archive parses and declares our own package name.
|
* 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 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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user