From e8a7afa8845e259f812d04c1a29a703c362f940f Mon Sep 17 00:00:00 2001 From: Bucci <149576997+BucciMobile@users.noreply.github.com> Date: Wed, 4 Nov 2026 08:30:53 +0100 Subject: [PATCH 01/15] .coderabbit.yaml aktualisieren --- .coderabbit.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 770ba8d19..849ea7d60 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -25,3 +25,5 @@ reviews: # and they are the least likely place for a shipped bug. Re-include them # for a PR that is mostly test work. - "!test/**" + auto_review: + drafts: true \ No newline at end of file From 1fb0e12b83f17fe07f993bd415e4ffef297d971c Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Sat, 7 Nov 2026 09:44:05 +0000 Subject: [PATCH 02/15] Export confirmed naps to Apple Health as daytime sleep segments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HealthKit has no nap category (HKCategoryValueSleepAnalysis has no nap value), so a confirmed/manual nap is written as one inBed + one asleepUnspecified sleepAnalysis sample over the nap window — visible in Apple Health as an ordinary daytime sleep segment, never labelled as a nap, and never with fabricated stage samples. - lib/health/health_nap_export.dart: HealthNapExporter seam; only source 'confirmed'/'manual' rows, window re-validated at export time (5 min-6 h, not in the future, no overlap with the derived main sleep via ActivityStore.overlapsMainSleep); gated on the health_sync switch; iOS only (Android reports false, writes nothing); never throws. - ios/Runner/HealthKitSleepWriter.swift: replaceNap/deleteNapRange on the existing healthkit_sleep channel; delete-then-write of OUR own samples scoped to the nap window only, so repeated exports never duplicate and the main night's samples are never touched. - Hooks: nap confirm (local_repository_impl.confirmActivity), manual log, remove/reject and restore on the naps screen. - test/health_nap_export_test.dart: confirmed-only export, main-sleep overlap filtered, switch-off/Android no-op, repeat-export idempotence, delete path, error-as-false. Co-authored-by: BucciMobile --- ios/Runner/HealthKitSleepWriter.swift | 114 ++++++++++-- lib/data/local_repository_impl.dart | 8 + lib/health/health_nap_export.dart | 248 ++++++++++++++++++++++++++ lib/ui2/screens/naps.dart | 19 +- test/health_nap_export_test.dart | 247 +++++++++++++++++++++++++ 5 files changed, 613 insertions(+), 23 deletions(-) create mode 100644 lib/health/health_nap_export.dart create mode 100644 test/health_nap_export_test.dart diff --git a/ios/Runner/HealthKitSleepWriter.swift b/ios/Runner/HealthKitSleepWriter.swift index 65c9d43ce..60bc8b713 100644 --- a/ios/Runner/HealthKitSleepWriter.swift +++ b/ios/Runner/HealthKitSleepWriter.swift @@ -19,50 +19,93 @@ enum HealthKitSleepWriter { static func register(messenger: FlutterBinaryMessenger) { let channel = FlutterMethodChannel(name: channelName, binaryMessenger: messenger) channel.setMethodCallHandler { call, result in - guard call.method == "replaceSleepSession" else { + guard call.method == "replaceSleepSession" + || call.method == "replaceNap" + || call.method == "deleteNapRange" else { result(FlutterMethodNotImplemented) return } let args = call.arguments as? [String: Any] ?? [:] queue.async { - replace(args) { ok in - DispatchQueue.main.async { result(ok) } + switch call.method { + case "replaceNap": + replaceNap(args) { ok in + DispatchQueue.main.async { result(ok) } + } + case "deleteNapRange": + deleteRange(args) { ok in + DispatchQueue.main.async { result(ok) } + } + default: + replace(args) { ok in + DispatchQueue.main.async { result(ok) } + } } } } } - private static func replace(_ args: [String: Any], completion: @escaping (Bool) -> Void) { + // A confirmed nap: delete OUR overlapping sleepAnalysis samples in the nap + // window, then write one inBed envelope plus one asleep sample. HealthKit + // has no nap value (HKCategoryValueSleepAnalysis offers no nap case), so the + // nap appears in Apple Health as an ordinary daytime sleep segment — never + // labelled as a nap. No stage samples: a nap has no hypnogram, and writing + // asleepCore/Deep/REM would fabricate stages. The cleanup window is the nap + // window itself, NOT the noon-to-noon night window — it must never touch + // the exported main sleep. + private static func replaceNap(_ args: [String: Any], completion: @escaping (Bool) -> Void) { guard HKHealthStore.isHealthDataAvailable() else { completion(true) // iPad / simulator: no store, not an export failure return } - guard let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) else { + guard let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis), + let start = dateMs(args["startTime"]), + let end = dateMs(args["endTime"]), + start < end else { completion(false) return } - guard let cleanupStart = dateMs(args["cleanupStartTime"]), - let cleanupEnd = dateMs(args["cleanupEndTime"]), - cleanupStart < cleanupEnd else { - completion(false) - return + let asleepValue: HKCategoryValueSleepAnalysis + if #available(iOS 16.0, *) { + asleepValue = .asleepUnspecified + } else { + asleepValue = .asleep // deprecated pre-16 name for asleepUnspecified } + let samples: [HKCategorySample] = [ + HKCategorySample(type: sleepType, value: HKCategoryValueSleepAnalysis.inBed.rawValue, start: start, end: end), + HKCategorySample(type: sleepType, value: asleepValue.rawValue, start: start, end: end), + ] + deleteAndSave(samples: samples, type: sleepType, cleanupStart: start, cleanupEnd: end, completion: completion) + } - let samples: [HKCategorySample] - do { - samples = try buildSamples(args, type: sleepType) - } catch { + // A nap removed/rejected in Edge: delete OUR sleepAnalysis samples + // overlapping the window so the export does not outlive the decision. + private static func deleteRange(_ args: [String: Any], completion: @escaping (Bool) -> Void) { + guard HKHealthStore.isHealthDataAvailable() else { + completion(true) + return + } + guard let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis), + let start = dateMs(args["startTime"]), + let end = dateMs(args["endTime"]), + start < end else { completion(false) return } + deleteAndSave(samples: [], type: sleepType, cleanupStart: start, cleanupEnd: end, completion: completion) + } - let datePred = HKQuery.predicateForSamples( - withStart: cleanupStart, end: cleanupEnd, options: [] - ) + private static func deleteAndSave( + samples: [HKCategorySample], + type: HKCategoryType, + cleanupStart: Date, + cleanupEnd: Date, + completion: @escaping (Bool) -> Void + ) { + let datePred = HKQuery.predicateForSamples(withStart: cleanupStart, end: cleanupEnd, options: []) let sourcePred = HKQuery.predicateForObjects(from: HKSource.default()) let pred = NSCompoundPredicate(andPredicateWithSubpredicates: [datePred, sourcePred]) - - store.deleteObjects(of: sleepType, predicate: pred) { _, _, error in + store.deleteObjects(of: type, predicate: pred) { _, _, error in if error != nil { completion(false) return @@ -77,6 +120,39 @@ enum HealthKitSleepWriter { } } + private static func replace(_ args: [String: Any], completion: @escaping (Bool) -> Void) { + guard HKHealthStore.isHealthDataAvailable() else { + completion(true) // iPad / simulator: no store, not an export failure + return + } + guard let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) else { + completion(false) + return + } + guard let cleanupStart = dateMs(args["cleanupStartTime"]), + let cleanupEnd = dateMs(args["cleanupEndTime"]), + cleanupStart < cleanupEnd else { + completion(false) + return + } + + let samples: [HKCategorySample] + do { + samples = try buildSamples(args, type: sleepType) + } catch { + completion(false) + return + } + + deleteAndSave( + samples: samples, + type: sleepType, + cleanupStart: cleanupStart, + cleanupEnd: cleanupEnd, + completion: completion + ) + } + private static func buildSamples( _ args: [String: Any], type: HKCategoryType diff --git a/lib/data/local_repository_impl.dart b/lib/data/local_repository_impl.dart index f74dba334..7d704eb71 100644 --- a/lib/data/local_repository_impl.dart +++ b/lib/data/local_repository_impl.dart @@ -37,6 +37,7 @@ import 'db.dart'; import 'activity_store.dart'; import '../models/activity_suggestion.dart'; import '../health/health_export.dart'; +import '../health/health_nap_export.dart'; import '../import/whoop_import.dart' show WhoopImporter; import 'journal_fields.dart'; import '../ble/adapters/_registry.dart' show kBandRegistry; @@ -93,6 +94,13 @@ class LocalRepositoryImpl extends LocalRepository { await ActivityStore(await LocalDb.instance).confirm(suggestion, startTs: start, endTs: end, edited: start != suggestion.startTs || end != suggestion.endTs); + // A nap the user just confirmed goes to Apple Health right away (iOS); + // the exporter re-checks the row and the main-sleep overlap itself. + await HealthNapExporter.exportConfirmedNap( + dayId: dayLabelOf(DateTime.fromMillisecondsSinceEpoch(start * 1000)), + startTs: start, + endTs: end, + ); } await onActivitiesChanged?.call(); } diff --git a/lib/health/health_nap_export.dart b/lib/health/health_nap_export.dart new file mode 100644 index 000000000..ec23a8151 --- /dev/null +++ b/lib/health/health_nap_export.dart @@ -0,0 +1,248 @@ +// health_nap_export.dart — write a user-confirmed nap to Apple Health +// (HealthKit) on iOS. +// +// HealthKit has NO nap category: the only sleep type is +// HKCategoryTypeIdentifier.sleepAnalysis, and HKCategoryValueSleepAnalysis +// offers exactly inBed / awake / asleepCore / asleepDeep / asleepREM / +// asleepUnspecified (plus the deprecated asleep). There is no value and no +// metadata key the Health app renders as "nap". So a nap is exported as an +// inBed envelope plus one asleepUnspecified sample over the confirmed window +// — it shows up in Apple Health as an ordinary (daytime) sleep segment and +// counts toward Time Asleep there; it is NOT labelled as a nap. +// +// Naps carry no hypnogram (the detector yields a window only), so no stage +// samples are written — fabricating asleepCore/Deep/REM would violate the +// app's never-fabricate rule. +// +// Only rows with source 'confirmed' or 'manual' are ever exported; 'pending' +// detections and 'rejected' windows are not. The window is re-validated at +// export time (bounds, not in the future, and NOT overlapping the derived +// main sleep of the surrounding nights — the same check the confirm path +// enforces, repeated here so a stale row or a shifted night can never push +// night-sleep minutes into Health as a nap). +// +// Idempotency: the native writer deletes THIS APP's own sleepAnalysis samples +// overlapping the nap window before writing, so repeated exports never +// duplicate. Removing/rejecting a nap in Edge deletes the same window. +// +// iOS only for now — Health Connect (Android) has no nap export yet; on +// Android every method returns false and writes nothing. Nothing here throws: +// a missing store, a denied permission or a failed write reads as false. + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../compute/nap_edits.dart' show manualNapWindowIsValid; +import '../data/activity_store.dart'; +import '../data/db.dart'; +import 'health_export.dart' show HealthExporter, kHealthSyncPref; + +/// Native side: `HealthKitSleepWriter` on the existing sleep channel. +abstract interface class HealthKitNapWriter { + /// Delete our own sleepAnalysis samples overlapping [start, end), then write + /// one inBed + one asleepUnspecified sample over [start, end). + Future replaceNap({required DateTime start, required DateTime end}); + + /// Delete our own sleepAnalysis samples overlapping [start, end). + Future deleteRange({required DateTime start, required DateTime end}); +} + +class MethodChannelHealthKitNapWriter implements HealthKitNapWriter { + MethodChannelHealthKitNapWriter({ + this.channel = const MethodChannel('openstrap/healthkit_sleep'), + }); + + final MethodChannel channel; + Future _pending = Future.value(); + + @override + Future replaceNap({required DateTime start, required DateTime end}) => + _invoke('replaceNap', { + 'startTime': start.millisecondsSinceEpoch, + 'endTime': end.millisecondsSinceEpoch, + }); + + @override + Future deleteRange({required DateTime start, required DateTime end}) => + _invoke('deleteNapRange', { + 'startTime': start.millisecondsSinceEpoch, + 'endTime': end.millisecondsSinceEpoch, + }); + + Future _invoke(String method, Map args) { + final result = Completer(); + _pending = _pending.then((_) async { + try { + // FINITE timeout, same as the sleep-session writer: a native handler + // that never calls back must not leave this future pending forever. + result.complete( + await channel + .invokeMethod(method, args) + .timeout(const Duration(seconds: 30)) == + true, + ); + } catch (error, stackTrace) { + result.completeError(error, stackTrace); + } + }); + return result.future; + } +} + +/// Whether a `sleep_nap` row may be exported: only a confirmed or manually +/// logged nap inside the 5 min – 6 h window that has already ended. +@visibleForTesting +bool napWindowExportable({ + required String? source, + required int startTs, + required int endTs, + required int nowSec, +}) { + if (source != 'confirmed' && source != 'manual') return false; + if (!manualNapWindowIsValid(startTs, endTs)) return false; + return endTs <= nowSec; +} + +/// Whether [startTs, endTs) overlaps a main-sleep window given in epoch ms. +@visibleForTesting +bool napOverlapsWindowMs(int startTs, int endTs, num? onsetMs, num? offsetMs) { + if (onsetMs == null || offsetMs == null) return false; + return startTs * 1000 < offsetMs && endTs * 1000 > onsetMs; +} + +class HealthNapExporter { + HealthNapExporter({ + HealthKitNapWriter? writer, + bool? isApple, + Future Function()? healthSyncEnabled, + Future?> Function(String dayId, int startTs)? napRow, + Future Function(int startTs, int endTs)? overlapsMainSleep, + }) : _writer = writer ?? MethodChannelHealthKitNapWriter(), + isApple = isApple ?? HealthExporter.isApple, + _healthSyncEnabled = + healthSyncEnabled ?? _defaultHealthSyncEnabled, + _napRow = napRow ?? _defaultNapRow, + _overlapsMainSleep = + overlapsMainSleep ?? _defaultOverlapsMainSleep; + + final HealthKitNapWriter _writer; + final bool isApple; + final Future Function() _healthSyncEnabled; + final Future?> Function(String dayId, int startTs) + _napRow; + final Future Function(int startTs, int endTs) _overlapsMainSleep; + + /// Process-wide exporter; call sites use the static wrappers below. + static final HealthNapExporter shared = HealthNapExporter(); + + /// Export the nap stored at ([dayId], [startTs]) — its [endTs] is read from + /// the row. Used after "put back" restores a rejected nap to 'confirmed'. + /// Never throws; false when nothing was written. + static Future exportNapRow({ + required String dayId, + required int startTs, + }) async { + final row = await shared._napRow(dayId, startTs); + final endTs = row?['end_ts']; + if (endTs is! num) return false; + return shared.exportNap( + dayId: dayId, + startTs: startTs, + endTs: endTs.toInt(), + ); + } + + /// Export a confirmed/manual nap window to Apple Health. Re-reads the row + /// and re-checks the main-sleep overlap at export time. Never throws; + /// false when nothing was written (switch off, Android, not confirmed, + /// overlap, store error). + static Future exportConfirmedNap({ + required String dayId, + required int startTs, + required int endTs, + }) => shared.exportNap(dayId: dayId, startTs: startTs, endTs: endTs); + + /// Remove a previously exported nap window from Apple Health (the nap was + /// deleted or rejected in Edge). Never throws; false when nothing was + /// deleted. + static Future deleteExportedNap({ + required int startTs, + required int endTs, + }) => shared.deleteNapWindow(startTs: startTs, endTs: endTs); + + Future exportNap({ + required String dayId, + required int startTs, + required int endTs, + }) async { + try { + // Health Connect has no nap export yet; never claim one happened. + if (!isApple) return false; + if (!await _healthSyncEnabled()) return false; + final row = await _napRow(dayId, startTs); + final source = row?['source']; + final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000; + if (!napWindowExportable( + source: source is String ? source : null, + startTs: startTs, + endTs: endTs, + nowSec: nowSec, + )) { + return false; + } + // Defense in depth: the confirm/log paths already refuse windows that + // overlap the main sleep, but re-check here so a stale row or a night + // that moved since can never be written as a nap. + if (await _overlapsMainSleep(startTs, endTs)) return false; + return await _writer.replaceNap( + start: DateTime.fromMillisecondsSinceEpoch(startTs * 1000), + end: DateTime.fromMillisecondsSinceEpoch(endTs * 1000), + ); + } catch (e) { + debugPrint('[health] exportNap $dayId $startTs-$endTs: $e'); + return false; + } + } + + Future deleteNapWindow({ + required int startTs, + required int endTs, + }) async { + try { + if (!isApple) return false; + if (!await _healthSyncEnabled()) return false; + if (endTs <= startTs) return false; + return await _writer.deleteRange( + start: DateTime.fromMillisecondsSinceEpoch(startTs * 1000), + end: DateTime.fromMillisecondsSinceEpoch(endTs * 1000), + ); + } catch (e) { + debugPrint('[health] deleteNapWindow $startTs-$endTs: $e'); + return false; + } + } + + static Future _defaultHealthSyncEnabled() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(kHealthSyncPref) == true; + } + + static Future?> _defaultNapRow( + String dayId, + int startTs, + ) async { + final db = await LocalDb.instance; + final rows = await db.query( + 'sleep_nap', + where: 'day_id = ? AND start_ts = ?', + whereArgs: [dayId, startTs], + ); + return rows.isEmpty ? null : rows.single; + } + + static Future _defaultOverlapsMainSleep(int startTs, int endTs) async => + ActivityStore.overlapsMainSleep(await LocalDb.instance, startTs, endTs); +} diff --git a/lib/ui2/screens/naps.dart b/lib/ui2/screens/naps.dart index d8a6bda96..b4178ef99 100644 --- a/lib/ui2/screens/naps.dart +++ b/lib/ui2/screens/naps.dart @@ -32,6 +32,7 @@ import '../../compute/nap_edits.dart'; import '../../data/day_label.dart'; import '../../data/db.dart'; import '../../data/local_repository.dart'; +import '../../health/health_nap_export.dart'; import '../../l10n/app_localizations.dart'; import '../../models/metric.dart' show whyFromNote; import '../../state/app_state.dart'; @@ -212,10 +213,16 @@ class _NapsScreenState extends State { await LocalDb.putNapEdit( dayId: day, startTs: start, endTs: end, source: 'rejected'); } + // The window is no longer a confirmed nap (deleted, or replaced by a + // rejection), so its Apple Health samples go too. + await HealthNapExporter.deleteExportedNap(startTs: start, endTs: end); }); - Future _restore(String day, int start) => - _edit(() => LocalDb.deleteNapEdit(day, start)); + Future _restore(String day, int start) => _edit(() async { + await LocalDb.deleteNapEdit(day, start); + // "Put it back" restores the row as 'confirmed' — re-export it. + await HealthNapExporter.exportNapRow(dayId: day, startTs: start); + }); /// Two pickers on the day being shown. Deliberately not a duration: people /// remember when they lay down and when they got up, not how long it was. @@ -275,8 +282,12 @@ class _NapsScreenState extends State { return; } if (!mounted) return; - await _edit(() => LocalDb.putNapEdit( - dayId: day, startTs: s, endTs: e, source: 'manual')); + await _edit(() async { + await LocalDb.putNapEdit( + dayId: day, startTs: s, endTs: e, source: 'manual'); + await HealthNapExporter.exportConfirmedNap( + dayId: day, startTs: s, endTs: e); + }); } @override diff --git a/test/health_nap_export_test.dart b/test/health_nap_export_test.dart new file mode 100644 index 000000000..56302a322 --- /dev/null +++ b/test/health_nap_export_test.dart @@ -0,0 +1,247 @@ +// Nap -> Apple Health export: only confirmed/manual naps, never windows +// overlapping the derived main sleep; delete-then-write makes repeated +// exports idempotent; removing a nap deletes its Health window. + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:openstrap_edge/health/health_nap_export.dart'; + +class FakeNapWriter implements HealthKitNapWriter { + final calls = <({String method, DateTime start, DateTime end})>[]; + bool nextResult = true; + bool throwOnCall = false; + + @override + Future replaceNap({required DateTime start, required DateTime end}) => + _record('replaceNap', start, end); + + @override + Future deleteRange({required DateTime start, required DateTime end}) => + _record('deleteNapRange', start, end); + + Future _record(String method, DateTime start, DateTime end) async { + calls.add((method: method, start: start, end: end)); + if (throwOnCall) throw Exception('store error'); + return nextResult; + } +} + +HealthNapExporter exporter({ + required FakeNapWriter writer, + bool isApple = true, + bool healthSync = true, + Map? row, + bool overlaps = false, +}) => + HealthNapExporter( + writer: writer, + isApple: isApple, + healthSyncEnabled: () async => healthSync, + napRow: (dayId, startTs) async => row, + overlapsMainSleep: (s, e) async => overlaps, + ); + +void main() { + const nowSec = 1785700000; + // A 30 min afternoon nap window. + const start = 1785680000; + const end = start + 1800; + + group('napWindowExportable', () { + test('confirmed and manual naps inside the bounds are exportable', () { + for (final source in ['confirmed', 'manual']) { + expect( + napWindowExportable( + source: source, startTs: start, endTs: end, nowSec: nowSec), + isTrue, + ); + } + }); + + test('pending, rejected and legacy rows are never exportable', () { + for (final source in ['pending', 'rejected', 'legacy', null]) { + expect( + napWindowExportable( + source: source, startTs: start, endTs: end, nowSec: nowSec), + isFalse, + ); + } + }); + + test('windows outside 5 min - 6 h are not exportable', () { + expect( + napWindowExportable( + source: 'confirmed', + startTs: start, + endTs: start + 4 * 60, + nowSec: nowSec), + isFalse, + ); + expect( + napWindowExportable( + source: 'confirmed', + startTs: start, + endTs: start + 6 * 3600 + 60, + nowSec: nowSec), + isFalse, + ); + }); + + test('a nap ending in the future is not exportable', () { + expect( + napWindowExportable( + source: 'manual', + startTs: nowSec - 600, + endTs: nowSec + 600, + nowSec: nowSec), + isFalse, + ); + }); + }); + + group('napOverlapsWindowMs', () { + test('overlap detection on epoch-ms main-sleep windows', () { + final onsetMs = (start + 3600) * 1000; + final offsetMs = (start + 3600 + 8 * 3600) * 1000; + expect(napOverlapsWindowMs(start, end, onsetMs, offsetMs), isFalse); + expect( + napOverlapsWindowMs(start + 3600, end + 3600, onsetMs, offsetMs), + isTrue, + ); + expect(napOverlapsWindowMs(start, end, null, offsetMs), isFalse); + }); + }); + + group('HealthNapExporter.exportNap', () { + test('a confirmed nap is written via replaceNap', () async { + final writer = FakeNapWriter(); + final ok = await exporter( + writer: writer, + row: {'source': 'confirmed', 'start_ts': start, 'end_ts': end}, + ).exportNap(dayId: '2026-08-03', startTs: start, endTs: end); + expect(ok, isTrue); + expect(writer.calls, hasLength(1)); + expect(writer.calls.single.method, 'replaceNap'); + expect(writer.calls.single.start.millisecondsSinceEpoch, start * 1000); + expect(writer.calls.single.end.millisecondsSinceEpoch, end * 1000); + }); + + test('a repeated export re-runs replaceNap (native delete-then-write)', () async { + final writer = FakeNapWriter(); + final e = exporter( + writer: writer, + row: {'source': 'manual', 'start_ts': start, 'end_ts': end}, + ); + expect( + await e.exportNap(dayId: '2026-08-03', startTs: start, endTs: end), + isTrue); + expect( + await e.exportNap(dayId: '2026-08-03', startTs: start, endTs: end), + isTrue); + expect( + writer.calls.where((c) => c.method == 'replaceNap'), + hasLength(2), + ); + }); + + test('a pending or rejected row is not exported', () async { + for (final source in ['pending', 'rejected']) { + final writer = FakeNapWriter(); + final ok = await exporter( + writer: writer, + row: {'source': source, 'start_ts': start, 'end_ts': end}, + ).exportNap(dayId: '2026-08-03', startTs: start, endTs: end); + expect(ok, isFalse); + expect(writer.calls, isEmpty); + } + }); + + test('a nap overlapping the main sleep is not exported', () async { + final writer = FakeNapWriter(); + final ok = await exporter( + writer: writer, + row: {'source': 'confirmed', 'start_ts': start, 'end_ts': end}, + overlaps: true, + ).exportNap(dayId: '2026-08-03', startTs: start, endTs: end); + expect(ok, isFalse); + expect(writer.calls, isEmpty); + }); + + test('nothing is written when the health sync switch is off', () async { + final writer = FakeNapWriter(); + final ok = await exporter( + writer: writer, + healthSync: false, + row: {'source': 'confirmed', 'start_ts': start, 'end_ts': end}, + ).exportNap(dayId: '2026-08-03', startTs: start, endTs: end); + expect(ok, isFalse); + expect(writer.calls, isEmpty); + }); + + test('nothing is written on Android (no Health Connect nap export)', () async { + final writer = FakeNapWriter(); + final ok = await exporter( + writer: writer, + isApple: false, + row: {'source': 'confirmed', 'start_ts': start, 'end_ts': end}, + ).exportNap(dayId: '2026-08-03', startTs: start, endTs: end); + expect(ok, isFalse); + expect(writer.calls, isEmpty); + }); + + test('a store error reads as false, never throws', () async { + final writer = FakeNapWriter()..throwOnCall = true; + final ok = await exporter( + writer: writer, + row: {'source': 'confirmed', 'start_ts': start, 'end_ts': end}, + ).exportNap(dayId: '2026-08-03', startTs: start, endTs: end); + expect(ok, isFalse); + }); + + test('a failed native write reports false (no fake success)', () async { + final writer = FakeNapWriter()..nextResult = false; + final ok = await exporter( + writer: writer, + row: {'source': 'confirmed', 'start_ts': start, 'end_ts': end}, + ).exportNap(dayId: '2026-08-03', startTs: start, endTs: end); + expect(ok, isFalse); + }); + }); + + group('HealthNapExporter.deleteNapWindow', () { + test('a removed nap window is deleted from the store', () async { + final writer = FakeNapWriter(); + final ok = await exporter(writer: writer) + .deleteNapWindow(startTs: start, endTs: end); + expect(ok, isTrue); + expect(writer.calls.single.method, 'deleteNapRange'); + }); + + test('delete is skipped when the switch is off or on Android', () async { + final off = FakeNapWriter(); + expect( + await exporter(writer: off, healthSync: false) + .deleteNapWindow(startTs: start, endTs: end), + isFalse, + ); + final android = FakeNapWriter(); + expect( + await exporter(writer: android, isApple: false) + .deleteNapWindow(startTs: start, endTs: end), + isFalse, + ); + expect(off.calls, isEmpty); + expect(android.calls, isEmpty); + }); + + test('an empty window is not deleted', () async { + final writer = FakeNapWriter(); + expect( + await exporter(writer: writer) + .deleteNapWindow(startTs: end, endTs: start), + isFalse, + ); + expect(writer.calls, isEmpty); + }); + }); +} From 28e42c6d46427d5f45f0d3fb913dcea7c41ad2b2 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Sat, 7 Nov 2026 10:07:09 +0000 Subject: [PATCH 03/15] Address PR review: row-authoritative nap window, per-day nap re-export, stale-call guard - exportNap now rejects a caller endTs that differs from the stored row's end_ts (the row is the source of truth). - _exportDay (iOS) re-writes the day's confirmed naps after the main-sleep replace: that replace's noon-to-noon cleanup window covers the wake day's afternoon and would otherwise delete our nap samples on every re-derive of a non-finalized day (previously missed in review). - Nap channel calls carry a monotonic seq; the native writer drops stale nap calls so a late replaceNap cannot overwrite a newer deleteNapRange. - The remove/reject Health delete is documented as best-effort. - Tests: stored-end mismatch, exportDayNaps filtering/failure/no-op paths. Co-authored-by: BucciMobile --- ios/Runner/HealthKitSleepWriter.swift | 19 +++++++ lib/health/health_export.dart | 10 ++++ lib/health/health_nap_export.dart | 56 +++++++++++++++++++ lib/ui2/screens/naps.dart | 5 +- test/health_nap_export_test.dart | 79 +++++++++++++++++++++++++++ 5 files changed, 168 insertions(+), 1 deletion(-) diff --git a/ios/Runner/HealthKitSleepWriter.swift b/ios/Runner/HealthKitSleepWriter.swift index 60bc8b713..ea1c64004 100644 --- a/ios/Runner/HealthKitSleepWriter.swift +++ b/ios/Runner/HealthKitSleepWriter.swift @@ -15,6 +15,11 @@ enum HealthKitSleepWriter { private static let channelName = "openstrap/healthkit_sleep" private static let store = HKHealthStore() private static let queue = DispatchQueue(label: "wtf.openstrap.healthkit.sleep") + // Nap calls carry a monotonic `seq` from Dart. A native call that outlives + // its Dart-side timeout can still land later; dropping any nap call whose + // seq is not newer than the last applied one keeps a late replaceNap from + // overwriting a newer deleteNapRange for the same window. Guarded by queue. + private static var lastNapSeq = 0 static func register(messenger: FlutterBinaryMessenger) { let channel = FlutterMethodChannel(name: channelName, binaryMessenger: messenger) @@ -54,6 +59,7 @@ enum HealthKitSleepWriter { // window itself, NOT the noon-to-noon night window — it must never touch // the exported main sleep. private static func replaceNap(_ args: [String: Any], completion: @escaping (Bool) -> Void) { + guard staleNapCallDropped(args, completion: completion) else { return } guard HKHealthStore.isHealthDataAvailable() else { completion(true) // iPad / simulator: no store, not an export failure return @@ -81,6 +87,7 @@ enum HealthKitSleepWriter { // A nap removed/rejected in Edge: delete OUR sleepAnalysis samples // overlapping the window so the export does not outlive the decision. private static func deleteRange(_ args: [String: Any], completion: @escaping (Bool) -> Void) { + guard staleNapCallDropped(args, completion: completion) else { return } guard HKHealthStore.isHealthDataAvailable() else { completion(true) return @@ -95,6 +102,18 @@ enum HealthKitSleepWriter { deleteAndSave(samples: [], type: sleepType, cleanupStart: start, cleanupEnd: end, completion: completion) } + // Runs on `queue`. Returns true when the call is stale and was dropped + // (completed with false — a dropped call never claims success). + private static func staleNapCallDropped(_ args: [String: Any], completion: @escaping (Bool) -> Void) -> Bool { + let seq = (args["seq"] as? NSNumber)?.intValue ?? 0 + if seq <= lastNapSeq { + completion(false) + return true + } + lastNapSeq = seq + return false + } + private static func deleteAndSave( samples: [HKCategorySample], type: HKCategoryType, diff --git a/lib/health/health_export.dart b/lib/health/health_export.dart index 0e227ee9d..d07a30391 100644 --- a/lib/health/health_export.dart +++ b/lib/health/health_export.dart @@ -30,6 +30,7 @@ import '../data/day_label.dart'; import '../data/db.dart'; import '../data/series_codec.dart'; import 'health_heart_rate_batch.dart'; +import 'health_nap_export.dart'; import 'health_sleep_session.dart'; /// What we can do with the health store right now. @@ -1259,6 +1260,15 @@ class HealthExporter { debugPrint('[health] write Apple sleep session: $e'); success = false; } + // The sleep replace's noon-to-noon cleanup window covers the wake day's + // afternoon too, so it deletes OUR nap samples there. Re-write this + // day's confirmed naps after it on every pass (delete-then-write, so + // idempotent) — otherwise the next re-derive of a non-finalized day + // would silently remove a nap the user confirmed. + if (!await HealthNapExporter.shared.exportDayNaps(date)) { + debugPrint('[health] write Apple naps returned false'); + success = false; + } } // One-shot cleanup of the fabricated step samples earlier versions wrote. diff --git a/lib/health/health_nap_export.dart b/lib/health/health_nap_export.dart index ec23a8151..857300b02 100644 --- a/lib/health/health_nap_export.dart +++ b/lib/health/health_nap_export.dart @@ -57,12 +57,18 @@ class MethodChannelHealthKitNapWriter implements HealthKitNapWriter { final MethodChannel channel; Future _pending = Future.value(); + // Monotonic per-call sequence. A native call that outlives its 30 s timeout + // can still land later; the native side drops nap calls whose seq is not + // newer than the last one it applied, so a late replaceNap can never + // overwrite a newer deleteNapRange for the same window. + int _seq = 0; @override Future replaceNap({required DateTime start, required DateTime end}) => _invoke('replaceNap', { 'startTime': start.millisecondsSinceEpoch, 'endTime': end.millisecondsSinceEpoch, + 'seq': ++_seq, }); @override @@ -70,6 +76,7 @@ class MethodChannelHealthKitNapWriter implements HealthKitNapWriter { _invoke('deleteNapRange', { 'startTime': start.millisecondsSinceEpoch, 'endTime': end.millisecondsSinceEpoch, + 'seq': ++_seq, }); Future _invoke(String method, Map args) { @@ -119,12 +126,14 @@ class HealthNapExporter { bool? isApple, Future Function()? healthSyncEnabled, Future?> Function(String dayId, int startTs)? napRow, + Future>> Function(String dayId)? dayNapRows, Future Function(int startTs, int endTs)? overlapsMainSleep, }) : _writer = writer ?? MethodChannelHealthKitNapWriter(), isApple = isApple ?? HealthExporter.isApple, _healthSyncEnabled = healthSyncEnabled ?? _defaultHealthSyncEnabled, _napRow = napRow ?? _defaultNapRow, + _dayNapRows = dayNapRows ?? LocalDb.napEdits, _overlapsMainSleep = overlapsMainSleep ?? _defaultOverlapsMainSleep; @@ -133,6 +142,7 @@ class HealthNapExporter { final Future Function() _healthSyncEnabled; final Future?> Function(String dayId, int startTs) _napRow; + final Future>> Function(String dayId) _dayNapRows; final Future Function(int startTs, int endTs) _overlapsMainSleep; /// Process-wide exporter; call sites use the static wrappers below. @@ -184,6 +194,11 @@ class HealthNapExporter { if (!await _healthSyncEnabled()) return false; final row = await _napRow(dayId, startTs); final source = row?['source']; + // The stored row is the source of truth: a caller passing a stale + // endTs (e.g. before an edit landed) must not export a window the row + // no longer holds. + final storedEnd = row?['end_ts']; + if (storedEnd is! num || storedEnd.toInt() != endTs) return false; final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000; if (!napWindowExportable( source: source is String ? source : null, @@ -207,6 +222,47 @@ class HealthNapExporter { } } + /// Export every exportable nap of [dayId]. Called from the per-day health + /// export AFTER the main-sleep replace: that replace's noon-to-noon + /// cleanup window also covers the wake day's afternoon, so it deletes OUR + /// nap samples there — re-writing them on every pass keeps them in the + /// store. Rows that are not exportable (pending/rejected/legacy, overlap + /// with main sleep) are skipped silently; false means an attempted write + /// failed. On Android or with the sync switch off this is a no-op `true`. + Future exportDayNaps(String dayId) async { + try { + if (!isApple) return true; + if (!await _healthSyncEnabled()) return true; + final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000; + var ok = true; + for (final row in await _dayNapRows(dayId)) { + final startTs = row['start_ts']; + final endTs = row['end_ts']; + final source = row['source']; + if (startTs is! num || endTs is! num) continue; + final s = startTs.toInt(), e = endTs.toInt(); + if (!napWindowExportable( + source: source is String ? source : null, + startTs: s, + endTs: e, + nowSec: nowSec, + )) { + continue; + } + if (await _overlapsMainSleep(s, e)) continue; + final written = await _writer.replaceNap( + start: DateTime.fromMillisecondsSinceEpoch(s * 1000), + end: DateTime.fromMillisecondsSinceEpoch(e * 1000), + ); + if (!written) ok = false; + } + return ok; + } catch (e) { + debugPrint('[health] exportDayNaps $dayId: $e'); + return false; + } + } + Future deleteNapWindow({ required int startTs, required int endTs, diff --git a/lib/ui2/screens/naps.dart b/lib/ui2/screens/naps.dart index b4178ef99..9bd4462a6 100644 --- a/lib/ui2/screens/naps.dart +++ b/lib/ui2/screens/naps.dart @@ -214,7 +214,10 @@ class _NapsScreenState extends State { dayId: day, startTs: start, endTs: end, source: 'rejected'); } // The window is no longer a confirmed nap (deleted, or replaced by a - // rejection), so its Apple Health samples go too. + // rejection), so its Apple Health samples go too. Best-effort on + // purpose: a failed delete returns false and is only logged — the + // next per-day export re-writes the day's remaining naps, and a + // stale sample here is harmless (it is the app's own window). await HealthNapExporter.deleteExportedNap(startTs: start, endTs: end); }); diff --git a/test/health_nap_export_test.dart b/test/health_nap_export_test.dart index 56302a322..bb3892141 100644 --- a/test/health_nap_export_test.dart +++ b/test/health_nap_export_test.dart @@ -31,6 +31,7 @@ HealthNapExporter exporter({ bool isApple = true, bool healthSync = true, Map? row, + List>? dayRows, bool overlaps = false, }) => HealthNapExporter( @@ -38,6 +39,7 @@ HealthNapExporter exporter({ isApple: isApple, healthSyncEnabled: () async => healthSync, napRow: (dayId, startTs) async => row, + dayNapRows: (dayId) async => dayRows ?? (row == null ? [] : [row]), overlapsMainSleep: (s, e) async => overlaps, ); @@ -156,6 +158,16 @@ void main() { } }); + test('a caller endTs that differs from the stored row is rejected', () async { + final writer = FakeNapWriter(); + final ok = await exporter( + writer: writer, + row: {'source': 'confirmed', 'start_ts': start, 'end_ts': end + 600}, + ).exportNap(dayId: '2026-08-03', startTs: start, endTs: end); + expect(ok, isFalse); + expect(writer.calls, isEmpty); + }); + test('a nap overlapping the main sleep is not exported', () async { final writer = FakeNapWriter(); final ok = await exporter( @@ -244,4 +256,71 @@ void main() { expect(writer.calls, isEmpty); }); }); + + group('HealthNapExporter.exportDayNaps', () { + final confirmed = { + 'source': 'confirmed', + 'start_ts': start, + 'end_ts': end, + }; + final rejected = { + 'source': 'rejected', + 'start_ts': start + 7200, + 'end_ts': start + 7200 + 1800, + }; + + test('writes only the exportable rows of the day', () async { + final writer = FakeNapWriter(); + final ok = await exporter(writer: writer, dayRows: [confirmed, rejected]) + .exportDayNaps('2026-08-03'); + expect(ok, isTrue); + expect( + writer.calls.where((c) => c.method == 'replaceNap'), + hasLength(1), + ); + }); + + test('a failed attempted write reports false', () async { + final writer = FakeNapWriter()..nextResult = false; + final ok = await exporter(writer: writer, dayRows: [confirmed]) + .exportDayNaps('2026-08-03'); + expect(ok, isFalse); + }); + + test('rows overlapping the main sleep are skipped, not failed', () async { + final writer = FakeNapWriter(); + final ok = await exporter( + writer: writer, + dayRows: [confirmed], + overlaps: true, + ).exportDayNaps('2026-08-03'); + expect(ok, isTrue); + expect(writer.calls, isEmpty); + }); + + test('Android and switch-off are no-ops, not failures', () async { + final android = FakeNapWriter(); + expect( + await exporter(writer: android, isApple: false, dayRows: [confirmed]) + .exportDayNaps('2026-08-03'), + isTrue, + ); + final off = FakeNapWriter(); + expect( + await exporter(writer: off, healthSync: false, dayRows: [confirmed]) + .exportDayNaps('2026-08-03'), + isTrue, + ); + expect(android.calls, isEmpty); + expect(off.calls, isEmpty); + }); + + test('a store error during the day pass reports false, never throws', + () async { + final writer = FakeNapWriter()..throwOnCall = true; + final ok = await exporter(writer: writer, dayRows: [confirmed]) + .exportDayNaps('2026-08-03'); + expect(ok, isFalse); + }); + }); } From 7cebb7600bb35db76ae0300a832a35cec1a1f5d7 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Sat, 7 Nov 2026 10:30:16 +0000 Subject: [PATCH 04/15] Make nap export tests clock-relative exportNap validates 'not in the future' against DateTime.now(), so fixed timestamps would rot; derive the test window from the real clock. Co-authored-by: BucciMobile --- test/health_nap_export_test.dart | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/test/health_nap_export_test.dart b/test/health_nap_export_test.dart index bb3892141..47b252891 100644 --- a/test/health_nap_export_test.dart +++ b/test/health_nap_export_test.dart @@ -44,10 +44,12 @@ HealthNapExporter exporter({ ); void main() { - const nowSec = 1785700000; - // A 30 min afternoon nap window. - const start = 1785680000; - const end = start + 1800; + // Derived from the real clock: exportNap validates "not in the future" + // against DateTime.now(), so fixed timestamps would rot. + final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000; + // A 30 min nap window that ended an hour ago. + final start = nowSec - 7200; + final end = start + 1800; group('napWindowExportable', () { test('confirmed and manual naps inside the bounds are exportable', () { From c3777e9d8a312a848ea6e022ddf02e2f9e745ca8 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Sat, 7 Nov 2026 10:49:04 +0000 Subject: [PATCH 05/15] Fix inverted native seq guard; isolate per-row failures in exportDayNaps - staleNapCallDropped returns true for stale calls, but callers used 'guard ... else { return }', which returns on FRESH calls (never completing them -> Dart timeout) and executes stale ones. Now 'if'. - exportDayNaps: a throwing row (overlap lookup / store error) no longer aborts the loop; later naps are still written and the day reports false so it stays eligible for retry. - Test: one failing row does not skip the remaining naps. Co-authored-by: BucciMobile --- ios/Runner/HealthKitSleepWriter.swift | 4 ++-- lib/health/health_nap_export.dart | 21 +++++++++++++++------ test/health_nap_export_test.dart | 22 +++++++++++++++++++++- 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/ios/Runner/HealthKitSleepWriter.swift b/ios/Runner/HealthKitSleepWriter.swift index ea1c64004..a65bac342 100644 --- a/ios/Runner/HealthKitSleepWriter.swift +++ b/ios/Runner/HealthKitSleepWriter.swift @@ -59,7 +59,7 @@ enum HealthKitSleepWriter { // window itself, NOT the noon-to-noon night window — it must never touch // the exported main sleep. private static func replaceNap(_ args: [String: Any], completion: @escaping (Bool) -> Void) { - guard staleNapCallDropped(args, completion: completion) else { return } + if staleNapCallDropped(args, completion: completion) { return } guard HKHealthStore.isHealthDataAvailable() else { completion(true) // iPad / simulator: no store, not an export failure return @@ -87,7 +87,7 @@ enum HealthKitSleepWriter { // A nap removed/rejected in Edge: delete OUR sleepAnalysis samples // overlapping the window so the export does not outlive the decision. private static func deleteRange(_ args: [String: Any], completion: @escaping (Bool) -> Void) { - guard staleNapCallDropped(args, completion: completion) else { return } + if staleNapCallDropped(args, completion: completion) { return } guard HKHealthStore.isHealthDataAvailable() else { completion(true) return diff --git a/lib/health/health_nap_export.dart b/lib/health/health_nap_export.dart index 857300b02..9b146ab8a 100644 --- a/lib/health/health_nap_export.dart +++ b/lib/health/health_nap_export.dart @@ -249,12 +249,21 @@ class HealthNapExporter { )) { continue; } - if (await _overlapsMainSleep(s, e)) continue; - final written = await _writer.replaceNap( - start: DateTime.fromMillisecondsSinceEpoch(s * 1000), - end: DateTime.fromMillisecondsSinceEpoch(e * 1000), - ); - if (!written) ok = false; + // Per-row isolation: one throwing row (overlap lookup, store error) + // must not skip the remaining naps — the main-sleep replace above + // may already have deleted their Health samples, and a day that + // gives up retries would leave them missing. + try { + if (await _overlapsMainSleep(s, e)) continue; + final written = await _writer.replaceNap( + start: DateTime.fromMillisecondsSinceEpoch(s * 1000), + end: DateTime.fromMillisecondsSinceEpoch(e * 1000), + ); + if (!written) ok = false; + } catch (rowError) { + debugPrint('[health] exportDayNaps $dayId row $s-$e: $rowError'); + ok = false; + } } return ok; } catch (e) { diff --git a/test/health_nap_export_test.dart b/test/health_nap_export_test.dart index 47b252891..76582e0e3 100644 --- a/test/health_nap_export_test.dart +++ b/test/health_nap_export_test.dart @@ -10,6 +10,7 @@ class FakeNapWriter implements HealthKitNapWriter { final calls = <({String method, DateTime start, DateTime end})>[]; bool nextResult = true; bool throwOnCall = false; + int throwOnCallNumber = 0; // 1-based; 0 = never @override Future replaceNap({required DateTime start, required DateTime end}) => @@ -21,7 +22,10 @@ class FakeNapWriter implements HealthKitNapWriter { Future _record(String method, DateTime start, DateTime end) async { calls.add((method: method, start: start, end: end)); - if (throwOnCall) throw Exception('store error'); + final n = calls.length; + if (throwOnCall || (throwOnCallNumber > 0 && n == throwOnCallNumber)) { + throw Exception('store error'); + } return nextResult; } } @@ -324,5 +328,21 @@ void main() { .exportDayNaps('2026-08-03'); expect(ok, isFalse); }); + + test('one failing row does not skip the remaining naps', () async { + final second = { + 'source': 'manual', + 'start_ts': start + 7200, + 'end_ts': start + 7200 + 1800, + }; + final writer = FakeNapWriter()..throwOnCallNumber = 1; + final ok = await exporter(writer: writer, dayRows: [confirmed, second]) + .exportDayNaps('2026-08-03'); + expect(ok, isFalse); + expect( + writer.calls.where((c) => c.method == 'replaceNap'), + hasLength(2), + ); + }); }); } From 025b589a9ea606898f759b5bdc4284618add94e0 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Sat, 7 Nov 2026 10:58:52 +0000 Subject: [PATCH 06/15] feat: cap naps at 3 hours (manual entries and detected bouts) kMaxNapSec = 3h is now the single cap: manualNapWindowIsValid rejects longer manual windows (was 6h), and dropOversizedNaps removes detected bouts longer than 3h before they reach suggestions/nap_min. A >3h bout is a sleep, not a nap. Co-authored-by: BucciMobile --- lib/compute/derivation_engine.dart | 8 +++++++- lib/compute/nap_edits.dart | 21 ++++++++++++++++++--- test/nap_edits_test.dart | 25 +++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 16eb8578b..da8857c74 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -10269,7 +10269,13 @@ class DerivationEngine { 'confidence': nap.confidence, }, ]; - candidates?.addAll(detected.where((n) => !napEdits.any((e) => + // A nap longer than kMaxNapSec (3 h) is a sleep, not a nap — drop it + // before it can reach the suggestions, `nap_min`, sleep need or the + // timeline. Manual entries are already capped at entry time by + // manualNapWindowIsValid, which shares the same constant. The merge + // input below stays as-is; only the detected list is capped. + final capped = dropOversizedNaps(detected); + candidates?.addAll(capped.where((n) => !napEdits.any((e) => e.overlaps(n['start'] as int, n['end'] as int)))); final merged = applyNapEdits(const [], napEdits); diff --git a/lib/compute/nap_edits.dart b/lib/compute/nap_edits.dart index ccc49887d..2f55908c9 100644 --- a/lib/compute/nap_edits.dart +++ b/lib/compute/nap_edits.dart @@ -54,9 +54,14 @@ typedef NapMap = Map; /// scatter of two-minute entries would swamp the real ones in every total. const int kMinManualNapSec = 5 * 60; -/// Maximum. Anything longer is a sleep, not a nap, and belongs in the main -/// sleep window where the stager can actually say something about its stages. -const int kMaxManualNapSec = 6 * 60 * 60; +/// Maximum length of anything counted as a nap: 3 hours. Anything longer is a +/// sleep, not a nap, and belongs in the main sleep window where the stager can +/// actually say something about its stages. Applies to detected AND manually +/// logged naps alike — one cap, one place. +const int kMaxNapSec = 3 * 60 * 60; + +/// Maximum for a manually logged nap — the same cap as detected naps. +const int kMaxManualNapSec = kMaxNapSec; /// Whether a window can be logged as a nap. bool manualNapWindowIsValid(int startSec, int endSec) { @@ -129,6 +134,16 @@ List applyNapEdits(List detected, List edits) { return out; } +/// Drops every nap whose window exceeds [kMaxNapSec]. A detected bout longer +/// than 3 h is a sleep fragment the detector misfiled as a nap; crediting it +/// into `nap_min` would count a night's sleep as daytime rest. The filter is +/// pure so the rule is testable without a derivation run. +List dropOversizedNaps(List naps) => [ + for (final n in naps) + if ((n['end'] as num).toInt() - (n['start'] as num).toInt() <= kMaxNapSec) + n, + ]; + /// Total asleep minutes across [naps] — the `nap_min` scalar. /// /// Always a number, including 0 for an empty list. The absent-versus-zero diff --git a/test/nap_edits_test.dart b/test/nap_edits_test.dart index 2b745b4bf..36bd4f9cf 100644 --- a/test/nap_edits_test.dart +++ b/test/nap_edits_test.dart @@ -218,12 +218,37 @@ void main() { expect(manualNapWindowIsValid(0, kMaxManualNapSec), isTrue); }); + test('the cap is 3 hours — exactly 3h is a nap, 3h+1s is a night', () { + expect(kMaxNapSec, 3 * hour); + expect(kMaxManualNapSec, kMaxNapSec); + expect(manualNapWindowIsValid(0, 3 * hour), isTrue); + expect(manualNapWindowIsValid(0, 3 * hour + 1), isFalse); + }); + test('rejects a backwards or empty window', () { expect(manualNapWindowIsValid(100, 100), isFalse); expect(manualNapWindowIsValid(200, 100), isFalse); }); }); + group('dropOversizedNaps', () { + test('keeps naps up to 3h, drops longer ones', () { + final naps = [ + {'start': 0, 'end': 2 * hour, 'duration_min': 120}, + {'start': 3 * hour, 'end': 6 * hour, 'duration_min': 180}, // exactly 3h + {'start': 7 * hour, 'end': 11 * hour, 'duration_min': 240}, // 4h -> drop + ]; + final kept = dropOversizedNaps(naps); + expect(kept.length, 2); + expect(kept[0]['end'], 2 * hour); + expect(kept[1]['end'], 6 * hour); + }); + + test('an empty list stays empty', () { + expect(dropOversizedNaps(const []), isEmpty); + }); + }); + group('manualNapWindowHasEnded', () { test('accepts a nap that is over', () { expect(manualNapWindowHasEnded(14 * hour, 15 * hour), isTrue); From d1438d3f1a4056eb65a6edbeefa8b7aeb4ee4e89 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Sat, 7 Nov 2026 12:57:41 +0000 Subject: [PATCH 07/15] Pin the nap export bounds test to the shared 3 h cap The export honors manualNapWindowIsValid, which now uses kMaxNapSec (3 h); assert the exact boundary instead of a hardcoded 6 h. Co-authored-by: BucciMobile --- test/health_nap_export_test.dart | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/test/health_nap_export_test.dart b/test/health_nap_export_test.dart index 76582e0e3..bb90db547 100644 --- a/test/health_nap_export_test.dart +++ b/test/health_nap_export_test.dart @@ -4,6 +4,8 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/compute/nap_edits.dart' + show kMaxNapSec, kMinManualNapSec; import 'package:openstrap_edge/health/health_nap_export.dart'; class FakeNapWriter implements HealthKitNapWriter { @@ -76,12 +78,15 @@ void main() { } }); - test('windows outside 5 min - 6 h are not exportable', () { + test('windows outside the shared nap bounds are not exportable', () { + // The export honors the same 5 min - kMaxNapSec (3 h) bounds as entry: + // anything longer is a sleep, not a nap, and must not reach HealthKit + // as one. expect( napWindowExportable( source: 'confirmed', startTs: start, - endTs: start + 4 * 60, + endTs: start + kMinManualNapSec - 60, nowSec: nowSec), isFalse, ); @@ -89,7 +94,15 @@ void main() { napWindowExportable( source: 'confirmed', startTs: start, - endTs: start + 6 * 3600 + 60, + endTs: start + kMaxNapSec, + nowSec: nowSec), + isTrue, + ); + expect( + napWindowExportable( + source: 'confirmed', + startTs: start, + endTs: start + kMaxNapSec + 1, nowSec: nowSec), isFalse, ); From a49d8915d97616fc646b37b986e54959c96e3575 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Sat, 7 Nov 2026 13:12:40 +0000 Subject: [PATCH 08/15] Coordinate nap removal with the daily Health export; 3 h in all nap messages - HealthNapExporter gains a reentrant lock (synchronized): exportNap, exportDayNaps and deleteNapWindow run inside it, and the confirm/log/ remove/restore call sites wrap their DB edit + health call in one critical section. A removal can no longer be overwritten by an in-flight per-day export that read the row before the delete, because the export re-reads the rows inside the lock. Leaf lock: no deadlock against exportAll's workout lock. - The 3 h nap cap (cherry-picked from #20) now shows in every user-facing message: napsInvalidWindow/activityNapWindow in all 8 arb locales plus the fallback strings in naps.dart, activity_store.dart and detected_activities.dart. - Test: synchronized is reentrant (health call inside a wrapped DB edit does not deadlock). Co-authored-by: BucciMobile --- lib/data/activity_store.dart | 2 +- lib/data/local_repository_impl.dart | 25 +++++++++------- lib/health/health_nap_export.dart | 38 ++++++++++++++++++++---- lib/l10n/app_de.arb | 4 +-- lib/l10n/app_en.arb | 6 ++-- lib/l10n/app_es.arb | 2 +- lib/l10n/app_fr.arb | 2 +- lib/l10n/app_hi.arb | 2 +- lib/l10n/app_ru.arb | 4 +-- lib/l10n/app_th.arb | 6 ++-- lib/l10n/app_zh.arb | 2 +- lib/ui2/screens/detected_activities.dart | 2 +- lib/ui2/screens/naps.dart | 24 ++++++++++----- test/health_nap_export_test.dart | 15 ++++++++++ 14 files changed, 94 insertions(+), 40 deletions(-) diff --git a/lib/data/activity_store.dart b/lib/data/activity_store.dart index 6c69eedea..77ca7ba7b 100644 --- a/lib/data/activity_store.dart +++ b/lib/data/activity_store.dart @@ -465,7 +465,7 @@ class ActivityStore { } else { if (!manualNapWindowIsValid(startTs, endTs)) { throw const ActivityReviewException( - 'A nap must last between 5 minutes and 6 hours.', + 'A nap must last between 5 minutes and 3 hours.', ); } if (await _overlapsSleep(tx, startTs, endTs)) { diff --git a/lib/data/local_repository_impl.dart b/lib/data/local_repository_impl.dart index 7d704eb71..966df83d5 100644 --- a/lib/data/local_repository_impl.dart +++ b/lib/data/local_repository_impl.dart @@ -91,16 +91,21 @@ class LocalRepositoryImpl extends LocalRepository { sessionId: 'suggestion:${suggestion.id}', suggestion: suggestion); await HealthExporter.exportWorkoutId('suggestion:${suggestion.id}'); } else { - await ActivityStore(await LocalDb.instance).confirm(suggestion, - startTs: start, endTs: end, - edited: start != suggestion.startTs || end != suggestion.endTs); - // A nap the user just confirmed goes to Apple Health right away (iOS); - // the exporter re-checks the row and the main-sleep overlap itself. - await HealthNapExporter.exportConfirmedNap( - dayId: dayLabelOf(DateTime.fromMillisecondsSinceEpoch(start * 1000)), - startTs: start, - endTs: end, - ); + // DB write + Health write in one critical section: a concurrently + // running per-day export must not read the pre-confirm state and write + // the nap after a removal, and vice versa. + await HealthNapExporter.shared.synchronized(() async { + await ActivityStore(await LocalDb.instance).confirm(suggestion, + startTs: start, endTs: end, + edited: start != suggestion.startTs || end != suggestion.endTs); + // A nap the user just confirmed goes to Apple Health right away (iOS); + // the exporter re-checks the row and the main-sleep overlap itself. + await HealthNapExporter.exportConfirmedNap( + dayId: dayLabelOf(DateTime.fromMillisecondsSinceEpoch(start * 1000)), + startTs: start, + endTs: end, + ); + }); } await onActivitiesChanged?.call(); } diff --git a/lib/health/health_nap_export.dart b/lib/health/health_nap_export.dart index 9b146ab8a..098ec784a 100644 --- a/lib/health/health_nap_export.dart +++ b/lib/health/health_nap_export.dart @@ -30,6 +30,7 @@ // a missing store, a denied permission or a failed write reads as false. import 'dart:async'; +import 'dart:isolate'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -120,6 +121,11 @@ bool napOverlapsWindowMs(int startTs, int endTs, num? onsetMs, num? offsetMs) { return startTs * 1000 < offsetMs && endTs * 1000 > onsetMs; } +/// Zone key marking "inside [HealthNapExporter.synchronized]" — makes the +/// nap lock reentrant so a call site can wrap a DB edit and a health call in +/// one critical section without deadlocking on the inner health call. +const _napHealthLockKey = #healthNapHealthLock; + class HealthNapExporter { HealthNapExporter({ HealthKitNapWriter? writer, @@ -148,6 +154,26 @@ class HealthNapExporter { /// Process-wide exporter; call sites use the static wrappers below. static final HealthNapExporter shared = HealthNapExporter(); + Future _lockTail = Future.value(); + + /// Serializes nap DB edits + Health writes against [exportDayNaps]'s + /// read-and-write pass. Without this, a remove/reject that lands while a + /// daily export has already read the row (but not yet written) lets the + /// export write the just-removed nap back into HealthKit after the + /// delete. With the lock, either order is safe: the export re-reads the + /// rows inside the critical section, so a row deleted first is skipped. + /// Reentrant via [_napHealthLockKey]; leaf lock — never acquires another + /// lock while held, so it cannot deadlock against exportAll's workout lock. + Future synchronized(Future Function() op) { + if (Zone.current[_napHealthLockKey] == true) return op(); + final prev = _lockTail; + final release = Completer(); + _lockTail = release.future; + return prev + .then((_) => runZonedGuarded(op, zoneValues: {_napHealthLockKey: true})) + .whenComplete(release.complete); + } + /// Export the nap stored at ([dayId], [startTs]) — its [endTs] is read from /// the row. Used after "put back" restores a rejected nap to 'confirmed'. /// Never throws; false when nothing was written. @@ -187,7 +213,7 @@ class HealthNapExporter { required String dayId, required int startTs, required int endTs, - }) async { + }) => synchronized(() async { try { // Health Connect has no nap export yet; never claim one happened. if (!isApple) return false; @@ -220,7 +246,7 @@ class HealthNapExporter { debugPrint('[health] exportNap $dayId $startTs-$endTs: $e'); return false; } - } + }); /// Export every exportable nap of [dayId]. Called from the per-day health /// export AFTER the main-sleep replace: that replace's noon-to-noon @@ -229,7 +255,7 @@ class HealthNapExporter { /// store. Rows that are not exportable (pending/rejected/legacy, overlap /// with main sleep) are skipped silently; false means an attempted write /// failed. On Android or with the sync switch off this is a no-op `true`. - Future exportDayNaps(String dayId) async { + Future exportDayNaps(String dayId) => synchronized(() async { try { if (!isApple) return true; if (!await _healthSyncEnabled()) return true; @@ -270,12 +296,12 @@ class HealthNapExporter { debugPrint('[health] exportDayNaps $dayId: $e'); return false; } - } + }); Future deleteNapWindow({ required int startTs, required int endTs, - }) async { + }) => synchronized(() async { try { if (!isApple) return false; if (!await _healthSyncEnabled()) return false; @@ -288,7 +314,7 @@ class HealthNapExporter { debugPrint('[health] deleteNapWindow $startTs-$endTs: $e'); return false; } - } + }); static Future _defaultHealthSyncEnabled() async { final prefs = await SharedPreferences.getInstance(); diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index fe1fa7f57..66aeca81e 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -419,7 +419,7 @@ "aiBriefingEmptyCardBody": "Beim Erstellen hatte kein Wert Daten, daher enthielt der Prompt keine.", "napsFellAsleepHelp": "EINGESCHLAFEN UM", "napsWokeUpHelp": "AUFGEWACHT UM", - "napsInvalidWindow": "Ein Nickerchen muss zwischen 5 Minuten und 6 Stunden dauern. Längere Schlafphasen gehören zum Nachtschlaf und werden dort ausgewertet.", + "napsInvalidWindow": "Ein Nickerchen muss zwischen 5 Minuten und 3 Stunden dauern. Längere Schlafphasen gehören zum Nachtschlaf und werden dort ausgewertet.", "napsOverlap": "Dieser Zeitraum überschneidet sich mit einem bereits eingetragenen Nickerchen. Entferne zuerst den anderen Eintrag, damit die Zeit nicht doppelt gezählt wird.", "napsOverlapsSleep": "Das überschneidet sich mit deinem Hauptschlaf, der diese Zeit schon zählt. Trage nur den Teil nach dem Aufstehen ein oder korrigiere stattdessen die Nacht.", "napsNotReanalysed": "Der Tag wurde nicht neu ausgewertet, weil gerade schon eine andere Auswertung lief. Deine Änderung ist gespeichert und wird beim nächsten Mal berücksichtigt.", @@ -2598,7 +2598,7 @@ "activityStart": "Beginn", "activityEnd": "Ende", "activityCheckTimes": "Zeiten prüfen", - "activityNapWindow": "Wähle einen vergangenen Zeitraum zwischen 5 Minuten und 6 Stunden.", + "activityNapWindow": "Wähle einen vergangenen Zeitraum zwischen 5 Minuten und 3 Stunden.", "activityEditNap": "Nickerchen bearbeiten", "activityEditWorkout": "Training bearbeiten", "activityNapAlerts": "Erkannte Nickerchen", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4ea800171..caf0e0a89 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1943,9 +1943,9 @@ "@napsWokeUpHelp": { "description": "Time picker helper text for logging when a manual nap ended." }, - "napsInvalidWindow": "A nap is between 5 minutes and 6 hours. Anything longer is a sleep, and it belongs in the night where the stages can be read.", + "napsInvalidWindow": "A nap is between 5 minutes and 3 hours. Anything longer is a sleep, and it belongs in the night where the stages can be read.", "@napsInvalidWindow": { - "description": "Validation error when a manually logged nap window is outside 5 minutes to 6 hours." + "description": "Validation error when a manually logged nap window is outside 5 minutes to 3 hours." }, "napsOverlap": "That overlaps a nap already on this day. Remove that one first, rather than counting the same hour twice.", "@napsOverlap": { @@ -12737,7 +12737,7 @@ "activityStart": "Start", "activityEnd": "End", "activityCheckTimes": "Check the times", - "activityNapWindow": "Choose a past window between 5 minutes and 6 hours.", + "activityNapWindow": "Choose a past window between 5 minutes and 3 hours.", "activityEditNap": "Edit nap", "activityEditWorkout": "Edit workout", "activityNapAlerts": "Detected naps", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 415c2de32..976666059 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -391,7 +391,7 @@ "aiBriefingEmptyCardBody": "Ninguna métrica tenía un valor cuando se escribió esto, así que el mensaje no llevaba ninguno.", "napsFellAsleepHelp": "CUÁNDO TE QUEDASTE DORMIDO", "napsWokeUpHelp": "CUÁNDO TE DESPERTASTE", - "napsInvalidWindow": "Una siesta dura entre 5 minutos y 6 horas. Cualquier cosa más larga es un sueño nocturno y pertenece a la noche, donde se pueden leer las fases.", + "napsInvalidWindow": "Una siesta dura entre 5 minutos y 3 horas. Cualquier cosa más larga es un sueño nocturno y pertenece a la noche, donde se pueden leer las fases.", "napsOverlap": "Eso se superpone con una siesta ya registrada este día. Elimina esa primero, en lugar de contar la misma hora dos veces.", "napsOverlapsSleep": "Eso se superpone con tu sueño principal, que ya cuenta ese tiempo. Registra solo la parte posterior a levantarte o corrige la noche.", "napsNotReanalysed": "El día no se ha vuelto a analizar: ya había otro análisis en curso. Tu edición está guardada y se aplicará la próxima vez.", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 7c26d7e89..d4f45aeb5 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -391,7 +391,7 @@ "aiBriefingEmptyCardBody": "Aucune métrique n'avait de valeur au moment de la rédaction, donc l'invite n'en contenait aucune.", "napsFellAsleepHelp": "QUAND VOUS VOUS ÊTES ENDORMI", "napsWokeUpHelp": "QUAND VOUS VOUS ÊTES RÉVEILLÉ", - "napsInvalidWindow": "Une sieste dure entre 5 minutes et 6 heures. Au-delà, il s'agit d'un sommeil, et cela appartient à la nuit, où les phases peuvent être lues.", + "napsInvalidWindow": "Une sieste dure entre 5 minutes et 3 heures. Au-delà, il s'agit d'un sommeil, et cela appartient à la nuit, où les phases peuvent être lues.", "napsOverlap": "Cela chevauche une sieste déjà enregistrée ce jour-là. Supprimez d'abord celle-ci plutôt que de compter la même heure deux fois.", "napsOverlapsSleep": "Cela chevauche votre sommeil principal, qui compte déjà ce temps. Enregistrez seulement la partie après votre lever, ou corrigez plutôt la nuit.", "napsNotReanalysed": "La journée n'a pas été réanalysée — une autre analyse était déjà en cours. Votre modification est enregistrée et s'appliquera la prochaine fois.", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 990d67975..0dc173cac 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -391,7 +391,7 @@ "aiBriefingEmptyCardBody": "जब यह लिखा गया, तब किसी भी मेट्रिक का कोई मान नहीं था, इसलिए प्रॉम्प्ट में कोई मान शामिल नहीं था।", "napsFellAsleepHelp": "आप कब सोए", "napsWokeUpHelp": "आप कब जागे", - "napsInvalidWindow": "झपकी 5 मिनट से 6 घंटे के बीच होती है। इससे अधिक लंबी अवधि नींद मानी जाती है, और वह रात के हिस्से में आती है जहाँ चरणों को पढ़ा जा सकता है।", + "napsInvalidWindow": "झपकी 5 मिनट से 3 घंटे के बीच होती है। इससे अधिक लंबी अवधि नींद मानी जाती है, और वह रात के हिस्से में आती है जहाँ चरणों को पढ़ा जा सकता है।", "napsOverlap": "यह इस दिन पहले से मौजूद एक झपकी से टकराता है। उसे पहले हटाएँ, बजाय इसके कि उसी घंटे को दो बार गिना जाए।", "napsOverlapsSleep": "यह आपकी मुख्य नींद से टकराता है, जो उस समय को पहले से गिनती है। केवल उठने के बाद का हिस्सा दर्ज करें, या इसके बजाय रात को ठीक करें।", "napsNotReanalysed": "दिन का पुनः विश्लेषण नहीं हुआ — पहले से ही एक अन्य विश्लेषण चल रहा था। आपका बदलाव सहेज लिया गया है और अगली बार लागू होगा।", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index c86544a36..b80d9c14e 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -819,7 +819,7 @@ "aiBriefingEmptyCardBody": "На момент составления ни у одного показателя не было значения, поэтому запрос их не содержал.", "napsFellAsleepHelp": "КОГДА ВЫ ЗАСНУЛИ", "napsWokeUpHelp": "КОГДА ВЫ ПРОСНУЛИСЬ", - "napsInvalidWindow": "Дневной сон длится от 5 минут до 6 часов. Более долгий сон относится к ночному, где можно просмотреть его стадии.", + "napsInvalidWindow": "Дневной сон длится от 5 минут до 3 часов. Более долгий сон относится к ночному, где можно просмотреть его стадии.", "napsOverlap": "Этот период пересекается с уже записанным дневным сном. Сначала удалите прежнюю запись, чтобы не учитывать одно время дважды.", "napsOverlapsSleep": "Этот период пересекается с основным сном, который уже учитывает это время. Запишите только часть после подъёма или исправьте ночь.", "napsNotReanalysed": "Повторный анализ дня не выполнен: уже идёт другой анализ. Изменение сохранено и будет учтено в следующий раз.", @@ -5828,7 +5828,7 @@ "activityStart": "Начало", "activityEnd": "Конец", "activityCheckTimes": "Проверьте время", - "activityNapWindow": "Выберите прошедший период от 5 минут до 6 часов.", + "activityNapWindow": "Выберите прошедший период от 5 минут до 3 часов.", "activityEditNap": "Изменить дневной сон", "activityEditWorkout": "Изменить тренировку", "activityNapAlerts": "Обнаруженный дневной сон", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 924d3dbf9..1ff2fa3fa 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -1943,9 +1943,9 @@ "@napsWokeUpHelp": { "description": "Time picker helper text for logging when a manual nap ended." }, - "napsInvalidWindow": "การงีบต้องยาว 5 นาทีถึง 6 ชั่วโมง หากนานกว่านั้นถือเป็นการนอนหลักและควรอยู่ในคืนนั้นเพื่ออ่านระยะการนอน", + "napsInvalidWindow": "การงีบต้องยาว 5 นาทีถึง 3 ชั่วโมง หากนานกว่านั้นถือเป็นการนอนหลักและควรอยู่ในคืนนั้นเพื่ออ่านระยะการนอน", "@napsInvalidWindow": { - "description": "Validation error when a manually logged nap window is outside 5 minutes to 6 hours." + "description": "Validation error when a manually logged nap window is outside 5 minutes to 3 hours." }, "napsOverlap": "เวลาทับซ้อนกับการงีบที่มีอยู่ในวันนี้ โปรดลบรายการเดิมก่อนเพื่อไม่นับชั่วโมงซ้ำ", "@napsOverlap": { @@ -12737,7 +12737,7 @@ "activityStart": "เริ่ม", "activityEnd": "จบ", "activityCheckTimes": "ตรวจสอบเวลา", - "activityNapWindow": "เลือกช่วงเวลาที่ผ่านมา ความยาว 5 นาทีถึง 6 ชั่วโมง", + "activityNapWindow": "เลือกช่วงเวลาที่ผ่านมา ความยาว 5 นาทีถึง 3 ชั่วโมง", "activityEditNap": "แก้ไขการงีบ", "activityEditWorkout": "แก้ไขการออกกำลังกาย", "activityNapAlerts": "การงีบที่ตรวจพบ", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 1cf2c9848..4cf46e2bf 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -391,7 +391,7 @@ "aiBriefingEmptyCardBody": "生成此内容时没有任何指标存在数值,因此提示词中未包含任何数据。", "napsFellAsleepHelp": "入睡时间", "napsWokeUpHelp": "醒来时间", - "napsInvalidWindow": "小睡的时长应在 5 分钟到 6 小时之间。超过这个时长应算作夜间睡眠,属于可以读取睡眠阶段的夜晚记录。", + "napsInvalidWindow": "小睡的时长应在 5 分钟到 3 小时之间。超过这个时长应算作夜间睡眠,属于可以读取睡眠阶段的夜晚记录。", "napsOverlap": "该时间段与当天已有的一次小睡重叠。请先删除那一次记录,而不是把同一小时计算两次。", "napsOverlapsSleep": "该时间段与你的主睡眠重叠,主睡眠已经计入了这段时间。请只记录起床之后的部分,或改为修正当晚的睡眠。", "napsNotReanalysed": "当天未被重新分析——已有另一项分析正在运行。你的修改已保存,将在下次生效。", diff --git a/lib/ui2/screens/detected_activities.dart b/lib/ui2/screens/detected_activities.dart index ba0be02fc..d734aeca0 100644 --- a/lib/ui2/screens/detected_activities.dart +++ b/lib/ui2/screens/detected_activities.dart @@ -463,7 +463,7 @@ class _NapProposalEditorState extends State { StatusCard( l?.activityCheckTimes ?? 'Check the times', l?.activityNapWindow ?? - 'Choose a past window between 5 minutes and 6 hours.', + 'Choose a past window between 5 minutes and 3 hours.', ), BigButton( l?.activitySaveConfirm ?? 'Save and confirm', diff --git a/lib/ui2/screens/naps.dart b/lib/ui2/screens/naps.dart index 9bd4462a6..52e41629f 100644 --- a/lib/ui2/screens/naps.dart +++ b/lib/ui2/screens/naps.dart @@ -202,6 +202,9 @@ class _NapsScreenState extends State { } Future _remove(String day, Map nap) => _edit(() async { + // DB edit + Health delete in one critical section, so a concurrently + // running per-day export cannot write this nap back after the delete. + await HealthNapExporter.shared.synchronized(() async { final start = nap['start'] as int, end = nap['end'] as int; // Their own entry is DELETED; the detector's is SUPPRESSED. Storing a // rejection for something the user typed would leave two rows fighting @@ -219,12 +222,15 @@ class _NapsScreenState extends State { // next per-day export re-writes the day's remaining naps, and a // stale sample here is harmless (it is the app's own window). await HealthNapExporter.deleteExportedNap(startTs: start, endTs: end); + }); }); Future _restore(String day, int start) => _edit(() async { - await LocalDb.deleteNapEdit(day, start); - // "Put it back" restores the row as 'confirmed' — re-export it. - await HealthNapExporter.exportNapRow(dayId: day, startTs: start); + await HealthNapExporter.shared.synchronized(() async { + await LocalDb.deleteNapEdit(day, start); + // "Put it back" restores the row as 'confirmed' — re-export it. + await HealthNapExporter.exportNapRow(dayId: day, startTs: start); + }); }); /// Two pickers on the day being shown. Deliberately not a duration: people @@ -260,7 +266,7 @@ class _NapsScreenState extends State { // Both refusals are the shared rules, not a second copy written here. if (!manualNapWindowIsValid(s, e)) { setState(() => _failed = l?.napsInvalidWindow ?? - 'A nap is between 5 minutes and 6 hours. ' + 'A nap is between 5 minutes and 3 hours. ' 'Anything longer is a sleep, and it belongs in the night where the ' 'stages can be read.'); return; @@ -286,10 +292,12 @@ class _NapsScreenState extends State { } if (!mounted) return; await _edit(() async { - await LocalDb.putNapEdit( - dayId: day, startTs: s, endTs: e, source: 'manual'); - await HealthNapExporter.exportConfirmedNap( - dayId: day, startTs: s, endTs: e); + await HealthNapExporter.shared.synchronized(() async { + await LocalDb.putNapEdit( + dayId: day, startTs: s, endTs: e, source: 'manual'); + await HealthNapExporter.exportConfirmedNap( + dayId: day, startTs: s, endTs: e); + }); }); } diff --git a/test/health_nap_export_test.dart b/test/health_nap_export_test.dart index bb90db547..0fd84418b 100644 --- a/test/health_nap_export_test.dart +++ b/test/health_nap_export_test.dart @@ -342,6 +342,21 @@ void main() { expect(ok, isFalse); }); + test('synchronized is reentrant: a health call inside a wrapped DB edit ' + 'does not deadlock', () async { + final writer = FakeNapWriter(); + final e = exporter( + writer: writer, + row: {'source': 'confirmed', 'start_ts': start, 'end_ts': end}, + ); + final ok = await e.synchronized(() async { + // Simulates a call site wrapping DB edit + health call together. + return e.exportNap(dayId: '2026-08-03', startTs: start, endTs: end); + }); + expect(ok, isTrue); + expect(writer.calls, hasLength(1)); + }); + test('one failing row does not skip the remaining naps', () async { final second = { 'source': 'manual', From 22b984333bb3fc9feb621ae79cf22dfd31bf12dd Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Sat, 7 Nov 2026 13:19:52 +0000 Subject: [PATCH 09/15] Fix analyzer error in HealthNapExporter.synchronized runZonedGuarded requires a positional onError; runZoned takes the body as its single positional argument. CI flutter analyze flagged the call. Co-authored-by: BucciMobile --- lib/health/health_nap_export.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/health/health_nap_export.dart b/lib/health/health_nap_export.dart index 098ec784a..2f48fabf6 100644 --- a/lib/health/health_nap_export.dart +++ b/lib/health/health_nap_export.dart @@ -170,7 +170,7 @@ class HealthNapExporter { final release = Completer(); _lockTail = release.future; return prev - .then((_) => runZonedGuarded(op, zoneValues: {_napHealthLockKey: true})) + .then((_) => runZoned(op, zoneValues: {_napHealthLockKey: true})) .whenComplete(release.complete); } From 2a23ce3aa97f3f2b4b7626de51e1243fcd558acc Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Sat, 7 Nov 2026 13:23:06 +0000 Subject: [PATCH 10/15] Drop unused dart:isolate import (Zone/runZoned resolve via dart:async) Co-authored-by: BucciMobile --- lib/health/health_nap_export.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/health/health_nap_export.dart b/lib/health/health_nap_export.dart index 2f48fabf6..35cd28bbb 100644 --- a/lib/health/health_nap_export.dart +++ b/lib/health/health_nap_export.dart @@ -30,7 +30,6 @@ // a missing store, a denied permission or a failed write reads as false. import 'dart:async'; -import 'dart:isolate'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; From 2956770903d8323a97e2cf445686d2e41a0e69bf Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Sat, 7 Nov 2026 14:16:52 +0000 Subject: [PATCH 11/15] Fix tests broken by the 3 h nap cap and by future-dated test windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - health_nap_export_test: bounds test now uses a window starting 5 h in the past (a 3 h nap starting 2 h ago ends in the future and is correctly rejected); the per-row failure test's second nap now ends in the past instead of the future. - nap_attribution_test: the attribution bout is now exactly 3 h (kMaxNapSec) so dropOversizedNaps keeps it. - activity_review_test DST fold: bout 2 h (00:30->02:30 across the fold), edit pulls start 1 h back across midnight, total exactly 3 h — within the cap, still two local day labels. - settings_report_l10n_test failures (missing reportContactLabel in es/fr/hi/zh) are pre-existing on main, untouched by this PR. Co-authored-by: BucciMobile --- test/activity_review_test.dart | 17 ++++++++++------- test/health_nap_export_test.dart | 20 ++++++++++---------- test/nap_attribution_test.dart | 4 +++- 3 files changed, 23 insertions(+), 18 deletions(-) diff --git a/test/activity_review_test.dart b/test/activity_review_test.dart index 7ba776ae5..cdc8a601f 100644 --- a/test/activity_review_test.dart +++ b/test/activity_review_test.dart @@ -677,21 +677,24 @@ void main() { () async { tz_data.initializeTimeZones(); final rome = tz.getLocation('Europe/Rome'); - final a = tz.TZDateTime(rome, 2025, 10, 26, 1, 30); - final b = tz.TZDateTime(rome, 2025, 10, 26, 3, 30); + // The detected bout is 2 h (00:30 -> 02:30 across the fold); the edit + // pulls the start 1 h further back, crossing midnight, and stays within + // the 3 h nap cap: 3 h exactly. + final a = tz.TZDateTime(rome, 2025, 10, 26, 0, 30); + final b = tz.TZDateTime(rome, 2025, 10, 26, 2, 30); final st = a.millisecondsSinceEpoch ~/ 1000, en = b.millisecondsSinceEpoch ~/ 1000; - expect(en - st, 3 * 3600); // repeated hour is real elapsed sleep time + expect(en - st, 2 * 3600); // repeated hour is real elapsed sleep time await db.update('activity_review_meta', {'activated_at': st - 86400}); final proposal = (await store.reconcile(ActivityKind.nap, [ - {'start': st, 'end': en, 'duration_min': 150}, + {'start': st, 'end': en, 'duration_min': 120}, ])).single; - await repo.confirmActivity(proposal, startTs: st - 2 * 3600, endTs: en); + await repo.confirmActivity(proposal, startTs: st - 3600, endTs: en); final label = dayLabelOf( - DateTime.fromMillisecondsSinceEpoch((st - 2 * 3600) * 1000), + DateTime.fromMillisecondsSinceEpoch((st - 3600) * 1000), ); final row = (await LocalDb.napEdits(label)).single; - expect((row['end_ts'] as int) - (row['start_ts'] as int), 5 * 3600); + expect((row['end_ts'] as int) - (row['start_ts'] as int), 3 * 3600); // both the detected and the edited start's local day get revised; they // are two days only where the host zone puts midnight between them final days = (await db.query('activity_review_days')) diff --git a/test/health_nap_export_test.dart b/test/health_nap_export_test.dart index 0fd84418b..7bdb9143b 100644 --- a/test/health_nap_export_test.dart +++ b/test/health_nap_export_test.dart @@ -81,28 +81,28 @@ void main() { test('windows outside the shared nap bounds are not exportable', () { // The export honors the same 5 min - kMaxNapSec (3 h) bounds as entry: // anything longer is a sleep, not a nap, and must not reach HealthKit - // as one. + // as one. The window starts far enough in the past that even the 3 h + // bound ends before nowSec. + final s0 = nowSec - 5 * 3600; expect( napWindowExportable( source: 'confirmed', - startTs: start, - endTs: start + kMinManualNapSec - 60, + startTs: s0, + endTs: s0 + kMinManualNapSec - 60, nowSec: nowSec), isFalse, ); expect( napWindowExportable( - source: 'confirmed', - startTs: start, - endTs: start + kMaxNapSec, + source: 'confirmed', startTs: s0, endTs: s0 + kMaxNapSec, nowSec: nowSec), isTrue, ); expect( napWindowExportable( source: 'confirmed', - startTs: start, - endTs: start + kMaxNapSec + 1, + startTs: s0, + endTs: s0 + kMaxNapSec + 1, nowSec: nowSec), isFalse, ); @@ -360,8 +360,8 @@ void main() { test('one failing row does not skip the remaining naps', () async { final second = { 'source': 'manual', - 'start_ts': start + 7200, - 'end_ts': start + 7200 + 1800, + 'start_ts': start + 3600, + 'end_ts': start + 5400, // ends in the past }; final writer = FakeNapWriter()..throwOnCallNumber = 1; final ok = await exporter(writer: writer, dayRows: [confirmed, second]) diff --git a/test/nap_attribution_test.dart b/test/nap_attribution_test.dart index 9e6a0ba9c..f695d7c18 100644 --- a/test/nap_attribution_test.dart +++ b/test/nap_attribution_test.dart @@ -440,7 +440,9 @@ void main() { // bout ends inside the 3 h buffer, so it is not deferred as unfinished. const first = midnight + 18 * 3600; const bed = midnight + 21 * 3600 + 1800; - const up = midnight + 86400 + 2 * 3600; + // The bout is exactly 3 h (kMaxNapSec): longer is a sleep, not a nap, + // and would be dropped by dropOversizedNaps before attribution. + const up = midnight + 86400 + 1800; final s = _daySubstrate( startSec: first, lengthSec: 9 * 3600, From a33bd35e2f5e83d96dd5a3d8d5efd8f3d20ff1d0 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Sun, 8 Nov 2026 09:23:29 +0000 Subject: [PATCH 12/15] DST fold test: use a 2 h elapsed bout (00:30->01:30) so the midnight-crossing edit stays within the 3 h cap Co-authored-by: BucciMobile --- test/activity_review_test.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/activity_review_test.dart b/test/activity_review_test.dart index cdc8a601f..f1c03ffa6 100644 --- a/test/activity_review_test.dart +++ b/test/activity_review_test.dart @@ -677,11 +677,11 @@ void main() { () async { tz_data.initializeTimeZones(); final rome = tz.getLocation('Europe/Rome'); - // The detected bout is 2 h (00:30 -> 02:30 across the fold); the edit - // pulls the start 1 h further back, crossing midnight, and stays within - // the 3 h nap cap: 3 h exactly. + // The detected bout is 2 h elapsed (00:30 -> 01:30 across the repeated + // hour: 1 h wall, 2 h real); the edit pulls the start 1 h further back, + // crossing midnight, and stays within the 3 h nap cap: 3 h exactly. final a = tz.TZDateTime(rome, 2025, 10, 26, 0, 30); - final b = tz.TZDateTime(rome, 2025, 10, 26, 2, 30); + final b = tz.TZDateTime(rome, 2025, 10, 26, 1, 30); final st = a.millisecondsSinceEpoch ~/ 1000, en = b.millisecondsSinceEpoch ~/ 1000; expect(en - st, 2 * 3600); // repeated hour is real elapsed sleep time From 02a7677a20063165d3e5d17ff91601edf1d2c198 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Sun, 8 Nov 2026 13:35:14 +0000 Subject: [PATCH 13/15] DST fold test: anchor to UTC instants, not ambiguous wall times TZDateTime on the repeated wall hour resolves differently across tzdata versions (CI Linux vs Windows ICU), which made the elapsed-time assert environment-dependent. Anchor st to 2025-10-25 22:30Z (00:30 CEST local, unambiguous) and derive en as st + 2 h. Co-authored-by: BucciMobile --- test/activity_review_test.dart | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/test/activity_review_test.dart b/test/activity_review_test.dart index f1c03ffa6..5ba891ab4 100644 --- a/test/activity_review_test.dart +++ b/test/activity_review_test.dart @@ -677,14 +677,18 @@ void main() { () async { tz_data.initializeTimeZones(); final rome = tz.getLocation('Europe/Rome'); - // The detected bout is 2 h elapsed (00:30 -> 01:30 across the repeated - // hour: 1 h wall, 2 h real); the edit pulls the start 1 h further back, - // crossing midnight, and stays within the 3 h nap cap: 3 h exactly. - final a = tz.TZDateTime(rome, 2025, 10, 26, 0, 30); - final b = tz.TZDateTime(rome, 2025, 10, 26, 1, 30); - final st = a.millisecondsSinceEpoch ~/ 1000, - en = b.millisecondsSinceEpoch ~/ 1000; + // Anchors are UTC instants, not wall times: on the Rome fall-back day + // (2025-10-26) the wall hour 01:00-01:59 occurs twice, and whether a + // TZDateTime picks the first or second occurrence differs between + // tzdata versions (CI vs Windows ICU) — elapsed-time math must not + // depend on it. 22:30Z == 00:30 CEST local; +2 h crosses the fold. + final st = DateTime.utc(2025, 10, 25, 22, 30).millisecondsSinceEpoch ~/ + 1000; + final en = st + 2 * 3600; // 2 h elapsed across the fold (01:30 CET local) expect(en - st, 2 * 3600); // repeated hour is real elapsed sleep time + expect(tz.TZDateTime(rome, 2025, 10, 26, 0, 30).millisecondsSinceEpoch ~/ + 1000, + st); // sanity: the UTC anchor really is 00:30 local in Rome await db.update('activity_review_meta', {'activated_at': st - 86400}); final proposal = (await store.reconcile(ActivityKind.nap, [ {'start': st, 'end': en, 'duration_min': 120}, From 1d861e405c7eb70f694c23c898fc14b34bc73500 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Sun, 8 Nov 2026 15:06:36 +0000 Subject: [PATCH 14/15] Address OpenStrap/edge#677 review findings - kAlgoVersion 120 -> 121 with changelog: the 3 h nap cap now applies to stored edits too (dropOversizedNaps around applyNapEdits in both nap merge paths), so pre-cap manual naps no longer count after re-derive. - ActivityStore.reconcile retires oversized (> kMaxNapSec) pending nap candidates instead of offering them. - HealthKitSleepWriter: nap seq is re-checked inside the delete completion before saving, so a superseded replaceNap can never save after a newer deleteNapRange; deleteNapRange protects samples overlapping the current main-sleep window (protectStartTime/protectEndTime args). - _exportDay re-exports naps of the export day AND the previous day (noon-to-noon cleanup reaches into it) and treats a nap-write timeout as store-hung instead of burning the day's retry budget. - HealthNapExporter: failed exports/deletes persist in retry cursors and are retried by the per-day pass; overlapping nap rows in one pass keep only the first; deleteNapWindow passes the protected main-sleep window; exportNapRowAt never throws on a failing row read. - Tests: retry cursors, overlap skip, protected delete args, row-read failure. Co-authored-by: BucciMobile --- ios/Runner/HealthKitSleepWriter.swift | 85 ++++++- lib/compute/derivation_engine.dart | 21 +- lib/data/activity_store.dart | 33 +++ lib/health/health_export.dart | 25 ++- lib/health/health_nap_export.dart | 311 ++++++++++++++++++++++---- test/health_nap_export_test.dart | 139 ++++++++++-- 6 files changed, 531 insertions(+), 83 deletions(-) diff --git a/ios/Runner/HealthKitSleepWriter.swift b/ios/Runner/HealthKitSleepWriter.swift index a65bac342..2fd15131b 100644 --- a/ios/Runner/HealthKitSleepWriter.swift +++ b/ios/Runner/HealthKitSleepWriter.swift @@ -59,7 +59,11 @@ enum HealthKitSleepWriter { // window itself, NOT the noon-to-noon night window — it must never touch // the exported main sleep. private static func replaceNap(_ args: [String: Any], completion: @escaping (Bool) -> Void) { - if staleNapCallDropped(args, completion: completion) { return } + guard let seq = napSeq(args) else { + completion(false) + return + } + if staleNapCallDropped(seq, completion: completion) { return } guard HKHealthStore.isHealthDataAvailable() else { completion(true) // iPad / simulator: no store, not an export failure return @@ -81,13 +85,21 @@ enum HealthKitSleepWriter { HKCategorySample(type: sleepType, value: HKCategoryValueSleepAnalysis.inBed.rawValue, start: start, end: end), HKCategorySample(type: sleepType, value: asleepValue.rawValue, start: start, end: end), ] - deleteAndSave(samples: samples, type: sleepType, cleanupStart: start, cleanupEnd: end, completion: completion) + deleteAndSave(samples: samples, type: sleepType, cleanupStart: start, cleanupEnd: end, seq: napSeq(args), completion: completion) } // A nap removed/rejected in Edge: delete OUR sleepAnalysis samples // overlapping the window so the export does not outlive the decision. + // [protectStartTime/protectEndTime] (optional, epoch ms) mark the current + // main-sleep window: if the night shifted onto a previously exported nap, + // samples overlapping that window are kept — deleting them would remove + // the user's night from Apple Health. private static func deleteRange(_ args: [String: Any], completion: @escaping (Bool) -> Void) { - if staleNapCallDropped(args, completion: completion) { return } + guard let seq = napSeq(args) else { + completion(false) + return + } + if staleNapCallDropped(seq, completion: completion) { return } guard HKHealthStore.isHealthDataAvailable() else { completion(true) return @@ -99,13 +111,19 @@ enum HealthKitSleepWriter { completion(false) return } - deleteAndSave(samples: [], type: sleepType, cleanupStart: start, cleanupEnd: end, completion: completion) + let protectStart = dateMs(args["protectStartTime"]) + let protectEnd = dateMs(args["protectEndTime"]) + deleteRange(samples: [], type: sleepType, start: start, end: end, + protectStart: protectStart, protectEnd: protectEnd, seq: seq, completion: completion) + } + + private static func napSeq(_ args: [String: Any]) -> Int? { + (args["seq"] as? NSNumber)?.intValue } // Runs on `queue`. Returns true when the call is stale and was dropped // (completed with false — a dropped call never claims success). - private static func staleNapCallDropped(_ args: [String: Any], completion: @escaping (Bool) -> Void) -> Bool { - let seq = (args["seq"] as? NSNumber)?.intValue ?? 0 + private static func staleNapCallDropped(_ seq: Int, completion: @escaping (Bool) -> Void) -> Bool { if seq <= lastNapSeq { completion(false) return true @@ -114,11 +132,17 @@ enum HealthKitSleepWriter { return false } + // Delete-then-save. [seq] (nap calls only) is re-checked in the delete's + // completion callback: a newer nap call that started while this delete was + // in flight has already bumped lastNapSeq, so this call must NOT save its + // samples afterwards — otherwise a removed nap reappears after the newer + // deletion. Nil seq (replaceSleepSession) skips the re-check. private static func deleteAndSave( samples: [HKCategorySample], type: HKCategoryType, cleanupStart: Date, cleanupEnd: Date, + seq: Int?, completion: @escaping (Bool) -> Void ) { let datePred = HKQuery.predicateForSamples(withStart: cleanupStart, end: cleanupEnd, options: []) @@ -129,6 +153,10 @@ enum HealthKitSleepWriter { completion(false) return } + if let seq, seq != lastNapSeq { + completion(false) // superseded while the delete was in flight + return + } guard !samples.isEmpty else { completion(true) return @@ -139,6 +167,50 @@ enum HealthKitSleepWriter { } } + // Delete OUR sleepAnalysis samples overlapping [start, end), except those + // overlapping [protectStart, protectEnd) (the current main-sleep window) — + // a night that shifted onto a previously exported nap must not be deleted + // with it. The seq re-check happens before the delete lands. + private static func deleteRange( + samples: [HKCategorySample], + type: HKCategoryType, + start: Date, + end: Date, + protectStart: Date?, + protectEnd: Date?, + seq: Int, + completion: @escaping (Bool) -> Void + ) { + let datePred = HKQuery.predicateForSamples(withStart: start, end: end, options: []) + let sourcePred = HKQuery.predicateForObjects(from: HKSource.default()) + let pred = NSCompoundPredicate(andPredicateWithSubpredicates: [datePred, sourcePred]) + let query = HKSampleQuery(sampleType: type, predicate: pred, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, results, error in + guard let samples = results as? [HKCategorySample], error == nil else { + completion(false) + return + } + if seq != lastNapSeq { + completion(false) // superseded while the query was in flight + return + } + // Samples overlapping the protected main-sleep window are kept. + let doomed = samples.filter { s in + if let ps = protectStart, let pe = protectEnd { + return !(s.startDate < pe && s.endDate > ps) + } + return true + } + guard !doomed.isEmpty else { + completion(true) + return + } + store.delete(doomed) { success, error in + completion(success && error == nil) + } + } + store.execute(query) + } + private static func replace(_ args: [String: Any], completion: @escaping (Bool) -> Void) { guard HKHealthStore.isHealthDataAvailable() else { completion(true) // iPad / simulator: no store, not an export failure @@ -168,6 +240,7 @@ enum HealthKitSleepWriter { type: sleepType, cleanupStart: cleanupStart, cleanupEnd: cleanupEnd, + seq: nil, // sleep sessions carry no seq — unchanged behavior completion: completion ) } diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index da8857c74..fb8f882fd 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -1852,7 +1852,12 @@ import 'vendor_sleep.dart'; // 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; +// 120 → 121 (nap cap 3 h): naps are capped at kMaxNapSec on every path — +// detected bouts via dropOversizedNaps AND stored edits replayed through +// applyNapEdits — so a pre-cap manual nap longer than 3 h no longer counts +// into naps/nap_min. Days derived at v120 keep their stale naps without this +// bump. +const int kAlgoVersion = 121; /// The sibling SHAs this version was derived against, asserted against /// pubspec.yaml in test/db_serve_version_and_reads_test.dart. /// @@ -10082,7 +10087,9 @@ class DerivationEngine { List napEdits, String note, ) { - final merged = applyNapEdits(const [], napEdits); + // Cap stored edits too: a manual nap logged before the 3 h cap must not + // survive a re-derive as a counted nap. + final merged = dropOversizedNaps(applyNapEdits(const [], napEdits)); if (merged.isEmpty) { _writeUnknownNaps(bundle, note); return null; @@ -10271,13 +10278,15 @@ class DerivationEngine { ]; // A nap longer than kMaxNapSec (3 h) is a sleep, not a nap — drop it // before it can reach the suggestions, `nap_min`, sleep need or the - // timeline. Manual entries are already capped at entry time by - // manualNapWindowIsValid, which shares the same constant. The merge - // input below stays as-is; only the detected list is capped. + // timeline. Entry-time paths share the same constant via + // manualNapWindowIsValid; stored edits are capped again at the merge + // below so pre-cap rows cannot survive a re-derive. final capped = dropOversizedNaps(detected); candidates?.addAll(capped.where((n) => !napEdits.any((e) => e.overlaps(n['start'] as int, n['end'] as int)))); - final merged = applyNapEdits(const [], napEdits); + // Cap stored edits too: a manual nap logged before the 3 h cap must not + // survive a re-derive as a counted nap. + final merged = dropOversizedNaps(applyNapEdits(const [], napEdits)); bundle['naps'] = { 'value': merged, diff --git a/lib/data/activity_store.dart b/lib/data/activity_store.dart index 77ca7ba7b..7d5e61862 100644 --- a/lib/data/activity_store.dart +++ b/lib/data/activity_store.dart @@ -177,6 +177,17 @@ class ActivityStore { final start = ((c['start_ts'] ?? c['start']) as num).toInt(); final end = ((c['end_ts'] ?? c['end']) as num).toInt(); if (end <= start) continue; + // A nap candidate over kMaxNapSec (3 h) is a sleep fragment, not a + // nap: never offer it for review — retire any pending rows it + // overlaps instead of keeping or re-creating them. + if (kind == ActivityKind.nap && end - start > kMaxNapSec) { + await tx.rawUpdate( + "UPDATE activity_suggestions SET status = 'superseded' " + "WHERE kind = ? AND status = 'pending' AND start_ts < ? AND end_ts > ?", + [kind.name, end, start], + ); + continue; + } final matches = await tx.query( 'activity_suggestions', where: @@ -329,6 +340,28 @@ class ActivityStore { return false; } + /// The main-sleep windows (epoch ms) of the nights around [s, e) — the + /// night ending on [s]'s date and the one beginning that evening. Used to + /// PROTECT night samples when a nap window is deleted from the health + /// store after the night shifted onto it. + static Future> mainSleepWindowsAround( + DatabaseExecutor tx, + int s, + int e, + ) async { + final out = <({int onsetMs, int offsetMs})>[]; + for (final b in await _nightsAround(tx, s, e)) { + final w = ((b['sleep'] as Map?)?['window'] as Map?)?['value']; + if (w is Map) { + final on = w['onset_ms'], off = w['offset_ms']; + if (on is num && off is num && off > on) { + out.add((onsetMs: on.toInt(), offsetMs: off.toInt())); + } + } + } + return out; + } + static Future> _nightsAround( DatabaseExecutor tx, int s, diff --git a/lib/health/health_export.dart b/lib/health/health_export.dart index d07a30391..43f4e315f 100644 --- a/lib/health/health_export.dart +++ b/lib/health/health_export.dart @@ -1261,12 +1261,25 @@ class HealthExporter { success = false; } // The sleep replace's noon-to-noon cleanup window covers the wake day's - // afternoon too, so it deletes OUR nap samples there. Re-write this - // day's confirmed naps after it on every pass (delete-then-write, so - // idempotent) — otherwise the next re-derive of a non-finalized day - // would silently remove a nap the user confirmed. - if (!await HealthNapExporter.shared.exportDayNaps(date)) { - debugPrint('[health] write Apple naps returned false'); + // afternoon AND can reach into the previous calendar day, so it deletes + // OUR nap samples there. Re-write the confirmed naps of BOTH days after + // it on every pass (delete-then-write, so idempotent) — otherwise the + // next re-derive of a non-finalized day would silently remove a nap the + // user confirmed. + final prevDate = dayLabelOf( + DateTime(dayStart.year, dayStart.month, dayStart.day - 1), + ); + try { + if (!await HealthNapExporter.shared.exportDayNaps(date) || + !await HealthNapExporter.shared.exportDayNaps(prevDate)) { + debugPrint('[health] write Apple naps returned false'); + success = false; + } + } on TimeoutException { + // A hung nap write means the store is busy/locked — same handling as + // the other native deletes: stop the pass instead of burning the + // day's retry budget (and holding the lock) for 30 s per nap. + _storeHung = true; success = false; } } diff --git a/lib/health/health_nap_export.dart b/lib/health/health_nap_export.dart index 35cd28bbb..4a86225f1 100644 --- a/lib/health/health_nap_export.dart +++ b/lib/health/health_nap_export.dart @@ -16,20 +16,28 @@ // // Only rows with source 'confirmed' or 'manual' are ever exported; 'pending' // detections and 'rejected' windows are not. The window is re-validated at -// export time (bounds, not in the future, and NOT overlapping the derived -// main sleep of the surrounding nights — the same check the confirm path -// enforces, repeated here so a stale row or a shifted night can never push -// night-sleep minutes into Health as a nap). +// export time (shared 5 min – kMaxNapSec (3 h) bounds, not in the future, and +// NOT overlapping the derived main sleep of the surrounding nights — the +// same check the confirm path enforces, repeated here so a stale row or a +// shifted night can never push night-sleep minutes into Health as a nap). // // Idempotency: the native writer deletes THIS APP's own sleepAnalysis samples // overlapping the nap window before writing, so repeated exports never -// duplicate. Removing/rejecting a nap in Edge deletes the same window. +// duplicate. Removing/rejecting a nap in Edge deletes the same window, while +// samples overlapping the current main-sleep window are protected (the +// night may have shifted onto a previously exported nap). +// +// Failed exports/deletes are persisted (cursors `health_nap_pending_exports` +// / `health_nap_pending_deletes`) and retried by the per-day export pass, so +// a confirmed nap does not stay missing from Apple Health just because one +// write failed on a day behind the export cursor. // // iOS only for now — Health Connect (Android) has no nap export yet; on // Android every method returns false and writes nothing. Nothing here throws: // a missing store, a denied permission or a failed write reads as false. import 'dart:async'; +import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -46,8 +54,15 @@ abstract interface class HealthKitNapWriter { /// one inBed + one asleepUnspecified sample over [start, end). Future replaceNap({required DateTime start, required DateTime end}); - /// Delete our own sleepAnalysis samples overlapping [start, end). - Future deleteRange({required DateTime start, required DateTime end}); + /// Delete our own sleepAnalysis samples overlapping [start, end), except + /// those overlapping [protectStartMs, protectEndMs) (the current main-sleep + /// window in epoch ms, when known). + Future deleteRange({ + required DateTime start, + required DateTime end, + int? protectStartMs, + int? protectEndMs, + }); } class MethodChannelHealthKitNapWriter implements HealthKitNapWriter { @@ -59,8 +74,8 @@ class MethodChannelHealthKitNapWriter implements HealthKitNapWriter { Future _pending = Future.value(); // Monotonic per-call sequence. A native call that outlives its 30 s timeout // can still land later; the native side drops nap calls whose seq is not - // newer than the last one it applied, so a late replaceNap can never - // overwrite a newer deleteNapRange for the same window. + // newer than the last one it applied AND re-checks the seq before saving, + // so a late replaceNap can never overwrite a newer deleteNapRange. int _seq = 0; @override @@ -72,10 +87,17 @@ class MethodChannelHealthKitNapWriter implements HealthKitNapWriter { }); @override - Future deleteRange({required DateTime start, required DateTime end}) => + Future deleteRange({ + required DateTime start, + required DateTime end, + int? protectStartMs, + int? protectEndMs, + }) => _invoke('deleteNapRange', { 'startTime': start.millisecondsSinceEpoch, 'endTime': end.millisecondsSinceEpoch, + if (protectStartMs != null) 'protectStartTime': protectStartMs, + if (protectEndMs != null) 'protectEndTime': protectEndMs, 'seq': ++_seq, }); @@ -100,7 +122,8 @@ class MethodChannelHealthKitNapWriter implements HealthKitNapWriter { } /// Whether a `sleep_nap` row may be exported: only a confirmed or manually -/// logged nap inside the 5 min – 6 h window that has already ended. +/// logged nap inside the shared 5 min – kMaxNapSec (3 h) window that has +/// already ended. @visibleForTesting bool napWindowExportable({ required String? source, @@ -125,6 +148,11 @@ bool napOverlapsWindowMs(int startTs, int endTs, num? onsetMs, num? offsetMs) { /// one critical section without deadlocking on the inner health call. const _napHealthLockKey = #healthNapHealthLock; +/// Retry cursors (JSON lists in the sync_cursor table) for failed nap +/// exports / deletes — retried by the per-day export pass. +const _kPendingNapExports = 'health_nap_pending_exports'; +const _kPendingNapDeletes = 'health_nap_pending_deletes'; + class HealthNapExporter { HealthNapExporter({ HealthKitNapWriter? writer, @@ -133,14 +161,19 @@ class HealthNapExporter { Future?> Function(String dayId, int startTs)? napRow, Future>> Function(String dayId)? dayNapRows, Future Function(int startTs, int endTs)? overlapsMainSleep, + Future> Function(int, int)? + mainSleepWindows, + Future Function(String name)? getCursor, + Future Function(String name, String value)? setCursor, }) : _writer = writer ?? MethodChannelHealthKitNapWriter(), isApple = isApple ?? HealthExporter.isApple, - _healthSyncEnabled = - healthSyncEnabled ?? _defaultHealthSyncEnabled, + _healthSyncEnabled = healthSyncEnabled ?? _defaultHealthSyncEnabled, _napRow = napRow ?? _defaultNapRow, _dayNapRows = dayNapRows ?? LocalDb.napEdits, - _overlapsMainSleep = - overlapsMainSleep ?? _defaultOverlapsMainSleep; + _overlapsMainSleep = overlapsMainSleep ?? _defaultOverlapsMainSleep, + _mainSleepWindows = mainSleepWindows ?? _defaultMainSleepWindows, + _getCursor = getCursor ?? LocalDb.getCursor, + _setCursor = setCursor ?? LocalDb.setCursor; final HealthKitNapWriter _writer; final bool isApple; @@ -149,6 +182,10 @@ class HealthNapExporter { _napRow; final Future>> Function(String dayId) _dayNapRows; final Future Function(int startTs, int endTs) _overlapsMainSleep; + final Future> Function(int, int) + _mainSleepWindows; + final Future Function(String name) _getCursor; + final Future Function(String name, String value) _setCursor; /// Process-wide exporter; call sites use the static wrappers below. static final HealthNapExporter shared = HealthNapExporter(); @@ -179,21 +216,34 @@ class HealthNapExporter { static Future exportNapRow({ required String dayId, required int startTs, + }) => shared.exportNapRowAt(dayId: dayId, startTs: startTs); + + /// Instance-level [exportNapRow] — a failing row read returns false + /// instead of throwing into the caller (e.g. the restore flow). + Future exportNapRowAt({ + required String dayId, + required int startTs, }) async { - final row = await shared._napRow(dayId, startTs); - final endTs = row?['end_ts']; - if (endTs is! num) return false; - return shared.exportNap( - dayId: dayId, - startTs: startTs, - endTs: endTs.toInt(), - ); + try { + final row = await _napRow(dayId, startTs); + final endTs = row?['end_ts']; + if (endTs is! num) return false; + return await exportNap( + dayId: dayId, + startTs: startTs, + endTs: endTs.toInt(), + ); + } catch (e) { + debugPrint('[health] exportNapRow $dayId $startTs: $e'); + return false; + } } /// Export a confirmed/manual nap window to Apple Health. Re-reads the row /// and re-checks the main-sleep overlap at export time. Never throws; /// false when nothing was written (switch off, Android, not confirmed, - /// overlap, store error). + /// overlap, store error). A failed write is queued for retry by the + /// per-day export pass. static Future exportConfirmedNap({ required String dayId, required int startTs, @@ -201,8 +251,9 @@ class HealthNapExporter { }) => shared.exportNap(dayId: dayId, startTs: startTs, endTs: endTs); /// Remove a previously exported nap window from Apple Health (the nap was - /// deleted or rejected in Edge). Never throws; false when nothing was - /// deleted. + /// deleted or rejected in Edge). Samples overlapping the current main + /// sleep are protected. Never throws; false when nothing was deleted. A + /// failed delete is queued for retry. static Future deleteExportedNap({ required int startTs, required int endTs, @@ -237,57 +288,110 @@ class HealthNapExporter { // overlap the main sleep, but re-check here so a stale row or a night // that moved since can never be written as a nap. if (await _overlapsMainSleep(startTs, endTs)) return false; - return await _writer.replaceNap( + final written = await _writer.replaceNap( start: DateTime.fromMillisecondsSinceEpoch(startTs * 1000), end: DateTime.fromMillisecondsSinceEpoch(endTs * 1000), ); + if (!written) { + await _recordPending(_kPendingNapExports, { + 'dayId': dayId, + 'startTs': startTs, + 'endTs': endTs, + }); + } else { + await _removePending(_kPendingNapExports, dayId, startTs); + } + return written; } catch (e) { debugPrint('[health] exportNap $dayId $startTs-$endTs: $e'); + await _recordPending(_kPendingNapExports, { + 'dayId': dayId, + 'startTs': startTs, + 'endTs': endTs, + }); return false; } }); - /// Export every exportable nap of [dayId]. Called from the per-day health - /// export AFTER the main-sleep replace: that replace's noon-to-noon - /// cleanup window also covers the wake day's afternoon, so it deletes OUR - /// nap samples there — re-writing them on every pass keeps them in the - /// store. Rows that are not exportable (pending/rejected/legacy, overlap - /// with main sleep) are skipped silently; false means an attempted write - /// failed. On Android or with the sync switch off this is a no-op `true`. + /// Export every exportable nap of [dayId] plus any pending retries for + /// that day, and first retries all pending deletes. Called from the + /// per-day health export AFTER the main-sleep replace: that replace's + /// noon-to-noon cleanup window also covers the wake day's afternoon, so it + /// deletes OUR nap samples there — re-writing them on every pass keeps + /// them in the store. Rows that are not exportable (pending/rejected, + /// overlap with main sleep or with another nap exported in this pass) are + /// skipped silently; false means an attempted write failed. On Android or + /// with the sync switch off this is a no-op `true`. Future exportDayNaps(String dayId) => synchronized(() async { try { if (!isApple) return true; if (!await _healthSyncEnabled()) return true; + await _retryPendingDeletes(); final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000; - var ok = true; - for (final row in await _dayNapRows(dayId)) { - final startTs = row['start_ts']; - final endTs = row['end_ts']; - final source = row['source']; - if (startTs is! num || endTs is! num) continue; - final s = startTs.toInt(), e = endTs.toInt(); + final accepted = <({int s, int e})>[]; + bool overlapsAccepted(int s, int e) => + accepted.any((w) => s < w.e && e > w.s); + void consider(int s, int e, String? source) { if (!napWindowExportable( - source: source is String ? source : null, + source: source, startTs: s, endTs: e, nowSec: nowSec, )) { - continue; + return; } + // Two overlapping nap rows must not erase each other's samples: + // the first one written in this pass wins. + if (overlapsAccepted(s, e)) return; + accepted.add((s: s, e: e)); + } + + for (final row in await _dayNapRows(dayId)) { + final startTs = row['start_ts']; + final endTs = row['end_ts']; + final source = row['source']; + if (startTs is! num || endTs is! num) continue; + consider(startTs.toInt(), endTs.toInt(), source is String ? source : null); + } + // Retry previously failed exports of THIS day (failed writes on days + // behind the export cursor would otherwise never be retried). + for (final p in await _pendingList(_kPendingNapExports)) { + if (p['dayId'] != dayId) continue; + final s = p['startTs'], e = p['endTs']; + if (s is! num || e is! num) continue; + consider(s.toInt(), e.toInt(), 'confirmed'); + } + + var ok = true; + for (final w in accepted) { // Per-row isolation: one throwing row (overlap lookup, store error) // must not skip the remaining naps — the main-sleep replace above // may already have deleted their Health samples, and a day that // gives up retries would leave them missing. try { - if (await _overlapsMainSleep(s, e)) continue; + if (await _overlapsMainSleep(w.s, w.e)) continue; final written = await _writer.replaceNap( - start: DateTime.fromMillisecondsSinceEpoch(s * 1000), - end: DateTime.fromMillisecondsSinceEpoch(e * 1000), + start: DateTime.fromMillisecondsSinceEpoch(w.s * 1000), + end: DateTime.fromMillisecondsSinceEpoch(w.e * 1000), ); - if (!written) ok = false; + if (!written) { + ok = false; + await _recordPending(_kPendingNapExports, { + 'dayId': dayId, + 'startTs': w.s, + 'endTs': w.e, + }); + } else { + await _removePending(_kPendingNapExports, dayId, w.s); + } } catch (rowError) { - debugPrint('[health] exportDayNaps $dayId row $s-$e: $rowError'); + debugPrint('[health] exportDayNaps $dayId row ${w.s}-${w.e}: $rowError'); ok = false; + await _recordPending(_kPendingNapExports, { + 'dayId': dayId, + 'startTs': w.s, + 'endTs': w.e, + }); } } return ok; @@ -305,16 +409,116 @@ class HealthNapExporter { if (!isApple) return false; if (!await _healthSyncEnabled()) return false; if (endTs <= startTs) return false; - return await _writer.deleteRange( + // Protect the current main-sleep window: if the night shifted onto a + // previously exported nap, its samples must survive the nap delete. + int? protectStartMs, protectEndMs; + try { + final windows = await _mainSleepWindows(startTs, endTs); + if (windows.isNotEmpty) { + protectStartMs = windows.map((w) => w.onsetMs).reduce((a, b) => a < b ? a : b); + protectEndMs = windows.map((w) => w.offsetMs).reduce((a, b) => a > b ? a : b); + } + } catch (e) { + debugPrint('[health] nap protect-window lookup: $e'); + } + final deleted = await _writer.deleteRange( start: DateTime.fromMillisecondsSinceEpoch(startTs * 1000), end: DateTime.fromMillisecondsSinceEpoch(endTs * 1000), + protectStartMs: protectStartMs, + protectEndMs: protectEndMs, ); + if (!deleted) { + await _recordPending(_kPendingNapDeletes, { + 'startTs': startTs, + 'endTs': endTs, + }); + } else { + await _removePending(_kPendingNapDeletes, null, startTs); + } + return deleted; } catch (e) { debugPrint('[health] deleteNapWindow $startTs-$endTs: $e'); + await _recordPending(_kPendingNapDeletes, { + 'startTs': startTs, + 'endTs': endTs, + }); return false; } }); + /// Retry every pending nap delete (a failed delete leaves the removed + /// nap's samples in Apple Health). Successful ones are dropped from the + /// cursor list. + Future _retryPendingDeletes() async { + final pending = await _pendingList(_kPendingNapDeletes); + if (pending.isEmpty) return; + for (final p in pending) { + final s = p['startTs'], e = p['endTs']; + if (s is! num || e is! num || e <= s) continue; + try { + int? protectStartMs, protectEndMs; + final windows = await _mainSleepWindows(s.toInt(), e.toInt()); + if (windows.isNotEmpty) { + protectStartMs = windows.map((w) => w.onsetMs).reduce((a, b) => a < b ? a : b); + protectEndMs = windows.map((w) => w.offsetMs).reduce((a, b) => a > b ? a : b); + } + if (await _writer.deleteRange( + start: DateTime.fromMillisecondsSinceEpoch(s.toInt() * 1000), + end: DateTime.fromMillisecondsSinceEpoch(e.toInt() * 1000), + protectStartMs: protectStartMs, + protectEndMs: protectEndMs, + )) { + await _removePending(_kPendingNapDeletes, null, s.toInt()); + } + } catch (e) { + debugPrint('[health] retry pending nap delete $s-$e: $e'); + } + } + } + + Future>> _pendingList(String key) async { + final raw = await _getCursor(key); + if (raw == null || raw.isEmpty) return []; + try { + final decoded = jsonDecode(raw); + if (decoded is! List) return []; + return [ + for (final e in decoded) + if (e is Map) Map.from(e), + ]; + } catch (_) { + return []; + } + } + + Future _recordPending(String key, Map entry) async { + try { + final list = await _pendingList(key); + final dup = list.any((e) => + e['startTs'] == entry['startTs'] && e['endTs'] == entry['endTs']); + if (!dup) { + list.add(entry); + await _setCursor(key, jsonEncode(list)); + } + } catch (e) { + debugPrint('[health] record pending nap op: $e'); + } + } + + Future _removePending(String key, String? dayId, int startTs) async { + try { + final list = await _pendingList(key); + final kept = list.where((e) { + if (e['startTs'] != startTs) return true; + if (dayId != null && e['dayId'] != dayId) return true; + return false; + }).toList(); + await _setCursor(key, jsonEncode(kept)); + } catch (e) { + debugPrint('[health] remove pending nap op: $e'); + } + } + static Future _defaultHealthSyncEnabled() async { final prefs = await SharedPreferences.getInstance(); return prefs.getBool(kHealthSyncPref) == true; @@ -333,6 +537,15 @@ class HealthNapExporter { return rows.isEmpty ? null : rows.single; } + static Future>> _defaultDayNapRows(String dayId) => + LocalDb.napEdits(dayId); + static Future _defaultOverlapsMainSleep(int startTs, int endTs) async => ActivityStore.overlapsMainSleep(await LocalDb.instance, startTs, endTs); + + static Future> _defaultMainSleepWindows( + int startTs, + int endTs, + ) async => + ActivityStore.mainSleepWindowsAround(await LocalDb.instance, startTs, endTs); } diff --git a/test/health_nap_export_test.dart b/test/health_nap_export_test.dart index 7bdb9143b..7d7d24668 100644 --- a/test/health_nap_export_test.dart +++ b/test/health_nap_export_test.dart @@ -9,21 +9,26 @@ import 'package:openstrap_edge/compute/nap_edits.dart' import 'package:openstrap_edge/health/health_nap_export.dart'; class FakeNapWriter implements HealthKitNapWriter { - final calls = <({String method, DateTime start, DateTime end})>[]; + final calls = <({String method, DateTime start, DateTime end, int? protectStartMs, int? protectEndMs})>[]; bool nextResult = true; bool throwOnCall = false; int throwOnCallNumber = 0; // 1-based; 0 = never @override Future replaceNap({required DateTime start, required DateTime end}) => - _record('replaceNap', start, end); + _record('replaceNap', start, end, null, null); @override - Future deleteRange({required DateTime start, required DateTime end}) => - _record('deleteNapRange', start, end); - - Future _record(String method, DateTime start, DateTime end) async { - calls.add((method: method, start: start, end: end)); + Future deleteRange({ + required DateTime start, + required DateTime end, + int? protectStartMs, + int? protectEndMs, + }) => + _record('deleteNapRange', start, end, protectStartMs, protectEndMs); + + Future _record(String method, DateTime start, DateTime end, int? ps, int? pe) async { + calls.add((method: method, start: start, end: end, protectStartMs: ps, protectEndMs: pe)); final n = calls.length; if (throwOnCall || (throwOnCallNumber > 0 && n == throwOnCallNumber)) { throw Exception('store error'); @@ -39,15 +44,27 @@ HealthNapExporter exporter({ Map? row, List>? dayRows, bool overlaps = false, -}) => - HealthNapExporter( - writer: writer, - isApple: isApple, - healthSyncEnabled: () async => healthSync, - napRow: (dayId, startTs) async => row, - dayNapRows: (dayId) async => dayRows ?? (row == null ? [] : [row]), - overlapsMainSleep: (s, e) async => overlaps, - ); + Map? cursors, + bool throwOnNapRow = false, +}) { + final store = cursors ?? {}; + return HealthNapExporter( + writer: writer, + isApple: isApple, + healthSyncEnabled: () async => healthSync, + napRow: (dayId, startTs) async { + if (throwOnNapRow) throw Exception('db down'); + return row; + }, + dayNapRows: (dayId) async => dayRows ?? (row == null ? [] : [row]), + overlapsMainSleep: (s, e) async => overlaps, + mainSleepWindows: (s, e) async => const [], + getCursor: (name) async => store[name], + setCursor: (name, value) async { + store[name] = value; + }, + ); +} void main() { // Derived from the real clock: exportNap validates "not in the future" @@ -372,5 +389,95 @@ void main() { hasLength(2), ); }); + + test('overlapping nap rows do not erase each other — first wins', () async { + final overlap = { + 'source': 'manual', + 'start_ts': start + 600, + 'end_ts': start + 2400, + }; + final writer = FakeNapWriter(); + final ok = await exporter(writer: writer, dayRows: [confirmed, overlap]) + .exportDayNaps('2026-08-03'); + expect(ok, isTrue); + expect( + writer.calls.where((c) => c.method == 'replaceNap'), + hasLength(1), + ); + }); + + test('a failed export is queued for retry and retried on the next pass', + () async { + final cursors = {}; + final writer = FakeNapWriter()..nextResult = false; + final e = exporter( + writer: writer, + row: {'source': 'confirmed', 'start_ts': start, 'end_ts': end}, + cursors: cursors, + ); + expect( + await e.exportNap(dayId: '2026-08-03', startTs: start, endTs: end), + isFalse, + ); + expect(cursors['health_nap_pending_exports'], contains('"startTs":$start')); + // Next day pass: writer now succeeds, retry is attempted and cleared. + writer.nextResult = true; + final ok = await e.exportDayNaps('2026-08-03'); + expect(ok, isTrue); + expect( + writer.calls.where((c) => c.method == 'replaceNap'), + hasLength(1), + ); + expect(cursors['health_nap_pending_exports'], '[]'); + }); + + test('a failed delete is queued and retried by the day pass', () async { + final cursors = {}; + final writer = FakeNapWriter()..nextResult = false; + final e = exporter(writer: writer, cursors: cursors); + expect( + await e.deleteNapWindow(startTs: start, endTs: end), + isFalse, + ); + expect(cursors['health_nap_pending_deletes'], contains('"startTs":$start')); + writer.nextResult = true; + await e.exportDayNaps('2026-08-03'); + expect( + writer.calls.where((c) => c.method == 'deleteNapRange'), + hasLength(1), + ); + expect(cursors['health_nap_pending_deletes'], '[]'); + }); + + test('delete passes the protected main-sleep window to the writer', + () async { + final writer = FakeNapWriter(); + final e = HealthNapExporter( + writer: writer, + isApple: true, + healthSyncEnabled: () async => true, + napRow: (d, s) async => null, + dayNapRows: (d) async => const [], + overlapsMainSleep: (s, e) async => false, + mainSleepWindows: (s, e) async => [ + (onsetMs: (start + 7200) * 1000, offsetMs: (start + 7200 + 8 * 3600) * 1000), + ], + getCursor: (n) async => null, + setCursor: (n, v) async {}, + ); + expect(await e.deleteNapWindow(startTs: start, endTs: end), isTrue); + final call = writer.calls.single; + expect(call.method, 'deleteNapRange'); + expect(call.protectStartMs, (start + 7200) * 1000); + expect(call.protectEndMs, (start + 7200 + 8 * 3600) * 1000); + }); + + test('exportNapRowAt returns false instead of throwing when the row read fails', + () async { + final writer = FakeNapWriter(); + final e = exporter(writer: writer, throwOnNapRow: true); + expect(await e.exportNapRowAt(dayId: '2026-08-03', startTs: start), isFalse); + expect(writer.calls, isEmpty); + }); }); } From a386b20d5e3781bea64dc32391edac46aa180ca8 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Sun, 8 Nov 2026 18:06:58 +0000 Subject: [PATCH 15/15] Address second review round on #677: per-window protection, independent day exports, retry revalidation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - deleteNapRange now takes a LIST of protected main-sleep windows (protectWindows) — a union span would also cover a nap between two nights and silently keep everything; each window is protected separately in the initial delete and in the retry path. - A failed protection lookup no longer proceeds unprotected: it propagates and the delete is queued for retry. - _exportDay awaits both days' nap exports independently (a failure on one no longer skips the other). - Pending exports are retried for ANY day and revalidated against the current row (source + end_ts) before writing; obsolete entries are dropped. Pending deletes are reconciled the same way: a restored (re-confirmed) nap drops its pending delete instead of being deleted. - Tests: per-window protect args, restore-reconciles-pending-delete, cross-day pending export retried / obsolete dropped. Co-authored-by: BucciMobile --- ios/Runner/HealthKitSleepWriter.swift | 36 ++++---- lib/health/health_export.dart | 7 +- lib/health/health_nap_export.dart | 126 +++++++++++++++++--------- lib/ui2/screens/naps.dart | 3 +- test/health_nap_export_test.dart | 81 +++++++++++++++-- 5 files changed, 183 insertions(+), 70 deletions(-) diff --git a/ios/Runner/HealthKitSleepWriter.swift b/ios/Runner/HealthKitSleepWriter.swift index 2fd15131b..6abd808fe 100644 --- a/ios/Runner/HealthKitSleepWriter.swift +++ b/ios/Runner/HealthKitSleepWriter.swift @@ -90,10 +90,12 @@ enum HealthKitSleepWriter { // A nap removed/rejected in Edge: delete OUR sleepAnalysis samples // overlapping the window so the export does not outlive the decision. - // [protectStartTime/protectEndTime] (optional, epoch ms) mark the current - // main-sleep window: if the night shifted onto a previously exported nap, - // samples overlapping that window are kept — deleting them would remove - // the user's night from Apple Health. + // `protectWindows` (list of {startMs, endMs}) marks the CURRENT main-sleep + // windows: if a night shifted onto a previously exported nap, samples + // overlapping any of those windows are kept — deleting them would remove + // the user's night from Apple Health. Each window is protected separately; + // a union span would also cover a nap lying BETWEEN two nights and the + // delete would silently keep everything. private static func deleteRange(_ args: [String: Any], completion: @escaping (Bool) -> Void) { guard let seq = napSeq(args) else { completion(false) @@ -111,10 +113,16 @@ enum HealthKitSleepWriter { completion(false) return } - let protectStart = dateMs(args["protectStartTime"]) - let protectEnd = dateMs(args["protectEndTime"]) + var protect: [(Date, Date)] = [] + for raw in (args["protectWindows"] as? [Any] ?? []) { + if let m = raw as? [String: Any], + let ps = dateMs(m["startMs"]), + let pe = dateMs(m["endMs"]), ps < pe { + protect.append((ps, pe)) + } + } deleteRange(samples: [], type: sleepType, start: start, end: end, - protectStart: protectStart, protectEnd: protectEnd, seq: seq, completion: completion) + protect: protect, seq: seq, completion: completion) } private static func napSeq(_ args: [String: Any]) -> Int? { @@ -176,8 +184,7 @@ enum HealthKitSleepWriter { type: HKCategoryType, start: Date, end: Date, - protectStart: Date?, - protectEnd: Date?, + protect: [(Date, Date)], seq: Int, completion: @escaping (Bool) -> Void ) { @@ -185,7 +192,7 @@ enum HealthKitSleepWriter { let sourcePred = HKQuery.predicateForObjects(from: HKSource.default()) let pred = NSCompoundPredicate(andPredicateWithSubpredicates: [datePred, sourcePred]) let query = HKSampleQuery(sampleType: type, predicate: pred, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { _, results, error in - guard let samples = results as? [HKCategorySample], error == nil else { + guard let found = results as? [HKCategorySample], error == nil else { completion(false) return } @@ -193,12 +200,9 @@ enum HealthKitSleepWriter { completion(false) // superseded while the query was in flight return } - // Samples overlapping the protected main-sleep window are kept. - let doomed = samples.filter { s in - if let ps = protectStart, let pe = protectEnd { - return !(s.startDate < pe && s.endDate > ps) - } - return true + // Samples overlapping ANY protected main-sleep window are kept. + let doomed = found.filter { s in + !protect.contains { ps, pe in s.startDate < pe && s.endDate > ps } } guard !doomed.isEmpty else { completion(true) diff --git a/lib/health/health_export.dart b/lib/health/health_export.dart index 43f4e315f..82af140b1 100644 --- a/lib/health/health_export.dart +++ b/lib/health/health_export.dart @@ -1270,8 +1270,11 @@ class HealthExporter { DateTime(dayStart.year, dayStart.month, dayStart.day - 1), ); try { - if (!await HealthNapExporter.shared.exportDayNaps(date) || - !await HealthNapExporter.shared.exportDayNaps(prevDate)) { + // Both days independently: a failure on one must not skip the other — + // the cleanup may already have deleted that day's nap samples. + final todayOk = await HealthNapExporter.shared.exportDayNaps(date); + final prevOk = await HealthNapExporter.shared.exportDayNaps(prevDate); + if (!todayOk || !prevOk) { debugPrint('[health] write Apple naps returned false'); success = false; } diff --git a/lib/health/health_nap_export.dart b/lib/health/health_nap_export.dart index 4a86225f1..912c5284b 100644 --- a/lib/health/health_nap_export.dart +++ b/lib/health/health_nap_export.dart @@ -55,13 +55,12 @@ abstract interface class HealthKitNapWriter { Future replaceNap({required DateTime start, required DateTime end}); /// Delete our own sleepAnalysis samples overlapping [start, end), except - /// those overlapping [protectStartMs, protectEndMs) (the current main-sleep - /// window in epoch ms, when known). + /// those overlapping any window in [protectWindows] (the current main-sleep + /// windows as {'startMs', 'endMs'} maps, epoch ms). Future deleteRange({ required DateTime start, required DateTime end, - int? protectStartMs, - int? protectEndMs, + List> protectWindows = const [], }); } @@ -90,14 +89,12 @@ class MethodChannelHealthKitNapWriter implements HealthKitNapWriter { Future deleteRange({ required DateTime start, required DateTime end, - int? protectStartMs, - int? protectEndMs, + List> protectWindows = const [], }) => _invoke('deleteNapRange', { 'startTime': start.millisecondsSinceEpoch, 'endTime': end.millisecondsSinceEpoch, - if (protectStartMs != null) 'protectStartTime': protectStartMs, - if (protectEndMs != null) 'protectEndTime': protectEndMs, + 'protectWindows': protectWindows, 'seq': ++_seq, }); @@ -257,7 +254,9 @@ class HealthNapExporter { static Future deleteExportedNap({ required int startTs, required int endTs, - }) => shared.deleteNapWindow(startTs: startTs, endTs: endTs); + String? dayId, + }) => + shared.deleteNapWindow(startTs: startTs, endTs: endTs, dayId: dayId); Future exportNap({ required String dayId, @@ -353,13 +352,35 @@ class HealthNapExporter { if (startTs is! num || endTs is! num) continue; consider(startTs.toInt(), endTs.toInt(), source is String ? source : null); } - // Retry previously failed exports of THIS day (failed writes on days - // behind the export cursor would otherwise never be retried). + // Retry previously failed exports of ANY day (failed writes on days + // behind the export cursor would otherwise never be retried). Each + // queued entry is revalidated against the CURRENT row before writing: + // if the nap was since removed/rejected or its window changed, the + // entry is obsolete and dropped instead of recreating it in Health. for (final p in await _pendingList(_kPendingNapExports)) { - if (p['dayId'] != dayId) continue; + final pDay = p['dayId']; final s = p['startTs'], e = p['endTs']; - if (s is! num || e is! num) continue; - consider(s.toInt(), e.toInt(), 'confirmed'); + if (pDay is! String || s is! num || e is! num) { + await _removePendingEntry(_kPendingNapExports, p); // drop malformed + continue; + } + Map? current; + try { + current = await _napRow(pDay, s.toInt()); + } catch (_) { + continue; // DB down — keep the entry for a later pass + } + final source = current?['source']; + final storedEnd = current?['end_ts']; + final stillValid = + (source == 'confirmed' || source == 'manual') && + storedEnd is num && + storedEnd.toInt() == e.toInt(); + if (!stillValid) { + await _removePending(_kPendingNapExports, pDay, s.toInt()); + continue; + } + consider(s.toInt(), e.toInt(), source as String); } var ok = true; @@ -404,41 +425,39 @@ class HealthNapExporter { Future deleteNapWindow({ required int startTs, required int endTs, + String? dayId, }) => synchronized(() async { try { if (!isApple) return false; if (!await _healthSyncEnabled()) return false; if (endTs <= startTs) return false; - // Protect the current main-sleep window: if the night shifted onto a - // previously exported nap, its samples must survive the nap delete. - int? protectStartMs, protectEndMs; - try { - final windows = await _mainSleepWindows(startTs, endTs); - if (windows.isNotEmpty) { - protectStartMs = windows.map((w) => w.onsetMs).reduce((a, b) => a < b ? a : b); - protectEndMs = windows.map((w) => w.offsetMs).reduce((a, b) => a > b ? a : b); - } - } catch (e) { - debugPrint('[health] nap protect-window lookup: $e'); - } + // Protect EACH current main-sleep window separately: if a night + // shifted onto a previously exported nap, its samples must survive the + // nap delete. A failed lookup must NOT proceed unprotected — it + // propagates to the outer catch, which queues the delete for retry. + final windows = await _mainSleepWindows(startTs, endTs); + final protect = [ + for (final w in windows) {'startMs': w.onsetMs, 'endMs': w.offsetMs}, + ]; final deleted = await _writer.deleteRange( start: DateTime.fromMillisecondsSinceEpoch(startTs * 1000), end: DateTime.fromMillisecondsSinceEpoch(endTs * 1000), - protectStartMs: protectStartMs, - protectEndMs: protectEndMs, + protectWindows: protect, ); if (!deleted) { await _recordPending(_kPendingNapDeletes, { + 'dayId': dayId, 'startTs': startTs, 'endTs': endTs, }); } else { - await _removePending(_kPendingNapDeletes, null, startTs); + await _removePending(_kPendingNapDeletes, dayId, startTs); } return deleted; } catch (e) { debugPrint('[health] deleteNapWindow $startTs-$endTs: $e'); await _recordPending(_kPendingNapDeletes, { + 'dayId': dayId, 'startTs': startTs, 'endTs': endTs, }); @@ -447,31 +466,46 @@ class HealthNapExporter { }); /// Retry every pending nap delete (a failed delete leaves the removed - /// nap's samples in Apple Health). Successful ones are dropped from the - /// cursor list. + /// nap's samples in Apple Health). Before replaying, each entry is + /// reconciled with the CURRENT row: if the nap was restored ("put back") + /// and is confirmed again, the pending delete is dropped — replaying it + /// would remove the restored nap from Apple Health. A failed protection + /// lookup keeps the entry (never delete unprotected). Successful deletes + /// are dropped from the cursor list. Future _retryPendingDeletes() async { final pending = await _pendingList(_kPendingNapDeletes); if (pending.isEmpty) return; for (final p in pending) { final s = p['startTs'], e = p['endTs']; if (s is! num || e is! num || e <= s) continue; + final dayId = p['dayId']; try { - int? protectStartMs, protectEndMs; - final windows = await _mainSleepWindows(s.toInt(), e.toInt()); - if (windows.isNotEmpty) { - protectStartMs = windows.map((w) => w.onsetMs).reduce((a, b) => a < b ? a : b); - protectEndMs = windows.map((w) => w.offsetMs).reduce((a, b) => a > b ? a : b); + if (dayId is String) { + final row = await _napRow(dayId, s.toInt()); + final source = row?['source']; + final storedEnd = row?['end_ts']; + if ((source == 'confirmed' || source == 'manual') && + storedEnd is num && + storedEnd.toInt() == e.toInt()) { + // Restored after the failed delete — the pending delete is stale. + await _removePending(_kPendingNapDeletes, dayId, s.toInt()); + continue; + } } + final windows = await _mainSleepWindows(s.toInt(), e.toInt()); + final protect = [ + for (final w in windows) {'startMs': w.onsetMs, 'endMs': w.offsetMs}, + ]; if (await _writer.deleteRange( start: DateTime.fromMillisecondsSinceEpoch(s.toInt() * 1000), end: DateTime.fromMillisecondsSinceEpoch(e.toInt() * 1000), - protectStartMs: protectStartMs, - protectEndMs: protectEndMs, + protectWindows: protect, )) { - await _removePending(_kPendingNapDeletes, null, s.toInt()); + await _removePending(_kPendingNapDeletes, dayId is String ? dayId : null, s.toInt()); } - } catch (e) { - debugPrint('[health] retry pending nap delete $s-$e: $e'); + } catch (err) { + // Lookup or store failure: keep the entry for a later pass. + debugPrint('[health] retry pending nap delete $s-$e: $err'); } } } @@ -505,6 +539,16 @@ class HealthNapExporter { } } + Future _removePendingEntry(String key, Map entry) async { + try { + final list = await _pendingList(key); + list.removeWhere((e) => e['startTs'] == entry['startTs'] && e['endTs'] == entry['endTs']); + await _setCursor(key, jsonEncode(list)); + } catch (e) { + debugPrint('[health] remove pending nap entry: $e'); + } + } + Future _removePending(String key, String? dayId, int startTs) async { try { final list = await _pendingList(key); diff --git a/lib/ui2/screens/naps.dart b/lib/ui2/screens/naps.dart index 52e41629f..a67a21b66 100644 --- a/lib/ui2/screens/naps.dart +++ b/lib/ui2/screens/naps.dart @@ -221,7 +221,8 @@ class _NapsScreenState extends State { // purpose: a failed delete returns false and is only logged — the // next per-day export re-writes the day's remaining naps, and a // stale sample here is harmless (it is the app's own window). - await HealthNapExporter.deleteExportedNap(startTs: start, endTs: end); + await HealthNapExporter.deleteExportedNap( + startTs: start, endTs: end, dayId: day); }); }); diff --git a/test/health_nap_export_test.dart b/test/health_nap_export_test.dart index 7d7d24668..269a47ede 100644 --- a/test/health_nap_export_test.dart +++ b/test/health_nap_export_test.dart @@ -2,6 +2,8 @@ // overlapping the derived main sleep; delete-then-write makes repeated // exports idempotent; removing a nap deletes its Health window. +import 'dart:convert'; + import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/compute/nap_edits.dart' @@ -9,26 +11,25 @@ import 'package:openstrap_edge/compute/nap_edits.dart' import 'package:openstrap_edge/health/health_nap_export.dart'; class FakeNapWriter implements HealthKitNapWriter { - final calls = <({String method, DateTime start, DateTime end, int? protectStartMs, int? protectEndMs})>[]; + final calls = <({String method, DateTime start, DateTime end, List> protect})>[]; bool nextResult = true; bool throwOnCall = false; int throwOnCallNumber = 0; // 1-based; 0 = never @override Future replaceNap({required DateTime start, required DateTime end}) => - _record('replaceNap', start, end, null, null); + _record('replaceNap', start, end, const []); @override Future deleteRange({ required DateTime start, required DateTime end, - int? protectStartMs, - int? protectEndMs, + List> protectWindows = const [], }) => - _record('deleteNapRange', start, end, protectStartMs, protectEndMs); + _record('deleteNapRange', start, end, protectWindows); - Future _record(String method, DateTime start, DateTime end, int? ps, int? pe) async { - calls.add((method: method, start: start, end: end, protectStartMs: ps, protectEndMs: pe)); + Future _record(String method, DateTime start, DateTime end, List> protect) async { + calls.add((method: method, start: start, end: end, protect: protect)); final n = calls.length; if (throwOnCall || (throwOnCallNumber > 0 && n == throwOnCallNumber)) { throw Exception('store error'); @@ -449,7 +450,7 @@ void main() { expect(cursors['health_nap_pending_deletes'], '[]'); }); - test('delete passes the protected main-sleep window to the writer', + test('delete passes each protected main-sleep window to the writer', () async { final writer = FakeNapWriter(); final e = HealthNapExporter( @@ -460,6 +461,7 @@ void main() { dayNapRows: (d) async => const [], overlapsMainSleep: (s, e) async => false, mainSleepWindows: (s, e) async => [ + (onsetMs: (start - 86400) * 1000, offsetMs: (start - 80000) * 1000), (onsetMs: (start + 7200) * 1000, offsetMs: (start + 7200 + 8 * 3600) * 1000), ], getCursor: (n) async => null, @@ -468,8 +470,67 @@ void main() { expect(await e.deleteNapWindow(startTs: start, endTs: end), isTrue); final call = writer.calls.single; expect(call.method, 'deleteNapRange'); - expect(call.protectStartMs, (start + 7200) * 1000); - expect(call.protectEndMs, (start + 7200 + 8 * 3600) * 1000); + expect(call.protect, hasLength(2)); + expect(call.protect[1]['startMs'], (start + 7200) * 1000); + }); + + test('a pending delete is dropped when the nap was restored (re-confirmed)', + () async { + final cursors = { + 'health_nap_pending_deletes': + '[{"dayId":"2026-08-03","startTs":$start,"endTs":$end}]', + }; + final writer = FakeNapWriter(); + final e = exporter( + writer: writer, + cursors: cursors, + row: {'source': 'confirmed', 'start_ts': start, 'end_ts': end}, + ); + await e.exportDayNaps('2026-08-03'); + // Restored nap: the pending delete must NOT be replayed. + expect( + writer.calls.where((c) => c.method == 'deleteNapRange'), + isEmpty, + ); + expect(cursors['health_nap_pending_deletes'], '[]'); + }); + + test('a pending export of another day is retried; an obsolete one dropped', + () async { + final otherDay = '2026-08-01'; + final cursors = { + 'health_nap_pending_exports': jsonEncode([ + {'dayId': otherDay, 'startTs': start, 'endTs': end}, + {'dayId': '2026-08-02', 'startTs': start + 100, 'endTs': end + 100}, + ]), + }; + final writer = FakeNapWriter(); + // The other-day row is still confirmed and unchanged -> retried. + // The second entry's row no longer matches -> dropped, never written. + final e = HealthNapExporter( + writer: writer, + isApple: true, + healthSyncEnabled: () async => true, + napRow: (d, s) async => d == otherDay + ? {'source': 'confirmed', 'start_ts': s, 'end_ts': end} + : null, + dayNapRows: (d) async => const [], + overlapsMainSleep: (s, e) async => false, + mainSleepWindows: (s, e) async => const [], + getCursor: (n) async => cursors[n], + setCursor: (n, v) async { + cursors[n] = v; + }, + ); + final ok = await e.exportDayNaps('2026-08-03'); + expect(ok, isTrue); + expect( + writer.calls.where((c) => c.method == 'replaceNap'), + hasLength(1), + ); + final remaining = jsonDecode(cursors['health_nap_pending_exports']!); + expect(remaining, hasLength(1)); + expect(remaining.single['dayId'], '2026-08-02'); }); test('exportNapRowAt returns false instead of throwing when the row read fails',