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
133 lines
5.7 KiB
Java
133 lines
5.7 KiB
Java
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();
|
||
}
|
||
}
|