simplify: backend derives lifetime from day totals, app stops sending it

- backend daily PUT: no longer $set lifetime (app's local counter is unreliable after APK wipe)
- backend history GET: derives lifetime as sum of all days' totals, returns it at response level
- app: stops sending lifetime in chunks; reads it from history response
- app: refresh button on consumption pane + 'Şimdi yükle' on log pane (manual cache flush)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
TCFDONMEZ
2026-10-09 21:41:15 +03:00
co-authored by Claude
parent 8b25799d55
commit 06265bacc9
8 changed files with 144 additions and 27 deletions
@@ -29,7 +29,7 @@ import java.util.List;
*/
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 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;
@@ -137,14 +137,16 @@ final class ConsumptionCloudClient {
}
/**
* 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.
* 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);
@@ -153,20 +155,18 @@ final class ConsumptionCloudClient {
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;
// Server rejected — keep it, try the next one too.
remaining.put(body);
failed++;
}
} 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;
// 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;
}
@@ -203,8 +203,7 @@ final class ConsumptionCloudClient {
.put("installationId", installationId)
.put("recordedAt", nowMs)
.put("date", snapshot.date)
.put("day", totals(snapshot.day))
.put("lifetime", totals(snapshot.lifetime));
.put("day", totals(snapshot.day));
if (snapshot.sohPercent != null) body.put("sohPercent", snapshot.sohPercent);
return body;
}
@@ -290,26 +289,32 @@ final class ConsumptionCloudClient {
/**
* 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.
* 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.
*/
List<DayRecord> fetchHistoryIfDue() {
synchronized 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;
new Thread(this::fetchHistoryAsync, "history-fetch").start();
return cachedHistory;
}
private void fetchHistoryAsync() {
try {
List<DayRecord> fresh = fetchHistory();
if (!fresh.isEmpty()) cachedHistory = fresh;
Log.d(TAG, "Fetched " + fresh.size() + " history records from cloud");
logEvent(true, 200, "History çekildi: " + fresh.size() + " gün");
} 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. */
@@ -317,6 +322,37 @@ final class ConsumptionCloudClient {
lastHistoryFetchMs = 0L;
}
/**
* Immediately attempts to send every cached chunk and take a fresh one, ignoring the
* 2-minute throttle. Called from the UI "Şimdi yükle" button. Returns a one-line
* status for the log.
*/
String flushNow(ConsumptionTracker tracker, long nowMs) {
if (endpoint.isEmpty() || token.isEmpty()) return "API yapılandırılmamış";
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 yüklendi" + (pending > 0 ? ", " + pending + " hâlâ bekliyor" : "")
: (pending > 0 ? pending + " chunk yüklenemedi (internet?)" : "Yüklenecek yeni veri yok");
logEvent(total > 0, total > 0 ? 200 : 0, "Manuel sync: " + msg);
return msg;
}
private List<DayRecord> fetchHistory() throws Exception {
HttpURLConnection connection = null;
try {
@@ -327,23 +363,29 @@ final class ConsumptionCloudClient {
connection.setReadTimeout(TIMEOUT_MS);
connection.setRequestProperty("Authorization", "Bearer " + token);
int status = connection.getResponseCode();
Log.d(TAG, "History GET response: HTTP " + status);
if (status < 200 || status >= 300) {
Log.w(TAG, "History fetch rejected with HTTP " + status);
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"));
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));
result.add(new DayRecord(date, day, lifetime, soh));
}
return result;
} finally {