feat: add OTA

This commit is contained in:
TCFDONMEZ
2026-09-29 17:04:59 +03:00
parent 511c166436
commit 48a7a0dd72
10 changed files with 292 additions and 38 deletions
+17
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
064930b9ffc4f7e99abe5867ca42258e35d756907b58d99440108ad72a901c48 FatihsMG4-unstable-2.2.7.apk
+25 -17
View File
@@ -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-<version>.apk` and nothing else.
`FatihsMG4-unstable-<version>.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
+2 -2
View File
@@ -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"
}
@@ -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 -> {
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);
android.widget.Toast.makeText(this, message,
android.widget.Toast.LENGTH_LONG).show();
dialog.dismiss();
new MaterialAlertDialogBuilder(MainActivity.this)
.setTitle(R.string.update_dialog_title)
.setMessage(message)
.setPositiveButton(android.R.string.ok, null)
.show();
}
});
}
+4 -1
View File
@@ -112,7 +112,10 @@
<string name="ota_install_success">Güncelleme kuruldu — uygulamayı yeniden başlatın</string>
<string name="ota_install_failed">Kurulum başarısız: %1$s</string>
<string name="update_check">Güncelleme kontrol et</string>
<string name="update_checking">Kontrol ediliyor…</string>
<string name="update_checking">Yeni sürüm kontrol ediliyor…</string>
<string name="update_dialog_title">Yazılım güncellemesi</string>
<string name="update_downloading">İndiriliyor ve doğrulanıyor…</string>
<string name="update_downloading_percent">İndiriliyor… %1$d%%</string>
<string name="about_action">About</string>
<string name="about_title">Fatih\'sMG4 Hakkında</string>
<string name="about_version">Version %1$s</string>
@@ -18,6 +18,7 @@ final class UpdateHook {
* Nothing implements it here.
*/
interface Listener {
default void onProgress(int percent) { }
void onResult(String message);
}
@@ -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);
}
@@ -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
@@ -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.