The car refuses the installer session, and only a refusal thrown at once opened the installer screen; one that came back through the session's result ended in a toast, so the update downloaded and then nothing seemed to happen. Now any result but success or the driver's cancel opens the installer screen with the verified APK, as does a confirmation that cannot be shown, and a session silent for 20 s is abandoned for it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
691 lines
32 KiB
Java
691 lines
32 KiB
Java
package com.evsuite.abrp;
|
|
|
|
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.net.Uri;
|
|
import android.os.Build;
|
|
import android.os.Environment;
|
|
import android.provider.Settings;
|
|
import android.util.Log;
|
|
|
|
import androidx.core.content.FileProvider;
|
|
|
|
import org.json.JSONArray;
|
|
import org.json.JSONException;
|
|
import org.json.JSONObject;
|
|
|
|
import java.io.BufferedReader;
|
|
import java.io.File;
|
|
import java.io.FileInputStream;
|
|
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.
|
|
*
|
|
* 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-";
|
|
|
|
/** 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";
|
|
}
|
|
|
|
/** 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$",
|
|
java.util.regex.Pattern.CASE_INSENSITIVE);
|
|
|
|
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() { }
|
|
|
|
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 exactly [allowedHost].
|
|
*
|
|
* 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, String allowedHost) {
|
|
if (url == null || allowedHost == null) return false;
|
|
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 && host.toLowerCase(Locale.US).equals(allowedHost.toLowerCase(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:
|
|
* "NilsMG4-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 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(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, String allowedHost) throws IOException {
|
|
try {
|
|
conn.setInstanceFollowRedirects(false);
|
|
conn.setRequestProperty("Accept", "application/json");
|
|
conn.setRequestProperty("User-Agent", "NilsMG4-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, allowedHost);
|
|
} 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, String allowedHost) 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, 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, allowedHost)) best = new Update(version, url, name, hashUrl);
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
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, allowedHost)) 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, ServerConfig config) throws IOException {
|
|
if (update == null || !isAllowedUrl(update.hashUrl, allowedHost(config)))
|
|
throw new IOException("Missing SHA-256 sidecar");
|
|
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, ServerConfig config) throws IOException {
|
|
URL current = new URL(initialUrl);
|
|
for (int redirects = 0; redirects <= 5; redirects++) {
|
|
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);
|
|
connection.setRequestProperty("User-Agent", "NilsMG4-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 safe + ".apk";
|
|
}
|
|
|
|
/**
|
|
* 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(Update update, String expectedSha256, ServerConfig config,
|
|
ProgressListener listener) {
|
|
if (!isAllowedUrl(update.apkUrl, allowedHost(config))) {
|
|
Log.w(TAG, "Refusing to download from an unexpected host");
|
|
return null;
|
|
}
|
|
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 {
|
|
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;
|
|
}
|
|
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.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");
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
/** Opens Android's document UI directly in Downloads; the driver chooses the APK. */
|
|
static boolean openDownloads(Context context) {
|
|
Intent picker = new Intent(Intent.ACTION_OPEN_DOCUMENT)
|
|
.addCategory(Intent.CATEGORY_OPENABLE)
|
|
.setType("application/vnd.android.package-archive")
|
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
|
if (Build.VERSION.SDK_INT >= 26) {
|
|
picker.putExtra("android.provider.extra.INITIAL_URI", Uri.parse(
|
|
"content://com.android.externalstorage.documents/document/primary%3ADownload"));
|
|
}
|
|
try {
|
|
context.startActivity(picker);
|
|
return true;
|
|
} catch (Throwable first) {
|
|
try {
|
|
Intent downloads = new Intent(DownloadManager.ACTION_VIEW_DOWNLOADS)
|
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
|
context.startActivity(downloads);
|
|
return true;
|
|
} catch (Throwable second) {
|
|
Log.w(TAG, "No Downloads UI available", second);
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
/** Session install was unavailable; the system's interactive installer was opened. */
|
|
INSTALL_UI_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 {
|
|
int sessionId = commitSession(context, apk);
|
|
watchForAnswer(context, sessionId, apk);
|
|
return InstallResult.SESSION_STARTED;
|
|
} catch (Throwable t) {
|
|
Log.w(TAG, "OTA session install failed; trying system install UI", t);
|
|
UpdateLog.record(context, "Installer session failed: " + t);
|
|
return openInstallerScreen(context, apk) ? InstallResult.INSTALL_UI_STARTED : InstallResult.SESSION_FAILED;
|
|
}
|
|
}
|
|
|
|
/** The installer screen for [apk], as the fallback; false if even that could not open. */
|
|
static boolean openInstallerScreen(Context context, File apk) {
|
|
try {
|
|
launchInstallerActivity(context, apk);
|
|
UpdateLog.record(context, "Opened the system installer instead");
|
|
return true;
|
|
} catch (Throwable fallback) {
|
|
Log.w(TAG, "OTA install UI failed", fallback);
|
|
UpdateLog.record(context, "System installer failed too: " + fallback);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** How long a committed session may stay silent before the installer screen is opened. */
|
|
private static final long ANSWER_WAIT_MS = 20_000L;
|
|
/** Sessions the platform has answered (any status), so the watch knows to stand down. */
|
|
private static final java.util.Set<Integer> answered =
|
|
java.util.Collections.synchronizedSet(new java.util.HashSet<>());
|
|
|
|
/** The platform said something about [sessionId]; called by {@link OtaInstallResultReceiver}. */
|
|
static void noteAnswer(int sessionId) {
|
|
answered.add(sessionId);
|
|
}
|
|
|
|
/**
|
|
* A committed session normally answers within a second or two: installed (the app
|
|
* restarts), refused, or "confirm on screen". If it says nothing at all, the update would
|
|
* end silently after its download, so the installer screen is opened instead.
|
|
*/
|
|
private static void watchForAnswer(Context context, int sessionId, File apk) {
|
|
Context app = context.getApplicationContext();
|
|
new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(() -> {
|
|
if (answered.contains(sessionId)) return;
|
|
UpdateLog.record(app, "No answer from the installer after " + ANSWER_WAIT_MS / 1000 + " s");
|
|
try {
|
|
app.getPackageManager().getPackageInstaller().abandonSession(sessionId);
|
|
} catch (Throwable ignored) {
|
|
// already gone
|
|
}
|
|
new Thread(() -> openInstallerScreen(app, apk), "ota-fallback").start();
|
|
}, ANSWER_WAIT_MS);
|
|
}
|
|
|
|
/**
|
|
* 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");
|
|
UpdateLog.record(context, "Package manager cannot parse " + apk.getName()
|
|
+ " (" + apk.length() + " bytes)");
|
|
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;
|
|
}
|
|
}
|
|
|
|
/** Commits [apk] to a new installer session and returns its id. */
|
|
private static int 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())
|
|
// Where the verified APK is, for the installer screen if the session fails.
|
|
.putExtra(OtaInstallResultReceiver.EXTRA_APK, apk.getAbsolutePath())
|
|
.putExtra(OtaInstallResultReceiver.EXTRA_SESSION, sessionId);
|
|
// 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);
|
|
// Written before commit: a successful install replaces this process at once.
|
|
UpdateLog.record(context, "Handed " + apk.length() + " bytes to the installer");
|
|
session.commit(pending.getIntentSender());
|
|
committed = true;
|
|
Log.i(TAG, "OTA session " + sessionId + " committed (" + apk.length() + " bytes)");
|
|
return sessionId;
|
|
} 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) { }
|
|
}
|
|
}
|
|
}
|
|
|
|
/** DriveHub_Dort-compatible fallback for head units that refuse installer sessions. */
|
|
private static void launchInstallerActivity(Context context, File apk) {
|
|
if (Build.VERSION.SDK_INT >= 26
|
|
&& !context.getPackageManager().canRequestPackageInstalls()) {
|
|
Intent settings = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES)
|
|
.setData(Uri.parse("package:" + context.getPackageName()))
|
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
|
context.startActivity(settings);
|
|
throw new IllegalStateException("unknown sources permission required");
|
|
}
|
|
|
|
File publicApk = copyToPublicDownloads(apk);
|
|
if (android.os.Process.myUid() == android.os.Process.SYSTEM_UID) {
|
|
// As the system user, our FileProvider's read grant is refused ("not exported from
|
|
// UID 1000") and the installer reports the unreadable file as a parse error. A file
|
|
// URI it reads with its own storage permission instead, as from the Files app.
|
|
Intent install = new Intent(Intent.ACTION_VIEW)
|
|
.setDataAndType(Uri.fromFile(publicApk), "application/vnd.android.package-archive")
|
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
|
if (install.resolveActivity(context.getPackageManager()) == null) {
|
|
throw new IllegalStateException("No package installer activity");
|
|
}
|
|
// StrictMode forbids file URIs in intents (FileUriExposedException): lifted for this
|
|
// one call only.
|
|
android.os.StrictMode.VmPolicy policy = android.os.StrictMode.getVmPolicy();
|
|
android.os.StrictMode.setVmPolicy(new android.os.StrictMode.VmPolicy.Builder().build());
|
|
try {
|
|
context.startActivity(install);
|
|
} finally {
|
|
android.os.StrictMode.setVmPolicy(policy);
|
|
}
|
|
Log.i(TAG, "System install UI opened with a file URI for " + publicApk.getAbsolutePath());
|
|
return;
|
|
}
|
|
Uri contentUri = FileProvider.getUriForFile(context,
|
|
context.getPackageName() + ".fileprovider", publicApk);
|
|
Intent install = new Intent(Intent.ACTION_VIEW)
|
|
.setDataAndType(contentUri, "application/vnd.android.package-archive")
|
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
|
install.setClipData(ClipData.newRawUri("Nils' MG4 update", contentUri));
|
|
grantToInstallers(context, contentUri);
|
|
if (install.resolveActivity(context.getPackageManager()) == null) {
|
|
throw new IllegalStateException("No package installer activity");
|
|
}
|
|
context.startActivity(install);
|
|
Log.i(TAG, "System install UI opened for " + publicApk.getAbsolutePath());
|
|
}
|
|
|
|
private static File copyToPublicDownloads(File source) {
|
|
File downloads = Environment.getExternalStoragePublicDirectory(
|
|
Environment.DIRECTORY_DOWNLOADS);
|
|
if (downloads == null || (!downloads.isDirectory() && !downloads.mkdirs())) {
|
|
throw new IllegalStateException("Downloads directory unavailable");
|
|
}
|
|
File destination = new File(downloads, "NilsMG4-OTA-update.apk");
|
|
try (FileInputStream input = new FileInputStream(source);
|
|
FileOutputStream output = new FileOutputStream(destination, false)) {
|
|
byte[] buffer = new byte[64 * 1024];
|
|
int count;
|
|
while ((count = input.read(buffer)) != -1) output.write(buffer, 0, count);
|
|
output.getFD().sync();
|
|
} catch (IOException e) {
|
|
throw new IllegalStateException("Could not stage APK in Downloads", e);
|
|
}
|
|
if (destination.length() != source.length()) {
|
|
throw new IllegalStateException("Staged APK size mismatch");
|
|
}
|
|
return destination;
|
|
}
|
|
|
|
private static void grantToInstallers(Context context, Uri uri) {
|
|
String[] packages = {
|
|
"com.android.packageinstaller",
|
|
"com.google.android.packageinstaller",
|
|
"com.samsung.android.packageinstaller"
|
|
};
|
|
for (String packageName : packages) {
|
|
try {
|
|
context.grantUriPermission(packageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
|
} catch (Throwable ignored) { }
|
|
}
|
|
Intent probe = new Intent(Intent.ACTION_VIEW)
|
|
.setDataAndType(uri, "application/vnd.android.package-archive");
|
|
for (ResolveInfo info : context.getPackageManager().queryIntentActivities(probe, 0)) {
|
|
if (info.activityInfo == null) continue;
|
|
try {
|
|
context.grantUriPermission(info.activityInfo.packageName, uri,
|
|
Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
|
} catch (Throwable ignored) { }
|
|
}
|
|
}
|
|
|
|
}
|