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
|
[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.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
|
## [2.2.6] - 2026-09-29
|
||||||
|
|
||||||
### Changed
|
### 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
|
## 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
|
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.
|
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.
|
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
|
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
|
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
|
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
|
The vehicle artifact uses `com.evsuite.abrp.unstable`, `android.uid.system`, and the
|
||||||
DriveHub_Kamera platform key. Its certificate SHA-256 must be
|
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" |
|
| 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 | `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
|
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
|
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`:
|
1. **Raise the version.** `app/build.gradle`, `defaultConfig`:
|
||||||
|
|
||||||
```gradle
|
```gradle
|
||||||
versionCode 23 // must increase — Android orders updates by this, not by name
|
versionCode 24 // must increase — Android orders updates by this, not by name
|
||||||
versionName "2.2.6"
|
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
|
Check what the installed build reports before picking a number: the app's About
|
||||||
dialog shows it, and `isNewer` compares numerically segment by segment.
|
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 \
|
"$BT/apksigner" sign \
|
||||||
--key "$KEYS/platform.pk8" \
|
--key "$KEYS/platform.pk8" \
|
||||||
--cert "$KEYS/platform.x509.pem" \
|
--cert "$KEYS/platform.x509.pem" \
|
||||||
--out FatihsMG4-unstable-2.2.6.apk \
|
--out FatihsMG4-unstable-2.2.7.apk \
|
||||||
aligned.apk
|
aligned.apk
|
||||||
|
|
||||||
rm aligned.apk
|
rm aligned.apk
|
||||||
```
|
```
|
||||||
|
|
||||||
The output filename **is** the version the updater reads. Name it
|
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`:
|
4. **Verify the signer** before uploading. It must say `CN=Android`:
|
||||||
|
|
||||||
```bash
|
```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:
|
5. **Publish.** On github.com/fatihdonmezdev/MG4ABRP → Releases → Draft a new release:
|
||||||
- tag: `unstable` (reuse it; the rolling tag is the design)
|
- tag: `unstable` (reuse it; the rolling tag is the design)
|
||||||
- **tick "Set as a pre-release"** ← the whole thing is inert without this
|
- **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
|
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
|
## When it says something else
|
||||||
|
|
||||||
| Toast | Meaning |
|
| Toast | Meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `Güncel (2.2.2.0-unstable)` | No release beat the installed version. Check the pre-release tick and the asset filename. |
|
| `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. |
|
| `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: 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. |
|
| `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
|
Still unverified on the car: whether this build installs silently or raises that
|
||||||
confirmation. Either way it installs, which the old path did not.
|
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
|
`UpdateHook.checkInBackground(this)` performs an availability-only check at app start and
|
||||||
app start, but the app is rarely launched — the upload service does the work, and the
|
shows a notification toast when a complete APK + hash pair exists. It never downloads or
|
||||||
driver does not open the UI on every trip. The button is there so an update can be pulled
|
installs. The refresh button is the only trigger for download, verification and installation.
|
||||||
deliberately rather than waiting for a launch that may not come.
|
|
||||||
|
|
||||||
The upload service does **not** check for updates on its tick. Installing a new APK
|
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
|
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"
|
applicationId "com.evsuite.abrp"
|
||||||
minSdk 27
|
minSdk 27
|
||||||
targetSdk 36
|
targetSdk 36
|
||||||
versionCode 23
|
versionCode 25
|
||||||
versionName "2.2.6"
|
versionName "2.2.8"
|
||||||
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
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
|
* 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 result lands, whatever that result is.
|
||||||
*
|
*
|
||||||
* The outcome goes to a toast because there is nowhere better: the check is reachable
|
* A modal progress surface keeps the driver informed while the APK is downloaded and
|
||||||
* from every page, so no one page owns the answer.
|
* SHA-256 verified; the final result remains visible until dismissed.
|
||||||
*/
|
*/
|
||||||
private void checkForUpdate(View button) {
|
private void checkForUpdate(View button) {
|
||||||
button.setEnabled(false);
|
button.setEnabled(false);
|
||||||
android.widget.Toast.makeText(this, R.string.update_checking,
|
android.widget.LinearLayout panel = new android.widget.LinearLayout(this);
|
||||||
android.widget.Toast.LENGTH_SHORT).show();
|
panel.setOrientation(android.widget.LinearLayout.VERTICAL);
|
||||||
UpdateHook.checkInBackground(this, message -> {
|
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);
|
button.setEnabled(true);
|
||||||
android.widget.Toast.makeText(this, message,
|
dialog.dismiss();
|
||||||
android.widget.Toast.LENGTH_LONG).show();
|
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_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="ota_install_failed">Kurulum başarısız: %1$s</string>
|
||||||
<string name="update_check">Güncelleme kontrol et</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_action">About</string>
|
||||||
<string name="about_title">Fatih\'sMG4 Hakkında</string>
|
<string name="about_title">Fatih\'sMG4 Hakkında</string>
|
||||||
<string name="about_version">Version %1$s</string>
|
<string name="about_version">Version %1$s</string>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ final class UpdateHook {
|
|||||||
* Nothing implements it here.
|
* Nothing implements it here.
|
||||||
*/
|
*/
|
||||||
interface Listener {
|
interface Listener {
|
||||||
|
default void onProgress(int percent) { }
|
||||||
void onResult(String message);
|
void onResult(String message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,28 @@ public class OtaUpdaterTest {
|
|||||||
assertArrayEquals(new int[]{1, 0, 5}, OtaUpdater.segments("1.x.5"));
|
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
|
@Test
|
||||||
public void newestAssetWinsEvenWhenItIsLaterInTheSameRelease() throws Exception {
|
public void newestAssetWinsEvenWhenItIsLaterInTheSameRelease() throws Exception {
|
||||||
JSONArray releases = new JSONArray().put(release(true,
|
JSONArray releases = new JSONArray().put(release(true,
|
||||||
@@ -201,6 +223,22 @@ public class OtaUpdaterTest {
|
|||||||
|
|
||||||
private static JSONObject release(boolean prerelease, JSONObject... assets) throws Exception {
|
private static JSONObject release(boolean prerelease, JSONObject... assets) throws Exception {
|
||||||
JSONArray list = new JSONArray();
|
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);
|
for (JSONObject asset : assets) list.put(asset);
|
||||||
return new JSONObject().put("prerelease", prerelease).put("assets", list);
|
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.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Over-the-air updater — UNSTABLE BUILDS ONLY.
|
* Over-the-air updater — UNSTABLE BUILDS ONLY.
|
||||||
@@ -68,6 +69,7 @@ final class OtaUpdater {
|
|||||||
java.util.regex.Pattern.CASE_INSENSITIVE);
|
java.util.regex.Pattern.CASE_INSENSITIVE);
|
||||||
|
|
||||||
private static final int TIMEOUT_MS = 10_000;
|
private static final int TIMEOUT_MS = 10_000;
|
||||||
|
private static final int MAX_HASH_BYTES = 16 * 1024;
|
||||||
|
|
||||||
private OtaUpdater() { }
|
private OtaUpdater() { }
|
||||||
|
|
||||||
@@ -84,13 +86,21 @@ final class OtaUpdater {
|
|||||||
static final class Update {
|
static final class Update {
|
||||||
final String versionName;
|
final String versionName;
|
||||||
final String apkUrl;
|
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.versionName = versionName;
|
||||||
this.apkUrl = apkUrl;
|
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.
|
* 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 (version == null || !isNewer(version, currentVersion)) continue;
|
||||||
if (best != null && !isNewer(version, best.versionName)) continue;
|
if (best != null && !isNewer(version, best.versionName)) continue;
|
||||||
String url = asset.optString("browser_download_url", "");
|
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;
|
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
|
* 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
|
* 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.
|
* 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)) {
|
if (!isAllowedUrl(update.apkUrl)) {
|
||||||
Log.w(TAG, "Refusing to download from " + update.apkUrl);
|
Log.w(TAG, "Refusing to download from " + update.apkUrl);
|
||||||
return null;
|
return null;
|
||||||
@@ -262,14 +359,32 @@ final class OtaUpdater {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (status != HttpURLConnection.HTTP_OK) return null;
|
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 (FileOutputStream output = new FileOutputStream(temporary)) {
|
||||||
try (java.io.InputStream input = connection.getInputStream()) {
|
try (java.io.InputStream input = connection.getInputStream()) {
|
||||||
byte[] buffer = new byte[32 * 1024];
|
byte[] buffer = new byte[32 * 1024];
|
||||||
int count;
|
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();
|
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;
|
if (!temporary.renameTo(target)) return null;
|
||||||
return target;
|
return target;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -284,6 +399,12 @@ final class OtaUpdater {
|
|||||||
return null;
|
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,
|
* 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
|
* 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. */
|
/** Result of one check, for the UI to report. */
|
||||||
interface Listener {
|
interface Listener {
|
||||||
|
default void onProgress(int percent) { }
|
||||||
void onResult(String message);
|
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) {
|
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(() -> {
|
new Thread(() -> {
|
||||||
String message;
|
String message;
|
||||||
try {
|
try {
|
||||||
message = runCheck(app);
|
message = runCheck(app, listener, main);
|
||||||
} catch (Throwable t) {
|
} catch (Throwable t) {
|
||||||
Log.w(TAG, "Update check threw", t);
|
Log.w(TAG, "Update check threw", t);
|
||||||
message = "Güncelleme kontrolü başarısız";
|
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. */
|
/** 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;
|
String current;
|
||||||
try {
|
try {
|
||||||
current = app.getPackageManager()
|
current = app.getPackageManager()
|
||||||
@@ -88,8 +104,18 @@ final class UpdateHook {
|
|||||||
if (update == null) return "Güncel (" + current + ")";
|
if (update == null) return "Güncel (" + current + ")";
|
||||||
|
|
||||||
Log.i(TAG, "Update available: " + update.versionName);
|
Log.i(TAG, "Update available: " + update.versionName);
|
||||||
File apk = OtaUpdater.download(app, update);
|
String expectedHash;
|
||||||
if (apk == null) return "İndirme başarısız (" + update.versionName + ")";
|
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
|
// 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.
|
// linger, and an accepted one has already been streamed into the installer session.
|
||||||
|
|||||||
Reference in New Issue
Block a user