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"; + } } }