- 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>
435 lines
19 KiB
Java
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);
|
|
}
|
|
}
|