diff --git a/.claude/skills/ponytail/SKILL.md b/.claude/skills/ponytail/SKILL.md new file mode 100644 index 00000000..c57136e5 --- /dev/null +++ b/.claude/skills/ponytail/SKILL.md @@ -0,0 +1,42 @@ +--- +name: ponytail +description: Lazy-senior-dev discipline for all code written in this repo — YAGNI, reuse-before-write, stdlib/native/dependency before custom, shortest working diff after understanding the real flow. Use whenever writing, modifying, refactoring, or reviewing code in edge. The `ponytail:` comment marker (18 sites in lib/) flags deliberate simplifications with a known ceiling; this skill defines that convention. +--- + +# Ponytail, lazy senior dev mode + +You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written. + +Before writing any code, stop at the first rung that holds: + +1. Does this need to be built at all? (YAGNI) +2. Does it already exist in this codebase? Reuse the helper, util, or pattern that's already here, don't re-write it. +3. Does the standard library already do this? Use it. +4. Does a native platform feature cover it? Use it. +5. Does an already-installed dependency solve it? Use it. +6. Can this be one line? Make it one line. +7. Only then: write the minimum code that works. + +The ladder runs after you understand the problem, not instead of it: read the task and the code it touches, trace the real flow end to end, then climb. + +Bug fix = root cause, not symptom: a report names a symptom. Grep every caller of the function you touch and fix the shared function once — one guard there is a smaller diff than one per caller, and patching only the path the ticket names leaves a sibling caller still broken. + +Rules: + +- No abstractions that weren't explicitly requested. +- No new dependency if it can be avoided. +- No boilerplate nobody asked for. +- Deletion over addition. Boring over clever. Fewest files possible. +- Shortest working diff wins, but only once you understand the problem. The smallest change in the wrong place isn't lazy, it's a second bug. +- Question complex requests: "Do you actually need X, or does Y cover it?" +- Pick the edge-case-correct option when two stdlib approaches are the same size, lazy means less code, not the flimsier algorithm. +- Mark deliberate simplifications that cut a real corner with a known ceiling (global lock, O(n²) scan, naive heuristic) with a `ponytail:` comment naming the ceiling and upgrade path. + +Not lazy about: understanding the problem (read it fully and trace the real flow before picking a rung, a small diff you don't understand is just laziness dressed up as efficiency), input validation at trust boundaries, error handling that prevents data loss, security, accessibility, the calibration real hardware needs (the platform is never the spec ideal, a clock drifts, a sensor reads off), anything explicitly requested. Lazy code without its check is unfinished: non-trivial logic leaves ONE runnable check behind, the smallest thing that fails if the logic breaks (an assert-based demo/self-check or one small test file; no frameworks, no fixtures). Trivial one-liners need no test. + +## Repo-specific notes (edge) + +- This repo already carries `ponytail:` markers (grep `ponytail:` under `lib/` and `android/`). Treat each as a documented, deliberate ceiling — do not "fix" one without a real-world trigger, and when you cut a corner yourself, leave the marker. +- Rung 2 is load-bearing here: pure policies live in `lib/ble/ble_state.dart` and `lib/sync/sync_policy.dart`, the single day-label helper is `lib/data/day_label.dart`, the single notification emitter is `lib/notify/notification_center.dart`. Check those before writing a new detector, policy, or helper. +- Rung 5 candidates already installed: `clock` (injectable time), `archive` (zip), `pointycastle` (AEAD), `collection` (direct dep — `DeepCollectionEquality` etc.), `latlong2` (geo math), `workmanager` (background jobs), `flutter_local_notifications` + `timezone` (scheduling). +- Semantics this repo protects that a "simpler" version must never change: the safe-trim invariant (commit before HISTORY_END ACK), ACK seq discipline, DST-correct day windows, the dangerous-opcode block, and the analytics/protocol pin gates in `lib/compute/derivation_engine.dart`. diff --git a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/BootReceiver.kt b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/BootReceiver.kt index 905d906d..9bcfa0ea 100644 --- a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/BootReceiver.kt +++ b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/BootReceiver.kt @@ -17,10 +17,10 @@ import android.os.Build * prefixed "flutter.". Our PairedDevice uses the key "paired_remote_id", so the XML * key is "flutter.paired_remote_id". * - * Starting the service also triggers EdgeApplication.onCreate, which pre-warms the - * cached FlutterEngine. The Dart main() runs in that engine — it sees no Activity - * (isHeadlessBoot path) and calls headlessBoot() which starts EdgeTracking + connects - * to the paired band. + * Starting the service warms the cached FlutterEngine (EdgeTrackingService.onCreate + * → EdgeApplication.ensureEngine). The Dart main() runs in that engine — it sees no + * Activity (isHeadlessBoot path) and calls headlessBoot() which starts EdgeTracking + + * connects to the paired band. */ class BootReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { @@ -28,25 +28,9 @@ class BootReceiver : BroadcastReceiver() { if (action != Intent.ACTION_BOOT_COMPLETED && action != "android.intent.action.QUICKBOOT_POWERON") return - if (!hasPairedDevice(context)) return + if (!KeepAliveWorker.hasPairedDevice(context)) return markPendingHeadlessBoot(context) - - val svcIntent = Intent(context, EdgeTrackingService::class.java) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - context.startForegroundService(svcIntent) - } else { - context.startService(svcIntent) - } - } - - private fun hasPairedDevice(context: Context): Boolean { - // Flutter SharedPreferences file name + key prefix. - val prefs: SharedPreferences = context.getSharedPreferences( - "FlutterSharedPreferences", - Context.MODE_PRIVATE - ) - val id = prefs.getString("flutter.paired_remote_id", null) - return !id.isNullOrEmpty() + EdgeTrackingService.start(context) } private fun markPendingHeadlessBoot(context: Context) { diff --git a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/CompanionBridge.kt b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/CompanionBridge.kt index ca97f584..665a7b27 100644 --- a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/CompanionBridge.kt +++ b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/CompanionBridge.kt @@ -229,6 +229,11 @@ class EdgeCompanionService : CompanionDeviceService() { } private fun onBandAppeared() { + // Routine re-appearances (arm-swing / body-block dropouts, many per hour + // mid-workout) must not churn an already-running service — a restart just + // rebuilds and re-posts the notification. `running` is exact in-process, + // and a dead process initializes it false, so the cold path still starts. + if (EdgeTrackingService.running) return try { EdgeTrackingService.start(this) } catch (e: Exception) { diff --git a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/EdgeApplication.kt b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/EdgeApplication.kt index 2a41d8cf..a594d5be 100644 --- a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/EdgeApplication.kt +++ b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/EdgeApplication.kt @@ -1,46 +1,78 @@ package wtf.openstrap.openstrap_edge import android.app.Application +import android.content.Context import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngineCache import io.flutter.embedding.engine.dart.DartExecutor /** - * Pre-warms a single long-lived [FlutterEngine] and caches it. MainActivity attaches - * to THIS engine (via getCachedEngineId) instead of creating its own, and does NOT - * destroy it when the Activity is finished (shouldDestroyEngineWithHost = false). + * Owns the single long-lived [FlutterEngine], created LAZILY via [ensureEngine]. + * MainActivity attaches to it (via getCachedEngineId) instead of creating its own, + * and does NOT destroy it when the Activity is finished (shouldDestroyEngineWithHost + * = false). * - * Why: when the user swipes the app from recents, Android destroys the Activity and, - * with a default Activity-owned engine, tears the engine down too (onDetachedFromEngine - * in logcat). That kills the Dart VM — and with it flutter_blue_plus's BLE connection - * AND the notification-relay stream (the native listener keeps firing but "FlutterJNI - * detached … could not send"). By retaining the engine here and keeping the process - * alive with the EdgeTracking foreground service, the Dart side keeps running headless - * after task removal, so the relay can still buzz the band. + * Why retained: when the user swipes the app from recents, Android destroys the + * Activity and, with a default Activity-owned engine, tears the engine down too + * (onDetachedFromEngine in logcat). That kills the Dart VM — and with it + * flutter_blue_plus's BLE connection AND the notification-relay stream. By retaining + * the engine and keeping the process alive with the EdgeTracking foreground service, + * the Dart side keeps running headless after task removal. * - * The trade-off is RAM: the app stays warm in memory. That's the intended cost of a - * persistent foreground BLE companion, and matches the existing foreground-service model. + * Why LAZY (moved out of Application.onCreate): the process is also started by + * widget update alarms, KeepAliveWorker runs, CDM device-presence binds and Tasker + * broadcasts — wakes that need ZERO Dart. Widgets render native snapshots from + * prefs; the worker/CDM paths just start EdgeTrackingService, whose own onCreate + * calls [ensureEngine]; TaskerReceiver has an engine-dead fallback. Cold-booting a + * full FlutterEngine + Dart main() for each of those wakes was pure battery burn on + * exactly the devices (background-restricted / low-RAM) that kill the process most + * often. Accepted trade-off: the system-bound NotificationListener can cold-start + * the process engine-less, so relayed notification buzzes drop until the tracking + * service starts (CDM presence when the band is in range — the only time a buzz + * can land anyway — or the ≤15 min KeepAliveWorker). + * + * The trade-off is RAM while the engine IS up: intended cost of a persistent + * foreground BLE companion, matching the foreground-service model. */ class EdgeApplication : Application() { companion object { const val ENGINE_ID = "openstrap_main_engine" + + /** + * Create, register, run and cache the shared engine if it doesn't exist + * yet; return the cached one otherwise. Idempotent. Main-thread only — + * every caller (Activity/Service onCreate) already is. + */ + @JvmStatic + fun ensureEngine(context: Context): FlutterEngine { + FlutterEngineCache.getInstance().get(ENGINE_ID)?.let { return it } + val app = context.applicationContext + // Constructor auto-registers plugins (GeneratedPluginRegistrant) → + // flutter_blue_plus, notification_listener_service, shared_preferences, + // etc. are all available headless. + val engine = FlutterEngine(app) + // Register platform channels on the engine BEFORE Dart starts, so they + // exist even when no Activity is attached (headless calls like + // EdgeTracking.start must work). + NativeChannels.register(engine, app) + engine.dartExecutor.executeDartEntrypoint( + DartExecutor.DartEntrypoint.createDefault() + ) + FlutterEngineCache.getInstance().put(ENGINE_ID, engine) + return engine + } } override fun onCreate() { super.onCreate() - // Constructor auto-registers plugins (GeneratedPluginRegistrant) → flutter_blue_plus, - // notification_listener_service, shared_preferences, etc. are all available headless. - val engine = FlutterEngine(this) - // Register platform channels on the engine BEFORE Dart starts, so they exist even - // when no Activity is attached (headless calls like EdgeTracking.start must work). - NativeChannels.register(engine, applicationContext) - engine.dartExecutor.executeDartEntrypoint( - DartExecutor.DartEntrypoint.createDefault() - ) - FlutterEngineCache.getInstance().put(ENGINE_ID, engine) - // Periodic watchdog: restart the tracking foreground service if the OS killed - // it while a band is paired (START_STICKY backup). Idempotent (KEEP policy); - // the worker itself no-ops when unpaired or already running. - KeepAliveWorker.schedule(applicationContext) + // Periodic watchdog: restart the tracking foreground service if the OS + // killed it while a band is paired (START_STICKY backup). Idempotent (KEEP + // policy). Paired-gated: unconditional scheduling gave even a never-paired + // install a persisted 15-min periodic wake forever. Pairing (re-)arms it + // via EdgeTrackingService.onCreate, and the worker cancels its own chain + // if it ever runs unpaired. + if (KeepAliveWorker.hasPairedDevice(this)) { + KeepAliveWorker.schedule(this) + } } } diff --git a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/EdgeTrackingService.kt b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/EdgeTrackingService.kt index 06088bd1..4d1fe849 100644 --- a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/EdgeTrackingService.kt +++ b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/EdgeTrackingService.kt @@ -11,6 +11,7 @@ import android.os.Build import android.os.IBinder import android.util.Log import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat /** * Foreground service that keeps the app process alive while backgrounded so the live @@ -77,15 +78,19 @@ class EdgeTrackingService : Service() { var running: Boolean = false private set - /** Start the foreground service (idempotent). */ + /** + * Start the foreground service (idempotent). The ONE entry point — + * BootReceiver / TaskerReceiver / NativeChannels route through here + * instead of hand-rolling the SDK_INT >= O branch (ContextCompat owns + * that check). [location] non-null sets EXTRA_LOCATION (authoritative, + * see its tri-state doc); null omits the extra so a live session's + * mode is inherited. + */ @JvmStatic - fun start(context: Context) { + fun start(context: Context, location: Boolean? = null) { val intent = Intent(context, EdgeTrackingService::class.java) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - context.startForegroundService(intent) - } else { - context.startService(intent) - } + if (location != null) intent.putExtra(EXTRA_LOCATION, location) + ContextCompat.startForegroundService(context, intent) } } @@ -148,6 +153,23 @@ class EdgeTrackingService : Service() { // crash the process over a keep-alive notification. Log.w(TAG, "startForeground failed: $e") } + // AFTER startForeground, so the 5 s foreground-service deadline is met + // before any heavier work runs on the main thread: + // + // Engine warm-up — the tracking service is the one headless path that + // genuinely needs Dart (the BLE session lives there); moved here from + // EdgeApplication.onCreate so widget alarms / worker runs / CDM binds in + // a dead process stay lightweight broadcasts instead of each cold-booting + // a full FlutterEngine + Dart main(). Idempotent. + EdgeApplication.ensureEngine(this) + // Watchdog (re-)schedule. In onStartCommand, not onCreate, deliberately: + // it must also run when a start lands on an ALREADY-RUNNING service — + // e.g. re-pairing after an unpair whose EdgeTracking.stop() silently + // failed, where the worker has already cancelled its own chain and a + // fresh onCreate never fires. KEEP policy makes the repeat free. + // (EdgeApplication gates its own schedule on the paired flag; the worker + // cancels itself when unpaired.) + KeepAliveWorker.schedule(applicationContext) // STICKY: recreate after an OS kill so the headless engine reconnects the // band without waiting for the user to reopen the app. return START_STICKY diff --git a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/KeepAliveWorker.kt b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/KeepAliveWorker.kt index 819b961b..2af44f31 100644 --- a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/KeepAliveWorker.kt +++ b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/KeepAliveWorker.kt @@ -48,8 +48,13 @@ class KeepAliveWorker(ctx: Context, params: WorkerParameters) : Worker(ctx, para } } - private fun hasPairedDevice(context: Context): Boolean { - // Flutter SharedPreferences file name + key prefix (see BootReceiver). + /** + * Whether Dart has a band paired. Flutter SharedPreferences writes to + * "FlutterSharedPreferences.xml" with keys prefixed "flutter.". + * Internal: EdgeApplication and BootReceiver gate on the same check. + */ + @JvmStatic + internal fun hasPairedDevice(context: Context): Boolean { val prefs = context.getSharedPreferences( "FlutterSharedPreferences", Context.MODE_PRIVATE, @@ -60,7 +65,17 @@ class KeepAliveWorker(ctx: Context, params: WorkerParameters) : Worker(ctx, para override fun doWork(): Result { val ctx = applicationContext - if (!hasPairedDevice(ctx)) return Result.success() // nothing to keep alive + if (!hasPairedDevice(ctx)) { + // Unpaired: there is nothing to keep alive, ever — cancel the chain + // rather than waking a dead process every ~15 min for the life of the + // install. Pairing re-schedules via EdgeTrackingService.onCreate. + try { + WorkManager.getInstance(ctx).cancelUniqueWork(WORK_NAME) + } catch (e: Exception) { + Log.w(TAG, "cancel failed: $e") + } + return Result.success() + } if (EdgeTrackingService.running) return Result.success() // healthy return try { Log.i(TAG, "paired but service not running — restarting EdgeTrackingService") diff --git a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/MainActivity.kt b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/MainActivity.kt index 0aced5ae..493ecd4d 100644 --- a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/MainActivity.kt +++ b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/MainActivity.kt @@ -32,6 +32,10 @@ class MainActivity : FlutterFragmentActivity() { override fun shouldDestroyEngineWithHost(): Boolean = false override fun onCreate(savedInstanceState: Bundle?) { + // The shared engine is created lazily now (EdgeApplication.ensureEngine). + // getCachedEngineId() is consulted during super.onCreate, so the cache + // entry must exist before it runs. + EdgeApplication.ensureEngine(applicationContext) activityAttached = true clearPendingHeadlessBoot() super.onCreate(savedInstanceState) diff --git a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/NativeChannels.kt b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/NativeChannels.kt index fd2760a2..fbca1323 100644 --- a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/NativeChannels.kt +++ b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/NativeChannels.kt @@ -71,16 +71,13 @@ object NativeChannels { .setMethodCallHandler { call, result -> when (call.method) { "start" -> { - val intent = Intent(app, EdgeTrackingService::class.java) // Route workout live → the FGS also claims the location type - // (see EdgeTrackingService.EXTRA_LOCATION). - val location = call.argument("location") == true - intent.putExtra(EdgeTrackingService.EXTRA_LOCATION, location) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - app.startForegroundService(intent) - } else { - app.startService(intent) - } + // (see EdgeTrackingService.EXTRA_LOCATION). Dart always sends + // the flag, so the extra is always set (authoritative). + EdgeTrackingService.start( + app, + call.argument("location") == true, + ) result.success(null) } "stop" -> { diff --git a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/OpenStrapBatteryWidgetProvider.kt b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/OpenStrapBatteryWidgetProvider.kt index ebc6ce1b..d1eeb3d1 100644 --- a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/OpenStrapBatteryWidgetProvider.kt +++ b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/OpenStrapBatteryWidgetProvider.kt @@ -15,7 +15,8 @@ import es.antonborri.home_widget.HomeWidgetProvider * while the band was connected. It says so in words until we have ever seen the * band, and the reading is muted once it's > 24 h old — we genuinely don't know the current * level if we haven't talked to the band. updatePeriodMillis re-renders every - * ~30 min so the staleness flip happens without the app's help. + * ~6 h so the staleness flip happens without the app's help (it moves at most + * once a day; see widget_band_battery_info.xml). */ class OpenStrapBatteryWidgetProvider : HomeWidgetProvider() { diff --git a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/OpenStrapWidgetProvider.kt b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/OpenStrapWidgetProvider.kt index 619e425a..dd7b5a07 100644 --- a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/OpenStrapWidgetProvider.kt +++ b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/OpenStrapWidgetProvider.kt @@ -118,7 +118,9 @@ class OpenStrapWidgetProvider : HomeWidgetProvider() { // "" = no measurement. A bare dash is the one rendering the phone's // grammar forbids outright. - val strainText = if (strain >= 0) String.format("%.1f", strain) else "" + // Locale.ROOT for the same reason as StrapWidgets.hm — one digit system. + val strainText = + if (strain >= 0) String.format(java.util.Locale.ROOT, "%.1f", strain) else "" val readinessText = if (readiness >= 0) "$readiness" else "" val hrvText = if (hrv >= 0) "$hrv" else "" diff --git a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/PhoneStepCounter.kt b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/PhoneStepCounter.kt index 4ab313da..f2f5d920 100644 --- a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/PhoneStepCounter.kt +++ b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/PhoneStepCounter.kt @@ -190,6 +190,12 @@ object PhoneStepCounter : SensorEventListener { // accumulates while the application processor sleeps and delivers in one go, // so this costs approximately nothing. Today's count therefore trails real // life by up to a minute, which no screen can tell. + // + // NOT raised further: attribution is delivery-time (onSensorChanged, `now`), + // so the batch window is also the worst-case misattribution across a bin/day + // boundary — a longer latency credits pre-midnight steps to the next day. The + // per-delivery full-prefs-XML rewrite is the real cost here; the fix is the + // SQLite move (see audit follow-ups), which cuts writes WITHOUT widening this. registered = sm.registerListener(this, s, SensorManager.SENSOR_DELAY_NORMAL, 60_000_000) } diff --git a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/StrapWidgets.kt b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/StrapWidgets.kt index 1ab0cd0c..a86d2482 100644 --- a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/StrapWidgets.kt +++ b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/StrapWidgets.kt @@ -146,7 +146,10 @@ internal object StrapWidgets { fun hm(min: Int): String { if (min < 0) return "" if (min < 60) return "${min}m" - return String.format("%dh %02dm", min / 60, min % 60) + // Locale.ROOT: default-locale %d emits localized digit shapes (ar/fa/bn), + // diverging from the plain "$x" templates beside it — two digit systems + // on one widget. The iOS sibling formats invariantly. + return String.format(java.util.Locale.ROOT, "%dh %02dm", min / 60, min % 60) } // ── ring renderer ──────────────────────────────────────────────────────── diff --git a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/TaskerReceiver.kt b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/TaskerReceiver.kt index abdcf3a7..d7d86623 100644 --- a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/TaskerReceiver.kt +++ b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/TaskerReceiver.kt @@ -78,12 +78,7 @@ class TaskerReceiver : BroadcastReceiver() { .putInt(PENDING_PATTERN_KEY, pattern) .apply() - val svcIntent = Intent(context, EdgeTrackingService::class.java) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - context.startForegroundService(svcIntent) - } else { - context.startService(svcIntent) - } + EdgeTrackingService.start(context) } companion object { diff --git a/android/app/src/main/res/xml/widget_band_battery_info.xml b/android/app/src/main/res/xml/widget_band_battery_info.xml index 39650a45..3cd103ff 100644 --- a/android/app/src/main/res/xml/widget_band_battery_info.xml +++ b/android/app/src/main/res/xml/widget_band_battery_info.xml @@ -1,7 +1,10 @@ = O` service-start branches → one + `EdgeTrackingService.start` on `ContextCompat.startForegroundService`. +- `String.format` without a locale on the widgets → `Locale.ROOT` + (mixed digit-systems on ar/fa/bn). +- min/max reduce lambdas → `dart:math`. +- `background_derivation.dart` gutted to a tombstone: only the task-name + constants main.dart's cancel-migration needs survive. +- Live-HR trace now clears on disconnect (two sessions no longer splice into + one chart); route recording no longer copies the whole vertex list per GPS + fix (`pathEmitEvery`, ~1/s, final emit on stop). + +## Deliberate semantic changes + +- Widget `updated_at` now means "last **value** change within a day", not + "last push" — but the snapshot's day leads the change-gate fingerprint, so a + new day's sync always advances `updated_at`; staleness can only appear when + data genuinely stops (never from unchanged values across days). Noted in + OpenStrapWidget.swift. +- An engine-less process start can delay relay buzzes ≤15 min (CDM presence or + the KeepAliveWorker recovers it). Documented in EdgeApplication. +- On Android in background, `state.wristOn`/`liveHr` stop updating in realtime; + wrist state still lands via the historical records each backfill round. + +## Follow-ups (found, not implemented) + +1. **Vendor a ~100-line native NotificationListenerService** with native-side + package filtering. The plugin extracts/compresses icons and pictures for + EVERY phone notification (even with the relay toggle off — the OS keeps the + listener bound while the grant exists), and the heal path drives the + plugin's private channel handlers (an API contract it never made). Biggest + remaining per-notification cost on chatty phones; needs device testing. +2. **Stream the telemetry `.db` upload** (health_uploader buffers the full + snapshot + its gzip in memory; auto_backup already streams the identical + pipeline). OOM risk on large DBs, not battery — upload is charging+Wi-Fi + gated. +3. **`DrainController.awaitComplete`**: 1 s poll → Completer completed from + `onComplete()`/`onLinkDown()` (bounded to active bursts; low value now). +4. **Standing reminders** re-cancel 27 ids + re-arm ~24 alarms per foreground + resume — fingerprint-skip when nothing changed. +5. **PhoneStepCounter storage**: prefs holds ~2,880 bucket keys rewritten per + batch; a small SQLite table would make each batch one row upsert. +6. **Live-workout 1 Hz UI tick** keeps ticking while backgrounded mid-workout — + pause/resume via lifecycle observer (elapsed is wall-clock-derived, loss-free). +7. `docs/internal/GATES.md` is referenced from code comments but absent from + the repo. +8. **Route the direct derive triggers through `DeriveDebouncer`** (CR-001 m5): + `markStoredData()`/`requestHeavy()` called directly from AppState + (reconnect backlog, foreground catch-up, periodic backfill) and + `_teardownSession` bypass the background pacing tier. Low practical impact — + the continuous 1 Hz stream (the real all-night drain) already goes through + the debouncer, and the direct calls are event-bounded (≥15 min apart, heavy + throttled to 1/30 min in background) — but a single debounced entry point + would make the pacing uniform. + +## Maintainer considerations + +- **Measure the win.** A before/after night of `adb shell dumpsys batterystats` + (or Battery Historian) on a Pixel would quantify this change set — the + dominant fixes (1 Hz stream off in background, ~5-min derives → ~45-min, + engine cold-boots eliminated) predict a large drop, and a number makes both + the release note and any regression later measurable. +- **Revisit the Doze-exemption steering.** Onboarding pushes users into the + battery-optimization exemption; that was necessary while the design depended + on unthrottled background work, and it also amplified every waste this audit + removed (the exemption is why Doze never damped any of it). Still worth + keeping for link survival, but the strength of the steering copy can be + softened once lower drain is confirmed on-device. +- The two entries under *Deliberate semantic changes* (widget `updated_at` + meaning, realtime `wristOn` in background) are judgment calls that deserve + explicit maintainer sign-off, not silent acceptance. + **Sign-off status: PENDING** — owner: the repo maintainer, via review of + PR #262. This audit is not "complete" until that review records accept/revert + on each; update this line with the decision (and PR link) when it lands. + +## Verification status + +No Flutter toolchain on the audit machine — the diff was verified by three +independent full-file review passes (compile-surface + call-site + test-impact) +instead of `flutter analyze`. Known test surface: five route_tracker tests take +`pathEmitEvery: Duration.zero`; everything else was checked call-site-compatible. +CI is the real gate. diff --git a/ios/OpenStrapWidget/OpenStrapWidget.swift b/ios/OpenStrapWidget/OpenStrapWidget.swift index 8dfe7319..1620025c 100644 --- a/ios/OpenStrapWidget/OpenStrapWidget.swift +++ b/ios/OpenStrapWidget/OpenStrapWidget.swift @@ -105,7 +105,10 @@ let kStaleAfter: TimeInterval = 26 * 3600 struct OpenStrapEntry: TimelineEntry { var date: Date let hasData: Bool - let updatedAt: Int // epoch sec of the last push, 0 = unknown + let updatedAt: Int // epoch sec of the last value change or new-day sync + // (Dart change-gates identical same-day pushes; the day + // leads the fingerprint so a new day always bumps this), + // 0 = unknown let readiness: Int // -1 = none (composite 0..100) — the headline let tier: Int // -1 = not scored · 0 rest · 1 easy · 2 steady · 3 good let band: String // the phone's own label for `tier` ("Steady", …) diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 53274245..a70b18e5 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -672,7 +672,17 @@ class BleEngine { } // ── OS-managed pending reconnect (background fallback) ─────────────────────── - static const Duration _osAutoConnectPoll = Duration(seconds: 5); + // Cancellation-poll cadence while parked in the OS-autoConnect wait. The + // actual connect is fully OS-driven (the connectionState listener completes + // the wait); this timer exists ONLY to notice `keepWaiting` flipping false, + // so 60 s of latency on unpair/give-up is fine — at 5 s it was 12 needless + // CPU wakes/min, around the clock, whenever the band was out of range. + static const Duration _osAutoConnectPoll = Duration(seconds: 60); + + /// Consecutive autoConnect ARM failures (not connect timeouts — the arm + /// itself throwing, e.g. adapter wedged). Drives the bounded backoff below; + /// reset by a successful arm. + int _autoConnectArmFailures = 0; /// Arm a flutter_blue_plus `autoConnect` pending connection and wait for the /// OS to complete it. Unlike the direct `connect(autoConnect:false)` retry @@ -704,17 +714,37 @@ class BleEngine { bool Function()? keepWaiting, }) async { final device = BluetoothDevice.fromId(remoteId); + // Adapter not on (Bluetooth toggled off, permission revoked): arming would + // just throw, once per pass, all night. Park on the adapterState stream — + // a native event — until it reports `on`, the caller stops wanting the + // link, or the window ends, then hand back false so the caller's loop + // re-enters and arms against a live adapter. + try { + final adapter = await FlutterBluePlus.adapterState.first + .timeout(const Duration(seconds: 5)); + if (adapter != BluetoothAdapterState.on) { + _log('OS autoConnect: adapter is ${adapter.name} — waiting for it to ' + 'come back (event-driven, max ${wait.inMinutes} min) instead of ' + 'arming a connect that cannot succeed.'); + await _waitForAdapterOn(wait: wait, keepWaiting: keepWaiting); + return false; + } + } catch (_) {/* adapter state unavailable — fall through to the arm */} try { // Arm under the op lock so it can't overlap a connect/disconnect. await _locked(() => device.connect(autoConnect: true, mtu: null)); + _autoConnectArmFailures = 0; } catch (e) { - // Cost the caller the poll interval before handing back a failure. The - // reconnect loop's OS-pending branch has no backoff of its own (only the - // direct-connect branch delays), so returning immediately — which is what - // an arm against a powered-off adapter does — spun that loop at - // event-loop rate, burning CPU/battery for as long as Bluetooth was off. - _log('autoConnect arm failed: $e'); - await Future.delayed(_osAutoConnectPoll); + // Bounded backoff before handing back the failure. The reconnect loop's + // OS-pending branch has no backoff of its own (only the direct-connect + // branch delays), so this delay is the only thing between the loop and + // an all-night retry spin when the arm keeps failing for a reason the + // adapter-state gate above didn't catch. + _autoConnectArmFailures++; + final delay = reconnectPolicy.delayFor(_autoConnectArmFailures); + _log('autoConnect arm failed (attempt $_autoConnectArmFailures): $e — ' + 'backing off ${delay.inSeconds}s.'); + await Future.delayed(delay); return false; } _log('OS autoConnect armed for $remoteId — waiting (max ' @@ -750,6 +780,35 @@ class BleEngine { return ok; } + /// Park until the Bluetooth adapter reports `on`, the caller stops wanting + /// the link ([keepWaiting] false, checked on the cancellation poll), or + /// [wait] elapses. Event-driven off the native adapterState stream — zero + /// per-attempt platform calls while the adapter stays off. + Future _waitForAdapterOn({ + required Duration wait, + bool Function()? keepWaiting, + }) async { + final done = Completer(); + final sub = FlutterBluePlus.adapterState.listen((s) { + if (s == BluetoothAdapterState.on && !done.isCompleted) done.complete(); + }); + final poll = Timer.periodic(_osAutoConnectPoll, (_) { + if (keepWaiting != null && !keepWaiting() && !done.isCompleted) { + done.complete(); + } + }); + final deadline = Timer(wait, () { + if (!done.isCompleted) done.complete(); + }); + try { + await done.future; + } finally { + await sub.cancel(); + poll.cancel(); + deadline.cancel(); + } + } + // Historical-offload bookkeeping. A controller is live for the whole connection // (we keep ACKing HISTORY_END markers as they arrive, even after the first // HISTORY_COMPLETE — a later strap-triggered offload reuses it). @@ -915,6 +974,15 @@ class BleEngine { if (_backgrounded == value) return; _backgrounded = value; unawaited(_applyLinkPriority()); + // The debounced derive trigger's one-shot deadline was computed for the + // OLD tier — a foreground flip must re-evaluate promptly (the user is + // looking at the screen; the foreground tier fires within 15 s), not at a + // background-tier deadline minutes away. + if (_deriveTimer != null) { + _deriveTimer!.cancel(); + _deriveTimer = null; + _armDeriveTimer(const Duration(seconds: 1)); + } } /// Bring the link to the priority the current state calls for. @@ -1242,22 +1310,46 @@ class BleEngine { final now = DateTime.now(); _lastStored = now; _firstPending ??= now; - _deriveTimer ??= Timer.periodic(const Duration(seconds: 2), (_) { - final fp = _firstPending; - if (fp == null) return; + _armDeriveTimer(); + } + + /// One-shot timer armed at the debouncer's computed next boundary — replaces + /// the old 2 s Timer.periodic poll, which during continuous background + /// listening ran for the whole pending window (a permanent 0.5 Hz CPU wake, + /// 24/7 with a healthy link). Fires, re-evaluates, and either delivers + /// [onDataStored] or re-arms at the next boundary. Only armed when none is + /// pending, so record floods don't churn timers; tier flips are handled by + /// the [setBackground] poke below. + void _armDeriveTimer([Duration? delay]) { + if (_deriveTimer != null) return; + final fp = _firstPending; + if (fp == null) return; + final d = delay ?? + deriveDebouncer.nextCheckDelay( + sinceLastRecord: DateTime.now().difference(_lastStored), + sinceFirstPending: DateTime.now().difference(fp), + dataStaleness: deriveDataStaleness(), + isForeground: isForegroundActive(), + isBackgrounded: _backgrounded, + ); + _deriveTimer = Timer(d, () { + _deriveTimer = null; + final pending = _firstPending; + if (pending == null) return; final fire = deriveDebouncer.shouldDerive( hasPending: true, sinceLastRecord: DateTime.now().difference(_lastStored), - sinceFirstPending: DateTime.now().difference(fp), + sinceFirstPending: DateTime.now().difference(pending), dataStaleness: deriveDataStaleness(), isForeground: isForegroundActive(), + isBackgrounded: _backgrounded, ); if (fire) { _firstPending = null; - _deriveTimer?.cancel(); - _deriveTimer = null; onDataStored!.call(); + return; } + _armDeriveTimer(); }); } @@ -1793,6 +1885,20 @@ class BleEngine { shouldPauseMaintenanceTraffic(offloadActive: _offloadActive)) { return; } + // Backgrounded: 60 s cadence. LINK_VALID is an app-level write, not + // link-layer maintenance — the controller keeps the connection alive on + // its own, and offloads already pause this write for minutes at a time + // with no link loss, so a 60 s gap is proven safe on real hardware. + // 10 s stays for foreground (cheap there, and the responsive case is + // where a firmware-side idle policy would first show). + if (_backgrounded) { + final last = _lastLinkValidAt; + if (last != null && + DateTime.now().difference(last) < const Duration(seconds: 60)) { + return; + } + } + _lastLinkValidAt = DateTime.now(); _send(Cmd.linkValid, const [0x00]); }); // Keep-alive (30s): liveness watchdog (bounce a silently-dead link), periodic @@ -1909,7 +2015,20 @@ class BleEngine { _send(Cmd.sendR10R11Realtime, const [0x01]); } } - _send(Cmd.toggleRealtimeHr, const [0x01]); + // Evidence-gated: the HR re-arm exists to recover a stream that silently + // died, so send it only when the stream is demonstrably NOT delivering + // (no valid reading for >60 s — off-wrist stamps nothing, which + // correctly degrades to the old blind re-arm there). Blindly re-sending + // every 30 s was ~2,880 write-with-response round trips/day whose + // payload was a no-op. The IMU re-arm above stays unconditional: it runs + // only in foreground full-live (bounded by screen-on time), and a + // flowing HR stream is no proof the IMU stream is alive. + final hrAtMs = state.liveHrAt; + final hrDelivering = hrAtMs != null && + DateTime.now().millisecondsSinceEpoch - hrAtMs < 60 * 1000; + if (!hrDelivering) { + _send(Cmd.toggleRealtimeHr, const [0x01]); + } } // Battery is a DISPLAY value that moves over hours. Polling it on every // 30 s keep-alive tick was 2,880 radio round-trips a day for a handful of @@ -1924,7 +2043,16 @@ class BleEngine { // and force one as soon as silence approaches the fuse. unawaited( _pollBatteryIfDue( - force: sinceLastRx.inSeconds > kLivenessFuseSeconds ~/ 2, + // With no live stream armed (Android background keeps live fully OFF) + // the battery REPLY is the only inbound traffic this link generates, + // and resume-time staleness is judged against + // kLinkFreshnessNoStreamSeconds — so force the poll well under that + // bar (~every other 30 s tick ⇒ sinceLastRx stays ≤ ~65 s). With a + // stream armed, the original fuse/2 threshold stands. + force: sinceLastRx.inSeconds > + (_liveEnabled + ? kLivenessFuseSeconds ~/ 2 + : kNoStreamPollSilenceSeconds), ), ); // Cheap retry hook for a priority request that failed earlier: a no-op @@ -1934,6 +2062,10 @@ class BleEngine { DateTime? _lastBatteryPollAt; + /// Last LINK_VALID heartbeat actually sent — drives the backgrounded 60 s + /// stretch in the 10 s heartbeat timer above. + DateTime? _lastLinkValidAt; + /// Ask the band for its battery level, at most once per /// [kBatteryPollIntervalSeconds]. /// @@ -4710,6 +4842,7 @@ class BleEngine { // level once rather than inheriting the last link's 5-minute cooldown. _appliedPriority = null; _lastBatteryPollAt = null; + _lastLinkValidAt = null; // Every failure exit in `_doConnect` between setting this and `sendInit` // skips the clear in sendInit's finally, which would leave the target // pinned at `high` for the life of the process. diff --git a/lib/ble/ble_state.dart b/lib/ble/ble_state.dart index 1e85d69a..18c06bf0 100644 --- a/lib/ble/ble_state.dart +++ b/lib/ble/ble_state.dart @@ -1032,8 +1032,40 @@ class DeriveDebouncer { // tier. This tier takes priority over fresh/stale whenever foreground. this.foregroundQuietPeriod = const Duration(seconds: 5), this.foregroundMaxWait = const Duration(seconds: 15), + // A FOURTH tier: explicitly backgrounded (Android — the foreground service + // keeps capture running with no OS deferral, see DeriveScheduler). Nobody + // can see a fresh number while backgrounded, the queued jobs are durable, + // and the foreground flip re-evaluates immediately (the engine pokes the + // timer in setBackground) — so the only thing a fast background cadence + // buys is widget/Health-Connect freshness, which tolerates ~45 min. This + // is what caps the all-night light-derive churn (one pass per maxWait + // instead of one per 5-min fresh window). + this.backgroundQuietPeriod = const Duration(minutes: 20), + this.backgroundMaxWait = const Duration(minutes: 45), }); + final Duration backgroundQuietPeriod; + final Duration backgroundMaxWait; + + /// The (quietPeriod, maxWait) pair for the current tier. One copy of the + /// tier priority: foreground > backgrounded > stale/fresh. + ({Duration quietPeriod, Duration maxWait}) _tierFor({ + required Duration dataStaleness, + required bool isForeground, + required bool isBackgrounded, + }) { + if (isForeground) { + return (quietPeriod: foregroundQuietPeriod, maxWait: foregroundMaxWait); + } + if (isBackgrounded) { + return (quietPeriod: backgroundQuietPeriod, maxWait: backgroundMaxWait); + } + final staleMode = dataStaleness >= staleThreshold; + return staleMode + ? (quietPeriod: staleQuietPeriod, maxWait: staleMaxWait) + : (quietPeriod: freshQuietPeriod, maxWait: freshMaxWait); + } + /// Should we derive now, given the pending-record bookkeeping? /// [hasPending] — records persisted since the last derive /// [sinceLastRecord] — how long since the most recent persisted record @@ -1041,28 +1073,52 @@ class DeriveDebouncer { /// [isForeground] — the app is actively in the foreground right now; /// takes priority over the fresh/stale staleness /// tiers when true (see foregroundQuietPeriod doc) + /// [isBackgrounded] — the app is explicitly backgrounded (engine + /// setBackground); slowest tier, second in priority bool shouldDerive({ required bool hasPending, required Duration sinceLastRecord, required Duration sinceFirstPending, required Duration dataStaleness, bool isForeground = false, + bool isBackgrounded = false, }) { if (!hasPending) return false; - Duration quietPeriod; - Duration maxWait; - if (isForeground) { - quietPeriod = foregroundQuietPeriod; - maxWait = foregroundMaxWait; - } else { - final staleMode = dataStaleness >= staleThreshold; - quietPeriod = staleMode ? staleQuietPeriod : freshQuietPeriod; - maxWait = staleMode ? staleMaxWait : freshMaxWait; - } - if (sinceLastRecord >= quietPeriod) return true; // stream went quiet - if (sinceFirstPending >= maxWait) return true; // never-quiet floor + final tier = _tierFor( + dataStaleness: dataStaleness, + isForeground: isForeground, + isBackgrounded: isBackgrounded, + ); + if (sinceLastRecord >= tier.quietPeriod) return true; // stream went quiet + if (sinceFirstPending >= tier.maxWait) return true; // never-quiet floor return false; } + + /// How long until [shouldDerive] could next flip true, given the same + /// inputs — lets the engine arm ONE one-shot timer at the exact boundary + /// instead of polling every 2 s for the whole pending window (which, with a + /// continuous background stream, was a permanent 0.5 Hz CPU wake). Clamped + /// to ≥1 s. Tier flips (foreground/background transitions) are handled by + /// the engine re-arming, not by this estimate. + Duration nextCheckDelay({ + required Duration sinceLastRecord, + required Duration sinceFirstPending, + required Duration dataStaleness, + bool isForeground = false, + bool isBackgrounded = false, + }) { + final tier = _tierFor( + dataStaleness: dataStaleness, + isForeground: isForeground, + isBackgrounded: isBackgrounded, + ); + final untilQuiet = tier.quietPeriod - sinceLastRecord; + final untilMax = tier.maxWait - sinceFirstPending; + final next = untilQuiet < untilMax ? untilQuiet : untilMax; + return next < const Duration(seconds: 1) + ? const Duration(seconds: 1) + : next; + } } /// Pure builders for the on-device wake-alarm command PAYLOADS (the inner body diff --git a/lib/cloud/cloud_import.dart b/lib/cloud/cloud_import.dart index 82261744..90bb31b5 100644 --- a/lib/cloud/cloud_import.dart +++ b/lib/cloud/cloud_import.dart @@ -23,6 +23,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart' show visibleForTesting; import '../compute/derivation_engine.dart' show kAlgoVersion; +import '../data/day_label.dart' show dayLabelOf; import '../data/db.dart'; import 'backend_client.dart'; @@ -49,7 +50,9 @@ class CloudImporter { // today so the current local day is never excluded. final now = DateTime.now(); final fromD = now.subtract(Duration(days: days)); - final from = _ymd(fromD), to = _ymd(now); + // day_label.dart is THE one day-label helper (byte-identical output here — + // both inputs are local DateTimes). + final from = dayLabelOf(fromD), to = dayLabelOf(now); final profileRaw = await api.getProfile(); final dailies = await api.getDailies(from, to); @@ -297,9 +300,6 @@ class CloudImporter { return true; } - static String _ymd(DateTime d) => '${d.year.toString().padLeft(4, '0')}-' - '${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; - static Map? _parseObj(Object? v) { if (v is Map) return v.cast(); if (v is String && v.isNotEmpty) { diff --git a/lib/compute/background_derivation.dart b/lib/compute/background_derivation.dart index e1b70fd5..95cdae84 100644 --- a/lib/compute/background_derivation.dart +++ b/lib/compute/background_derivation.dart @@ -1,143 +1,26 @@ -// background_derivation.dart — scheduled HEAVY derivation, app-closed. +// background_derivation.dart — TOMBSTONE of the removed WorkManager scheduling. // -// The light pass (most-recent affected day) is kicked synchronously from every -// drain/flush completion in AppState (foreground + background BLE wakes) — see -// AppState._afterDrain. THIS file is the SCHEDULED heavy pass (full sleep -// staging + 24-h spectra over every stale day). +// This file used to register two Android WorkManager periodic tasks (sync + +// heavy derive) at the 15-min floor with NO constraints (requiresCharging: +// false, requiresBatteryNotLow: false) and a dispatcher that initialized +// Firebase in every background isolate. `BackgroundDerivation.init()` was +// deliberately un-wired from main.dart (it collided with AppState's own +// persistent-connection background session — see the note there, "don't +// re-add"), which left the registration + dispatcher as dead code whose latent +// configuration was a battery disaster by construction if anyone ever re-wired +// it. It is deleted now; background derivation is owned by DeriveScheduler + +// DeriveDebouncer (background tier) on the persistent connection. // -// Android: two real OS-scheduled WorkManager periodic jobs (sync + heavy -// derive), requested every 10 min and clamped by Android to 15, with -// NO constraints — see `init`: `requiresCharging: false`, -// `requiresBatteryNotLow: false`, no network, no device-idle. They -// run whatever the battery is doing. (This block used to claim -// "constrained to when charging + idle is preferred"; nothing in the -// registration ever asked for that.) WorkManager genuinely runs us in -// a background isolate even when the app is killed. +// ONLY the unique task names remain: main.dart cancels both by name on every +// Android cold start, because registrations persisted by the OS survive app +// updates. Keep the constants (and the cancel calls) until it is reasonable to +// assume no installed device still carries the old registrations. // -// iOS: HONEST CAVEAT — heavy compute on iOS is NOT guaranteed. -// BackgroundTasks.swift registers "wtf.openstrap.edge.bgsync" as a -// BGProcessingTask and ios_bg_task.dart handles the run→Dart callout -// (sync + heavy derive). iOS decides if/when to run it (idle + power -// preferred; force-quit apps never run background tasks at all). -// Reliable iOS coverage: (a) the light pass during CoreBluetooth- -// restoration BLE wakes (IosBleRestore), (b) BGProcessingTask when iOS -// grants budget (IosBgTask), and (c) finalize-on-foreground when the -// app next opens. We do NOT pretend the BGTask is guaranteed. -// -// The WorkManager callback runs in its OWN isolate with no Provider/UI — it reads -// the profile straight from shared_preferences and drives DerivationEngine, which -// keeps all DB I/O on that (its own main) isolate and offloads the pure pipeline -// via Isolate.run. - -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter/widgets.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:workmanager/workmanager.dart'; -import 'package:firebase_core/firebase_core.dart'; -import '../firebase_options.dart'; - -import 'derivation_engine.dart'; -import 'profile.dart'; -import '../sync/background_sync.dart'; - // Public (not `_`-prefixed): main.dart needs these unique names to scope its // startup cancelByUniqueName() cleanup to exactly these two tasks, without // touching unrelated WorkManager jobs (e.g. the native KeepAliveWorker // watchdog, which shares the same OS-level WorkManager instance and would // otherwise get wiped by an unscoped cancelAll()). + const String kHeavyDeriveTaskName = 'openstrap.derive.heavy'; const String kSyncTaskName = 'openstrap.sync'; -const String _kProfileKey = 'local_profile_json'; // mirrors AppState._kProfile - -/// The WorkManager entry point. MUST be a top-level / static fn with the -/// @pragma so it survives tree-shaking in the background isolate. -@pragma('vm:entry-point') -void derivationDispatcher() { - Workmanager().executeTask((task, _) async { - WidgetsFlutterBinding.ensureInitialized(); - try { - if (Firebase.apps.isEmpty) { - await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); - } - } catch (_) {} - - try { - if (task == kSyncTaskName) { - debugPrint('[bg-sync] triggered by WorkManager'); - await runHeadlessSync(); - return true; - } else if (task == kHeavyDeriveTaskName) { - debugPrint('[bg-derive] triggered by WorkManager'); - final profile = await _loadProfile(); - final engine = DerivationEngine( - log: (m) => debugPrint('[bg-derive] $m'), background: true); - await engine.run(profile, heavy: true); - // Baseline-dirty rescan on the scheduled tick: refresh baseline-dependent - // scalars on recent finalized days when the rolling baseline has moved. - // Cheap no-op when the baseline signature is unchanged. - await engine.rescanRecent(profile); - return true; - } - return true; - } catch (e, st) { - debugPrint('[bg-task] failed: $e\n$st'); - return true; // don't thrash retries; the next run catches up. - } - }); -} - -Future _loadProfile() async { - try { - final prefs = await SharedPreferences.getInstance(); - final raw = prefs.getString(_kProfileKey); - if (raw == null) return const Profile(); - final m = (jsonDecode(raw) as Map).cast(); - return Profile.fromMap(m); - } catch (_) { - return const Profile(); - } -} - -/// Initialize + schedule the heavy derivation and sync. Call once at app startup. -/// No-op-safe: failures are swallowed (compute still happens on drain hooks + -/// on foreground). -class BackgroundDerivation { - static Future init() async { - // Android only: WorkManager has no iOS background-fetch guarantee for heavy - // compute. On iOS we rely on the drain-hook light pass + foreground finalize. - if (!Platform.isAndroid) return; - try { - await Workmanager().initialize(derivationDispatcher); - - // Schedule Sync Task (every 10 min - note Android clamps to 15 min minimum) - await Workmanager().registerPeriodicTask( - kSyncTaskName, - kSyncTaskName, - frequency: const Duration(minutes: 10), - constraints: Constraints( - networkType: NetworkType.notRequired, - requiresBatteryNotLow: false, - requiresCharging: false, - ), - existingWorkPolicy: ExistingPeriodicWorkPolicy.keep, - ); - - // Schedule Analyze/Derivation Task (every 10 min - note Android clamps to 15 min minimum) - await Workmanager().registerPeriodicTask( - kHeavyDeriveTaskName, - kHeavyDeriveTaskName, - frequency: const Duration(minutes: 10), - constraints: Constraints( - networkType: NetworkType.notRequired, - requiresBatteryNotLow: false, - requiresCharging: false, - ), - existingWorkPolicy: ExistingPeriodicWorkPolicy.keep, - ); - } catch (e) { - debugPrint('[bg-task] schedule failed: $e'); - } - } -} diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 337026ad..1cdba49a 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -1719,7 +1719,11 @@ class DerivationEngine { Trace? runTrace; try { - if (Firebase.apps.isNotEmpty) { + // Heavy/force passes only. Light passes run many times a day (including + // all night in the background), and each trace is buffered + eventually + // uploaded — periodic radio wakeups from a local-first app, for timings + // the _diag map already captures locally. + if (Firebase.apps.isNotEmpty && (heavy || force)) { runTrace = FirebasePerformance.instance.newTrace('derivation_engine_run'); await runTrace.start(); runTrace.putAttribute('mode', force ? 'force' : (heavy ? 'heavy' : 'light')); diff --git a/lib/compute/derive_scheduler.dart b/lib/compute/derive_scheduler.dart index d7f15375..6984ad6d 100644 --- a/lib/compute/derive_scheduler.dart +++ b/lib/compute/derive_scheduler.dart @@ -40,13 +40,16 @@ class DeriveScheduler { bool _workoutActive = false; // While the app is backgrounded we must NOT run derivation: a derive pass - // decodes the whole retained substrate + runs the metric compute, and doing + // decodes the retained substrate + runs the metric compute, and doing // that on a short background BLE wake trips iOS's CPU watchdog // (cpu_resource_fatal) or memory jetsam → the app gets terminated. Capture // (persist + ACK) is lightweight and keeps running; the derive intent is - // durable in compute_jobs, so it simply waits and drains on foreground return - // (Android WorkManager / iOS BGProcessingTask still handle heavy passes with a - // proper OS budget). Held exactly like _offloadActive. + // durable in compute_jobs, so it simply waits and drains on foreground + // return. (No OS periodic scheduler backs this up — the old WorkManager + // registration was deliberately removed, see main.dart — so on Android, + // where the foreground service gives derivation a real budget, backgrounded + // derives DO run; their cadence is capped by DeriveDebouncer's background + // tier, not blocked here.) Held exactly like _offloadActive. bool _background = false; bool _running = false; bool _pendingLight = false; diff --git a/lib/data/series_codec.dart b/lib/data/series_codec.dart index eeebb6e2..1bf43ba4 100644 --- a/lib/data/series_codec.dart +++ b/lib/data/series_codec.dart @@ -36,6 +36,8 @@ import 'dart:convert'; +import 'package:collection/collection.dart' show DeepCollectionEquality; + /// Encoder/decoder for the curve shapes stored in `day_result.payload_json`. /// /// The invariant every method here upholds: **encode → decode is lossless, or @@ -286,24 +288,11 @@ class SeriesCodec { } } - static bool _deepEquals(Object? a, Object? b) { - if (identical(a, b)) return true; - if (a is Map && b is Map) { - if (a.length != b.length) return false; - for (final k in a.keys) { - if (!b.containsKey(k) || !_deepEquals(a[k], b[k])) return false; - } - return true; - } - if (a is List && b is List) { - if (a.length != b.length) return false; - for (var i = 0; i < a.length; i++) { - if (!_deepEquals(a[i], b[i])) return false; - } - return true; - } - return a == b; - } + // package:collection's structural equality — identical semantics for + // JSON-shaped data (ordered lists, keyed maps, == on scalars) to the ~18 + // hand-rolled lines this replaces. + static bool _deepEquals(Object? a, Object? b) => + const DeepCollectionEquality().equals(a, b); /// True when [payloadJson] still holds at least one legacy-shaped curve, i.e. /// re-encoding it would shrink the row. Used by the background backfill to diff --git a/lib/gps/route_tracker.dart b/lib/gps/route_tracker.dart index 82e40951..2141ab11 100644 --- a/lib/gps/route_tracker.dart +++ b/lib/gps/route_tracker.dart @@ -92,8 +92,14 @@ class RouteTracker { this.rejectStreakLimit = 3, this.zoneNow, this.stallAfter = const Duration(seconds: 15), + this.pathEmitEvery = const Duration(seconds: 1), }); + /// Minimum spacing between [path] emissions (the ~1/s throttle — see the fix + /// handler). Injectable so tests that feed many fixes inside one wall-clock + /// second can pass [Duration.zero] and assert per-fix vertices. + final Duration pathEmitEvery; + /// The full path so far, coloured by live zone — drives the live map. final ValueNotifier> path = ValueNotifier>(const []); @@ -127,6 +133,9 @@ class RouteTracker { int _movingMs = 0; bool _stopped = false; DateTime _lastFixAt = clock.now(); + // Wall-ms of the last `path` emission (see the ~1/s throttle in the fix + // handler). 0 ⇒ the first accepted fix always emits. + int _lastPathEmitMs = 0; bool get isRunning => _sub != null && !_stopped; int get pointCount => _seq; @@ -231,8 +240,16 @@ class RouteTracker { _buffer.add(p); _vertices.add(RouteVertex(p.latLng, zoneNow?.call(), gapBefore: gapBefore)); - // Emit a fresh list so ValueNotifier listeners rebuild. - path.value = List.unmodifiable(_vertices); + // Emit a fresh list so ValueNotifier listeners rebuild — throttled to ~1/s. + // Per-fix emission was an O(n) unmodifiable copy + full polyline rebuild on + // EVERY accepted fix (O(n²) over a long ride: ~10k points at a 5 m filter), + // and the map cannot visually resolve per-5-m updates anyway. `current`/ + // speed stay per-fix, so the position dot and pace never lag. + final nowMs = clock.now().millisecondsSinceEpoch; + if (nowMs - _lastPathEmitMs >= pathEmitEvery.inMilliseconds) { + _lastPathEmitMs = nowMs; + path.value = List.unmodifiable(_vertices); + } current.value = p.latLng; if (_buffer.length >= batchSize) { @@ -273,6 +290,9 @@ class RouteTracker { _watchdog = null; await _sub?.cancel(); _sub = null; + // Final emit: the ~1/s throttle can be holding up to a second of tail + // vertices — the caller reads the notifiers right after stop(). + path.value = List.unmodifiable(_vertices); await _flush(); if (_buffer.isNotEmpty) await _flush(); // one retry for the tail dispose(); diff --git a/lib/health/health_export.dart b/lib/health/health_export.dart index 5bd82fe6..bedff048 100644 --- a/lib/health/health_export.dart +++ b/lib/health/health_export.dart @@ -78,9 +78,20 @@ bool shouldAttemptHealthExport({ required DateTime now, required DateTime? lastAttempt, required Duration backoff, + DateTime? lastSuccess, + Duration minRewriteInterval = Duration.zero, bool force = false, }) { if (force) return true; + // Success-side throttle for the re-written recent tail: a NON-finalized day + // that just exported cleanly is byte-identical minutes later, but exportAll + // runs on every drain/derive pass, so without this the full delete+rewrite + // (hourly buckets + minute HR) hit the health store every ~10 min around the + // clock. Finalized days pass Duration.zero and are unaffected; forceRetry + // and finalization (which resets the retry entry) still bypass. + if (lastSuccess != null && now.difference(lastSuccess) < minRewriteInterval) { + return false; + } if (attempts >= maxAttempts) return false; return lastAttempt == null || now.difference(lastAttempt) >= backoff; } @@ -92,6 +103,8 @@ bool shouldAttemptHealthBulkExport({ required DateTime? lastAttempt, required Duration backoff, required bool prioritySleepAlreadyWritten, + DateTime? lastSuccess, + Duration minRewriteInterval = Duration.zero, bool force = false, }) => shouldAttemptHealthExport( attempts: attempts, @@ -99,6 +112,8 @@ bool shouldAttemptHealthBulkExport({ now: now, lastAttempt: lastAttempt, backoff: backoff, + lastSuccess: lastSuccess, + minRewriteInterval: minRewriteInterval, force: force || prioritySleepAlreadyWritten, ); @@ -384,6 +399,16 @@ class HealthExporter { // false is not success and must keep the day out of the exported prefix. static const _kRetryCursor = 'health_export_retry_state'; static const _kMaxExportAttempts = 6; + + /// Success-side floor for re-writing a NON-finalized day (the mutable recent + /// tail). exportAll runs on every drain/derive pass — every ~10 min while + /// connected — and each pass re-deletes and re-writes the whole current day + /// (hourly buckets + minute HR) into the health store; almost all of it + /// identical. New minutes reach Health Connect/HealthKit within this window; + /// finalization and forceRetry bypass it entirely, so the finalized prefix + /// and user-initiated exports are unaffected. Tracked per day as `ok_ms` in + /// the same retry-state JSON. + static const _kNonFinalizedRewriteInterval = Duration(minutes: 30); static const _kRetryBackoff = [ Duration(minutes: 5), Duration(minutes: 30), @@ -467,6 +492,7 @@ class HealthExporter { ?.cast(); var attempts = (entry?['attempts'] as num?)?.toInt() ?? 0; var lastAttemptMs = (entry?['last_ms'] as num?)?.toInt(); + final okMs = (entry?['ok_ms'] as num?)?.toInt(); final wasFinalized = entry?['finalized'] as bool? ?? false; if (pendingPriorityDay.finalized && !wasFinalized && attempts > 0) { attempts = 0; @@ -481,10 +507,29 @@ class HealthExporter { ? null : DateTime.fromMillisecondsSinceEpoch(lastAttemptMs), backoff: _backoffFor(attempts), + lastSuccess: okMs == null + ? null + : DateTime.fromMillisecondsSinceEpoch(okMs), + minRewriteInterval: pendingPriorityDay.finalized + ? Duration.zero + : _kNonFinalizedRewriteInterval, force: forceRetry, ); if (!shouldAttempt) { - if (attempts >= _kMaxExportAttempts) return exportBulk(null); + // The priority day is held back by the CAP or by the success-side + // rewrite throttle (it exported cleanly <30 min ago) — in either + // case its sleep session is already in the store and is not what's + // blocking, so still export every OTHER pending day. Only a + // genuine retry backoff (recent FAILURE, still under the cap) holds + // bulk, matching the pre-throttle behaviour. A day with `ok_ms` set + // carries no pending failure (the success branch clears + // attempts/last_ms), so the two conditions are mutually exclusive. + final throttledBySuccess = okMs != null && + !pendingPriorityDay.finalized && + nowMs - okMs < _kNonFinalizedRewriteInterval.inMilliseconds; + if (attempts >= _kMaxExportAttempts || throttledBySuccess) { + return exportBulk(null); + } return 0; } Future recordPriorityFailure() async { @@ -544,6 +589,7 @@ class HealthExporter { final entry = (retryState[date] as Map?)?.cast(); var attempts = (entry?['attempts'] as num?)?.toInt() ?? 0; var lastAttemptMs = (entry?['last_ms'] as num?)?.toInt(); + final okMs = (entry?['ok_ms'] as num?)?.toInt(); final wasFinalized = entry?['finalized'] as bool? ?? false; if (finalized && !wasFinalized && attempts > 0) { // The day just transitioned non-finalized -> finalized: a @@ -568,6 +614,11 @@ class HealthExporter { : DateTime.fromMillisecondsSinceEpoch(lastAttemptMs), backoff: _backoffFor(attempts), prioritySleepAlreadyWritten: date == androidSleepAlreadyWritten, + lastSuccess: okMs == null + ? null + : DateTime.fromMillisecondsSinceEpoch(okMs), + minRewriteInterval: + finalized ? Duration.zero : _kNonFinalizedRewriteInterval, force: forceRetry, ); if (!shouldAttempt && attempts >= _kMaxExportAttempts) { @@ -582,8 +633,19 @@ class HealthExporter { androidSleepAlreadyWritten: date == androidSleepAlreadyWritten, ); // delete-then-write (idempotent) if (ok) { - if (entry != null) { - retryState.remove(date); + if (finalized) { + // Finalized + exported → the cursor advances past it; no + // per-day state left behind. + if (entry != null) { + retryState.remove(date); + retryStateDirty = true; + } + } else { + // Non-finalized success: stamp ok_ms so the next passes skip + // the identical rewrite until _kNonFinalizedRewriteInterval + // elapses. Replacing the entry also resets any failure budget, + // exactly as the old remove-on-success did. + retryState[date] = {'ok_ms': nowMs}; retryStateDirty = true; } } else { diff --git a/lib/import/journal_csv_import.dart b/lib/import/journal_csv_import.dart index 1e30a089..751d74d8 100644 --- a/lib/import/journal_csv_import.dart +++ b/lib/import/journal_csv_import.dart @@ -23,6 +23,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; +import '../data/day_label.dart' show dayLabelOf; import '../data/db.dart'; /// Exactly the columns `csv_export.dart` emits for the journal set. A file @@ -76,8 +77,9 @@ class JournalCsvFormatException implements Exception { /// /// Written out rather than pulled from a package because the writing half is /// also six lines in this repo (`csvField`), and a reader that disagrees with -/// its own writer is the actual risk here. -@visibleForTesting +/// its own writer is the actual risk here. THE one CSV reader in lib/ — +/// whoop_import reads through it too (its old line-based reader broke on +/// quoted embedded newlines). List> parseCsv(String text) { final records = >[]; var fields = []; @@ -167,9 +169,7 @@ JournalCsvParse parseJournalCsv(String text, {DateTime? today}) { } final now = today ?? DateTime.now(); - final todayLabel = '${now.year.toString().padLeft(4, '0')}-' - '${now.month.toString().padLeft(2, '0')}-' - '${now.day.toString().padLeft(2, '0')}'; + final todayLabel = dayLabelOf(now); final rows = []; final rejected = []; diff --git a/lib/import/whoop_import.dart b/lib/import/whoop_import.dart index 75cf9e84..ed80a026 100644 --- a/lib/import/whoop_import.dart +++ b/lib/import/whoop_import.dart @@ -19,6 +19,7 @@ import '../compute/profile.dart'; import '../compute/substrate.dart' show localDateLabel; import '../data/db.dart'; import 'import_container.dart'; +import 'journal_csv_import.dart' show parseCsv; class WhoopImportResult { final int days; @@ -404,52 +405,21 @@ class WhoopImporter { return t.isEmpty ? 'other' : t; } - /// Minimal quote-aware CSV reader (handles fields wrapped in double-quotes with - /// embedded commas / escaped ""). Streams lines so a large export isn't all in - /// memory at once for the split step. + /// Read a CSV via the repo's one RFC 4180 parser ([parseCsv], + /// journal_csv_import). The line-based reader that lived here split records + /// on newlines BEFORE quote-parsing, so a quoted WHOOP field containing an + /// embedded newline (free-text activity names/notes) was torn into two + /// malformed records — quote state cannot survive a LineSplitter. Lenient + /// decode preserved: a WHOOP export saved under a non-UTF-8 locale should + /// lose a character, not the whole import. Blank lines are dropped, as the + /// old reader did. static Future>> _readCsv(String path) async { - final lines = File(path) - .openRead() - // Lenient: a WHOOP export saved under a non-UTF-8 locale should lose a - // character, not the whole import. - .transform(const Utf8Decoder(allowMalformed: true)) - .transform(const LineSplitter()); - final out = >[]; - await for (final line in lines) { - if (line.isEmpty) continue; - out.add(_splitCsvLine(line)); - } - return out; - } - - static List _splitCsvLine(String line) { - final out = []; - final sb = StringBuffer(); - var inQ = false; - for (var i = 0; i < line.length; i++) { - final c = line[i]; - if (inQ) { - if (c == '"') { - if (i + 1 < line.length && line[i + 1] == '"') { - sb.write('"'); - i++; - } else { - inQ = false; - } - } else { - sb.write(c); - } - } else if (c == '"') { - inQ = true; - } else if (c == ',') { - out.add(sb.toString()); - sb.clear(); - } else { - sb.write(c); - } - } - out.add(sb.toString()); - return out; + final bytes = await File(path).readAsBytes(); + final text = const Utf8Decoder(allowMalformed: true).convert(bytes); + return [ + for (final r in parseCsv(text)) + if (r.length != 1 || r.single.isNotEmpty) r, + ]; } } diff --git a/lib/notify/fired_keys.dart b/lib/notify/fired_keys.dart index 15417962..be3e3d07 100644 --- a/lib/notify/fired_keys.dart +++ b/lib/notify/fired_keys.dart @@ -8,10 +8,12 @@ // flag) would fire over and over (issue #136). This store makes emit() honour the // promise: a key that has already fired is skipped until a *new* key comes along. // -// CROSS-ISOLATE. Derivation runs in TWO isolates: the long-lived foreground pass -// (kept alive for BLE) and the WorkManager background pass -// (background_derivation.dart) — both call emit()/this store, ~every drain and -// ~every 15 min. The NotificationCenter lock only orders emits WITHIN one +// CROSS-ISOLATE. Derivation ran in TWO isolates when this was written: the +// long-lived foreground pass (kept alive for BLE) and the WorkManager +// background pass (background_derivation.dart, since removed — its tombstone +// explains why). The hardening below stays: the iOS headless/BGTask paths can +// still run emit() from a second isolate, and the failure modes are the same. +// The NotificationCenter lock only orders emits WITHIN one // isolate; it can't coordinate across them. Three distinct things went wrong // there, and a same-day key re-fired all day: // diff --git a/lib/notify/notification_relay.dart b/lib/notify/notification_relay.dart index 5de52fea..2166c158 100644 --- a/lib/notify/notification_relay.dart +++ b/lib/notify/notification_relay.dart @@ -25,7 +25,11 @@ class NotificationRelay extends ChangeNotifier with WidgetsBindingObserver { // unbinds the NotificationListenerService (it does this routinely over time). static const MethodChannel _pluginChannel = MethodChannel('x-slayer/notifications_channel'); - static const Duration _healEvery = Duration(seconds: 120); + // 15 min, not 120 s: the heal is a belt-and-braces rebind for a listener + // Android rarely unbinds, foreground resume already heals eagerly, and a + // missed buzz during the window costs nothing — while the timer itself ran + // a platform-channel round trip forever in an always-alive process. + static const Duration _healEvery = Duration(minutes: 15); Timer? _healTimer; /// Fire the strap haptic. Wired by AppState to `engine.buzz()`. Best-effort. @@ -142,7 +146,14 @@ class NotificationRelay extends ChangeNotifier with WidgetsBindingObserver { // stream down so we're not holding a system callback for nothing. Also runs a // periodic heal so a system-unbound listener gets re-armed while we're alive. void _resync() { - final shouldListen = supported && _enabled && _granted; + // [active] (which includes `_packages.isNotEmpty`), not just + // enabled+granted: with ZERO apps selected the feature can never produce a + // buzz, yet it used to hold the stream subscription (every phone + // notification crossing the platform channel into Dart just to be + // discarded) and the heal timer, forever, in a process the FGS keeps + // alive. setAppEnabled calls back in here, so selecting the first app + // arms everything again. + final shouldListen = active; if (shouldListen) { _startListening(); _healTimer ??= Timer.periodic(_healEvery, (_) => _heal()); diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 64246c8f..22df01aa 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -141,8 +141,9 @@ class AppState extends ChangeNotifier { LocalRepository? repo; /// The on-device compute orchestrator. Kicked (light) after every drain/flush - /// completion, and (heavy) on foreground finalize. Background heavy passes run - /// via WorkManager (Android) — see lib/compute/background_derivation.dart. + /// completion, and (heavy) on foreground finalize + throttled reconnect + /// backlogs. (The old WorkManager background heavy pass was removed — see the + /// tombstone in lib/compute/background_derivation.dart.) // `background` is final on the engine and picks the concurrency + per-day // timeout, so seeding the scheduler alone left a headless first sweep running // the foreground budget. Late-initialized, so this reads the value both @@ -226,6 +227,22 @@ class AppState extends ChangeNotifier { String? _widgetBattName; int? _storedBatteryPct; + /// Raw strapName last seen from the engine — change-gates the per-tick + /// cleanDeviceLabel/Prefs work in [_onEngineState]. + String? _lastSeenStrapNameRaw; + + /// Minute-of-day last checked by [_maybeWarnOvernightBattery]'s clock + /// pre-gate (it runs off the ~1 Hz engine-state pipeline). + int? _lastForecastGateMin; + + /// Last backgrounded heavy-derive request — throttles reconnect-driven heavy + /// passes while a flappy link churns in the background (30-min floor). + DateTime? _lastBackgroundHeavyAt; + + /// Last backgrounded wake-window re-plan (its inputs change at most daily; + /// see the throttle in [_runPeriodicBackfill]). + DateTime? _lastWakeWindowRefreshAt; + /// Last time the overnight battery forecast ran. `_onEngineState` fires on /// every device-state update, and the forecast reads a few hundred rows, so /// it is throttled rather than run per tick. The user-visible fire-once @@ -2111,7 +2128,17 @@ class AppState extends ChangeNotifier { // cold launch, so there is nothing to double-count. await _recoverOrphanedLiveSession(); _resetLivePedometer(); - _maybeDowngradeLiveForBackground(); + if (Platform.isIOS && !engine.liveEnabled) { + // A background cold-launch connects with NO stream armed, and + // _maybeDowngradeLiveForBackground no-ops when live is already + // off — but on iOS zero inbound traffic can stall the suspended + // process's Dart timers (the 1 Hz notification is what keeps it + // schedulable; see the downgrade doc). Arm HR-only directly. + // Android correctly stays stream-less here. + unawaited(engine.enableHrOnlyLive()); + } else { + _maybeDowngradeLiveForBackground(); + } _startBackfillTimer(); } else { // Connect attempt didn't succeed on this background cold-launch — @@ -2368,14 +2395,43 @@ class AppState extends ChangeNotifier { bool get _hasLiveConsumer => activeWorkout != null || breathingActive || breathingWindowOpen; - /// Downgrade live to HR-only when backgrounded with no live consumer. The - /// keep-alive re-arm respects the HR-only mode, so the downgrade sticks until - /// [openSession]'s fast reclaim (or a reconnect in the foreground) restores - /// the full set. + /// Step live down when backgrounded with no live consumer. Platform-split: + /// + /// • Android: live goes fully OFF. The EdgeTracking foreground service + /// keeps the process alive without any inbound stream, so the 1 Hz + /// HR-only stream bought nothing here — it was ~86,400 CPU/radio wakes + /// per day (each one decode → state mutation → notifyListeners) with no + /// consumer, the single largest steady drain on the phone. Liveness is + /// covered by the keep-alive's forced battery poll (see + /// kNoStreamPollSilenceSeconds) and the resume paths judge freshness by + /// the no-stream bar (isLinkStale liveStreamArmed: false). Nothing is + /// lost: records keep landing via the 15-min flash backfill, and the + /// wrist-on bit rides in with the historical records. wristOn/liveHr + /// simply stop updating in realtime while backgrounded. + /// + /// • iOS: HR-only downgrade, as before. The inbound 1 Hz notification is + /// what keeps the suspended process schedulable (bluetooth-central + /// resumes us per notification) — with zero inbound traffic the Dart + /// timers (keep-alive, backfill) may never run, stalling continuous + /// background capture. The stream is load-bearing there, not waste. + /// + /// [openSession]'s fast reclaim (or a foreground reconnect) restores the + /// full set either way. + /// The in-flight background live downgrade, if any. `disableLiveStreams` + /// (Android) clears `liveEnabled`/`liveHrOnly` only AFTER its ~300 ms write + /// sequence, so a foreground reclaim landing inside that window must AWAIT + /// this before deciding whether to re-arm — otherwise it reads stale + /// full-live flags, skips `enableLiveStreams`, and the pending disable's OFF + /// writes then leave foreground live off. See [openSession]. + Future? _bgLiveDowngrade; + void _maybeDowngradeLiveForBackground() { if (!engine.isConnected || !engine.liveEnabled) return; if (_hasLiveConsumer) return; - unawaited(engine.enableHrOnlyLive()); + _bgLiveDowngrade = Platform.isAndroid + ? engine.disableLiveStreams() + : engine.enableHrOnlyLive(); + unawaited(_bgLiveDowngrade!); } /// iOS recovery: release the band to the native restore central's no-timeout pending @@ -2988,6 +3044,15 @@ class AppState extends ChangeNotifier { if (last != null && now.difference(last) < const Duration(minutes: 15)) { return; } + // Clock-only pre-gate: this runs off _onEngineState, which fires ~1 Hz + // during live HR — and the 15-min stamp above is (deliberately) written + // only once the evening-window check passes, so outside the window every + // tick fell through to the prefs load below. One check per wall-clock + // minute is plenty; the first tick of a minute still runs the full path, + // so the first evening forecast is delayed by <1 min at most. + final gateMin = now.hour * 60 + now.minute; + if (gateMin == _lastForecastGateMin) return; + _lastForecastGateMin = gateMin; final prefs = await NotificationPrefs.load(); final nowMin = now.hour * 60 + now.minute; @@ -3073,10 +3138,16 @@ class AppState extends ChangeNotifier { // Bank the name the moment the band says it, so it survives the // disconnect. Written through `cleanDeviceLabel` for the same reason the // BLE side reads through it: a garbled response must never become the - // remembered name. - final nm = cleanDeviceLabel(s.strapName); - if (nm != null && nm != Prefs.getString(_kStrapName, '')) { - Prefs.setString(_kStrapName, nm); + // remembered name. Change-gated on the RAW value first (same pattern as + // _widgetBattName below): this handler fires ~1 Hz during live HR, and + // cleanDeviceLabel's regex work per tick is pure waste when the name + // hasn't moved. + if (s.strapName != _lastSeenStrapNameRaw) { + _lastSeenStrapNameRaw = s.strapName; + final nm = cleanDeviceLabel(s.strapName); + if (nm != null && nm != Prefs.getString(_kStrapName, '')) { + Prefs.setString(_kStrapName, nm); + } } // Battery-low / charging OS notifications (edge-triggered + de-duped inside). _deviceAlerts.onDeviceState( @@ -3135,6 +3206,14 @@ class AppState extends ChangeNotifier { // and reset the counter. (No cadence calibration is involved: it was // removed with the 1 Hz step estimator at v55/v56.) unawaited(_finalizeLivePedometer()); + // A new connection is a new live-HR session: without this the trace + // buffer spliced readings from before the drop (or from a previously + // paired band) onto the next session's chart as one continuous line. + if (_liveHrTrace.isNotEmpty) { + _liveHrTrace.clear(); + _liveHrTraceAt = null; + liveHrTraceRev++; + } if (_keepAlive && isPaired && !_reconnecting && !device.autoReconnectPaused) { _log('Connection dropped — reconnecting…'); _stopBackfillTimer(); @@ -3171,8 +3250,11 @@ class AppState extends ChangeNotifier { /// Cadence of the reconnect supervisor. Cheap — the tick reads local flags /// and does nothing at all unless the app is paired, wants a link, and does - /// not have one. - static const Duration _reconnectSupervisorInterval = Duration(minutes: 1); + /// not have one. 5 min, not 1: the condition it catches (a loop wedged for + /// ≥25 min, sync_policy staleAfter) tolerates minutes of detection latency, + /// and this timer runs for the whole life of a Doze-exempt process — 288 + /// wakes/day instead of 1,440. + static const Duration _reconnectSupervisorInterval = Duration(minutes: 5); /// Start the level-triggered reconnect supervision (issue #208). /// @@ -3191,7 +3273,7 @@ class AppState extends ChangeNotifier { /// Stop supervising. Called from `dispose` and from every path that stops /// wanting a link at all (unpair / endSession) — otherwise the tick outlives - /// its purpose and keeps poking the engine once a minute forever. + /// its purpose and keeps poking the engine every few minutes forever. void _stopReconnectSupervisor() { _reconnectSupervisor?.cancel(); _reconnectSupervisor = null; @@ -3271,10 +3353,22 @@ class AppState extends ChangeNotifier { // 90-minute pre-wake window opens. Skipping it outright meant a band // that connected at 22:00 and stayed connected never armed high-frequency // sync for that night at all. - try { - await _refreshHighFreqWakeWindow(); - } catch (e) { - _log('Wake-window refresh failed: $e'); + // + // Throttled to every 25 min while backgrounded (every third 10-min + // tick): the plan's input (habitual wake median off 14 derived days) + // changes at most once a day, and the window it arms is 90 min wide — + // a ~30-min check still opens it with ≥60 min of lead. Re-running the + // 14-day DB read + JSON decode every 10 min all night bought nothing. + final lastRefresh = _lastWakeWindowRefreshAt; + if (lastRefresh == null || + DateTime.now().difference(lastRefresh) >= + const Duration(minutes: 25)) { + _lastWakeWindowRefreshAt = DateTime.now(); + try { + await _refreshHighFreqWakeWindow(); + } catch (e) { + _log('Wake-window refresh failed: $e'); + } } _log('Periodic history refresh skipped — backgrounded; the engine\'s ' 'floored 15-min backfill owns the offload.'); @@ -3786,6 +3880,16 @@ class AppState extends ChangeNotifier { // Back in the foreground with an OS CPU/memory budget again — let the // scheduler drain any derive jobs that queued (durably) while backgrounded. _deriveScheduler.setBackground(false); + // A background live downgrade may still be writing (its flags clear only on + // completion). Let it finish before any reclaim path below re-arms live, so + // the re-arm sees settled flags and its ON writes can't interleave with the + // disable's trailing OFF writes. + if (_bgLiveDowngrade != null) { + try { + await _bgLiveDowngrade; + } catch (_) {} + _bgLiveDowngrade = null; + } if (wasBackground && engine.isConnected) { IosBleRestore.foregroundActive = true; await IosBleRestore.setOwnsBand(true); @@ -3796,7 +3900,10 @@ class AppState extends ChangeNotifier { // notification arrived recently the link is genuinely live → keep the fast reclaim. // Otherwise it's stale → tear it down and fall through to a clean reconnect, which // re-subscribes (the only place setNotifyValue runs) and drains the gap. - if (!isLinkStale(engine.sinceLastRx)) { + if (!isLinkStale( + engine.sinceLastRx, + liveStreamArmed: engine.liveEnabled, + )) { // Healthy link → fast reclaim. But the fast path skips the band polls the full // connect path runs, so the cached battery %/charging/strap-name go stale. // Re-poll them in the background so the UI stays current. Non-blocking. @@ -3808,9 +3915,12 @@ class AppState extends ChangeNotifier { await engine.getStrapName(); } catch (_) {} }()); - // Backgrounding downgraded live to HR-only (no raw flood) — restore the - // full live set now that the foreground UI is consuming it again. - if (engine.liveHrOnly) unawaited(engine.enableLiveStreams()); + // Backgrounding downgraded live to HR-only (iOS) or fully OFF + // (Android) — restore the full live set now that the foreground UI is + // consuming it again. + if (!engine.liveEnabled || engine.liveHrOnly) { + unawaited(engine.enableLiveStreams()); + } // FOREGROUND CATCH-UP: R24 drains on a ~15-min timer while backgrounded, // so "last data" can lag up to 15 min behind a healthy link. The user // just opened the app — pull the flash backlog now. Floored at 90 s @@ -4011,10 +4121,14 @@ class AppState extends ChangeNotifier { // Live streams come up promptly; the FULL drain (no short timeout — // the ENTIRE offline backlog the band flashed while out of range) // runs concurrently, single-flight, exactly as in openSession. - // Background reconnect with no live consumer → HR-only live, so the - // raw flood can't starve the backlog drain we're about to run. + // Background reconnect with no live consumer: Android leaves live + // fully OFF (the FGS keeps the process alive; the 1 Hz stream has no + // consumer — see _maybeDowngradeLiveForBackground); iOS arms HR-only + // (the inbound notification keeps the suspended process schedulable). if (_background && !_hasLiveConsumer) { - await engine.enableHrOnlyLive(); + if (!Platform.isAndroid) { + await engine.enableHrOnlyLive(); + } } else { await engine.enableLiveStreams(); } @@ -4032,7 +4146,22 @@ class AppState extends ChangeNotifier { // landed. await _refreshHighFreqWakeWindow(); // Backlog (often an overnight gap) just landed → derive it. - _deriveScheduler.requestHeavy(); + // Backgrounded, a flappy link (routine arm-swing dropouts) + // reconnects many times an hour; each heavy pass spawns an + // isolate and re-stages the pending days, so throttle heavy to + // one per 30 min while backgrounded — the interim reconnects + // still get a light pass, and the foreground return finalizes + // with a real heavy anyway. + final now = DateTime.now(); + final lastHeavy = _lastBackgroundHeavyAt; + if (_background && + lastHeavy != null && + now.difference(lastHeavy) < const Duration(minutes: 30)) { + _deriveScheduler.markStoredData(); + } else { + if (_background) _lastBackgroundHeavyAt = now; + _deriveScheduler.requestHeavy(); + } notifyListeners(); }).catchError((Object e) { _log('Reconnect sync burst failed: $e'); @@ -4119,7 +4248,10 @@ class AppState extends ChangeNotifier { /// tries to reconnect"). Future foregroundCatchUp() async { if (!engine.isConnected) return; - if (isLinkStale(engine.sinceLastRx)) { + if (isLinkStale( + engine.sinceLastRx, + liveStreamArmed: engine.liveEnabled, + )) { _log( 'Foreground catch-up: no BLE data for ${engine.sinceLastRx.inSeconds}s ' '— zombie link, forcing reconnect instead of a stale-link pull.', @@ -5111,6 +5243,12 @@ class AppState extends ChangeNotifier { : 'Live session ended. Burned $finalKcal kcal.', ); LiveActivity.end(); + // A workout stopped while backgrounded (band double-tap gesture) was the + // one path that left FULL live armed with no consumer — the keep-alive + // then faithfully re-armed the 100 Hz flood every 30 s until the next + // lifecycle transition. Re-run the background downgrade now the consumer + // is gone (no-op when foregrounded or already downgraded). + if (_background) _maybeDowngradeLiveForBackground(); // A workout often rides the live feed; if the connection blipped during it, the // band may hold that window in flash. Pull it now over the live connection so the // just-finished session isn't left with a gap. diff --git a/lib/sync/background_sync.dart b/lib/sync/background_sync.dart index eb6310fd..5ede10cf 100644 --- a/lib/sync/background_sync.dart +++ b/lib/sync/background_sync.dart @@ -3,11 +3,14 @@ // Invoked by the iOS CoreBluetooth-restoration RECOVERY path (ios_ble_restore.dart) // when the band reappears after the live connection dropped. // -// There is NO OS periodic scheduler (no WorkManager task, no BGTask): continuous -// capture is the kept-alive live connection in AppState. This is purely the -// relaunch-recovery fallback that pulls the band's offline flash backlog into the -// local SQLite store (lib/data/db.dart), the system of record. A missed run is -// harmless; the next reconnect catches up from the non-destructive cursor. +// There is NO OS periodic scheduler on Android (the old WorkManager tasks were +// removed — background_derivation.dart is a tombstone; main.dart still cancels +// their persisted registrations by name). iOS registers opportunistic BGTasks +// (ios_bg_task.dart) that are never guaranteed. Continuous capture is the +// kept-alive live connection in AppState. This is purely the relaunch-recovery +// fallback that pulls the band's offline flash backlog into the local SQLite +// store (lib/data/db.dart), the system of record. A missed run is harmless; +// the next reconnect catches up from the non-destructive cursor. import 'dart:convert'; diff --git a/lib/sync/file_log.dart b/lib/sync/file_log.dart index 8aed1abb..70c54f31 100644 --- a/lib/sync/file_log.dart +++ b/lib/sync/file_log.dart @@ -3,6 +3,13 @@ // Writes to the app's external files dir on Android so it can be pulled with a // plain `adb pull` (no run-as needed): // /storage/emulated/0/Android/data/wtf.openstrap.openstrap_edge/files/openstrap_sync.log +// +// Bounded: rotates to a single .1 sibling at [_maxBytes] so a 24/7 headless +// process can't grow it without limit, and appends WITHOUT a per-line fsync — +// AppState routes every log line here, so `flush: true` was a full +// open→write→fsync→close flash cycle per line, around the clock. The page +// cache still lands the line on process crash; only a hard power loss can drop +// the last few lines of a diagnostics log, which is an acceptable trade. import 'dart:io'; import 'package:path_provider/path_provider.dart'; @@ -11,6 +18,12 @@ class FileLog { static File? _file; static bool _init = false; + static const int _maxBytes = 2 * 1024 * 1024; + // ponytail: the size check stats the file only once every 128 writes, so the + // log can overshoot _maxBytes by a burst's worth of lines before rotating. + static const int _sizeCheckEvery = 128; + static int _writesSinceCheck = 0; + static Future _ensure() async { if (_init) return; _init = true; @@ -25,9 +38,24 @@ class FileLog { static Future write(String line) async { await _ensure(); + final f = _file; + if (f == null) return; + try { + if (_writesSinceCheck++ % _sizeCheckEvery == 0) { + await _rotateIfNeeded(f); + } + await f.writeAsString('$line\n', mode: FileMode.append); + } catch (_) {} + } + + /// One-file rotation: current → .1 (replacing any previous .1), and the next + /// append recreates the live file. Keeps at most ~2×[_maxBytes] on disk. + static Future _rotateIfNeeded(File f) async { try { - await _file?.writeAsString('$line\n', - mode: FileMode.append, flush: true); + if (!await f.exists() || await f.length() < _maxBytes) return; + final old = File('${f.path}.1'); + if (await old.exists()) await old.delete(); + await f.rename('${f.path}.1'); } catch (_) {} } @@ -40,6 +68,11 @@ class FileLog { await _ensure(); try { await _file?.writeAsString(''); + final f = _file; + if (f != null) { + final old = File('${f.path}.1'); + if (await old.exists()) await old.delete(); + } } catch (_) {} } } diff --git a/lib/sync/paired_device.dart b/lib/sync/paired_device.dart index ad4beb28..a82d43b7 100644 --- a/lib/sync/paired_device.dart +++ b/lib/sync/paired_device.dart @@ -43,6 +43,11 @@ class PairedDevice { } } +// Compiled once — cleanDeviceLabel is called from the ~1 Hz engine-state +// pipeline, and RegExp construction per call was pure per-tick waste. +final RegExp _labelSafeCharset = RegExp(r"^[A-Za-z0-9 '._-]+$"); +final RegExp _labelHasAlnum = RegExp(r'[A-Za-z0-9]'); + /// A WHOOP serial ("4C2248092") or a user-set strap name ("Abdul's WHOOP") is /// made of letters, digits, spaces and a little ordinary punctuation. Anything /// containing other characters (the "?*"-style junk a bad HELLO parse produced) @@ -51,7 +56,7 @@ String? cleanDeviceLabel(String? s) { if (s == null) return null; final t = s.trim(); if (t.isEmpty) return null; - if (!RegExp(r"^[A-Za-z0-9 '._-]+$").hasMatch(t)) return null; // safe charset - if (!RegExp(r'[A-Za-z0-9]').hasMatch(t)) return null; // needs ≥1 alnum + if (!_labelSafeCharset.hasMatch(t)) return null; // safe charset + if (!_labelHasAlnum.hasMatch(t)) return null; // needs ≥1 alnum return t; } diff --git a/lib/sync/sync_policy.dart b/lib/sync/sync_policy.dart index 81206fd5..9a49c8c5 100644 --- a/lib/sync/sync_policy.dart +++ b/lib/sync/sync_policy.dart @@ -45,11 +45,29 @@ const int kHistoricalAbortRetryDelaySeconds = /// wake, a headless entry point. const int kLinkFreshnessSeconds = 30; +/// The freshness bar when NO live stream is armed (Android background, where +/// live is fully off and the only inbound traffic is the keep-alive's forced +/// battery poll — see `BleEngine._keepAliveFire`). The poll lands roughly once +/// per minute (forced past [kNoStreamPollSilenceSeconds] of silence, checked on +/// 30 s ticks), so a healthy quiet link legitimately shows up to ~65 s of +/// silence; judging it by the 30 s streaming bar would tear down a live link on +/// every foreground resume. +const int kLinkFreshnessNoStreamSeconds = 90; + +/// Silence threshold past which the keep-alive FORCES a battery poll when no +/// live stream is armed, keeping `sinceLastRx` under +/// [kLinkFreshnessNoStreamSeconds] on a healthy link. +const int kNoStreamPollSilenceSeconds = 45; + /// True when a connection reporting "connected" should NOT be trusted because -/// no data has actually arrived within [kLinkFreshnessSeconds]. Pure — callers -/// own the actual teardown/reconnect. See [kLinkFreshnessSeconds]. -bool isLinkStale(Duration sinceLastRx) => - sinceLastRx.inSeconds >= kLinkFreshnessSeconds; +/// no data has actually arrived recently. The bar depends on what inbound +/// traffic a healthy link actually produces: [kLinkFreshnessSeconds] while a +/// live stream is armed (≥1 Hz expected), [kLinkFreshnessNoStreamSeconds] when +/// nothing is armed and only poll replies arrive. Pure — callers own the +/// actual teardown/reconnect. +bool isLinkStale(Duration sinceLastRx, {bool liveStreamArmed = true}) => + sinceLastRx.inSeconds >= + (liveStreamArmed ? kLinkFreshnessSeconds : kLinkFreshnessNoStreamSeconds); // ── plausibility gates (unix seconds) ──────────────────────────────────────── const int kMinPlausibleUnix = 1700000000; // 2023-11 floor diff --git a/lib/ui2/live_hr.dart b/lib/ui2/live_hr.dart index d018668b..97977a86 100644 --- a/lib/ui2/live_hr.dart +++ b/lib/ui2/live_hr.dart @@ -24,6 +24,8 @@ // · It repaints ALONE. A 1 Hz stream hung off a `watch` in a parent would // rebuild that whole tree once a second for the life of the connection. +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:provider/provider.dart'; @@ -120,8 +122,8 @@ class LiveHrCard extends StatelessWidget { ), const SizedBox(height: S.x2), Text( - 'The last ${trace.length} readings — ${trace.reduce((a, b) => a < b ? a : b)}' - '–${trace.reduce((a, b) => a > b ? a : b)} bpm. Not stored; this is ' + 'The last ${trace.length} readings — ${trace.reduce(math.min)}' + '–${trace.reduce(math.max)} bpm. Not stored; this is ' 'the live stream, not a record of your day.', style: F.over.copyWith(color: p.ink3), ), diff --git a/lib/widget/widget_service.dart b/lib/widget/widget_service.dart index ba5c5c36..b30b6134 100644 --- a/lib/widget/widget_service.dart +++ b/lib/widget/widget_service.dart @@ -100,6 +100,14 @@ class WidgetService { return DateTime(y, m, d); } + /// Fingerprint of the last snapshot that fully landed (all keys + reload + + /// Watch sync). The change gate below — same pattern as [pushBattery]'s + /// caller — because push() runs after EVERY derive pass, and an unchanged + /// snapshot was still ~15 binder calls, a native widget re-render broadcast, + /// and a WCSession transfer. `updated_at` is deliberately excluded: it is + /// write-time metadata, and any genuinely new data moves at least one value. + static String? _lastPushFingerprint; + /// Push the latest snapshot and trigger a widget reload. Best-effort; never /// throws into the caller. Sentinels: ints use -1 / strings use '' for "no data". static Future push(TodayData t) async { @@ -111,9 +119,6 @@ class WidgetService { final need = t.sleepNeed; final rhr = t.restingHr; - Future setI(String k, int v) => - HomeWidget.saveWidgetData(k, v); - // has_data is the ONE flag every native reader gates on (the WidgetKit // home + lock-screen widgets, the Watch mirror, the Siri intents), and it // is the only way this side can say "don't show a number" to any of them. @@ -121,34 +126,24 @@ class WidgetService { // screen looking current — the widget's own no-data state is the honest // answer, and the alternative is a readiness score from last week with // nothing on it to say so. - await HomeWidget.saveWidgetData('has_data', !t.isEmpty && !isStale(t)); + final hasData = !t.isEmpty && !isStale(t); // Headline composite Readiness + the three rings (Strain · Sleep · HRV). final rv = t.readiness.isEmpty ? null : t.readiness.value; - await setI('readiness', rv == null ? -1 : rv.round()); + final readiness = rv == null ? -1 : rv.round(); // The banding, published rather than re-derived. The widget, the Watch // and Siri each carried their own thresholds, so the same 65 read green // here, orange on the widget and yellow on the wrist. They now render // `readiness_tier` (colour) and `readiness_band` (label) and decide // nothing themselves — see `readinessBand`, the only copy of the cut-offs. final band = readinessBand(rv); - await setI('readiness_tier', band.tier); - await HomeWidget.saveWidgetData( - // '' for "no data", like every other string key here. Every native - // reader gates its label on `readiness >= 0` anyway, so "Not scored" - // would only ever be text nobody sees. - 'readiness_band', - band.tier < 0 ? '' : band.label, - ); - await setI('hrv', hrv == null ? -1 : hrv.rmssd.round()); - await setI( - 'hrv_baseline', - hrv?.baseline == null ? -1 : hrv!.baseline!.round(), - ); - await HomeWidget.saveWidgetData( - 'strain', - s.isEmpty ? -1.0 : s.value!.toDouble(), - ); - await setI('sleep_min', sleep.isEmpty ? -1 : sleep.value!.round()); + // '' for "no data", like every other string key here. Every native + // reader gates its label on `readiness >= 0` anyway, so "Not scored" + // would only ever be text nobody sees. + final bandLabel = band.tier < 0 ? '' : band.label; + final hrvV = hrv == null ? -1 : hrv.rmssd.round(); + final hrvBase = hrv?.baseline == null ? -1 : hrv!.baseline!.round(); + final strainV = s.isEmpty ? -1.0 : s.value!.toDouble(); + final sleepMin = sleep.isEmpty ? -1 : sleep.value!.round(); // -1, like every other int key here, whenever the payload carries no // learned sleep need. `/today` used to hand this side a hard 480 — // `_sleepSummary` wrote `need_min: 480` unconditionally — so this branch @@ -157,12 +152,40 @@ class WidgetService { // The payload now omits the key until `sleep_coach.need` exists; the // native readers gate their ring on `needMin > 0`, so the sentinel leaves // it empty. - await setI('sleep_need_min', need.isEmpty ? -1 : need.value!.round()); - await setI('rhr', rhr.isEmpty ? -1 : rhr.value!.round()); - await HomeWidget.saveWidgetData( - 'coach_line', - _coachLine(t.coach), - ); + final needMin = need.isEmpty ? -1 : need.value!.round(); + final rhrV = rhr.isEmpty ? -1 : rhr.value!.round(); + final coach = _coachLine(t.coach); + + // The day this snapshot describes leads the fingerprint — the SAME field + // `isStale` reads. Without it, two consecutive days with identical rounded + // metrics produce the same fingerprint, the push is skipped, `updated_at` + // never advances, and the native `fresh` check (updatedAt + 26 h) flips to + // "No recent data" on day 2 despite a clean current-day sync. Including it + // guarantees a new day always pushes (advancing `updated_at`) while keeping + // the within-day skip that is the whole point of this gate. + final statusDay = t.status?.overnightDay ?? + t.status?.activityDay ?? + t.status?.todayDay ?? + ''; + + final fp = '$statusDay|$hasData|$readiness|${band.tier}|$bandLabel|$hrvV|' + '$hrvBase|$strainV|$sleepMin|$needMin|$rhrV|$coach'; + if (fp == _lastPushFingerprint) return; + + Future setI(String k, int v) => + HomeWidget.saveWidgetData(k, v); + + await HomeWidget.saveWidgetData('has_data', hasData); + await setI('readiness', readiness); + await setI('readiness_tier', band.tier); + await HomeWidget.saveWidgetData('readiness_band', bandLabel); + await setI('hrv', hrvV); + await setI('hrv_baseline', hrvBase); + await HomeWidget.saveWidgetData('strain', strainV); + await setI('sleep_min', sleepMin); + await setI('sleep_need_min', needMin); + await setI('rhr', rhrV); + await HomeWidget.saveWidgetData('coach_line', coach); await setI('updated_at', DateTime.now().millisecondsSinceEpoch ~/ 1000); await HomeWidget.updateWidget( @@ -170,6 +193,13 @@ class WidgetService { androidName: _androidName, ); await _syncWatch(); + // Only after everything landed — a mid-write failure must retry on the + // next push, not be remembered as done. The Watch leg is best-effort: + // `_syncWatch` always resolves and WatchBridge uses updateApplicationContext + // (WCSession re-delivers the latest state on reconnect), so a transient + // WCSession failure self-heals on the next push — and the day-in-fingerprint + // above guarantees a push at least once per day. + _lastPushFingerprint = fp; } catch (_) { /* widgets unavailable / not configured yet — ignore */ } @@ -189,6 +219,8 @@ class WidgetService { static Future clear() async { try { await init(); + // The change gate must not swallow the first push after a wipe. + _lastPushFingerprint = null; await HomeWidget.saveWidgetData('has_data', false); for (final k in const [ 'readiness', diff --git a/pubspec.yaml b/pubspec.yaml index ee5192f7..1a4cc6f2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -266,6 +266,10 @@ dependencies: # `IconData` became a final class, and PhosphorIconData extends it — which is # what was failing 33 test files on main before this branch. + # Structural deep-equality (series_codec's verifyLossless) — was transitive + # via flutter itself; declared directly since lib/ code now imports it. + collection: ^1.18.0 + # Injectable clock (RouteTracker's stall watchdog) — was a transitive dep via # fake_async/flutter_test; declared directly since lib/ code now imports it. # `clock.now()` instead of raw DateTime.now() lets fakeAsync tests control diff --git a/test/app_state_regressions_test.dart b/test/app_state_regressions_test.dart index 9fe95451..e17dd6ce 100644 --- a/test/app_state_regressions_test.dart +++ b/test/app_state_regressions_test.dart @@ -269,7 +269,7 @@ void main() { addTo(app.screenRequest.addListener); addTo(app.insightsRevision.addListener); addTo(app.gestureSettings.addListener); - // NotificationRelay holds a WidgetsBindingObserver, a 120 s + // NotificationRelay holds a WidgetsBindingObserver, a 15-min heal // Timer.periodic and a StreamSubscription — its observer accumulated on // the binding across every hot restart. addTo(app.notificationRelay.addListener); diff --git a/test/route_tracker_test.dart b/test/route_tracker_test.dart index faaa3fb1..56a117c5 100644 --- a/test/route_tracker_test.dart +++ b/test/route_tracker_test.dart @@ -117,6 +117,9 @@ void main() { sink: (_) async {}, batchSize: 100, zoneNow: () => 3, + // Fixes land within one wall-clock second here — disable the ~1/s path + // throttle so per-fix vertices can be asserted. + pathEmitEvery: Duration.zero, ); t.start(ctrl.stream); @@ -143,6 +146,7 @@ void main() { batchSize: 100, maxJumpM: 200, rejectStreakLimit: 3, + pathEmitEvery: Duration.zero, // per-fix path assertions below ); t.start(ctrl.stream); @@ -176,7 +180,12 @@ void main() { test('speed-based allowance: a far fix after a LONG gap is plausible travel ' '(distance counted, no segment break)', () async { final ctrl = StreamController(); - final t = RouteTracker(sink: (_) async {}, batchSize: 100, maxJumpM: 200); + final t = RouteTracker( + sink: (_) async {}, + batchSize: 100, + maxJumpM: 200, + pathEmitEvery: Duration.zero, // per-fix path assertions below + ); t.start(ctrl.stream); ctrl.add(_fix(0)); @@ -415,6 +424,7 @@ void main() { sink: (_) async {}, batchSize: 100, minMovementM: 5, + pathEmitEvery: Duration.zero, // per-fix path assertions below ); t.start(ctrl.stream); @@ -444,7 +454,12 @@ void main() { // movement starts contributes exactly ONE vertex (the anchor), not one // per fix. final ctrl = StreamController(); - final t = RouteTracker(sink: (_) async {}, batchSize: 100, minMovementM: 5); + final t = RouteTracker( + sink: (_) async {}, + batchSize: 100, + minMovementM: 5, + pathEmitEvery: Duration.zero, // per-fix path assertions below + ); t.start(ctrl.stream); ctrl.add(_fix(0, stepMeters: 0)); @@ -467,6 +482,42 @@ void main() { await ctrl.close(); }); + test('default 1s throttle coalesces path emissions; stop() flushes the tail', + () { + fakeAsync((async) { + final ctrl = StreamController(); + // Production default pathEmitEvery (1s) — the throttle under test. + final t = RouteTracker(sink: (_) async {}, batchSize: 100); + t.start(ctrl.stream); + // Move the fake wall clock well past the 0 sentinel so the first accepted + // fix crosses the throttle rather than depending on the epoch base. + async.elapse(const Duration(seconds: 2)); + + ctrl.add(_fix(0)); + async.flushMicrotasks(); + expect(t.path.value.length, 1); // first accepted fix always emits + + ctrl.add(_fix(1)); // same wall-second → throttled, not emitted + async.flushMicrotasks(); + expect(t.path.value.length, 1); + + async.elapse(const Duration(seconds: 1)); + ctrl.add(_fix(2)); // throttle window passed → emits all vertices so far + async.flushMicrotasks(); + expect(t.path.value.length, 3); + + ctrl.add(_fix(3)); // throttled again + async.flushMicrotasks(); + expect(t.path.value.length, 3); + + unawaited(t.stop()); // stop() force-emits the suppressed tail vertex + async.flushMicrotasks(); + expect(t.path.value.length, 4); + + unawaited(ctrl.close()); + }); + }); + test('stalled never trips for a session shorter than stallAfter', () { fakeAsync((async) { final ctrl = StreamController();