App 2.16.0, server 2.6.0: over-the-air updates from our own repository

The car updates itself again, from this fork's own releases, while the
repository stays private:

- publish-car (flake) creates the pre-release car-<version> on the
  forge and attaches the APK and its .sha256. It needs a forge token
  that may write releases, from ~/.config/nilsmg4/forge-token or
  $FORGE_TOKEN; it is sent in a header and never printed.
- The server relays releases and attachments (/v1/app/releases,
  /v1/app/assets/...), read with a read-only FORGE_TOKEN from its
  Secret, GitHub-shaped with download URLs back at itself. Redirects
  away from the forge are not followed; off (404) without a token.
- The app asks its server with its own paired token, allows exactly
  the server's host, downloads the APK itself instead of through the
  DownloadManager (so the token stays in the app), verifies SHA-256 and
  package name, and tries a PackageInstaller session (it may install
  silently as the system user) before falling back to Downloads.

OTA.md is rewritten for this flow; the k8s and server READMEs explain
the new token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
This commit is contained in:
2026-10-18 18:08:08 +02:00
co-authored by Claude Opus 5.5
parent f88249427f
commit e5549dddb0
17 changed files with 677 additions and 306 deletions
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId "com.stinnesbeck.nilsmg4"
minSdk 27
targetSdk 36
versionCode 83
versionName "2.15.0"
versionCode 84
versionName "2.16.0"
// Nothing about the server ships in the APK: its URL and ingest token are entered in
// the app's Server tab (ServerConfig), and the database is only reachable by the server.
+1 -1
View File
@@ -198,7 +198,7 @@
<string name="notif_title">ABRP Uploader</string>
<string name="notif_action_stop">Stop</string>
<string name="ota_install_success">Update installed — restart the app</string>
<string name="ota_install_failed">Installation failed: %1$s</string>
<string name="ota_install_failed">Installation failed: %1$s. The APK is in Downloads.</string>
<string name="update_check">Check for updates</string>
<string name="update_checking">Checking for a new OTA version…</string>
<string name="update_dialog_title">Software update</string>
@@ -24,46 +24,55 @@ import java.nio.charset.StandardCharsets;
*/
public class OtaUpdaterTest {
// ---- URL allowlist ----
// ---- URL allowlist: exactly the configured car server ----
private static final String HOST = "car.example.com";
@Test
public void httpsFromAnAllowedHostIsAccepted() {
assertTrue(OtaUpdater.isAllowedUrl(
"https://github.com/malys/EVABRPUploader/releases/download/v1.2.0/app.apk"));
assertTrue(OtaUpdater.isAllowedUrl(
"https://objects.githubusercontent.com/github-production-release-asset/x.apk"));
public void httpsFromTheServerIsAccepted() {
assertTrue(OtaUpdater.isAllowedUrl("https://car.example.com/v1/app/assets/7/11/app.apk", HOST));
}
@Test
public void httpIsRejectedEvenOnAnAllowedHost() {
assertFalse(OtaUpdater.isAllowedUrl("http://github.com/malys/app.apk"));
public void httpIsRejectedEvenFromTheServer() {
assertFalse(OtaUpdater.isAllowedUrl("http://car.example.com/v1/app/assets/7/11/app.apk", HOST));
}
@Test
public void foreignHostsAreRejected() {
assertFalse(OtaUpdater.isAllowedUrl("https://evil.example.com/app.apk"));
assertFalse(OtaUpdater.isAllowedUrl("https://evil.example.com/app.apk", HOST));
assertFalse(OtaUpdater.isAllowedUrl("https://github.com/fatihdonmezdev/FatihsMG4/app.apk", HOST));
}
@Test
public void lookalikeHostsAreRejected() {
// Exact match, never a suffix test.
assertFalse(OtaUpdater.isAllowedUrl("https://github.com.attacker.net/app.apk"));
assertFalse(OtaUpdater.isAllowedUrl("https://evil-github.com/app.apk"));
assertFalse(OtaUpdater.isAllowedUrl("https://notgithub.com/app.apk"));
assertFalse(OtaUpdater.isAllowedUrl("https://car.example.com.attacker.net/app.apk", HOST));
assertFalse(OtaUpdater.isAllowedUrl("https://evil-car.example.com/app.apk", HOST));
assertFalse(OtaUpdater.isAllowedUrl("https://example.com/app.apk", HOST));
}
@Test
public void unparsableOrNonHttpUrlsAreRejected() {
assertFalse(OtaUpdater.isAllowedUrl(""));
assertFalse(OtaUpdater.isAllowedUrl("not a url"));
assertFalse(OtaUpdater.isAllowedUrl("ftp://github.com/app.apk"));
assertFalse(OtaUpdater.isAllowedUrl("file:///sdcard/Download/app.apk"));
assertFalse(OtaUpdater.isAllowedUrl("", HOST));
assertFalse(OtaUpdater.isAllowedUrl(null, HOST));
assertFalse(OtaUpdater.isAllowedUrl("not a url", HOST));
assertFalse(OtaUpdater.isAllowedUrl("ftp://car.example.com/app.apk", HOST));
assertFalse(OtaUpdater.isAllowedUrl("file:///sdcard/Download/app.apk", HOST));
assertFalse(OtaUpdater.isAllowedUrl("https://car.example.com/app.apk", null)); // no server set up
}
@Test
public void hostMatchingIgnoresCase() {
assertTrue(OtaUpdater.isAllowedUrl("https://GitHub.com/malys/app.apk"));
assertTrue(OtaUpdater.isAllowedUrl("HTTPS://github.com/malys/app.apk"));
assertTrue(OtaUpdater.isAllowedUrl("https://Car.Example.com/app.apk", HOST));
assertTrue(OtaUpdater.isAllowedUrl("HTTPS://car.example.com/app.apk", HOST));
}
@Test
public void theAllowedHostIsTheConfiguredServers() {
assertEquals("car.example.com", OtaUpdater.allowedHost(new ServerConfig("https://Car.Example.com", "t", "i")));
assertEquals("https://car.example.com/v1/app/releases",
OtaUpdater.releasesUrl(new ServerConfig("https://car.example.com", "t", "i")));
}
// ---- Version comparison ----
@@ -138,7 +147,7 @@ public class OtaUpdaterTest {
@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");
OtaUpdater.Update update = OtaUpdater.selectUpdate(releases, "2.2.6.0-unstable", HOST);
assertEquals("2.2.7", update.versionName);
assertNull(update.hashUrl);
}
@@ -148,7 +157,7 @@ public class OtaUpdaterTest {
JSONArray releases = new JSONArray().put(release(true,
asset("2.2.3"), asset("2.2.6"), asset("2.2.4")))
.put(release(true, asset("2.2.5")));
OtaUpdater.Update update = OtaUpdater.selectUpdate(releases, "2.2.2.0-unstable");
OtaUpdater.Update update = OtaUpdater.selectUpdate(releases, "2.2.2.0-unstable", HOST);
assertEquals("2.2.6", update.versionName);
assertTrue(update.apkUrl.endsWith("NilsMG4-unstable-2.2.6.apk"));
}
@@ -162,14 +171,14 @@ public class OtaUpdaterTest {
.put(release(false, asset("9.0.0")))
.put(release(true, asset("9.0.0")).put("draft", true))
.put(release(true, foreign, unversioned, stableName, asset("2.2.5")));
assertEquals("2.2.5", OtaUpdater.selectUpdate(releases, "2.2.4.0-unstable").versionName);
assertEquals("2.2.5", OtaUpdater.selectUpdate(releases, "2.2.4.0-unstable", HOST).versionName);
}
@Test
public void successfulCheckFindsUpdateAndDisconnects() throws Exception {
Response response = new Response(200,
new JSONArray().put(release(true, asset("2.2.5"))).toString());
assertEquals("2.2.5", OtaUpdater.check("2.2.4.0-unstable", response).versionName);
assertEquals("2.2.5", OtaUpdater.check("2.2.4.0-unstable", response, HOST).versionName);
assertTrue(response.disconnected);
}
@@ -177,7 +186,7 @@ public class OtaUpdaterTest {
public void successfulCheckWithOnlyOlderOrEqualVersionsIsCurrent() throws Exception {
Response response = new Response(200,
new JSONArray().put(release(true, asset("2.2.3"), asset("2.2.5"))).toString());
assertNull(OtaUpdater.check("2.2.5.0-unstable", response));
assertNull(OtaUpdater.check("2.2.5.0-unstable", response, HOST));
assertTrue(response.disconnected);
}
@@ -185,7 +194,7 @@ public class OtaUpdaterTest {
public void rateLimitMustNotBeReportedAsCurrent() throws Exception {
Response response = new Response(403, "{}");
try {
OtaUpdater.check("2.2.4.0-unstable", response);
OtaUpdater.check("2.2.4.0-unstable", response, HOST);
} finally {
assertTrue(response.disconnected);
}
@@ -195,7 +204,7 @@ public class OtaUpdaterTest {
public void malformedResponseMustNotBeReportedAsCurrent() throws Exception {
Response response = new Response(200, "<html>unavailable</html>");
try {
OtaUpdater.check("2.2.4.0-unstable", response);
OtaUpdater.check("2.2.4.0-unstable", response, HOST);
} finally {
assertTrue(response.disconnected);
}
@@ -209,7 +218,7 @@ public class OtaUpdaterTest {
}
};
try {
OtaUpdater.check("2.2.4.0-unstable", response);
OtaUpdater.check("2.2.4.0-unstable", response, HOST);
} finally {
assertTrue(response.disconnected);
}
@@ -218,7 +227,7 @@ public class OtaUpdaterTest {
private static JSONObject asset(String version) throws Exception {
String name = "NilsMG4-unstable-" + version + ".apk";
return new JSONObject().put("name", name).put("browser_download_url",
"https://github.com/fatihdonmezdev/FatihsMG4/releases/download/unstable/" + name);
"https://car.example.com/v1/app/assets/7/11/" + name);
}
private static JSONObject release(boolean prerelease, JSONObject... assets) throws Exception {
@@ -249,7 +258,7 @@ public class OtaUpdaterTest {
boolean disconnected;
Response(int status, String body) throws Exception {
super(new URL("https://api.github.com/repos/fatihdonmezdev/FatihsMG4/releases"));
super(new URL("https://car.example.com/v1/app/releases"));
this.status = status;
this.body = body;
}
@@ -262,8 +271,6 @@ public class OtaUpdaterTest {
@Override public void connect() { }
}
// The `pm install -r` exit-code test that stood here is gone with the shell-out it
// covered. Installation is now a PackageInstaller session, whose every step needs a real
// PackageManager — there is no pure part of it left to exercise on the JVM. What used to
// be asserted here is now reported by OtaInstallResultReceiver, on the car.
// Installation is a PackageInstaller session, whose every step needs a real PackageManager
// — nothing of it can be exercised on the JVM. OtaInstallResultReceiver reports it on the car.
}
@@ -1,17 +1,15 @@
package com.evsuite.abrp;
import android.app.PendingIntent;
import android.app.DownloadManager;
import android.app.PendingIntent;
import android.content.ClipData;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageInstaller;
import android.content.pm.ResolveInfo;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;
import android.os.ParcelFileDescriptor;
import android.provider.Settings;
import android.util.Log;
@@ -43,43 +41,34 @@ import java.security.MessageDigest;
* 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.
* Releases come from the car server (server/releases.go), which relays them from the private
* forge repository: the car asks with its own paired token and never holds a forge
* credential. The server only carries bytes; the checks are all here:
* - every URL in the release list is untrusted: https only, and exactly the configured
* server's host (no suffix match), checked again before anything is fetched;
* - the APK must match the SHA-256 published next to it, or it is deleted;
* - it must be this app's package, and the installer refuses any other signing key;
* - every check fails closed.
*/
final class OtaUpdater {
private static final String TAG = "OtaUpdater";
private static final String CACHE_PREFIX = "NilsMG4-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.
*
* Disabled for now: the .invalid TLD (RFC 2606) never resolves, so every check fails
* with "Update check failed" instead of offering upstream's builds over this one. To
* re-enable, point this at a fork's releases API, e.g.
* "https://api.github.com/repos/<owner>/<repo>/releases".
*/
private static final String RELEASES_API =
"https://updates.invalid/repos/fatihdonmezdev/FatihsMG4/releases";
/** The release list on the car server, for the server configured in the app. */
static String releasesUrl(ServerConfig config) {
return config.url + "/v1/app/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");
/** The one host updates may come from: the configured server's. Null if unusable. */
static String allowedHost(ServerConfig config) {
try {
String host = new URI(config.url).getHost();
return host == null ? null : host.toLowerCase(Locale.US);
} catch (Exception e) {
return null;
}
}
private static final java.util.regex.Pattern ASSET_VERSION =
java.util.regex.Pattern.compile("-(\\d[0-9.]*?)\\.apk$",
@@ -87,6 +76,7 @@ final class OtaUpdater {
private static final int TIMEOUT_MS = 10_000;
private static final int MAX_HASH_BYTES = 16 * 1024;
private static final long MAX_APK_BYTES = 64L * 1024 * 1024;
private OtaUpdater() { }
@@ -119,13 +109,14 @@ final class OtaUpdater {
}
/**
* True if [url] is https and points at an allowed host.
* True if [url] is https and points at exactly [allowedHost].
*
* 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.
* Rejects http (including an https -> http downgrade), other hosts, unparsable URLs, and
* lookalikes such as "car.example.com.attacker.net" — the host match is exact, never a
* suffix test.
*/
static boolean isAllowedUrl(String url) {
static boolean isAllowedUrl(String url, String allowedHost) {
if (url == null || allowedHost == null) return false;
URI uri;
try {
uri = new URI(url);
@@ -134,7 +125,7 @@ final class OtaUpdater {
}
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));
return host != null && host.toLowerCase(Locale.US).equals(allowedHost.toLowerCase(Locale.US));
}
/**
@@ -191,18 +182,21 @@ final class OtaUpdater {
}
/**
* 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.
* Asks the car server 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());
static Update check(ServerConfig config, String currentVersion) throws IOException {
HttpURLConnection conn = (HttpURLConnection) new URL(releasesUrl(config)).openConnection();
// The car's own token, in a header; never in a URL or a log line.
conn.setRequestProperty("Authorization", "Bearer " + config.token);
return check(currentVersion, conn, allowedHost(config));
}
/** Connection seam for exercising real response handling without a device or network. */
static Update check(String currentVersion, HttpURLConnection conn) throws IOException {
static Update check(String currentVersion, HttpURLConnection conn, String allowedHost) throws IOException {
try {
conn.setInstanceFollowRedirects(false);
conn.setRequestProperty("Accept", "application/vnd.github.v3+json");
conn.setRequestProperty("Accept", "application/json");
conn.setRequestProperty("User-Agent", "NilsMG4-Android");
conn.setConnectTimeout(TIMEOUT_MS);
conn.setReadTimeout(TIMEOUT_MS);
@@ -217,7 +211,7 @@ final class OtaUpdater {
while ((line = br.readLine()) != null) body.append(line);
}
return selectUpdate(new JSONArray(body.toString()), currentVersion);
return selectUpdate(new JSONArray(body.toString()), currentVersion, allowedHost);
} catch (JSONException e) {
throw new IOException("Invalid release response", e);
} finally {
@@ -226,7 +220,7 @@ final class OtaUpdater {
}
/** Scan every asset of every pre-release; asset ordering does not imply version order. */
static Update selectUpdate(JSONArray releases, String currentVersion) throws JSONException {
static Update selectUpdate(JSONArray releases, String currentVersion, String allowedHost) throws JSONException {
Update best = null;
for (int i = 0; i < releases.length(); i++) {
JSONObject release = releases.getJSONObject(i);
@@ -241,24 +235,24 @@ 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", "");
String hashUrl = findHashUrl(assets, name);
String hashUrl = findHashUrl(assets, name, allowedHost);
// 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);
if (isAllowedUrl(url, allowedHost)) best = new Update(version, url, name, hashUrl);
}
}
return best;
}
private static String findHashUrl(JSONArray assets, String apkName) throws JSONException {
private static String findHashUrl(JSONArray assets, String apkName, String allowedHost) 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 (!isAllowedUrl(url, allowedHost)) continue;
if (exact.equalsIgnoreCase(name)) return url;
if ("SHA256SUMS".equalsIgnoreCase(name)) sums = url;
}
@@ -285,20 +279,21 @@ final class OtaUpdater {
return value != null && value.matches("(?i)[a-f0-9]{64}");
}
static String fetchExpectedSha256(Update update) throws IOException {
if (update == null || !isAllowedUrl(update.hashUrl))
static String fetchExpectedSha256(Update update, ServerConfig config) throws IOException {
if (update == null || !isAllowedUrl(update.hashUrl, allowedHost(config)))
throw new IOException("Missing SHA-256 sidecar");
String body = readSmallText(update.hashUrl);
String body = readSmallText(update.hashUrl, config);
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 {
private static String readSmallText(String initialUrl, ServerConfig config) throws IOException {
URL current = new URL(initialUrl);
for (int redirects = 0; redirects <= 5; redirects++) {
if (!isAllowedUrl(current.toString())) throw new IOException("Hash URL refused");
if (!isAllowedUrl(current.toString(), allowedHost(config))) throw new IOException("Hash URL refused");
HttpURLConnection connection = (HttpURLConnection) current.openConnection();
connection.setRequestProperty("Authorization", "Bearer " + config.token);
connection.setInstanceFollowRedirects(false);
connection.setConnectTimeout(TIMEOUT_MS);
connection.setReadTimeout(TIMEOUT_MS);
@@ -346,64 +341,64 @@ final class OtaUpdater {
}
/**
* Downloads into private cache. Every redirect URL is validated before it is followed.
* Downloads the APK from the car server into the public Downloads folder, hashing it on
* the way. Done here rather than with the system DownloadManager, so the car's token stays
* in this app instead of another app's download database. A wrong hash deletes the file.
* No redirects: the server streams the file itself.
*/
static File download(Context context, Update update, String expectedSha256,
static File download(Update update, String expectedSha256, ServerConfig config,
ProgressListener listener) {
if (!isAllowedUrl(update.apkUrl)) {
Log.w(TAG, "Refusing to download from " + update.apkUrl);
if (!isAllowedUrl(update.apkUrl, allowedHost(config))) {
Log.w(TAG, "Refusing to download from an unexpected host");
return null;
}
DownloadManager manager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE);
if (manager == null) return null;
String publicName = downloadFileName(update.versionName);
File publicFile = new File(Environment.getExternalStoragePublicDirectory(
Environment.DIRECTORY_DOWNLOADS), publicName);
if (publicFile.exists() && !publicFile.delete()) return null;
long downloadId = -1L;
File downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
if (downloads == null || (!downloads.isDirectory() && !downloads.mkdirs())) return null;
File target = new File(downloads, downloadFileName(update.versionName));
HttpURLConnection connection = null;
boolean ok = false;
try {
DownloadManager.Request request = new DownloadManager.Request(Uri.parse(update.apkUrl))
.setTitle("Nils' MG4 " + update.versionName)
.setDescription("Downloading update")
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, publicName)
.setAllowedOverMetered(true)
.setAllowedOverRoaming(true);
downloadId = manager.enqueue(request);
while (true) {
try (Cursor cursor = manager.query(new DownloadManager.Query().setFilterById(downloadId))) {
if (cursor == null || !cursor.moveToFirst()) return null;
int status = cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS));
long received = cursor.getLong(cursor.getColumnIndexOrThrow(
DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR));
long total = cursor.getLong(cursor.getColumnIndexOrThrow(
DownloadManager.COLUMN_TOTAL_SIZE_BYTES));
if (listener != null) listener.onProgress(total > 0
? (int) Math.min(100, received * 100 / total) : -1);
if (status == DownloadManager.STATUS_SUCCESSFUL) break;
if (status == DownloadManager.STATUS_FAILED) return null;
}
Thread.sleep(250L);
}
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (ParcelFileDescriptor descriptor = manager.openDownloadedFile(downloadId);
java.io.InputStream input = new ParcelFileDescriptor.AutoCloseInputStream(descriptor)) {
byte[] buffer = new byte[64 * 1024];
int count;
while ((count = input.read(buffer)) != -1) {
digest.update(buffer, 0, count);
}
}
if (!hex(digest.digest()).equalsIgnoreCase(expectedSha256)) {
if (!publicFile.delete()) Log.w(TAG, "Could not remove invalid OTA file");
connection = (HttpURLConnection) new URL(update.apkUrl).openConnection();
connection.setInstanceFollowRedirects(false);
connection.setConnectTimeout(TIMEOUT_MS);
connection.setReadTimeout(30_000);
connection.setRequestProperty("Authorization", "Bearer " + config.token);
connection.setRequestProperty("User-Agent", "NilsMG4-Android");
if (connection.getResponseCode() != HttpURLConnection.HTTP_OK) {
Log.w(TAG, "Update download returned " + connection.getResponseCode());
return null;
}
return publicFile;
long total = connection.getContentLengthLong();
MessageDigest digest = MessageDigest.getInstance("SHA-256");
long received = 0;
int lastPercent = -2;
try (java.io.InputStream in = connection.getInputStream();
FileOutputStream out = new FileOutputStream(target, false)) {
byte[] buffer = new byte[64 * 1024];
int count;
while ((count = in.read(buffer)) != -1) {
out.write(buffer, 0, count);
digest.update(buffer, 0, count);
received += count;
if (received > MAX_APK_BYTES) throw new IOException("APK too large");
int percent = total > 0 ? (int) Math.min(100, received * 100 / total) : -1;
if (listener != null && percent != lastPercent) {
listener.onProgress(percent);
lastPercent = percent;
}
}
out.getFD().sync();
}
ok = hex(digest.digest()).equalsIgnoreCase(expectedSha256);
if (!ok) Log.w(TAG, "Update APK does not match its SHA-256");
return ok ? target : null;
} catch (Exception e) {
Log.w(TAG, "Update download failed", e);
Log.w(TAG, "Update download failed: " + e.getClass().getSimpleName());
return null;
} finally {
if (connection != null) connection.disconnect();
if (!ok && target.exists() && !target.delete()) Log.w(TAG, "Could not remove a bad update file");
}
return null;
}
private static String hex(byte[] bytes) {
@@ -10,9 +10,9 @@ import java.util.concurrent.atomic.AtomicBoolean;
/**
* Drives {@link OtaUpdater} — UNSTABLE BUILDS ONLY.
*
* Upstream keeps this seam inert pending a safety and legal audit of the published suite.
* This fork publishes its own builds from its own repository, signed with the platform key
* the head unit already trusts, so that audit does not govern here and the seam is live.
* Releases are this fork's own car builds, published as pre-releases on the private forge and
* relayed by the car server (OtaUpdater); they are signed with the platform key the head unit
* already trusts.
*
* Installation goes through the platform {@link android.content.pm.PackageInstaller}, not
* {@code pm install -r}: that shelled out with a path inside our private cache, which the
@@ -21,8 +21,11 @@ import java.util.concurrent.atomic.AtomicBoolean;
* authority for signing-certificate compatibility; the app verifies SHA-256 and package name
* before handing it the archive.
*
* Committing a session is asynchronous, so the message this returns says an install has been
* started, never that one finished. {@link OtaInstallResultReceiver} has the verdict.
* The verified APK stays in Downloads either way. The app first offers it to a PackageInstaller
* session — as the system user it may install without asking — and only if that cannot start
* does it open the file manager there, to tap the file. Committing a session is asynchronous,
* so the message says an install has started, never that one finished;
* {@link OtaInstallResultReceiver} has the verdict.
*/
final class UpdateHook {
@@ -44,9 +47,11 @@ final class UpdateHook {
Context app = context.getApplicationContext();
new Thread(() -> {
try {
ServerConfig config = ServerConfig.get(app);
if (!config.isConfigured()) return;
String current = app.getPackageManager()
.getPackageInfo(app.getPackageName(), 0).versionName;
OtaUpdater.Update update = OtaUpdater.check(current);
OtaUpdater.Update update = OtaUpdater.check(config, current);
if (update != null && update.hashUrl != null)
new android.os.Handler(android.os.Looper.getMainLooper())
.post(() -> android.widget.Toast.makeText(app,
@@ -100,25 +105,37 @@ final class UpdateHook {
return "Could not read version";
}
OtaUpdater.Update update = OtaUpdater.check(current);
ServerConfig config = ServerConfig.get(app);
if (!config.isConfigured()) return "Set up the server first (Server tab): updates come through it";
OtaUpdater.Update update = OtaUpdater.check(config, current);
if (update == null) return "Up to date (" + current + ")";
Log.i(TAG, "Update available: " + update.versionName);
String expectedHash;
try {
expectedHash = OtaUpdater.fetchExpectedSha256(update);
expectedHash = OtaUpdater.fetchExpectedSha256(update, config);
} catch (IOException e) {
Log.w(TAG, "OTA hash unavailable", e);
Log.w(TAG, "OTA hash unavailable: " + e.getMessage());
return "Update refused: no SHA-256 verification";
}
File apk = OtaUpdater.download(app, update, expectedHash, percent -> {
File apk = OtaUpdater.download(update, expectedHash, config, percent -> {
if (listener != null) main.post(() -> listener.onProgress(percent));
});
if (apk == null) return "Download or SHA-256 verification failed ("
+ update.versionName + ")";
switch (OtaUpdater.install(app, apk)) {
case SESSION_STARTED:
return "Installing " + update.versionName + "… the app restarts when it is done";
case INSTALL_UI_STARTED:
return "Confirm the installation of " + update.versionName + " on the screen";
case UNREADABLE_ARCHIVE:
return "Update refused: the downloaded file is not this app";
default:
break;
}
if (!OtaUpdater.openDownloads(app))
return "APK downloaded to Downloads; could not open the file manager";
return update.versionName + ".apk downloaded to Downloads — tap the file to install";
return update.versionName + " is in Downloads; open it with the Files app to install";
return update.versionName + ".apk is in Downloads — tap the file to install";
}
}