Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions ios/Runner.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -150,6 +151,7 @@
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
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 = "<group>"; };
D5F9B2E3A7C4418BA6D3F2C8 /* HealthKitHeartbeatWriter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HealthKitHeartbeatWriter.swift; sourceTree = "<group>"; };
ACCE55E7000000000000F11E /* AccessorySetup.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AccessorySetup.swift; sourceTree = "<group>"; };
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 = "<group>"; };
BEEF00000000000000000001 /* BreathingLiveActivityBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreathingLiveActivityBridge.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -344,6 +346,7 @@
5A0C00000000000000000004 /* LongSyncDataIntent.swift */,
46A2B400A52A2CA90242C195 /* HealthRoutes.swift */,
A8C1E3F507294B6D81A0C2E4 /* HealthKitSleepWriter.swift */,
D5F9B2E3A7C4418BA6D3F2C8 /* HealthKitHeartbeatWriter.swift */,
);
path = Runner;
sourceTree = "<group>";
Expand Down Expand Up @@ -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;
};
Expand Down
4 changes: 4 additions & 0 deletions ios/Runner/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Expand Down
174 changes: 174 additions & 0 deletions ios/Runner/HealthKitHeartbeatWriter.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
2 changes: 1 addition & 1 deletion ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@
<key>NSCameraUsageDescription</key>
<string>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.</string>
<key>NSHealthUpdateUsageDescription</key>
<string>OpenStrap writes your sleep, resting heart rate, HRV, respiratory rate, energy and workouts into Apple Health.</string>
<string>OpenStrap writes your sleep, resting heart rate, HRV, your beat-to-beat heart rhythm during sleep, respiratory rate, energy and workouts into Apple Health.</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>OpenStrap connects to your WHOOP band over Bluetooth to sync your health data.</string>
<key>NSLocationWhenInUseUsageDescription</key>
Expand Down
101 changes: 101 additions & 0 deletions lib/health/health_export.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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<bool> _exportAppleHeartbeats(
Map<String, dynamic> 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 = <num>[];
final ts = <num>[];
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<bool> _exportDay(
String date,
Map<String, dynamic> b, {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<List<HealthHeartbeatSeries>> _buildHeartbeatsOffIsolate(
List<num> rrMs,
List<num> rrTsMs,
DateTime sleepStart,
DateTime sleepEnd,
) => Isolate.run(
() => buildSleepHeartbeatSeries(
rrMs: rrMs,
rrTsMs: rrTsMs,
sleepStart: sleepStart,
sleepEnd: sleepEnd,
),
);
Loading