Code review fixes: app 2.9.0, server 2.2.0

Telemetry no longer loses queued batches. The server drops a bad value,
or a sample without a plausible time, instead of rejecting the batch,
and answers 200 with what it dropped. The app sends only values inside
the server's ranges (speed as a magnitude, so reversing is fine) and no
samples before the head unit's clock is set.

Server:
- Pairing hands out and accepts a token only after the device is saved;
  a failed save leaves the request pending.
- A paired token serves only the installation id it was paired with
  (403 otherwise); the shared INGEST_TOKEN still serves any.
- Wrong-password lockouts and open pairing requests are limited per
  client address (last X-Forwarded-For entry), so a stranger cannot
  lock the owner out; a car starting again replaces its own request.
- ABRP forwarding skips samples stamped ahead of the server's clock,
  which used to stop it until a restart.

App:
- Settings written to plaintext during a keystore failure move back
  into encrypted storage; the stored installation id wins.
- The installation id is derived from ANDROID_ID on first start, so a
  reinstall finds its history; a stored or imported id still wins.
- The scanned config file is deleted after import (it holds the token).
- GPS stops while live telemetry is off or the server unconfigured, and
  pauses while parked and idle, keeping the fix the car parked with.
- Range estimates use the monotonic clock.
- One HTTP helper (ServerHttp) for telemetry, sync and pairing.
- Last Turkish strings translated; mise logs shows the NilsMG4 tags.

Grafana reads InfluxDB with its own read-only token (new grafana-influx
Secret, created by hand as described in k8s/README.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
This commit is contained in:
2026-10-18 00:36:19 +02:00
co-authored by Claude Opus 5.5
parent 9bebf56b3a
commit a5db5604d3
35 changed files with 962 additions and 323 deletions
@@ -0,0 +1,87 @@
package com.evsuite.abrp;
import org.json.JSONObject;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
/**
* One JSON request to the car server, shared by telemetry, consumption sync and pairing.
* Blocking, with short timeouts — call it off the main thread.
*
* The token goes in the Authorization header, never the URL, and is never logged. The answer
* body is read for every status (error bodies name the problem) and capped, so a wrong server
* cannot fill memory.
*/
final class ServerHttp {
static final int TIMEOUT_MS = 8_000;
private static final int MAX_BODY_BYTES = 64 * 1024;
/** Status and body of an answer. */
static final class Response {
final int status;
final String body;
Response(int status, String body) {
this.status = status;
this.body = body;
}
boolean ok() { return status >= 200 && status < 300; }
/** The body for a log line: short, on one line. */
String excerpt() {
String text = body.replace('\n', ' ').trim();
return text.length() > 300 ? text.substring(0, 300) + "…" : text;
}
}
private ServerHttp() { }
/**
* Sends {@code json} (or nothing, when null) with {@code method} to {@code url}.
* {@code token} may be null for the unauthenticated pairing calls.
*/
static Response request(String method, String url, String token, JSONObject json) throws Exception {
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
try {
connection.setRequestMethod(method);
connection.setConnectTimeout(TIMEOUT_MS);
connection.setReadTimeout(TIMEOUT_MS);
if (token != null) connection.setRequestProperty("Authorization", "Bearer " + token);
if (json != null) {
byte[] body = json.toString().getBytes(StandardCharsets.UTF_8);
connection.setDoOutput(true);
connection.setFixedLengthStreamingMode(body.length);
connection.setRequestProperty("Content-Type", "application/json");
try (OutputStream out = connection.getOutputStream()) { out.write(body); }
}
int status = connection.getResponseCode();
return new Response(status, readBody(connection, status));
} finally {
connection.disconnect();
}
}
private static String readBody(HttpURLConnection connection, int status) {
try {
InputStream in = status < 400 ? connection.getInputStream() : connection.getErrorStream();
if (in == null) return "";
try (InputStream stream = in) {
ByteArrayOutputStream buf = new ByteArrayOutputStream();
byte[] chunk = new byte[4096];
int n;
while ((n = stream.read(chunk)) != -1 && buf.size() < MAX_BODY_BYTES) buf.write(chunk, 0, n);
return buf.toString(StandardCharsets.UTF_8.name());
}
} catch (Exception e) {
// The status is what matters; an unreadable body is just empty.
return "";
}
}
}