feat: add OTA
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
064930b9ffc4f7e99abe5867ca42258e35d756907b58d99440108ad72a901c48 FatihsMG4-unstable-2.2.7.apk
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user