Files
mg4/app/src/main/java/com/evsuite/abrp/ConsumptionCloudClient.java
T
nilsandClaude Opus 5.5 6145736c97 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>
2026-10-17 14:23:04 +02:00

435 lines
19 KiB
Java

package com.evsuite.abrp;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* Best-effort sync to the car server. Never blocks local persistence.
*
* 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";
private static final long CONSUMPTION_SYNC_INTERVAL_MS = 2 * 60_000L;
private static final long CHARGE_RETRY_INTERVAL_MS = 5 * 60_000L;
private static final long HISTORY_FETCH_INTERVAL_MS = 5 * 60_000L;
private static final int TIMEOUT_MS = 8_000;
private static final String PREFS = "consumption_cloud";
private static final String CHUNK_CACHE = "chunk_cache"; // JSON array of pending chunks
private static final int MAX_CHARGES_PER_SYNC = 10;
private final Context context;
private final SharedPreferences prefs;
private long lastConsumptionAttemptMs;
private long lastChargeAttemptMs;
private String lastAttemptedChargeId = "";
/** Cached history read back from the cloud, refreshed on a 5-minute throttle. */
private volatile List<DayRecord> cachedHistory = Collections.emptyList();
private volatile long lastHistoryFetchMs;
/** One day's uploaded totals, as read back from the cloud. */
static final class DayRecord {
final LocalDate date;
final ConsumptionTracker.Totals day;
final ConsumptionTracker.Totals lifetime;
final Float sohPercent;
DayRecord(LocalDate date, ConsumptionTracker.Totals day, ConsumptionTracker.Totals lifetime, Float sohPercent) {
this.date = date;
this.day = day;
this.lifetime = lifetime;
this.sohPercent = sohPercent;
}
}
ConsumptionCloudClient(Context context) {
this.context = context.getApplicationContext();
prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
}
private ServerConfig config() {
return ServerConfig.get(context);
}
/**
* Every 30 minutes, takes a delta chunk from the tracker and sends it. Chunks that fail
* to send (offline, server error) accumulate in a local cache and are retried on the
* next cycle — so a drive through a dead-signal area still reaches the cloud once the
* head unit is back on the hotspot. Every attempt is logged to the in-app "Logs" tab.
*
* @param nowMs wall clock, recorded with the payload — the throttle below runs on a
* monotonic clock instead, so a backwards NTP correction cannot stall
* syncing for however long the jump was.
*/
void syncIfDue(ConsumptionTracker tracker, ChargeSessionTracker charges, long nowMs) {
long elapsed = android.os.SystemClock.elapsedRealtime();
if (!config().isConfigured()) {
Log.i(TAG, "Sync skipped: API not configured");
return;
}
syncPendingChargesIfDue(charges, elapsed);
// Drain any cached chunks first — a backlog from an offline period has priority.
int cached = drainChunkCache();
if (cached > 0) {
logEvent(true, 200, "Cache drained: " + cached + " chunk(s) sent");
invalidateHistory();
}
boolean due = lastConsumptionAttemptMs == 0L
|| elapsed - lastConsumptionAttemptMs >= CONSUMPTION_SYNC_INTERVAL_MS;
if (!due) return;
lastConsumptionAttemptMs = elapsed;
ConsumptionTracker.CloudSnapshot chunk = tracker.takeChunkDelta();
if (chunk == null) {
Log.i(TAG, "Sync due but no new consumption since last chunk");
return; // no driving since the last chunk
}
Log.i(TAG, "New chunk for " + chunk.date + ": +" + String.format("%.1f", chunk.day.km)
+ "km, +" + String.format("%.2f", chunk.day.kwh) + "kWh");
try {
JSONObject body = consumptionBody(chunk, nowMs);
if (send("/v1/consumption/daily", "PUT", body)) {
logEvent(true, 200, "Chunk " + chunk.date + " sent (+"
+ String.format("%.1f", chunk.day.km) + "km, +"
+ String.format("%.2f", chunk.day.kwh) + "kWh)");
invalidateHistory();
} else {
cacheChunk(body);
int pending = pendingChunkCount();
logEvent(false, 0, "Chunk not sent — waiting in cache (" + pending + " chunk(s))");
}
} catch (Exception e) {
// Network failure: cache the chunk for the next cycle.
try {
JSONObject body = consumptionBody(chunk, nowMs);
cacheChunk(body);
} catch (Exception ignored) { }
int pending = pendingChunkCount();
logEvent(false, 0, "Offline — waiting in cache (" + pending + " chunk(s)): " + e.getMessage());
Log.w(TAG, "Chunk sync failed, cached: " + e.getMessage());
}
}
/**
* Sends every cached chunk; removes each one that succeeds. A chunk that fails stays in
* the cache for the next attempt, but does NOT stop the drain — the next chunk is tried
* too, because a 5xx on one day's document is not a reason to hold back the rest.
*/
private int drainChunkCache() {
JSONArray cache = loadChunkCache();
if (cache.length() == 0) return 0;
JSONArray remaining = new JSONArray();
int sent = 0;
int failed = 0;
for (int i = 0; i < cache.length(); i++) {
try {
JSONObject body = cache.getJSONObject(i);
String date = body.optString("date", "?");
if (send("/v1/consumption/daily", "PUT", body)) {
sent++;
Log.i(TAG, "Cached chunk for " + date + " sent");
} else {
// Server rejected — keep it, try the next one too.
remaining.put(body);
failed++;
}
} catch (Exception e) {
// Network error — keep it, try the next one too.
try { remaining.put(cache.get(i)); } catch (Exception ignored) { }
failed++;
}
}
saveChunkCache(remaining);
if (failed > 0) Log.w(TAG, "Cache drain: " + sent + " sent, " + failed + " still pending");
return sent;
}
private JSONArray loadChunkCache() {
String raw = prefs.getString(CHUNK_CACHE, "");
if (raw == null || raw.isEmpty()) return new JSONArray();
try { return new JSONArray(raw); } catch (Exception e) { return new JSONArray(); }
}
private void saveChunkCache(JSONArray cache) {
prefs.edit().putString(CHUNK_CACHE, cache.toString()).commit();
}
private void cacheChunk(JSONObject body) {
JSONArray cache = loadChunkCache();
cache.put(body);
saveChunkCache(cache);
}
private int pendingChunkCount() {
return loadChunkCache().length();
}
/** Records a cloud-sync attempt to the in-app log (the "Logs" tab). */
private void logEvent(boolean success, int httpStatus, String detail) {
UploadLog log = AbrpUploadService.log();
if (log == null) return;
log.record(new UploadLog.Entry(System.currentTimeMillis(), httpStatus, success, detail));
}
private JSONObject consumptionBody(ConsumptionTracker.CloudSnapshot snapshot, long nowMs)
throws Exception {
JSONObject body = new JSONObject()
.put("installationId", config().installationId)
.put("recordedAt", nowMs)
.put("date", snapshot.date)
.put("day", totals(snapshot.day));
if (snapshot.sohPercent != null) body.put("sohPercent", snapshot.sohPercent);
return body;
}
private void syncPendingChargesIfDue(ChargeSessionTracker charges, long elapsed) {
List<JSONObject> pending = charges.pendingUploads();
if (pending.isEmpty()) return;
String firstId = pending.get(0).optString("sessionId", "");
boolean newlyCompleted = !firstId.equals(lastAttemptedChargeId);
if (!newlyCompleted && lastChargeAttemptMs != 0L
&& elapsed - lastChargeAttemptMs < CHARGE_RETRY_INTERVAL_MS) return;
lastAttemptedChargeId = firstId;
lastChargeAttemptMs = elapsed;
try {
syncPendingCharges(charges, pending);
} catch (Exception e) {
logEvent(false, 0, "Charging session sync error: " + e.getMessage());
Log.w(TAG, "Charging-session sync unavailable: " + e.getMessage());
}
}
private void syncPendingCharges(ChargeSessionTracker charges, List<JSONObject> pending)
throws Exception {
Log.i(TAG, "Found " + pending.size() + " pending charge sessions");
int sent = 0;
for (JSONObject session : pending) {
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)) {
charges.markUploaded(id);
logEvent(true, 200, "Charging session sent: " + id);
Log.i(TAG, "Successfully uploaded charge session: " + id);
} else {
logEvent(false, 0, "Charging session not sent: " + id);
Log.w(TAG, "Failed to upload charge session: " + id);
return;
}
if (++sent >= MAX_CHARGES_PER_SYNC) return;
}
}
private boolean send(String path, String method, JSONObject body) throws Exception {
HttpURLConnection connection = null;
try {
byte[] bytes = body.toString().getBytes(StandardCharsets.UTF_8);
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();
connection.setRequestMethod(method);
connection.setConnectTimeout(TIMEOUT_MS);
connection.setReadTimeout(TIMEOUT_MS);
connection.setDoOutput(true);
connection.setFixedLengthStreamingMode(bytes.length);
connection.setRequestProperty("Content-Type", "application/json");
connection.setRequestProperty("Authorization", "Bearer " + config().token);
try (OutputStream out = connection.getOutputStream()) { out.write(bytes); }
int status = connection.getResponseCode();
Log.i(TAG, "Response: HTTP " + status);
if (status < 200 || status >= 300) {
String errBody = readErrorBody(connection);
Log.w(TAG, "Sync rejected with HTTP " + status + (errBody.isEmpty() ? "" : ": " + errBody));
return false;
}
InputStream response = connection.getInputStream();
if (response != null) try (InputStream ignored = response) { }
return true;
} catch (Exception e) {
Log.w(TAG, "Network error: " + e.getMessage());
throw e;
} finally {
if (connection != null) connection.disconnect();
}
}
private static JSONObject totals(ConsumptionTracker.Totals totals) throws Exception {
return new JSONObject().put("km", totals.km).put("kwh", totals.kwh)
.put("hours", totals.hours).put("socDrop", totals.soc);
}
// ---------- Read-back for the consumption UI ----------
/**
* Returns the cached cloud history, fetching a fresh copy at most every 5 minutes.
* Called from the UI poll loop (every 2 s), so the throttle is what keeps the head unit
* off the network. The fetch itself runs on a background thread — this method is called
* from the UI thread and a network call there throws NetworkOnMainThreadException.
* Returns the current cache immediately; the fresh copy lands on the next poll.
*/
synchronized List<DayRecord> fetchHistoryIfDue() {
if (!config().isConfigured()) return cachedHistory;
long elapsed = android.os.SystemClock.elapsedRealtime();
if (lastHistoryFetchMs != 0L && elapsed - lastHistoryFetchMs < HISTORY_FETCH_INTERVAL_MS) {
return cachedHistory;
}
lastHistoryFetchMs = elapsed;
new Thread(this::fetchHistoryAsync, "history-fetch").start();
return cachedHistory;
}
private void fetchHistoryAsync() {
try {
List<DayRecord> fresh = fetchHistory();
if (!fresh.isEmpty()) cachedHistory = fresh;
Log.i(TAG, "Fetched " + fresh.size() + " history records from cloud");
logEvent(true, 200, "History fetched: " + fresh.size() + " day(s)");
} catch (Exception e) {
String msg = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
Log.w(TAG, "History fetch unavailable: " + msg);
logEvent(false, 0, "History fetch error: " + msg);
}
}
/** Forces a refresh on the next {@link #fetchHistoryIfDue} — call after an upload. */
void invalidateHistory() {
lastHistoryFetchMs = 0L;
}
/**
* Immediately attempts to send every cached chunk and take a fresh one, ignoring the
* 2-minute throttle. Called from the UI "Upload now" button. Returns a one-line
* status for the log.
*/
String flushNow(ConsumptionTracker tracker, long nowMs) {
if (!config().isConfigured()) return "API not configured";
int cached = drainChunkCache();
ConsumptionTracker.CloudSnapshot chunk = tracker.takeChunkDelta();
int total = cached;
if (chunk != null) {
try {
JSONObject body = consumptionBody(chunk, nowMs);
if (send("/v1/consumption/daily", "PUT", body)) {
total++;
invalidateHistory();
} else {
cacheChunk(body);
}
} catch (Exception e) {
try { cacheChunk(consumptionBody(chunk, nowMs)); } catch (Exception ignored) { }
}
}
int pending = pendingChunkCount();
String msg = total > 0
? total + " chunk(s) uploaded" + (pending > 0 ? ", " + pending + " still pending" : "")
: (pending > 0 ? pending + " chunk(s) could not be uploaded (internet?)" : "No new data to upload");
logEvent(total > 0, total > 0 ? 200 : 0, "Manual sync: " + msg);
return msg;
}
private List<DayRecord> fetchHistory() throws Exception {
HttpURLConnection connection = null;
try {
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 " + config().token);
int status = connection.getResponseCode();
Log.i(TAG, "History GET response: HTTP " + status);
if (status < 200 || status >= 300) {
String err = readErrorBody(connection);
Log.w(TAG, "History fetch rejected with HTTP " + status + (err.isEmpty() ? "" : ": " + err));
logEvent(false, status, "History GET HTTP " + status + (err.isEmpty() ? "" : " — " + err));
return cachedHistory;
}
String body = readBody(connection);
JSONObject json = new JSONObject(body);
JSONArray records = json.optJSONArray("records");
if (records == null) return Collections.emptyList();
// Lifetime is derived by the backend (sum of all days), sent once at the response
// level. Each DayRecord carries it so the UI's LIFETIME period can read it.
ConsumptionTracker.Totals lifetime = json.has("lifetime") && !json.isNull("lifetime")
? parseTotals(json.getJSONObject("lifetime")) : null;
List<DayRecord> result = new ArrayList<>(records.length());
for (int i = 0; i < records.length(); i++) {
JSONObject r = records.getJSONObject(i);
LocalDate date = LocalDate.parse(r.getString("date"));
ConsumptionTracker.Totals day = parseTotals(r.getJSONObject("day"));
Float soh = r.has("sohPercent") && !r.isNull("sohPercent")
? (float) r.getDouble("sohPercent") : null;
result.add(new DayRecord(date, day, lifetime, soh));
}
return result;
} finally {
if (connection != null) connection.disconnect();
}
}
private static ConsumptionTracker.Totals parseTotals(JSONObject o) {
return new ConsumptionTracker.Totals(
o.optDouble("km", 0d), o.optDouble("kwh", 0d),
o.optDouble("hours", 0d), o.optDouble("socDrop", 0d));
}
private static String readBody(HttpURLConnection connection) throws Exception {
InputStream stream = connection.getInputStream();
try (BufferedReader br = new BufferedReader(
new InputStreamReader(stream, StandardCharsets.UTF_8))) {
StringBuilder sb = new StringBuilder();
String line;
while ((line = br.readLine()) != null) sb.append(line);
return sb.toString();
}
}
/** Reads the error response body (truncated) for logging — never throws. */
private static String readErrorBody(HttpURLConnection connection) {
try {
InputStream stream = connection.getErrorStream();
if (stream == null) return "";
try (BufferedReader br = new BufferedReader(
new InputStreamReader(stream, StandardCharsets.UTF_8))) {
StringBuilder sb = new StringBuilder();
String line;
while ((line = br.readLine()) != null) sb.append(line);
String body = sb.toString();
return body.length() > 300 ? body.substring(0, 300) + "…" : body;
}
} catch (Exception e) {
return "";
}
}
private static String trimSlash(String value) {
int end = value.length();
while (end > 0 && value.charAt(end - 1) == '/') end--;
return value.substring(0, end);
}
}