Files
mg4/app/src/main/java/com/evsuite/abrp/ServerConfig.java
T
nilsandClaude Opus 5.5 a5db5604d3 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
2026-10-18 00:36:19 +02:00

133 lines
5.7 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.evsuite.abrp;
import android.content.Context;
import android.content.SharedPreferences;
import java.util.Locale;
import java.util.UUID;
import java.util.regex.Pattern;
/**
* Where the car's data goes: the server URL, its ingest token, and the installation id the
* data is stored under. Entered in the Server tab or imported from a config file, kept in
* {@link SecurePrefs}.
*
* Read through {@link #get}, which caches one immutable snapshot for the process and is
* replaced on {@link #save}: the service and the activity both see a change at once, without
* a restart.
*/
final class ServerConfig {
static final String KEY_URL = "server_url";
static final String KEY_TOKEN = "ingest_token";
static final String KEY_INSTALLATION_ID = "installation_id";
/** Same rule as the server: a version 1–5 UUID with the RFC 4122 variant. */
private static final Pattern UUID_PATTERN = Pattern.compile(
"^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
Pattern.CASE_INSENSITIVE);
/** Pre-filled so pairing needs no typing in the car; can still be changed. */
static final String DEFAULT_URL = "https://car.k8s.stinnesbeck.com";
/** The server refuses shorter ingest tokens outright. */
static final int MIN_TOKEN_LENGTH = 24;
final String url;
final String token;
final String installationId;
private static volatile ServerConfig current;
ServerConfig(String url, String token, String installationId) {
this.url = url;
this.token = token;
this.installationId = installationId;
}
boolean isConfigured() {
return !url.isEmpty() && !token.isEmpty();
}
// ANDROID_ID on purpose: it is hashed into the installation id, so a reinstall keeps its
// history (installationIdFor). It is never sent or logged as it is.
@android.annotation.SuppressLint("HardwareIds")
static ServerConfig get(Context context) {
ServerConfig config = current;
if (config != null) return config;
synchronized (ServerConfig.class) {
if (current != null) return current;
SharedPreferences prefs = SecurePrefs.get(context);
String id = prefs.getString(KEY_INSTALLATION_ID, "");
if (!isValidInstallationId(id)) {
// First start, or the first after a reinstall: derived from the head unit, so
// the history stays under the same id (see installationIdFor).
String androidId = null;
try {
androidId = android.provider.Settings.Secure.getString(
context.getApplicationContext().getContentResolver(),
android.provider.Settings.Secure.ANDROID_ID);
} catch (RuntimeException ignored) {
// no id from the platform: a random one below
}
id = installationIdFor(androidId);
if (id == null) id = UUID.randomUUID().toString();
prefs.edit().putString(KEY_INSTALLATION_ID, id).apply();
}
current = new ServerConfig(prefs.getString(KEY_URL, DEFAULT_URL), prefs.getString(KEY_TOKEN, ""), id);
return current;
}
}
/** Stores already-validated values; see {@link #validate}. */
static synchronized void save(Context context, String url, String token, String installationId) {
SecurePrefs.get(context).edit()
.putString(KEY_URL, url)
.putString(KEY_TOKEN, token)
.putString(KEY_INSTALLATION_ID, installationId)
.apply();
current = new ServerConfig(url, token, installationId);
}
// ---------- Validation (pure, unit-tested) ----------
/**
* The URL as it will be used: trimmed, without a trailing slash. Null when it is not an
* https URL with a host — the token travels in a header, so plain http is refused.
*/
static String normalizeUrl(String raw) {
if (raw == null) return null;
String url = raw.trim();
while (url.endsWith("/")) url = url.substring(0, url.length() - 1);
if (!url.toLowerCase(Locale.US).startsWith("https://")) return null;
String rest = url.substring("https://".length());
if (rest.isEmpty() || rest.startsWith("/") || rest.contains(" ") || rest.contains("?")
|| rest.contains("#") || rest.contains("@")) return null;
return url;
}
static boolean isValidToken(String token) {
return token != null && token.trim().length() >= MIN_TOKEN_LENGTH && !token.trim().contains(" ");
}
/**
* The installation id for a head unit, or null without a usable ANDROID_ID. From Android 8
* ANDROID_ID is fixed per device and signing key and survives an uninstall, so a
* reinstall of the same platform-signed build finds its history again without importing
* the id. Hashed into a name-based UUID: the server expects a UUID, and the raw
* ANDROID_ID never leaves the car. An id already stored (or imported) always wins over it.
*/
static String installationIdFor(String androidId) {
if (androidId == null) return null;
String trimmed = androidId.trim();
// The emulator-era constant that many devices once shared; not an identity.
if (trimmed.isEmpty() || trimmed.equalsIgnoreCase("9774d56d682e549c")) return null;
byte[] name = ("nilsmg4:" + trimmed.toLowerCase(Locale.US)).getBytes(java.nio.charset.StandardCharsets.UTF_8);
return UUID.nameUUIDFromBytes(name).toString();
}
static boolean isValidInstallationId(String id) {
return id != null && UUID_PATTERN.matcher(id.trim()).matches();
}
}