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
+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 -> {
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();
}
});
}
+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.