diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index c338a8395..cf225c078 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 { @@ -1899,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). /// @@ -2268,6 +2276,10 @@ class BleEngine { final ChunkFailureLedger _chunkFailures = ChunkFailureLedger(); 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 @@ -2457,7 +2469,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. @@ -2638,6 +2650,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, @@ -2989,12 +3006,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; - state.autoReconnectPaused = false; + // 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. } catch (e) { // A failed bond is NOT benign: the strap gates every command behind // encryption, so downstream GATT ops will fail confusingly (writes @@ -3004,18 +3019,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(); } } @@ -3085,6 +3089,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; } @@ -3101,9 +3108,20 @@ 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, and so does a long time offline + // (the band wasn't being asked then). _emptySync = EmptySyncTracker(); - _stuckStrap = StuckStrapDetector(); + final bandId = session.device.remoteId.str; + final down = _linkDownAt; + if (bandId != _stuckStrapBand || + (down != null && + _monotonicSecs() - down > kStuckOfflineResetSeconds)) { + _stuckStrap = StuckStrapDetector(); + _stuckStrapBand = bandId; + } _frameCorruption = FrameCorruptionDetector(); _crcFailuresThisSession = 0; _burstMismatchStreak = 0; @@ -3238,6 +3256,9 @@ class BleEngine { return ok; } catch (e) { _log('connect setup failed: $e'); + if (e is FlutterBluePlusException && isLinkAuthRefusal(e.code)) { + _recordBondRefusal(); + } await _failConnect(); return false; } @@ -3499,29 +3520,15 @@ 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; - state.autoReconnectPaused = false; + // 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. } catch (e) { _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; } @@ -3561,6 +3568,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; } @@ -3590,6 +3600,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; } @@ -4717,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) { @@ -4807,6 +4821,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. @@ -4828,6 +4858,11 @@ class BleEngine { if (!state.needsRepairGuide) return; state.needsRepairGuide = false; _postBondLoop.reset(); + // 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.'); } @@ -6466,7 +6501,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'); } @@ -7727,6 +7764,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 @@ -7767,17 +7821,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. @@ -8881,10 +8925,13 @@ class BleEngine { imuFresh: _imuFresh && !gen5, imuDirty: _imuDirty, hrDirty: _hrDirty, + holdImuOn: _offloadActive && !gen5, ); 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); } @@ -9038,8 +9085,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; } @@ -9111,6 +9163,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 — @@ -9242,6 +9295,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. @@ -10098,7 +10153,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/ble/ble_state.dart b/lib/ble/ble_state.dart index eef8b7c1e..cf0457582 100644 --- a/lib/ble/ble_state.dart +++ b/lib/ble/ble_state.dart @@ -263,6 +263,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`. /// @@ -2196,16 +2204,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/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index a304e800f..16eb8578b 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. /// @@ -8661,17 +8665,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/lib/health/health_export.dart b/lib/health/health_export.dart index 4b1a6df92..0e227ee9d 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 @@ -244,27 +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 (cursor.isEmpty) return; if ((await getCursor(kHealthStepsPrefixPurgeCursor) ?? '').isNotEmpty) { - return; + return true; } - final d = DateTime.tryParse(cursor); - if (d == null) return; + // An empty cursor (first export, or a reset) still has to finish the purge + // 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), ); - await setCursor(kHealthStepsPrefixPurgeCursor, cursor); + 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; } } @@ -458,6 +496,47 @@ class HealthExporter { /// Cleared at the start of every [_workoutLock] hold. bool _storeHung = false; + /// 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 _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); + f.whenComplete(() => _pendingDeletes.remove(entry)).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; + + /// 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), @@ -565,11 +644,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 +666,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 @@ -623,13 +700,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) { @@ -653,9 +724,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) { @@ -842,15 +911,24 @@ 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), - 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 { + if (_storeHung) return false; + try { + return healthDeleteClearedRange( + deleted: await _nativeDelete(HealthDataType.STEPS, start, end), + ios: isApple, + ); + } on TimeoutException { + _storeHung = true; + rethrow; + } + }, ); final retryState = await _loadRetryState(); var retryStateDirty = false; @@ -1028,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, @@ -1048,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; @@ -1240,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 @@ -1256,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; } @@ -1284,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(), @@ -1337,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 { @@ -1365,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++) { @@ -1411,7 +1495,48 @@ 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); + 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. 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 (_stepWritesHeld) { + success = false; + _dayHeldForPurge = true; + } else 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/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/lib/state/sync_controller.dart b/lib/state/sync_controller.dart index 0baa0282e..17dc758ab 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(); } @@ -419,7 +420,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 +446,17 @@ class SyncController { ); break; } + 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; + } 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/lib/sync/sync_policy.dart b/lib/sync/sync_policy.dart index 9f8fa44dc..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 @@ -937,7 +942,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/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( 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 diff --git a/test/app_state_sync_triggers_test.dart b/test/app_state_sync_triggers_test.dart index 63c7909a3..7936d9196 100644 --- a/test/app_state_sync_triggers_test.dart +++ b/test/app_state_sync_triggers_test.dart @@ -262,6 +262,42 @@ 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('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/ble_state_test.dart b/test/ble_state_test.dart index dc913b1cc..e45ea0500 100644 --- a/test/ble_state_test.dart +++ b/test/ble_state_test.dart @@ -976,4 +976,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/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, diff --git a/test/gen5_bootstrap_official_test.dart b/test/gen5_bootstrap_official_test.dart index f43d8431c..4e4935011 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'; @@ -143,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, @@ -204,6 +211,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 +226,7 @@ class _Ops implements GattBootstrapOps { this.discoveryFails = false, this.memfaultPresent = true, this.failSubscribe = const {}, + this.subscribeError, this.afterSubscribe, }); @@ -258,7 +268,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 +1281,77 @@ 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('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('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(); + 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(); diff --git a/test/health_export_energy_steps_test.dart b/test/health_export_energy_steps_test.dart index 3a6812980..5e27087e4 100644 --- a/test/health_export_energy_steps_test.dart +++ b/test/health_export_energy_steps_test.dart @@ -42,4 +42,65 @@ void main() { await run('2026-08-03'); 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('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({ + '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); + } + }); } 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')); + }); +} 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..98682fe05 --- /dev/null +++ b/test/health_export_steps_source_switch_test.dart @@ -0,0 +1,121 @@ +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 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 = []; + Completer? hangSteps; + + 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); + final hang = hangSteps; + if (isSteps && hang != null) return hang.future; + 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)); + }); + + 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)); + }); +} diff --git a/test/live_stream_ownership_test.dart b/test/live_stream_ownership_test.dart index 5ea84e15f..c13881e6e 100644 --- a/test/live_stream_ownership_test.dart +++ b/test/live_stream_ownership_test.dart @@ -570,6 +570,31 @@ 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('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); 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, + ); + }); } 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; 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( diff --git a/test/support/app_state_sync_harness.dart b/test/support/app_state_sync_harness.dart index bc24c9bff..eb9592642 100644 --- a/test/support/app_state_sync_harness.dart +++ b/test/support/app_state_sync_harness.dart @@ -211,6 +211,12 @@ class SyncFakeEngine extends BleEngine { bool get liveEnabled => liveArmed; @override bool get historyStuckThisSession => stuck; + bool offloading = false; + int gateDrops = 0; + @override + int get historyGateDrops => gateDrops; + @override + bool get offloadActive => offloading; @override int? get strapHistoryNewestTs => newestTs; @override diff --git a/test/sync_controller_test.dart b/test/sync_controller_test.dart index 1506d2e73..f8a6e3418 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; 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);