diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 4ee3bcbdc..c7b2c5ba3 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -35,6 +35,7 @@ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; ACCE55E7000000000000B11D /* AccessorySetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACCE55E7000000000000F11E /* AccessorySetup.swift */; }; B9D2F406183A5C7E92B1D3F5 /* HealthKitSleepWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C1E3F507294B6D81A0C2E4 /* HealthKitSleepWriter.swift */; }; + C4E8A1D2F6B3407A95C2E1B7 /* HealthKitHeartbeatWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5F9B2E3A7C4418BA6D3F2C8 /* HealthKitHeartbeatWriter.swift */; }; BEEF00000000000000000002 /* BreathingLiveActivityBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF00000000000000000001 /* BreathingLiveActivityBridge.swift */; }; FADE0001FADE0001FADE0001 /* OpenStrapWatch Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 53962EBE2FF6EF790061A61B /* OpenStrapWatch Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ @@ -150,6 +151,7 @@ 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 9AB0D2B3B0A362AED6D7BFB0 /* ShortcutUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ShortcutUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; A8C1E3F507294B6D81A0C2E4 /* HealthKitSleepWriter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HealthKitSleepWriter.swift; sourceTree = ""; }; + D5F9B2E3A7C4418BA6D3F2C8 /* HealthKitHeartbeatWriter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HealthKitHeartbeatWriter.swift; sourceTree = ""; }; ACCE55E7000000000000F11E /* AccessorySetup.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AccessorySetup.swift; sourceTree = ""; }; B0B95B9F07C8083A2DB92078 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; BEEF00000000000000000001 /* BreathingLiveActivityBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreathingLiveActivityBridge.swift; sourceTree = ""; }; @@ -344,6 +346,7 @@ 5A0C00000000000000000004 /* LongSyncDataIntent.swift */, 46A2B400A52A2CA90242C195 /* HealthRoutes.swift */, A8C1E3F507294B6D81A0C2E4 /* HealthKitSleepWriter.swift */, + D5F9B2E3A7C4418BA6D3F2C8 /* HealthKitHeartbeatWriter.swift */, ); path = Runner; sourceTree = ""; @@ -776,6 +779,7 @@ 5A0C00000000000000000003 /* LongSyncDataIntent.swift in Sources */, 60DE9D949573401269D6DF2E /* HealthRoutes.swift in Sources */, B9D2F406183A5C7E92B1D3F5 /* HealthKitSleepWriter.swift in Sources */, + C4E8A1D2F6B3407A95C2E1B7 /* HealthKitHeartbeatWriter.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 310cdbf25..1a6d8dc14 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -122,6 +122,10 @@ import CoreMotion if let registrar = registry.registrar(forPlugin: "HealthKitSleepWriter") { HealthKitSleepWriter.register(messenger: registrar.messenger()) } + // HealthKit beat-to-beat for the night. See HealthKitHeartbeatWriter.swift. + if let registrar = registry.registrar(forPlugin: "HealthKitHeartbeatWriter") { + HealthKitHeartbeatWriter.register(messenger: registrar.messenger()) + } if let registrar = registry.registrar(forPlugin: "ShortcutSyncBridge") { ShortcutSyncBridge.shared.attach(messenger: registrar.messenger()) } diff --git a/ios/Runner/HealthKitHeartbeatWriter.swift b/ios/Runner/HealthKitHeartbeatWriter.swift new file mode 100644 index 000000000..b1b14be98 --- /dev/null +++ b/ios/Runner/HealthKitHeartbeatWriter.swift @@ -0,0 +1,174 @@ +import Flutter +import HealthKit + +// HealthKit beat-to-beat replace. One channel call deletes OUR heartbeat series +// in the cleanup window Dart computed (the sleep replace's window), then writes +// each series through its own HKHeartbeatSeriesBuilder. +// +// The Flutter `health` plugin has no heartbeat-series type, which is also why +// this channel carries its own `requestAuthorization`: the plugin's grant sheet +// never lists HKSeriesType.heartbeat(). +// +// Dart already built the series (lib/health/health_heartbeat_series.dart): +// strictly increasing offsets from each series' start, and a precededByGap +// flag per beat. This file does not re-derive anything; it refuses a malformed +// series rather than repairing it. + +enum HealthKitHeartbeatWriter { + private static let channelName = "openstrap/healthkit_heartbeat" + private static let store = HKHealthStore() + private static let queue = DispatchQueue(label: "wtf.openstrap.healthkit.heartbeat") + + static func register(messenger: FlutterBinaryMessenger) { + let channel = FlutterMethodChannel(name: channelName, binaryMessenger: messenger) + channel.setMethodCallHandler { call, result in + switch call.method { + case "replaceHeartbeatSeries": + let args = call.arguments as? [String: Any] ?? [:] + queue.async { + replace(args) { ok in + DispatchQueue.main.async { result(ok) } + } + } + case "requestAuthorization": + requestAuthorization { ok in + DispatchQueue.main.async { result(ok) } + } + default: + result(FlutterMethodNotImplemented) + } + } + } + + private static func requestAuthorization(completion: @escaping (Bool) -> Void) { + guard HKHealthStore.isHealthDataAvailable() else { + completion(false) + return + } + store.requestAuthorization(toShare: [HKSeriesType.heartbeat()], read: nil) { ok, _ in + completion(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 cleanupStart = dateMs(args["cleanupStartTime"]), + let cleanupEnd = dateMs(args["cleanupEndTime"]), + cleanupStart < cleanupEnd else { + completion(false) + return + } + + let series: [ParsedSeries] + do { + series = try (args["series"] as? [Any] ?? []).map(parse) + } 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: HKSeriesType.heartbeat(), predicate: pred) { _, _, error in + if error != nil { + completion(false) + return + } + write(series, from: 0, allOk: true, completion: completion) + } + } + + /// One builder at a time, in order. A failed series does not stop the rest; + /// the day reports false and the next export pass replaces the lot. + private static func write( + _ series: [ParsedSeries], + from index: Int, + allOk: Bool, + completion: @escaping (Bool) -> Void + ) { + guard index < series.count else { + completion(allOk) + return + } + writeOne(series[index]) { ok in + queue.async { + write(series, from: index + 1, allOk: allOk && ok, completion: completion) + } + } + } + + private static func writeOne(_ s: ParsedSeries, completion: @escaping (Bool) -> Void) { + let builder = HKHeartbeatSeriesBuilder(healthStore: store, device: nil, start: s.start) + let group = DispatchGroup() + var addOk = true + let lock = NSLock() + for (offset, gap) in zip(s.offsets, s.gaps) { + group.enter() + builder.addHeartbeatWithTimeInterval( + sinceSeriesStartDate: offset, + precededByGap: gap + ) { ok, error in + if !ok || error != nil { + lock.lock() + addOk = false + lock.unlock() + } + group.leave() + } + } + group.notify(queue: queue) { + guard addOk else { + builder.discard() + completion(false) + return + } + builder.finishSeries { sample, error in + completion(sample != nil && error == nil) + } + } + } + + private struct ParsedSeries { + let start: Date + let offsets: [TimeInterval] + let gaps: [Bool] + } + + private static func parse(_ raw: Any) throws -> ParsedSeries { + guard let map = raw as? [String: Any], + let start = dateMs(map["startTime"]), + let rawOffsets = map["offsetsMs"] as? [NSNumber], + let rawGaps = map["precededByGap"] as? [Bool], + rawOffsets.count == rawGaps.count, + rawOffsets.count >= 2, + rawOffsets.count <= HKHeartbeatSeriesBuilder.maximumCount else { + throw HeartbeatWriteError.invalidSeries + } + var offsets: [TimeInterval] = [] + offsets.reserveCapacity(rawOffsets.count) + var previous = -1.0 + for n in rawOffsets { + let seconds = n.doubleValue / 1000.0 + guard seconds > previous else { throw HeartbeatWriteError.invalidSeries } + offsets.append(seconds) + previous = seconds + } + return ParsedSeries(start: start, offsets: offsets, gaps: rawGaps) + } + + 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 HeartbeatWriteError: Error { + case invalidSeries + } +} diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 7f970122f..96e3ea5f3 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -168,7 +168,7 @@ NSCameraUsageDescription OpenStrap uses the camera to read the barcode on a food packet, so logging a meal does not mean typing its nutrition panel out by hand. It reads the barcode digits and nothing else — no photo is taken, kept, or sent anywhere. NSHealthUpdateUsageDescription - OpenStrap writes your sleep, resting heart rate, HRV, respiratory rate, energy and workouts into Apple Health. + OpenStrap writes your sleep, resting heart rate, HRV, your beat-to-beat heart rhythm during sleep, respiratory rate, energy and workouts into Apple Health. NSBluetoothPeripheralUsageDescription OpenStrap connects to your WHOOP band over Bluetooth to sync your health data. NSLocationWhenInUseUsageDescription diff --git a/lib/health/health_export.dart b/lib/health/health_export.dart index 7134aae85..3ff3fa7d0 100644 --- a/lib/health/health_export.dart +++ b/lib/health/health_export.dart @@ -20,6 +20,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io' show Platform; +import 'dart:isolate'; import 'package:android_intent_plus/android_intent.dart'; import 'package:flutter/foundation.dart'; @@ -30,6 +31,7 @@ import '../data/day_label.dart'; import '../data/db.dart'; import '../data/series_codec.dart'; import 'health_heart_rate_batch.dart'; +import 'health_heartbeat_series.dart'; import 'health_sleep_session.dart'; /// What we can do with the health store right now. @@ -421,6 +423,7 @@ class HealthExporter { writer: MethodChannelHealthKitSleepSessionWriter(), ); final HealthConnectHeartRateWriter _androidHeartRate; + final HealthKitHeartbeatWriter _appleHeartbeat; bool _configured = false; /// Upper bound on one plugin `delete()`. HealthKit's delete runs a sample @@ -442,9 +445,12 @@ class HealthExporter { HealthExporter({ HealthConnectHeartRateWriter? androidHeartRate, + HealthKitHeartbeatWriter? appleHeartbeat, @visibleForTesting Duration deleteTimeout = const Duration(seconds: 30), }) : _androidHeartRate = androidHeartRate ?? MethodChannelHealthConnectHeartRateWriter(), + _appleHeartbeat = + appleHeartbeat ?? MethodChannelHealthKitHeartbeatWriter(), _deleteTimeout = deleteTimeout; /// The process-wide exporter. `AppState` holds this one, and so does every @@ -708,6 +714,9 @@ class HealthExporter { } catch (e) { debugPrint('[health] requestAuthorization: $e'); } + // Heartbeat series are not a `health` plugin type, so the sheet above never + // offers them. A second, native sheet listing just that one type. + if (isApple) await _appleHeartbeat.requestAuthorization(); return HealthLinkState.ready; } @@ -1124,6 +1133,73 @@ class HealthExporter { /// Write one day's metrics. DELETES our prior samples for the day window first /// (so a re-derive overwrites instead of duplicating). Best-effort; never throws. + /// The night's beat-to-beat as HKHeartbeatSeriesSamples, so Health readers + /// that compute RMSSD themselves have something to compute it from (see + /// health_heartbeat_series.dart). Same cleanup window as the sleep replace. + /// + /// The BAND'S OWN beats only (`kPrimaryBandSourceSql`), for the reason the + /// minute-HR read below gives: nothing in the OS store can say which sensor + /// a beat came from. + /// + /// `decoded_rr` is pruned rawRetentionDays behind the data edge, so a night + /// with no beats left is SKIPPED, not cleared: re-exporting an older day + /// must not delete series it can never write back. A day with no night at + /// all is a clear, exactly as the sleep replace treats it. + Future _exportAppleHeartbeats( + Map b, + DateTime dayStart, + DateTime dayEnd, + ) async { + final night = normalizeHealthSleepSession(b); + final window = sleepCleanupWindow( + dayStart: dayStart, + dayEnd: dayEnd, + night: night, + ); + if (night == null) { + return _appleHeartbeat.replace( + cleanupStart: window.start, + cleanupEnd: window.end, + series: const [], + ); + } + final db = await LocalDb.instance; + // A beat carries its record's rec_ts and can sit a few seconds before it + // (`beatTimesMs` walks backwards), so the read is padded at the far end; + // the builder clips on the beat's own instant. + final rows = await db.rawQuery( + 'SELECT rr_ms, COALESCE(beat_ts_ms, rr_ts_ms) AS beat_ms ' + 'FROM decoded_rr ' + 'WHERE rec_ts >= ? AND rec_ts <= ? AND $kPrimaryBandSourceSql ' + 'ORDER BY rec_ts ASC, beat_index ASC', + [ + night.start.millisecondsSinceEpoch ~/ 1000, + night.end.millisecondsSinceEpoch ~/ 1000 + 5, + ], + ); + if (rows.isEmpty) return true; + final rr = []; + final ts = []; + for (final r in rows) { + final v = r['rr_ms']; + final t = r['beat_ms']; + if (v is! num || t is! num) continue; + rr.add(v); + ts.add(t); + } + final series = await _buildHeartbeatsOffIsolate( + rr, + ts, + night.start, + night.end, + ); + return _appleHeartbeat.replace( + cleanupStart: window.start, + cleanupEnd: window.end, + series: series, + ); + } + Future _exportDay( String date, Map b, { @@ -1184,6 +1260,15 @@ class HealthExporter { debugPrint('[health] write Apple sleep session: $e'); success = false; } + try { + if (!await _exportAppleHeartbeats(b, dayStart, dayEnd)) { + debugPrint('[health] write Apple heartbeat series returned false'); + success = false; + } + } catch (e) { + debugPrint('[health] write Apple heartbeat series: $e'); + success = false; + } } // One-shot cleanup of the fabricated step samples earlier versions wrote. @@ -1815,3 +1900,19 @@ HealthWorkoutActivityType healthActivityForType( return HealthWorkoutActivityType.OTHER; } } + +/// `correctRr` over a night's beats, off the UI isolate. Top-level so the +/// closure captures only these arguments, never the exporter. +Future> _buildHeartbeatsOffIsolate( + List rrMs, + List rrTsMs, + DateTime sleepStart, + DateTime sleepEnd, +) => Isolate.run( + () => buildSleepHeartbeatSeries( + rrMs: rrMs, + rrTsMs: rrTsMs, + sleepStart: sleepStart, + sleepEnd: sleepEnd, + ), +); diff --git a/lib/health/health_heartbeat_series.dart b/lib/health/health_heartbeat_series.dart new file mode 100644 index 000000000..b8636ad3a --- /dev/null +++ b/lib/health/health_heartbeat_series.dart @@ -0,0 +1,217 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:openstrap_analytics/onehz.dart' as ana; + +import '../compute/substrate.dart' show monotonizeBeatAxis, plausibleRrOrNull; + +// Apple Health beat-to-beat export (HKHeartbeatSeriesSample) for the night. +// +// WHY THIS EXISTS. HealthKit's only HRV quantity is SDNN, so the nightly HRV +// sample this app writes is SDNN (`healthHrvExportValue`). Apps that read +// Health and want RMSSD (Bevel's default HRV method, Athlytic, ...) compute it +// themselves from heartbeat series, which today only the Apple Watch writes. +// Without these samples an OpenStrap night has no RMSSD anywhere in HealthKit +// (see #315 for what users see instead). +// +// WHAT IS WRITTEN. Beat INSTANTS, nothing else: HealthKit stores a series as +// offsets from its start plus a per-beat `precededByGap` flag, and a reader +// derives every interval from successive instants. So the instants are the +// clock the nightly RMSSD itself runs on, `correctRr(...).nnTimesMs`, which +// cumsums the measured R-R *inside* a contiguous run and re-anchors to the +// beat's timestamp at a dropout. A spline-corrected beat keeps its REAL time +// there (rr_correction.dart says so outright), so no interpolated value +// reaches the store; it only changes which pairs count as contiguous. +// +// A beat is preceded by a gap unless the elapsed time from the previous +// exported beat IS its cleaned interval (the contiguity test `hrvTime` applies, +// tightened to both directions): after a dropped artifact run, a sensor +// dropout, or a corrected beat whose real interval differs from its cleaned +// one. A reader that honours the flag pairs the intervals OpenStrap's own RMSSD +// pairs, less the few around a spline-corrected beat: `hrvTime` differences +// the corrected value, and a corrected value is exactly what may not leave +// this app. +// +// Absent input => no series. No beats in the window is "nothing to write", +// never a substituted value. + +/// One HKHeartbeatSeriesSample. [offsetsMs] are from [start]; the first is 0. +class HealthHeartbeatSeries { + const HealthHeartbeatSeries({ + required this.start, + required this.offsetsMs, + required this.precededByGap, + }); + + final DateTime start; + final List offsetsMs; + final List precededByGap; + + int get beats => offsetsMs.length; + + Map toMap() => { + 'startTime': start.millisecondsSinceEpoch, + 'offsetsMs': offsetsMs, + 'precededByGap': precededByGap, + }; +} + +/// Upper bound on one series' span. Apple Watch writes ~1 min series; 5 min is +/// the standard short-term HRV window and the one the nightly `sdnn_window` +/// already averages over. Splitting costs one pair per window, against an +/// unbounded native-call payload per night. +const Duration kHeartbeatSeriesMaxSpan = Duration(minutes: 5); + +/// A series needs at least one successive pair to say anything. +const int kHeartbeatSeriesMinBeats = 2; + +/// Build the night's heartbeat series from the band's raw beats. +/// +/// [rrMs] / [rrTsMs] are parallel: each beat's R-R interval (ms) and the +/// instant it ended (epoch ms, `beat_ts_ms` coalesced to `rr_ts_ms`), in +/// ascending time. Implausible intervals are dropped first, exactly as the +/// derive path does, so both see the same beat train. +/// +/// Pure and synchronous. Run it on a background isolate: `correctRr` over a +/// full night (~25k beats) is not UI-isolate work. +List buildSleepHeartbeatSeries({ + required List rrMs, + required List rrTsMs, + required DateTime sleepStart, + required DateTime sleepEnd, + Duration maxSpan = kHeartbeatSeriesMaxSpan, +}) { + if (rrMs.length != rrTsMs.length || !sleepStart.isBefore(sleepEnd)) { + return const []; + } + final from = sleepStart.millisecondsSinceEpoch; + final to = sleepEnd.millisecondsSinceEpoch; + + final rr = []; + final ts = []; + for (var i = 0; i < rrMs.length; i++) { + final v = plausibleRrOrNull(rrMs[i]); + if (v == null) continue; + final t = rrTsMs[i].toDouble(); + if (t < from || t > to) continue; + rr.add(v); + ts.add(t); + } + if (rr.length < kHeartbeatSeriesMinBeats) return const []; + monotonizeBeatAxis(ts); + + final cleaned = ana.correctRr(rr, rrTsMs: ts); + final nn = cleaned.nn; + final times = cleaned.nnTimesMs; + if (nn.isEmpty || nn.length != times.length) return const []; + // nnTimesMs is relative to the START of the first input beat. + final epochZero = ts.first - rr.first; + + final out = []; + var seriesStartMs = 0; + var offsets = []; + var gaps = []; + void flush() { + if (offsets.length >= kHeartbeatSeriesMinBeats) { + out.add( + HealthHeartbeatSeries( + start: DateTime.fromMillisecondsSinceEpoch(seriesStartMs), + offsetsMs: offsets, + precededByGap: gaps, + ), + ); + } + offsets = []; + gaps = []; + } + + final spanMs = maxSpan.inMilliseconds; + double? prevTime; // clock of the last EXPORTED beat + for (var k = 0; k < nn.length; k++) { + final beatMs = (epochZero + times[k]).round(); + if (offsets.isEmpty || beatMs - seriesStartMs > spanMs) { + flush(); + seriesStartMs = beatMs; + } + final offset = beatMs - seriesStartMs; + // HealthKit needs strictly increasing instants. Two cleaned beats on one + // millisecond cannot both be heartbeats; keep the first. + if (offsets.isNotEmpty && offset <= offsets.last) continue; + final contiguous = + prevTime != null && ((times[k] - prevTime) - nn[k]).abs() <= 0.5; + prevTime = times[k]; + offsets.add(offset); + // The first beat of a series has no predecessor inside it. + gaps.add(offsets.length > 1 && !contiguous); + } + flush(); + return out; +} + +abstract interface class HealthKitHeartbeatWriter { + /// Delete OUR heartbeat series in [cleanupStart, cleanupEnd), then write + /// [series]. An empty [series] is a clear. + Future replace({ + required DateTime cleanupStart, + required DateTime cleanupEnd, + required List series, + }); + + /// Ask for share access to HKSeriesType.heartbeat(). The `health` plugin has + /// no heartbeat-series type, so its grant sheet never lists it. Call from a + /// user gesture. + Future requestAuthorization(); +} + +class MethodChannelHealthKitHeartbeatWriter + implements HealthKitHeartbeatWriter { + MethodChannelHealthKitHeartbeatWriter({ + this.channel = const MethodChannel('openstrap/healthkit_heartbeat'), + }); + + final MethodChannel channel; + Future _pending = Future.value(); + + @override + Future replace({ + required DateTime cleanupStart, + required DateTime cleanupEnd, + required List series, + }) { + final result = Completer(); + _pending = _pending.then((_) async { + try { + final args = { + 'cleanupStartTime': cleanupStart.millisecondsSinceEpoch, + 'cleanupEndTime': cleanupEnd.millisecondsSinceEpoch, + 'series': [for (final s in series) s.toMap()], + }; + // Finite, for the same reason as the sleep writer: a native handler + // that never calls back would otherwise stall every later day. A night + // is ~100 series, each its own builder save, so this gets longer than + // the sleep replace's 30 s. + result.complete( + await channel + .invokeMethod('replaceHeartbeatSeries', args) + .timeout(const Duration(seconds: 90)) == + true, + ); + } catch (error, stackTrace) { + result.completeError(error, stackTrace); + } + }); + return result.future; + } + + @override + Future requestAuthorization() async { + try { + await channel + .invokeMethod('requestAuthorization') + .timeout(const Duration(minutes: 5)); + } catch (_) { + // Best-effort, like the plugin grant: writes are attempted regardless + // and an ungranted one no-ops. + } + } +} diff --git a/test/health_heartbeat_series_test.dart b/test/health_heartbeat_series_test.dart new file mode 100644 index 000000000..e3a5ac276 --- /dev/null +++ b/test/health_heartbeat_series_test.dart @@ -0,0 +1,354 @@ +import 'dart:math' as math; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_analytics/onehz.dart' as ana; +import 'package:openstrap_edge/health/health_heartbeat_series.dart'; + +final _sleepStart = DateTime(2026, 8, 5, 0, 0); +final _sleepEnd = DateTime(2026, 8, 5, 7, 0); + +/// A steady beat train: [count] beats of [rrMs] each, the first ending +/// [startOffsetMs] after sleep onset. +({List rr, List ts}) _steady( + int count, { + int rrMs = 1000, + int startOffsetMs = 1000, +}) { + final t0 = _sleepStart.millisecondsSinceEpoch + startOffsetMs; + return ( + rr: List.filled(count, rrMs), + ts: [for (var i = 0; i < count; i++) t0 + i * rrMs], + ); +} + +List _instants(List series) => [ + for (final s in series) + for (final o in s.offsetsMs) s.start.millisecondsSinceEpoch + o, +]; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('buildSleepHeartbeatSeries', () { + test('a contiguous run exports every beat with no gaps inside it', () { + final beats = _steady(120); + final series = buildSleepHeartbeatSeries( + rrMs: beats.rr, + rrTsMs: beats.ts, + sleepStart: _sleepStart, + sleepEnd: _sleepEnd, + ); + expect(series, hasLength(1)); + final s = series.single; + expect(s.beats, 120); + expect(s.offsetsMs.first, 0); + expect(s.precededByGap.first, isFalse); + expect(s.precededByGap.skip(1), everyElement(isFalse)); + // Successive instants ARE the measured intervals. + for (var i = 1; i < s.beats; i++) { + expect(s.offsetsMs[i] - s.offsetsMs[i - 1], 1000); + } + expect(_instants(series), beats.ts); + }); + + test('a sensor dropout re-anchors and flags the first beat after it', () { + final before = _steady(60); + // 30 s with no beats reported, then the train resumes. + final after = _steady(60, startOffsetMs: 1000 + 60 * 1000 + 30 * 1000); + final series = buildSleepHeartbeatSeries( + rrMs: [...before.rr, ...after.rr], + rrTsMs: [...before.ts, ...after.ts], + sleepStart: _sleepStart, + sleepEnd: _sleepEnd, + ); + expect(series, hasLength(1)); + final s = series.single; + expect(s.beats, 120); + // The resumed beat sits at its real time, not spliced onto the first run. + expect(s.offsetsMs[60] - s.offsetsMs[59], 31000); + expect(s.precededByGap[60], isTrue); + expect( + [ + for (var i = 0; i < s.beats; i++) + if (s.precededByGap[i]) i, + ], + [60], + ); + }); + + test('a missed beat keeps its real instant and is flagged, never ' + 'interpolated', () { + final t0 = _sleepStart.millisecondsSinceEpoch + 1000; + final rr = []; + final ts = []; + var t = t0; + for (var i = 0; i < 120; i++) { + // Beat 60's detection was missed: one 2000 ms interval stands in for + // two 1000 ms ones. + final interval = i == 60 ? 2000 : 1000; + if (i > 0) t += interval; + rr.add(interval); + ts.add(t); + } + final series = buildSleepHeartbeatSeries( + rrMs: rr, + rrTsMs: ts, + sleepStart: _sleepStart, + sleepEnd: _sleepEnd, + ); + final instants = _instants(series); + // Every exported instant is one the band reported; no beat is invented + // in the middle of the 2000 ms hole. + expect(instants.toSet().difference(ts.toSet()), isEmpty); + final s = series.single; + final idx = s.offsetsMs.indexOf(ts[60].toInt() - ts[0].toInt()); + expect(idx, greaterThan(0)); + expect(s.precededByGap[idx], isTrue); + // The beat after it is an ordinary 1000 ms pair again. + expect(s.precededByGap[idx + 1], isFalse); + }); + + test( + 'an implausible interval is dropped and the beat after it flagged', + () { + final t0 = _sleepStart.millisecondsSinceEpoch + 1000; + final rr = []; + final ts = []; + var t = t0; + for (var i = 0; i < 60; i++) { + // Beat 30 reports 3000 ms: past kMaxPlausibleRrMs, so not a beat + // interval the derive path would keep either. + final interval = i == 30 ? 3000 : 1000; + if (i > 0) t += interval; + rr.add(interval); + ts.add(t); + } + final series = buildSleepHeartbeatSeries( + rrMs: rr, + rrTsMs: ts, + sleepStart: _sleepStart, + sleepEnd: _sleepEnd, + ); + final s = series.single; + expect(_instants(series), isNot(contains(ts[30]))); + expect(s.beats, 59); + // Beat 31 is placed at its own reported time, and the pair 29 -> 31 is + // not an interval anyone measured. + expect(_instants(series)[30], ts[31]); + expect(s.precededByGap[30], isTrue); + expect( + [ + for (var i = 0; i < s.beats; i++) + if (s.precededByGap[i]) i, + ], + [30], + ); + }, + ); + + test('only beats inside the sleep window are exported', () { + final beats = _steady(120, startOffsetMs: -60 * 1000); + final series = buildSleepHeartbeatSeries( + rrMs: beats.rr, + rrTsMs: beats.ts, + sleepStart: _sleepStart, + sleepEnd: _sleepEnd, + ); + final from = _sleepStart.millisecondsSinceEpoch; + expect(_instants(series), everyElement(greaterThanOrEqualTo(from))); + expect(series.single.beats, 60); + }); + + test('a long run is split into series no wider than the max span', () { + final beats = _steady(12 * 60); // 12 minutes + final series = buildSleepHeartbeatSeries( + rrMs: beats.rr, + rrTsMs: beats.ts, + sleepStart: _sleepStart, + sleepEnd: _sleepEnd, + ); + expect(series, hasLength(3)); + for (final s in series) { + expect( + s.offsetsMs.last, + lessThanOrEqualTo(kHeartbeatSeriesMaxSpan.inMilliseconds), + ); + expect(s.precededByGap.first, isFalse); + } + expect(_instants(series), beats.ts); + }); + + test('absent or malformed input writes nothing', () { + expect( + buildSleepHeartbeatSeries( + rrMs: const [], + rrTsMs: const [], + sleepStart: _sleepStart, + sleepEnd: _sleepEnd, + ), + isEmpty, + ); + expect( + buildSleepHeartbeatSeries( + rrMs: const [1000, 1000], + rrTsMs: const [1], + sleepStart: _sleepStart, + sleepEnd: _sleepEnd, + ), + isEmpty, + ); + final beats = _steady(10); + expect( + buildSleepHeartbeatSeries( + rrMs: beats.rr, + rrTsMs: beats.ts, + sleepStart: _sleepEnd, + sleepEnd: _sleepStart, + ), + isEmpty, + ); + }); + }); + + group('what a Health reader computes from the export', () { + test('RMSSD over gap-honouring pairs tracks the nightly RMSSD', () { + // An hour of sinus-arrhythmic beats with dropouts and missed beats, the + // shapes a wrist night actually has. + final rng = math.Random(7); + final rr = []; + final ts = []; + var t = _sleepStart.millisecondsSinceEpoch + 1000.0; + var phase = 0.0; + while (t < _sleepStart.millisecondsSinceEpoch + 3600 * 1000) { + phase += 0.25; + var interval = (1000 + 60 * math.sin(phase) + rng.nextInt(41) - 20) + .toDouble(); + final roll = rng.nextDouble(); + if (roll < 0.01) { + t += 8000 + rng.nextInt(20000); // dropout: nothing reported + } else if (roll < 0.02) { + interval *= 2; // missed detection + } + t += interval; + rr.add(interval.round()); + ts.add(t.round()); + } + + final series = buildSleepHeartbeatSeries( + rrMs: rr, + rrTsMs: ts, + sleepStart: _sleepStart, + sleepEnd: _sleepEnd, + ); + + // A reader: interval = successive instants, kept only when the beat is + // not preceded by a gap; RMSSD over pairs of adjacent kept intervals. + var ssd = 0.0; + var pairs = 0; + for (final s in series) { + double? prevInterval; + for (var i = 1; i < s.beats; i++) { + if (s.precededByGap[i]) { + prevInterval = null; + continue; + } + final interval = (s.offsetsMs[i] - s.offsetsMs[i - 1]).toDouble(); + if (prevInterval != null) { + final d = interval - prevInterval; + ssd += d * d; + pairs++; + } + prevInterval = interval; + } + } + final exported = math.sqrt(ssd / pairs); + + final cleaned = ana.correctRr( + [for (final v in rr) v.toDouble()], + rrTsMs: [for (final v in ts) v.toDouble()], + ); + final nightly = ana + .hrvTime(cleaned.nn, nnTimesMs: cleaned.nnTimesMs) + .value + ?.rmssd; + expect(nightly, isNotNull); + expect((exported - nightly!).abs() / nightly, lessThan(0.05)); + }); + }); + + group('MethodChannelHealthKitHeartbeatWriter', () { + test( + 'one replace call carries the cleanup window and every series', + () async { + const channel = MethodChannel('openstrap/test_healthkit_heartbeat'); + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return true; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + final beats = _steady(12 * 60); + final series = buildSleepHeartbeatSeries( + rrMs: beats.rr, + rrTsMs: beats.ts, + sleepStart: _sleepStart, + sleepEnd: _sleepEnd, + ); + final writer = MethodChannelHealthKitHeartbeatWriter(channel: channel); + final cleanupStart = DateTime(2026, 8, 4, 12); + final cleanupEnd = DateTime(2026, 8, 5, 12); + expect( + await writer.replace( + cleanupStart: cleanupStart, + cleanupEnd: cleanupEnd, + series: series, + ), + isTrue, + ); + expect(calls, hasLength(1)); + expect(calls.single.method, 'replaceHeartbeatSeries'); + final args = (calls.single.arguments as Map).cast(); + expect(args['cleanupStartTime'], cleanupStart.millisecondsSinceEpoch); + expect(args['cleanupEndTime'], cleanupEnd.millisecondsSinceEpoch); + final sent = args['series'] as List; + expect(sent, hasLength(3)); + final first = (sent.first as Map).cast(); + expect(first['startTime'], beats.ts.first); + expect( + (first['offsetsMs'] as List).length, + (first['precededByGap'] as List).length, + ); + }, + ); + + test('an empty series list is still sent: it is the clear', () async { + const channel = MethodChannel('openstrap/test_healthkit_heartbeat_clear'); + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return true; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + final writer = MethodChannelHealthKitHeartbeatWriter(channel: channel); + expect( + await writer.replace( + cleanupStart: DateTime(2026, 8, 4, 12), + cleanupEnd: DateTime(2026, 8, 5, 12), + series: const [], + ), + isTrue, + ); + expect(((calls.single.arguments as Map)['series'] as List), isEmpty); + }); + }); +}