@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.
- package/ElizaosCapacitorMobileSignals.podspec +18 -0
- package/android/build.gradle +53 -0
- package/android/src/main/AndroidManifest.xml +8 -0
- package/android/src/main/java/ai/eliza/plugins/mobilesignals/MobileSignalsPlugin.kt +857 -0
- package/dist/esm/definitions.d.ts +162 -0
- package/dist/esm/definitions.d.ts.map +1 -0
- package/dist/esm/definitions.js +1 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +6 -0
- package/dist/esm/web.d.ts +29 -0
- package/dist/esm/web.d.ts.map +1 -0
- package/dist/esm/web.js +272 -0
- package/dist/esm/web.test.d.ts +2 -0
- package/dist/esm/web.test.d.ts.map +1 -0
- package/dist/esm/web.test.js +75 -0
- package/dist/plugin.cjs.js +285 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +288 -0
- package/dist/plugin.js.map +1 -0
- package/ios/Sources/MobileSignalsPlugin/MobileSignalsPlugin.swift +968 -0
- package/ios/Sources/MobileSignalsPlugin/ScreenTimeSupport.swift +188 -0
- package/package.json +84 -0
- package/scripts/validate-ios-screen-time.mjs +320 -0
|
@@ -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
|
+
}
|