diff --git a/android/app/src/main/kotlin/com/exptech/dpip/BgLocationStore.kt b/android/app/src/main/kotlin/com/exptech/dpip/BgLocationStore.kt index df11b5c2f..ad4b673a3 100644 --- a/android/app/src/main/kotlin/com/exptech/dpip/BgLocationStore.kt +++ b/android/app/src/main/kotlin/com/exptech/dpip/BgLocationStore.kt @@ -32,6 +32,12 @@ object BgLocationStore { const val KEY_LAST_REPORT_OK = "last_report_ok" const val KEY_LAST_REPORT_CODE = "last_report_code" + /** When a report was last *sent*, which is what the throttle measures. */ + const val KEY_LAST_SENT_AT = "last_sent_at" + + /** How many triggers the throttle has dropped, for the diagnostics page. */ + const val KEY_THROTTLED_N = "throttled_n" + fun prefs(context: Context): SharedPreferences = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) @@ -83,6 +89,29 @@ object BgLocationStore { */ fun report(context: Context, lat: Double, lng: Double) { val prefs = prefs(context) + + // At most one report a minute, across every trigger. + // + // Four callers fire this independently — the geofence, the alarm + // fallback, and two paths from Dart — and nothing coordinated them, so + // a crossing that arrived alongside an alarm sent two reports seconds + // apart. The server answers 429, the location never lands, and the app + // looks like it stopped reporting: on the device this was found on, + // `last_report_code` was 429 with the geofence armed and a fix in hand. + // + // Measured from KEY_LAST_SENT_AT, not KEY_LAST_REPORT_AT: the latter is + // stamped on every outcome including this one, so gating on it would + // let a burst of triggers push the window ahead of itself and starve + // reporting entirely. + val now = System.currentTimeMillis() + val sent = prefs.getLong(KEY_LAST_SENT_AT, 0L) + if (sent > 0L && now - sent < MIN_REPORT_INTERVAL_MS) { + prefs.edit() + .putInt(KEY_THROTTLED_N, prefs.getInt(KEY_THROTTLED_N, 0) + 1) + .apply() + stamp(prefs, THROTTLED) + return + } // Stamped even on the way out. These two returns sat *above* the stamp, // so a run that never had a token was indistinguishable from one that // never happened — and "never happened" is what the developer page @@ -106,14 +135,28 @@ object BgLocationStore { code = responseCode // fire the request disconnect() } + prefs.edit().putLong(KEY_LAST_SENT_AT, now).apply() } catch (e: Exception) { // Best-effort; the next trigger retries. The outcome is still // recorded below — "tried at T and failed" is the diagnostic that // separates "never fired" from "fires but cannot reach the server". + // + // The exception's name is kept, because -1 on its own is not a + // diagnosis. On a device where the alarm path failed three times + // running while the app's own SSE connections were live, -1 was + // everything the app could say, and it fits UnknownHostException, + // a timeout, a TLS failure and a Doze network block equally well. + noteWake(context, "report failed: ${e.javaClass.simpleName}") } stamp(prefs, code) } + /** Dropped by the throttle — a report went out less than a minute ago. */ + const val THROTTLED = -4 + + /** The floor between two reports, whichever trigger asks. */ + const val MIN_REPORT_INTERVAL_MS = 60_000L + /** No push token stored — the report has nowhere to go. */ const val NO_TOKEN = -2 diff --git a/android/app/src/main/res/raw/keep.xml b/android/app/src/main/res/raw/keep.xml new file mode 100644 index 000000000..ca2f2b6ed --- /dev/null +++ b/android/app/src/main/res/raw/keep.xml @@ -0,0 +1,18 @@ + + + diff --git a/lib/core/diagnostics/diagnostics_report.dart b/lib/core/diagnostics/diagnostics_report.dart index ddae73ec7..04aee7529 100644 --- a/lib/core/diagnostics/diagnostics_report.dart +++ b/lib/core/diagnostics/diagnostics_report.dart @@ -121,6 +121,10 @@ String _lastReport(Map d) { String _outcome(bool ok, Object? code) => switch (code) { -2 => 'no push token', -3 => 'no app version', + // Not a failure: a report went out inside the last minute and this trigger + // was dropped on purpose. Without a name it reads as an error code, and the + // page exists so nobody has to guess what a number means. + -4 => 'throttled (a report went out under a minute ago)', -1 => 'could not reach the server', final int c when c > 0 => ok ? 'ok ($c)' : 'failed ($c)', _ => ok ? 'ok' : 'failed',