fix: align OTA install with DriveHub Dort
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
e72c247b52963445f6e2b2995300578ba35440ca8de120065040b12b6fe7904e FatihsMG4-unstable-2.2.10.apk
|
||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user