App 2.19.3: update log on the Logs tab
Every update and install step, with the platform's own result, kept across the restart an install causes, so a failed install on the car can be read off the screen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
This commit is contained in:
@@ -114,7 +114,9 @@ cd server && go vet ./... && go test ./... && govulncheck ./...
|
|||||||
car's platform key, so the car build installs as the system user. `adb shell wm size 1778x640`
|
car's platform key, so the car build installs as the system user. `adb shell wm size 1778x640`
|
||||||
gives the car's app window (taps then use those coordinates; screenshots stay 1920×720). It has
|
gives the car's app window (taps then use those coordinates; screenshots stay 1920×720). It has
|
||||||
no car data (every value is "—"). While the app is open, `uiautomator dump` never sees an idle
|
no car data (every value is "—"). While the app is open, `uiautomator dump` never sees an idle
|
||||||
screen; use screenshots.
|
screen; use screenshots. A USB stick: a FAT image (`mkfs.vfat`, files in with `mcopy`) passed as
|
||||||
|
`-sdcard <image>` mounts as a removable volume like the car's stick; `adb push` to
|
||||||
|
`/storage/<id>/` changes it while running.
|
||||||
- **After each change:** a `SPECS.md` done-log entry, docs that the change affects, explicit
|
- **After each change:** a `SPECS.md` done-log entry, docs that the change affects, explicit
|
||||||
staging, commit, push.
|
staging, commit, push.
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,16 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does *
|
|||||||
confirmed car facts, app structure, UI conventions, build/test/ship, documents. It replaces
|
confirmed car facts, app structure, UI conventions, build/test/ship, documents. It replaces
|
||||||
upstream's EVABRPUploader text. The test emulator is now in the flake: `nix run .#car-emulator`
|
upstream's EVABRPUploader text. The test emulator is now in the flake: `nix run .#car-emulator`
|
||||||
(Android 9 AOSP at 1920×720, 160 dpi; the car build installs as the system user — checked).
|
(Android 9 AOSP at 1920×720, 160 dpi; the car build installs as the system user — checked).
|
||||||
|
- 2026-10-23 — App 2.19.3: an update log on the Logs tab, above the uploads. A USB install on the
|
||||||
|
car ended in an installer message about a broken package, while the same APK installed through
|
||||||
|
the Bluetooth-settings route; the message was only a toast and logcat, so its exact words are
|
||||||
|
lost. Now every step is kept (last 10, survives the restart an install causes): file found with
|
||||||
|
its size, SHA-256 match, the package manager refusing to parse, a failed installer session with
|
||||||
|
its exception, bytes handed to the installer, the platform's result in its own words, and "Now
|
||||||
|
running <version>" on the first start of a new version. On the emulator, with a FAT image as a
|
||||||
|
removable volume (like the car's stick), the USB install worked silently and the log showed the
|
||||||
|
whole sequence; a truncated APK with a matching hash was refused at the package check, logged.
|
||||||
|
The car's failure is not reproduced or explained yet.
|
||||||
- 2026-10-23 — App 2.19.2: Settings → "Full screen for CarPlay (experiment)". The Logs tab showed
|
- 2026-10-23 — App 2.19.2: Settings → "Full screen for CarPlay (experiment)". The Logs tab showed
|
||||||
CarPlay on the car as `com.allgo.carplay.service/.CarPlayActivity`, an ordinary activity. The
|
CarPlay on the car as `com.allgo.carplay.service/.CarPlayActivity`, an ordinary activity. The
|
||||||
switch adds that package to `immersive.full` in Android 9's `policy_control` global setting
|
switch adds that package to `immersive.full` in Android 9's `policy_control` global setting
|
||||||
|
|||||||
+2
-2
@@ -34,8 +34,8 @@ android {
|
|||||||
applicationId "com.stinnesbeck.nilsmg4"
|
applicationId "com.stinnesbeck.nilsmg4"
|
||||||
minSdk 27
|
minSdk 27
|
||||||
targetSdk 36
|
targetSdk 36
|
||||||
versionCode 101
|
versionCode 102
|
||||||
versionName "2.19.2"
|
versionName "2.19.3"
|
||||||
|
|
||||||
// Nothing about the server ships in the APK: its URL and ingest token are entered in
|
// Nothing about the server ships in the APK: its URL and ingest token are entered in
|
||||||
// the app's Server tab (ServerConfig), and the database is only reachable by the server.
|
// the app's Server tab (ServerConfig), and the database is only reachable by the server.
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ public class AbrpUploadService extends Service {
|
|||||||
super.onCreate();
|
super.onCreate();
|
||||||
prefs = getSharedPreferences("abrp_prefs", MODE_PRIVATE);
|
prefs = getSharedPreferences("abrp_prefs", MODE_PRIVATE);
|
||||||
uploadLog.clear();
|
uploadLog.clear();
|
||||||
|
UpdateLog.noteVersion(this, BuildConfig.VERSION_NAME);
|
||||||
scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||||
Thread t = new Thread(r, "abrp-upload");
|
Thread t = new Thread(r, "abrp-upload");
|
||||||
t.setDaemon(false);
|
t.setDaemon(false);
|
||||||
|
|||||||
@@ -285,6 +285,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
panes = new View[] { vehiclePane, consumptionPane, chargingPane, servicePane, serverPane,
|
panes = new View[] { vehiclePane, consumptionPane, chargingPane, servicePane, serverPane,
|
||||||
settingsPane, logPane };
|
settingsPane, logPane };
|
||||||
|
|
||||||
|
UpdateLog.noteVersion(this, BuildConfig.VERSION_NAME);
|
||||||
bindVehiclePane();
|
bindVehiclePane();
|
||||||
bindConsumptionPane();
|
bindConsumptionPane();
|
||||||
bindChargingPane();
|
bindChargingPane();
|
||||||
@@ -1792,13 +1793,21 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
|
|
||||||
private void refreshCallLog() {
|
private void refreshCallLog() {
|
||||||
java.util.List<UploadLog.Entry> entries = AbrpUploadService.log().recent();
|
java.util.List<UploadLog.Entry> entries = AbrpUploadService.log().recent();
|
||||||
|
// Update steps first: after a failed install, they are what the driver came here for.
|
||||||
|
java.util.List<String> updates = UpdateLog.lines(this);
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
if (!updates.isEmpty()) {
|
||||||
|
sb.append(getString(R.string.update_log_title));
|
||||||
|
for (String line : updates) sb.append('\n').append(line);
|
||||||
|
sb.append('\n');
|
||||||
|
}
|
||||||
if (entries.isEmpty()) {
|
if (entries.isEmpty()) {
|
||||||
callLogText.setText(R.string.call_log_empty);
|
if (sb.length() == 0) callLogText.setText(R.string.call_log_empty);
|
||||||
|
else callLogText.setText(sb.append('\n').append(getString(R.string.call_log_empty)).toString());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
java.text.SimpleDateFormat format =
|
java.text.SimpleDateFormat format =
|
||||||
new java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.US);
|
new java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.US);
|
||||||
StringBuilder sb = new StringBuilder();
|
|
||||||
for (UploadLog.Entry entry : entries) {
|
for (UploadLog.Entry entry : entries) {
|
||||||
if (sb.length() > 0) sb.append('\n');
|
if (sb.length() > 0) sb.append('\n');
|
||||||
sb.append(entry.success ? "OK " : "ERR ")
|
sb.append(entry.success ? "OK " : "ERR ")
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.SharedPreferences;
|
||||||
|
|
||||||
|
import java.text.SimpleDateFormat;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The last {@link #MAX_LINES} steps of updating the app — checks, USB installs, and the
|
||||||
|
* platform's own words about an install — shown on the Logs tab.
|
||||||
|
*
|
||||||
|
* Kept apart from {@link UploadLog}, whose failures decide the service's health, and stored in
|
||||||
|
* preferences rather than memory: a successful install restarts the process, and the car has
|
||||||
|
* no adb, so this is the only place the driver can read why an install failed. It never holds
|
||||||
|
* a URL or a credential, only what happened.
|
||||||
|
*/
|
||||||
|
final class UpdateLog {
|
||||||
|
|
||||||
|
static final int MAX_LINES = 10;
|
||||||
|
private static final String PREFS = "update_log";
|
||||||
|
private static final String KEY = "lines";
|
||||||
|
private static final String KEY_VERSION = "version";
|
||||||
|
|
||||||
|
private UpdateLog() {}
|
||||||
|
|
||||||
|
static void record(Context context, String what) {
|
||||||
|
if (context == null || what == null) return;
|
||||||
|
String line = new SimpleDateFormat("dd.MM. HH:mm:ss", Locale.US).format(new Date()) + " " + what;
|
||||||
|
synchronized (UpdateLog.class) {
|
||||||
|
SharedPreferences prefs = context.getApplicationContext()
|
||||||
|
.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||||
|
List<String> lines = append(split(prefs.getString(KEY, null)), line, MAX_LINES);
|
||||||
|
prefs.edit().putString(KEY, String.join("\n", lines)).commit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records the version the app now runs, once per version: after an install, the line that
|
||||||
|
* says it worked, since the installing process does not live to say so.
|
||||||
|
*/
|
||||||
|
static void noteVersion(Context context, String version) {
|
||||||
|
if (context == null || version == null) return;
|
||||||
|
SharedPreferences prefs = context.getApplicationContext()
|
||||||
|
.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||||
|
String last = prefs.getString(KEY_VERSION, null);
|
||||||
|
if (version.equals(last)) return;
|
||||||
|
prefs.edit().putString(KEY_VERSION, version).commit();
|
||||||
|
if (last != null) record(context, "Now running " + version + " (was " + last + ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Newest first. */
|
||||||
|
static List<String> lines(Context context) {
|
||||||
|
SharedPreferences prefs = context.getApplicationContext()
|
||||||
|
.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||||
|
return split(prefs.getString(KEY, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code line} in front, newlines flattened, at most {@code max} lines kept. */
|
||||||
|
static List<String> append(List<String> newestFirst, String line, int max) {
|
||||||
|
List<String> out = new ArrayList<>();
|
||||||
|
out.add(line.replace('\n', ' ').replace('\r', ' '));
|
||||||
|
for (String l : newestFirst) {
|
||||||
|
if (out.size() >= max) break;
|
||||||
|
out.add(l);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> split(String stored) {
|
||||||
|
if (stored == null || stored.isEmpty()) return new ArrayList<>();
|
||||||
|
return new ArrayList<>(Arrays.asList(stored.split("\n")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -171,6 +171,7 @@
|
|||||||
<!-- Call log -->
|
<!-- Call log -->
|
||||||
<string name="label_call_log">Recent uploads</string>
|
<string name="label_call_log">Recent uploads</string>
|
||||||
<string name="call_log_empty">No upload attempted yet.</string>
|
<string name="call_log_empty">No upload attempted yet.</string>
|
||||||
|
<string name="update_log_title">Updates, newest first:</string>
|
||||||
<string name="btn_flush_now">Upload now</string>
|
<string name="btn_flush_now">Upload now</string>
|
||||||
<string name="foreground_title">Foreground apps</string>
|
<string name="foreground_title">Foreground apps</string>
|
||||||
<string name="foreground_hint">For the CarPlay full-screen test: open CarPlay, come back here, read its name off the list. Reads only.</string>
|
<string name="foreground_hint">For the CarPlay full-screen test: open CarPlay, come back here, read its name off the list. Reads only.</string>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
/** The update log's line handling — pure JVM. */
|
||||||
|
public class UpdateLogTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void newestComesFirstAndTheOldestFallsOff() {
|
||||||
|
List<String> lines = UpdateLog.append(Arrays.asList("b", "a"), "c", 2);
|
||||||
|
assertEquals(Arrays.asList("c", "b"), lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void aMultiLinePlatformMessageStaysOneLine() {
|
||||||
|
List<String> lines = UpdateLog.append(Collections.emptyList(), "failed:\nINSTALL_X\r", 10);
|
||||||
|
assertEquals(Collections.singletonList("failed: INSTALL_X "), lines);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,8 @@ public final class OtaInstallResultReceiver extends BroadcastReceiver {
|
|||||||
int status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE);
|
int status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE);
|
||||||
String message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE);
|
String message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE);
|
||||||
Log.i(TAG, "OTA install result: status=" + status + " msg=" + message);
|
Log.i(TAG, "OTA install result: status=" + status + " msg=" + message);
|
||||||
|
UpdateLog.record(context, "Install result: status " + status
|
||||||
|
+ (message != null ? ", " + message : ""));
|
||||||
|
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case PackageInstaller.STATUS_PENDING_USER_ACTION:
|
case PackageInstaller.STATUS_PENDING_USER_ACTION:
|
||||||
|
|||||||
@@ -468,11 +468,14 @@ final class OtaUpdater {
|
|||||||
return InstallResult.SESSION_STARTED;
|
return InstallResult.SESSION_STARTED;
|
||||||
} catch (Throwable t) {
|
} catch (Throwable t) {
|
||||||
Log.w(TAG, "OTA session install failed; trying system install UI", t);
|
Log.w(TAG, "OTA session install failed; trying system install UI", t);
|
||||||
|
UpdateLog.record(context, "Installer session failed: " + t);
|
||||||
try {
|
try {
|
||||||
launchInstallerActivity(context, apk);
|
launchInstallerActivity(context, apk);
|
||||||
|
UpdateLog.record(context, "Opened the system installer instead");
|
||||||
return InstallResult.INSTALL_UI_STARTED;
|
return InstallResult.INSTALL_UI_STARTED;
|
||||||
} catch (Throwable fallback) {
|
} catch (Throwable fallback) {
|
||||||
Log.w(TAG, "OTA install UI failed", fallback);
|
Log.w(TAG, "OTA install UI failed", fallback);
|
||||||
|
UpdateLog.record(context, "System installer failed too: " + fallback);
|
||||||
return InstallResult.SESSION_FAILED;
|
return InstallResult.SESSION_FAILED;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -490,6 +493,8 @@ final class OtaUpdater {
|
|||||||
.getPackageArchiveInfo(apk.getAbsolutePath(), 0);
|
.getPackageArchiveInfo(apk.getAbsolutePath(), 0);
|
||||||
if (info == null || info.packageName == null) {
|
if (info == null || info.packageName == null) {
|
||||||
Log.w(TAG, "OTA archive cannot be parsed by the package manager");
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
if (!context.getPackageName().equals(info.packageName)) {
|
if (!context.getPackageName().equals(info.packageName)) {
|
||||||
@@ -533,6 +538,8 @@ final class OtaUpdater {
|
|||||||
// confirm, EXTRA_INTENT. An immutable PendingIntent would arrive empty.
|
// confirm, EXTRA_INTENT. An immutable PendingIntent would arrive empty.
|
||||||
PendingIntent pending = PendingIntent.getBroadcast(context, sessionId, callback,
|
PendingIntent pending = PendingIntent.getBroadcast(context, sessionId, callback,
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE);
|
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());
|
session.commit(pending.getIntentSender());
|
||||||
committed = true;
|
committed = true;
|
||||||
Log.i(TAG, "OTA session " + sessionId + " committed (" + apk.length() + " bytes)");
|
Log.i(TAG, "OTA session " + sessionId + " committed (" + apk.length() + " bytes)");
|
||||||
|
|||||||
@@ -83,10 +83,12 @@ final class UpdateHook {
|
|||||||
message = runCheck(app, listener, main);
|
message = runCheck(app, listener, main);
|
||||||
} catch (Throwable t) {
|
} catch (Throwable t) {
|
||||||
Log.w(TAG, "Update check threw", t);
|
Log.w(TAG, "Update check threw", t);
|
||||||
message = "Update check failed";
|
// The class only: a network error's text can carry the server's address.
|
||||||
|
message = "Update check failed (" + t.getClass().getSimpleName() + ")";
|
||||||
} finally {
|
} finally {
|
||||||
checking.set(false);
|
checking.set(false);
|
||||||
}
|
}
|
||||||
|
UpdateLog.record(app, "Check: " + message);
|
||||||
if (listener != null) {
|
if (listener != null) {
|
||||||
final String result = message;
|
final String result = message;
|
||||||
main.post(() -> listener.onResult(result));
|
main.post(() -> listener.onResult(result));
|
||||||
@@ -112,10 +114,11 @@ final class UpdateHook {
|
|||||||
message = runUsbInstall(app);
|
message = runUsbInstall(app);
|
||||||
} catch (Throwable t) {
|
} catch (Throwable t) {
|
||||||
Log.w(TAG, "USB install threw", t);
|
Log.w(TAG, "USB install threw", t);
|
||||||
message = "Installing from USB failed";
|
message = "Installing from USB failed: " + t;
|
||||||
} finally {
|
} finally {
|
||||||
checking.set(false);
|
checking.set(false);
|
||||||
}
|
}
|
||||||
|
UpdateLog.record(app, "USB: " + message);
|
||||||
if (listener != null) {
|
if (listener != null) {
|
||||||
final String result = message;
|
final String result = message;
|
||||||
main.post(() -> listener.onResult(result));
|
main.post(() -> listener.onResult(result));
|
||||||
@@ -141,6 +144,7 @@ final class UpdateHook {
|
|||||||
if (best == null) {
|
if (best == null) {
|
||||||
return "No newer NilsMG4-unstable-<version>.apk on a USB stick (installed: " + current + ")";
|
return "No newer NilsMG4-unstable-<version>.apk on a USB stick (installed: " + current + ")";
|
||||||
}
|
}
|
||||||
|
UpdateLog.record(app, "USB: found " + best + " (" + best.length() + " bytes)");
|
||||||
File sidecar = new File(best.getPath() + ".sha256");
|
File sidecar = new File(best.getPath() + ".sha256");
|
||||||
String expected = sidecar.isFile() && sidecar.length() < 16 * 1024
|
String expected = sidecar.isFile() && sidecar.length() < 16 * 1024
|
||||||
? OtaUpdater.parseExpectedSha256(new String(java.nio.file.Files.readAllBytes(sidecar.toPath()),
|
? OtaUpdater.parseExpectedSha256(new String(java.nio.file.Files.readAllBytes(sidecar.toPath()),
|
||||||
@@ -156,6 +160,7 @@ final class UpdateHook {
|
|||||||
StringBuilder hex = new StringBuilder();
|
StringBuilder hex = new StringBuilder();
|
||||||
for (byte b : digest.digest()) hex.append(String.format(java.util.Locale.US, "%02x", b));
|
for (byte b : digest.digest()) hex.append(String.format(java.util.Locale.US, "%02x", b));
|
||||||
if (!hex.toString().equalsIgnoreCase(expected)) return "Not installed: " + best.getName() + " does not match its SHA-256";
|
if (!hex.toString().equalsIgnoreCase(expected)) return "Not installed: " + best.getName() + " does not match its SHA-256";
|
||||||
|
UpdateLog.record(app, "USB: SHA-256 matches");
|
||||||
String version = OtaUpdater.versionFromAssetName(best.getName());
|
String version = OtaUpdater.versionFromAssetName(best.getName());
|
||||||
switch (OtaUpdater.install(app, best)) {
|
switch (OtaUpdater.install(app, best)) {
|
||||||
case SESSION_STARTED:
|
case SESSION_STARTED:
|
||||||
|
|||||||
Reference in New Issue
Block a user