fix: install OTA updates through PackageInstaller

The refresh button found 2.2.3, downloaded it, and reported
"Kurulum reddedildi (imza uyuşmuyor?)" for a build signed with exactly the same
platform certificate as the one already running. The signature was never the
problem.

install() shelled out to `pm install -r` with a path inside the app's private
cache. pm does not read the file — it hands the path to the package manager
service, which opens it from its own process and SELinux context, where that
directory is not readable. The install failed for a reason nobody could see:
install() returned a bare boolean, and UpdateHook mapped every false to the one
message it had. The pm output that would have named the real error was captured
into a StringBuilder and discarded.

A PackageInstaller session takes no path. We open the archive ourselves, as the
app, and stream it into the session, so no second process ever has to reach our
cache. Approach taken from merthankaraman/DriveHub_Dort, which hit the same wall
on the same head unit.

Consequences worth knowing when reading a future failure:

- Committing a session is asynchronous, so the refresh button can only say an
  install started. OtaInstallResultReceiver has the verdict, and surfaces the
  package manager's own INSTALL_FAILED_* text rather than guessing.
- STATUS_PENDING_USER_ACTION is not an error. The receiver launches the
  confirmation activity the platform supplies.
- The PendingIntent must be MUTABLE or it arrives with no status extras.

install() now returns an enum, so a certificate mismatch, an unparseable or
wrong-package archive, and a failed session each say what they are.

Also checks the archive's package name against ours, which was a genuine gap:
the platform key signs every app on this unit, so a certificate match alone
never meant the APK was this app.

The `pm install -r` exit-code test goes with the shell-out it covered. Nothing
in the session path is exercisable off-device.

REQUEST_INSTALL_PACKAGES and the receiver are declared in the unstable manifest
only — the stable channel has no updater at all, and its merged manifest was
checked to confirm it gains neither.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
TCFDONMEZ
2026-09-25 14:02:55 +03:00
co-authored by Claude Opus 5
parent 322ebcc4c0
commit 091d26d6d6
7 changed files with 249 additions and 37 deletions
@@ -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";
}
}
}