diff --git a/CHANGELOG.md b/CHANGELOG.md index 8502a62..bbdf322 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.2.8] - 2026-09-29 + +### Changed + +- Updated the check-in-progress copy for an end-to-end OTA upgrade test from 2.2.7. + +## [2.2.7] - 2026-09-29 + +### Changed + +- OTA updates now require an APK-specific `.sha256` asset or matching `SHA256SUMS` entry, + adapted from DriveHub_Dort commit `3b043875`. Downloaded bytes are hashed while streaming + and rejected before PackageInstaller if integrity does not match. +- Startup performs a quiet availability check and never downloads by itself. Manual updates + show download progress and a final result dialog. +- Existing exact-host, same-certificate and same-package checks remain mandatory. + ## [2.2.6] - 2026-09-29 ### Changed diff --git a/FatihsMG4-unstable-2.2.7.apk.sha256 b/FatihsMG4-unstable-2.2.7.apk.sha256 new file mode 100644 index 0000000..82b3b3b --- /dev/null +++ b/FatihsMG4-unstable-2.2.7.apk.sha256 @@ -0,0 +1 @@ +064930b9ffc4f7e99abe5867ca42258e35d756907b58d99440108ad72a901c48 FatihsMG4-unstable-2.2.7.apk diff --git a/OTA.md b/OTA.md index 56f5adb..3af83ef 100644 --- a/OTA.md +++ b/OTA.md @@ -5,16 +5,17 @@ next — probably me, having forgotten the details. ## First upgrade from 2.2.3 or older -Install the platform-signed 2.2.6 APK manually once if the car still runs 2.2.3 or older. +Install the platform-signed 2.2.7 APK manually once if the car still runs 2.2.3 or older. Those builds use the old `pm install` path; publishing a newer APK does not repair the installer already running on the car. Builds from 2.2.4 use `PackageInstaller` sessions. Keep the unstable package ID, system shared UID and signing certificate when upgrading. The 2.2.5 build removes window automation. It also distinguishes failed release checks from a successful check with no newer release. On 2026-09-29, GitHub still published only -2.2.3 at the last live check. The local 2.2.6 build includes these fixes and the redesigned +2.2.3 at the last live check. The local 2.2.7 build includes these fixes and the redesigned UI; it becomes available to OTA only after publication as a pre-release asset named -`FatihsMG4-unstable-2.2.6.apk`. +`FatihsMG4-unstable-2.2.7.apk`, together with +`FatihsMG4-unstable-2.2.7.apk.sha256`. The vehicle artifact uses `com.evsuite.abrp.unstable`, `android.uid.system`, and the DriveHub_Kamera platform key. Its certificate SHA-256 must be @@ -36,6 +37,7 @@ release is a candidate only if all of these hold: | Version strictly higher than installed | `isNewer()` | Nothing to do, reports "Güncel" | | Download host on the allowlist | `isAllowedUrl()` | Refused and logged | | APK signed with the **same certificate** as the running app | `signatureMatchesRunningApp()` | Deleted, never installed | +| 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 with a debug key — or anyone else's key — is downloaded, rejected and deleted. Sign every @@ -49,11 +51,11 @@ The version comes from the **asset filename**, not the git tag. The tag can stay 1. **Raise the version.** `app/build.gradle`, `defaultConfig`: ```gradle - versionCode 23 // must increase — Android orders updates by this, not by name - versionName "2.2.6" + versionCode 24 // must increase — Android orders updates by this, not by name + versionName "2.2.7" ``` - The unstable flavour appends its own suffix, so `2.2.6` becomes `2.2.6.0-unstable`. + The unstable flavour appends its own suffix, so `2.2.7` becomes `2.2.7.0-unstable`. Check what the installed build reports before picking a number: the app's About dialog shows it, and `isNewer` compares numerically segment by segment. @@ -76,35 +78,42 @@ The version comes from the **asset filename**, not the git tag. The tag can stay "$BT/apksigner" sign \ --key "$KEYS/platform.pk8" \ --cert "$KEYS/platform.x509.pem" \ - --out FatihsMG4-unstable-2.2.6.apk \ + --out FatihsMG4-unstable-2.2.7.apk \ aligned.apk rm aligned.apk ``` The output filename **is** the version the updater reads. Name it - `FatihsMG4-unstable-.apk` and nothing else. + `FatihsMG4-unstable-.apk`, then create its required integrity sidecar: + + ```bash + shasum -a 256 FatihsMG4-unstable-2.2.7.apk \ + > FatihsMG4-unstable-2.2.7.apk.sha256 + ``` 4. **Verify the signer** before uploading. It must say `CN=Android`: ```bash - "$BT/apksigner" verify --print-certs FatihsMG4-unstable-2.2.6.apk + "$BT/apksigner" verify --print-certs FatihsMG4-unstable-2.2.7.apk ``` 5. **Publish.** On github.com/fatihdonmezdev/MG4ABRP → Releases → Draft a new release: - tag: `unstable` (reuse it; the rolling tag is the design) - **tick "Set as a pre-release"** ← the whole thing is inert without this - - attach the signed APK + - attach the signed APK and its `.sha256` sidecar 6. **Install it.** Open the app on the head unit and press the refresh button in the top - bar. Expected toast: `Güncellendi: 2.2.3 — yeniden başlatın`. + bar. The dialog shows download progress, verifies SHA-256, then reports that the + PackageInstaller session has started. ## When it says something else | Toast | Meaning | |---|---| | `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. | +| `Güncelleme reddedildi: SHA-256 doğrulaması yok` | APK has no matching sidecar, or the sidecar is invalid/unreachable. | +| `İndirme veya SHA-256 doğrulaması başarısız` | Download failed or its bytes do not match the published hash. | | `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. | @@ -142,12 +151,11 @@ Two consequences for anyone reading a failure: 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 +## Why installation is manual -The refresh button is the only trigger. `UpdateHook.checkInBackground(this)` also runs on -app start, but the app is rarely launched — the upload service does the work, and the -driver does not open the UI on every trip. The button is there so an update can be pulled -deliberately rather than waiting for a launch that may not come. +`UpdateHook.checkInBackground(this)` performs an availability-only check at app start and +shows a notification toast when a complete APK + hash pair exists. It never downloads or +installs. The refresh button is the only trigger for download, verification and installation. The upload service does **not** check for updates on its tick. Installing a new APK restarts the process, and doing that mid-drive would interrupt the telemetry the service diff --git a/app/build.gradle b/app/build.gradle index ca456d4..8cdf97b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.evsuite.abrp" minSdk 27 targetSdk 36 - versionCode 23 - versionName "2.2.6" + versionCode 25 + versionName "2.2.8" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index 7766cab..2dcaa99 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -658,17 +658,56 @@ public class MainActivity extends AppCompatActivity { * a second press would start a second download of the same APK. It comes back on when * the result lands, whatever that result is. * - * The outcome goes to a toast because there is nowhere better: the check is reachable - * from every page, so no one page owns the answer. + * A modal progress surface keeps the driver informed while the APK is downloaded and + * SHA-256 verified; the final result remains visible until dismissed. */ private void checkForUpdate(View button) { button.setEnabled(false); - android.widget.Toast.makeText(this, R.string.update_checking, - android.widget.Toast.LENGTH_SHORT).show(); - UpdateHook.checkInBackground(this, message -> { - button.setEnabled(true); - android.widget.Toast.makeText(this, message, - android.widget.Toast.LENGTH_LONG).show(); + android.widget.LinearLayout panel = new android.widget.LinearLayout(this); + panel.setOrientation(android.widget.LinearLayout.VERTICAL); + int padding = (int) (24 * getResources().getDisplayMetrics().density); + panel.setPadding(padding, padding, padding, padding); + TextView status = new TextView(this); + status.setText(R.string.update_checking); + status.setTextAppearance(R.style.Text_EV_Body); + android.widget.ProgressBar progress = new android.widget.ProgressBar(this, null, + android.R.attr.progressBarStyleHorizontal); + progress.setIndeterminate(true); + android.widget.LinearLayout.LayoutParams progressParams = + new android.widget.LinearLayout.LayoutParams( + android.view.ViewGroup.LayoutParams.MATCH_PARENT, + android.view.ViewGroup.LayoutParams.WRAP_CONTENT); + progressParams.topMargin = padding; + panel.addView(status); + panel.addView(progress, progressParams); + androidx.appcompat.app.AlertDialog dialog = new MaterialAlertDialogBuilder(this) + .setTitle(R.string.update_dialog_title) + .setView(panel) + .setCancelable(false) + .create(); + dialog.show(); + UpdateHook.checkInBackground(this, new UpdateHook.Listener() { + @Override public void onProgress(int percent) { + if (percent < 0) { + progress.setIndeterminate(true); + status.setText(R.string.update_downloading); + } else { + progress.setIndeterminate(false); + progress.setMax(100); + progress.setProgress(percent); + status.setText(getString(R.string.update_downloading_percent, percent)); + } + } + + @Override public void onResult(String message) { + button.setEnabled(true); + dialog.dismiss(); + new MaterialAlertDialogBuilder(MainActivity.this) + .setTitle(R.string.update_dialog_title) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .show(); + } }); } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3e17277..fbe0a1c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -112,7 +112,10 @@ Güncelleme kuruldu — uygulamayı yeniden başlatın Kurulum başarısız: %1$s Güncelleme kontrol et - Kontrol ediliyor… + Yeni sürüm kontrol ediliyor… + Yazılım güncellemesi + İndiriliyor ve doğrulanıyor… + İndiriliyor… %1$d%% About Fatih\'sMG4 Hakkında Version %1$s diff --git a/app/src/stable/java/com/evsuite/abrp/UpdateHook.java b/app/src/stable/java/com/evsuite/abrp/UpdateHook.java index 36f81a0..ed29e05 100644 --- a/app/src/stable/java/com/evsuite/abrp/UpdateHook.java +++ b/app/src/stable/java/com/evsuite/abrp/UpdateHook.java @@ -18,6 +18,7 @@ final class UpdateHook { * Nothing implements it here. */ interface Listener { + default void onProgress(int percent) { } void onResult(String message); } diff --git a/app/src/testUnstable/java/com/evsuite/abrp/OtaUpdaterTest.java b/app/src/testUnstable/java/com/evsuite/abrp/OtaUpdaterTest.java index 0ab4a38..ad9d356 100644 --- a/app/src/testUnstable/java/com/evsuite/abrp/OtaUpdaterTest.java +++ b/app/src/testUnstable/java/com/evsuite/abrp/OtaUpdaterTest.java @@ -121,6 +121,28 @@ public class OtaUpdaterTest { assertArrayEquals(new int[]{1, 0, 5}, OtaUpdater.segments("1.x.5")); } + @Test + public void sha256SidecarFormatsAreParsed() { + String hash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + assertEquals(hash, OtaUpdater.parseExpectedSha256(hash, + "FatihsMG4-unstable-2.2.7.apk")); + assertEquals(hash, OtaUpdater.parseExpectedSha256( + hash + " *FatihsMG4-unstable-2.2.7.apk\n", + "FatihsMG4-unstable-2.2.7.apk")); + assertNull(OtaUpdater.parseExpectedSha256(hash + " another.apk", + "FatihsMG4-unstable-2.2.7.apk")); + assertNull(OtaUpdater.parseExpectedSha256("not-a-hash", + "FatihsMG4-unstable-2.2.7.apk")); + } + + @Test + public void updateWithoutHashSidecarIsReportedButCannotBeVerified() throws Exception { + JSONArray releases = new JSONArray().put(releaseWithoutHashes(true, asset("2.2.7"))); + OtaUpdater.Update update = OtaUpdater.selectUpdate(releases, "2.2.6.0-unstable"); + assertEquals("2.2.7", update.versionName); + assertNull(update.hashUrl); + } + @Test public void newestAssetWinsEvenWhenItIsLaterInTheSameRelease() throws Exception { JSONArray releases = new JSONArray().put(release(true, @@ -201,6 +223,22 @@ public class OtaUpdaterTest { private static JSONObject release(boolean prerelease, JSONObject... assets) throws Exception { JSONArray list = new JSONArray(); + for (JSONObject asset : assets) { + list.put(asset); + String name = asset.optString("name", ""); + if (name.toLowerCase(java.util.Locale.US).endsWith(".apk")) { + list.put(new JSONObject() + .put("name", name + ".sha256") + .put("browser_download_url", + asset.optString("browser_download_url") + ".sha256")); + } + } + return new JSONObject().put("prerelease", prerelease).put("assets", list); + } + + private static JSONObject releaseWithoutHashes(boolean prerelease, JSONObject... assets) + throws Exception { + JSONArray list = new JSONArray(); for (JSONObject asset : assets) list.put(asset); return new JSONObject().put("prerelease", prerelease).put("assets", list); } diff --git a/app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java b/app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java index 4bbfa56..67bc62a 100644 --- a/app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java +++ b/app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java @@ -22,6 +22,7 @@ import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.List; import java.util.Locale; +import java.security.MessageDigest; /** * Over-the-air updater — UNSTABLE BUILDS ONLY. @@ -68,6 +69,7 @@ final class OtaUpdater { java.util.regex.Pattern.CASE_INSENSITIVE); private static final int TIMEOUT_MS = 10_000; + private static final int MAX_HASH_BYTES = 16 * 1024; private OtaUpdater() { } @@ -84,13 +86,21 @@ final class OtaUpdater { static final class Update { final String versionName; final String apkUrl; + final String apkName; + final String hashUrl; - Update(String versionName, String apkUrl) { + Update(String versionName, String apkUrl, String apkName, String hashUrl) { this.versionName = versionName; this.apkUrl = apkUrl; + this.apkName = apkName; + this.hashUrl = hashUrl; } } + interface ProgressListener { + void onProgress(int percent); + } + /** * True if [url] is https and points at an allowed host. * @@ -214,12 +224,98 @@ final class OtaUpdater { if (version == null || !isNewer(version, currentVersion)) continue; if (best != null && !isNewer(version, best.versionName)) continue; String url = asset.optString("browser_download_url", ""); - if (isAllowedUrl(url)) best = new Update(version, url); + String hashUrl = findHashUrl(assets, name); + // Keep the candidate even when the sidecar is absent so the UI can report + // a broken release instead of incorrectly saying the app is current. + // fetchExpectedSha256 still fails closed before any APK is downloaded. + if (isAllowedUrl(url)) best = new Update(version, url, name, hashUrl); } } return best; } + private static String findHashUrl(JSONArray assets, String apkName) throws JSONException { + String exact = apkName + ".sha256"; + String sums = null; + for (int i = 0; i < assets.length(); i++) { + JSONObject asset = assets.getJSONObject(i); + String name = asset.optString("name", ""); + String url = asset.optString("browser_download_url", ""); + if (!isAllowedUrl(url)) continue; + if (exact.equalsIgnoreCase(name)) return url; + if ("SHA256SUMS".equalsIgnoreCase(name)) sums = url; + } + return sums; + } + + static String parseExpectedSha256(String content, String apkName) { + if (content == null || apkName == null) return null; + for (String raw : content.split("\\r?\\n")) { + String line = raw.trim(); + if (line.isEmpty()) continue; + String[] fields = line.split("\\s+"); + if (fields.length == 1 && isSha256(fields[0])) + return fields[0].toLowerCase(Locale.US); + if (fields.length >= 2 && isSha256(fields[0])) { + String named = fields[fields.length - 1].replaceFirst("^\\*", ""); + if (apkName.equals(named)) return fields[0].toLowerCase(Locale.US); + } + } + return null; + } + + private static boolean isSha256(String value) { + return value != null && value.matches("(?i)[a-f0-9]{64}"); + } + + static String fetchExpectedSha256(Update update) throws IOException { + if (update == null || !isAllowedUrl(update.hashUrl)) + throw new IOException("Missing SHA-256 sidecar"); + String body = readSmallText(update.hashUrl); + String hash = parseExpectedSha256(body, update.apkName); + if (hash == null) throw new IOException("Invalid SHA-256 sidecar"); + return hash; + } + + private static String readSmallText(String initialUrl) throws IOException { + URL current = new URL(initialUrl); + for (int redirects = 0; redirects <= 5; redirects++) { + if (!isAllowedUrl(current.toString())) throw new IOException("Hash URL refused"); + HttpURLConnection connection = (HttpURLConnection) current.openConnection(); + connection.setInstanceFollowRedirects(false); + connection.setConnectTimeout(TIMEOUT_MS); + connection.setReadTimeout(TIMEOUT_MS); + connection.setRequestProperty("User-Agent", "EVABRPUploader-Android"); + try { + int status = connection.getResponseCode(); + if (status >= 300 && status <= 399) { + String location = connection.getHeaderField("Location"); + if (location == null) throw new IOException("Hash redirect missing location"); + current = current.toURI().resolve(location).toURL(); + continue; + } + if (status != HttpURLConnection.HTTP_OK) + throw new IOException("Hash download returned " + status); + StringBuilder body = new StringBuilder(); + try (java.io.InputStream in = connection.getInputStream()) { + byte[] buffer = new byte[1024]; + int count; + while ((count = in.read(buffer)) != -1) { + if (body.length() + count > MAX_HASH_BYTES) + throw new IOException("Hash response too large"); + body.append(new String(buffer, 0, count, StandardCharsets.UTF_8)); + } + } + return body.toString(); + } catch (java.net.URISyntaxException e) { + throw new IOException("Invalid hash redirect", e); + } finally { + connection.disconnect(); + } + } + throw new IOException("Too many hash redirects"); + } + /** * Safe diagnostic name for the downloaded APK. The version comes from a remote * asset name, so it is reduced to a safe character set before it reaches a path. Callers that @@ -235,7 +331,8 @@ final class OtaUpdater { /** * Downloads into private cache. Every redirect URL is validated before it is followed. */ - static File download(Context context, Update update) { + static File download(Context context, Update update, String expectedSha256, + ProgressListener listener) { if (!isAllowedUrl(update.apkUrl)) { Log.w(TAG, "Refusing to download from " + update.apkUrl); return null; @@ -262,14 +359,32 @@ final class OtaUpdater { continue; } if (status != HttpURLConnection.HTTP_OK) return null; + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + long total = connection.getContentLengthLong(); + long received = 0; + int lastPercent = -1; try (FileOutputStream output = new FileOutputStream(temporary)) { try (java.io.InputStream input = connection.getInputStream()) { byte[] buffer = new byte[32 * 1024]; int count; - while ((count = input.read(buffer)) != -1) output.write(buffer, 0, count); + while ((count = input.read(buffer)) != -1) { + output.write(buffer, 0, count); + digest.update(buffer, 0, count); + received += count; + int percent = total > 0 ? (int) Math.min(100, received * 100 / total) : -1; + if (listener != null && percent != lastPercent) { + listener.onProgress(percent); + lastPercent = percent; + } + } } output.getFD().sync(); } + String actual = hex(digest.digest()); + if (!actual.equalsIgnoreCase(expectedSha256)) { + Log.w(TAG, "Downloaded APK SHA-256 mismatch"); + return null; + } if (!temporary.renameTo(target)) return null; return target; } finally { @@ -284,6 +399,12 @@ final class OtaUpdater { return null; } + private static String hex(byte[] bytes) { + StringBuilder out = new StringBuilder(bytes.length * 2); + for (byte value : bytes) out.append(String.format(Locale.US, "%02x", value)); + return out.toString(); + } + /** * 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 diff --git a/app/src/unstable/java/com/evsuite/abrp/UpdateHook.java b/app/src/unstable/java/com/evsuite/abrp/UpdateHook.java index e866046..11d7bb9 100644 --- a/app/src/unstable/java/com/evsuite/abrp/UpdateHook.java +++ b/app/src/unstable/java/com/evsuite/abrp/UpdateHook.java @@ -35,12 +35,27 @@ final class UpdateHook { /** Result of one check, for the UI to report. */ interface Listener { + default void onProgress(int percent) { } void onResult(String message); } - /** Fire-and-forget check on app start. Nothing is reported; failures stay in the log. */ + /** Startup only checks and notifies. Download/install remains an explicit driver action. */ static void checkInBackground(Context context) { - checkInBackground(context, null); + Context app = context.getApplicationContext(); + new Thread(() -> { + try { + String current = app.getPackageManager() + .getPackageInfo(app.getPackageName(), 0).versionName; + OtaUpdater.Update update = OtaUpdater.check(current); + if (update != null && update.hashUrl != null) + new android.os.Handler(android.os.Looper.getMainLooper()) + .post(() -> android.widget.Toast.makeText(app, + "Güncelleme hazır: " + update.versionName, + android.widget.Toast.LENGTH_LONG).show()); + } catch (Throwable t) { + Log.w(TAG, "Startup update check failed", t); + } + }, "ota-startup-check").start(); } /** @@ -60,7 +75,7 @@ final class UpdateHook { new Thread(() -> { String message; try { - message = runCheck(app); + message = runCheck(app, listener, main); } catch (Throwable t) { Log.w(TAG, "Update check threw", t); message = "Güncelleme kontrolü başarısız"; @@ -75,7 +90,8 @@ final class UpdateHook { } /** The blocking part. Returns a line describing what happened, for the UI. */ - private static String runCheck(Context app) throws IOException { + private static String runCheck(Context app, Listener listener, + android.os.Handler main) throws IOException { String current; try { current = app.getPackageManager() @@ -88,8 +104,18 @@ final class UpdateHook { if (update == null) return "Güncel (" + current + ")"; Log.i(TAG, "Update available: " + update.versionName); - File apk = OtaUpdater.download(app, update); - if (apk == null) return "İndirme başarısız (" + update.versionName + ")"; + String expectedHash; + try { + expectedHash = OtaUpdater.fetchExpectedSha256(update); + } catch (IOException e) { + Log.w(TAG, "OTA hash unavailable", e); + return "Güncelleme reddedildi: SHA-256 doğrulaması yok"; + } + File apk = OtaUpdater.download(app, update, expectedHash, percent -> { + if (listener != null) main.post(() -> listener.onProgress(percent)); + }); + if (apk == null) return "İndirme veya SHA-256 doğrulaması 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 streamed into the installer session.