From e6106610a48d0b1f32e3b3e13186c346e6130383 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Mon, 2 Nov 2026 04:43:49 +0530 Subject: [PATCH 01/21] ios: a refused key on notify now counts as a bond refusal ios bonds implicitly, so a key the band no longer accepts only shows up as an att 5/15 on the first notify registration. we just failed the connect and looped forever with no re-pair guide. count it like a refused bond, and let a command reply end the refusal streak since ios never runs createBond. --- lib/ble/ble_engine.dart | 59 +++++++++++++++----------- lib/ble/ble_state.dart | 8 ++++ test/ble_state_test.dart | 9 ++++ test/gen5_bootstrap_official_test.dart | 39 ++++++++++++++++- 4 files changed, 89 insertions(+), 26 deletions(-) diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 58afc6e30..315ac8538 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -2987,18 +2987,7 @@ class BleEngine { _log('BOND FAILED: $e — encrypted commands will be silently dropped ' 'by the band. Remove the bond in system Bluetooth settings and ' 're-pair.'); - state.needsRepairGuide = true; - state.bondRefusals++; - // After a run of consecutive refusals, stop the auto-reconnect loop - // (it would otherwise pin the radio + drain the battery on a band that - // will never accept the bond) and surface the re-pair guide. A manual - // user connect still runs createBond, so a successful re-pair recovers. - if (_bondGiveUp.bondRefused()) { - state.autoReconnectPaused = true; - _log('[RECONNECT] bond-refusal give-up (${_bondGiveUp.consecutive}) ' - '— pausing auto-reconnect; re-pair required.'); - } - onState(state); + _recordBondRefusal(); } } @@ -3068,6 +3057,9 @@ class BleEngine { return await _finishConnect(session); } catch (e) { _log('connect setup failed: $e'); + if (e is FlutterBluePlusException && isLinkAuthRefusal(e.code)) { + _recordBondRefusal(); + } await _failConnect(); return false; } @@ -3221,6 +3213,9 @@ class BleEngine { return ok; } catch (e) { _log('connect setup failed: $e'); + if (e is FlutterBluePlusException && isLinkAuthRefusal(e.code)) { + _recordBondRefusal(); + } await _failConnect(); return false; } @@ -3492,19 +3487,7 @@ class BleEngine { _log('BOND FAILED: $e — bootstrap stops here (no subscriptions, no ' 'HELLO, no READY). Remove the bond in system Bluetooth settings ' 'and re-pair.'); - state.needsRepairGuide = true; - state.bondRefusals++; - // After a run of consecutive refusals, stop the auto-reconnect loop - // (it would otherwise pin the radio + drain the battery on a band - // that will never accept the bond) and surface the re-pair guide. A - // manual user connect still runs the bond, so a successful re-pair - // recovers. - if (_bondGiveUp.bondRefused()) { - state.autoReconnectPaused = true; - _log('[RECONNECT] bond-refusal give-up (${_bondGiveUp.consecutive}) ' - '— pausing auto-reconnect; re-pair required.'); - } - onState(state); + _recordBondRefusal(); await _failConnect(); return _Gen5ConnectOutcome.failed; } @@ -3544,6 +3527,9 @@ class BleEngine { } catch (e) { _log('[BOOT gen5] required notification registration failed ' '($role): $e — connection failed.'); + if (e is FlutterBluePlusException && isLinkAuthRefusal(e.code)) { + _recordBondRefusal(); + } await _failConnect(); return false; } @@ -3573,6 +3559,9 @@ class BleEngine { : _Gen5ConnectOutcome.failed; } catch (e) { _log('connect setup failed: $e'); + if (e is FlutterBluePlusException && isLinkAuthRefusal(e.code)) { + _recordBondRefusal(); + } await _failConnect(); return _Gen5ConnectOutcome.failed; } @@ -4790,6 +4779,22 @@ class BleEngine { _feedReconnectDetectors(timedOut: timedOut); } + /// One refusal of the phone's encryption key: surface the re-pair guide, + /// count it, and after a run of consecutive refusals pause auto-reconnect + /// (it would otherwise pin the radio and drain both batteries on a band that + /// will never accept the key). A manual connect still runs, so a successful + /// re-pair recovers. + void _recordBondRefusal() { + state.needsRepairGuide = true; + state.bondRefusals++; + if (_bondGiveUp.bondRefused()) { + state.autoReconnectPaused = true; + _log('[RECONNECT] bond-refusal give-up (${_bondGiveUp.consecutive}) ' + '— pausing auto-reconnect; re-pair required.'); + } + onState(state); + } + /// A command reply is the direct contradiction of the re-pair guide: the band /// decrypted one of our commands and answered it, so encryption is not /// blocking traffic and there is nothing to re-pair. @@ -4811,6 +4816,10 @@ class BleEngine { if (!state.needsRepairGuide) return; state.needsRepairGuide = false; _postBondLoop.reset(); + // On iOS nothing else ends a refusal streak (there is no explicit bond + // step to succeed), so the reply does it here. + _bondGiveUp.bondSucceeded(); + state.bondRefusals = 0; onState(state); _log('[RECONNECT] band answered a command — clearing the re-pair guide.'); } diff --git a/lib/ble/ble_state.dart b/lib/ble/ble_state.dart index 160be589e..c6d230284 100644 --- a/lib/ble/ble_state.dart +++ b/lib/ble/ble_state.dart @@ -226,6 +226,14 @@ class BandStatus { condition != BandCondition.disconnected; } +/// True when a GATT error [code] means the band will not accept the phone's +/// encryption key: insufficient authentication (5), authorization (8) or +/// encryption (15), or an auth failure (137). On iOS there is no explicit bond +/// step, so a stale or rejected key only ever shows up here, on the first +/// notification registration. +bool isLinkAuthRefusal(int? code) => + code == 5 || code == 8 || code == 15 || code == 137; + /// Fold the phone-level blocker and the band's own diagnostic flags into one /// state. Pure; [connection] is `DeviceState.connection`. /// diff --git a/test/ble_state_test.dart b/test/ble_state_test.dart index 656b7db0c..f4f37c6e5 100644 --- a/test/ble_state_test.dart +++ b/test/ble_state_test.dart @@ -952,4 +952,13 @@ void main() { expect(w.isLive(101), isFalse); }); }); + + test('isLinkAuthRefusal: only the key-refusal GATT codes', () { + for (final c in [5, 8, 15, 137]) { + expect(isLinkAuthRefusal(c), isTrue, reason: '$c'); + } + for (final c in [null, 0, 6, 14, 133]) { + expect(isLinkAuthRefusal(c), isFalse, reason: '$c'); + } + }); } diff --git a/test/gen5_bootstrap_official_test.dart b/test/gen5_bootstrap_official_test.dart index f43d8431c..9175231ae 100644 --- a/test/gen5_bootstrap_official_test.dart +++ b/test/gen5_bootstrap_official_test.dart @@ -26,6 +26,8 @@ import 'dart:async'; import 'dart:typed_data'; import 'package:fake_async/fake_async.dart'; +import 'package:flutter_blue_plus/flutter_blue_plus.dart' + show ErrorPlatform, FlutterBluePlusException; import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/ble/adapters/_registry.dart'; import 'package:openstrap_edge/ble/ble_engine.dart'; @@ -204,6 +206,8 @@ class _Ops implements GattBootstrapOps { final bool discoveryFails; final bool memfaultPresent; final Set failSubscribe; + /// What a failed registration throws; a plain exception by default. + final Object? subscribeError; /// Runs right after a registration succeeds — traffic the band (or another /// client's transfer) delivers the moment notifications are on. final void Function(String role)? afterSubscribe; @@ -217,6 +221,7 @@ class _Ops implements GattBootstrapOps { this.discoveryFails = false, this.memfaultPresent = true, this.failSubscribe = const {}, + this.subscribeError, this.afterSubscribe, }); @@ -258,7 +263,9 @@ class _Ops implements GattBootstrapOps { @override Future subscribe(String role) async { rig.trace.add('sub:$role'); - if (failSubscribe.contains(role)) throw Exception('CCC write failed'); + if (failSubscribe.contains(role)) { + throw subscribeError ?? Exception('CCC write failed'); + } afterSubscribe?.call(role); } @@ -1269,6 +1276,36 @@ void main() { }); }); + test('a registration refused for the phone\'s key is a bond refusal', () { + // iOS bonds implicitly, so a stale key only surfaces as an ATT error on + // the first registration. Without this the user saw an endless + // connect loop and no re-pair guide. + fakeAsync((async) { + final rig = _Rig()..answerAll(); + final ops = _Ops( + rig, + failSubscribe: {'cmd_from'}, + subscribeError: FlutterBluePlusException(ErrorPlatform.apple, + 'setNotifyValue', 15, 'Encryption is insufficient.'), + ); + expect(_run(rig, async, ops: ops), isFalse); + expect(rig.engine.state.needsRepairGuide, isTrue); + expect(rig.engine.state.bondRefusals, 1); + }); + }); + + test('a registration failing for any other reason is not', () { + fakeAsync((async) { + final rig = _Rig()..answerAll(); + expect( + _run(rig, async, ops: _Ops(rig, failSubscribe: {'cmd_from'})), + isFalse, + ); + expect(rig.engine.state.needsRepairGuide, isFalse); + expect(rig.engine.state.bondRefusals, 0); + }); + }); + test('an absent optional Memfault characteristic never blocks READY', () { fakeAsync((async) { final rig = _Rig()..answerAll(); From 557d7c9ad8f3d1e408eb91dd3eae1cfae07329b6 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Mon, 2 Nov 2026 04:42:15 +0530 Subject: [PATCH 02/21] stop the sync burst re-asking a band that never starts history a task that hit its terminal still held the offload claim while its abort went out, so the burst read it as running and sent another request, 20 per burst. also stop when batches carry no records behind a stuck frontier (off-wrist strap), that isn't a stale block to drain through. --- lib/ble/ble_engine.dart | 11 +++++++++-- lib/state/sync_controller.dart | 13 ++++++++++++- test/app_state_sync_triggers_test.dart | 23 +++++++++++++++++++++++ test/support/app_state_sync_harness.dart | 3 +++ 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 315ac8538..62f8e7858 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -495,7 +495,12 @@ class SyncReport { final int records; final int batches; final bool complete; - SyncReport(this.records, this.batches, this.complete); + + /// The awaited task itself hit its terminal (abort boundary) — it is over, + /// even if the engine has not released the offload claim yet (the release + /// runs after the abort write). A caller must not wait on it again. + final bool ended; + SyncReport(this.records, this.batches, this.complete, {this.ended = false}); } enum _HpsTerminalKind { @@ -10090,7 +10095,9 @@ class DrainController { ' — this offload ended ${complete ? 'complete (a new task claimed ' 'immediately after HISTORY_COMPLETE)' : 'without completing (abort ' 'boundary)'}.'); - done.complete(report); + done.complete(complete + ? report + : SyncReport(report.records, report.batches, false, ended: true)); return; } if (!isLinkUp()) _linkDown = true; diff --git a/lib/state/sync_controller.dart b/lib/state/sync_controller.dart index 0993b6a5d..f4339d0c3 100644 --- a/lib/state/sync_controller.dart +++ b/lib/state/sync_controller.dart @@ -419,7 +419,11 @@ class SyncController { // it says nothing about the drain. Wait on the same task again — only // its own terminals end it, and the stop rules below judge a task that // has. - if (!report.complete && engine.offloadActive) { + // `ended` first: the claim is released only after the terminal's abort + // write, so offloadActive still reads true for a task that is over — + // a band that accepts every request and never starts one was asked 20 + // times per burst, every burst. + if (!report.complete && !report.ended && engine.offloadActive) { _log('Backfill continuation ${i + 1}/$maxSessions — the history task ' 'is still running; waiting on it again.'); continue; @@ -441,6 +445,13 @@ class SyncController { ); break; } + if (!frontierAdvanced && report.records == 0) { + // Batches with no records in them (a strap writing no 1 Hz data, e.g. + // off-wrist) are not a stale block: there is nothing to drain through. + _log('Backfill stop — batches carried no records; frontier stuck ' + '(strap newest=$strapNewest, frontier=$frontierAfter).'); + break; + } if (!frontierAdvanced) { // Frontier stuck but the strap says it HAS newer data. This happens when // a stretch of flash carries STALE/duplicate timestamps — e.g. the band diff --git a/test/app_state_sync_triggers_test.dart b/test/app_state_sync_triggers_test.dart index 63c7909a3..7f9738c3e 100644 --- a/test/app_state_sync_triggers_test.dart +++ b/test/app_state_sync_triggers_test.dart @@ -262,6 +262,29 @@ void main() { await rig.quiesce(); })); + test('a task that hit its terminal is not waited on again, even while ' + 'the claim is still held', syncCase((rig, timers) async { + await _open(rig); + rig.engine.offloading = true; + rig.engine.syncScript.addAll( + [for (var i = 0; i < 5; i++) SyncReport(0, 0, false, ended: true)]); + await rig.app.forceResync(); + expect(rig.engine.count('runSync'), 1); + expect(_logged(rig, 'Backfill stop — no batch ACKs; trim did not advance.'), isTrue); + })); + + test('batches with no records behind a stuck frontier end the loop', + syncCase((rig, timers) async { + await _open(rig); + rig.engine.newestTs = 1800000000; + await LocalDb.setCursor('rec_ts_hw', '1700000000'); + rig.engine.syncScript.addAll([for (var i = 0; i < 5; i++) SyncReport(0, 1, true)]); + await rig.app.forceResync(); + expect(rig.engine.count('runSync'), 1); + expect(_logged(rig, 'Backfill stop — batches carried no records'), isTrue); + await rig.quiesce(); + })); + test('a terminal Stuck connection runs no session at all', syncCase((rig, timers) async { await _open(rig); diff --git a/test/support/app_state_sync_harness.dart b/test/support/app_state_sync_harness.dart index bc24c9bff..f4790c20a 100644 --- a/test/support/app_state_sync_harness.dart +++ b/test/support/app_state_sync_harness.dart @@ -211,6 +211,9 @@ class SyncFakeEngine extends BleEngine { bool get liveEnabled => liveArmed; @override bool get historyStuckThisSession => stuck; + bool offloading = false; + @override + bool get offloadActive => offloading; @override int? get strapHistoryNewestTs => newestTs; @override From faa474198f418f8bd31b5f87806b80fe80e86b28 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Mon, 2 Nov 2026 04:42:19 +0530 Subject: [PATCH 03/21] gen4: hold the imu arm until the history drain ends arming r10/imu mid-burst silences the strap, the idle watchdog aborts after 60s and the drain starts over. hr still arms; the bundle goes out when the offload ends. --- lib/ble/ble_engine.dart | 3 +++ lib/ble/ble_state.dart | 10 +++++++++- test/live_stream_policy_test.dart | 17 +++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 62f8e7858..aaa0be339 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -8878,6 +8878,7 @@ class BleEngine { imuFresh: _imuFresh && !gen5, imuDirty: _imuDirty, hrDirty: _hrDirty, + holdImuOn: _offloadActive && !gen5, ); final generation = _linkGeneration; if (step == null) { @@ -9239,6 +9240,8 @@ class BleEngine { // An offload is the one thing that genuinely needs the fast interval; as // soon as it ends the link steps back down (issue #200). unawaited(_applyLinkPriority()); + // A gen4 IMU arm held for the drain ([nextLiveStreamStep]) goes out now. + if (!active) unawaited(_reconcileLive()); // This is a CALLER-SUPPLIED hook (UI state plumbing), called synchronously // from _enqueueOffloadFrame BEFORE it kicks off _drainOffloadFrames — a // throw here must never abort the frame from reaching the drain queue. diff --git a/lib/ble/ble_state.dart b/lib/ble/ble_state.dart index c6d230284..c5de61661 100644 --- a/lib/ble/ble_state.dart +++ b/lib/ble/ble_state.dart @@ -2167,16 +2167,24 @@ enum LiveStreamStep { /// gen4 bundle can be half-applied). Dirty ALWAYS replays the newest desired /// direction before convergence, even when nothing is desired: a dirty OFF /// must be cleaned up, unlike a fresh one. +/// +/// [holdImuOn] — a history offload is running on gen4. Arming the bundle +/// mid-burst silences the strap: the burst stops, the 60 s idle watchdog +/// aborts it and the drain restarts. Only the ON is held; the caller +/// re-runs the reconcile when the offload ends. LiveStreamStep? nextLiveStreamStep({ required LiveStreamIntent applied, required LiveStreamIntent desired, bool imuFresh = false, bool imuDirty = false, bool hrDirty = false, + bool holdImuOn = false, }) { final replayImu = imuDirty || (imuFresh && desired.any); if (desired.hr && (!applied.hr || hrDirty)) return LiveStreamStep.hrOn; - if (desired.imu && (!applied.imu || replayImu)) return LiveStreamStep.imuOn; + if (desired.imu && (!applied.imu || replayImu) && !holdImuOn) { + return LiveStreamStep.imuOn; + } if (!desired.imu && (applied.imu || replayImu)) return LiveStreamStep.imuOff; if (!desired.hr && (applied.hr || hrDirty)) return LiveStreamStep.hrOff; return null; diff --git a/test/live_stream_policy_test.dart b/test/live_stream_policy_test.dart index b593a4892..6f1c3ff16 100644 --- a/test/live_stream_policy_test.dart +++ b/test/live_stream_policy_test.dart @@ -229,4 +229,21 @@ void main() { ); }); }); + + test('a held IMU ON (gen4 drain running) still lets HR through, and the ' + 'bundle goes out once the hold lifts', () { + expect( + nextLiveStreamStep(applied: _off, desired: _both, holdImuOn: true), + LiveStreamStep.hrOn, + ); + expect( + nextLiveStreamStep( + applied: _hrOnly, desired: _both, imuFresh: true, holdImuOn: true), + isNull, + ); + expect( + nextLiveStreamStep(applied: _hrOnly, desired: _both, imuFresh: true), + LiveStreamStep.imuOn, + ); + }); } From 26896ef0284708e9c97f836f588d2764e81133d7 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Mon, 2 Nov 2026 04:34:47 +0530 Subject: [PATCH 04/21] surface a band that takes the sync request and sends nothing stuck-strap only ran after HISTORY_COMPLETE and skipped a null frontier, so a band that never answers SEND_HISTORICAL_DATA (or a fresh pair with nothing banked yet) looped forever showing connected. check it on the no-start terminal too and treat nothing-banked as a frozen frontier. --- lib/ble/ble_engine.dart | 33 +++++++++++++++++++++------------ lib/sync/sync_policy.dart | 6 +++++- test/sync_policy_test.dart | 12 ++++++++++++ 3 files changed, 38 insertions(+), 13 deletions(-) diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index aaa0be339..652b5ad36 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -6463,7 +6463,9 @@ class BleEngine { kind: _HpsTerminalKind.timeout, reason: 'no_history_start', ); - if (!retry || _sessionIsStale(session)) return; + if (_sessionIsStale(session)) return; + await _observeStuckStrap(); + if (!retry) return; _armHistoricalRetry(session, reason: 'no_history_start'); } @@ -7724,6 +7726,23 @@ class BleEngine { } // ── post-offload policy: empty-sync, stuck-strap, auto-continue ────────────── + /// Stuck-strap: frontier frozen ≥10 min while the strap is >5 min ahead. + /// Elapsed time on the monotonic clock: a forward wall step (a phone fixing + /// its clock after a dead-battery reboot) would otherwise read as ten + /// frozen minutes at once. Checked after a completed offload AND after a + /// request the band never answered: a band that accepts SEND_HISTORICAL_DATA + /// and then sends nothing never reaches HISTORY_COMPLETE, so checking only + /// there left it looping silently with the UI saying "connected". + Future _observeStuckStrap() async { + if (_stuckStrap.observe( + _sessionNewestUnix, _recordGate.frontierTs, _monotonicSecs())) { + state.strapNeedsReboot = true; + onState(state); + _log('[SYNC] stuck-strap tripped — defensive SET_CLOCK.'); + await setClock(); + } + } + /// Reached from the HISTORY_COMPLETE branch only — a drain that ends any /// other way (link down, idle watchdog) records its own terminal and never /// gets here. This used to take a `complete` flag that its single call site @@ -7764,17 +7783,7 @@ class BleEngine { _log('[SYNC] empty-sync tripped — strap RTC likely lost.'); } - // Stuck-strap: frontier frozen ≥10 min while the strap is >5 min ahead. - // Elapsed time on the monotonic clock: a forward wall step (a phone fixing - // its clock after a dead-battery reboot) would otherwise read as ten - // frozen minutes at once. - if (_stuckStrap.observe( - _sessionNewestUnix, _recordGate.frontierTs, _monotonicSecs())) { - state.strapNeedsReboot = true; - onState(state); - _log('[SYNC] stuck-strap tripped — defensive SET_CLOCK.'); - await setClock(); - } + await _observeStuckStrap(); // Auto-continue: re-kick immediately (bypassing the 15-min floor) if the strap // still has real backlog and the cursor advanced — but cap per connection. diff --git a/lib/sync/sync_policy.dart b/lib/sync/sync_policy.dart index 9f8fa44dc..e51865be3 100644 --- a/lib/sync/sync_policy.dart +++ b/lib/sync/sync_policy.dart @@ -937,7 +937,11 @@ class StuckStrapDetector { double? _lastAdvanceWall; bool observe(int? strapNewestTs, int? ourFrontierTs, double now) { - if (strapNewestTs == null || ourFrontierTs == null) return false; + if (strapNewestTs == null) return false; + // Nothing banked yet (fresh pair) is a frontier that has never advanced, + // not an unknown one: a band that holds days of data and sends none of it + // is the stuck case, and it used to stay invisible forever. + ourFrontierTs ??= 0; if (_lastFrontierTs == null) { _lastFrontierTs = ourFrontierTs; _lastAdvanceWall = now; diff --git a/test/sync_policy_test.dart b/test/sync_policy_test.dart index 99d36957d..dda11dc2c 100644 --- a/test/sync_policy_test.dart +++ b/test/sync_policy_test.dart @@ -633,6 +633,18 @@ void main() { expect(d.observe(5000, 2000, 600), isFalse); // advanced → re-seed expect(d.observe(5000, 3000, 1200), isFalse); }); + test('nothing ever banked (fresh pair) still trips — the band holds data ' + 'and never sends it', () { + final d = StuckStrapDetector(); + expect(d.observe(5000, null, 0), isFalse); // seed + expect(d.observe(5000, null, 540), isFalse); + expect(d.observe(5000, null, 600), isTrue); + }); + test('no band range yet is never stuck', () { + final d = StuckStrapDetector(); + d.observe(null, null, 0); + expect(d.observe(null, null, 6000), isFalse); + }); test('caught up (within the behind-gap) is not stuck', () { final d = StuckStrapDetector(); d.observe(1200, 1000, 0); From 55b2d10a329d318eeb58daf423af533834237c40 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Mon, 2 Nov 2026 04:36:33 +0530 Subject: [PATCH 05/21] steps: gen5 counter wins when it counted more than live/phone spans --- lib/compute/derivation_engine.dart | 24 +++++++++++++++++------- test/step_source_ladder_test.dart | 11 +++++++++++ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 2610e7726..de34c0860 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -1848,7 +1848,11 @@ import 'vendor_sleep.dart'; // temperature rhythm and alertness forecast use local time. Steps: the live // pedometer counts from stride periodicity. Workouts: a 5-15 min rest with HR // still raised stays inside one suggested workout. -const int kAlgoVersion = 119; +// 119 → 120 (gen5/MG steps): the on-chip counter answers whenever it counted +// more than the windowed live/phone sources, instead of only when they counted +// nothing, so a short tracked walk no longer hides the day's chip total. +// gen4 (no counter) is unchanged. Edge-only. +const int kAlgoVersion = 120; /// The sibling SHAs this version was derived against, asserted against /// pubspec.yaml in test/db_serve_version_and_reads_test.dart. /// @@ -8658,17 +8662,23 @@ class DerivationEngine { // per record, so the day screen gets hourly `spans` off them; but adding // it to the windowed spans would double-count every walk the other two // already counted. So it stays a - // WHOLE-DAY FALLBACK — used only when no span source covered the day at - // all. That inversion is deliberate: whole-day precedence for this counter - // is exactly the bug being fixed (622 steps published over the phone's - // 18,856 on a day the strap synced for part of). + // WHOLE-DAY ALTERNATIVE: whichever of the two whole-day answers counted + // MORE wins. Unconditional counter precedence is the old bug (622 steps + // published over the phone's 18,856 on a day the strap synced for part + // of); "counter only when nothing else counted" was the next one: a + // 53-step manual walk hid the chip's 6,164 for the whole day. Neither + // answer adds to the other, so nothing is double-counted. + // ponytail: max undercounts a day split between disjoint sources; the + // upgrade is windowed total + counter ticks outside those windows + // (`counterTicksPerWindow`). final strap = liveStepsFromStrap.clamp(0, liveStepsReal); final phone = liveStepsReal - strap; final rawBand = counter?.total; final calibratedBand = rawBand == null ? null : applyStepCalibration(rawBand, profile); - final useBand = - liveStepsReal <= 0 && calibratedBand != null && calibratedBand > 0; + final useBand = calibratedBand != null && + calibratedBand > 0 && + calibratedBand > liveStepsReal; final steps = useBand ? calibratedBand : liveStepsReal; final calibrated = useBand && profile != null && profile.isCalibrated; final haveRealSteps = steps > 0; diff --git a/test/step_source_ladder_test.dart b/test/step_source_ladder_test.dart index c81d3b99a..2ddc327d0 100644 --- a/test/step_source_ladder_test.dart +++ b/test/step_source_ladder_test.dart @@ -298,6 +298,17 @@ void main() { expect(spans.last['end_ts'], gen5.tsSec.last); }); + test('a short tracked walk does not hide a bigger on-chip day', () { + final (steps, scalars) = _derive( + gen5, + liveStepsReal: 53, + liveStepsFromStrap: 53, + ); + expect(scalars['steps'], 622.0); + expect(steps['source'], 'strap_counter'); + expect(steps['real_measured'], 53); + }); + test('a learned factor scales the counter and is disclosed only when ' 'the counter is the answer', () { const bicep = StepCalibrationProfile( From f594596c38f02f59838b9374a7ace712e9e30690 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Mon, 2 Nov 2026 04:36:33 +0530 Subject: [PATCH 06/21] freeze goldens: algo_version 120 stamp only --- test/fixtures/whoop_freeze/gen4_minus0400.json | 18 +++++++++--------- test/fixtures/whoop_freeze/gen4_plus0530.json | 18 +++++++++--------- test/fixtures/whoop_freeze/gen4_utc.json | 18 +++++++++--------- test/fixtures/whoop_freeze/gen5_minus0400.json | 18 +++++++++--------- test/fixtures/whoop_freeze/gen5_plus0530.json | 18 +++++++++--------- test/fixtures/whoop_freeze/gen5_utc.json | 18 +++++++++--------- 6 files changed, 54 insertions(+), 54 deletions(-) diff --git a/test/fixtures/whoop_freeze/gen4_minus0400.json b/test/fixtures/whoop_freeze/gen4_minus0400.json index 0204b2d6b..aa0ad6829 100644 --- a/test/fixtures/whoop_freeze/gen4_minus0400.json +++ b/test/fixtures/whoop_freeze/gen4_minus0400.json @@ -2,7 +2,7 @@ "day_result": [ { "day_id": "2026-07-13", - "algo_version": 119, + "algo_version": 120, "payload_json": { "date": "2026-07-13", "day_confidence": 0.6, @@ -24902,7 +24902,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 119, + "algo_version": 120, "payload_json": { "date": "2026-07-14", "day_confidence": 0.6, @@ -49595,7 +49595,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 119, + "algo_version": 120, "payload_json": { "date": "2026-07-15", "day_confidence": 0.6, @@ -73355,7 +73355,7 @@ "metric_series_version": [ { "date": "2026-07-13", - "algo_version": 119, + "algo_version": 120, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73363,7 +73363,7 @@ }, { "date": "2026-07-14", - "algo_version": 119, + "algo_version": 120, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73371,7 +73371,7 @@ }, { "date": "2026-07-15", - "algo_version": 119, + "algo_version": 120, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73381,7 +73381,7 @@ "wake_day_features": [ { "day_id": "2026-07-13", - "algo_version": 119, + "algo_version": 120, "payload_json": { "day_id": "2026-07-13", "active_min": 90, @@ -74589,7 +74589,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 119, + "algo_version": 120, "payload_json": { "day_id": "2026-07-14", "active_min": 240, @@ -75797,7 +75797,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 119, + "algo_version": 120, "payload_json": { "day_id": "2026-07-15", "active_min": 40, diff --git a/test/fixtures/whoop_freeze/gen4_plus0530.json b/test/fixtures/whoop_freeze/gen4_plus0530.json index 36585524a..979b1196d 100644 --- a/test/fixtures/whoop_freeze/gen4_plus0530.json +++ b/test/fixtures/whoop_freeze/gen4_plus0530.json @@ -2,7 +2,7 @@ "day_result": [ { "day_id": "2026-07-13", - "algo_version": 119, + "algo_version": 120, "payload_json": { "date": "2026-07-13", "day_confidence": 0.6, @@ -24902,7 +24902,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 119, + "algo_version": 120, "payload_json": { "date": "2026-07-14", "day_confidence": 0.6, @@ -49595,7 +49595,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 119, + "algo_version": 120, "payload_json": { "date": "2026-07-15", "day_confidence": 0.6, @@ -73355,7 +73355,7 @@ "metric_series_version": [ { "date": "2026-07-13", - "algo_version": 119, + "algo_version": 120, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73363,7 +73363,7 @@ }, { "date": "2026-07-14", - "algo_version": 119, + "algo_version": 120, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73371,7 +73371,7 @@ }, { "date": "2026-07-15", - "algo_version": 119, + "algo_version": 120, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73381,7 +73381,7 @@ "wake_day_features": [ { "day_id": "2026-07-13", - "algo_version": 119, + "algo_version": 120, "payload_json": { "day_id": "2026-07-13", "active_min": 90, @@ -74589,7 +74589,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 119, + "algo_version": 120, "payload_json": { "day_id": "2026-07-14", "active_min": 240, @@ -75797,7 +75797,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 119, + "algo_version": 120, "payload_json": { "day_id": "2026-07-15", "active_min": 40, diff --git a/test/fixtures/whoop_freeze/gen4_utc.json b/test/fixtures/whoop_freeze/gen4_utc.json index edfbb77fe..6dccd1590 100644 --- a/test/fixtures/whoop_freeze/gen4_utc.json +++ b/test/fixtures/whoop_freeze/gen4_utc.json @@ -2,7 +2,7 @@ "day_result": [ { "day_id": "2026-07-13", - "algo_version": 119, + "algo_version": 120, "payload_json": { "date": "2026-07-13", "day_confidence": 0.6, @@ -24902,7 +24902,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 119, + "algo_version": 120, "payload_json": { "date": "2026-07-14", "day_confidence": 0.6, @@ -49595,7 +49595,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 119, + "algo_version": 120, "payload_json": { "date": "2026-07-15", "day_confidence": 0.6, @@ -73355,7 +73355,7 @@ "metric_series_version": [ { "date": "2026-07-13", - "algo_version": 119, + "algo_version": 120, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73363,7 +73363,7 @@ }, { "date": "2026-07-14", - "algo_version": 119, + "algo_version": 120, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73371,7 +73371,7 @@ }, { "date": "2026-07-15", - "algo_version": 119, + "algo_version": 120, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73381,7 +73381,7 @@ "wake_day_features": [ { "day_id": "2026-07-13", - "algo_version": 119, + "algo_version": 120, "payload_json": { "day_id": "2026-07-13", "active_min": 90, @@ -74589,7 +74589,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 119, + "algo_version": 120, "payload_json": { "day_id": "2026-07-14", "active_min": 239, @@ -75797,7 +75797,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 119, + "algo_version": 120, "payload_json": { "day_id": "2026-07-15", "active_min": 40, diff --git a/test/fixtures/whoop_freeze/gen5_minus0400.json b/test/fixtures/whoop_freeze/gen5_minus0400.json index f598c655d..bc362c3b6 100644 --- a/test/fixtures/whoop_freeze/gen5_minus0400.json +++ b/test/fixtures/whoop_freeze/gen5_minus0400.json @@ -2,7 +2,7 @@ "day_result": [ { "day_id": "2026-07-13", - "algo_version": 119, + "algo_version": 120, "payload_json": { "date": "2026-07-13", "day_confidence": 0.6, @@ -25080,7 +25080,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 119, + "algo_version": 120, "payload_json": { "date": "2026-07-14", "day_confidence": 0.6, @@ -49946,7 +49946,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 119, + "algo_version": 120, "payload_json": { "date": "2026-07-15", "day_confidence": 0.6, @@ -73868,7 +73868,7 @@ "metric_series_version": [ { "date": "2026-07-13", - "algo_version": 119, + "algo_version": 120, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73876,7 +73876,7 @@ }, { "date": "2026-07-14", - "algo_version": 119, + "algo_version": 120, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73884,7 +73884,7 @@ }, { "date": "2026-07-15", - "algo_version": 119, + "algo_version": 120, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73894,7 +73894,7 @@ "wake_day_features": [ { "day_id": "2026-07-13", - "algo_version": 119, + "algo_version": 120, "payload_json": { "day_id": "2026-07-13", "active_min": 90, @@ -75102,7 +75102,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 119, + "algo_version": 120, "payload_json": { "day_id": "2026-07-14", "active_min": 240, @@ -76310,7 +76310,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 119, + "algo_version": 120, "payload_json": { "day_id": "2026-07-15", "active_min": 40, diff --git a/test/fixtures/whoop_freeze/gen5_plus0530.json b/test/fixtures/whoop_freeze/gen5_plus0530.json index c6818f6d5..04467b01d 100644 --- a/test/fixtures/whoop_freeze/gen5_plus0530.json +++ b/test/fixtures/whoop_freeze/gen5_plus0530.json @@ -2,7 +2,7 @@ "day_result": [ { "day_id": "2026-07-13", - "algo_version": 119, + "algo_version": 120, "payload_json": { "date": "2026-07-13", "day_confidence": 0.6, @@ -25080,7 +25080,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 119, + "algo_version": 120, "payload_json": { "date": "2026-07-14", "day_confidence": 0.6, @@ -49946,7 +49946,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 119, + "algo_version": 120, "payload_json": { "date": "2026-07-15", "day_confidence": 0.6, @@ -73868,7 +73868,7 @@ "metric_series_version": [ { "date": "2026-07-13", - "algo_version": 119, + "algo_version": 120, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73876,7 +73876,7 @@ }, { "date": "2026-07-14", - "algo_version": 119, + "algo_version": 120, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73884,7 +73884,7 @@ }, { "date": "2026-07-15", - "algo_version": 119, + "algo_version": 120, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73894,7 +73894,7 @@ "wake_day_features": [ { "day_id": "2026-07-13", - "algo_version": 119, + "algo_version": 120, "payload_json": { "day_id": "2026-07-13", "active_min": 90, @@ -75102,7 +75102,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 119, + "algo_version": 120, "payload_json": { "day_id": "2026-07-14", "active_min": 240, @@ -76310,7 +76310,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 119, + "algo_version": 120, "payload_json": { "day_id": "2026-07-15", "active_min": 40, diff --git a/test/fixtures/whoop_freeze/gen5_utc.json b/test/fixtures/whoop_freeze/gen5_utc.json index f309a8e15..857d2cd9c 100644 --- a/test/fixtures/whoop_freeze/gen5_utc.json +++ b/test/fixtures/whoop_freeze/gen5_utc.json @@ -2,7 +2,7 @@ "day_result": [ { "day_id": "2026-07-13", - "algo_version": 119, + "algo_version": 120, "payload_json": { "date": "2026-07-13", "day_confidence": 0.6, @@ -25080,7 +25080,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 119, + "algo_version": 120, "payload_json": { "date": "2026-07-14", "day_confidence": 0.6, @@ -49946,7 +49946,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 119, + "algo_version": 120, "payload_json": { "date": "2026-07-15", "day_confidence": 0.6, @@ -73868,7 +73868,7 @@ "metric_series_version": [ { "date": "2026-07-13", - "algo_version": 119, + "algo_version": 120, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73876,7 +73876,7 @@ }, { "date": "2026-07-14", - "algo_version": 119, + "algo_version": 120, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73884,7 +73884,7 @@ }, { "date": "2026-07-15", - "algo_version": 119, + "algo_version": 120, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73894,7 +73894,7 @@ "wake_day_features": [ { "day_id": "2026-07-13", - "algo_version": 119, + "algo_version": 120, "payload_json": { "day_id": "2026-07-13", "active_min": 90, @@ -75102,7 +75102,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 119, + "algo_version": 120, "payload_json": { "day_id": "2026-07-14", "active_min": 239, @@ -76310,7 +76310,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 119, + "algo_version": 120, "payload_json": { "day_id": "2026-07-15", "active_min": 40, From af037165dbbf9f533be8bdd67e785edf62376996 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Mon, 2 Nov 2026 04:31:16 +0530 Subject: [PATCH 07/21] export strap counter steps to apple health / health connect --- lib/health/health_export.dart | 69 ++++++++++++++++++++--- test/health_export_energy_steps_test.dart | 18 ++++++ 2 files changed, 79 insertions(+), 8 deletions(-) diff --git a/lib/health/health_export.dart b/lib/health/health_export.dart index 4b1a6df92..59ab7e1f3 100644 --- a/lib/health/health_export.dart +++ b/lib/health/health_export.dart @@ -188,6 +188,35 @@ num? healthHrvExportValue(Map b, {required bool apple}) { return index is num ? index : null; } +/// The step samples to write for a day: the strap's on-chip counter spans, +/// and only when that counter is the day's answer (`source: strap_counter`). +/// Any day the phone counted gets nothing, because the phone already writes +/// its own steps to the store and ours would double them. +@visibleForTesting +List<({DateTime start, DateTime end, int steps})> healthStepSpans( + Map b, +) { + final steps = b['steps']; + if (steps is! Map || steps['source'] != 'strap_counter') return const []; + final spans = steps['spans']; + if (spans is! List) return const []; + return [ + for (final s in spans) + if (s is Map && + s['start_ts'] is int && + s['end_ts'] is int && + s['steps'] is int && + (s['steps'] as int) > 0 && + (s['end_ts'] as int) > (s['start_ts'] as int)) + ( + start: DateTime.fromMillisecondsSinceEpoch( + (s['start_ts'] as int) * 1000), + end: DateTime.fromMillisecondsSinceEpoch((s['end_ts'] as int) * 1000), + steps: s['steps'] as int, + ), + ]; +} + /// Cursor for the one-shot Apple Health sleep rewrite. Bump when the writer /// changes enough that nights already sitting in HealthKit should be replaced /// (plugin Core/in-bed misses, leftover 11pm fragments). Does not bump @@ -565,11 +594,9 @@ class HealthExporter { HealthDataType.HEART_RATE, HealthDataType.ACTIVE_ENERGY_BURNED, HealthDataType.BASAL_ENERGY_BURNED, - // STEPS is requested for DELETE SCOPE ONLY — nothing writes steps any - // more (see the block further down for why). We still need the write - // permission to purge the fabricated step samples earlier versions put - // into Apple Health / Health Connect, which is why WRITE_STEPS stays in - // the Android manifest. That purge is a ONE-SHOT migration and does not + // STEPS: only strap-counter days are written ([healthStepSpans]); the + // permission also covers the purge of the fabricated step samples + // earlier versions put into Apple Health / Health Connect. That purge is a ONE-SHOT migration and does not // belong in the per-day rewrite loop — see [_purgeLegacyStepsIfNeeded] // and [_rewriteTypes]. HealthDataType.STEPS, @@ -589,8 +616,8 @@ class HealthExporter { /// * `healthDeleteTypes` (platform-aware) drops sleep on both stores and /// HEART_RATE on Android, because the native sleep replace writers and /// the minute-HR batch own their own cleanup there. - /// * STEPS is then removed on top, because NOTHING writes steps any more. - /// Deleting a type we never write would run on every re-export of the + /// * STEPS is then removed on top: only strap-counter days write steps, + /// and [_exportDay] clears those itself. Deleting a type we don't write would run on every re-export of the /// not-yet-finalized tail forever, and — since a false `delete()` flips /// `success` — could permanently stall a day's export cursor. The /// historical fabricated samples are handled once by @@ -1411,7 +1438,33 @@ class HealthExporter { } } - // STEPS ARE DELIBERATELY NOT EXPORTED. + // Steps: only the strap's on-chip counter spans (see [healthStepSpans]). + // Delete-then-write over our own samples, and only on days we write, so a + // phone-counted day never runs a delete for a type nothing writes. + final stepSpans = healthStepSpans(b); + if (stepSpans.isNotEmpty) { + if (!await _deleteOwnSamples(HealthDataType.STEPS, dayStart, dayEnd)) { + success = false; + } else { + for (final s in stepSpans) { + try { + final wrote = await _health.writeHealthData( + value: s.steps.toDouble(), + type: HealthDataType.STEPS, + startTime: s.start, + endTime: s.end, + unit: HealthDataUnit.COUNT, + ); + if (!wrote) success = false; + } catch (e) { + debugPrint('[health] write steps: $e'); + success = false; + } + } + } + } + + // NO OTHER STEPS ARE EXPORTED. // // We used to write `scalars.steps` here as a plain HealthDataType.STEPS // sample. Two reasons that had to stop: diff --git a/test/health_export_energy_steps_test.dart b/test/health_export_energy_steps_test.dart index 3a6812980..713b7eb87 100644 --- a/test/health_export_energy_steps_test.dart +++ b/test/health_export_energy_steps_test.dart @@ -42,4 +42,22 @@ void main() { await run('2026-08-03'); expect(deletes, hasLength(1), reason: 'one-shot'); }); + + test('only strap counter steps are exported', () { + const span = {'start_ts': 1000, 'end_ts': 4600, 'steps': 30}; + final out = healthStepSpans({ + 'steps': { + 'source': 'strap_counter', + 'spans': [span, {'start_ts': 5000, 'end_ts': 5000, 'steps': 3}], + }, + }); + expect(out, hasLength(1)); + expect(out.single.steps, 30); + expect(out.single.start.millisecondsSinceEpoch, 1000000); + // The phone already writes its own steps to the store. + for (final source in ['phone', 'strap', 'mixed', null]) { + expect(healthStepSpans({'steps': {'source': source, 'spans': [span]}}), + isEmpty); + } + }); } From 2c60e798a1d73ed1b51e9faa6573560091d6cf5e Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Mon, 2 Nov 2026 04:28:35 +0530 Subject: [PATCH 08/21] zones sheet scrolls above the keyboard, number pads dismiss on tap outside --- lib/ui2/activity/zones.dart | 6 +++++- lib/ui2/screens/journal_compose.dart | 3 +++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/ui2/activity/zones.dart b/lib/ui2/activity/zones.dart index eae91532e..b268061f4 100644 --- a/lib/ui2/activity/zones.dart +++ b/lib/ui2/activity/zones.dart @@ -231,7 +231,10 @@ class _ZonesDetailState extends State { right: S.x5, top: S.x5, bottom: MediaQuery.of(s).viewInsets.bottom + S.x5), - child: Column( + // Five fields plus the number pad outgrow a phone screen, which hid + // Save under the keyboard. + child: SingleChildScrollView( + child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -262,6 +265,7 @@ class _ZonesDetailState extends State { color: C.red, onTap: () => Navigator.of(s).pop(true)), ], ), + ), ), ); final typed = [for (final ctl in ctrls) Typed.of(ctl.text)]; diff --git a/lib/ui2/screens/journal_compose.dart b/lib/ui2/screens/journal_compose.dart index 0b9ddc44e..344f8f4ff 100644 --- a/lib/ui2/screens/journal_compose.dart +++ b/lib/ui2/screens/journal_compose.dart @@ -716,6 +716,9 @@ class OsTextField extends StatelessWidget { maxLines: lines, minLines: 1, keyboardType: keyboard, + // iOS number pads have no return key: tapping anywhere else is + // the only way to put the keyboard away. + onTapOutside: (_) => FocusScope.of(c).unfocus(), style: F.body.copyWith(color: p.ink), cursorColor: p.on(C.domMind), decoration: InputDecoration( From d6e1eac47c327f5308af5730cd5141ea9bde33f3 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Mon, 2 Nov 2026 04:31:07 +0530 Subject: [PATCH 09/21] sleep chart legend stopped printing skin temp sensor counts as a range --- lib/ui2/screens/sleep_detail.dart | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/ui2/screens/sleep_detail.dart b/lib/ui2/screens/sleep_detail.dart index 1dc514654..bf2f8a4c2 100644 --- a/lib/ui2/screens/sleep_detail.dart +++ b/lib/ui2/screens/sleep_detail.dart @@ -2230,7 +2230,7 @@ class _SleepDetailState extends State with RevisionReload { // unlabelled lines on three invisible axes was the least readable chart // in the app. void lane(List<(int, double)> raw, String label, String unit, Color col, - {String Function(double) format = axisInt}) { + {String Function(double) format = axisInt, bool range = true}) { if (raw.isEmpty || cols == 0) return; final g = grid(raw); final present = [for (final v in g) ?v]; @@ -2245,7 +2245,11 @@ class _SleepDetailState extends State with RevisionReload { if (v < lo) lo = v; if (v > hi) hi = v; } - legend.add(('$label ${format(lo)}–${format(hi)} ${uiText(c, unit)}', col)); + legend.add(( + range + ? '$label ${format(lo)}–${format(hi)} ${uiText(c, unit)}' + : '$label (${uiText(c, unit)})', + col)); axes.add(AxisSpec.of(present, ticks: 2, format: format)); units.add(unit); } @@ -2257,9 +2261,10 @@ class _SleepDetailState extends State with RevisionReload { lane(hrv, loc?.sleepDetailHrv ?? 'HRV', 'ms', p.on(C.green)); lane(resp, loc?.sleepDetailBreathing ?? 'Breathing', 'br/min', p.on(C.teal)); // Skin temperature is ADC-relative — a deviation, never a °C. The unit - // says so rather than implying a thermometer. + // says so rather than implying a thermometer. No low–high: sensor counts + // like "-206.6–68.2" read as impossible temperatures. lane(temp, loc?.sleepDetailSkinTemp ?? 'Skin temp', 'rel', p.on(C.orange), - format: axisFixed); + format: axisFixed, range: false); if (series.isEmpty) { return summary.isEmpty From 9249cf70cf3042a811a892da225fa52d63fdd624 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Mon, 2 Nov 2026 04:30:38 +0530 Subject: [PATCH 10/21] import: open wal-mode .noopbak read-only on ios (flip copy to rollback mode) --- lib/import/noop_backup_import.dart | 25 +++++++++++++++++++------ test/noop_backup_import_test.dart | 25 +++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/lib/import/noop_backup_import.dart b/lib/import/noop_backup_import.dart index 8f6157795..2862b8369 100644 --- a/lib/import/noop_backup_import.dart +++ b/lib/import/noop_backup_import.dart @@ -95,7 +95,7 @@ class NoopBackupImporter { } Directory? scratch; try { - final opened = await _repairTruncatedHeader(path); + final opened = await repairTruncatedHeader(path); scratch = opened.$2; final Database src; try { @@ -141,7 +141,8 @@ class NoopBackupImporter { /// write (a torn B-tree page, rather than a stale page count) — that surfaces /// downstream as [_isCorruptPageError], and [_read] drops just the affected /// table rather than the whole import. - static Future<(String, Directory?)> _repairTruncatedHeader( + @visibleForTesting + static Future<(String, Directory?)> repairTruncatedHeader( String path, ) async { final f = await File(path).open(); @@ -175,7 +176,13 @@ class NoopBackupImporter { final declaredPages = (header[28] << 24) | (header[29] << 16) | (header[30] << 8) | header[31]; final actualPages = fileLen ~/ pageSize; - if (actualPages <= declaredPages) return (path, null); // header agrees + // Bytes 18/19 == 2 mark a WAL-mode database. Read-only, without its -wal + // and -shm beside it (a backup ships only the main file), SQLite cannot + // open it at all: SQLITE_CANTOPEN, "unable to open database file". The + // copy is flipped to rollback mode (1/1), which is what + // `PRAGMA journal_mode=DELETE` writes; the pages are unchanged. + final wal = header[18] == 2 || header[19] == 2; + if (actualPages <= declaredPages && !wal) return (path, null); // header agrees final scratch = await Directory.systemTemp.createTemp('openstrap_noopfix_'); try { @@ -183,9 +190,15 @@ class NoopBackupImporter { await File(path).copy(repaired); // OS-level copy, not materialised here final raf = await File(repaired).open(mode: FileMode.append); try { - await raf.setPosition(28); - final be = ByteData(4)..setUint32(0, actualPages, Endian.big); - await raf.writeFrom(be.buffer.asUint8List()); + if (wal) { + await raf.setPosition(18); + await raf.writeFrom(const [1, 1]); + } + if (actualPages > declaredPages) { + await raf.setPosition(28); + final be = ByteData(4)..setUint32(0, actualPages, Endian.big); + await raf.writeFrom(be.buffer.asUint8List()); + } } finally { await raf.close(); } diff --git a/test/noop_backup_import_test.dart b/test/noop_backup_import_test.dart index 016071d84..cc7683148 100644 --- a/test/noop_backup_import_test.dart +++ b/test/noop_backup_import_test.dart @@ -199,6 +199,31 @@ void main() { expect(days, isNotEmpty); }, timeout: const Timeout(Duration(minutes: 5))); + test('a WAL-mode backup with no -wal beside it still opens', () async { + // Read-only, a WAL-mode database without its -wal/-shm fails with + // SQLITE_CANTOPEN ("unable to open database file", code 14). + const t0 = 1785747600; // 2026-08-03 + final dbPath = await writeNoopDb('w.sqlite', t0: t0, seconds: 600); + final raf = File(dbPath).openSync(mode: FileMode.append); + raf.setPositionSync(18); + raf.writeFromSync(const [2, 2]); // the header's WAL marker + raf.closeSync(); + // The test sqlite builds the -wal itself, so assert the copy's header + // (what the system sqlite on iOS needs), then the import as a whole. + final (fixed, scratch) = + await NoopBackupImporter.repairTruncatedHeader(dbPath); + expect(fixed, isNot(dbPath)); + expect(File(fixed).readAsBytesSync().sublist(18, 20), [1, 1]); + expect(File(dbPath).readAsBytesSync().sublist(18, 20), [2, 2], + reason: 'the picked file itself is never written'); + await scratch!.delete(recursive: true); + + final bak = writeBackup('w.noopbak', dbPath); + final res = await NoopImporter.importFile( + bak, const Profile(), DerivationEngine()); + expect(res.steps, 599); + }, timeout: const Timeout(Duration(minutes: 5))); + test('re-importing the same backup does not double-count steps', () async { const t0 = 1785660000; // 2026-08-02 const secs = 900; From 981a4dd8e66fd26236ead9ab0359fae76ea8e18d Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:47:16 +0530 Subject: [PATCH 11/21] sync burst: gate-dropped batches still drain through, only truly empty ones stop --- lib/ble/ble_engine.dart | 5 +++++ lib/state/sync_controller.dart | 7 ++++++- test/app_state_sync_triggers_test.dart | 13 +++++++++++++ test/support/app_state_sync_harness.dart | 3 +++ test/sync_controller_test.dart | 2 ++ 5 files changed, 29 insertions(+), 1 deletion(-) diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 652b5ad36..0ff1be7b2 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -2643,6 +2643,11 @@ class BleEngine { /// again. The raw latch stays visible in diagnostics as `history_stuck`. bool get historyStuckThisSession => _session?.historyStuckActive ?? false; + /// Historical records RecordGate has rejected so far (monotonic). A burst + /// whose batches carried no counted records but raised this was walking a + /// stale-timestamp block, not an empty flash. + int get historyGateDrops => _recordGate.dropped; + Map get offloadSnapshot => { 'active': _offloadActive, 'queued_frames': _offloadFrames.length, diff --git a/lib/state/sync_controller.dart b/lib/state/sync_controller.dart index f4339d0c3..af5613b33 100644 --- a/lib/state/sync_controller.dart +++ b/lib/state/sync_controller.dart @@ -368,6 +368,7 @@ class SyncController { // not mistake that for a stuck drain (spin-guard/backlogRemains below // read frontierAfter too). final frontierBefore = await LocalDb.getCursorInt('rec_ts_hw'); + final dropsBefore = engine.historyGateDrops; if (kickFirst || i > 0) { await engine.requestHistorySync(); } @@ -445,9 +446,13 @@ class SyncController { ); break; } - if (!frontierAdvanced && report.records == 0) { + if (!frontierAdvanced && + report.records == 0 && + engine.historyGateDrops == dropsBefore) { // Batches with no records in them (a strap writing no 1 Hz data, e.g. // off-wrist) are not a stale block: there is nothing to drain through. + // Gate drops are: a frozen-clock block's records are rejected below the + // frontier and never counted, but the trim still walks past them. _log('Backfill stop — batches carried no records; frontier stuck ' '(strap newest=$strapNewest, frontier=$frontierAfter).'); break; diff --git a/test/app_state_sync_triggers_test.dart b/test/app_state_sync_triggers_test.dart index 7f9738c3e..7936d9196 100644 --- a/test/app_state_sync_triggers_test.dart +++ b/test/app_state_sync_triggers_test.dart @@ -285,6 +285,19 @@ void main() { await rig.quiesce(); })); + test('gate-dropped batches behind a stuck frontier keep draining', + syncCase((rig, timers) async { + await _open(rig); + rig.engine.newestTs = 1800000000; + await LocalDb.setCursor('rec_ts_hw', '1700000000'); + rig.engine.onRunSync = () async => rig.engine.gateDrops += 50; + rig.engine.syncScript.addAll([for (var i = 0; i < 3; i++) SyncReport(0, 1, true)]); + await rig.app.forceResync(); + expect(rig.engine.count('runSync'), greaterThan(1)); + expect(_logged(rig, 'Backfill stop — batches carried no records'), isFalse); + await rig.quiesce(); + })); + test('a terminal Stuck connection runs no session at all', syncCase((rig, timers) async { await _open(rig); diff --git a/test/support/app_state_sync_harness.dart b/test/support/app_state_sync_harness.dart index f4790c20a..eb9592642 100644 --- a/test/support/app_state_sync_harness.dart +++ b/test/support/app_state_sync_harness.dart @@ -212,6 +212,9 @@ class SyncFakeEngine extends BleEngine { @override bool get historyStuckThisSession => stuck; bool offloading = false; + int gateDrops = 0; + @override + int get historyGateDrops => gateDrops; @override bool get offloadActive => offloading; @override diff --git a/test/sync_controller_test.dart b/test/sync_controller_test.dart index 3ee3ba819..6765565fd 100644 --- a/test/sync_controller_test.dart +++ b/test/sync_controller_test.dart @@ -70,6 +70,8 @@ class _Engine implements BleEngine { @override bool get historyStuckThisSession => false; @override + int get historyGateDrops => 0; + @override int? get strapHistoryNewestTs => newestTs; @override DateTime? get lastRxAt => null; From 970893313a3db57f7481319630c09644b05967f1 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:48:56 +0530 Subject: [PATCH 12/21] bond refusal streak survives an existing bond, only a command reply ends it --- lib/ble/ble_engine.dart | 22 ++++++++++------------ test/gen5_bootstrap_official_test.dart | 23 +++++++++++++++++++++++ 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 0ff1be7b2..a659f8fd8 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -2982,11 +2982,10 @@ class BleEngine { try { await device.createBond(); _log('Bonded (or already bonded).'); - // A clean bond clears the refusal streak + any give-up latch, so a - // later run of refusals can trip the pause again, and un-pauses the - // auto-reconnect loop. - _bondGiveUp.bondSucceeded(); - state.bondRefusals = 0; + // Un-pause auto-reconnect, but leave the refusal streak: an + // existing bond the band rejects at notification registration + // "succeeds" here on every reconnect. Only a command reply + // ([_clearRepairGuideOnCommandReply]) proves the key works. state.autoReconnectPaused = false; } catch (e) { // A failed bond is NOT benign: the strap gates every command behind @@ -3487,11 +3486,10 @@ class BleEngine { await gatt.createBond(); _log('Bonded.'); } - // A clean bond clears the refusal streak + any give-up latch, so a - // later run of refusals can trip the pause again, and un-pauses the - // auto-reconnect loop. - _bondGiveUp.bondSucceeded(); - state.bondRefusals = 0; + // Un-pause auto-reconnect, but leave the refusal streak: an + // existing bond the band rejects at notification registration + // "succeeds" here on every reconnect. Only a command reply + // ([_clearRepairGuideOnCommandReply]) proves the key works. state.autoReconnectPaused = false; } catch (e) { _log('BOND FAILED: $e — bootstrap stops here (no subscriptions, no ' @@ -4826,8 +4824,8 @@ class BleEngine { if (!state.needsRepairGuide) return; state.needsRepairGuide = false; _postBondLoop.reset(); - // On iOS nothing else ends a refusal streak (there is no explicit bond - // step to succeed), so the reply does it here. + // The one place a refusal streak ends: a bond can "succeed" (or already + // exist) and still be refused at registration, a reply can't. _bondGiveUp.bondSucceeded(); state.bondRefusals = 0; onState(state); diff --git a/test/gen5_bootstrap_official_test.dart b/test/gen5_bootstrap_official_test.dart index 9175231ae..22e56df9f 100644 --- a/test/gen5_bootstrap_official_test.dart +++ b/test/gen5_bootstrap_official_test.dart @@ -145,6 +145,11 @@ class _Rig { // carries no requestConnectionPriority (doc 06 found none in the official // data path); the post-READY offload transition still may. engine.debugOnPriorityRequest = () => trace.add('link_priority'); + relink(armDrains: armDrains); + } + + /// A fresh fake link (a reconnect after a failed connect tore it down). + void relink({bool armDrains = true}) { engine.debugInstallFakeLink( band: BandProfile.gen5, armDrains: armDrains, @@ -1294,6 +1299,24 @@ void main() { }); }); + test('an existing bond refused at registration keeps its streak across ' + 'reconnects', () { + fakeAsync((async) { + final rig = _Rig()..answerAll(); + _Ops refused() => _Ops( + rig, + alreadyBonded: true, + failSubscribe: {'cmd_from'}, + subscribeError: FlutterBluePlusException(ErrorPlatform.android, + 'setNotifyValue', 5, 'GATT_INSUFFICIENT_AUTHENTICATION'), + ); + expect(_run(rig, async, ops: refused()), isFalse); + rig.relink(); + expect(_run(rig, async, ops: refused()), isFalse); + expect(rig.engine.state.bondRefusals, 2); + }); + }); + test('a registration failing for any other reason is not', () { fakeAsync((async) { final rig = _Rig()..answerAll(); From c9dcc2574dd301cda014b88e06a8493928ee65e0 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:49:47 +0530 Subject: [PATCH 13/21] stuck strap: keep the observation across reconnects to the same band --- lib/ble/ble_engine.dart | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index a659f8fd8..e00b13fa4 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -2273,6 +2273,7 @@ class BleEngine { final ChunkFailureLedger _chunkFailures = ChunkFailureLedger(); EmptySyncTracker _emptySync = EmptySyncTracker(); StuckStrapDetector _stuckStrap = StuckStrapDetector(); + String? _stuckStrapBand; // Detector 1b: sustained frame corruption (CRC8/CRC32 failures) — an // independent failure axis from marginal-radio, which only ever sees // timeouts. Per-connection like empty-sync/stuck-strap: a new link starts @@ -2462,7 +2463,7 @@ class BleEngine { // that would otherwise loop empty drop-ACKs (or, after the refuse guard, // re-deliver forever without ever correcting the clock). // - // NOT reset per connection (unlike _emptySync / _stuckStrap). The remedy IS + // NOT reset per connection (unlike _emptySync). The remedy IS // a reconnect, so a per-connection reset made the escalation unreachable: // the idle watchdog tears the session down after a refuse-only burst, the // next connect zeroed the count, and the loop ran forever at streak 1. @@ -3085,9 +3086,16 @@ class BleEngine { _lastClockVerifyAt = _wallSecs(); // Per-connection policy reset. Marginal-radio + post-bond-loop are NOT reset // here — they count consecutive bad cycles across reconnects and self-reset on - // a healthy disconnect. Empty-sync + stuck are per-connection. + // a healthy disconnect. Empty-sync is per-connection. Stuck is per band: + // the liveness fuse reconnects a silent link every ~2 min, so a + // per-connection reset never let its 10 min observation finish. A real + // frontier advance resets it on its own. _emptySync = EmptySyncTracker(); - _stuckStrap = StuckStrapDetector(); + final bandId = session.device.remoteId.str; + if (bandId != _stuckStrapBand) { + _stuckStrap = StuckStrapDetector(); + _stuckStrapBand = bandId; + } _frameCorruption = FrameCorruptionDetector(); _crcFailuresThisSession = 0; _burstMismatchStreak = 0; From 91170a4563a3e4694759ca9bd7f9c6ce8609b37a Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:51:49 +0530 Subject: [PATCH 14/21] gen4: a history claim mid-arm stops the rest of the imu bundle, replayed after --- lib/ble/ble_engine.dart | 8 ++++++++ test/live_stream_ownership_test.dart | 13 +++++++++++++ 2 files changed, 21 insertions(+) diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index e00b13fa4..709f3a1bf 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -1904,6 +1904,9 @@ class BleEngine { @visibleForTesting void debugIngestHistoricalFrame(Frame frame) => _ingestHistoricalFrame(frame); + @visibleForTesting + void debugSetOffloadActive(bool active) => _setOffloadActive(active); + /// Feed one inbound control frame through the real immediate-receive path /// (decode → event handling → state absorb). /// @@ -9056,8 +9059,13 @@ class BleEngine { // old methods judged it; a toggle's own correlated reply is not awaited. // Correlating 0x03/0x6A replies (`_sendAwaited`) is the upgrade once the // strap's reply behaviour for these toggles is confirmed on hardware. + // A gen4 history claim can land during the arm's gaps: the rest of the + // bundle must not go out mid-offload ([nextLiveStreamStep] holdImuOn). + // Failed marks it dirty, so the replay runs when the offload ends. + final holdable = step == LiveStreamStep.imuOn && !session.band.isGen5; var ok = true; for (final op in ops) { + if (holdable && _offloadActive) return _LiveWrite.failed; if (!await op()) ok = false; if (_liveStale(session, generation)) return _LiveWrite.stale; } diff --git a/test/live_stream_ownership_test.dart b/test/live_stream_ownership_test.dart index 5ea84e15f..edd237871 100644 --- a/test/live_stream_ownership_test.dart +++ b/test/live_stream_ownership_test.dart @@ -570,6 +570,19 @@ void main() { expect(rig.link[2].body.first, 0x01, reason: 'gen4 IMU body is a bare byte'); }); + test('a history claim landing mid-arm holds the rest of the bundle until ' + 'the offload ends', () async { + final rig = _Rig(band: BandProfile.gen4); + final pass = rig.setOwners(fg); + await rig.settle(); // inside the arm's opening gap + rig.engine.debugSetOffloadActive(true); + await pass; + expect(rig.ops, [(_hr, 1)]); + rig.engine.debugSetOffloadActive(false); + await rig.reconcile(); + expect(rig.opcodes, contains(_imu)); + }); + test('full → off: optical mode, optical save, R10/R11, IMU, then HR', () async { final rig = _Rig(band: BandProfile.gen4); await rig.setOwners(fg); From db65605064040ca327b417a7251a048b64f33dcb Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:55:45 +0530 Subject: [PATCH 15/21] health export: finish the steps purge before the first write, clear steps when the counter loses a day --- lib/health/health_export.dart | 39 +++++++-- test/health_export_energy_steps_test.dart | 21 +++++ ...ealth_export_steps_source_switch_test.dart | 85 +++++++++++++++++++ 3 files changed, 137 insertions(+), 8 deletions(-) create mode 100644 test/health_export_steps_source_switch_test.dart diff --git a/lib/health/health_export.dart b/lib/health/health_export.dart index 59ab7e1f3..ea9e59171 100644 --- a/lib/health/health_export.dart +++ b/lib/health/health_export.dart @@ -279,11 +279,13 @@ Future purgeLegacyStepsBehindCursor({ required Future Function(String name, String value) setCursor, required Future Function(DateTime start, DateTime end) deleteSteps, }) async { - if (cursor.isEmpty) return; if ((await getCursor(kHealthStepsPrefixPurgeCursor) ?? '').isNotEmpty) { return; } - final d = DateTime.tryParse(cursor); + // An empty cursor (first export, or a reset) still has to finish the purge + // now, before this pass writes strap-counter steps: run later, behind the + // advanced cursor, it would delete those and nothing would rewrite them. + final d = cursor.isEmpty ? DateTime.now() : DateTime.tryParse(cursor); if (d == null) return; try { // HealthKit and Health Connect only delete this app's own samples. @@ -291,7 +293,8 @@ Future purgeLegacyStepsBehindCursor({ DateTime.fromMillisecondsSinceEpoch(0), DateTime(d.year, d.month, d.day + 1), ); - await setCursor(kHealthStepsPrefixPurgeCursor, cursor); + await setCursor( + kHealthStepsPrefixPurgeCursor, cursor.isEmpty ? 'initial' : cursor); } catch (e) { debugPrint('[health] purge legacy steps behind $cursor: $e'); } @@ -873,11 +876,22 @@ class HealthExporter { cursor: cursor, getCursor: LocalDb.getCursor, setCursor: (name, value) => LocalDb.setCursor(name, value), - deleteSteps: (start, end) => _health.delete( - type: HealthDataType.STEPS, - startTime: start, - endTime: end, - ), + // Bounded like every other delete: a locked store ends the pass + // here instead of hanging it. + deleteSteps: (start, end) async { + try { + return await _health + .delete( + type: HealthDataType.STEPS, + startTime: start, + endTime: end, + ) + .timeout(_deleteTimeout); + } on TimeoutException { + _storeHung = true; + rethrow; + } + }, ); final retryState = await _loadRetryState(); var retryStateDirty = false; @@ -1442,6 +1456,15 @@ class HealthExporter { // Delete-then-write over our own samples, and only on days we write, so a // phone-counted day never runs a delete for a type nothing writes. final stepSpans = healthStepSpans(b); + final stepsMap = b['steps']; + if (stepSpans.isEmpty && + stepsMap is Map && + stepsMap['band_measured'] != null) { + // A counter day the counter lost (a not-yet-final day it won on an + // earlier pass) must not keep the samples written then. Best effort: + // nothing is written here, so a failed delete doesn't hold the cursor. + await _deleteOwnSamples(HealthDataType.STEPS, dayStart, dayEnd); + } if (stepSpans.isNotEmpty) { if (!await _deleteOwnSamples(HealthDataType.STEPS, dayStart, dayEnd)) { success = false; diff --git a/test/health_export_energy_steps_test.dart b/test/health_export_energy_steps_test.dart index 713b7eb87..8ac756284 100644 --- a/test/health_export_energy_steps_test.dart +++ b/test/health_export_energy_steps_test.dart @@ -43,6 +43,27 @@ void main() { expect(deletes, hasLength(1), reason: 'one-shot'); }); + test('a first export with no cursor finishes the purge before it writes', + () async { + final stored = {}; + final deletes = <(DateTime, DateTime)>[]; + Future run(String cursor) => purgeLegacyStepsBehindCursor( + cursor: cursor, + getCursor: (name) async => stored[name], + setCursor: (name, value) async => stored[name] = value, + deleteSteps: (s, e) async { + deletes.add((s, e)); + return true; + }, + ); + await run(''); + expect(deletes, hasLength(1)); + expect(deletes.single.$2.isAfter(DateTime.now()), isTrue); + // The next pass, cursor advanced over freshly written steps: no purge. + await run('2026-08-03'); + expect(deletes, hasLength(1)); + }); + test('only strap counter steps are exported', () { const span = {'start_ts': 1000, 'end_ts': 4600, 'steps': 30}; final out = healthStepSpans({ diff --git a/test/health_export_steps_source_switch_test.dart b/test/health_export_steps_source_switch_test.dart new file mode 100644 index 000000000..77c9537cb --- /dev/null +++ b/test/health_export_steps_source_switch_test.dart @@ -0,0 +1,85 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/health/health_export.dart'; + +/// A not-yet-final day the strap counter won on one pass and lost on the next +/// must not keep the step samples written the first time. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('flutter_health'); + const appleSleep = MethodChannel('openstrap/healthkit_sleep'); + final now = DateTime.now(); + final y = DateTime(now.year, now.month, now.day - 1); + String two(int x) => x.toString().padLeft(2, '0'); + final day = '${y.year}-${two(y.month)}-${two(y.day)}'; + final noon = DateTime(y.year, y.month, y.day, 12).millisecondsSinceEpoch ~/ 1000; + final stepWrites = []; + final stepDeletes = []; + + Future putDay(Map steps) => LocalDb.putDayResult( + dayId: day, + algoVersion: 120, + payloadJson: jsonEncode({'steps': steps}), + windowJson: '{}', + finalized: false, + ); + + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_health_steps_switch_test.db'; + await databaseFactory.deleteDatabase( + p.join(await databaseFactory.getDatabasesPath(), LocalDb.dbName), + ); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + final args = call.arguments is Map ? call.arguments as Map : const {}; + final isSteps = args['dataTypeKey'] == 'STEPS'; + if (call.method == 'delete') { + if (isSteps) stepDeletes.add(args['startTime'] as int); + return true; + } + if (call.method == 'writeData' && isSteps) { + stepWrites.add(args['startTime'] as int); + } + return call.method == 'writeData' ? true : null; + }); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(appleSleep, (_) async => true); + }); + + tearDownAll(() async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(appleSleep, null); + await LocalDb.close(); + await databaseFactory.deleteDatabase( + p.join(await databaseFactory.getDatabasesPath(), LocalDb.dbName), + ); + }); + + test('a counter day that switches to the phone clears its steps', () async { + final exporter = HealthExporter(); + await putDay({ + 'source': 'strap_counter', + 'band_measured': 400, + 'spans': [ + {'start_ts': noon, 'end_ts': noon + 3600, 'steps': 400}, + ], + }); + await exporter.exportAll(); + expect(stepWrites, hasLength(1)); + + stepDeletes.clear(); + await putDay({'source': 'phone', 'band_measured': 400, 'value': 900}); + await exporter.exportAll(forceRetry: true); + expect(stepDeletes, hasLength(1)); + expect(stepWrites, hasLength(1)); + }); +} From 8a03749624016de02fba9ff63c574cbdb00f1d03 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:31:56 +0530 Subject: [PATCH 16/21] bond give-up pause stays until the band answers a command, not until a bond succeeds --- lib/ble/ble_engine.dart | 7 +++---- test/gen5_bootstrap_official_test.dart | 23 +++++++++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 00f55d2ed..636b05626 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -3003,11 +3003,10 @@ class BleEngine { try { await device.createBond(); _log('Bonded (or already bonded).'); - // Un-pause auto-reconnect, but leave the refusal streak: an + // Leave the refusal streak and any auto-reconnect pause alone: an // existing bond the band rejects at notification registration // "succeeds" here on every reconnect. Only a command reply // ([_clearRepairGuideOnCommandReply]) proves the key works. - state.autoReconnectPaused = false; } catch (e) { // A failed bond is NOT benign: the strap gates every command behind // encryption, so downstream GATT ops will fail confusingly (writes @@ -3514,11 +3513,10 @@ class BleEngine { await gatt.createBond(); _log('Bonded.'); } - // Un-pause auto-reconnect, but leave the refusal streak: an + // Leave the refusal streak and any auto-reconnect pause alone: an // existing bond the band rejects at notification registration // "succeeds" here on every reconnect. Only a command reply // ([_clearRepairGuideOnCommandReply]) proves the key works. - state.autoReconnectPaused = false; } catch (e) { _log('BOND FAILED: $e — bootstrap stops here (no subscriptions, no ' 'HELLO, no READY). Remove the bond in system Bluetooth settings ' @@ -4855,6 +4853,7 @@ class BleEngine { // The one place a refusal streak ends: a bond can "succeed" (or already // exist) and still be refused at registration, a reply can't. _bondGiveUp.bondSucceeded(); + state.autoReconnectPaused = false; state.bondRefusals = 0; onState(state); _log('[RECONNECT] band answered a command — clearing the re-pair guide.'); diff --git a/test/gen5_bootstrap_official_test.dart b/test/gen5_bootstrap_official_test.dart index 22e56df9f..4e4935011 100644 --- a/test/gen5_bootstrap_official_test.dart +++ b/test/gen5_bootstrap_official_test.dart @@ -1317,6 +1317,29 @@ void main() { }); }); + test('an existing bond succeeding does not lift a give-up pause', () { + // After give-up the streak no longer records refusals, so a bond + // "success" clearing the pause re-armed auto-reconnect for good. + fakeAsync((async) { + final rig = _Rig()..answerAll(); + _Ops refused() => _Ops( + rig, + alreadyBonded: true, + failSubscribe: {'cmd_from'}, + subscribeError: FlutterBluePlusException(ErrorPlatform.android, + 'setNotifyValue', 5, 'GATT_INSUFFICIENT_AUTHENTICATION'), + ); + for (var i = 0; i < 5; i++) { + if (i > 0) rig.relink(); + expect(_run(rig, async, ops: refused()), isFalse); + } + expect(rig.engine.state.autoReconnectPaused, isTrue); + rig.relink(); + expect(_run(rig, async, ops: refused()), isFalse); + expect(rig.engine.state.autoReconnectPaused, isTrue); + }); + }); + test('a registration failing for any other reason is not', () { fakeAsync((async) { final rig = _Rig()..answerAll(); From fdabc4f70fec2b563151c1b46a4d230f654cdfc2 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:32:48 +0530 Subject: [PATCH 17/21] stuck strap: time out of range isn't stuck time, restart the window after 5 min offline --- lib/ble/ble_engine.dart | 13 +++++++++++-- lib/sync/sync_policy.dart | 5 +++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 636b05626..ebb88849b 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -2277,6 +2277,9 @@ class BleEngine { EmptySyncTracker _emptySync = EmptySyncTracker(); StuckStrapDetector _stuckStrap = StuckStrapDetector(); String? _stuckStrapBand; + // When the link last went down (monotonic). A band out of range for longer + // than a retry reconnect wasn't being asked, so that time isn't "stuck". + double? _linkDownAt; // Detector 1b: sustained frame corruption (CRC8/CRC32 failures) — an // independent failure axis from marginal-radio, which only ever sees // timeouts. Per-connection like empty-sync/stuck-strap: a new link starts @@ -3108,10 +3111,14 @@ class BleEngine { // a healthy disconnect. Empty-sync is per-connection. Stuck is per band: // the liveness fuse reconnects a silent link every ~2 min, so a // per-connection reset never let its 10 min observation finish. A real - // frontier advance resets it on its own. + // frontier advance resets it on its own, and so does a long time offline + // (the band wasn't being asked then). _emptySync = EmptySyncTracker(); final bandId = session.device.remoteId.str; - if (bandId != _stuckStrapBand) { + final down = _linkDownAt; + if (bandId != _stuckStrapBand || + (down != null && + _monotonicSecs() - down > kStuckOfflineResetSeconds)) { _stuckStrap = StuckStrapDetector(); _stuckStrapBand = bandId; } @@ -4723,6 +4730,7 @@ class BleEngine { } final wasIntentional = session.intentionalClose; session.connected = false; + _linkDownAt = _monotonicSecs(); // A drain in flight must complete (with linkDown) immediately, not run out // its full budget. if (_offloadActive) { @@ -9153,6 +9161,7 @@ class BleEngine { final session = _session; if (session == null) return; session.intentionalClose = intentional; + _linkDownAt = _monotonicSecs(); // SYNCHRONOUSLY, before any await: see `_Session.closing`. Ingress freezes // here — the receive guards drop anything for a closing session, and the // notification subscriptions are cancelled before anything is awaited — diff --git a/lib/sync/sync_policy.dart b/lib/sync/sync_policy.dart index e51865be3..eef415135 100644 --- a/lib/sync/sync_policy.dart +++ b/lib/sync/sync_policy.dart @@ -108,6 +108,11 @@ const int kForeignHistoryStallSeconds = 60; // no fuse) and what to poll to provoke it. [isLinkStale] already has the right // shape — one bar per traffic mode — it just cannot know a second band's modes. const int kLivenessFuseSeconds = 120; // no data for >fuse ⇒ bounce the link + +/// Offline longer than this between sessions restarts the stuck-strap window: +/// a liveness bounce reconnects within seconds, a band out of range for +/// longer wasn't being asked for history. +const int kStuckOfflineResetSeconds = 300; // Every existing RTC recheck is symptom-triggered (drift detected on the ONE // GET_CLOCK read at connect, or a defensive SET_CLOCK on StuckStrapDetector // tripping). A connection that stays open for many hours (iOS's From d28900c518167ddf5378cc6c8c54ab680e1ec105 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:45:46 +0530 Subject: [PATCH 18/21] health export: steps wait for a confirmed purge, a late delete holds later passes, a failed step clear retries --- lib/health/health_export.dart | 98 +++++++++++++------ test/health_export_energy_steps_test.dart | 22 +++++ ...ealth_export_steps_source_switch_test.dart | 36 +++++++ 3 files changed, 124 insertions(+), 32 deletions(-) diff --git a/lib/health/health_export.dart b/lib/health/health_export.dart index ea9e59171..76f802596 100644 --- a/lib/health/health_export.dart +++ b/lib/health/health_export.dart @@ -273,30 +273,36 @@ const kHealthStepsPrefixPurgeCursor = 'health_steps_prefix_purged'; /// [cursor]. The per-day purge only runs for days exportAll still exports, and /// days already behind the cursor are never exported again, so on a store that /// never had its cursor cleared those samples would stay forever. -Future purgeLegacyStepsBehindCursor({ +/// +/// Returns whether the purge is done. Until it is, nothing may write steps: +/// a purge that finishes later, behind an advanced cursor, deletes those and +/// nothing rewrites them. [deleteSteps] answers "is the range clear", already +/// read per platform (see [healthDeleteClearedRange]). +Future purgeLegacyStepsBehindCursor({ required String cursor, required Future Function(String name) getCursor, required Future Function(String name, String value) setCursor, required Future Function(DateTime start, DateTime end) deleteSteps, }) async { if ((await getCursor(kHealthStepsPrefixPurgeCursor) ?? '').isNotEmpty) { - return; + return true; } // An empty cursor (first export, or a reset) still has to finish the purge - // now, before this pass writes strap-counter steps: run later, behind the - // advanced cursor, it would delete those and nothing would rewrite them. - final d = cursor.isEmpty ? DateTime.now() : DateTime.tryParse(cursor); - if (d == null) return; + // now, before this pass writes strap-counter steps. + final d = DateTime.tryParse(cursor) ?? DateTime.now(); try { // HealthKit and Health Connect only delete this app's own samples. - await deleteSteps( + final cleared = await deleteSteps( DateTime.fromMillisecondsSinceEpoch(0), DateTime(d.year, d.month, d.day + 1), ); + if (!cleared) return false; await setCursor( kHealthStepsPrefixPurgeCursor, cursor.isEmpty ? 'initial' : cursor); + return true; } catch (e) { debugPrint('[health] purge legacy steps behind $cursor: $e'); + return false; } } @@ -490,6 +496,41 @@ class HealthExporter { /// Cleared at the start of every [_workoutLock] hold. bool _storeHung = false; + /// Native deletes still running, and since when. [Future.timeout] doesn't + /// cancel the plugin call, so a delete that timed out can still land after + /// a later pass wrote into its range and take those samples with it. + int _deletesInFlight = 0; + DateTime? _deletesInFlightSince; + + // ponytail: a hung native call may never call back, so the hold is capped; + // a delete landing after the cap can still remove a newer sample. + static const _kInFlightDeleteHold = Duration(minutes: 30); + + /// The [_storeHung] a new lock hold starts with: hung while a timed-out + /// delete may still land. + bool get _deleteStillPending { + final since = _deletesInFlightSince; + return since != null && + DateTime.now().difference(since) < _kInFlightDeleteHold; + } + + Future _nativeDelete( + HealthDataType type, + DateTime start, + DateTime end, + ) { + final f = _health.delete(type: type, startTime: start, endTime: end); + if (_deletesInFlight++ == 0) _deletesInFlightSince = DateTime.now(); + f.whenComplete(() { + if (--_deletesInFlight == 0) _deletesInFlightSince = null; + }).ignore(); + return f.timeout(_deleteTimeout); + } + + /// Set when the legacy steps prefix purge hasn't finished: step writes wait + /// for it (see [purgeLegacyStepsBehindCursor]). + bool _stepWritesHeld = false; + HealthExporter({ HealthConnectHeartRateWriter? androidHeartRate, @visibleForTesting Duration deleteTimeout = const Duration(seconds: 30), @@ -565,7 +606,7 @@ class HealthExporter { await shared._ensureConfigured(); if (await shared._androidUnavailable() != null) return; await shared._workoutLock.run(() { - shared._storeHung = false; + shared._storeHung = shared._deleteStillPending; return shared._deleteOwnSamples( HealthDataType.WORKOUT, DateTime.fromMillisecondsSinceEpoch(startTs * 1000), @@ -653,13 +694,7 @@ class HealthExporter { if (through.isNotEmpty && date.compareTo(through) <= 0) return; if (_storeHung) return; try { - await _health - .delete( - type: HealthDataType.STEPS, - startTime: dayStart, - endTime: dayEnd, - ) - .timeout(_deleteTimeout); + await _nativeDelete(HealthDataType.STEPS, dayStart, dayEnd); _stepsPurgedThrough = date; await LocalDb.setCursor(_kStepsPurgeCursor, date); } catch (e) { @@ -683,9 +718,7 @@ class HealthExporter { if (_storeHung) return false; try { return healthDeleteClearedRange( - deleted: await _health - .delete(type: type, startTime: start, endTime: end) - .timeout(_deleteTimeout), + deleted: await _nativeDelete(type, start, end), ios: isApple, ); } catch (e) { @@ -858,7 +891,7 @@ class HealthExporter { await _ensureConfigured(); if (await _androidUnavailable() != null) return 0; // HC missing/outdated return _workoutLock.run(() async { - _storeHung = false; + _storeHung = _deleteStillPending; try { await ensureHealthSleepExportEpoch( getCursor: LocalDb.getCursor, @@ -872,21 +905,19 @@ class HealthExporter { await LocalDb.setCursor(_kRetryCursor, ''); } final cursor = await LocalDb.getCursor('health_export_through') ?? ''; - await purgeLegacyStepsBehindCursor( + _stepWritesHeld = !await purgeLegacyStepsBehindCursor( cursor: cursor, getCursor: LocalDb.getCursor, setCursor: (name, value) => LocalDb.setCursor(name, value), // Bounded like every other delete: a locked store ends the pass // here instead of hanging it. deleteSteps: (start, end) async { + if (_storeHung) return false; try { - return await _health - .delete( - type: HealthDataType.STEPS, - startTime: start, - endTime: end, - ) - .timeout(_deleteTimeout); + return healthDeleteClearedRange( + deleted: await _nativeDelete(HealthDataType.STEPS, start, end), + ios: isApple, + ); } on TimeoutException { _storeHung = true; rethrow; @@ -1461,12 +1492,15 @@ class HealthExporter { stepsMap is Map && stepsMap['band_measured'] != null) { // A counter day the counter lost (a not-yet-final day it won on an - // earlier pass) must not keep the samples written then. Best effort: - // nothing is written here, so a failed delete doesn't hold the cursor. - await _deleteOwnSamples(HealthDataType.STEPS, dayStart, dayEnd); + // earlier pass) must not keep the samples written then. A failed clear + // fails the day, so the next pass tries again. + if (!await _deleteOwnSamples(HealthDataType.STEPS, dayStart, dayEnd)) { + success = false; + } } if (stepSpans.isNotEmpty) { - if (!await _deleteOwnSamples(HealthDataType.STEPS, dayStart, dayEnd)) { + if (_stepWritesHeld || + !await _deleteOwnSamples(HealthDataType.STEPS, dayStart, dayEnd)) { success = false; } else { for (final s in stepSpans) { @@ -1623,7 +1657,7 @@ class HealthExporter { final en = (session['end_ts'] as num?)?.toInt(); if (st == null || en == null || en <= st) return false; return _workoutLock.run(() async { - _storeHung = false; + _storeHung = _deleteStillPending; try { await _ensureConfigured(); if (await _androidUnavailable() != null) return false; diff --git a/test/health_export_energy_steps_test.dart b/test/health_export_energy_steps_test.dart index 8ac756284..5e27087e4 100644 --- a/test/health_export_energy_steps_test.dart +++ b/test/health_export_energy_steps_test.dart @@ -64,6 +64,28 @@ void main() { expect(deletes, hasLength(1)); }); + test('a purge the store did not confirm is retried, not marked done', + () async { + final stored = {}; + var attempts = 0; + Future run(Future Function() answer) => + purgeLegacyStepsBehindCursor( + cursor: '2026-08-01', + getCursor: (name) async => stored[name], + setCursor: (name, value) async => stored[name] = value, + deleteSteps: (s, e) { + attempts++; + return answer(); + }, + ); + expect(await run(() async => false), isFalse); + expect(await run(() async => throw StateError('store')), isFalse); + expect(stored, isEmpty, reason: 'no marker, so step writes stay held'); + expect(await run(() async => true), isTrue); + expect(await run(() async => true), isTrue); + expect(attempts, 3, reason: 'done once confirmed'); + }); + test('only strap counter steps are exported', () { const span = {'start_ts': 1000, 'end_ts': 4600, 'steps': 30}; final out = healthStepSpans({ diff --git a/test/health_export_steps_source_switch_test.dart b/test/health_export_steps_source_switch_test.dart index 77c9537cb..98682fe05 100644 --- a/test/health_export_steps_source_switch_test.dart +++ b/test/health_export_steps_source_switch_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter/services.dart'; @@ -20,6 +21,7 @@ void main() { final noon = DateTime(y.year, y.month, y.day, 12).millisecondsSinceEpoch ~/ 1000; final stepWrites = []; final stepDeletes = []; + Completer? hangSteps; Future putDay(Map steps) => LocalDb.putDayResult( dayId: day, @@ -42,6 +44,8 @@ void main() { final isSteps = args['dataTypeKey'] == 'STEPS'; if (call.method == 'delete') { if (isSteps) stepDeletes.add(args['startTime'] as int); + final hang = hangSteps; + if (isSteps && hang != null) return hang.future; return true; } if (call.method == 'writeData' && isSteps) { @@ -82,4 +86,36 @@ void main() { expect(stepDeletes, hasLength(1)); expect(stepWrites, hasLength(1)); }); + + test('a step delete still running after its timeout holds later passes', + () async { + final exporter = + HealthExporter(deleteTimeout: const Duration(milliseconds: 50)); + await putDay({ + 'source': 'strap_counter', + 'band_measured': 500, + 'spans': [ + {'start_ts': noon, 'end_ts': noon + 3600, 'steps': 500}, + ], + }); + stepWrites.clear(); + stepDeletes.clear(); + hangSteps = Completer(); + await exporter.exportAll(forceRetry: true); + expect(stepDeletes, hasLength(1)); + expect(stepWrites, isEmpty, reason: 'nothing written beside a survivor'); + + // The native delete hasn't landed: the next pass must not write into + // its range, or the late delete takes the new samples with it. + await exporter.exportAll(forceRetry: true); + expect(stepDeletes, hasLength(1)); + expect(stepWrites, isEmpty); + + final hang = hangSteps!; + hangSteps = null; + hang.complete(true); + await Future.delayed(Duration.zero); + await exporter.exportAll(forceRetry: true); + expect(stepWrites, hasLength(1)); + }); } From f0a7e36ba0d806ec893efd6760a30030135b2a8d Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:12:39 +0530 Subject: [PATCH 19/21] health export: hold only deletes that overlap one still running, other windows go ahead --- lib/health/health_export.dart | 44 ++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/lib/health/health_export.dart b/lib/health/health_export.dart index 76f802596..a15faa146 100644 --- a/lib/health/health_export.dart +++ b/lib/health/health_export.dart @@ -496,34 +496,36 @@ class HealthExporter { /// Cleared at the start of every [_workoutLock] hold. bool _storeHung = false; - /// Native deletes still running, and since when. [Future.timeout] doesn't - /// cancel the plugin call, so a delete that timed out can still land after - /// a later pass wrote into its range and take those samples with it. - int _deletesInFlight = 0; - DateTime? _deletesInFlightSince; + /// Native deletes still running. [Future.timeout] doesn't cancel the + /// plugin call, so a delete that timed out can still land after a later + /// pass wrote into its range and take those samples with it. + final _pendingDeletes = + <({HealthDataType type, DateTime start, DateTime end, DateTime since})>[]; // ponytail: a hung native call may never call back, so the hold is capped; // a delete landing after the cap can still remove a newer sample. - static const _kInFlightDeleteHold = Duration(minutes: 30); - - /// The [_storeHung] a new lock hold starts with: hung while a timed-out - /// delete may still land. - bool get _deleteStillPending { - final since = _deletesInFlightSince; - return since != null && - DateTime.now().difference(since) < _kInFlightDeleteHold; - } + static const _kPendingDeleteHold = Duration(minutes: 30); + /// Every native delete goes through here. One that overlaps a delete of the + /// same type still running reads as a busy store (a timeout): nothing gets + /// written into that range and the pass ends without spending an attempt. Future _nativeDelete( HealthDataType type, DateTime start, DateTime end, ) { + final now = DateTime.now(); + _pendingDeletes + .removeWhere((d) => now.difference(d.since) >= _kPendingDeleteHold); + if (_pendingDeletes.any((d) => + d.type == type && d.start.isBefore(end) && start.isBefore(d.end))) { + return Future.error( + TimeoutException('an earlier ${type.name} delete is still running')); + } + final entry = (type: type, start: start, end: end, since: now); + _pendingDeletes.add(entry); final f = _health.delete(type: type, startTime: start, endTime: end); - if (_deletesInFlight++ == 0) _deletesInFlightSince = DateTime.now(); - f.whenComplete(() { - if (--_deletesInFlight == 0) _deletesInFlightSince = null; - }).ignore(); + f.whenComplete(() => _pendingDeletes.remove(entry)).ignore(); return f.timeout(_deleteTimeout); } @@ -606,7 +608,7 @@ class HealthExporter { await shared._ensureConfigured(); if (await shared._androidUnavailable() != null) return; await shared._workoutLock.run(() { - shared._storeHung = shared._deleteStillPending; + shared._storeHung = false; return shared._deleteOwnSamples( HealthDataType.WORKOUT, DateTime.fromMillisecondsSinceEpoch(startTs * 1000), @@ -891,7 +893,7 @@ class HealthExporter { await _ensureConfigured(); if (await _androidUnavailable() != null) return 0; // HC missing/outdated return _workoutLock.run(() async { - _storeHung = _deleteStillPending; + _storeHung = false; try { await ensureHealthSleepExportEpoch( getCursor: LocalDb.getCursor, @@ -1657,7 +1659,7 @@ class HealthExporter { final en = (session['end_ts'] as num?)?.toInt(); if (st == null || en == null || en <= st) return false; return _workoutLock.run(() async { - _storeHung = _deleteStillPending; + _storeHung = false; try { await _ensureConfigured(); if (await _androidUnavailable() != null) return false; From 3466c52fb1d39fd94ac603d654cd73e09f558734 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:48:20 +0530 Subject: [PATCH 20/21] gen4: hold the keep-alive live reassert until the drain ends --- lib/ble/ble_engine.dart | 4 +++- test/live_stream_ownership_test.dart | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index ebb88849b..cf225c078 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -8929,7 +8929,9 @@ class BleEngine { ); final generation = _linkGeneration; if (step == null) { - if (_liveReassert) { + // The reassert re-sends the gen4 IMU bundle too; hold it (flag + // stays set) while a drain runs, the offload end re-runs this. + if (_liveReassert && !(_offloadActive && !gen5)) { _liveReassert = false; await _reassertLive(session, generation); } diff --git a/test/live_stream_ownership_test.dart b/test/live_stream_ownership_test.dart index edd237871..c13881e6e 100644 --- a/test/live_stream_ownership_test.dart +++ b/test/live_stream_ownership_test.dart @@ -583,6 +583,18 @@ void main() { expect(rig.opcodes, contains(_imu)); }); + test('a keep-alive reassert waits for the offload to end', () async { + final rig = _Rig(band: BandProfile.gen4); + await rig.setOwners(fg); + final before = rig.ops.length; + rig.engine.debugSetOffloadActive(true); + await rig.engine.debugReassertLiveStreams(); + expect(rig.ops.length, before, reason: 'no R10/IMU re-arm mid-drain'); + rig.engine.debugSetOffloadActive(false); + await rig.reconcile(); + expect(rig.ops.sublist(before), contains((_r10, 1))); + }); + test('full → off: optical mode, optical save, R10/R11, IMU, then HR', () async { final rig = _Rig(band: BandProfile.gen4); await rig.setOwners(fg); From 90518226cdc36eab4ff2e09f21fb4e93d1e22654 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:52:30 +0530 Subject: [PATCH 21/21] health export: purge-held days stay pending without an attempt, no write beside an uncleared delete --- lib/health/health_export.dart | 39 +++++--- test/health_export_purge_hold_test.dart | 120 ++++++++++++++++++++++++ 2 files changed, 146 insertions(+), 13 deletions(-) create mode 100644 test/health_export_purge_hold_test.dart diff --git a/lib/health/health_export.dart b/lib/health/health_export.dart index a15faa146..0e227ee9d 100644 --- a/lib/health/health_export.dart +++ b/lib/health/health_export.dart @@ -533,6 +533,10 @@ class HealthExporter { /// for it (see [purgeLegacyStepsBehindCursor]). bool _stepWritesHeld = false; + /// Set by [_exportDay] when the day failed only because its steps were held + /// behind the purge: exportAll keeps it pending without spending an attempt. + bool _dayHeldForPurge = false; + HealthExporter({ HealthConnectHeartRateWriter? androidHeartRate, @visibleForTesting Duration deleteTimeout = const Duration(seconds: 30), @@ -1102,6 +1106,7 @@ class HealthExporter { // Not due for retry yet — don't hammer the health store on every // drain/derive pass; counts as "not done" for the cursor below. } else { + _dayHeldForPurge = false; ok = await _exportDay( date, bundle, @@ -1122,10 +1127,11 @@ class HealthExporter { retryState[date] = {'ok_ms': nowMs}; retryStateDirty = true; } - } else if (_storeHung) { - // Store timed out (locked phone): transient, not a failed - // export. Leave the day pending without spending an attempt, - // or locked background passes burn the cap and give it up. + } else if (_storeHung || _dayHeldForPurge) { + // Store timed out (locked phone), or the day's steps are waiting + // on the legacy purge: transient, not a failed export. Leave the + // day pending without spending an attempt, or the cap gives it + // up and the cursor skips its steps for good. debugPrint('[health] day $date not exported, store unavailable'); } else { final nextAttempts = attempts + 1; @@ -1314,11 +1320,9 @@ class HealthExporter { // // A type whose delete did NOT clear the window must not be re-written on // top of the survivor — that is how a retry turns one stale sample into - // two, then three. Only WORKOUT is tracked, because a duplicated workout - // is the one that shows up as a second entry in the user's activity list - // (and is the type `exportWorkout` re-writes out-of-band too); the scalar - // types below still write unconditionally, so an uncleared RHR/HRV window - // can still double until the next successful delete replaces both. + // two, then three, or how a delete still running from an earlier pass + // lands after the write and takes the new sample with it. Every type that + // didn't clear skips its write this pass and the day stays pending. // // This day-wide WORKOUT delete runs before the per-session loop below // skips any end_ts_fabricated row (see _writeOneWorkout) — that is safe @@ -1330,12 +1334,14 @@ class HealthExporter { // row, that invariant breaks and this delete would need to become // per-session instead of day-wide. var workoutCleared = true; + final uncleared = {}; for (final t in _rewriteTypes) { final from = t == HealthDataType.HEART_RATE ? hrFrom : dayStart; if (from == null) continue; if (await _deleteOwnSamples(t, from, dayEnd)) continue; debugPrint('[health] delete ${t.name} did not clear the day'); success = false; + uncleared.add(t); if (t == HealthDataType.WORKOUT) workoutCleared = false; } @@ -1358,6 +1364,7 @@ class HealthExporter { DateTime t, ) async { if (v == null || v <= 0) return; // absent input, not a failure + if (uncleared.contains(type)) return; // day already failed above try { final wrote = await _health.writeHealthData( value: v.toDouble(), @@ -1411,7 +1418,9 @@ class HealthExporter { // them vanish from the day instead of preventing a double count. final cal = sc('calories')?.toDouble() ?? 0.0; - if (cal > 0 && bucketCount > 0) { + if (cal > 0 && + bucketCount > 0 && + !uncleared.contains(HealthDataType.ACTIVE_ENERGY_BURNED)) { final calPerHour = cal / bucketCount; for (int i = 0; i < bucketCount; i++) { try { @@ -1439,7 +1448,8 @@ class HealthExporter { if (calTotal != null && rawCal != null && calTotal > rawCal && - bucketCount > 0) { + bucketCount > 0 && + !uncleared.contains(HealthDataType.BASAL_ENERGY_BURNED)) { final basal = (calTotal - rawCal).toDouble(); final basalPerHour = basal / bucketCount; for (int i = 0; i < bucketCount; i++) { @@ -1501,8 +1511,11 @@ class HealthExporter { } } if (stepSpans.isNotEmpty) { - if (_stepWritesHeld || - !await _deleteOwnSamples(HealthDataType.STEPS, dayStart, dayEnd)) { + if (_stepWritesHeld) { + success = false; + _dayHeldForPurge = true; + } else if (!await _deleteOwnSamples( + HealthDataType.STEPS, dayStart, dayEnd)) { success = false; } else { for (final s in stepSpans) { diff --git a/test/health_export_purge_hold_test.dart b/test/health_export_purge_hold_test.dart new file mode 100644 index 000000000..0c0aba5aa --- /dev/null +++ b/test/health_export_purge_hold_test.dart @@ -0,0 +1,120 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/health/health_export.dart'; + +/// A day held behind the legacy steps purge, or behind a delete still running, +/// stays pending: no attempt is charged and nothing is written beside the +/// delete. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('flutter_health'); + const appleSleep = MethodChannel('openstrap/healthkit_sleep'); + final now = DateTime.now(); + final y = DateTime(now.year, now.month, now.day - 1); + String two(int x) => x.toString().padLeft(2, '0'); + final day = '${y.year}-${two(y.month)}-${two(y.day)}'; + final noon = DateTime(y.year, y.month, y.day, 12).millisecondsSinceEpoch ~/ 1000; + final writes = []; + var purgeFails = false; + Completer? hangRhr; + + Future putDay(Map payload) => LocalDb.putDayResult( + dayId: day, + algoVersion: 120, + payloadJson: jsonEncode(payload), + windowJson: '{}', + finalized: false, + ); + + Future> retryState() async { + final raw = await LocalDb.getCursor('health_export_retry_state') ?? ''; + return raw.isEmpty + ? const {} + : (jsonDecode(raw) as Map).cast(); + } + + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_health_purge_hold_test.db'; + await databaseFactory.deleteDatabase( + p.join(await databaseFactory.getDatabasesPath(), LocalDb.dbName), + ); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + final args = call.arguments is Map ? call.arguments as Map : const {}; + final type = args['dataTypeKey']; + if (call.method == 'delete') { + if (type == 'STEPS' && args['startTime'] == 0 && purgeFails) { + throw PlatformException(code: 'busy'); + } + final hang = hangRhr; + if (type == 'RESTING_HEART_RATE' && hang != null) return hang.future; + return true; + } + if (call.method == 'writeData') writes.add('$type'); + return call.method == 'writeData' ? true : null; + }); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(appleSleep, (_) async => true); + }); + + tearDownAll(() async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(appleSleep, null); + await LocalDb.close(); + await databaseFactory.deleteDatabase( + p.join(await databaseFactory.getDatabasesPath(), LocalDb.dbName), + ); + }); + + test('a day held behind a failing steps purge spends no attempt', () async { + final exporter = HealthExporter(); + await putDay({ + 'steps': { + 'source': 'strap_counter', + 'band_measured': 400, + 'spans': [ + {'start_ts': noon, 'end_ts': noon + 3600, 'steps': 400}, + ], + }, + }); + purgeFails = true; + await exporter.exportAll(forceRetry: true); + expect(writes, isNot(contains('STEPS'))); + expect((await retryState())[day], isNull, + reason: 'a purge hold is not a failed export'); + + purgeFails = false; + await exporter.exportAll(forceRetry: true); + expect(writes, contains('STEPS')); + }); + + test('no scalar is written beside a delete still running', () async { + final exporter = + HealthExporter(deleteTimeout: const Duration(milliseconds: 50)); + await putDay({ + 'scalars': {'rhr': 55}, + }); + writes.clear(); + hangRhr = Completer(); + await exporter.exportAll(forceRetry: true); + await exporter.exportAll(forceRetry: true); + expect(writes, isNot(contains('RESTING_HEART_RATE'))); + + final hang = hangRhr!; + hangRhr = null; + hang.complete(true); + await Future.delayed(Duration.zero); + await exporter.exportAll(forceRetry: true); + expect(writes, contains('RESTING_HEART_RATE')); + }); +}