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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user