App 2.32.0: an ACC recorder that records every value the head unit offers

The 2.31.1 log on the car showed the named assist properties constant
while ACC was engaged behind another car: they are not fed. The recorder
reads every property and vendor getter (read only, at most half the time
busy) for 3 minutes, plus steering-wheel key broadcasts, and lists what
changed, the rarest first. The recording is saved in the app and on a
USB stick if one is plugged in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
This commit is contained in:
2026-10-25 19:27:43 +01:00
co-authored by Claude Opus 5.5
parent 36cc417638
commit 2e6bb9cb83
9 changed files with 406 additions and 3 deletions
@@ -0,0 +1,182 @@
package com.evsuite.abrp;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.Bundle;
import android.os.Environment;
import com.evsuite.hardware.probe.VendorSurfaceProbe;
import java.io.File;
import java.io.FileOutputStream;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/**
* The ACC change recorder on the Logs tab: for {@link #DURATION_MS} it reads every value the
* head unit offers (`VendorSurfaceProbe.capture`: every CarPropertyManager property and every
* value getter of the vendor objects, read only) again and again, and every steering-wheel key
* the head unit reports, into an {@link AccRecording}. The driver notes the times they switch
* the cruise control on, change the set speed and the distance, and switch it off; the values
* that change then are the leads.
*
* A full capture is far heavier than the Overview's reading, so the next one waits at least
* {@link #MIN_INTERVAL_MS} and at least as long as the last one took: the recorder uses at most
* half the time. It runs on its own thread, on after the app leaves the screen (the service
* keeps the process), and stops by itself. At the end the recording goes to the app's storage
* and to the app's folder on every USB stick plugged in.
*/
final class AccRecorder {
static final long DURATION_MS = 3 * 60_000L;
static final long MIN_INTERVAL_MS = 2_000L;
static final String FILE = "acc_recording.txt";
private static AccRecording recording;
private static volatile boolean running;
private static long startedMs;
private static String result;
private static Thread thread;
private AccRecorder() {}
static synchronized boolean running() {
return running;
}
static synchronized void start(Context context) {
if (running) return;
Context app = context.getApplicationContext();
recording = new AccRecording();
result = null;
startedMs = System.currentTimeMillis();
running = true;
thread = new Thread(() -> record(app), "acc-recorder");
thread.start();
}
static synchronized void stop() {
running = false;
if (thread != null) thread.interrupt();
}
private static void record(Context app) {
AccRecording rec = recording;
BroadcastReceiver keys = new BroadcastReceiver() {
@Override public void onReceive(Context c, Intent intent) {
rec.note(System.currentTimeMillis(), "steering-wheel key: " + extras(intent.getExtras()));
}
};
boolean keysRegistered = false;
try {
androidx.core.content.ContextCompat.registerReceiver(app, keys,
new IntentFilter(SteeringKeyListener.ACTION), androidx.core.content.ContextCompat.RECEIVER_EXPORTED);
keysRegistered = true;
} catch (RuntimeException ignored) {
// No key reports: the properties are recorded all the same.
}
try {
long end = startedMs + DURATION_MS;
while (running && System.currentTimeMillis() < end) {
long t0 = System.currentTimeMillis();
Map<String, String> capture = VendorSurfaceProbe.INSTANCE.capture(Collections.emptyList());
rec.add(t0, capture);
long took = System.currentTimeMillis() - t0;
Thread.sleep(Math.max(MIN_INTERVAL_MS - took, took));
}
} catch (InterruptedException ignored) {
// Stopped by the driver.
} catch (Throwable t) {
rec.note(System.currentTimeMillis(), "recorder stopped: " + t.getClass().getSimpleName());
} finally {
if (keysRegistered) {
try {
app.unregisterReceiver(keys);
} catch (RuntimeException ignored) {
// already gone
}
}
String saved = save(app, rec);
synchronized (AccRecorder.class) {
running = false;
result = saved;
}
}
}
private static String extras(Bundle extras) {
if (extras == null) return "(no extras)";
List<String> parts = new ArrayList<>();
for (String k : new java.util.TreeSet<>(extras.keySet())) parts.add(k + "=" + extras.get(k));
return String.join(", ", parts);
}
/** Writes the recording; says where it went. */
private static String save(Context app, AccRecording rec) {
List<String> text = new ArrayList<>();
text.add("ACC recording, " + rec.captures() + " captures from "
+ AccRecording.time(startedMs) + ", app " + BuildConfig.VERSION_NAME);
text.add("");
text.add("Steering-wheel keys and notes:");
text.addAll(rec.notes());
text.add("");
text.add("Changed values, the rarest first:");
text.addAll(rec.summary());
text.add("");
text.add("Everything, in order:");
text.addAll(rec.lines());
List<String> places = new ArrayList<>();
if (write(new File(app.getFilesDir(), FILE), text)) places.add("app");
String name = "acc-recording-" + new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US)
.format(new Date(startedMs)) + ".txt";
File[] dirs = app.getExternalFilesDirs(null);
for (File dir : dirs == null ? new File[0] : dirs) {
try {
if (dir == null || !Environment.isExternalStorageRemovable(dir)) continue;
File file = new File(dir, name);
if (write(file, text)) places.add(file.getPath());
} catch (RuntimeException ignored) {
// A volume that cannot say what it is gets nothing.
}
}
return places.isEmpty() ? "not saved" : "saved: " + String.join(", ", places);
}
private static boolean write(File file, List<String> text) {
try (Writer out = new OutputStreamWriter(new FileOutputStream(file), StandardCharsets.UTF_8)) {
for (String line : text) out.write(line + "\n");
return true;
} catch (Exception e) {
return false;
}
}
/** What the Logs tab shows: the state, then the changed values, the rarest first. */
static synchronized List<String> status(Context context) {
List<String> out = new ArrayList<>();
if (recording == null) return out;
long elapsed = (running ? System.currentTimeMillis() : startedMs + DURATION_MS) - startedMs;
if (running) {
out.add(context.getString(R.string.acc_recorder_running, elapsed / 60_000, (elapsed / 1000) % 60,
recording.captures()));
} else {
out.add(context.getString(R.string.acc_recorder_done, AccRecording.time(startedMs),
recording.captures(), result == null ? "…" : result));
}
// Steering-wheel keys and the like first: few, and each one a lead.
List<String> notes = recording.notes();
out.addAll(notes.subList(Math.max(0, notes.size() - 10), notes.size()));
out.addAll(recording.summary());
return out;
}
}
@@ -0,0 +1,108 @@
package com.evsuite.abrp;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TreeSet;
/**
* One recording of the ACC change recorder ({@link AccRecorder}): every value the head unit
* offers, captured again and again while the driver switches the adaptive cruise control on,
* changes the set speed and the distance, and switches it off. It keeps what changed, when,
* and per value how often and through which values, so the value that follows the cruise
* control stands out: it changes a few times, at the moments the driver noted.
* Pure Java, unit-tested.
*/
final class AccRecording {
/** Distinct values kept per key for the summary. */
static final int VALUES_KEPT = 6;
/** Longest value text in the summary. */
static final int VALUE_CHARS = 40;
private Map<String, String> last;
private int captures;
private final List<String> lines = new ArrayList<>();
private final List<String> notes = new ArrayList<>();
private final Map<String, Integer> changes = new HashMap<>();
private final Map<String, LinkedHashSet<String>> seen = new HashMap<>();
static String time(long ms) {
return new SimpleDateFormat("HH:mm:ss", Locale.US).format(new Date(ms));
}
/** A capture: the first is kept whole, each later one as what changed against the one before. */
synchronized void add(long ms, Map<String, String> capture) {
captures++;
if (last == null) {
lines.add(time(ms) + " first capture, " + capture.size() + " values:");
for (Map.Entry<String, String> e : capture.entrySet()) {
lines.add(" " + e.getKey() + " = " + e.getValue());
remember(e.getKey(), e.getValue());
}
} else {
TreeSet<String> keys = new TreeSet<>(last.keySet());
keys.addAll(capture.keySet());
for (String key : keys) {
String before = last.get(key), after = capture.get(key);
if (before == null ? after == null : before.equals(after)) continue;
lines.add(time(ms) + " " + key + ": " + before + " → " + after);
changes.merge(key, 1, Integer::sum);
remember(key, after);
}
}
last = capture;
}
/** Something else that happened, such as a steering-wheel key the head unit reported. */
synchronized void note(long ms, String what) {
lines.add(time(ms) + " " + what);
notes.add(time(ms) + " " + what);
}
/** The notes alone, in order. */
synchronized List<String> notes() {
return new ArrayList<>(notes);
}
private void remember(String key, String value) {
LinkedHashSet<String> values = seen.computeIfAbsent(key, k -> new LinkedHashSet<>());
if (values.size() < VALUES_KEPT) values.add(shorten(String.valueOf(value)));
}
static String shorten(String value) {
return value.length() <= VALUE_CHARS ? value : value.substring(0, VALUE_CHARS - 1) + "…";
}
synchronized int captures() {
return captures;
}
/** Everything, in order: the first capture, then each change and note. */
synchronized List<String> lines() {
return new ArrayList<>(lines);
}
/**
* Every value that changed, the rarest first (the cruise control's switch and set speed
* change a handful of times; speed and power all the time): key, how often, the values.
*/
synchronized List<String> summary() {
List<String> keys = new ArrayList<>(changes.keySet());
keys.sort((a, b) -> {
int c = Integer.compare(changes.get(a), changes.get(b));
return c != 0 ? c : a.compareTo(b);
});
List<String> out = new ArrayList<>();
for (String key : keys) {
out.add(key + " " + changes.get(key) + "× " + String.join(" → ", seen.get(key))
+ (seen.get(key).size() >= VALUES_KEPT ? " …" : ""));
}
return out;
}
}
@@ -375,6 +375,11 @@ public class MainActivity extends AppCompatActivity {
statusText = servicePane.findViewById(R.id.status_text);
callLogText = logPane.findViewById(R.id.call_log_text);
logPane.findViewById(R.id.flush_now_button).setOnClickListener(v -> manualFlush());
logPane.findViewById(R.id.acc_record_button).setOnClickListener(v -> {
if (AccRecorder.running()) AccRecorder.stop();
else AccRecorder.start(this);
refreshAccRecorder();
});
setUpPager();
// Back on the page that was open when the activity was recreated (accent colour change).
@@ -2128,7 +2133,20 @@ public class MainActivity extends AppCompatActivity {
}, "cloud-refresh").start();
}
/** The ACC recorder's button and what it has found so far. */
private void refreshAccRecorder() {
MaterialButton button = logPane.findViewById(R.id.acc_record_button);
button.setText(AccRecorder.running() ? R.string.btn_acc_stop : R.string.btn_acc_record);
TextView text = logPane.findViewById(R.id.acc_record_text);
java.util.List<String> status = AccRecorder.status(this);
text.setVisibility(status.isEmpty() ? View.GONE : View.VISIBLE);
// The rarest changes are the leads; the rest is in the saved recording.
if (status.size() > 81) status = status.subList(0, 81);
if (!status.isEmpty()) text.setText(String.join("\n", status));
}
private void refreshCallLog() {
refreshAccRecorder();
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);
+7
View File
@@ -1,6 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<com.evsuite.abrp.StableScrollView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true" android:clipToPadding="false">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="12dp" android:paddingBottom="12dp">
<!-- The ACC change recorder (AccRecorder): every value the head unit offers, recorded while driving. -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="16dp" android:paddingBottom="16dp" android:layout_marginBottom="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_acc_recorder" android:textAppearance="@style/Text.EV.Eyebrow" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:text="@string/acc_recorder_hint" android:textAppearance="@style/Text.EV.Secondary" />
<com.google.android.material.button.MaterialButton android:id="@+id/acc_record_button" android:layout_width="match_parent" android:layout_height="72dp" android:layout_marginTop="16dp" android:text="@string/btn_acc_record" style="@style/Widget.EV.Button.Outlined" />
<TextView android:id="@+id/acc_record_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:textAppearance="@style/Text.EV.Body" android:fontFamily="monospace" android:textSize="17sp" android:lineSpacingExtra="8dp" android:textIsSelectable="true" android:visibility="gone" />
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="16dp" android:paddingBottom="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_call_log" android:textAppearance="@style/Text.EV.Eyebrow" />
<com.google.android.material.button.MaterialButton android:id="@+id/flush_now_button" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:text="@string/btn_flush_now" app:icon="@drawable/ic_refresh" app:iconGravity="textStart" style="@style/Widget.EV.Button.Outlined" />
+6
View File
@@ -302,6 +302,12 @@
<!-- Call log -->
<string name="label_call_log">Recent uploads</string>
<string name="label_acc_recorder">ACC recorder</string>
<string name="acc_recorder_hint">Records every value the head unit offers for 3 minutes, plus the steering-wheel keys it reports, and lists what changed. Start it while driving, note the times you switch the cruise control on, change the set speed and the distance, and switch it off. With a USB stick plugged in, the whole recording is saved on it too (Android/data/…/files).</string>
<string name="btn_acc_record">Record 3 minutes</string>
<string name="btn_acc_stop">Stop recording</string>
<string name="acc_recorder_running">Recording: %1$d:%2$02d of 3:00, %3$d captures. Keys reported, then values changed so far, the rarest first:</string>
<string name="acc_recorder_done">Recording from %1$s: %2$d captures, %3$s. Keys reported, then values changed, the rarest first:</string>
<string name="call_log_empty">No upload attempted yet.</string>
<string name="update_log_title">Updates, newest first:</string>
<string name="cruise_log_title">Adaptive cruise control as the car reports it, raw, newest first: a line when a mode or the target changes, and every 2 s while driving with this app open. cc target speed, ica assist mode, tja traffic jam assist, sas/sam speed assist, obj/dist/rel the car ahead (presence, distance, relative speed).\nNow: %1$s</string>
@@ -0,0 +1,66 @@
package com.evsuite.abrp;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import org.junit.Test;
/** The ACC change recorder's bookkeeping — pure JVM. */
public class AccRecordingTest {
private static Map<String, String> capture(String speed, String acc) {
Map<String, String> m = new TreeMap<>();
m.put("speed", speed);
m.put("acc", acc);
m.put("steady", "1");
return m;
}
@Test
public void rarestChangeFirst() {
AccRecording r = new AccRecording();
r.add(0, capture("10", "0"));
r.add(2000, capture("20", "0"));
r.add(4000, capture("30", "120"));
r.add(6000, capture("40", "120"));
r.add(8000, capture("50", "0"));
List<String> summary = r.summary();
assertEquals(2, summary.size());
assertEquals("acc 2× 0 → 120", summary.get(0));
assertTrue(summary.get(1).startsWith("speed 4× 10 → 20 → 30 → 40 → 50"));
assertEquals(5, r.captures());
}
@Test
public void appearingAndVanishingKeysAreChanges() {
AccRecording r = new AccRecording();
Map<String, String> a = capture("1", "0");
Map<String, String> b = capture("1", "0");
b.put("new", "5");
r.add(0, a);
r.add(1000, b);
r.add(2000, a);
assertEquals("new 2× 5 → null", r.summary().get(0));
}
@Test
public void longValuesAreShortened() {
String v = "[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]";
assertEquals(AccRecording.VALUE_CHARS, AccRecording.shorten(v).length());
assertEquals("short", AccRecording.shorten("short"));
}
@Test
public void firstCaptureIsKeptWhole() {
AccRecording r = new AccRecording();
r.add(0, capture("1", "0"));
r.note(500, "key 287 down");
assertEquals(5, r.lines().size());
assertTrue(r.lines().get(4).endsWith("key 287 down"));
assertEquals(1, r.notes().size());
}
}