Files
mg4/app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java
T
nilsandClaude Opus 5.5 e52d7f01fc App 2.43.5: update checks log what happens
Each check writes down the networks Android sees, the server's name lookup,
how a failure happened and how many reports wait, and on a failure whether
the internet answers at all, to tell a SIM without data from a server that is
down. The report goes to a USB stick and, once online, the server. No address
or token is written. Tried on the emulator. Not tried on the car.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-11-01 10:21:15 +01:00

699 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-";
/** Why the last download failed, for the update log. */
static volatile String downloadProblem = "unknown";
/** 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());
downloadProblem = UpdateDiagnosis.status(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");
downloadProblem = "it does not match its SHA-256";
}
return ok ? target : null;
} catch (Exception e) {
Log.w(TAG, "Update download failed: " + e.getClass().getSimpleName());
downloadProblem = UpdateDiagnosis.failure(e);
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) { }
}
}
}