Fatih'sMG4 — ABRP telemetry for an MG4 EH32 on SWI69
A fork of malys/EVABRPUploader (itself a fork of Leon Kernan's ABRP_Uploader) carrying the changes that make it work on this car. History starts here: the tree is what matters, and the upstream projects are where the earlier work belongs. EVHardware is vendored rather than tracked as a submodule, so one clone builds. Telemetry. SOC, battery power and range read as unavailable on SWI69 because the vendor fallbacks were gated to SWI68 alone, while the property ids they use are the ones this car publishes — the gates now admit SWI69. Tyre pressure, the odometer and charge status come from the vendor block too, since the generic properties for all three are declared here and never publish. Tyre pressure is confirmed on the vehicle; the other two are not yet. Vehicle page. Every signal the car answers, in a table. One that it does not publish shows a dash: an unimplemented VHAL property returns zero, and "0 °C" would read as a measurement. Wi-Fi page. Joins a network the unit already has saved, so a phone hotspot does not need a trip into Settings on every drive. Tried on the upload tick and from a button, skipped while already associated. This leans on the unit being API 28 and the build being platform-signed — getConfiguredNetworks and enableNetwork are closed to ordinary apps from API 29 — and fails quietly where that does not hold. The OTA updater points at this repository. An APK from upstream is signed with a different key, which Android will not install over a platform-signed build. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.pm.PackageInfo;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.pm.Signature;
|
||||
import android.content.pm.SigningInfo;
|
||||
import android.os.Build;
|
||||
import android.util.Log;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/** SHA-256 fingerprints of the certificates signing an APK. Unstable builds only. */
|
||||
final class ApkSignature {
|
||||
|
||||
private static final String TAG = "ApkSignature";
|
||||
|
||||
private ApkSignature() { }
|
||||
|
||||
/** Fingerprints of the APK file at [path]; empty when it cannot be read. */
|
||||
static Set<String> of(Context context, String path) {
|
||||
try {
|
||||
PackageInfo info = context.getPackageManager()
|
||||
.getPackageArchiveInfo(path, flags());
|
||||
return digests(info);
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Cannot read archive signature: " + e.getMessage());
|
||||
return Collections.emptySet();
|
||||
}
|
||||
}
|
||||
|
||||
/** Fingerprints of the running app; empty when they cannot be read. */
|
||||
static Set<String> ofPackage(Context context) {
|
||||
try {
|
||||
PackageInfo info = context.getPackageManager()
|
||||
.getPackageInfo(context.getPackageName(), flags());
|
||||
return digests(info);
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Cannot read our own signature: " + e.getMessage());
|
||||
return Collections.emptySet();
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
private static int flags() {
|
||||
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
|
||||
? PackageManager.GET_SIGNING_CERTIFICATES
|
||||
: PackageManager.GET_SIGNATURES;
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
private static Set<String> digests(PackageInfo info) {
|
||||
if (info == null) return Collections.emptySet();
|
||||
|
||||
Signature[] signatures;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
SigningInfo signingInfo = info.signingInfo;
|
||||
if (signingInfo == null) return Collections.emptySet();
|
||||
signatures = signingInfo.hasMultipleSigners()
|
||||
? signingInfo.getApkContentsSigners()
|
||||
: signingInfo.getSigningCertificateHistory();
|
||||
} else {
|
||||
signatures = info.signatures;
|
||||
}
|
||||
if (signatures == null) return Collections.emptySet();
|
||||
|
||||
Set<String> out = new HashSet<>();
|
||||
try {
|
||||
MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
|
||||
for (Signature signature : signatures) {
|
||||
if (signature == null) continue;
|
||||
StringBuilder hex = new StringBuilder();
|
||||
for (byte b : sha256.digest(signature.toByteArray())) {
|
||||
hex.append(String.format("%02x", b));
|
||||
}
|
||||
out.add(hex.toString());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Digest failed: " + e.getMessage());
|
||||
return Collections.emptySet();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStreamReader;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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/EVABRP/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 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 check: null when there is nothing newer or the check failed. */
|
||||
static final class Update {
|
||||
final String versionName;
|
||||
final String apkUrl;
|
||||
|
||||
Update(String versionName, String apkUrl) {
|
||||
this.versionName = versionName;
|
||||
this.apkUrl = apkUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
HttpURLConnection conn = null;
|
||||
try {
|
||||
conn = (HttpURLConnection) new URL(RELEASES_API).openConnection();
|
||||
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);
|
||||
if (conn.getResponseCode() != 200) {
|
||||
Log.w(TAG, "Release API returned " + conn.getResponseCode());
|
||||
return null;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
JSONArray releases = new JSONArray(body.toString());
|
||||
|
||||
// Scan every entry and keep the HIGHEST version, not simply the first one
|
||||
// that beats the installed build: the API's ordering is by creation date, and
|
||||
// a re-published or back-dated release would otherwise win over a newer one.
|
||||
//
|
||||
// Stable releases are skipped on purpose. This channel tracks pre-releases
|
||||
// only — and a stable APK could not update a unstable install anyway, since
|
||||
// the unstable applicationId carries a .unstable suffix.
|
||||
//
|
||||
// The version comes from the asset name, not the tag: the unstable channel is
|
||||
// a single rolling pre-release tagged "unstable", overwritten on every build.
|
||||
Update best = null;
|
||||
for (int i = 0; i < releases.length(); i++) {
|
||||
JSONObject release = releases.getJSONObject(i);
|
||||
if (!release.optBoolean("prerelease", 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(java.util.Locale.US).endsWith(".apk")) continue;
|
||||
if (!name.contains("unstable")) continue;
|
||||
|
||||
String version = versionFromAssetName(name);
|
||||
if (version == null) continue;
|
||||
if (!isNewer(version, currentVersion)) continue;
|
||||
if (best != null && !isNewer(version, best.versionName)) continue;
|
||||
|
||||
String url = asset.optString("browser_download_url", "");
|
||||
if (!isAllowedUrl(url)) {
|
||||
Log.w(TAG, "Rejected update URL from an unexpected host: " + url);
|
||||
continue;
|
||||
}
|
||||
best = new Update(version, url);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Update check failed: " + e.getMessage());
|
||||
return null;
|
||||
} finally {
|
||||
if (conn != null) conn.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
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;
|
||||
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);
|
||||
}
|
||||
output.getFD().sync();
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
static boolean install(Context context, File apk) {
|
||||
if (!signatureMatchesRunningApp(context, apk)) return false;
|
||||
try {
|
||||
Process process = new ProcessBuilder("/system/bin/pm", "install", "-r",
|
||||
apk.getAbsolutePath()).redirectErrorStream(true).start();
|
||||
StringBuilder output = new StringBuilder();
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(
|
||||
process.getInputStream(), StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) output.append(line).append('\n');
|
||||
}
|
||||
int exitCode = process.waitFor();
|
||||
return installSucceeded(exitCode, output.toString());
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Update install failed", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static boolean installSucceeded(int exitCode, String output) {
|
||||
return exitCode == 0 && output.contains("Success");
|
||||
}
|
||||
|
||||
/**
|
||||
* True if [apk] is signed by the same certificate as the running app.
|
||||
*
|
||||
* Fail closed: an unreadable archive, a missing signature or a failed API call all
|
||||
* return false. The caller must delete the file rather than offer it for install.
|
||||
*/
|
||||
static boolean signatureMatchesRunningApp(Context context, File apk) {
|
||||
java.util.Set<String> archive = ApkSignature.of(context, apk.getAbsolutePath());
|
||||
java.util.Set<String> installed = ApkSignature.ofPackage(context);
|
||||
boolean ok = !archive.isEmpty() && !installed.isEmpty() && archive.equals(installed);
|
||||
if (!ok) {
|
||||
Log.w(TAG, "Signature mismatch — refusing update ("
|
||||
+ archive.size() + " vs " + installed.size() + " cert(s))");
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.content.Context;
|
||||
/**
|
||||
* OTA is suspended while the suite safety and legal audit is open.
|
||||
* Keep this flavour seam inert until a reviewed change explicitly re-enables it.
|
||||
*/
|
||||
final class UpdateHook {
|
||||
private UpdateHook() { }
|
||||
|
||||
static boolean isSupported() { return false; }
|
||||
static void checkInBackground(Context context) { }
|
||||
}
|
||||
Reference in New Issue
Block a user