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 surplus(List names, int max) { List 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 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 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 waiting(File dir) { String[] names = dir.list(); List 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(); } }