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
@@ -20,12 +20,13 @@ import java.util.Collections;
import java.util.List;
/**
* Best-effort MongoDB sync through the web API. Never blocks local persistence.
* Best-effort sync to the car server. Never blocks local persistence.
*
* The installationId is a fixed value baked into BuildConfig — never a random UUID — so an
* APK wipe cannot orphan the cloud history under a new identity. The consumption UI reads
* back its own uploaded totals via {@link #fetchHistoryIfDue}, which means an APK reinstall
* picks up right where the old one left off instead of showing zeroes.
* Server URL, token and installation id come from {@link ServerConfig}, read on every use so
* a change in the Server tab applies at once. The installation id is kept across updates; a
* reinstall gets it back through the config file, so the history is not orphaned under a new
* identity. The consumption UI reads back its own uploaded totals via
* {@link #fetchHistoryIfDue}, so a reinstall picks up where the old one left off.
*/
final class ConsumptionCloudClient {
private static final String TAG = "NilsMG4.Mongo";
@@ -37,9 +38,7 @@ final class ConsumptionCloudClient {
private static final String CHUNK_CACHE = "chunk_cache"; // JSON array of pending chunks
private static final int MAX_CHARGES_PER_SYNC = 10;
private final String endpoint;
private final String token;
private final String installationId;
private final Context context;
private final SharedPreferences prefs;
private long lastConsumptionAttemptMs;
private long lastChargeAttemptMs;
@@ -65,14 +64,12 @@ final class ConsumptionCloudClient {
}
ConsumptionCloudClient(Context context) {
endpoint = BuildConfig.CONSUMPTION_API_URL.trim();
token = BuildConfig.CONSUMPTION_API_TOKEN.trim();
this.context = context.getApplicationContext();
prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
installationId = BuildConfig.CONSUMPTION_INSTALLATION_ID.trim();
if (endpoint.isEmpty() || token.isEmpty()) {
Log.w(TAG, "Consumption API not configured: endpoint=" + (endpoint.isEmpty() ? "empty" : "ok")
+ ", token=" + (token.isEmpty() ? "empty" : "ok"));
}
}
private ServerConfig config() {
return ServerConfig.get(context);
}
/**
@@ -87,7 +84,7 @@ final class ConsumptionCloudClient {
*/
void syncIfDue(ConsumptionTracker tracker, ChargeSessionTracker charges, long nowMs) {
long elapsed = android.os.SystemClock.elapsedRealtime();
if (endpoint.isEmpty() || token.isEmpty()) {
if (!config().isConfigured()) {
Log.i(TAG, "Sync skipped: API not configured");
return;
}
@@ -200,7 +197,7 @@ final class ConsumptionCloudClient {
private JSONObject consumptionBody(ConsumptionTracker.CloudSnapshot snapshot, long nowMs)
throws Exception {
JSONObject body = new JSONObject()
.put("installationId", installationId)
.put("installationId", config().installationId)
.put("recordedAt", nowMs)
.put("date", snapshot.date)
.put("day", totals(snapshot.day));
@@ -230,7 +227,7 @@ final class ConsumptionCloudClient {
Log.i(TAG, "Found " + pending.size() + " pending charge sessions");
int sent = 0;
for (JSONObject session : pending) {
JSONObject body = new JSONObject(session.toString()).put("installationId", installationId);
JSONObject body = new JSONObject(session.toString()).put("installationId", config().installationId);
String id = session.getString("sessionId");
Log.i(TAG, "Uploading charge session: " + id);
if (send("/v1/charging-sessions/" + id, "PUT", body)) {
@@ -250,7 +247,7 @@ final class ConsumptionCloudClient {
HttpURLConnection connection = null;
try {
byte[] bytes = body.toString().getBytes(StandardCharsets.UTF_8);
String url = trimSlash(endpoint) + path;
String url = trimSlash(config().url) + path;
Log.i(TAG, "Sending " + method + " request to " + url + " (" + bytes.length + " bytes)");
Log.i(TAG, "Body: " + body.toString());
connection = (HttpURLConnection) new URL(url).openConnection();
@@ -260,7 +257,7 @@ final class ConsumptionCloudClient {
connection.setDoOutput(true);
connection.setFixedLengthStreamingMode(bytes.length);
connection.setRequestProperty("Content-Type", "application/json");
connection.setRequestProperty("Authorization", "Bearer " + token);
connection.setRequestProperty("Authorization", "Bearer " + config().token);
try (OutputStream out = connection.getOutputStream()) { out.write(bytes); }
int status = connection.getResponseCode();
Log.i(TAG, "Response: HTTP " + status);
@@ -295,7 +292,7 @@ final class ConsumptionCloudClient {
* Returns the current cache immediately; the fresh copy lands on the next poll.
*/
synchronized List<DayRecord> fetchHistoryIfDue() {
if (endpoint.isEmpty() || token.isEmpty()) return cachedHistory;
if (!config().isConfigured()) return cachedHistory;
long elapsed = android.os.SystemClock.elapsedRealtime();
if (lastHistoryFetchMs != 0L && elapsed - lastHistoryFetchMs < HISTORY_FETCH_INTERVAL_MS) {
return cachedHistory;
@@ -329,7 +326,7 @@ final class ConsumptionCloudClient {
* status for the log.
*/
String flushNow(ConsumptionTracker tracker, long nowMs) {
if (endpoint.isEmpty() || token.isEmpty()) return "API not configured";
if (!config().isConfigured()) return "API not configured";
int cached = drainChunkCache();
ConsumptionTracker.CloudSnapshot chunk = tracker.takeChunkDelta();
int total = cached;
@@ -357,12 +354,12 @@ final class ConsumptionCloudClient {
private List<DayRecord> fetchHistory() throws Exception {
HttpURLConnection connection = null;
try {
String url = trimSlash(endpoint) + "/v1/consumption/history/" + installationId;
String url = trimSlash(config().url) + "/v1/consumption/history/" + config().installationId;
connection = (HttpURLConnection) new URL(url).openConnection();
connection.setRequestMethod("GET");
connection.setConnectTimeout(TIMEOUT_MS);
connection.setReadTimeout(TIMEOUT_MS);
connection.setRequestProperty("Authorization", "Bearer " + token);
connection.setRequestProperty("Authorization", "Bearer " + config().token);
int status = connection.getResponseCode();
Log.i(TAG, "History GET response: HTTP " + status);
if (status < 200 || status >= 300) {