@elizaos/capacitor-mobile-signals 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,968 @@
1
+ import Foundation
2
+ import BackgroundTasks
3
+ import Capacitor
4
+ import HealthKit
5
+ import UIKit
6
+
7
+ @objc(MobileSignalsPlugin)
8
+ public class MobileSignalsPlugin: CAPPlugin, CAPBridgedPlugin {
9
+ public let identifier = "MobileSignalsPlugin"
10
+ public let jsName = "MobileSignals"
11
+ public let pluginMethods: [CAPPluginMethod] = [
12
+ CAPPluginMethod(name: "checkPermissions", returnType: CAPPluginReturnPromise),
13
+ CAPPluginMethod(name: "requestPermissions", returnType: CAPPluginReturnPromise),
14
+ CAPPluginMethod(name: "openSettings", returnType: CAPPluginReturnPromise),
15
+ CAPPluginMethod(name: "startMonitoring", returnType: CAPPluginReturnPromise),
16
+ CAPPluginMethod(name: "stopMonitoring", returnType: CAPPluginReturnPromise),
17
+ CAPPluginMethod(name: "getSnapshot", returnType: CAPPluginReturnPromise),
18
+ CAPPluginMethod(name: "scheduleBackgroundRefresh", returnType: CAPPluginReturnPromise),
19
+ CAPPluginMethod(name: "cancelBackgroundRefresh", returnType: CAPPluginReturnPromise),
20
+ ]
21
+
22
+ // Background task identifier — must be listed in the host app's Info.plist
23
+ // under `BGTaskSchedulerPermittedIdentifiers`. When it is not listed,
24
+ // registration fails silently (see `registerBackgroundTaskIfAvailable`)
25
+ // and the plugin still works with foreground-only HealthKit polling.
26
+ private static let backgroundRefreshIdentifier = "ai.eliza.mobile-signals.sleep-refresh"
27
+ private static let backgroundRefreshInterval: TimeInterval = 30 * 60
28
+ private var backgroundTaskRegistered = false
29
+
30
+ private struct HealthCapture {
31
+ let source: String
32
+ let screenTime: [String: Any]
33
+ let permissions: [String: Bool]
34
+ let sleep: [String: Any]
35
+ let biometrics: [String: Any]
36
+ let warnings: [String]
37
+ }
38
+
39
+ private struct SleepEpisode {
40
+ let startDate: Date
41
+ let endDate: Date
42
+ let durationMinutes: Double
43
+ let latestStageValue: Int
44
+ }
45
+
46
+ private var monitoring = false
47
+ private var observers: [NSObjectProtocol] = []
48
+ private let healthStore = HKHealthStore()
49
+ private let healthQueue = DispatchQueue(label: "ai.eliza.mobile-signals.health", qos: .utility)
50
+
51
+ public override func load() {
52
+ UIDevice.current.isBatteryMonitoringEnabled = true
53
+ registerBackgroundTaskIfAvailable()
54
+ }
55
+
56
+ private func registerBackgroundTaskIfAvailable() {
57
+ if #available(iOS 13.0, *) {
58
+ guard !backgroundTaskRegistered else { return }
59
+ let identifier = Self.backgroundRefreshIdentifier
60
+ let registered = BGTaskScheduler.shared.register(
61
+ forTaskWithIdentifier: identifier,
62
+ using: nil
63
+ ) { [weak self] task in
64
+ guard let self = self, let refreshTask = task as? BGAppRefreshTask else {
65
+ task.setTaskCompleted(success: false)
66
+ return
67
+ }
68
+ self.handleBackgroundRefresh(task: refreshTask)
69
+ }
70
+ backgroundTaskRegistered = registered
71
+ if !registered {
72
+ FileHandle.standardError.write(
73
+ "[mobile-signals] BGTaskScheduler registration declined. Add `\(identifier)` to Info.plist BGTaskSchedulerPermittedIdentifiers to enable background HealthKit polling.\n"
74
+ .data(using: .utf8) ?? Data()
75
+ )
76
+ }
77
+ }
78
+ }
79
+
80
+ @available(iOS 13.0, *)
81
+ private func handleBackgroundRefresh(task: BGAppRefreshTask) {
82
+ let expirationHandler = { [weak task] in
83
+ guard let task = task else { return }
84
+ task.setTaskCompleted(success: false)
85
+ }
86
+ task.expirationHandler = expirationHandler
87
+ buildHealthSnapshot(reason: "background-refresh") { [weak self] healthSnapshot in
88
+ self?.notifyListeners("signal", data: healthSnapshot)
89
+ self?.scheduleNextBackgroundRefresh()
90
+ task.setTaskCompleted(success: true)
91
+ }
92
+ }
93
+
94
+ @available(iOS 13.0, *)
95
+ private func scheduleNextBackgroundRefresh() {
96
+ guard backgroundTaskRegistered else { return }
97
+ let request = BGAppRefreshTaskRequest(
98
+ identifier: Self.backgroundRefreshIdentifier
99
+ )
100
+ request.earliestBeginDate = Date(timeIntervalSinceNow: Self.backgroundRefreshInterval)
101
+ do {
102
+ try BGTaskScheduler.shared.submit(request)
103
+ } catch {
104
+ FileHandle.standardError.write(
105
+ "[mobile-signals] Failed to schedule background refresh: \(error.localizedDescription)\n"
106
+ .data(using: .utf8) ?? Data()
107
+ )
108
+ }
109
+ }
110
+
111
+ @objc func scheduleBackgroundRefresh(_ call: CAPPluginCall) {
112
+ if #available(iOS 13.0, *) {
113
+ if !backgroundTaskRegistered {
114
+ call.resolve([
115
+ "scheduled": false,
116
+ "reason": "BGTaskSchedulerPermittedIdentifiers missing \(Self.backgroundRefreshIdentifier)",
117
+ ])
118
+ return
119
+ }
120
+ scheduleNextBackgroundRefresh()
121
+ call.resolve([
122
+ "scheduled": true,
123
+ "identifier": Self.backgroundRefreshIdentifier,
124
+ "earliestBeginInSeconds": Int(Self.backgroundRefreshInterval),
125
+ ])
126
+ } else {
127
+ call.resolve([
128
+ "scheduled": false,
129
+ "reason": "BackgroundTasks requires iOS 13.0 or later.",
130
+ ])
131
+ }
132
+ }
133
+
134
+ @objc func cancelBackgroundRefresh(_ call: CAPPluginCall) {
135
+ if #available(iOS 13.0, *) {
136
+ BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.backgroundRefreshIdentifier)
137
+ call.resolve(["cancelled": true])
138
+ } else {
139
+ call.resolve([
140
+ "cancelled": false,
141
+ "reason": "BackgroundTasks requires iOS 13.0 or later.",
142
+ ])
143
+ }
144
+ }
145
+
146
+ deinit {
147
+ stopInternal()
148
+ UIDevice.current.isBatteryMonitoringEnabled = false
149
+ }
150
+
151
+ @objc func startMonitoring(_ call: CAPPluginCall) {
152
+ if monitoring {
153
+ call.resolve(buildStartResult())
154
+ return
155
+ }
156
+
157
+ monitoring = true
158
+ registerObservers()
159
+ call.resolve(buildStartResult())
160
+
161
+ if call.getBool("emitInitial") ?? true {
162
+ emitSignal(reason: "start")
163
+ emitHealthSignal(reason: "start")
164
+ }
165
+ }
166
+
167
+ @objc func stopMonitoring(_ call: CAPPluginCall) {
168
+ stopInternal()
169
+ call.resolve(["stopped": true])
170
+ }
171
+
172
+ @objc public override func checkPermissions(_ call: CAPPluginCall) {
173
+ call.resolve(buildPermissionResult())
174
+ }
175
+
176
+ @objc public override func requestPermissions(_ call: CAPPluginCall) {
177
+ let types = requestedHealthTypes()
178
+ guard !types.isEmpty else {
179
+ resolvePermissionAfterScreenTimeRequest(
180
+ call,
181
+ status: "not-applicable",
182
+ canRequest: false,
183
+ reason: "HealthKit sleep and biometric types are unavailable on this device."
184
+ )
185
+ return
186
+ }
187
+
188
+ healthStore.requestAuthorization(toShare: nil, read: Set(types)) { [weak self] success, error in
189
+ DispatchQueue.main.async {
190
+ guard let self = self else { return }
191
+ let healthReason = !success
192
+ ? "HealthKit permission request failed: \(error?.localizedDescription ?? "unknown error")"
193
+ : nil
194
+ self.resolvePermissionAfterScreenTimeRequest(
195
+ call,
196
+ reason: healthReason
197
+ )
198
+ }
199
+ }
200
+ }
201
+
202
+ @objc func openSettings(_ call: CAPPluginCall) {
203
+ let target = call.getString("target") ?? "app"
204
+ let reason: String?
205
+ let actualTarget: String
206
+ let urlString: String
207
+
208
+ if target == "notification", #available(iOS 16.0, *) {
209
+ actualTarget = "notification"
210
+ urlString = UIApplication.openNotificationSettingsURLString
211
+ reason = nil
212
+ } else {
213
+ actualTarget = "app"
214
+ urlString = UIApplication.openSettingsURLString
215
+ reason = target == "app" || target == "health" || target == "localNetwork"
216
+ ? nil
217
+ : "iOS only supports stable public deep links to this app's Settings screen."
218
+ }
219
+
220
+ guard let url = URL(string: urlString) else {
221
+ call.resolve([
222
+ "opened": false,
223
+ "target": target,
224
+ "actualTarget": actualTarget,
225
+ "reason": "Unable to build iOS settings URL.",
226
+ ])
227
+ return
228
+ }
229
+
230
+ DispatchQueue.main.async {
231
+ UIApplication.shared.open(url, options: [:]) { opened in
232
+ let resolvedReason: Any
233
+ if opened {
234
+ if let reason {
235
+ resolvedReason = reason
236
+ } else {
237
+ resolvedReason = NSNull()
238
+ }
239
+ } else {
240
+ resolvedReason = "iOS declined to open Settings."
241
+ }
242
+ call.resolve([
243
+ "opened": opened,
244
+ "target": target,
245
+ "actualTarget": actualTarget,
246
+ "reason": resolvedReason,
247
+ ])
248
+ }
249
+ }
250
+ }
251
+
252
+ @objc func getSnapshot(_ call: CAPPluginCall) {
253
+ let device = buildSnapshot(reason: "snapshot")
254
+ buildHealthSnapshot(reason: "snapshot") { health in
255
+ call.resolve([
256
+ "supported": true,
257
+ "snapshot": device,
258
+ "healthSnapshot": health,
259
+ ])
260
+ }
261
+ }
262
+
263
+ private func registerObservers() {
264
+ let center = NotificationCenter.default
265
+ let names: [Notification.Name] = [
266
+ UIApplication.didBecomeActiveNotification,
267
+ UIApplication.willResignActiveNotification,
268
+ UIApplication.didEnterBackgroundNotification,
269
+ UIApplication.willEnterForegroundNotification,
270
+ UIApplication.protectedDataDidBecomeAvailableNotification,
271
+ UIApplication.protectedDataWillBecomeUnavailableNotification,
272
+ Notification.Name.NSProcessInfoPowerStateDidChange,
273
+ UIDevice.batteryStateDidChangeNotification,
274
+ ]
275
+
276
+ for name in names {
277
+ let observer = center.addObserver(
278
+ forName: name,
279
+ object: nil,
280
+ queue: .main
281
+ ) { [weak self] _ in
282
+ self?.emitSignal(reason: name.rawValue)
283
+ if name == UIApplication.didBecomeActiveNotification ||
284
+ name == UIApplication.willEnterForegroundNotification ||
285
+ name == UIApplication.protectedDataDidBecomeAvailableNotification {
286
+ self?.emitHealthSignal(reason: name.rawValue)
287
+ }
288
+ }
289
+ observers.append(observer)
290
+ }
291
+ }
292
+
293
+ private func stopInternal() {
294
+ let center = NotificationCenter.default
295
+ for observer in observers {
296
+ center.removeObserver(observer)
297
+ }
298
+ observers.removeAll()
299
+ monitoring = false
300
+ }
301
+
302
+ private func buildStartResult() -> [String: Any] {
303
+ [
304
+ "enabled": monitoring,
305
+ "supported": true,
306
+ "platform": "ios",
307
+ "snapshot": buildSnapshot(reason: "start"),
308
+ "healthSnapshot": NSNull(),
309
+ ]
310
+ }
311
+
312
+ private func requestedHealthTypes() -> [HKObjectType] {
313
+ var types: [HKObjectType] = []
314
+ if let sleepType = self.sleepHealthType() {
315
+ types.append(sleepType)
316
+ }
317
+ types.append(contentsOf: biometricHealthTypes())
318
+ return types
319
+ }
320
+
321
+ private func sleepHealthType() -> HKObjectType? {
322
+ HKObjectType.categoryType(forIdentifier: .sleepAnalysis)
323
+ }
324
+
325
+ private func biometricHealthTypes() -> [HKObjectType] {
326
+ let biometricIdentifiers: [HKQuantityTypeIdentifier] = [
327
+ .heartRate,
328
+ .restingHeartRate,
329
+ .heartRateVariabilitySDNN,
330
+ .respiratoryRate,
331
+ .oxygenSaturation,
332
+ ]
333
+ return biometricIdentifiers.compactMap {
334
+ HKObjectType.quantityType(forIdentifier: $0)
335
+ }
336
+ }
337
+
338
+ private func buildPermissionResult(
339
+ status overrideStatus: String? = nil,
340
+ canRequest overrideCanRequest: Bool? = nil,
341
+ reason overrideReason: String? = nil
342
+ ) -> [String: Any] {
343
+ let screenTimeStatus = ScreenTimeSupport.buildStatus()
344
+ guard HKHealthStore.isHealthDataAvailable() else {
345
+ return [
346
+ "status": overrideStatus ?? "not-applicable",
347
+ "canRequest": overrideCanRequest ?? false,
348
+ "reason": overrideReason ?? "HealthKit is not available on this device.",
349
+ "permissions": [
350
+ "sleep": false,
351
+ "biometrics": false,
352
+ ],
353
+ "screenTime": screenTimeStatus,
354
+ "setupActions": buildSetupActions(
355
+ healthStatus: overrideStatus ?? "not-applicable",
356
+ healthCanRequest: overrideCanRequest ?? false,
357
+ screenTimeStatus: screenTimeStatus
358
+ ),
359
+ ]
360
+ }
361
+
362
+ let sleepType = sleepHealthType()
363
+ let biometricTypes = biometricHealthTypes()
364
+ let sleepGranted = sleepType.map { healthStore.authorizationStatus(for: $0) == .sharingAuthorized } ?? false
365
+ let biometricGranted = biometricTypes.isEmpty
366
+ ? false
367
+ : biometricTypes.allSatisfy { healthStore.authorizationStatus(for: $0) == .sharingAuthorized }
368
+ let hasRequestedTypes = sleepType != nil || !biometricTypes.isEmpty
369
+ let hasDenied = (sleepType.map { healthStore.authorizationStatus(for: $0) == .sharingDenied } ?? false) ||
370
+ biometricTypes.contains { healthStore.authorizationStatus(for: $0) == .sharingDenied }
371
+ let hasPending = (sleepType.map { healthStore.authorizationStatus(for: $0) == .notDetermined } ?? false) ||
372
+ biometricTypes.contains { healthStore.authorizationStatus(for: $0) == .notDetermined }
373
+ let status = overrideStatus ?? {
374
+ if !hasRequestedTypes {
375
+ return "not-applicable"
376
+ }
377
+ if sleepGranted || biometricGranted {
378
+ return "granted"
379
+ }
380
+ if hasDenied {
381
+ return "denied"
382
+ }
383
+ if hasPending {
384
+ return "not-determined"
385
+ }
386
+ return "not-determined"
387
+ }()
388
+
389
+ return [
390
+ "status": status,
391
+ "canRequest": overrideCanRequest ?? (status != "granted" && hasRequestedTypes),
392
+ "reason": overrideReason ?? NSNull(),
393
+ "screenTime": screenTimeStatus,
394
+ "setupActions": buildSetupActions(
395
+ healthStatus: status,
396
+ healthCanRequest: overrideCanRequest ?? (status != "granted" && hasRequestedTypes),
397
+ screenTimeStatus: screenTimeStatus
398
+ ),
399
+ "permissions": [
400
+ "sleep": sleepGranted,
401
+ "biometrics": biometricGranted,
402
+ ],
403
+ ]
404
+ }
405
+
406
+ private func resolvePermissionAfterScreenTimeRequest(
407
+ _ call: CAPPluginCall,
408
+ status: String? = nil,
409
+ canRequest: Bool? = nil,
410
+ reason: String? = nil
411
+ ) {
412
+ ScreenTimeSupport.requestAuthorizationIfAvailable { [weak self] screenTimeReason in
413
+ guard let self = self else { return }
414
+ var result = self.buildPermissionResult(
415
+ status: status,
416
+ canRequest: canRequest,
417
+ reason: reason
418
+ )
419
+ if let screenTimeReason {
420
+ if let existingReason = result["reason"] as? String, !existingReason.isEmpty {
421
+ result["reason"] = "\(existingReason) \(screenTimeReason)"
422
+ } else {
423
+ result["reason"] = screenTimeReason
424
+ }
425
+ }
426
+ call.resolve(result)
427
+ }
428
+ }
429
+
430
+ private func buildSetupActions(
431
+ healthStatus: String,
432
+ healthCanRequest: Bool,
433
+ screenTimeStatus: [String: Any]
434
+ ) -> [[String: Any]] {
435
+ let healthReady = healthStatus == "granted"
436
+ let authorization = screenTimeStatus["authorization"] as? [String: Any] ?? [:]
437
+ let screenTimeAuthStatus = authorization["status"] as? String ?? "unavailable"
438
+ let screenTimeCanRequest = authorization["canRequest"] as? Bool ?? false
439
+ let screenTimeSupported = screenTimeStatus["supported"] as? Bool ?? false
440
+ let screenTimeReady = screenTimeAuthStatus == "approved"
441
+ let screenTimeReason = screenTimeStatus["reason"] ?? NSNull()
442
+
443
+ return [
444
+ [
445
+ "id": "health_permissions",
446
+ "label": "HealthKit",
447
+ "status": healthReady
448
+ ? "ready"
449
+ : (healthStatus == "not-applicable" ? "unavailable" : "needs-action"),
450
+ "canRequest": healthCanRequest,
451
+ "canOpenSettings": true,
452
+ "settingsTarget": "health",
453
+ "reason": healthReady
454
+ ? NSNull()
455
+ : "Grant Health read access for sleep, heart rate, HRV, respiratory rate, and oxygen saturation.",
456
+ ],
457
+ [
458
+ "id": "screen_time_authorization",
459
+ "label": "Screen Time",
460
+ "status": screenTimeReady
461
+ ? "ready"
462
+ : (screenTimeSupported ? "needs-action" : "unavailable"),
463
+ "canRequest": screenTimeCanRequest,
464
+ "canOpenSettings": true,
465
+ "settingsTarget": "screenTime",
466
+ "reason": screenTimeReady ? NSNull() : screenTimeReason,
467
+ ],
468
+ [
469
+ "id": "local_network",
470
+ "label": "Local Network",
471
+ "status": "needs-action",
472
+ "canRequest": false,
473
+ "canOpenSettings": true,
474
+ "settingsTarget": "localNetwork",
475
+ "reason": "Allow Local Network when this phone sends data to a Mac or LAN agent.",
476
+ ],
477
+ [
478
+ "id": "notification_settings",
479
+ "label": "Notifications",
480
+ "status": "needs-action",
481
+ "canRequest": false,
482
+ "canOpenSettings": true,
483
+ "settingsTarget": "notification",
484
+ "reason": "Open notification settings if reminders or telemetry prompts are muted.",
485
+ ],
486
+ ]
487
+ }
488
+
489
+ private func buildSnapshot(reason: String) -> [String: Any] {
490
+ let app = UIApplication.shared
491
+ let protectedAvailable = app.isProtectedDataAvailable
492
+ let lowPower = ProcessInfo.processInfo.isLowPowerModeEnabled
493
+ let batteryState = UIDevice.current.batteryState
494
+ let batteryLevel = UIDevice.current.batteryLevel
495
+ let onBattery: Bool? = {
496
+ switch batteryState {
497
+ case .charging, .full:
498
+ return false
499
+ case .unplugged:
500
+ return true
501
+ case .unknown:
502
+ return nil
503
+ @unknown default:
504
+ return nil
505
+ }
506
+ }()
507
+ let state: String = {
508
+ if !protectedAvailable {
509
+ return "locked"
510
+ }
511
+ switch app.applicationState {
512
+ case .active:
513
+ return lowPower ? "idle" : "active"
514
+ case .inactive:
515
+ return "idle"
516
+ case .background:
517
+ return "background"
518
+ @unknown default:
519
+ return "background"
520
+ }
521
+ }()
522
+ let idleState: String = {
523
+ if !protectedAvailable {
524
+ return "locked"
525
+ }
526
+ if lowPower {
527
+ return "idle"
528
+ }
529
+ return state == "active" ? "active" : "idle"
530
+ }()
531
+ let level = batteryLevel >= 0 ? Double(batteryLevel) : nil
532
+ let onBatteryValue: Any = onBattery ?? NSNull()
533
+ let levelValue: Any = level ?? NSNull()
534
+
535
+ return [
536
+ "source": "mobile_device",
537
+ "platform": "ios",
538
+ "state": state,
539
+ "observedAt": Int64(Date().timeIntervalSince1970 * 1000),
540
+ "idleState": idleState,
541
+ "idleTimeSeconds": NSNull(),
542
+ "onBattery": onBatteryValue,
543
+ "metadata": [
544
+ "reason": reason,
545
+ "applicationState": app.applicationState.rawValue,
546
+ "isProtectedDataAvailable": protectedAvailable,
547
+ "isLowPowerModeEnabled": lowPower,
548
+ "batteryState": batteryState.rawValue,
549
+ "batteryLevel": levelValue,
550
+ ],
551
+ ]
552
+ }
553
+
554
+ private func emitSignal(reason: String) {
555
+ guard monitoring else { return }
556
+ notifyListeners("signal", data: buildSnapshot(reason: reason))
557
+ }
558
+
559
+ private func emitHealthSignal(reason: String) {
560
+ guard monitoring else { return }
561
+ buildHealthSnapshot(reason: reason) { [weak self] healthSnapshot in
562
+ guard let self = self, self.monitoring else { return }
563
+ self.notifyListeners("signal", data: healthSnapshot)
564
+ }
565
+ }
566
+
567
+ private func buildHealthSnapshot(
568
+ reason: String,
569
+ completion: @escaping ([String: Any]) -> Void
570
+ ) {
571
+ guard HKHealthStore.isHealthDataAvailable() else {
572
+ completion(makeHealthSnapshot(
573
+ reason: reason,
574
+ capture: HealthCapture(
575
+ source: "healthkit",
576
+ screenTime: ScreenTimeSupport.buildStatus(),
577
+ permissions: ["sleep": false, "biometrics": false],
578
+ sleep: [
579
+ "available": false,
580
+ "isSleeping": false,
581
+ "asleepAt": NSNull(),
582
+ "awakeAt": NSNull(),
583
+ "durationMinutes": NSNull(),
584
+ "stage": NSNull(),
585
+ ],
586
+ biometrics: [
587
+ "sampleAt": NSNull(),
588
+ "heartRateBpm": NSNull(),
589
+ "restingHeartRateBpm": NSNull(),
590
+ "heartRateVariabilityMs": NSNull(),
591
+ "respiratoryRate": NSNull(),
592
+ "bloodOxygenPercent": NSNull(),
593
+ ],
594
+ warnings: ["HealthKit is not available on this device"]
595
+ )
596
+ ))
597
+ return
598
+ }
599
+
600
+ healthQueue.async {
601
+ let group = DispatchGroup()
602
+ var sleepSummary: HealthCapture?
603
+ var biometricsSummary: HealthCapture?
604
+ var warnings: [String] = []
605
+
606
+ group.enter()
607
+ self.fetchSleepSummary { capture, fetchWarning in
608
+ sleepSummary = capture
609
+ if let fetchWarning {
610
+ warnings.append(fetchWarning)
611
+ }
612
+ group.leave()
613
+ }
614
+
615
+ group.enter()
616
+ self.fetchBiometrics { capture, fetchWarning in
617
+ biometricsSummary = capture
618
+ if let fetchWarning {
619
+ warnings.append(fetchWarning)
620
+ }
621
+ group.leave()
622
+ }
623
+
624
+ group.notify(queue: .main) {
625
+ let capture = HealthCapture(
626
+ source: "healthkit",
627
+ screenTime: ScreenTimeSupport.buildStatus(),
628
+ permissions: [
629
+ "sleep": sleepSummary?.permissions["sleep"] ?? false,
630
+ "biometrics": biometricsSummary?.permissions["biometrics"] ?? false,
631
+ ],
632
+ sleep: sleepSummary?.sleep ?? [
633
+ "available": false,
634
+ "isSleeping": false,
635
+ "asleepAt": NSNull(),
636
+ "awakeAt": NSNull(),
637
+ "durationMinutes": NSNull(),
638
+ "stage": NSNull(),
639
+ ],
640
+ biometrics: biometricsSummary?.biometrics ?? [
641
+ "sampleAt": NSNull(),
642
+ "heartRateBpm": NSNull(),
643
+ "restingHeartRateBpm": NSNull(),
644
+ "heartRateVariabilityMs": NSNull(),
645
+ "respiratoryRate": NSNull(),
646
+ "bloodOxygenPercent": NSNull(),
647
+ ],
648
+ warnings: warnings
649
+ )
650
+ completion(
651
+ self.makeHealthSnapshot(
652
+ reason: reason,
653
+ capture: capture
654
+ )
655
+ )
656
+ }
657
+ }
658
+ }
659
+
660
+ private func makeHealthSnapshot(
661
+ reason: String,
662
+ capture: HealthCapture
663
+ ) -> [String: Any] {
664
+ let deviceBatteryState = UIDevice.current.batteryState
665
+ let onBattery: Bool? = {
666
+ switch deviceBatteryState {
667
+ case .charging, .full:
668
+ return false
669
+ case .unplugged:
670
+ return true
671
+ case .unknown:
672
+ return nil
673
+ @unknown default:
674
+ return nil
675
+ }
676
+ }()
677
+ let state = (capture.sleep["isSleeping"] as? Bool) == true ? "sleeping" : "idle"
678
+ return [
679
+ "source": "mobile_health",
680
+ "platform": "ios",
681
+ "state": state,
682
+ "observedAt": Int64(Date().timeIntervalSince1970 * 1000),
683
+ "idleState": NSNull(),
684
+ "idleTimeSeconds": NSNull(),
685
+ "onBattery": onBattery ?? NSNull(),
686
+ "healthSource": capture.source,
687
+ "screenTime": capture.screenTime,
688
+ "permissions": capture.permissions,
689
+ "sleep": capture.sleep,
690
+ "biometrics": capture.biometrics,
691
+ "warnings": capture.warnings,
692
+ "metadata": [
693
+ "reason": reason,
694
+ "healthSource": capture.source,
695
+ "deviceState": UIApplication.shared.applicationState.rawValue,
696
+ ],
697
+ ]
698
+ }
699
+
700
+ private func fetchSleepSummary(
701
+ completion: @escaping (HealthCapture?, String?) -> Void
702
+ ) {
703
+ guard let sampleType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) else {
704
+ completion(nil, "Sleep analysis type unavailable")
705
+ return
706
+ }
707
+
708
+ let startDate = Calendar.current.date(byAdding: .day, value: -7, to: Date()) ?? Date().addingTimeInterval(-7 * 24 * 60 * 60)
709
+ let predicate = HKQuery.predicateForSamples(
710
+ withStart: startDate,
711
+ end: nil,
712
+ options: .strictStartDate
713
+ )
714
+ let sortDescriptors = [
715
+ NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
716
+ ]
717
+
718
+ let query = HKSampleQuery(
719
+ sampleType: sampleType,
720
+ predicate: predicate,
721
+ limit: HKObjectQueryNoLimit,
722
+ sortDescriptors: sortDescriptors
723
+ ) { _, samples, error in
724
+ guard error == nil else {
725
+ completion(nil, "Sleep analysis query failed")
726
+ return
727
+ }
728
+ let categories = (samples as? [HKCategorySample]) ?? []
729
+ guard !categories.isEmpty else {
730
+ completion(
731
+ HealthCapture(
732
+ source: "healthkit",
733
+ screenTime: ScreenTimeSupport.buildStatus(),
734
+ permissions: ["sleep": false, "biometrics": false],
735
+ sleep: [
736
+ "available": false,
737
+ "isSleeping": false,
738
+ "asleepAt": NSNull(),
739
+ "awakeAt": NSNull(),
740
+ "durationMinutes": NSNull(),
741
+ "stage": NSNull(),
742
+ ],
743
+ biometrics: [
744
+ "sampleAt": NSNull(),
745
+ "heartRateBpm": NSNull(),
746
+ "restingHeartRateBpm": NSNull(),
747
+ "heartRateVariabilityMs": NSNull(),
748
+ "respiratoryRate": NSNull(),
749
+ "bloodOxygenPercent": NSNull(),
750
+ ],
751
+ warnings: []
752
+ ),
753
+ nil
754
+ )
755
+ return
756
+ }
757
+
758
+ let latestEpisode = Self.latestSleepEpisode(from: categories)
759
+ let latestAwake = categories.last(where: { $0.value == HKCategoryValueSleepAnalysis.awake.rawValue })
760
+ let now = Date()
761
+ let sleepFreshnessWindow: TimeInterval = 15 * 60
762
+ let isSleeping =
763
+ latestEpisode != nil &&
764
+ latestEpisode!.endDate >= now.addingTimeInterval(-sleepFreshnessWindow) &&
765
+ (latestAwake == nil || latestAwake!.endDate <= latestEpisode!.endDate)
766
+ let asleepAt = latestEpisode?.startDate
767
+ let awakeAt = isSleeping ? nil : latestEpisode?.endDate
768
+ let durationMinutes = latestEpisode?.durationMinutes
769
+ let stage = latestEpisode.map { episode in
770
+ isSleeping ? Self.sleepStageName(for: episode.latestStageValue) : "awake"
771
+ } ?? "awake"
772
+ completion(
773
+ HealthCapture(
774
+ source: "healthkit",
775
+ screenTime: ScreenTimeSupport.buildStatus(),
776
+ permissions: ["sleep": true, "biometrics": false],
777
+ sleep: [
778
+ "available": true,
779
+ "isSleeping": isSleeping,
780
+ "asleepAt": asleepAt.map { Int64($0.timeIntervalSince1970 * 1000) } ?? NSNull(),
781
+ "awakeAt": awakeAt.map { Int64($0.timeIntervalSince1970 * 1000) } ?? NSNull(),
782
+ "durationMinutes": durationMinutes.map { Int64($0.rounded()) } ?? NSNull(),
783
+ "stage": stage,
784
+ ],
785
+ biometrics: [
786
+ "sampleAt": NSNull(),
787
+ "heartRateBpm": NSNull(),
788
+ "restingHeartRateBpm": NSNull(),
789
+ "heartRateVariabilityMs": NSNull(),
790
+ "respiratoryRate": NSNull(),
791
+ "bloodOxygenPercent": NSNull(),
792
+ ],
793
+ warnings: []
794
+ ),
795
+ nil
796
+ )
797
+ }
798
+ healthStore.execute(query)
799
+ }
800
+
801
+ private func fetchBiometrics(
802
+ completion: @escaping (HealthCapture?, String?) -> Void
803
+ ) {
804
+ let startDate = Calendar.current.date(byAdding: .day, value: -7, to: Date()) ?? Date().addingTimeInterval(-7 * 24 * 60 * 60)
805
+ let endDate = Date()
806
+ let predicate = HKQuery.predicateForSamples(
807
+ withStart: startDate,
808
+ end: endDate,
809
+ options: .strictStartDate
810
+ )
811
+
812
+ let group = DispatchGroup()
813
+ var latestHeartRate: (value: Double, at: Date)?
814
+ var latestRestingHeartRate: (value: Double, at: Date)?
815
+ var latestHrv: (value: Double, at: Date)?
816
+ var latestRespiratoryRate: (value: Double, at: Date)?
817
+ var latestBloodOxygen: (value: Double, at: Date)?
818
+
819
+ func fetchLatest(
820
+ identifier: HKQuantityTypeIdentifier,
821
+ unit: HKUnit,
822
+ assign: @escaping (Double, Date) -> Void
823
+ ) {
824
+ guard let sampleType = HKObjectType.quantityType(forIdentifier: identifier) else {
825
+ return
826
+ }
827
+ group.enter()
828
+ let query = HKSampleQuery(
829
+ sampleType: sampleType,
830
+ predicate: predicate,
831
+ limit: 1,
832
+ sortDescriptors: [
833
+ NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)
834
+ ]
835
+ ) { _, samples, error in
836
+ defer { group.leave() }
837
+ guard error == nil,
838
+ let sample = samples?.first as? HKQuantitySample else {
839
+ return
840
+ }
841
+ assign(sample.quantity.doubleValue(for: unit), sample.startDate)
842
+ }
843
+ healthStore.execute(query)
844
+ }
845
+
846
+ fetchLatest(identifier: .heartRate, unit: HKUnit(from: "count/min")) { value, at in
847
+ latestHeartRate = (value, at)
848
+ }
849
+ fetchLatest(identifier: .restingHeartRate, unit: HKUnit(from: "count/min")) { value, at in
850
+ latestRestingHeartRate = (value, at)
851
+ }
852
+ fetchLatest(identifier: .heartRateVariabilitySDNN, unit: HKUnit.secondUnit(with: .milli)) { value, at in
853
+ latestHrv = (value, at)
854
+ }
855
+ fetchLatest(identifier: .respiratoryRate, unit: HKUnit(from: "count/min")) { value, at in
856
+ latestRespiratoryRate = (value, at)
857
+ }
858
+ fetchLatest(identifier: .oxygenSaturation, unit: HKUnit.percent()) { value, at in
859
+ latestBloodOxygen = (value * 100.0, at)
860
+ }
861
+
862
+ group.notify(queue: .main) {
863
+ let sampleAt = [
864
+ latestHeartRate?.at,
865
+ latestRestingHeartRate?.at,
866
+ latestHrv?.at,
867
+ latestRespiratoryRate?.at,
868
+ latestBloodOxygen?.at,
869
+ ].compactMap { $0 }.sorted().last
870
+ let hasBiometrics =
871
+ latestHeartRate != nil ||
872
+ latestRestingHeartRate != nil ||
873
+ latestHrv != nil ||
874
+ latestRespiratoryRate != nil ||
875
+ latestBloodOxygen != nil
876
+ let sleep: [String: Any] = [
877
+ "available": false,
878
+ "isSleeping": false,
879
+ "asleepAt": NSNull(),
880
+ "awakeAt": NSNull(),
881
+ "durationMinutes": NSNull(),
882
+ "stage": NSNull(),
883
+ ]
884
+ let biometrics: [String: Any] = [
885
+ "sampleAt": sampleAt.map { Int64($0.timeIntervalSince1970 * 1000) } ?? NSNull(),
886
+ "heartRateBpm": latestHeartRate.map { Int64($0.value.rounded()) } ?? NSNull(),
887
+ "restingHeartRateBpm": latestRestingHeartRate.map { Int64($0.value.rounded()) } ?? NSNull(),
888
+ "heartRateVariabilityMs": latestHrv?.value ?? NSNull(),
889
+ "respiratoryRate": latestRespiratoryRate?.value ?? NSNull(),
890
+ "bloodOxygenPercent": latestBloodOxygen?.value ?? NSNull(),
891
+ ]
892
+
893
+ completion(
894
+ HealthCapture(
895
+ source: "healthkit",
896
+ screenTime: ScreenTimeSupport.buildStatus(),
897
+ permissions: [
898
+ "sleep": false,
899
+ "biometrics": hasBiometrics,
900
+ ],
901
+ sleep: sleep,
902
+ biometrics: biometrics,
903
+ warnings: []
904
+ ),
905
+ nil
906
+ )
907
+ }
908
+ }
909
+
910
+ private static func isSleepSample(_ value: Int) -> Bool {
911
+ value != HKCategoryValueSleepAnalysis.awake.rawValue &&
912
+ value != HKCategoryValueSleepAnalysis.inBed.rawValue
913
+ }
914
+
915
+ private static func latestSleepEpisode(from categories: [HKCategorySample]) -> SleepEpisode? {
916
+ let sleepSamples = categories
917
+ .filter { isSleepSample($0.value) }
918
+ .sorted { left, right in left.startDate < right.startDate }
919
+ guard let first = sleepSamples.first else {
920
+ return nil
921
+ }
922
+
923
+ let maxStageGap: TimeInterval = 90 * 60
924
+ var episodes: [SleepEpisode] = []
925
+ var episodeStart = first.startDate
926
+ var episodeEnd = first.endDate
927
+ var episodeDuration = first.endDate.timeIntervalSince(first.startDate) / 60.0
928
+ var latestStageValue = first.value
929
+
930
+ for sample in sleepSamples.dropFirst() {
931
+ if sample.startDate.timeIntervalSince(episodeEnd) <= maxStageGap {
932
+ episodeEnd = max(episodeEnd, sample.endDate)
933
+ episodeDuration += sample.endDate.timeIntervalSince(sample.startDate) / 60.0
934
+ latestStageValue = sample.value
935
+ continue
936
+ }
937
+ episodes.append(SleepEpisode(
938
+ startDate: episodeStart,
939
+ endDate: episodeEnd,
940
+ durationMinutes: episodeDuration,
941
+ latestStageValue: latestStageValue
942
+ ))
943
+ episodeStart = sample.startDate
944
+ episodeEnd = sample.endDate
945
+ episodeDuration = sample.endDate.timeIntervalSince(sample.startDate) / 60.0
946
+ latestStageValue = sample.value
947
+ }
948
+
949
+ episodes.append(SleepEpisode(
950
+ startDate: episodeStart,
951
+ endDate: episodeEnd,
952
+ durationMinutes: episodeDuration,
953
+ latestStageValue: latestStageValue
954
+ ))
955
+ return episodes.sorted { left, right in left.endDate < right.endDate }.last
956
+ }
957
+
958
+ private static func sleepStageName(for value: Int) -> String {
959
+ switch value {
960
+ case HKCategoryValueSleepAnalysis.awake.rawValue:
961
+ return "awake"
962
+ case HKCategoryValueSleepAnalysis.inBed.rawValue:
963
+ return "in_bed"
964
+ default:
965
+ return "asleep"
966
+ }
967
+ }
968
+ }