diff --git a/OTA.md b/OTA.md
index 60138d1..5244b84 100644
--- a/OTA.md
+++ b/OTA.md
@@ -86,16 +86,42 @@ The version comes from the **asset filename**, not the git tag. The tag can stay
|---|---|
| `Güncel (2.2.2.0-unstable)` | No release beat the installed version. Check the pre-release tick and the asset filename. |
| `İndirme başarısız` | Release found, download did not complete. Usually no network. |
-| `Kurulum reddedildi (imza uyuşmuyor?)` | Signed with the wrong key — rebuild and re-sign with `platform.pk8`. |
+| `Kuruluyor: 2.2.3 — ekrandaki onayı bekleyin` | The session was committed. Not done yet: watch for the platform's own toast, or a confirmation dialog to accept. |
+| `Kurulum reddedildi: imza uyuşmuyor` | Genuinely the wrong key — rebuild and re-sign with `platform.pk8`. Unlike the old catch-all, this one means what it says. |
+| `Kurulum reddedildi: APK okunamadı` | The archive does not parse, or it is built for another package. |
+| `Kurulum başlatılamadı … Log sayfasına bakın` | The session could not be created or committed. `adb logcat -s EVABRP.Update`. |
+| `Kurulum başarısız: INSTALL_FAILED_…` | From `OtaInstallResultReceiver` — the package manager's own words for why it refused. |
| `Güncelleme kontrolü başarısız` | The check threw. `adb logcat -s EVABRP.Update OtaUpdater` if a cable is available; otherwise the in-app Log page. |
-## Untested
+## How the install works, and why it is not `pm install`
-`OtaUpdater.install()` shells out to `/system/bin/pm install -r`, which only a
-platform-signed app may call. That is what this build is, so it should work — but it has
-never actually run on the car. If installs are refused with a signature that *does* match,
-the fallback is an `ACTION_INSTALL_PACKAGE` intent, which asks the driver to confirm
-instead of installing silently.
+`OtaUpdater.install()` used to shell out to `/system/bin/pm install -r`. On this head unit
+that fails every time, and the reason is worth writing down because the symptom pointed
+somewhere else entirely.
+
+The downloaded APK lives in `context.getCacheDir()`. `pm` does not install the file itself
+— it passes the path to the package manager service, which opens it from *its* process and
+SELinux context, where an app's private cache is not readable. The install failed, and
+because `install()` returned a bare boolean, the caller reported the only failure it knew
+how to name: `Kurulum reddedildi (imza uyuşmuyor?)`. A correctly signed build, refused with
+a signature error it had not earned. The `pm` output that would have said so was captured
+and thrown away.
+
+It now uses the platform `PackageInstaller`: we open the archive ourselves and stream it
+into a session, so no second process ever has to read our cache. The approach is taken from
+[merthankaraman/DriveHub_Dort](https://github.com/merthankaraman/DriveHub_Dort), which hit
+the same wall on the same hardware.
+
+Two consequences for anyone reading a failure:
+
+- **Committing a session is not installing.** `commit()` is asynchronous. The refresh button
+ can only report that an install *started*; the verdict arrives at
+ `OtaInstallResultReceiver` and appears as its own toast.
+- **`STATUS_PENDING_USER_ACTION` is not an error.** The platform is asking the driver to
+ confirm. The receiver launches the confirmation activity the platform hands it.
+
+Still unverified on the car: whether this build installs silently or raises that
+confirmation. Either way it installs, which the old path did not.
## Why the check is manual
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 7ebd706..8c38057 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -124,6 +124,8 @@
Shows vehicle telemetry upload status
ABRP Uploader
Stop
+ Güncelleme kuruldu — uygulamayı yeniden başlatın
+ Kurulum başarısız: %1$s
Güncelleme kontrol et
Kontrol ediliyor…
About
diff --git a/app/src/testUnstable/java/com/evsuite/abrp/OtaUpdaterTest.java b/app/src/testUnstable/java/com/evsuite/abrp/OtaUpdaterTest.java
index 66810a8..f03a90e 100644
--- a/app/src/testUnstable/java/com/evsuite/abrp/OtaUpdaterTest.java
+++ b/app/src/testUnstable/java/com/evsuite/abrp/OtaUpdaterTest.java
@@ -111,10 +111,8 @@ public class OtaUpdaterTest {
assertArrayEquals(new int[]{1, 0, 5}, OtaUpdater.segments("1.x.5"));
}
- @Test
- public void pmInstallRequiresBothSuccessTextAndZeroExit() {
- assertTrue(OtaUpdater.installSucceeded(0, "Success\n"));
- assertFalse(OtaUpdater.installSucceeded(1, "Success\n"));
- assertFalse(OtaUpdater.installSucceeded(0, "Failure [INSTALL_FAILED]"));
- }
+ // The `pm install -r` exit-code test that stood here is gone with the shell-out it
+ // covered. Installation is now a PackageInstaller session, whose every step needs a real
+ // PackageManager — there is no pure part of it left to exercise on the JVM. What used to
+ // be asserted here is now reported by OtaInstallResultReceiver, on the car.
}
diff --git a/app/src/unstable/AndroidManifest.xml b/app/src/unstable/AndroidManifest.xml
index 8230814..4238ec2 100644
--- a/app/src/unstable/AndroidManifest.xml
+++ b/app/src/unstable/AndroidManifest.xml
@@ -1,3 +1,21 @@
+
+ android:sharedUserId="android.uid.system">
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/unstable/java/com/evsuite/abrp/OtaInstallResultReceiver.java b/app/src/unstable/java/com/evsuite/abrp/OtaInstallResultReceiver.java
new file mode 100644
index 0000000..3a4611a
--- /dev/null
+++ b/app/src/unstable/java/com/evsuite/abrp/OtaInstallResultReceiver.java
@@ -0,0 +1,65 @@
+package com.evsuite.abrp;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageInstaller;
+import android.util.Log;
+import android.widget.Toast;
+
+/**
+ * Where a {@link PackageInstaller} session reports what it did — UNSTABLE BUILDS ONLY.
+ *
+ * The session commit is asynchronous, so unlike the {@code pm install -r} it replaced, the
+ * install outcome cannot be the return value of the call that started it. It arrives here.
+ *
+ * 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.
+ */
+public final class OtaInstallResultReceiver extends BroadcastReceiver {
+
+ static final String ACTION_INSTALL_RESULT = "com.evsuite.abrp.OTA_INSTALL_RESULT";
+
+ private static final String TAG = "EVABRP.Update";
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (intent == null) return;
+ int status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE);
+ String message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE);
+ Log.i(TAG, "OTA install result: status=" + status + " msg=" + message);
+
+ 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"));
+ return;
+ }
+ confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ try {
+ 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()));
+ }
+ break;
+ case PackageInstaller.STATUS_SUCCESS:
+ toast(context, context.getString(R.string.ota_install_success));
+ 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));
+ break;
+ }
+ }
+
+ 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 6d13261..8f6a18f 100644
--- a/app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java
+++ b/app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java
@@ -1,6 +1,9 @@
package com.evsuite.abrp;
+import android.app.PendingIntent;
import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageInstaller;
import android.util.Log;
import org.json.JSONArray;
@@ -295,27 +298,112 @@ final class OtaUpdater {
return null;
}
- static boolean install(Context context, File apk) {
- if (!signatureMatchesRunningApp(context, apk)) return false;
+ /**
+ * What {@link #install} did. Every value except {@link #SESSION_STARTED} is a refusal,
+ * and they are distinct because the single "imza uyuşmuyor?" message they used to share
+ * sent a perfectly well-signed build to the wrong diagnosis.
+ */
+ enum InstallResult {
+ /** The archive is signed by a different certificate than the running app. */
+ SIGNATURE_MISMATCH,
+ /** The package manager cannot parse the archive, or it is for another package. */
+ UNREADABLE_ARCHIVE,
+ /** Handed to the platform. The outcome arrives at {@link OtaInstallResultReceiver}. */
+ SESSION_STARTED,
+ /** The session could not be created, written or committed. */
+ SESSION_FAILED
+ }
+
+ /**
+ * Hands [apk] to the platform's {@link PackageInstaller}.
+ *
+ * This used to shell out to {@code pm install -r}. That fails on this head unit: the APK
+ * lives in the app's private cache, and the package manager service opens the path from
+ * its own process and SELinux context, where that directory is not readable. The failure
+ * surfaced as a signature error, which it never was.
+ *
+ * A session takes no path. We open the archive ourselves, as the app, and stream the bytes
+ * into the session — so there is no second process that has to be able to read our cache.
+ * The bytes are fully written before commit, which is why the caller may delete the staged
+ * file as soon as this returns.
+ */
+ static InstallResult install(Context context, File apk) {
+ if (!signatureMatchesRunningApp(context, apk)) return InstallResult.SIGNATURE_MISMATCH;
+ if (!archiveIsThisPackage(context, apk)) return InstallResult.UNREADABLE_ARCHIVE;
try {
- Process process = new ProcessBuilder("/system/bin/pm", "install", "-r",
- apk.getAbsolutePath()).redirectErrorStream(true).start();
- StringBuilder output = new StringBuilder();
- try (BufferedReader reader = new BufferedReader(new InputStreamReader(
- process.getInputStream(), StandardCharsets.UTF_8))) {
- String line;
- while ((line = reader.readLine()) != null) output.append(line).append('\n');
+ commitSession(context, apk);
+ return InstallResult.SESSION_STARTED;
+ } catch (Throwable t) {
+ Log.w(TAG, "OTA session install failed", t);
+ return InstallResult.SESSION_FAILED;
+ }
+ }
+
+ /**
+ * True if the archive parses and declares our own package name.
+ *
+ * A signature match alone does not say the APK is this app: the same platform key signs
+ * every app on the unit, so a correctly signed archive for some other package would pass
+ * the certificate check and then fail deep inside the installer.
+ */
+ private static boolean archiveIsThisPackage(Context context, File apk) {
+ try {
+ android.content.pm.PackageInfo info = context.getPackageManager()
+ .getPackageArchiveInfo(apk.getAbsolutePath(), 0);
+ if (info == null || info.packageName == null) {
+ Log.w(TAG, "OTA archive cannot be parsed by the package manager");
+ return false;
}
- int exitCode = process.waitFor();
- return installSucceeded(exitCode, output.toString());
+ if (!context.getPackageName().equals(info.packageName)) {
+ Log.w(TAG, "OTA archive is for " + info.packageName + ", not "
+ + context.getPackageName());
+ return false;
+ }
+ Log.i(TAG, "OTA archive ok: " + info.packageName + " " + info.versionName);
+ return true;
} catch (Exception e) {
- Log.w(TAG, "Update install failed", e);
+ Log.w(TAG, "OTA archive check threw: " + e.getMessage());
return false;
}
}
- static boolean installSucceeded(int exitCode, String output) {
- return exitCode == 0 && output.contains("Success");
+ private static void commitSession(Context context, File apk) throws Exception {
+ PackageInstaller installer = context.getPackageManager().getPackageInstaller();
+ PackageInstaller.SessionParams params = new PackageInstaller.SessionParams(
+ PackageInstaller.SessionParams.MODE_FULL_INSTALL);
+ if (android.os.Build.VERSION.SDK_INT >= 31) {
+ // Only honoured for an installer the platform already trusts; harmless otherwise,
+ // and this unit is API 28 where the field does not exist at all.
+ params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED);
+ }
+ int sessionId = installer.createSession(params);
+ PackageInstaller.Session session = null;
+ try {
+ session = installer.openSession(sessionId);
+ try (java.io.InputStream in = new java.io.FileInputStream(apk);
+ java.io.OutputStream out = session.openWrite("base.apk", 0, apk.length())) {
+ byte[] buffer = new byte[64 * 1024];
+ int count;
+ while ((count = in.read(buffer)) != -1) out.write(buffer, 0, count);
+ session.fsync(out);
+ }
+ Intent callback = new Intent(context, OtaInstallResultReceiver.class)
+ .setAction(OtaInstallResultReceiver.ACTION_INSTALL_RESULT)
+ .setPackage(context.getPackageName());
+ // 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,
+ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE);
+ session.commit(pending.getIntentSender());
+ Log.i(TAG, "OTA session " + sessionId + " committed (" + apk.length() + " bytes)");
+ session = null;
+ } finally {
+ // Only reached when commit did not happen; an abandoned session frees its staging.
+ if (session != null) {
+ try { session.abandon(); } catch (Throwable ignored) { }
+ try { session.close(); } catch (Throwable ignored) { }
+ }
+ }
}
/**
diff --git a/app/src/unstable/java/com/evsuite/abrp/UpdateHook.java b/app/src/unstable/java/com/evsuite/abrp/UpdateHook.java
index 34e47ad..eda7acb 100644
--- a/app/src/unstable/java/com/evsuite/abrp/UpdateHook.java
+++ b/app/src/unstable/java/com/evsuite/abrp/UpdateHook.java
@@ -12,10 +12,15 @@ import java.io.File;
* This fork publishes its own builds from its own repository, signed with the platform key
* the head unit already trusts, so that audit does not govern here and the seam is live.
*
- * Installation goes through {@code pm install -r}, which only a platform-signed build may
- * call. {@link OtaUpdater#install} checks the downloaded APK's certificate against the
- * running app's first and refuses a mismatch, so an update signed with any other key is
- * deleted rather than installed — the same check that makes an upstream APK unusable here.
+ * Installation goes through the platform {@link android.content.pm.PackageInstaller}, not
+ * {@code pm install -r}: that shelled out with a path inside our private cache, which the
+ * package manager service cannot read from its own process, and the generic failure was
+ * reported as a signature mismatch it never was. {@link OtaUpdater#install} still checks the
+ * downloaded APK's certificate against the running app's first and refuses a mismatch, so an
+ * update signed with any other key is deleted rather than installed.
+ *
+ * Committing a session is asynchronous, so the message this returns says an install has been
+ * started, never that one finished. {@link OtaInstallResultReceiver} has the verdict.
*/
final class UpdateHook {
@@ -78,12 +83,22 @@ final class UpdateHook {
if (apk == null) return "İndirme başarısız (" + update.versionName + ")";
// The cached APK is cleared whichever way install goes: a rejected archive must not
- // linger, and an accepted one has already been copied out by the package manager.
- boolean installed = OtaUpdater.install(app, apk);
+ // linger, and an accepted one has already been streamed into the installer session.
+ OtaUpdater.InstallResult result = OtaUpdater.install(app, apk);
if (!apk.delete()) Log.w(TAG, "Could not remove cached OTA APK");
- return installed
- ? "Güncellendi: " + update.versionName + " — yeniden başlatın"
- : "Kurulum reddedildi (imza uyuşmuyor?)";
+ switch (result) {
+ case SESSION_STARTED:
+ // Deliberately not "Güncellendi": committing a session is not installing. The
+ // platform may still want the driver to confirm, and either way the verdict
+ // arrives at OtaInstallResultReceiver, not here.
+ return "Kuruluyor: " + update.versionName + " — ekrandaki onayı bekleyin";
+ case SIGNATURE_MISMATCH:
+ return "Kurulum reddedildi: imza uyuşmuyor";
+ case UNREADABLE_ARCHIVE:
+ return "Kurulum reddedildi: APK okunamadı";
+ default:
+ return "Kurulum başlatılamadı (" + update.versionName + ") — Log sayfasına bakın";
+ }
}
}