feat: add OTA
This commit is contained in:
+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 -> {
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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