fix: align OTA install with DriveHub Dort

This commit is contained in:
TCFDONMEZ
2026-09-30 10:29:57 +03:00
parent 1330c0b723
commit bad747a230
7 changed files with 19 additions and 120 deletions
+8
View File
@@ -4,6 +4,14 @@ All notable changes to this project are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
[Semantic Versioning](https://semver.org/spec/v2.0.0.html). [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.2.10] - 2026-09-30
### Fixed
- Matched DriveHub_Dort's proven install path: verify SHA-256 and package identity, then
let Android PackageInstaller perform the authoritative signing-certificate check. This
avoids the head unit's false signature mismatch while remaining fail-closed.
## [2.2.9] - 2026-09-30 ## [2.2.9] - 2026-09-30
### Changed ### Changed
+1
View File
@@ -0,0 +1 @@
e72c247b52963445f6e2b2995300578ba35440ca8de120065040b12b6fe7904e FatihsMG4-unstable-2.2.10.apk
+1 -1
View File
@@ -36,7 +36,7 @@ release is a candidate only if all of these hold:
| Asset name ends `-<version>.apk` | `ASSET_VERSION` regex | No version, asset ignored | | Asset name ends `-<version>.apk` | `ASSET_VERSION` regex | No version, asset ignored |
| Version strictly higher than installed | `isNewer()` | Nothing to do, reports "Güncel" | | Version strictly higher than installed | `isNewer()` | Nothing to do, reports "Güncel" |
| Download host on the allowlist | `isAllowedUrl()` | Refused and logged | | Download host on the allowlist | `isAllowedUrl()` | Refused and logged |
| APK signed with the **same certificate** as the running app | `signatureMatchesRunningApp()` | Deleted, never installed | | APK signed with the **same certificate** as the running app | Android `PackageInstaller` | Installation is rejected by the platform |
| Matching `.sha256` asset or `SHA256SUMS` entry | `fetchExpectedSha256()` | Update ignored/refused | | Matching `.sha256` asset or `SHA256SUMS` entry | `fetchExpectedSha256()` | Update ignored/refused |
The last one is the one that bites. The car runs a platform-signed build; an APK signed The last one is the one that bites. The car runs a platform-signed build; an APK signed
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId "com.evsuite.abrp" applicationId "com.evsuite.abrp"
minSdk 27 minSdk 27
targetSdk 36 targetSdk 36
versionCode 26 versionCode 27
versionName "2.2.9" versionName "2.2.10"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -1,87 +0,0 @@
package com.evsuite.abrp;
import android.content.Context;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.pm.Signature;
import android.content.pm.SigningInfo;
import android.os.Build;
import android.util.Log;
import java.security.MessageDigest;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
/** SHA-256 fingerprints of the certificates signing an APK. Unstable builds only. */
final class ApkSignature {
private static final String TAG = "ApkSignature";
private ApkSignature() { }
/** Fingerprints of the APK file at [path]; empty when it cannot be read. */
static Set<String> of(Context context, String path) {
try {
PackageInfo info = context.getPackageManager()
.getPackageArchiveInfo(path, flags());
return digests(info);
} catch (Exception e) {
Log.w(TAG, "Cannot read archive signature: " + e.getMessage());
return Collections.emptySet();
}
}
/** Fingerprints of the running app; empty when they cannot be read. */
static Set<String> ofPackage(Context context) {
try {
PackageInfo info = context.getPackageManager()
.getPackageInfo(context.getPackageName(), flags());
return digests(info);
} catch (Exception e) {
Log.w(TAG, "Cannot read our own signature: " + e.getMessage());
return Collections.emptySet();
}
}
@SuppressWarnings("deprecation")
private static int flags() {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
? PackageManager.GET_SIGNING_CERTIFICATES
: PackageManager.GET_SIGNATURES;
}
@SuppressWarnings("deprecation")
private static Set<String> digests(PackageInfo info) {
if (info == null) return Collections.emptySet();
Signature[] signatures;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
SigningInfo signingInfo = info.signingInfo;
if (signingInfo == null) return Collections.emptySet();
signatures = signingInfo.hasMultipleSigners()
? signingInfo.getApkContentsSigners()
: signingInfo.getSigningCertificateHistory();
} else {
signatures = info.signatures;
}
if (signatures == null) return Collections.emptySet();
Set<String> out = new HashSet<>();
try {
MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
for (Signature signature : signatures) {
if (signature == null) continue;
StringBuilder hex = new StringBuilder();
for (byte b : sha256.digest(signature.toByteArray())) {
hex.append(String.format("%02x", b));
}
out.add(hex.toString());
}
} catch (Exception e) {
Log.w(TAG, "Digest failed: " + e.getMessage());
return Collections.emptySet();
}
return out;
}
}
@@ -406,13 +406,10 @@ final class OtaUpdater {
} }
/** /**
* What {@link #install} did. Every value except {@link #SESSION_STARTED} is a refusal, * What {@link #install} did. The PackageInstaller is the authority for signing-certificate
* and they are distinct because the single "imza uyuşmuyor?" message they used to share * compatibility, matching the proven DriveHub_Dort flow on this head unit.
* sent a perfectly well-signed build to the wrong diagnosis.
*/ */
enum InstallResult { 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. */ /** The package manager cannot parse the archive, or it is for another package. */
UNREADABLE_ARCHIVE, UNREADABLE_ARCHIVE,
/** Handed to the platform. The outcome arrives at {@link OtaInstallResultReceiver}. */ /** Handed to the platform. The outcome arrives at {@link OtaInstallResultReceiver}. */
@@ -435,7 +432,6 @@ final class OtaUpdater {
* file as soon as this returns. * file as soon as this returns.
*/ */
static InstallResult install(Context context, File apk) { static InstallResult install(Context context, File apk) {
if (!signatureMatchesRunningApp(context, apk)) return InstallResult.SIGNATURE_MISMATCH;
if (!archiveIsThisPackage(context, apk)) return InstallResult.UNREADABLE_ARCHIVE; if (!archiveIsThisPackage(context, apk)) return InstallResult.UNREADABLE_ARCHIVE;
try { try {
commitSession(context, apk); commitSession(context, apk);
@@ -449,9 +445,8 @@ final class OtaUpdater {
/** /**
* True if the archive parses and declares our own package name. * 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 * The package name is checked here before any bytes reach an installer session. Android's
* every app on the unit, so a correctly signed archive for some other package would pass * PackageInstaller then performs the authoritative signing-certificate and downgrade checks.
* the certificate check and then fail deep inside the installer.
*/ */
private static boolean archiveIsThisPackage(Context context, File apk) { private static boolean archiveIsThisPackage(Context context, File apk) {
try { try {
@@ -516,20 +511,4 @@ final class OtaUpdater {
} }
} }
/**
* True if [apk] is signed by the same certificate as the running app.
*
* Fail closed: an unreadable archive, a missing signature or a failed API call all
* return false. The caller must delete the file rather than offer it for install.
*/
static boolean signatureMatchesRunningApp(Context context, File apk) {
java.util.Set<String> archive = ApkSignature.of(context, apk.getAbsolutePath());
java.util.Set<String> installed = ApkSignature.ofPackage(context);
boolean ok = !archive.isEmpty() && !installed.isEmpty() && archive.equals(installed);
if (!ok) {
Log.w(TAG, "Signature mismatch — refusing update ("
+ archive.size() + " vs " + installed.size() + " cert(s))");
}
return ok;
}
} }
@@ -17,9 +17,9 @@ import java.util.concurrent.atomic.AtomicBoolean;
* Installation goes through the platform {@link android.content.pm.PackageInstaller}, not * 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 * {@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 * 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 * reported as a signature mismatch it never was. As in DriveHub_Dort, PackageInstaller is the
* downloaded APK's certificate against the running app's first and refuses a mismatch, so an * authority for signing-certificate compatibility; the app verifies SHA-256 and package name
* update signed with any other key is deleted rather than installed. * before handing it the archive.
* *
* Committing a session is asynchronous, so the message this returns says an install has been * 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. * started, never that one finished. {@link OtaInstallResultReceiver} has the verdict.
@@ -128,8 +128,6 @@ final class UpdateHook {
// platform may still want the driver to confirm, and either way the verdict // platform may still want the driver to confirm, and either way the verdict
// arrives at OtaInstallResultReceiver, not here. // arrives at OtaInstallResultReceiver, not here.
return "Kuruluyor: " + update.versionName + " — ekrandaki onayı bekleyin"; return "Kuruluyor: " + update.versionName + " — ekrandaki onayı bekleyin";
case SIGNATURE_MISMATCH:
return "Kurulum reddedildi: imza uyuşmuyor";
case UNREADABLE_ARCHIVE: case UNREADABLE_ARCHIVE:
return "Kurulum reddedildi: APK okunamadı"; return "Kurulum reddedildi: APK okunamadı";
default: default: