Skip to content
Open
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
143 changes: 143 additions & 0 deletions ios/Runner/HealthKitSleepWriter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import Flutter
import HealthKit

// HealthKit sleep replace. Mirrors Android `HealthConnectSleepWriter`: one
// channel call deletes OUR overlapping `sleepAnalysis` samples, then writes
// one `inBed` envelope plus stages.
//
// The Flutter `health` plugin is not used here. Unknown type keys (SLEEP_SESSION)
// map to bodyMass and hang `delete()`. SLEEP_LIGHT / inBed writes have been
// failing on recent iOS as Core-less ~2 h nights with leftover 11pm fragments
// (#225/#239/#249). Dart already computed the noon-to-noon cleanup window;
// this file does not re-derive local noon.

enum HealthKitSleepWriter {
private static let channelName = "openstrap/healthkit_sleep"
private static let store = HKHealthStore()
private static let queue = DispatchQueue(label: "wtf.openstrap.healthkit.sleep")

static func register(messenger: FlutterBinaryMessenger) {
let channel = FlutterMethodChannel(name: channelName, binaryMessenger: messenger)
channel.setMethodCallHandler { call, result in
guard call.method == "replaceSleepSession" else {
result(FlutterMethodNotImplemented)
return
}
let args = call.arguments as? [String: Any] ?? [:]
queue.async {
replace(args) { ok in
DispatchQueue.main.async { result(ok) }
}
}
}
}

private static func replace(_ args: [String: Any], completion: @escaping (Bool) -> Void) {
guard HKHealthStore.isHealthDataAvailable() else {
completion(true) // iPad / simulator: no store, not an export failure
return
}
guard let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) else {
completion(false)
return
}
guard let cleanupStart = dateMs(args["cleanupStartTime"]),
let cleanupEnd = dateMs(args["cleanupEndTime"]),
cleanupStart < cleanupEnd else {
completion(false)
return
}

let samples: [HKCategorySample]
do {
samples = try buildSamples(args, type: sleepType)
} catch {
completion(false)
return
}

let datePred = HKQuery.predicateForSamples(
withStart: cleanupStart, end: cleanupEnd, options: []
)
let sourcePred = HKQuery.predicateForObjects(from: HKSource.default())
let pred = NSCompoundPredicate(andPredicateWithSubpredicates: [datePred, sourcePred])

store.deleteObjects(of: sleepType, predicate: pred) { _, _, error in
if error != nil {
completion(false)
return
}
guard !samples.isEmpty else {
completion(true)
return
}
store.save(samples) { success, error in
completion(success && error == nil)
}
}
}

private static func buildSamples(
_ args: [String: Any],
type: HKCategoryType
) throws -> [HKCategorySample] {
guard let start = dateMs(args["startTime"]),
let end = dateMs(args["endTime"]) else {
return []
}
guard start < end else {
throw SleepWriteError.invalidWindow
}

var samples: [HKCategorySample] = [
HKCategorySample(
type: type,
value: HKCategoryValueSleepAnalysis.inBed.rawValue,
start: start,
end: end
),
]

let rawStages = args["stages"] as? [Any] ?? []
var previousEnd = start
for raw in rawStages {
guard let map = raw as? [String: Any],
let stageStart = dateMs(map["startTime"]),
let stageEnd = dateMs(map["endTime"]),
let value = sleepValue(map["stage"] as? String) else {
throw SleepWriteError.invalidStage
}
guard stageStart < stageEnd,
stageStart >= start,
stageEnd <= end,
stageStart >= previousEnd else {
throw SleepWriteError.invalidStage
}
samples.append(
HKCategorySample(type: type, value: value.rawValue, start: stageStart, end: stageEnd)
)
previousEnd = stageEnd
}
return samples
}

private static func sleepValue(_ stage: String?) -> HKCategoryValueSleepAnalysis? {
switch stage {
case "awake": return .awake
case "rem": return .asleepREM
case "light": return .asleepCore
case "deep": return .asleepDeep
default: return nil
}
}

private static func dateMs(_ raw: Any?) -> Date? {
guard let n = raw as? NSNumber else { return nil }
return Date(timeIntervalSince1970: n.doubleValue / 1000.0)
}

private enum SleepWriteError: Error {
case invalidWindow
case invalidStage
}
}
Loading