Each check writes down the networks Android sees, the server's name lookup, how a failure happened and how many reports wait, and on a failure whether the internet answers at all, to tell a SIM without data from a server that is down. The report goes to a USB stick and, once online, the server. No address or token is written. Tried on the emulator. Not tried on the car. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
163 lines
6.6 KiB
Java
163 lines
6.6 KiB
Java
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;
|
|
}
|
|
|
|
/** Removes what waits under names starting with [prefix]. */
|
|
static synchronized void dropWaiting(Context context, String prefix) {
|
|
File dir = new File(context.getFilesDir(), DIR);
|
|
for (String name : waiting(dir)) if (name.startsWith(prefix)) new File(dir, name).delete();
|
|
}
|
|
|
|
/** 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();
|
|
}
|
|
}
|