From 3b2c0775230fedc942bceba6c503e7b0c4db33bd Mon Sep 17 00:00:00 2001 From: Ryan Beckett Date: Wed, 14 Oct 2026 11:15:29 +0100 Subject: [PATCH] health: export overnight beat-to-beat to Apple Health HealthKit's only HRV quantity is SDNN, so on iOS the nightly RMSSD never reaches Apple Health. Readers that compute RMSSD themselves from HKHeartbeatSeriesSample (Bevel's default HRV method) get no HRV from an OpenStrap night: today only an Apple Watch writes heartbeat series. Write the night's primary-band beats as heartbeat series on the clock the nightly RMSSD runs on (correctRr's nnTimesMs). Only measured instants are written; a spline-corrected beat keeps its real time and is flagged precededByGap, as is every beat after a dropout or dropped artifact run. Series are <=5 min; the native writer deletes our own series in the sleep cleanup window first, so re-export never duplicates. A night whose beats are pruned is skipped, not cleared. The health plugin has no heartbeat-series type, so request() also asks for HKSeriesType.heartbeat() share access natively. No metric output change (no kAlgoVersion bump), no schema change, Android untouched. --- ios/Runner.xcodeproj/project.pbxproj | 4 + ios/Runner/AppDelegate.swift | 4 + ios/Runner/HealthKitHeartbeatWriter.swift | 174 +++++++++++ ios/Runner/Info.plist | 2 +- lib/health/health_export.dart | 101 ++++++ lib/health/health_heartbeat_series.dart | 217 +++++++++++++ test/health_heartbeat_series_test.dart | 354 ++++++++++++++++++++++ 7 files changed, 855 insertions(+), 1 deletion(-) create mode 100644 ios/Runner/HealthKitHeartbeatWriter.swift create mode 100644 lib/health/health_heartbeat_series.dart create mode 100644 test/health_heartbeat_series_test.dart 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); + }); + }); +}