From ebe4903b7e78d1cd3afd5695d7cbdd534a43c078 Mon Sep 17 00:00:00 2001 From: Ignacio Juarez Date: Thu, 20 Aug 2026 15:34:35 -0700 Subject: [PATCH] write Apple Health sleep as one in-bed night, including Core The in-app hypnogram was already right. HealthKit still showed a ~2h REM/Deep sliver: the plugin dropped Core and in-bed, leftover 11pm samples sat outside the detected window, and nights already marked exported never got rewritten. Native replace mirrors Android; the first sync after this also replaces retained nights already in Health. Co-authored-by: Cursor Change-Id: I9508eac926f8269394d7d7e59f001d43184561a3 Signed-off-by: Ignacio Juarez --- ios/Runner.xcodeproj/project.pbxproj | 4 + ios/Runner/AppDelegate.swift | 4 + ios/Runner/HealthKitSleepWriter.swift | 143 +++++++++++ lib/health/health_export.dart | 221 ++++++---------- lib/health/health_sleep_session.dart | 243 ++++++++++++++++-- test/health_sleep_export_test.dart | 354 +++++++++++++++++++------- 6 files changed, 722 insertions(+), 247 deletions(-) create mode 100644 ios/Runner/HealthKitSleepWriter.swift diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 7854de0c..d9e27306 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -21,6 +21,7 @@ 53962E962FF6EE120061A61B /* WatchBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53962E952FF6EE120061A61B /* WatchBridge.swift */; }; 53962E972FF6EE120061A61B /* OpenStrapIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53962E942FF6EE120061A61B /* OpenStrapIntents.swift */; }; 60DE9D949573401269D6DF2E /* HealthRoutes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46A2B400A52A2CA90242C195 /* HealthRoutes.swift */; }; + B9D2F406183A5C7E92B1D3F5 /* HealthKitSleepWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C1E3F507294B6D81A0C2E4 /* HealthKitSleepWriter.swift */; }; 630172CC9317145AD5F8F3B7 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2C721EA0C8D31A3834E66203 /* Pods_RunnerTests.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; @@ -102,6 +103,7 @@ 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 46A2B400A52A2CA90242C195 /* HealthRoutes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HealthRoutes.swift; sourceTree = ""; }; + A8C1E3F507294B6D81A0C2E4 /* HealthKitSleepWriter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HealthKitSleepWriter.swift; sourceTree = ""; }; 5348973B2FDC19C80033A4D9 /* OpenStrapWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OpenStrapWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 5348973C2FDC19C80033A4D9 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; 5348973E2FDC19C80033A4D9 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; @@ -299,6 +301,7 @@ ACCE55E7000000000000F11E /* AccessorySetup.swift */, 0BGTASK00000000000000002 /* BgSyncScheduler.swift */, 46A2B400A52A2CA90242C195 /* HealthRoutes.swift */, + A8C1E3F507294B6D81A0C2E4 /* HealthKitSleepWriter.swift */, ); path = Runner; sourceTree = ""; @@ -680,6 +683,7 @@ ACCE55E7000000000000B11D /* AccessorySetup.swift in Sources */, 0BGTASK00000000000000001 /* BgSyncScheduler.swift in Sources */, 60DE9D949573401269D6DF2E /* HealthRoutes.swift in Sources */, + B9D2F406183A5C7E92B1D3F5 /* HealthKitSleepWriter.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index c944b0b0..d92e2bd5 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -107,6 +107,10 @@ import CoreMotion if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "HealthRouteBridge") { HealthRouteBridge.register(messenger: registrar.messenger()) } + // HealthKit sleep replace (inBed + Core/Deep/REM). See HealthKitSleepWriter.swift. + if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "HealthKitSleepWriter") { + HealthKitSleepWriter.register(messenger: registrar.messenger()) + } // BGTask channel: Dart handler for opportunistic headless sync + heavy derivation. if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "BackgroundTaskManager") { BackgroundTaskManager.wireChannel(messenger: registrar.messenger()) diff --git a/ios/Runner/HealthKitSleepWriter.swift b/ios/Runner/HealthKitSleepWriter.swift new file mode 100644 index 00000000..52cc7149 --- /dev/null +++ b/ios/Runner/HealthKitSleepWriter.swift @@ -0,0 +1,143 @@ +import Flutter +import HealthKit + +// HealthKit sleep replace. Mirrors Android `HealthConnectSleepWriter`: one +// channel call deletes OUR overlapping `sleepAnalysis` samples, then writes +// one `inBed` envelope plus stages. +// +// The Flutter `health` plugin is not used here. Unknown type keys (SLEEP_SESSION) +// map to bodyMass and hang `delete()`. SLEEP_LIGHT / inBed writes have been +// failing on recent iOS as Core-less ~2 h nights with leftover 11pm fragments +// (#225/#239/#249). Dart already computed the noon-to-noon cleanup window; +// this file does not re-derive local noon. + +enum HealthKitSleepWriter { + private static let channelName = "openstrap/healthkit_sleep" + private static let store = HKHealthStore() + private static let queue = DispatchQueue(label: "wtf.openstrap.healthkit.sleep") + + static func register(messenger: FlutterBinaryMessenger) { + let channel = FlutterMethodChannel(name: channelName, binaryMessenger: messenger) + channel.setMethodCallHandler { call, result in + guard call.method == "replaceSleepSession" else { + result(FlutterMethodNotImplemented) + return + } + let args = call.arguments as? [String: Any] ?? [:] + queue.async { + replace(args) { ok in + DispatchQueue.main.async { result(ok) } + } + } + } + } + + 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 + } + + 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 + if error != nil { + completion(false) + return + } + guard !samples.isEmpty else { + completion(true) + return + } + store.save(samples) { success, error in + completion(success && error == nil) + } + } + } + + private static func buildSamples( + _ args: [String: Any], + type: HKCategoryType + ) throws -> [HKCategorySample] { + guard let start = dateMs(args["startTime"]), + let end = dateMs(args["endTime"]) else { + return [] + } + guard start < end else { + throw SleepWriteError.invalidWindow + } + + var samples: [HKCategorySample] = [ + HKCategorySample( + type: type, + value: HKCategoryValueSleepAnalysis.inBed.rawValue, + start: start, + end: end + ), + ] + + let rawStages = args["stages"] as? [Any] ?? [] + var previousEnd = start + for raw in rawStages { + guard let map = raw as? [String: Any], + let stageStart = dateMs(map["startTime"]), + let stageEnd = dateMs(map["endTime"]), + let value = sleepValue(map["stage"] as? String) else { + throw SleepWriteError.invalidStage + } + guard stageStart < stageEnd, + stageStart >= start, + stageEnd <= end, + stageStart >= previousEnd else { + throw SleepWriteError.invalidStage + } + samples.append( + HKCategorySample(type: type, value: value.rawValue, start: stageStart, end: stageEnd) + ) + previousEnd = stageEnd + } + return samples + } + + private static func sleepValue(_ stage: String?) -> HKCategoryValueSleepAnalysis? { + switch stage { + case "awake": return .awake + case "rem": return .asleepREM + case "light": return .asleepCore + case "deep": return .asleepDeep + default: return nil + } + } + + private static func dateMs(_ raw: Any?) -> Date? { + guard let n = raw as? NSNumber else { return nil } + return Date(timeIntervalSince1970: n.doubleValue / 1000.0) + } + + private enum SleepWriteError: Error { + case invalidWindow + case invalidStage + } +} diff --git a/lib/health/health_export.dart b/lib/health/health_export.dart index f4681ade..90fbf675 100644 --- a/lib/health/health_export.dart +++ b/lib/health/health_export.dart @@ -54,8 +54,8 @@ const _sleepHealthTypes = { // The night's envelope. Health Connect models it as a SleepSessionRecord // parent; HealthKit has no session record, so the enclosing bar is an // `inBed` sleepAnalysis sample. Only one of the two is ever asked for — - // see `_types` and `_sleepEnvelopeFor` — but both belong to the sleep - // delete SCOPE, which is what this set answers. + // see `_types`. Native writers own delete+write on both stores; this set is + // the authorization scope, not the plugin rewrite loop. HealthDataType.SLEEP_SESSION, HealthDataType.SLEEP_IN_BED, }; @@ -73,6 +73,9 @@ HealthDataType _foreignSleepEnvelope(bool isApplePlatform) => isApplePlatform : HealthDataType.SLEEP_IN_BED; List healthDeleteTypes({required bool isApplePlatform}) { + // Sleep and (on Android) minute HR are owned by native replace writers. + // Keeping sleep on the plugin delete list re-opens the HealthKit hang: + // unknown keys map to bodyMass and `delete()` never callbacks. final types = [ HealthDataType.RESTING_HEART_RATE, isApplePlatform @@ -83,38 +86,30 @@ List healthDeleteTypes({required bool isApplePlatform}) { HealthDataType.ACTIVE_ENERGY_BURNED, HealthDataType.BASAL_ENERGY_BURNED, HealthDataType.STEPS, - for (final t in _sleepHealthTypes) - if (t != _foreignSleepEnvelope(isApplePlatform)) t, HealthDataType.WORKOUT, ]; return isApplePlatform ? types - : types - .where( - (type) => - !_sleepHealthTypes.contains(type) && - type != HealthDataType.HEART_RATE, - ) - .toList(); + : types.where((type) => type != HealthDataType.HEART_RATE).toList(); } -/// The span a day's SLEEP-type delete has to cover. -/// -/// The calendar day is not it. Stage samples are written at TRUE epoch, so a -/// night that began at 23:10 sits in the PREVIOUS day — deleting only -/// `[dayStart, dayEnd)` leaves that half behind and every re-export appends -/// another copy of it. Widen to the union of the day and the night; with no -/// night to write, the day window is already right. -({DateTime start, DateTime end}) sleepCleanupWindow({ - required DateTime dayStart, - required DateTime dayEnd, - HealthSleepSession? night, -}) => ( - start: (night != null && night.start.isBefore(dayStart)) - ? night.start - : dayStart, - end: (night != null && night.end.isAfter(dayEnd)) ? night.end : dayEnd, -); +/// Cursor for the one-shot Apple Health sleep rewrite. Bump when the writer +/// changes enough that nights already sitting in HealthKit should be replaced +/// (plugin Core/in-bed misses, leftover 11pm fragments). Does not bump +/// `kAlgoVersion` — derived metrics are unchanged. +const kHealthSleepExportEpoch = 'apple-native-1'; +const kHealthSleepExportEpochCursor = 'health_sleep_export_epoch'; + +Future ensureHealthSleepExportEpoch({ + required Future Function(String name) getCursor, + required Future Function(String name, String value) setCursor, + String epoch = kHealthSleepExportEpoch, +}) async { + if (await getCursor(kHealthSleepExportEpochCursor) == epoch) return; + await setCursor('health_export_through', ''); + await setCursor('health_export_retry_state', ''); + await setCursor(kHealthSleepExportEpochCursor, epoch); +} bool shouldAttemptHealthExport({ required int attempts, @@ -205,6 +200,9 @@ class HealthExporter { final _androidSleep = HealthConnectSleepSessionExporter( writer: MethodChannelHealthConnectSleepSessionWriter(), ); + final _appleSleep = HealthKitSleepSessionExporter( + writer: MethodChannelHealthKitSleepSessionWriter(), + ); final HealthConnectHeartRateWriter _androidHeartRate; bool _configured = false; @@ -260,30 +258,24 @@ class HealthExporter { String get _hrvScalarKey => isApple ? 'sdnn' : 'rmssd'; List get _types => [ - HealthDataType.RESTING_HEART_RATE, - _hrvType, - HealthDataType.RESPIRATORY_RATE, - HealthDataType.HEART_RATE, - HealthDataType.ACTIVE_ENERGY_BURNED, - HealthDataType.BASAL_ENERGY_BURNED, - // STEPS is requested for DELETE SCOPE ONLY — nothing writes steps any - // more (see the block further down for why). We still need the write - // permission to purge the fabricated step samples earlier versions put - // into Apple Health / Health Connect, which is why WRITE_STEPS stays in - // the Android manifest. That purge is a ONE-SHOT migration and does not - // belong in the per-day rewrite loop — see [_purgeLegacyStepsIfNeeded] - // and [_rewriteTypes]. - HealthDataType.STEPS, - HealthDataType.SLEEP_DEEP, - HealthDataType.SLEEP_REM, - HealthDataType.SLEEP_LIGHT, - HealthDataType.SLEEP_AWAKE, - // The envelope, in whichever form the platform actually has. Asking - // for the other one sends a type name that store has never heard of - // (SLEEP_SESSION is Health-Connect-only, SLEEP_IN_BED HealthKit-only). - isApple ? HealthDataType.SLEEP_IN_BED : HealthDataType.SLEEP_SESSION, - HealthDataType.WORKOUT, - ]; + HealthDataType.RESTING_HEART_RATE, + _hrvType, + HealthDataType.RESPIRATORY_RATE, + HealthDataType.HEART_RATE, + HealthDataType.ACTIVE_ENERGY_BURNED, + HealthDataType.BASAL_ENERGY_BURNED, + // STEPS is requested for DELETE SCOPE ONLY — nothing writes steps any + // more (see the block further down for why). We still need the write + // permission to purge the fabricated step samples earlier versions put + // into Apple Health / Health Connect, which is why WRITE_STEPS stays in + // the Android manifest. That purge is a ONE-SHOT migration and does not + // belong in the per-day rewrite loop — see [_purgeLegacyStepsIfNeeded] + // and [_rewriteTypes]. + HealthDataType.STEPS, + for (final t in _sleepHealthTypes) + if (t != _foreignSleepEnvelope(isApple)) t, + HealthDataType.WORKOUT, + ]; /// The types the per-day delete-then-write pass touches. /// @@ -293,9 +285,9 @@ class HealthExporter { /// finalized) tail, forever, and would let that delete's failure flip a day's /// export to unsuccessful. /// Composes both intents on this seam: - /// * `healthDeleteTypes` (platform-aware) drops the sleep types and - /// HEART_RATE on Android, because the native SleepSessionRecord writer - /// and the minute-HR batch own their own cleanup there. + /// * `healthDeleteTypes` (platform-aware) drops sleep on both stores and + /// HEART_RATE on Android, because the native sleep replace writers and + /// the minute-HR batch own their own cleanup there. /// * STEPS is then removed on top, because NOTHING writes steps any more. /// Deleting a type we never write would run on every re-export of the /// not-yet-finalized tail forever, and — since a false `delete()` flips @@ -303,9 +295,9 @@ class HealthExporter { /// historical fabricated samples are handled once by /// [_purgeLegacyStepsIfNeeded] instead, outside the success accounting. List get _rewriteTypes => [ - for (final t in healthDeleteTypes(isApplePlatform: isApple)) - if (t != HealthDataType.STEPS) t, - ]; + for (final t in healthDeleteTypes(isApplePlatform: isApple)) + if (t != HealthDataType.STEPS) t, + ]; /// Cursor for the one-shot legacy-STEPS purge: the newest day already purged. static const _kStepsPurgeCursor = 'health_steps_purged_through'; @@ -493,6 +485,10 @@ class HealthExporter { await _ensureConfigured(); if (await _androidUnavailable() != null) return 0; // HC missing/outdated try { + await ensureHealthSleepExportEpoch( + getCursor: LocalDb.getCursor, + setCursor: (name, value) => LocalDb.setCursor(name, value), + ); if (reset) { await LocalDb.setCursor('health_export_through', ''); await LocalDb.setCursor(_kRetryCursor, ''); @@ -739,9 +735,8 @@ class HealthExporter { // write on failure (best-effort, idempotent re-export corrects it later). var success = true; - // Sleep is the smallest, highest-value Android write. Do it before the - // high-volume minute-HR export can consume Health Connect's API quota. - // The native replace owns SleepSessionRecord cleanup on Android. + // Sleep is the smallest, highest-value write. Native replace owns cleanup + // on both stores (Health Connect SleepSessionRecord; HealthKit inBed+Core). if (Platform.isAndroid && !androidSleepAlreadyWritten) { try { if (!await _androidSleep.replace(b)) { @@ -753,35 +748,35 @@ class HealthExporter { success = false; } } + if (isApple) { + try { + if (!await _appleSleep.replace( + bundle: b, + dayStart: dayStart, + dayEnd: dayEnd, + )) { + debugPrint('[health] write Apple sleep session returned false'); + success = false; + } + } catch (e) { + debugPrint('[health] write Apple sleep session: $e'); + success = false; + } + } // One-shot cleanup of the fabricated step samples earlier versions wrote. // Outside the success accounting on purpose — see the method doc. await _purgeLegacyStepsIfNeeded(date, dayStart, dayEnd); - // The night this day owns, normalized ONCE: stages clipped to the sleep - // window, sorted, de-overlapped. Shared by the delete window below and the - // Apple write further down so both cover exactly the same span. Android - // gets this from its native writer instead (see [_androidSleep] above). - final night = isApple ? normalizeHealthSleepSession(b) : null; - - // Sleep deletes are night-scoped, everything else stays day-scoped — - // `HealthConnectSleepWriter.sleepCleanupRange` already does the equivalent - // on Android. - final sleepWindow = sleepCleanupWindow( - dayStart: dayStart, - dayEnd: dayEnd, - night: night, - ); - // Idempotency: remove OUR previously-written samples for this day (HealthKit / // Health Connect only let an app delete its own data), then re-write fresh. + // Sleep is not in this list — native replace already deleted it. for (final t in _rewriteTypes) { - final isSleep = _sleepHealthTypes.contains(t); try { final deleted = await _health.delete( type: t, - startTime: isSleep ? sleepWindow.start : dayStart, - endTime: isSleep ? sleepWindow.end : dayEnd, + startTime: dayStart, + endTime: dayEnd, ); if (!deleted) { debugPrint('[health] delete ${t.name} returned false'); @@ -988,55 +983,12 @@ class HealthExporter { // The "estimate" qualifier every in-app surface carries is also lost the // moment a sample lands in Apple Health as a bare STEPS count, so a wrong // number here contaminates every other app on the device. - - // Health Connect models stages as children of ONE SleepSessionRecord. The - // health 11.1.1 generic SLEEP_* writer instead creates one parent record - // per call, fragmenting a night. Android therefore uses our typed native - // replace API; Apple Health keeps its existing per-stage samples. - if (isApple && night != null) { - // THE ENVELOPE FIRST. Bare stage bars with nothing enclosing them is why - // readers (Bevel and friends) reconstruct a night as a short sleep plus a - // scatter of naps — HealthKit has no session record, so the wrapper is an - // `inBed` sleepAnalysis sample spanning the night. - // - // The span is the DETECTED sleep window, which is the same wall-clock - // number the app already reports as in-bed time (`in_bed_sec` is - // offset - onset). Nothing is invented: no window, no envelope, and a - // bundle without one writes no stages either — which is also why an - // unstaged night (an import, a night staging refused) contributes no - // fragments here. - try { - final wrote = await _health.writeHealthData( - value: 0, - type: HealthDataType.SLEEP_IN_BED, - startTime: night.start, - endTime: night.end, - ); - if (!wrote) success = false; - } catch (e) { - debugPrint('[health] write sleep envelope: $e'); - success = false; - } - // Stages come from the SAME normalization Android uses, so they are - // clipped to the sleep window instead of spilling past either end of it - // — which is what let a pre-midnight segment survive the day-scoped - // delete and pile up a fresh copy on every retry. - for (final seg in night.stages) { - final type = _sleepType(seg.stage); - try { - final wrote = await _health.writeHealthData( - value: 0, - type: type, - startTime: seg.start, - endTime: seg.end, - ); - if (!wrote) success = false; - } catch (e) { - debugPrint('[health] write sleep ${type.name}: $e'); - success = false; - } - } - } + // + // Sleep is written natively at the top of this method (Health Connect + // SleepSessionRecord on Android; HealthKit inBed + asleepCore/Deep/REM + // on Apple). The plugin per-stage writer is not used: on HealthKit it + // dropped Core, skipped in-bed, and left 11pm fragments outside the + // detected window (#225/#239/#249). // Workouts (manual/live/detected) finalized in this calendar day. Upper // bound is exclusive (dayEnd - 1s) for the same midnight-boundary reason @@ -1145,19 +1097,6 @@ class HealthExporter { } } - HealthDataType _sleepType(HealthSleepStage stage) { - switch (stage) { - case HealthSleepStage.deep: - return HealthDataType.SLEEP_DEEP; - case HealthSleepStage.rem: - return HealthDataType.SLEEP_REM; - case HealthSleepStage.light: - return HealthDataType.SLEEP_LIGHT; - case HealthSleepStage.awake: - return HealthDataType.SLEEP_AWAKE; - } - } - // `isApple`, not `Platform.isIOS`: every other platform decision in this file // (the HRV type, the delete list, the store name) keys off the same getter, // and a divergence here would hand macOS the Health Connect spellings. diff --git a/lib/health/health_sleep_session.dart b/lib/health/health_sleep_session.dart index a28ca55f..1db91bd1 100644 --- a/lib/health/health_sleep_session.dart +++ b/lib/health/health_sleep_session.dart @@ -56,17 +56,13 @@ HealthSleepSession? normalizeHealthSleepSession(Map bundle) { final rawStages = series is Map ? series['hypnogram'] : null; final candidates = []; if (rawStages is List) { - for (final raw in rawStages) { - if (raw is! Map) continue; - final startSeconds = (raw['start'] as num?)?.toInt(); - final endSeconds = (raw['end'] as num?)?.toInt(); - final stage = healthSleepStageOf(raw['stage']?.toString()); - if (startSeconds == null || endSeconds == null || stage == null) continue; - - final rawStart = DateTime.fromMillisecondsSinceEpoch(startSeconds * 1000); - final rawEnd = DateTime.fromMillisecondsSinceEpoch(endSeconds * 1000); - final clippedStart = rawStart.isBefore(start) ? start : rawStart; - final clippedEnd = rawEnd.isAfter(end) ? end : rawEnd; + for (final interval in _hypnogramIntervals(rawStages)) { + final stage = healthSleepStageOf(interval.stage); + if (stage == null) continue; + final clippedStart = interval.start.isBefore(start) + ? start + : interval.start; + final clippedEnd = interval.end.isAfter(end) ? end : interval.end; if (!clippedStart.isBefore(clippedEnd)) continue; candidates.add( HealthSleepStageInterval( @@ -83,35 +79,124 @@ HealthSleepSession? normalizeHealthSleepSession(Map bundle) { return byStart != 0 ? byStart : a.end.compareTo(b.end); }); + // Imported / unstaged nights have a window and no hypnogram. Do not invent + // a full-night "awake" session — Android treats that as a no-op so a CSV + // day cannot wipe Health Connect, and Apple writes only the in-bed bar. + if (candidates.isEmpty) { + return HealthSleepSession(start: start, end: end, stages: const []); + } + + // Tile the in-bed envelope. Dropping unknown labels used to leave holes, so + // Health Time Asleep was only the REM/Deep slivers that survived — #225's + // ~2 h night, #239's missing Core. Fill remaining gaps as awake. final normalized = []; var cursor = start; + void append(DateTime from, DateTime to, HealthSleepStage stage) { + if (!from.isBefore(to)) return; + if (normalized.isNotEmpty && + normalized.last.stage == stage && + !normalized.last.end.isBefore(from)) { + normalized[normalized.length - 1] = HealthSleepStageInterval( + start: normalized.last.start, + end: to.isAfter(normalized.last.end) ? to : normalized.last.end, + stage: stage, + ); + return; + } + normalized.add( + HealthSleepStageInterval(start: from, end: to, stage: stage), + ); + } + for (final candidate in candidates) { final normalizedStart = candidate.start.isBefore(cursor) ? cursor : candidate.start; + if (cursor.isBefore(normalizedStart)) { + append(cursor, normalizedStart, HealthSleepStage.awake); + } if (!normalizedStart.isBefore(candidate.end)) continue; - normalized.add( - HealthSleepStageInterval( - start: normalizedStart, - end: candidate.end, - stage: candidate.stage, - ), - ); - cursor = candidate.end; + append(normalizedStart, candidate.end, candidate.stage); + if (candidate.end.isAfter(cursor)) cursor = candidate.end; + } + if (cursor.isBefore(end)) { + append(cursor, end, HealthSleepStage.awake); } return HealthSleepSession(start: start, end: end, stages: normalized); } +/// Epoch seconds or milliseconds → a wall instant. Older/imported hypnograms +/// have mixed units; treating ms as seconds writes samples in the year 56000 +/// (or, after a failed write, leaves last night's fragments behind). +DateTime healthInstantFromEpoch(num raw) { + final n = raw.toInt(); + final ms = n.abs() >= 100000000000 ? n : n * 1000; + return DateTime.fromMillisecondsSinceEpoch(ms); +} + +class _RawHypnoInterval { + const _RawHypnoInterval({ + required this.start, + required this.end, + required this.stage, + }); + final DateTime start; + final DateTime end; + final String stage; +} + +/// Stored hypnograms are `{start,end,stage}` segments. The sleep screen also +/// materializes `{t,stage}` points. Accept both so Health is not empty when +/// a reader round-trips the UI shape into `series.hypnogram`. +List<_RawHypnoInterval> _hypnogramIntervals(List rawStages) { + final segmented = <_RawHypnoInterval>[]; + for (final raw in rawStages) { + if (raw is! Map) continue; + final stage = raw['stage']?.toString(); + if (stage == null) continue; + final startRaw = raw['start'] as num?; + final endRaw = raw['end'] as num?; + if (startRaw != null && endRaw != null) { + final start = healthInstantFromEpoch(startRaw); + final end = healthInstantFromEpoch(endRaw); + if (start.isBefore(end)) { + segmented.add(_RawHypnoInterval(start: start, end: end, stage: stage)); + } + } + } + if (segmented.isNotEmpty) return segmented; + + final points = <({DateTime t, String stage})>[]; + for (final raw in rawStages) { + if (raw is! Map) continue; + final t = raw['t'] as num?; + final stage = raw['stage']?.toString(); + if (t == null || stage == null) continue; + points.add((t: healthInstantFromEpoch(t), stage: stage)); + } + points.sort((a, b) => a.t.compareTo(b.t)); + final out = <_RawHypnoInterval>[]; + for (var i = 0; i + 1 < points.length; i++) { + final a = points[i]; + final b = points[i + 1]; + if (!a.t.isBefore(b.t)) continue; + out.add(_RawHypnoInterval(start: a.t, end: b.t, stage: a.stage)); + } + return out; +} + HealthSleepStage? healthSleepStageOf(String? stage) { switch (stage) { case 'wake': case 'awake': + case 'unobserved': return HealthSleepStage.awake; case 'rem': return HealthSleepStage.rem; case 'light': case 'nrem': + case 'core': return HealthSleepStage.light; case 'deep': return HealthSleepStage.deep; @@ -120,6 +205,53 @@ HealthSleepStage? healthSleepStageOf(String? stage) { } } +/// Noon-to-noon around the night's wake, matching Android +/// `HealthConnectSleepWriter.sleepCleanupRange`. +/// +/// A later derive that moves onset (23:00 → 01:06) leaves the previous +/// HealthKit samples *outside* `[night.start, night.end)`. Deleting only the +/// detected window is #225: Health keeps 11pm REM/Awake and a ~2 h remainder. +({DateTime start, DateTime end}) sleepSessionCleanupRange( + HealthSleepSession night, +) { + final localEnd = night.end; + final wakeMidnight = DateTime(localEnd.year, localEnd.month, localEnd.day); + final endDate = localEnd.hour < 12 + ? wakeMidnight + : wakeMidnight.add(const Duration(days: 1)); + final cleanupEnd = DateTime(endDate.year, endDate.month, endDate.day, 12); + final prevDate = DateTime(endDate.year, endDate.month, endDate.day - 1); + final calculatedStart = DateTime( + prevDate.year, + prevDate.month, + prevDate.day, + 12, + ); + return ( + start: night.start.isBefore(calculatedStart) + ? night.start + : calculatedStart, + end: cleanupEnd, + ); +} + +/// The span a day's SLEEP-type delete has to cover. +/// +/// Union of the calendar day and [sleepSessionCleanupRange], so pre-midnight +/// leftovers and the exported date's own samples both go. No night → day only. +({DateTime start, DateTime end}) sleepCleanupWindow({ + required DateTime dayStart, + required DateTime dayEnd, + HealthSleepSession? night, +}) { + if (night == null) return (start: dayStart, end: dayEnd); + final session = sleepSessionCleanupRange(night); + return ( + start: session.start.isBefore(dayStart) ? session.start : dayStart, + end: session.end.isAfter(dayEnd) ? session.end : dayEnd, + ); +} + abstract interface class HealthConnectSleepSessionWriter { Future replace(HealthSleepSession session); } @@ -184,3 +316,76 @@ class HealthConnectSleepSessionExporter { return writer.replace(session); } } + +abstract interface class HealthKitSleepSessionWriter { + Future replace({ + required DateTime cleanupStart, + required DateTime cleanupEnd, + HealthSleepSession? session, + }); +} + +class MethodChannelHealthKitSleepSessionWriter + implements HealthKitSleepSessionWriter { + MethodChannelHealthKitSleepSessionWriter({ + this.channel = const MethodChannel('openstrap/healthkit_sleep'), + }); + + final MethodChannel channel; + Future _pending = Future.value(); + + @override + Future replace({ + required DateTime cleanupStart, + required DateTime cleanupEnd, + HealthSleepSession? session, + }) { + final result = Completer(); + _pending = _pending.then((_) async { + try { + final args = { + 'cleanupStartTime': cleanupStart.millisecondsSinceEpoch, + 'cleanupEndTime': cleanupEnd.millisecondsSinceEpoch, + if (session != null) ...session.toMap(), + }; + result.complete( + await channel.invokeMethod('replaceSleepSession', args) == true, + ); + } catch (error, stackTrace) { + result.completeError(error, stackTrace); + } + }); + return result.future; + } +} + +/// HealthKit has no SleepSessionRecord. One native replace writes `inBed` plus +/// stages (`asleepCore` for light) and deletes *our* overlapping samples in the +/// noon-to-noon window Dart already computed. +/// +/// The Flutter `health` plugin is not used on this path: unknown keys (notably +/// SLEEP_SESSION) map to bodyMass and hang `delete()`, and SLEEP_LIGHT / inBed +/// writes have been failing on recent iOS as Core-less ~2 h nights (#239/#225). +class HealthKitSleepSessionExporter { + const HealthKitSleepSessionExporter({required this.writer}); + + final HealthKitSleepSessionWriter writer; + + Future replace({ + required Map bundle, + required DateTime dayStart, + required DateTime dayEnd, + }) { + final session = normalizeHealthSleepSession(bundle); + final window = sleepCleanupWindow( + dayStart: dayStart, + dayEnd: dayEnd, + night: session, + ); + return writer.replace( + cleanupStart: window.start, + cleanupEnd: window.end, + session: session, + ); + } +} diff --git a/test/health_sleep_export_test.dart b/test/health_sleep_export_test.dart index 56e8a184..e66893a9 100644 --- a/test/health_sleep_export_test.dart +++ b/test/health_sleep_export_test.dart @@ -248,25 +248,17 @@ void main() { ); }); - test('Apple delete scope never names the Health Connect envelope', () { - final types = healthDeleteTypes(isApplePlatform: true); - - // SLEEP_SESSION is Health-Connect-only. On iOS the plugin resolves an - // unknown key to bodyMass, queries a type we never asked for, and its - // error path never calls back — `delete()` hangs and the day's export - // stalls behind it. Same failure #239/#225 fixed on the write side. - expect(types, isNot(contains(HealthDataType.SLEEP_SESSION))); - expect(types, contains(HealthDataType.SLEEP_IN_BED)); - expect( - types, - containsAll([ - HealthDataType.SLEEP_DEEP, - HealthDataType.SLEEP_REM, - HealthDataType.SLEEP_LIGHT, - HealthDataType.SLEEP_AWAKE, - ]), - ); - }); + test( + 'Apple generic cleanup never names sleep — native replace owns it', + () { + final types = healthDeleteTypes(isApplePlatform: true); + + expect(types, contains(HealthDataType.HEART_RATE)); + expect(types, isNot(contains(HealthDataType.SLEEP_SESSION))); + expect(types, isNot(contains(HealthDataType.SLEEP_IN_BED))); + expect(types.where((type) => type.name.startsWith('SLEEP_')), isEmpty); + }, + ); test('the sleep delete covers the pre-midnight half of the night', () { final dayStart = DateTime(2026, 8, 5); @@ -283,7 +275,7 @@ void main() { dayEnd: dayEnd, night: night, ); - expect(window.start, night.start); + expect(window.start, DateTime(2026, 8, 4, 12)); expect(window.end, dayEnd, reason: 'the night ends well inside the day'); // No night to write — nothing to widen for, and the day window still has @@ -299,6 +291,8 @@ void main() { expect(healthSleepStageOf('rem'), HealthSleepStage.rem); expect(healthSleepStageOf('light'), HealthSleepStage.light); expect(healthSleepStageOf('nrem'), HealthSleepStage.light); + expect(healthSleepStageOf('core'), HealthSleepStage.light); + expect(healthSleepStageOf('unobserved'), HealthSleepStage.awake); expect(healthSleepStageOf('deep'), HealthSleepStage.deep); expect(healthSleepStageOf('unknown'), isNull); }); @@ -407,12 +401,18 @@ void main() { ifAbsent: () => stage.duration.inMinutes, ); } - expect(minutesByStage, { - HealthSleepStage.awake: 32, - HealthSleepStage.rem: 95, - HealthSleepStage.light: 318, - HealthSleepStage.deep: 16, - }); + expect( + minutesByStage, + { + HealthSleepStage.awake: 42, + HealthSleepStage.rem: 95, + HealthSleepStage.light: 318, + HealthSleepStage.deep: 16, + }, + reason: + '07:36–07:46 had no label; filling that hole as awake is 10 min ' + 'on top of the 32 min of explicit wake', + ); }); test( @@ -495,72 +495,62 @@ void main() { expect(args['stages'] as List, hasLength(6)); }); - test( - 'an empty normalized hypnogram is a benign no-op — it never replaces ' - 'native data, and it must not fail the whole day\'s export', - () async { - const channel = MethodChannel('openstrap/test_health_connect_empty'); - var calls = 0; + test('an empty normalized hypnogram is a benign no-op — it never replaces ' + 'native data, and it must not fail the whole day\'s export', () async { + const channel = MethodChannel('openstrap/test_health_connect_empty'); + var calls = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls++; + return true; + }); + addTearDown(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - calls++; - return true; - }); - addTearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - }); - final exporter = HealthConnectSleepSessionExporter( - writer: MethodChannelHealthConnectSleepSessionWriter( - channel: channel, - ), - ); - final bundle = _overnightBundle(); - ((bundle['series'] as Map)['hypnogram'] as List).clear(); + .setMockMethodCallHandler(channel, null); + }); + final exporter = HealthConnectSleepSessionExporter( + writer: MethodChannelHealthConnectSleepSessionWriter(channel: channel), + ); + final bundle = _overnightBundle(); + ((bundle['series'] as Map)['hypnogram'] as List).clear(); - expect( - await exporter.replace(bundle), - isTrue, - reason: - 'false is a HARD failure for the entire day in health_export.dart ' - '(success = false stops the cursor advancing), so a missing ' - 'hypnogram used to withhold steps/calories/HR too. Imported days ' - 'carry a sleep window with no substrate to stage from, so they ' - 'could never export at all.', - ); - expect(calls, 0, reason: 'empty stages must not delete native sleep'); - }, - ); + expect( + await exporter.replace(bundle), + isTrue, + reason: + 'false is a HARD failure for the entire day in health_export.dart ' + '(success = false stops the cursor advancing), so a missing ' + 'hypnogram used to withhold steps/calories/HR too. Imported days ' + 'carry a sleep window with no substrate to stage from, so they ' + 'could never export at all.', + ); + expect(calls, 0, reason: 'empty stages must not delete native sleep'); + }); - test( - 'an IMPORTED-shaped day (sleep window, no series at all) is a no-op, ' - 'not a failure — this is the case that never exported', - () async { - const channel = MethodChannel('openstrap/test_health_connect_imported'); - var calls = 0; + test('an IMPORTED-shaped day (sleep window, no series at all) is a no-op, ' + 'not a failure — this is the case that never exported', () async { + const channel = MethodChannel('openstrap/test_health_connect_imported'); + var calls = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls++; + return true; + }); + addTearDown(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - calls++; - return true; - }); - addTearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - }); - final exporter = HealthConnectSleepSessionExporter( - writer: MethodChannelHealthConnectSleepSessionWriter( - channel: channel, - ), - ); - // A CSV import gives a window but no per-second substrate to stage - // from, so `series` is absent entirely rather than merely empty. - final bundle = _overnightBundle(); - bundle.remove('series'); + .setMockMethodCallHandler(channel, null); + }); + final exporter = HealthConnectSleepSessionExporter( + writer: MethodChannelHealthConnectSleepSessionWriter(channel: channel), + ); + // A CSV import gives a window but no per-second substrate to stage + // from, so `series` is absent entirely rather than merely empty. + final bundle = _overnightBundle(); + bundle.remove('series'); - expect(await exporter.replace(bundle), isTrue); - expect(calls, 0); - }, - ); + expect(await exporter.replace(bundle), isTrue); + expect(calls, 0); + }); test( 'a day with NO sleep window and a day with a window but no stages agree ' @@ -670,5 +660,195 @@ void main() { expect(maxActiveCalls, 1); }, ); + + test( + 'noon-to-noon cleanup covers leftover fragments before a later onset', + () { + // Issue #225: in-app night is 01:06–08:42 but Health still shows REM + // from ~11pm — those samples sit outside [01:06, 08:42) and a + // night-scoped delete never touched them. + final night = HealthSleepSession( + start: DateTime(2026, 8, 5, 1, 6), + end: DateTime(2026, 8, 5, 8, 42), + stages: const [], + ); + final leftover = DateTime(2026, 8, 4, 23, 0); + final range = sleepSessionCleanupRange(night); + + expect(range.start, DateTime(2026, 8, 4, 12)); + expect(range.end, DateTime(2026, 8, 5, 12)); + expect(leftover.isBefore(range.start), isFalse); + expect(leftover.isBefore(range.end), isTrue); + }, + ); + + test('fills holes in the in-bed envelope as awake', () { + final start = DateTime(2026, 8, 5, 1); + final end = DateTime(2026, 8, 5, 4); + final bundle = { + 'sleep': { + 'window': { + 'value': { + 'onset_ms': start.millisecondsSinceEpoch, + 'offset_ms': end.millisecondsSinceEpoch, + }, + }, + }, + 'series': { + 'hypnogram': [ + _segment(start, start.add(const Duration(hours: 1)), 'light'), + _segment(start.add(const Duration(hours: 2)), end, 'rem'), + ], + }, + }; + + final session = normalizeHealthSleepSession(bundle)!; + expect(session.stages, hasLength(3)); + expect(session.stages[1].stage, HealthSleepStage.awake); + expect(session.stages[1].start, start.add(const Duration(hours: 1))); + expect(session.stages[1].end, start.add(const Duration(hours: 2))); + }); + + test('accepts UI-shaped {t,stage} points and millisecond timestamps', () { + final start = DateTime(2026, 8, 5, 1); + final mid = DateTime(2026, 8, 5, 2); + final end = DateTime(2026, 8, 5, 3); + final bundle = { + 'sleep': { + 'window': { + 'value': { + 'onset_ms': start.millisecondsSinceEpoch, + 'offset_ms': end.millisecondsSinceEpoch, + }, + }, + }, + 'series': { + 'hypnogram': [ + {'t': start.millisecondsSinceEpoch, 'stage': 'core'}, + {'t': mid.millisecondsSinceEpoch, 'stage': 'deep'}, + {'t': end.millisecondsSinceEpoch, 'stage': 'wake'}, + ], + }, + }; + + final session = normalizeHealthSleepSession(bundle)!; + expect(session.stages, hasLength(2)); + expect(session.stages[0].stage, HealthSleepStage.light); + expect(session.stages[0].start, start); + expect(session.stages[0].end, mid); + expect(session.stages[1].stage, HealthSleepStage.deep); + expect(session.stages[1].end, end); + }); + + test('a new sleep-writer epoch clears the export cursor once', () async { + final stored = { + 'health_export_through': '2026-08-01', + 'health_export_retry_state': '{"2026-08-02":1}', + }; + + await ensureHealthSleepExportEpoch( + getCursor: (name) async => stored[name], + setCursor: (name, value) async { + stored[name] = value; + }, + ); + expect(stored['health_export_through'], ''); + expect(stored['health_export_retry_state'], ''); + expect(stored[kHealthSleepExportEpochCursor], kHealthSleepExportEpoch); + + stored['health_export_through'] = '2026-08-05'; + await ensureHealthSleepExportEpoch( + getCursor: (name) async => stored[name], + setCursor: (name, value) async { + stored[name] = value; + }, + ); + expect(stored['health_export_through'], '2026-08-05'); + }); + + test('Apple replace sends in-bed even when stages are empty', () async { + const channel = MethodChannel('openstrap/test_healthkit_sleep'); + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return true; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + final exporter = HealthKitSleepSessionExporter( + writer: MethodChannelHealthKitSleepSessionWriter(channel: channel), + ); + final bundle = _overnightBundle(); + ((bundle['series'] as Map)['hypnogram'] as List).clear(); + + expect( + await exporter.replace( + bundle: bundle, + dayStart: DateTime(2026, 8, 5), + dayEnd: DateTime(2026, 8, 6), + ), + isTrue, + ); + expect(calls, hasLength(1)); + final args = (calls.single.arguments as Map).cast(); + expect(args['startTime'], isNotNull); + expect(args['stages'] as List, isEmpty); + expect( + args['cleanupStartTime'], + DateTime(2026, 8, 4, 12).millisecondsSinceEpoch, + ); + }); + + test( + 'Apple replace carries Core stages and noon-to-noon cleanup', + () async { + const channel = MethodChannel('openstrap/test_healthkit_sleep_full'); + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return true; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + final exporter = HealthKitSleepSessionExporter( + writer: MethodChannelHealthKitSleepSessionWriter(channel: channel), + ); + + expect( + await exporter.replace( + bundle: _overnightBundle(), + dayStart: DateTime(2026, 8, 5), + dayEnd: DateTime(2026, 8, 6), + ), + isTrue, + ); + expect(calls, hasLength(1)); + expect(calls.single.method, 'replaceSleepSession'); + final args = (calls.single.arguments as Map).cast(); + expect( + args['cleanupStartTime'], + DateTime(2026, 8, 4, 12).millisecondsSinceEpoch, + ); + expect( + args['cleanupEndTime'], + DateTime(2026, 8, 6).millisecondsSinceEpoch, + reason: 'union with the calendar day keeps the day-end sweep', + ); + final stages = args['stages'] as List; + expect(stages, isNotEmpty); + expect( + stages.map((raw) => (raw as Map)['stage']).toSet(), + containsAll(['awake', 'rem', 'light', 'deep']), + ); + }, + ); }); }