This commit is contained in:
TCFDONMEZ
2026-10-09 17:31:08 +03:00
parent 64811a1508
commit ea8032a813
46 changed files with 699 additions and 2230 deletions
@@ -4,27 +4,37 @@ 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;
import java.util.UUID;
/** Best-effort MongoDB sync through the web API. Never blocks local persistence. */
/**
* Best-effort MongoDB sync through the web API. 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.
*/
final class ConsumptionCloudClient {
private static final String TAG = "FatihsMG4.Mongo";
private static final long CONSUMPTION_SYNC_INTERVAL_MS = 30 * 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 INSTALLATION_ID = "installation_id";
private static final String LAST_CONSUMPTION_DATE = "last_consumption_date";
private static final int MAX_DAYS_PER_SYNC = 10;
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;
@@ -35,57 +45,156 @@ final class ConsumptionCloudClient {
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) {
endpoint = BuildConfig.CONSUMPTION_API_URL.trim();
token = BuildConfig.CONSUMPTION_API_TOKEN.trim();
prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
String existing = prefs.getString(INSTALLATION_ID, "");
if (existing == null || existing.isEmpty()) {
existing = UUID.randomUUID().toString();
prefs.edit().putString(INSTALLATION_ID, existing).commit();
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"));
}
installationId = existing;
}
/**
* 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 "Kayıtlar" 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 (endpoint.isEmpty() || token.isEmpty()) return;
if (endpoint.isEmpty() || token.isEmpty()) {
Log.d(TAG, "Sync skipped: API not configured");
return;
}
syncPendingChargesIfDue(charges, elapsed);
if (lastConsumptionAttemptMs != 0L
&& elapsed - lastConsumptionAttemptMs < CONSUMPTION_SYNC_INTERVAL_MS) return;
// Drain any cached chunks first — a backlog from an offline period has priority.
int cached = drainChunkCache();
if (cached > 0) {
logEvent(true, 200, "Cache boşaltıldı: " + cached + " chunk gönderildi");
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.d(TAG, "Sync due but no new consumption since last chunk");
return; // no driving since the last chunk
}
Log.d(TAG, "New chunk for " + chunk.date + ": +" + String.format("%.1f", chunk.day.km)
+ "km, +" + String.format("%.2f", chunk.day.kwh) + "kWh");
try {
syncDailyConsumption(tracker, nowMs);
JSONObject body = consumptionBody(chunk, nowMs);
if (send("/v1/consumption/daily", "PUT", body)) {
logEvent(true, 200, "Chunk " + chunk.date + " gönderildi (+"
+ 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 gönderilemedi — cache'de bekliyor (" + pending + " chunk)");
}
} catch (Exception e) {
Log.w(TAG, "Consumption sync unavailable");
// 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, "Çevrimdışı — cache'de bekliyor (" + pending + " chunk): " + e.getMessage());
Log.w(TAG, "Chunk sync failed, cached: " + e.getMessage());
}
}
/** Drains the backlog a few days per cycle; one a cycle took an hour per fortnight. */
private void syncDailyConsumption(ConsumptionTracker tracker, long nowMs) throws Exception {
String saved = prefs.getString(LAST_CONSUMPTION_DATE, "");
LocalDate lastUploaded = null;
try { if (saved != null && !saved.isEmpty()) lastUploaded = LocalDate.parse(saved); }
catch (RuntimeException ignored) { }
for (int sent = 0; sent < MAX_DAYS_PER_SYNC; sent++) {
LocalDate target = tracker.nextStoredDayAfter(lastUploaded);
if (target == null) break;
ConsumptionTracker.CloudSnapshot snapshot = tracker.cloudSnapshot(target);
JSONObject body = consumptionBody(snapshot, nowMs);
if (!send("/v1/consumption/daily", "PUT", body)) return;
prefs.edit().putString(LAST_CONSUMPTION_DATE, target.toString()).commit();
lastUploaded = target;
/**
* Sends every cached chunk; removes each one that succeeds. Returns how many were sent.
* A chunk that fails stops the drain — the rest stay in order for the next cycle.
*/
private int drainChunkCache() {
JSONArray cache = loadChunkCache();
if (cache.length() == 0) return 0;
JSONArray remaining = new JSONArray();
int sent = 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.d(TAG, "Cached chunk for " + date + " sent");
} else {
// Server rejected — keep this and everything after it.
for (int j = i; j < cache.length(); j++) remaining.put(cache.get(j));
break;
}
} catch (Exception e) {
// Network error — keep this and everything after it, stop draining.
for (int j = i; j < cache.length(); j++) {
try { remaining.put(cache.get(j)); } catch (Exception ignored) { }
}
Log.w(TAG, "Cache drain stopped: " + e.getMessage());
break;
}
}
// Today's cumulative counters overwrite the same installation/date document every
// 30 minutes. Sending totals rather than increments makes retries idempotent.
ConsumptionTracker.CloudSnapshot today = tracker.cloudSnapshot(LocalDate.now());
JSONObject current = consumptionBody(today, nowMs);
send("/v1/consumption/daily", "PUT", current);
saveChunkCache(remaining);
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 "Kayıtlar" 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)
@@ -112,18 +221,28 @@ final class ConsumptionCloudClient {
try {
syncPendingCharges(charges, pending);
} catch (Exception e) {
Log.w(TAG, "Charging-session sync unavailable");
logEvent(false, 0, "Şarj session sync hatası: " + e.getMessage());
Log.w(TAG, "Charging-session sync unavailable: " + e.getMessage());
}
}
private void syncPendingCharges(ChargeSessionTracker charges, List<JSONObject> pending)
throws Exception {
Log.d(TAG, "Found " + pending.size() + " pending charge sessions");
int sent = 0;
for (JSONObject session : pending) {
JSONObject body = new JSONObject(session.toString()).put("installationId", installationId);
String id = session.getString("sessionId");
if (!send("/v1/charging-sessions/" + id, "PUT", body)) return;
charges.markUploaded(id);
Log.d(TAG, "Uploading charge session: " + id);
if (send("/v1/charging-sessions/" + id, "PUT", body)) {
charges.markUploaded(id);
logEvent(true, 200, "Şarj session gönderildi: " + id);
Log.d(TAG, "Successfully uploaded charge session: " + id);
} else {
logEvent(false, 0, "Şarj session gönderilemedi: " + id);
Log.w(TAG, "Failed to upload charge session: " + id);
return;
}
if (++sent >= MAX_CHARGES_PER_SYNC) return;
}
}
@@ -132,7 +251,9 @@ final class ConsumptionCloudClient {
HttpURLConnection connection = null;
try {
byte[] bytes = body.toString().getBytes(StandardCharsets.UTF_8);
connection = (HttpURLConnection) new URL(trimSlash(endpoint) + path).openConnection();
String url = trimSlash(endpoint) + path;
Log.d(TAG, "Sending " + method + " request to " + url + " (" + bytes.length + " bytes)");
connection = (HttpURLConnection) new URL(url).openConnection();
connection.setRequestMethod(method);
connection.setConnectTimeout(TIMEOUT_MS);
connection.setReadTimeout(TIMEOUT_MS);
@@ -142,13 +263,18 @@ final class ConsumptionCloudClient {
connection.setRequestProperty("Authorization", "Bearer " + token);
try (OutputStream out = connection.getOutputStream()) { out.write(bytes); }
int status = connection.getResponseCode();
InputStream response = status < 400 ? connection.getInputStream() : connection.getErrorStream();
if (response != null) try (InputStream ignored = response) { }
Log.d(TAG, "Response: HTTP " + status);
if (status < 200 || status >= 300) {
Log.w(TAG, "Sync rejected with HTTP " + status);
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();
}
@@ -159,6 +285,107 @@ final class ConsumptionCloudClient {
.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. On a fetch failure the previous cache is kept — a transient outage
* must not blank the display.
*/
List<DayRecord> fetchHistoryIfDue() {
if (endpoint.isEmpty() || token.isEmpty()) return cachedHistory;
long elapsed = android.os.SystemClock.elapsedRealtime();
if (lastHistoryFetchMs != 0L && elapsed - lastHistoryFetchMs < HISTORY_FETCH_INTERVAL_MS) {
return cachedHistory;
}
lastHistoryFetchMs = elapsed;
try {
List<DayRecord> fresh = fetchHistory();
if (!fresh.isEmpty()) cachedHistory = fresh;
Log.d(TAG, "Fetched " + fresh.size() + " history records from cloud");
} 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 hatası: " + msg);
}
return cachedHistory;
}
/** Forces a refresh on the next {@link #fetchHistoryIfDue} — call after an upload. */
void invalidateHistory() {
lastHistoryFetchMs = 0L;
}
private List<DayRecord> fetchHistory() throws Exception {
HttpURLConnection connection = null;
try {
String url = trimSlash(endpoint) + "/v1/consumption/history/" + installationId;
connection = (HttpURLConnection) new URL(url).openConnection();
connection.setRequestMethod("GET");
connection.setConnectTimeout(TIMEOUT_MS);
connection.setReadTimeout(TIMEOUT_MS);
connection.setRequestProperty("Authorization", "Bearer " + token);
int status = connection.getResponseCode();
if (status < 200 || status >= 300) {
Log.w(TAG, "History fetch rejected with HTTP " + status);
return cachedHistory;
}
String body = readBody(connection);
JSONObject json = new JSONObject(body);
JSONArray records = json.optJSONArray("records");
if (records == null) return Collections.emptyList();
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"));
ConsumptionTracker.Totals life = parseTotals(r.getJSONObject("lifetime"));
Float soh = r.has("sohPercent") && !r.isNull("sohPercent")
? (float) r.getDouble("sohPercent") : null;
result.add(new DayRecord(date, day, life, 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--;