App 2.3.0: Server tab, live telemetry to the car server

- New Server tab: server URL, ingest token and installation id, with
  Save, Test connection (health, then a read-only token check) and the
  config-file import that upstream removed (app folder / USB stick first,
  document picker as fallback). Settings are kept encrypted and read on
  every use, so the service picks up a change without a restart. Each
  installation generates its own id; the inherited upstream UUID and
  the baked-in CONSUMPTION_API_* build fields are gone.
- Live telemetry restored from before ea8032a (TelemetryPayload,
  UploadCadence, UploadSettings, GPS) and sent in batches to
  POST /v1/telemetry instead of ABRP; the server forwards to ABRP. Adds
  ext_temp, voltage and current. Unsent samples queue in memory (about
  8 h) while offline.
- Upload tab: the live-telemetry switch and cadence controls work again;
  the dead service and autostart switches are removed.
- Manifest: location permissions and foreground service types.
- Unit tests compile again: 114 (stable) / 136 (unstable) pass. Tests
  for the deleted AbrpApi and HeadUnitWeather removed; new tests for
  ServerConfig and TelemetryQueue. Lint passes.

Not yet tried on the car.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 14:23:04 +02:00
co-authored by Claude Opus 5.5
parent c253876ed2
commit 6145736c97
27 changed files with 1590 additions and 287 deletions
@@ -0,0 +1,100 @@
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);
/** 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();
}
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: this installation gets its own identity, kept from now on.
id = UUID.randomUUID().toString();
prefs.edit().putString(KEY_INSTALLATION_ID, id).apply();
}
current = new ServerConfig(prefs.getString(KEY_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(" ");
}
static boolean isValidInstallationId(String id) {
return id != null && UUID_PATTERN.matcher(id.trim()).matches();
}
}