Skip to content
Merged
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 @@ -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 */; };
Expand Down Expand Up @@ -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 = "<group>"; };
46A2B400A52A2CA90242C195 /* HealthRoutes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HealthRoutes.swift; sourceTree = "<group>"; };
A8C1E3F507294B6D81A0C2E4 /* HealthKitSleepWriter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HealthKitSleepWriter.swift; sourceTree = "<group>"; };
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; };
Expand Down Expand Up @@ -299,6 +301,7 @@
ACCE55E7000000000000F11E /* AccessorySetup.swift */,
0BGTASK00000000000000002 /* BgSyncScheduler.swift */,
46A2B400A52A2CA90242C195 /* HealthRoutes.swift */,
A8C1E3F507294B6D81A0C2E4 /* HealthKitSleepWriter.swift */,
);
path = Runner;
sourceTree = "<group>";
Expand Down Expand Up @@ -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;
};
Expand Down
4 changes: 4 additions & 0 deletions ios/Runner/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
152 changes: 152 additions & 0 deletions ios/Runner/HealthKitSleepWriter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
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":
if #available(iOS 16.0, *) { return .asleepREM }
return .asleep
case "light":
// .asleepCore is the iOS 16+ name for light sleep; on iOS 15 HealthKit
// only has the binary .asleep. The runner target is 15.0 — without the
// guard this file does not compile at all.
if #available(iOS 16.0, *) { return .asleepCore }
return .asleep
case "deep":
if #available(iOS 16.0, *) { return .asleepDeep }
return .asleep
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
}
}
Loading
Loading