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 diff --git a/ios/Runner/HealthKitSleepWriter.swift b/ios/Runner/HealthKitSleepWriter.swift index 65c9d43ce..6abd808fe 100644 --- a/ios/Runner/HealthKitSleepWriter.swift +++ b/ios/Runner/HealthKitSleepWriter.swift @@ -15,58 +15,156 @@ 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) 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 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 } - 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 { + 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, 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. + // `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) return } - - let samples: [HKCategorySample] - do { - samples = try buildSamples(args, type: sleepType) - } catch { + if staleNapCallDropped(seq, completion: completion) { return } + 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 } + 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, + protect: protect, seq: seq, completion: completion) + } - let datePred = HKQuery.predicateForSamples( - withStart: cleanupStart, end: cleanupEnd, options: [] - ) + 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(_ seq: Int, completion: @escaping (Bool) -> Void) -> Bool { + if seq <= lastNapSeq { + completion(false) + return true + } + lastNapSeq = seq + 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: []) 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 } + if let seq, seq != lastNapSeq { + completion(false) // superseded while the delete was in flight + return + } guard !samples.isEmpty else { completion(true) return @@ -77,6 +175,80 @@ 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, + protect: [(Date, 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 found = results as? [HKCategorySample], error == nil else { + completion(false) + return + } + if seq != lastNapSeq { + completion(false) // superseded while the query was in flight + return + } + // 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) + 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 + 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, + seq: nil, // sleep sessions carry no seq — unchanged behavior + completion: completion + ) + } + private static func buildSamples( _ args: [String: Any], type: HKCategoryType diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 16eb8578b..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; @@ -10269,9 +10276,17 @@ 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. 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/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/lib/data/activity_store.dart b/lib/data/activity_store.dart index 6c69eedea..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, @@ -465,7 +498,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 f74dba334..966df83d5 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; @@ -90,9 +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); + // 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_export.dart b/lib/health/health_export.dart index 0e227ee9d..82af140b1 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,31 @@ 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 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 { + // 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; + } + } 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; + } } // 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 new file mode 100644 index 000000000..912c5284b --- /dev/null +++ b/lib/health/health_nap_export.dart @@ -0,0 +1,595 @@ +// 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 (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, 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'; +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), except + /// 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, + List> protectWindows = const [], + }); +} + +class MethodChannelHealthKitNapWriter implements HealthKitNapWriter { + MethodChannelHealthKitNapWriter({ + this.channel = const MethodChannel('openstrap/healthkit_sleep'), + }); + + 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 AND re-checks the seq before saving, + // so a late replaceNap can never overwrite a newer deleteNapRange. + int _seq = 0; + + @override + Future replaceNap({required DateTime start, required DateTime end}) => + _invoke('replaceNap', { + 'startTime': start.millisecondsSinceEpoch, + 'endTime': end.millisecondsSinceEpoch, + 'seq': ++_seq, + }); + + @override + Future deleteRange({ + required DateTime start, + required DateTime end, + List> protectWindows = const [], + }) => + _invoke('deleteNapRange', { + 'startTime': start.millisecondsSinceEpoch, + 'endTime': end.millisecondsSinceEpoch, + 'protectWindows': protectWindows, + 'seq': ++_seq, + }); + + 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 shared 5 min – kMaxNapSec (3 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; +} + +/// 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; + +/// 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, + bool? isApple, + Future Function()? healthSyncEnabled, + 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, + _napRow = napRow ?? _defaultNapRow, + _dayNapRows = dayNapRows ?? LocalDb.napEdits, + _overlapsMainSleep = overlapsMainSleep ?? _defaultOverlapsMainSleep, + _mainSleepWindows = mainSleepWindows ?? _defaultMainSleepWindows, + _getCursor = getCursor ?? LocalDb.getCursor, + _setCursor = setCursor ?? LocalDb.setCursor; + + final HealthKitNapWriter _writer; + final bool isApple; + 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; + 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(); + + 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((_) => runZoned(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. + 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 { + 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). A failed write is queued for retry by the + /// per-day export pass. + 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). 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, + String? dayId, + }) => + shared.deleteNapWindow(startTs: startTs, endTs: endTs, dayId: dayId); + + Future exportNap({ + required String dayId, + required int startTs, + required int endTs, + }) => synchronized(() 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']; + // 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, + 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; + 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] 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; + 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, + startTs: s, + endTs: e, + nowSec: nowSec, + )) { + 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 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)) { + final pDay = p['dayId']; + final s = p['startTs'], e = p['endTs']; + 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; + 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(w.s, w.e)) continue; + final written = await _writer.replaceNap( + start: DateTime.fromMillisecondsSinceEpoch(w.s * 1000), + end: DateTime.fromMillisecondsSinceEpoch(w.e * 1000), + ); + 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 ${w.s}-${w.e}: $rowError'); + ok = false; + await _recordPending(_kPendingNapExports, { + 'dayId': dayId, + 'startTs': w.s, + 'endTs': w.e, + }); + } + } + return ok; + } catch (e) { + debugPrint('[health] exportDayNaps $dayId: $e'); + return false; + } + }); + + 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 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), + protectWindows: protect, + ); + if (!deleted) { + await _recordPending(_kPendingNapDeletes, { + 'dayId': dayId, + 'startTs': startTs, + 'endTs': endTs, + }); + } else { + await _removePending(_kPendingNapDeletes, dayId, startTs); + } + return deleted; + } catch (e) { + debugPrint('[health] deleteNapWindow $startTs-$endTs: $e'); + await _recordPending(_kPendingNapDeletes, { + 'dayId': dayId, + 'startTs': startTs, + 'endTs': endTs, + }); + return false; + } + }); + + /// Retry every pending nap delete (a failed delete leaves the removed + /// 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 { + 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), + protectWindows: protect, + )) { + await _removePending(_kPendingNapDeletes, dayId is String ? dayId : null, s.toInt()); + } + } catch (err) { + // Lookup or store failure: keep the entry for a later pass. + debugPrint('[health] retry pending nap delete $s-$e: $err'); + } + } + } + + 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 _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); + 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; + } + + 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>> _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/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 d8a6bda96..a67a21b66 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'; @@ -201,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 @@ -212,10 +216,23 @@ 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. 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, dayId: day); + }); }); - Future _restore(String day, int start) => - _edit(() => LocalDb.deleteNapEdit(day, start)); + Future _restore(String day, int start) => _edit(() async { + 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 /// remember when they lay down and when they got up, not how long it was. @@ -250,7 +267,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; @@ -275,8 +292,14 @@ class _NapsScreenState extends State { return; } if (!mounted) return; - await _edit(() => LocalDb.putNapEdit( - dayId: day, startTs: s, endTs: e, source: 'manual')); + await _edit(() async { + 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); + }); + }); } @override diff --git a/test/activity_review_test.dart b/test/activity_review_test.dart index 7ba776ae5..5ba891ab4 100644 --- a/test/activity_review_test.dart +++ b/test/activity_review_test.dart @@ -677,21 +677,28 @@ 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); - final st = a.millisecondsSinceEpoch ~/ 1000, - en = b.millisecondsSinceEpoch ~/ 1000; - expect(en - st, 3 * 3600); // repeated hour is real elapsed sleep time + // 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': 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 new file mode 100644 index 000000000..269a47ede --- /dev/null +++ b/test/health_nap_export_test.dart @@ -0,0 +1,544 @@ +// 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 'dart:convert'; + +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 { + 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, const []); + + @override + Future deleteRange({ + required DateTime start, + required DateTime end, + List> protectWindows = const [], + }) => + _record('deleteNapRange', start, end, protectWindows); + + 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'); + } + return nextResult; + } +} + +HealthNapExporter exporter({ + required FakeNapWriter writer, + bool isApple = true, + bool healthSync = true, + Map? row, + List>? dayRows, + bool overlaps = false, + 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" + // 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', () { + 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 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. 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: s0, + endTs: s0 + kMinManualNapSec - 60, + nowSec: nowSec), + isFalse, + ); + expect( + napWindowExportable( + source: 'confirmed', startTs: s0, endTs: s0 + kMaxNapSec, + nowSec: nowSec), + isTrue, + ); + expect( + napWindowExportable( + source: 'confirmed', + startTs: s0, + endTs: s0 + kMaxNapSec + 1, + 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 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( + 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); + }); + }); + + 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); + }); + + 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', + '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]) + .exportDayNaps('2026-08-03'); + expect(ok, isFalse); + expect( + writer.calls.where((c) => c.method == 'replaceNap'), + 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 each 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 - 86400) * 1000, offsetMs: (start - 80000) * 1000), + (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.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', + () 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); + }); + }); +} 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, 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);