App 2.41.0, server 2.9.0: recordings reach the server

A finished signal recording goes to an outbox and is sent gzipped with
the car's token (PUT /v1/logs/{name}); the service retries every 2
minutes while the car is offline. The server keeps the newest 200 on a
new volume (LOGS_DIR); reading takes a token of its own (LOGS_TOKEN).
fetch-logs in the dev shell fetches new ones into logs/.

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-27 10:22:40 +01:00
co-authored by Claude Opus 5.5
parent 16e6f349da
commit eebe840b6a
20 changed files with 840 additions and 13 deletions
@@ -223,6 +223,7 @@ public class AbrpUploadService extends Service {
consumptionTracker.sample(vehicle);
chargeSessionTracker.sample(vehicle);
consumptionCloudClient.syncIfDue(consumptionTracker, chargeSessionTracker, sampleMs);
LogOutbox.sendIfDue(this); // recordings the car could not send when they ended
UploadSettings settings = UploadSettings.from(prefs);
// Live data goes to the server, to ABRP directly, or both; with neither set up there is
@@ -0,0 +1,156 @@
package com.evsuite.abrp;
import android.content.Context;
import android.util.Log;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.regex.Pattern;
import java.util.zip.GZIPOutputStream;
/**
* Files from the Logs tab (signal recordings) on their way to the car server, so they reach the
* owner without a USB stick: kept in {@code files/log-outbox} until the server has them
* ({@code PUT /v1/logs/{name}}, gzip, the ingest token in the header), sent at once and, when
* that fails, again from the service's tick, at most every {@link #RETRY_MS}. At most
* {@link #MAX_FILES} wait; the oldest goes first. Names are the app's own and say nothing about
* where the car is.
*/
final class LogOutbox {
static final String DIR = "log-outbox";
static final int MAX_FILES = 10;
static final long RETRY_MS = 2 * 60_000L;
private static final String TAG = "EVABRP.LogOutbox";
/** As the server accepts them. */
private static final Pattern NAME = Pattern.compile("[a-z0-9][a-z0-9-]{0,79}\\.txt");
/** What to do with a file after the server's answer. */
enum Outcome { SENT, DROP, KEEP }
private static long lastFailureMs;
private static String lastResult;
private LogOutbox() {}
/** SENT for a 2xx; KEEP for what may pass later (no answer, 401 before pairing, 408, 429,
* 5xx, and a 404 from a server without logs yet); DROP for any other refusal. */
static Outcome outcome(int status) {
if (status >= 200 && status < 300) return Outcome.SENT;
if (status <= 0 || status == 401 || status == 404 || status == 408 || status == 429 || status >= 500) {
return Outcome.KEEP;
}
return Outcome.DROP;
}
static boolean validName(String name) {
return name != null && NAME.matcher(name).matches();
}
/** The names to remove so that at most [max] remain: the oldest (by name, which starts
* with what they are and ends in the time) beyond the newest [max]. */
static List<String> surplus(List<String> names, int max) {
List<String> sorted = new ArrayList<>(names);
Collections.sort(sorted);
return sorted.size() <= max ? Collections.emptyList() : sorted.subList(0, sorted.size() - max);
}
/** Puts [text] in the outbox as [name]; false when it could not be written. */
static synchronized boolean add(Context context, String name, List<String> text) {
if (!validName(name)) return false;
File dir = new File(context.getFilesDir(), DIR);
if (!dir.isDirectory() && !dir.mkdirs()) return false;
File tmp = new File(dir, name + ".tmp");
try (Writer out = new OutputStreamWriter(new FileOutputStream(tmp), StandardCharsets.UTF_8)) {
for (String line : text) out.write(line + "\n");
} catch (Exception e) {
tmp.delete();
return false;
}
if (!tmp.renameTo(new File(dir, name))) {
tmp.delete();
return false;
}
for (String old : surplus(waiting(dir), MAX_FILES)) new File(dir, old).delete();
lastFailureMs = 0; // something new: try at once
return true;
}
/** 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) {
synchronized (LogOutbox.class) {
if (lastFailureMs != 0 && System.currentTimeMillis() - lastFailureMs < RETRY_MS) return;
}
send(context);
}
/** Sends every waiting file, oldest first, until one fails. Blocking. */
static synchronized void send(Context context) {
File dir = new File(context.getFilesDir(), DIR);
List<String> names = waiting(dir);
if (names.isEmpty()) return;
ServerConfig config = ServerConfig.get(context);
if (!config.isConfigured()) {
lastResult = "no server set up";
lastFailureMs = System.currentTimeMillis();
return;
}
Collections.sort(names);
for (String name : names) {
File file = new File(dir, name);
int status;
try {
byte[] body = gzip(Files.readAllBytes(file.toPath()));
status = ServerHttp.send("PUT", config.url + "/v1/logs/" + name, "Bearer " + config.token,
"text/plain; charset=utf-8", "gzip", body).status;
} catch (Exception e) {
Log.w(TAG, "Log not sent: " + e.getClass().getSimpleName());
status = 0;
}
Outcome outcome = outcome(status);
if (outcome == Outcome.KEEP) {
lastResult = status == 0 ? "server not reached" : "server answered " + status;
lastFailureMs = System.currentTimeMillis();
return;
}
file.delete();
lastResult = outcome == Outcome.SENT ? "sent" : "refused by the server (" + status + ")";
Log.i(TAG, "Log " + name + ": " + lastResult);
}
lastFailureMs = 0;
}
/** For the Logs tab: whether the last file reached the server, or why not yet. */
static synchronized String status(Context context) {
int waiting = waiting(new File(context.getFilesDir(), DIR)).size();
if (waiting == 0) return lastResult == null ? null : "sent".equals(lastResult)
? context.getString(R.string.log_outbox_sent) : lastResult;
return context.getString(R.string.log_outbox_waiting, waiting, lastResult == null ? "…" : lastResult);
}
private static List<String> waiting(File dir) {
String[] names = dir.list();
List<String> out = new ArrayList<>();
if (names == null) return out;
for (String n : Arrays.asList(names)) if (validName(n)) out.add(n);
return out;
}
private static byte[] gzip(byte[] data) throws java.io.IOException {
ByteArrayOutputStream buf = new ByteArrayOutputStream(data.length / 4 + 64);
try (GZIPOutputStream z = new GZIPOutputStream(buf)) {
z.write(data);
}
return buf.toByteArray();
}
}
@@ -59,6 +59,12 @@ final class ServerHttp {
*/
static Response send(String method, String url, String authorization, String contentType, byte[] body)
throws Exception {
return send(method, url, authorization, contentType, null, body);
}
/** As above, with a {@code Content-Encoding} ({@code "gzip"}) for the body, or null. */
static Response send(String method, String url, String authorization, String contentType,
String contentEncoding, byte[] body) throws Exception {
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
try {
connection.setRequestMethod(method);
@@ -69,6 +75,7 @@ final class ServerHttp {
connection.setDoOutput(true);
connection.setFixedLengthStreamingMode(body.length);
connection.setRequestProperty("Content-Type", contentType);
if (contentEncoding != null) connection.setRequestProperty("Content-Encoding", contentEncoding);
try (OutputStream out = connection.getOutputStream()) { out.write(body); }
}
int status = connection.getResponseCode();
@@ -39,8 +39,8 @@ import java.util.Map;
* 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.
* keeps the process), and stops by itself. At the end the recording goes to the app's storage,
* to the app's folder on every USB stick plugged in, and to the car server ({@link LogOutbox}).
*/
final class SignalRecorder {
@@ -181,6 +181,12 @@ final class SignalRecorder {
// A volume that cannot say what it is gets nothing.
}
}
// And to the server, so it reaches the owner without a stick; the service sends it again
// later when the car is offline now.
if (LogOutbox.add(app, name, text)) {
LogOutbox.send(app);
places.add("for the server");
}
return places.isEmpty() ? "not saved" : "saved: " + String.join(", ", places);
}
@@ -204,6 +210,8 @@ final class SignalRecorder {
} else {
out.add(context.getString(R.string.signal_recorder_done, SignalRecording.time(startedMs),
recording.captures(), recording.events(), result == null ? "…" : result));
String sent = LogOutbox.status(context);
if (sent != null) out.add(sent);
}
// Steering-wheel keys and the like first: few, and each one a lead.
List<String> notes = recording.notes();
+3 -1
View File
@@ -223,11 +223,13 @@
<string name="drive_mode_unreadable">Not changed: speed or gear unreadable.</string>
<string name="drive_mode_unknown">an unknown mode (%1$s)</string>
<string name="label_signal_recorder">Signal recorder</string>
<string name="signal_recorder_hint">Records every value the head unit offers for 3 minutes, listens to the driver-assistance properties for every change they report, and notes the steering-wheel keys; then lists what changed, the rarest first. To find a signal: start it, note the times you switch the feature on and off or change it, and look for the values that changed then. With a USB stick plugged in, the whole recording is saved on it too (Android/data/…/files).</string>
<string name="signal_recorder_hint">Records every value the head unit offers for 3 minutes, listens to the driver-assistance properties for every change they report, and notes the steering-wheel keys; then lists what changed, the rarest first. To find a signal: start it, note the times you switch the feature on and off or change it, and look for the values that changed then. The whole recording goes to your server (later, if the car is offline), and with a USB stick plugged in it is saved on it too (Android/data/…/files).</string>
<string name="btn_signal_record">Record 3 minutes</string>
<string name="btn_signal_stop">Stop recording</string>
<string name="signal_recorder_running">Recording: %1$d:%2$02d of 3:00, %3$d captures, %4$d change events heard. Keys reported, then assistance properties heard, then values changed so far, the rarest first:</string>
<string name="signal_recorder_done">Recording from %1$s: %2$d captures, %3$d change events heard, %4$s. Keys reported, then assistance properties heard, then values changed, the rarest first:</string>
<string name="log_outbox_sent">Sent to the server.</string>
<string name="log_outbox_waiting">Not on the server yet (%1$d waiting: %2$s); it is sent again every 2 minutes.</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>