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
@@ -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();
}