diff --git a/SPECS.md b/SPECS.md index a642041..80820fe 100644 --- a/SPECS.md +++ b/SPECS.md @@ -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 (`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. -- [ ] **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/` library (mostly `EVHardware.kt`, some tests), half-translated upstream. The 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 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. +- 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 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. diff --git a/app/build.gradle b/app/build.gradle index 53d4044..c85119b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 82 - versionName "2.14.4" + versionCode 83 + versionName "2.15.0" // 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. diff --git a/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java b/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java index 5b3e351..63892dc 100644 --- a/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java +++ b/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java @@ -141,7 +141,10 @@ public class AbrpUploadService extends Service { running = false; prefs.edit().putBoolean("service_running", false).apply(); 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(); Log.i(TAG, "Service stopped"); } diff --git a/app/src/main/java/com/evsuite/abrp/TelemetryQueue.java b/app/src/main/java/com/evsuite/abrp/TelemetryQueue.java index 50883d7..a93d827 100644 --- a/app/src/main/java/com/evsuite/abrp/TelemetryQueue.java +++ b/app/src/main/java/com/evsuite/abrp/TelemetryQueue.java @@ -9,8 +9,9 @@ import java.util.List; * * 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. - * Held in memory only — a process restart loses what is queued, which is the price of not - * writing to flash every 15 seconds. + * Kept in memory and saved to a file now and then (TelemetryUploader), so a reboot or a + * 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. */ @@ -29,7 +30,11 @@ final class TelemetryQueue { 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) { + version++; if (samples.size() >= capacity) { samples.removeFirst(); dropped++; @@ -49,6 +54,7 @@ final class TelemetryQueue { /** Drops the oldest {@code count} samples once the server has confirmed them. */ synchronized void remove(int count) { + if (count > 0) version++; 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 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 restored = decode(saved); + if (restored.isEmpty()) return 0; + ArrayDeque 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 decode(String saved) { + List 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. */ synchronized int dropped() { return dropped; } } diff --git a/app/src/main/java/com/evsuite/abrp/TelemetryUploader.java b/app/src/main/java/com/evsuite/abrp/TelemetryUploader.java index 8fbd144..f4f558c 100644 --- a/app/src/main/java/com/evsuite/abrp/TelemetryUploader.java +++ b/app/src/main/java/com/evsuite/abrp/TelemetryUploader.java @@ -59,6 +59,59 @@ final class TelemetryUploader { TelemetryUploader(Context context) { 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 ---------- @@ -101,11 +154,15 @@ final class TelemetryUploader { if (clockSet && (!idle || uploadDue)) { 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 // spend power exactly when it buys nothing. lastUploadAttemptMs = nowMs; flush(); + maybeSave(); } int queued() { return queue.size(); } diff --git a/app/src/test/java/com/evsuite/abrp/TelemetryQueueTest.java b/app/src/test/java/com/evsuite/abrp/TelemetryQueueTest.java index 823a491..76bee73 100644 --- a/app/src/test/java/com/evsuite/abrp/TelemetryQueueTest.java +++ b/app/src/test/java/com/evsuite/abrp/TelemetryQueueTest.java @@ -1,6 +1,7 @@ package com.evsuite.abrp; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; import java.util.List; @@ -37,4 +38,46 @@ public class TelemetryQueueTest { assertEquals(List.of("c", "d", "e"), queue.peekBatch()); 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); + } }