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
@@ -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.