App 2.15.0: queued live data survives restarts

Unsent live samples were kept in memory only, so a reboot or a killed
process during a dead spot lost them. The queue is now saved to
files/telemetry_queue.json with AtomicFile, at most every 5 minutes and
only after a change (the flash is not written every 15 s), and at once
when the service stops; it is restored, oldest first and within the
2000-sample cap, when the service starts. A damaged file restores
nothing rather than failing. Tested on the emulator across a reboot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
This commit is contained in:
2026-10-18 17:56:25 +02:00
co-authored by Claude Opus 5.5
parent f63f48131e
commit f88249427f
6 changed files with 164 additions and 7 deletions
+5 -1
View File
@@ -83,7 +83,6 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does *
write the file. To run more than one replica, first move Grafana to PostgreSQL or MySQL write the file. To run more than one replica, first move Grafana to PostgreSQL or MySQL
(`GF_DATABASE_TYPE`, `GF_DATABASE_HOST`, …); the replicas then share that database instead (`GF_DATABASE_TYPE`, `GF_DATABASE_HOST`, …); the replicas then share that database instead
of a volume. The car data itself is in InfluxDB and unaffected. of a volume. The car data itself is in InfluxDB and unaffected.
- [ ] **Telemetry queue survives restarts.** Unsent live samples are kept in memory only.
- [ ] **EVHardware's comments are partly French.** About 120 comment lines in the vendored - [ ] **EVHardware's comments are partly French.** About 120 comment lines in the vendored
`EVHardware/` library (mostly `EVHardware.kt`, some tests), half-translated upstream. The `EVHardware/` library (mostly `EVHardware.kt`, some tests), half-translated upstream. The
app, the docs and the build files are English. app, the docs and the build files are English.
@@ -116,6 +115,11 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does *
INGEST_TOKEN) into git and pushed it. Commit amended and force-pushed; both values regenerated in INGEST_TOKEN) into git and pushed it. Commit amended and force-pushed; both values regenerated in
the now gitignored `k8s/secrets.yaml` (since moved to `k8s/secrets/secrets.yaml`, outside the now gitignored `k8s/secrets.yaml` (since moved to `k8s/secrets/secrets.yaml`, outside
`kubectl apply -f k8s/`). Filled-in secrets live only there from now on. `kubectl apply -f k8s/`). Filled-in secrets live only there from now on.
- 2026-10-18 — App 2.15.0: unsent live samples survive a restart. The queue is saved to
`files/telemetry_queue.json` (AtomicFile) at most every 5 minutes and only after a change, and
at once when the service stops; it is restored, in order and within its 2000-sample cap, when
the service starts. A killed process loses at most 5 minutes. Tested on the emulator across a
reboot (6 queued samples back).
- 2026-10-18 — App 2.14.4: every UI text is in `strings.xml` (Charging, Consumption and Settings - 2026-10-18 — App 2.14.4: every UI text is in `strings.xml` (Charging, Consumption and Settings
labels, toasts, the trip-reset dialog, charging and Wi-Fi states, the daily summary, the "—" labels, toasts, the trip-reset dialog, charging and Wi-Fi states, the daily summary, the "—"
placeholder); lint reports no hard-coded text. Upload-log entries stay in code as log text. placeholder); lint reports no hard-coded text. Upload-log entries stay in code as log text.
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId "com.stinnesbeck.nilsmg4" applicationId "com.stinnesbeck.nilsmg4"
minSdk 27 minSdk 27
targetSdk 36 targetSdk 36
versionCode 82 versionCode 83
versionName "2.14.4" versionName "2.15.0"
// 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.
@@ -141,7 +141,10 @@ public class AbrpUploadService extends Service {
running = false; running = false;
prefs.edit().putBoolean("service_running", false).apply(); prefs.edit().putBoolean("service_running", false).apply();
if (scheduler != null) scheduler.shutdownNow(); if (scheduler != null) scheduler.shutdownNow();
if (telemetryUploader != null) telemetryUploader.stop(); if (telemetryUploader != null) {
telemetryUploader.persist(); // what is still queued survives the restart
telemetryUploader.stop();
}
if (steeringKeys != null) steeringKeys.unregister(); if (steeringKeys != null) steeringKeys.unregister();
Log.i(TAG, "Service stopped"); Log.i(TAG, "Service stopped");
} }
@@ -9,8 +9,9 @@ import java.util.List;
* *
* Bounded: while the car is offline the oldest samples are dropped once {@link #capacity} * Bounded: while the car is offline the oldest samples are dropped once {@link #capacity}
* is reached, so a long dead spot costs the start of the gap, never the head unit's memory. * is reached, so a long dead spot costs the start of the gap, never the head unit's memory.
* Held in memory only — a process restart loses what is queued, which is the price of not * Kept in memory and saved to a file now and then (TelemetryUploader), so a reboot or a
* writing to flash every 15 seconds. * killed process on the way through a dead spot does not lose the drive. {@link #encode} and
* {@link #decode} are the file format: a JSON array of the samples as they are sent.
* *
* Free of Android types so the policy is unit-tested on the JVM. * Free of Android types so the policy is unit-tested on the JVM.
*/ */
@@ -29,7 +30,11 @@ final class TelemetryQueue {
TelemetryQueue(int capacity) { this.capacity = capacity; } TelemetryQueue(int capacity) { this.capacity = capacity; }
/** Counts every change, so the owner can tell whether there is anything new to save. */
private long version;
synchronized void add(String sampleJson) { synchronized void add(String sampleJson) {
version++;
if (samples.size() >= capacity) { if (samples.size() >= capacity) {
samples.removeFirst(); samples.removeFirst();
dropped++; dropped++;
@@ -49,6 +54,7 @@ final class TelemetryQueue {
/** Drops the oldest {@code count} samples once the server has confirmed them. */ /** Drops the oldest {@code count} samples once the server has confirmed them. */
synchronized void remove(int count) { synchronized void remove(int count) {
if (count > 0) version++;
for (int i = 0; i < count && !samples.isEmpty(); i++) samples.removeFirst(); for (int i = 0; i < count && !samples.isEmpty(); i++) samples.removeFirst();
} }
@@ -57,6 +63,50 @@ final class TelemetryQueue {
synchronized int size() { return samples.size(); } synchronized int size() { return samples.size(); }
synchronized long version() { return version; }
/** Every queued sample, oldest first, as {@link #encode} writes them. */
synchronized String encode() {
org.json.JSONArray array = new org.json.JSONArray();
for (String sample : samples) array.put(sample);
return array.toString();
}
/**
* Puts saved samples back in front of anything queued since, oldest first and within the
* capacity. Unreadable input restores nothing: a damaged file costs the backlog, never
* the app.
*/
synchronized int restore(String saved) {
List<String> restored = decode(saved);
if (restored.isEmpty()) return 0;
ArrayDeque<String> merged = new ArrayDeque<>(restored);
merged.addAll(samples);
while (merged.size() > capacity) {
merged.removeFirst();
dropped++;
}
samples.clear();
samples.addAll(merged);
version++;
return restored.size();
}
static List<String> decode(String saved) {
List<String> out = new ArrayList<>();
if (saved == null || saved.isEmpty()) return out;
try {
org.json.JSONArray array = new org.json.JSONArray(saved);
for (int i = 0; i < array.length(); i++) {
String sample = array.optString(i, null);
if (sample != null && sample.startsWith("{")) out.add(sample);
}
} catch (org.json.JSONException e) {
out.clear();
}
return out;
}
/** Samples lost to the capacity limit since the queue was created. */ /** Samples lost to the capacity limit since the queue was created. */
synchronized int dropped() { return dropped; } synchronized int dropped() { return dropped; }
} }
@@ -59,6 +59,59 @@ final class TelemetryUploader {
TelemetryUploader(Context context) { TelemetryUploader(Context context) {
this.context = context.getApplicationContext(); this.context = context.getApplicationContext();
load();
}
// ---------- Keeping the queue across restarts ----------
private static final String QUEUE_FILE = "telemetry_queue.json";
/**
* At most this often, and only after a change: the flash is not written every 15 s, and a
* killed process loses at most this much. A clean stop saves at once ({@link #persist}).
*/
private static final long SAVE_INTERVAL_MS = 5 * 60_000L;
private long savedVersion;
private long lastSaveMs;
private android.util.AtomicFile queueFile() {
return new android.util.AtomicFile(new java.io.File(context.getFilesDir(), QUEUE_FILE));
}
private synchronized void load() {
try {
byte[] bytes = queueFile().readFully();
int restored = queue.restore(new String(bytes, java.nio.charset.StandardCharsets.UTF_8));
if (restored > 0) Log.i(TAG, restored + " queued sample(s) restored");
} catch (java.io.FileNotFoundException e) {
// nothing saved yet
} catch (Exception e) {
Log.w(TAG, "Saved queue not read: " + e.getClass().getSimpleName());
}
savedVersion = queue.version();
lastSaveMs = SystemClock.elapsedRealtime();
}
private void maybeSave() {
if (SystemClock.elapsedRealtime() - lastSaveMs >= SAVE_INTERVAL_MS) persist();
}
/** Saves the queue if it changed since the last save. Atomic: a crash keeps the old file. */
synchronized void persist() {
long version = queue.version();
lastSaveMs = SystemClock.elapsedRealtime();
if (version == savedVersion) return;
android.util.AtomicFile file = queueFile();
java.io.FileOutputStream out = null;
try {
out = file.startWrite();
out.write(queue.encode().getBytes(java.nio.charset.StandardCharsets.UTF_8));
file.finishWrite(out);
savedVersion = version;
} catch (Exception e) {
if (out != null) file.failWrite(out);
Log.w(TAG, "Queue not saved: " + e.getClass().getSimpleName());
}
} }
// ---------- Tick ---------- // ---------- Tick ----------
@@ -101,11 +154,15 @@ final class TelemetryUploader {
if (clockSet && (!idle || uploadDue)) { if (clockSet && (!idle || uploadDue)) {
queue.add(buildSample(nowMs, vehicle, session, settings, soc, charging, dcfc, parked, idle)); queue.add(buildSample(nowMs, vehicle, session, settings, soc, charging, dcfc, parked, idle));
} }
if (!uploadDue) return; if (!uploadDue) {
maybeSave();
return;
}
// Cadence counts attempts, not successes: retrying every tick while offline would // Cadence counts attempts, not successes: retrying every tick while offline would
// spend power exactly when it buys nothing. // spend power exactly when it buys nothing.
lastUploadAttemptMs = nowMs; lastUploadAttemptMs = nowMs;
flush(); flush();
maybeSave();
} }
int queued() { return queue.size(); } int queued() { return queue.size(); }
@@ -1,6 +1,7 @@
package com.evsuite.abrp; package com.evsuite.abrp;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import java.util.List; import java.util.List;
@@ -37,4 +38,46 @@ public class TelemetryQueueTest {
assertEquals(List.of("c", "d", "e"), queue.peekBatch()); assertEquals(List.of("c", "d", "e"), queue.peekBatch());
assertEquals(2, queue.dropped()); assertEquals(2, queue.dropped());
} }
@Test
public void aSavedQueueComesBackInOrder() {
TelemetryQueue q = new TelemetryQueue(10);
q.add("{\"utc\":1}");
q.add("{\"utc\":2}");
TelemetryQueue restarted = new TelemetryQueue(10);
restarted.add("{\"utc\":3}"); // taken after the restart
assertEquals(2, restarted.restore(q.encode()));
assertEquals(java.util.Arrays.asList("{\"utc\":1}", "{\"utc\":2}", "{\"utc\":3}"), restarted.peekBatch());
}
@Test
public void restoringKeepsTheCapacityByDroppingTheOldest() {
TelemetryQueue q = new TelemetryQueue(10);
for (int i = 1; i <= 4; i++) q.add("{\"utc\":" + i + "}");
TelemetryQueue small = new TelemetryQueue(3);
small.restore(q.encode());
assertEquals(java.util.Arrays.asList("{\"utc\":2}", "{\"utc\":3}", "{\"utc\":4}"), small.peekBatch());
assertEquals(1, small.dropped());
}
@Test
public void aDamagedFileRestoresNothing() {
TelemetryQueue q = new TelemetryQueue(10);
assertEquals(0, q.restore("[\"{\\\"utc\\\":1}\", trunc"));
assertEquals(0, q.restore(""));
assertEquals(0, q.restore(null));
assertEquals(0, q.size());
}
@Test
public void everyChangeMovesTheVersion() {
TelemetryQueue q = new TelemetryQueue(10);
long v0 = q.version();
q.add("{\"utc\":1}");
long v1 = q.version();
q.remove(0);
assertEquals(v1, q.version()); // nothing removed, nothing to save
q.remove(1);
assertTrue(q.version() > v1 && v1 > v0);
}
} }