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
This commit is contained in:
@@ -85,6 +85,12 @@ final class LogOutbox {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Removes what waits under names starting with [prefix]. */
|
||||
static synchronized void dropWaiting(Context context, String prefix) {
|
||||
File dir = new File(context.getFilesDir(), DIR);
|
||||
for (String name : waiting(dir)) if (name.startsWith(prefix)) new File(dir, name).delete();
|
||||
}
|
||||
|
||||
/** Sends what waits, unless the last attempt failed less than {@link #RETRY_MS} ago. Blocking:
|
||||
* call it off the main thread. */
|
||||
static void sendIfDue(Context context) {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.ConnectException;
|
||||
import java.net.NoRouteToHostException;
|
||||
import java.net.SocketException;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.net.UnknownHostException;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import javax.net.ssl.SSLException;
|
||||
|
||||
/**
|
||||
* Why an update check failed, in words for the update log, and what the car's networks look
|
||||
* like. Built from an error's type and an HTTP status only, because an error's own text can carry
|
||||
* the server's address: nothing here holds a URL or a credential. Android-free and unit-tested.
|
||||
*/
|
||||
final class UpdateDiagnosis {
|
||||
|
||||
private static final Pattern STATUS = Pattern.compile("^Release API returned (\\d{3})$");
|
||||
|
||||
private UpdateDiagnosis() {}
|
||||
|
||||
/** One line about a failed step. */
|
||||
static String failure(Throwable t) {
|
||||
if (t == null) return "unknown failure";
|
||||
String type = " (" + t.getClass().getSimpleName() + ")";
|
||||
if (t instanceof UnknownHostException) return "the name could not be looked up: no data connection, or no name service" + type;
|
||||
if (t instanceof SocketTimeoutException) return "no answer in time: a very slow or blocked connection" + type;
|
||||
if (t instanceof ConnectException || t instanceof NoRouteToHostException) return "no connection could be made" + type;
|
||||
if (t instanceof SSLException) return "the secure connection failed: something in between may have answered, or the head unit's clock is wrong" + type;
|
||||
if (t instanceof SocketException) return "the connection broke off" + type;
|
||||
if (t instanceof IOException && t.getMessage() != null) {
|
||||
Matcher status = STATUS.matcher(t.getMessage());
|
||||
if (status.matches()) return status(Integer.parseInt(status.group(1)));
|
||||
if (t.getMessage().equals("Invalid release response")) return "the answer was not a list of releases";
|
||||
}
|
||||
return "failed" + type;
|
||||
}
|
||||
|
||||
/** What a status from the car server means here. */
|
||||
static String status(int code) {
|
||||
String what;
|
||||
if (code >= 300 && code < 400) what = "something in between redirected it, as a SIM without data may";
|
||||
else if (code == 401 || code == 403) what = "the server refuses the car's token";
|
||||
else if (code == 404) what = "the server does not know this address";
|
||||
else if (code >= 500) what = "the server or the forge behind it has a problem";
|
||||
else what = "not what was expected";
|
||||
return "the server answered HTTP " + code + ": " + what;
|
||||
}
|
||||
|
||||
/** The outside-world test: a 204 is the internet working; anything else stands in between. */
|
||||
static String internet(int code) {
|
||||
if (code == 204) return "the internet works (HTTP 204)";
|
||||
return "something in between answered HTTP " + code + " instead, as a SIM without data may";
|
||||
}
|
||||
|
||||
/** One network as Android sees it. */
|
||||
static String network(List<String> transports, boolean internet, boolean validated,
|
||||
boolean active, boolean roaming) {
|
||||
return (transports.isEmpty() ? "unknown type" : String.join("+", transports))
|
||||
+ (active ? ", in use" : "")
|
||||
+ (internet ? ", offers internet" : ", no internet")
|
||||
+ (validated ? ", confirmed by Android" : ", not confirmed by Android")
|
||||
+ (roaming ? ", roaming" : "");
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import java.util.Locale;
|
||||
*/
|
||||
final class UpdateLog {
|
||||
|
||||
static final int MAX_LINES = 10;
|
||||
static final int MAX_LINES = 40;
|
||||
private static final String PREFS = "update_log";
|
||||
private static final String KEY = "lines";
|
||||
private static final String KEY_VERSION = "version";
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.ConnectException;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.net.UnknownHostException;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
|
||||
import javax.net.ssl.SSLHandshakeException;
|
||||
|
||||
public class UpdateDiagnosisTest {
|
||||
|
||||
@Test
|
||||
public void errorsAreToldApart() {
|
||||
assertTrue(UpdateDiagnosis.failure(new UnknownHostException()).contains("no data connection"));
|
||||
assertTrue(UpdateDiagnosis.failure(new SocketTimeoutException()).contains("in time"));
|
||||
assertTrue(UpdateDiagnosis.failure(new ConnectException()).contains("no connection"));
|
||||
assertTrue(UpdateDiagnosis.failure(new SSLHandshakeException("x")).contains("in between"));
|
||||
assertTrue(UpdateDiagnosis.failure(new IOException("Release API returned 401")).contains("token"));
|
||||
assertTrue(UpdateDiagnosis.failure(new IOException("Invalid release response")).contains("not a list"));
|
||||
assertEquals("unknown failure", UpdateDiagnosis.failure(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void anErrorsOwnTextNeverReachesTheLog() {
|
||||
String secret = "https://car.example.com/v1/app/releases?token=abc";
|
||||
for (Throwable t : Arrays.asList(new UnknownHostException(secret), new SocketTimeoutException(secret),
|
||||
new ConnectException(secret), new SSLHandshakeException(secret), new IOException(secret),
|
||||
new RuntimeException(secret))) {
|
||||
String line = UpdateDiagnosis.failure(t);
|
||||
assertFalse(line, line.contains("example"));
|
||||
assertFalse(line, line.contains("abc"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void statusesSayWhoIsAtFault() {
|
||||
assertTrue(UpdateDiagnosis.status(302).contains("SIM without data"));
|
||||
assertTrue(UpdateDiagnosis.status(401).contains("token"));
|
||||
assertTrue(UpdateDiagnosis.status(403).contains("token"));
|
||||
assertTrue(UpdateDiagnosis.status(404).contains("does not know"));
|
||||
assertTrue(UpdateDiagnosis.status(502).contains("forge"));
|
||||
assertTrue(UpdateDiagnosis.status(418).contains("HTTP 418"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void onlyA204IsTheInternetWorking() {
|
||||
assertTrue(UpdateDiagnosis.internet(204).startsWith("the internet works"));
|
||||
assertTrue(UpdateDiagnosis.internet(200).contains("in between"));
|
||||
assertTrue(UpdateDiagnosis.internet(302).contains("SIM without data"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aNetworkInOneLine() {
|
||||
assertEquals("cellular, in use, offers internet, not confirmed by Android, roaming",
|
||||
UpdateDiagnosis.network(Collections.singletonList("cellular"), true, false, true, true));
|
||||
assertEquals("unknown type, no internet, confirmed by Android",
|
||||
UpdateDiagnosis.network(Collections.emptyList(), false, true, false, false));
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@
|
||||
android:name="android.permission.MEDIA_CONTENT_CONTROL"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
|
||||
<!-- An update check writes down which networks Android sees (UpdateNetworkCheck). -->
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<!-- The Logs tab's sound check (SoundProbe): Android's audio analyser asks for both. The
|
||||
system user holds them without a prompt; the analyser reports levels only, no sound. -->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
|
||||
@@ -55,6 +55,9 @@ 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";
|
||||
@@ -366,6 +369,7 @@ final class OtaUpdater {
|
||||
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();
|
||||
@@ -390,10 +394,14 @@ final class OtaUpdater {
|
||||
out.getFD().sync();
|
||||
}
|
||||
ok = hex(digest.digest()).equalsIgnoreCase(expectedSha256);
|
||||
if (!ok) Log.w(TAG, "Update APK does not match its SHA-256");
|
||||
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();
|
||||
|
||||
@@ -5,6 +5,11 @@ import android.util.Log;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
@@ -58,7 +63,8 @@ final class UpdateHook {
|
||||
"Update available: " + update.versionName,
|
||||
android.widget.Toast.LENGTH_LONG).show());
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "Startup update check failed", t);
|
||||
Log.w(TAG, "Startup update check failed: " + t.getClass().getSimpleName());
|
||||
UpdateLog.record(app, "Check at start: " + UpdateDiagnosis.failure(t));
|
||||
}
|
||||
}, "ota-startup-check").start();
|
||||
}
|
||||
@@ -78,24 +84,46 @@ final class UpdateHook {
|
||||
return;
|
||||
}
|
||||
new Thread(() -> {
|
||||
long startedMs = System.currentTimeMillis();
|
||||
List<String> steps = new ArrayList<>();
|
||||
step(app, steps, "Check started, app " + BuildConfig.VERSION_NAME);
|
||||
String message;
|
||||
try {
|
||||
for (String network : UpdateNetworkCheck.networks(app)) step(app, steps, network);
|
||||
ServerConfig config = ServerConfig.get(app);
|
||||
String host = config.isConfigured() ? OtaUpdater.allowedHost(config) : null;
|
||||
if (host != null) step(app, steps, UpdateNetworkCheck.lookup(host));
|
||||
message = runCheck(app, listener, main);
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "Update check threw", t);
|
||||
// The class only: a network error's text can carry the server's address.
|
||||
message = "Update check failed (" + t.getClass().getSimpleName() + ")";
|
||||
Log.w(TAG, "Update check threw: " + t.getClass().getSimpleName());
|
||||
message = "Update check failed: " + UpdateDiagnosis.failure(t);
|
||||
// Is it the server or the connection? The answer to "is my SIM out of data".
|
||||
step(app, steps, UpdateNetworkCheck.internet());
|
||||
} finally {
|
||||
checking.set(false);
|
||||
}
|
||||
UpdateLog.record(app, "Check: " + message);
|
||||
step(app, steps, "Check: " + message);
|
||||
String waiting = LogOutbox.status(app);
|
||||
if (waiting != null) step(app, steps, "Reports: " + waiting);
|
||||
if (listener != null) {
|
||||
final String result = message;
|
||||
main.post(() -> listener.onResult(result));
|
||||
}
|
||||
// After the answer: without a connection this waits for the send to fail.
|
||||
String name = "update-check-" + new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US)
|
||||
.format(new Date(startedMs)) + ".txt";
|
||||
// Only the newest waits: repeated checks offline must not push out a recording.
|
||||
LogOutbox.dropWaiting(app, "update-check-");
|
||||
SignalRecorder.saveCopies(app, "update_check.txt", name, steps);
|
||||
}, "ota-check").start();
|
||||
}
|
||||
|
||||
/** One step of a check: on the Logs tab now, and in the report saved when it ends. */
|
||||
private static void step(Context app, List<String> steps, String what) {
|
||||
UpdateLog.record(app, what);
|
||||
steps.add(what);
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs the newest car build from the top folder of a USB stick (UsbUpdate), with the
|
||||
* same checks as the over-the-air update except the server. Background thread; [listener]
|
||||
@@ -195,14 +223,14 @@ final class UpdateHook {
|
||||
try {
|
||||
expectedHash = OtaUpdater.fetchExpectedSha256(update, config);
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "OTA hash unavailable: " + e.getMessage());
|
||||
return "Update refused: no SHA-256 verification";
|
||||
Log.w(TAG, "OTA hash unavailable: " + e.getClass().getSimpleName());
|
||||
return "Update refused, no SHA-256 verification: " + UpdateDiagnosis.failure(e);
|
||||
}
|
||||
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 + ")";
|
||||
+ update.versionName + "): " + OtaUpdater.downloadProblem;
|
||||
|
||||
switch (OtaUpdater.install(app, apk)) {
|
||||
case SESSION_STARTED:
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.ConnectivityManager;
|
||||
import android.net.Network;
|
||||
import android.net.NetworkCapabilities;
|
||||
import android.net.NetworkInfo;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.InetAddress;
|
||||
import java.net.URL;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* What an update check writes down about the connection before and after it fails: the networks
|
||||
* Android sees, whether the server's name can be looked up, and whether the internet answers at
|
||||
* all, which tells a SIM without data from a server that is down. Blocking: background thread.
|
||||
* Never logs the server's address or anything about the car. — UNSTABLE BUILDS ONLY.
|
||||
*/
|
||||
final class UpdateNetworkCheck {
|
||||
|
||||
/** Android's own connectivity test, as Android itself uses it; no data about the car is sent. */
|
||||
private static final String INTERNET_TEST = "https://www.gstatic.com/generate_204";
|
||||
private static final int TIMEOUT_MS = 10_000;
|
||||
|
||||
private UpdateNetworkCheck() {}
|
||||
|
||||
/** One line per network, or one saying there is none. */
|
||||
static List<String> networks(Context app) {
|
||||
List<String> out = new ArrayList<>();
|
||||
try {
|
||||
ConnectivityManager cm = (ConnectivityManager) app.getSystemService(Context.CONNECTIVITY_SERVICE);
|
||||
if (cm == null) {
|
||||
out.add("Network: no connectivity service");
|
||||
return out;
|
||||
}
|
||||
Network active = cm.getActiveNetwork();
|
||||
if (active == null) out.add("Network: none in use");
|
||||
for (Network network : cm.getAllNetworks()) {
|
||||
NetworkCapabilities caps = cm.getNetworkCapabilities(network);
|
||||
if (caps == null) continue;
|
||||
NetworkInfo info = cm.getNetworkInfo(network);
|
||||
out.add("Network: " + UpdateDiagnosis.network(transports(caps),
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET),
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED),
|
||||
network.equals(active), info != null && info.isRoaming()));
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
out.add("Network: unreadable (" + e.getClass().getSimpleName() + ")");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static List<String> transports(NetworkCapabilities caps) {
|
||||
List<String> out = new ArrayList<>();
|
||||
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) out.add("cellular");
|
||||
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) out.add("Wi-Fi");
|
||||
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) out.add("ethernet");
|
||||
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH)) out.add("Bluetooth");
|
||||
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) out.add("VPN");
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Whether the host's name resolves, without saying the name. */
|
||||
static String lookup(String host) {
|
||||
long started = System.currentTimeMillis();
|
||||
try {
|
||||
InetAddress.getByName(host);
|
||||
return "Server's name: found in " + (System.currentTimeMillis() - started) + " ms";
|
||||
} catch (IOException | RuntimeException e) {
|
||||
return "Server's name: " + UpdateDiagnosis.failure(e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the world outside the car server answers. */
|
||||
static String internet() {
|
||||
HttpURLConnection conn = null;
|
||||
try {
|
||||
conn = (HttpURLConnection) new URL(INTERNET_TEST).openConnection();
|
||||
conn.setInstanceFollowRedirects(false);
|
||||
conn.setConnectTimeout(TIMEOUT_MS);
|
||||
conn.setReadTimeout(TIMEOUT_MS);
|
||||
return "Internet test: " + UpdateDiagnosis.internet(conn.getResponseCode());
|
||||
} catch (IOException | RuntimeException e) {
|
||||
return "Internet test: " + UpdateDiagnosis.failure(e);
|
||||
} finally {
|
||||
if (conn != null) conn.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user