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',