Files
mg4/app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java
T

515 lines
23 KiB
Java

package com.evsuite.abrp;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageInstaller;
import android.util.Log;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStreamReader;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
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.
*
* The stable channel deliberately has no self-update path: this class does not exist in a
* stable build. Unstable testers get updates without manual work, and accept that channel's
* risk.
*
* Security posture is the one EVProfile settled on in its own OTA work:
* - the APK URL comes from a remote JSON document and is never trusted: https only,
* exact-match host allowlist, checked again before it reaches the system downloader;
* - the downloaded APK must be signed by the same certificate as the running app, or it
* is deleted rather than offered for install;
* - both checks fail closed.
*/
final class OtaUpdater {
private static final String TAG = "OtaUpdater";
private static final String CACHE_PREFIX = "EVABRPUploader-ota-";
/**
* Pre-releases live here; the unstable channel tracks them.
*
* This fork's own repository, deliberately: an update pulled from upstream would be
* signed with a different key, and Android refuses to install over a platform-signed
* build with anything but the same signature. Pointing here means the check finds
* nothing until this repository publishes a release, which is the intended quiet.
*/
private static final String RELEASES_API =
"https://api.github.com/repos/fatihdonmezdev/MG4ABRP/releases";
/**
* Hosts an update may come from. The githubusercontent entries are the CDNs GitHub
* redirects release-asset downloads to; without them the download fails.
*/
private static final List<String> ALLOWED_HOSTS = Arrays.asList(
"api.github.com",
"github.com",
"objects.githubusercontent.com",
"release-assets.githubusercontent.com");
private static final java.util.regex.Pattern ASSET_VERSION =
java.util.regex.Pattern.compile("-(\\d[0-9.]*?)\\.apk$",
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() { }
static void purgeCachedApks(Context context) {
File[] files = context.getCacheDir().listFiles((dir, name) ->
name.startsWith(CACHE_PREFIX) && name.endsWith(".apk"));
if (files == null) return;
for (File file : files) {
if (!file.delete()) Log.w(TAG, "Could not purge cached OTA APK: " + file.getName());
}
}
/** Result of a successful check: null means there is no newer eligible release. */
static final class Update {
final String versionName;
final String apkUrl;
final String apkName;
final String hashUrl;
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.
*
* Rejects http (including an https -> http downgrade), unknown hosts, unparsable
* URLs, and lookalikes such as "github.com.attacker.net" — the host match is exact,
* never a suffix test.
*/
static boolean isAllowedUrl(String url) {
URI uri;
try {
uri = new URI(url);
} catch (Exception e) {
return false;
}
if (uri.getScheme() == null || !uri.getScheme().equalsIgnoreCase("https")) return false;
String host = uri.getHost();
return host != null && ALLOWED_HOSTS.contains(host.toLowerCase(java.util.Locale.US));
}
/**
* Numeric core of a version: "v1.2.3-unstable" -> [1, 2, 3].
*
* A segment with no digits becomes 0 rather than being dropped, so later segments do
* not shift left and turn a patch into a minor.
*/
static int[] segments(String version) {
String core = version.startsWith("v") || version.startsWith("V")
? version.substring(1) : version;
int cut = core.indexOf('+');
if (cut >= 0) core = core.substring(0, cut);
cut = core.indexOf('-');
if (cut >= 0) core = core.substring(0, cut);
String[] parts = core.split("\\.");
int[] out = new int[parts.length];
for (int i = 0; i < parts.length; i++) {
int digits = 0;
while (digits < parts[i].length() && Character.isDigit(parts[i].charAt(digits))) digits++;
try {
out[i] = digits == 0 ? 0 : Integer.parseInt(parts[i].substring(0, digits));
} catch (NumberFormatException e) {
out[i] = 0;
}
}
return out;
}
/**
* Version carried by an unstable asset name:
* "EVABRPUploader-unstable-1.0.42.apk" -> "1.0.42".
*
* The release tag is the fixed string "unstable" (one rolling pre-release), so the asset
* name is what identifies a build. Returns null when the name carries no version.
*/
static String versionFromAssetName(String assetName) {
java.util.regex.Matcher m = ASSET_VERSION.matcher(assetName);
return m.find() ? m.group(1) : null;
}
/** True if [remote] is a strictly higher version than [current]. */
static boolean isNewer(String remote, String current) {
int[] r = segments(remote);
int[] c = segments(current);
for (int i = 0; i < Math.max(r.length, c.length); i++) {
int rv = i < r.length ? r[i] : 0;
int cv = i < c.length ? c[i] : 0;
if (rv > cv) return true;
if (rv < cv) return false;
}
return false;
}
/**
* Asks GitHub for the newest pre-release and returns it if it beats [currentVersion].
* Runs on the caller's thread — never call from the main thread.
*/
static Update check(String currentVersion) throws IOException {
return check(currentVersion, (HttpURLConnection) new URL(RELEASES_API).openConnection());
}
/** Connection seam for exercising real response handling without a device or network. */
static Update check(String currentVersion, HttpURLConnection conn) throws IOException {
try {
conn.setInstanceFollowRedirects(false);
conn.setRequestProperty("Accept", "application/vnd.github.v3+json");
conn.setRequestProperty("User-Agent", "EVABRPUploader-Android");
conn.setConnectTimeout(TIMEOUT_MS);
conn.setReadTimeout(TIMEOUT_MS);
int status = conn.getResponseCode();
if (status != HttpURLConnection.HTTP_OK)
throw new IOException("Release API returned " + status);
StringBuilder body = new StringBuilder();
try (BufferedReader br = new BufferedReader(new InputStreamReader(
conn.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = br.readLine()) != null) body.append(line);
}
return selectUpdate(new JSONArray(body.toString()), currentVersion);
} catch (JSONException e) {
throw new IOException("Invalid release response", e);
} finally {
conn.disconnect();
}
}
/** Scan every asset of every pre-release; asset ordering does not imply version order. */
static Update selectUpdate(JSONArray releases, String currentVersion) throws JSONException {
Update best = null;
for (int i = 0; i < releases.length(); i++) {
JSONObject release = releases.getJSONObject(i);
if (!release.optBoolean("prerelease", false) || release.optBoolean("draft", false)) continue;
JSONArray assets = release.optJSONArray("assets");
if (assets == null) continue;
for (int a = 0; a < assets.length(); a++) {
JSONObject asset = assets.getJSONObject(a);
String name = asset.optString("name", "");
if (!name.toLowerCase(Locale.US).contains("unstable")) continue;
String version = versionFromAssetName(name);
if (version == null || !isNewer(version, currentVersion)) continue;
if (best != null && !isNewer(version, best.versionName)) continue;
String url = asset.optString("browser_download_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
* look for an already-downloaded update must use this same name.
*/
static String downloadFileName(String versionName) {
String safe = (versionName == null || versionName.isEmpty())
? "unknown"
: versionName.toLowerCase(Locale.US).replaceAll("[^a-z0-9._-]", "_");
return "EVABRPUploader-unstable-" + safe + ".apk";
}
/**
* Downloads into private cache. Every redirect URL is validated before it is followed.
*/
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;
}
File target = new File(context.getCacheDir(), CACHE_PREFIX
+ java.util.UUID.randomUUID() + ".apk");
File temporary = null;
try {
temporary = File.createTempFile(CACHE_PREFIX, ".apk", context.getCacheDir());
URL current = new URL(update.apkUrl);
for (int redirects = 0; redirects <= 5; redirects++) {
if (!isAllowedUrl(current.toString())) return null;
HttpURLConnection connection = (HttpURLConnection) current.openConnection();
connection.setInstanceFollowRedirects(false);
connection.setConnectTimeout(TIMEOUT_MS);
connection.setReadTimeout(TIMEOUT_MS);
try {
int status = connection.getResponseCode();
if (status >= 300 && status <= 399) {
String location = connection.getHeaderField("Location");
if (location == null) return null;
current = current.toURI().resolve(location).toURL();
if (!isAllowedUrl(current.toString())) return null;
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);
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 {
connection.disconnect();
}
}
} catch (Exception e) {
Log.w(TAG, "Update download failed", e);
} finally {
if (temporary != null && temporary.exists()) temporary.delete();
}
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. The PackageInstaller is the authority for signing-certificate
* compatibility, matching the proven DriveHub_Dort flow on this head unit.
*/
enum InstallResult {
/** The package manager cannot parse the archive, or it is for another package. */
UNREADABLE_ARCHIVE,
/** Handed to the platform. The outcome arrives at {@link OtaInstallResultReceiver}. */
SESSION_STARTED,
/** The session could not be created, written or committed. */
SESSION_FAILED
}
/**
* Hands [apk] to the platform's {@link PackageInstaller}.
*
* This used to shell out to {@code pm install -r}. That fails on this head unit: the APK
* lives in the app's private cache, and the package manager service opens the path from
* its own process and SELinux context, where that directory is not readable. The failure
* surfaced as a signature error, which it never was.
*
* A session takes no path. We open the archive ourselves, as the app, and stream the bytes
* into the session — so there is no second process that has to be able to read our cache.
* The bytes are fully written before commit, which is why the caller may delete the staged
* file as soon as this returns.
*/
static InstallResult install(Context context, File apk) {
if (!archiveIsThisPackage(context, apk)) return InstallResult.UNREADABLE_ARCHIVE;
try {
commitSession(context, apk);
return InstallResult.SESSION_STARTED;
} catch (Throwable t) {
Log.w(TAG, "OTA session install failed", t);
return InstallResult.SESSION_FAILED;
}
}
/**
* True if the archive parses and declares our own package name.
*
* The package name is checked here before any bytes reach an installer session. Android's
* PackageInstaller then performs the authoritative signing-certificate and downgrade checks.
*/
private static boolean archiveIsThisPackage(Context context, File apk) {
try {
android.content.pm.PackageInfo info = context.getPackageManager()
.getPackageArchiveInfo(apk.getAbsolutePath(), 0);
if (info == null || info.packageName == null) {
Log.w(TAG, "OTA archive cannot be parsed by the package manager");
return false;
}
if (!context.getPackageName().equals(info.packageName)) {
Log.w(TAG, "OTA archive is for " + info.packageName + ", not "
+ context.getPackageName());
return false;
}
Log.i(TAG, "OTA archive ok: " + info.packageName + " " + info.versionName);
return true;
} catch (Exception e) {
Log.w(TAG, "OTA archive check threw: " + e.getMessage());
return false;
}
}
private static void commitSession(Context context, File apk) throws Exception {
PackageInstaller installer = context.getPackageManager().getPackageInstaller();
PackageInstaller.SessionParams params = new PackageInstaller.SessionParams(
PackageInstaller.SessionParams.MODE_FULL_INSTALL);
if (android.os.Build.VERSION.SDK_INT >= 31) {
// Only honoured for an installer the platform already trusts; harmless otherwise,
// and this unit is API 28 where the field does not exist at all.
params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED);
}
int sessionId = installer.createSession(params);
PackageInstaller.Session session = null;
boolean committed = false;
try {
session = installer.openSession(sessionId);
try (java.io.InputStream in = new java.io.FileInputStream(apk);
java.io.OutputStream out = session.openWrite("base.apk", 0, apk.length())) {
byte[] buffer = new byte[64 * 1024];
int count;
while ((count = in.read(buffer)) != -1) out.write(buffer, 0, count);
session.fsync(out);
}
Intent callback = new Intent(context, OtaInstallResultReceiver.class)
.setAction(OtaInstallResultReceiver.ACTION_INSTALL_RESULT)
.setPackage(context.getPackageName());
// MUTABLE: the platform fills in EXTRA_STATUS and, when it wants the driver to
// confirm, EXTRA_INTENT. An immutable PendingIntent would arrive empty.
PendingIntent pending = PendingIntent.getBroadcast(context, sessionId, callback,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE);
session.commit(pending.getIntentSender());
committed = true;
Log.i(TAG, "OTA session " + sessionId + " committed (" + apk.length() + " bytes)");
} finally {
// Abandon failed staging, but always close our local handle after a commit too.
if (!committed) {
try { installer.abandonSession(sessionId); } catch (Throwable ignored) { }
}
if (session != null) {
try { session.close(); } catch (Throwable ignored) { }
}
}
}
}