elizaos--eliza
426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
1135 行
43 KiB
Swift
1135 行
43 KiB
Swift
import Foundation
|
|
import Capacitor
|
|
import HealthKit
|
|
import UIKit
|
|
import UserNotifications
|
|
|
|
@objc(MobileSignalsPlugin)
|
|
public class MobileSignalsPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
public let identifier = "MobileSignalsPlugin"
|
|
public let jsName = "MobileSignals"
|
|
public let pluginMethods: [CAPPluginMethod] = [
|
|
CAPPluginMethod(name: "checkPermissions", returnType: CAPPluginReturnPromise),
|
|
CAPPluginMethod(name: "requestPermissions", returnType: CAPPluginReturnPromise),
|
|
CAPPluginMethod(name: "openSettings", returnType: CAPPluginReturnPromise),
|
|
CAPPluginMethod(name: "startMonitoring", returnType: CAPPluginReturnPromise),
|
|
CAPPluginMethod(name: "stopMonitoring", returnType: CAPPluginReturnPromise),
|
|
CAPPluginMethod(name: "getSnapshot", returnType: CAPPluginReturnPromise),
|
|
CAPPluginMethod(name: "scheduleBackgroundRefresh", returnType: CAPPluginReturnPromise),
|
|
CAPPluginMethod(name: "cancelBackgroundRefresh", returnType: CAPPluginReturnPromise),
|
|
]
|
|
|
|
private struct HealthCapture {
|
|
let source: String
|
|
let screenTime: [String: Any]
|
|
let permissions: [String: Bool]
|
|
let sleep: [String: Any]
|
|
let biometrics: [String: Any]
|
|
let warnings: [String]
|
|
}
|
|
|
|
private struct SleepEpisode {
|
|
let startDate: Date
|
|
let endDate: Date
|
|
let durationMinutes: Double
|
|
let latestStageValue: Int
|
|
}
|
|
|
|
private var monitoring = false
|
|
private var observers: [NSObjectProtocol] = []
|
|
private let healthStore = HKHealthStore()
|
|
private let healthQueue = DispatchQueue(label: "ai.eliza.mobile-signals.health", qos: .utility)
|
|
|
|
public override func load() {
|
|
UIDevice.current.isBatteryMonitoringEnabled = true
|
|
// Re-arm HealthKit background delivery on every cold boot. Apple's
|
|
// background-delivery registration does NOT persist across uninstalls
|
|
// and re-installations of the app, but it does persist across simple
|
|
// launches — the redundant call is cheap and ensures the foreground
|
|
// requestAuthorization → arm flow isn't the only path that turns it on.
|
|
enableHealthBackgroundDelivery()
|
|
}
|
|
|
|
@objc func scheduleBackgroundRefresh(_ call: CAPPluginCall) {
|
|
call.resolve([
|
|
"scheduled": false,
|
|
"reason": "iOS mobile signals use foreground monitoring; background scheduled work is routed through the eliza-tasks BackgroundRunner.",
|
|
])
|
|
}
|
|
|
|
@objc func cancelBackgroundRefresh(_ call: CAPPluginCall) {
|
|
call.resolve([
|
|
"cancelled": false,
|
|
"reason": "iOS mobile signals do not register a BGTaskScheduler background refresh task.",
|
|
])
|
|
}
|
|
|
|
deinit {
|
|
stopInternal()
|
|
UIDevice.current.isBatteryMonitoringEnabled = false
|
|
}
|
|
|
|
@objc func startMonitoring(_ call: CAPPluginCall) {
|
|
if monitoring {
|
|
call.resolve(buildStartResult())
|
|
return
|
|
}
|
|
|
|
monitoring = true
|
|
registerObservers()
|
|
call.resolve(buildStartResult())
|
|
|
|
if call.getBool("emitInitial") ?? true {
|
|
emitSignal(reason: "start")
|
|
emitHealthSignal(reason: "start")
|
|
}
|
|
}
|
|
|
|
@objc func stopMonitoring(_ call: CAPPluginCall) {
|
|
stopInternal()
|
|
call.resolve(["stopped": true])
|
|
}
|
|
|
|
@objc public override func checkPermissions(_ call: CAPPluginCall) {
|
|
buildPermissionResult { result in
|
|
call.resolve(result)
|
|
}
|
|
}
|
|
|
|
@objc public override func requestPermissions(_ call: CAPPluginCall) {
|
|
let target = call.getString("target") ?? "all"
|
|
if target == "screenTime" {
|
|
resolvePermissionAfterScreenTimeRequest(call)
|
|
return
|
|
}
|
|
if target == "notifications" {
|
|
requestNotificationPermissions(call)
|
|
return
|
|
}
|
|
|
|
let shouldRequestScreenTime = target != "health"
|
|
let types = requestedHealthTypes()
|
|
guard !types.isEmpty else {
|
|
resolvePermissionResult(
|
|
call,
|
|
status: "not-applicable",
|
|
canRequest: false,
|
|
reason: "HealthKit sleep and biometric types are unavailable on this device.",
|
|
requestScreenTime: shouldRequestScreenTime
|
|
)
|
|
return
|
|
}
|
|
|
|
healthStore.requestAuthorization(toShare: nil, read: Set(types)) { [weak self] success, error in
|
|
DispatchQueue.main.async {
|
|
guard let self = self else { return }
|
|
let healthReason = !success
|
|
? "HealthKit permission request failed: \(error?.localizedDescription ?? "unknown error")"
|
|
: nil
|
|
if success {
|
|
self.enableHealthBackgroundDelivery()
|
|
}
|
|
self.resolvePermissionResult(
|
|
call,
|
|
reason: healthReason,
|
|
requestScreenTime: shouldRequestScreenTime
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Turn on `HKHealthStore.enableBackgroundDelivery(for:frequency:)` for
|
|
/// the sleep + biometric sample types we already requested authorization
|
|
/// for. iOS will then wake the app — via the `com.apple.developer.healthkit.background-delivery`
|
|
/// entitlement that ships with `App.entitlements` — whenever HealthKit
|
|
/// has a new sample to deliver. The wake itself does not run our code:
|
|
/// it flips the WebView's app state to background-with-network-OK, which
|
|
/// is what the runtime's `HKObserverQuery` / next pull will pick up.
|
|
///
|
|
/// `.immediate` is the only honest choice for sleep + heart-rate signals;
|
|
/// HealthKit clamps observation cadence to whatever the underlying sensor
|
|
/// chose, so anything coarser would just delay the wake.
|
|
///
|
|
/// This method is intentionally fire-and-forget — a failure to enable
|
|
/// background delivery is not user-actionable; the foreground monitoring
|
|
/// path already works. We log and move on.
|
|
///
|
|
/// Entitlement probe: iOS exposes no public API to read the running
|
|
/// binary's code-signing entitlements, so the first sample type doubles as
|
|
/// the capability probe. When the binary is not signed with
|
|
/// `com.apple.developer.healthkit` (simulator lanes built with code
|
|
/// signing disabled, sideload/dev builds signed without the capability)
|
|
/// EVERY call fails identically with "Missing
|
|
/// com.apple.developer.healthkit entitlement" — so the probe failing that
|
|
/// way means the remaining registrations are skipped behind a single info
|
|
/// line instead of one warning per type.
|
|
private func enableHealthBackgroundDelivery() {
|
|
guard HKHealthStore.isHealthDataAvailable() else { return }
|
|
let sampleTypes = backgroundDeliverySampleTypes()
|
|
guard let probeType = sampleTypes.first else { return }
|
|
|
|
healthStore.enableBackgroundDelivery(
|
|
for: probeType,
|
|
frequency: .immediate
|
|
) { [weak self] success, error in
|
|
guard let self = self else { return }
|
|
let outcome = HealthBackgroundDeliveryGate.probeOutcome(
|
|
success: success,
|
|
errorMessage: error?.localizedDescription
|
|
)
|
|
switch outcome {
|
|
case .entitlementMissing:
|
|
NSLog(
|
|
"[MobileSignalsPlugin] HealthKit background delivery skipped: binary lacks the com.apple.developer.healthkit entitlement (expected for simulator and non-store dev builds); foreground health monitoring is unaffected"
|
|
)
|
|
return
|
|
case .probeFailed:
|
|
Self.logBackgroundDeliveryFailure(probeType, error)
|
|
case .succeeded:
|
|
break
|
|
}
|
|
for sampleType in sampleTypes.dropFirst() {
|
|
self.healthStore.enableBackgroundDelivery(
|
|
for: sampleType,
|
|
frequency: .immediate
|
|
) { ok, err in
|
|
if !ok {
|
|
Self.logBackgroundDeliveryFailure(sampleType, err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Sleep + biometric sample types eligible for background delivery, in
|
|
/// probe order (the first entry is the entitlement probe).
|
|
private func backgroundDeliverySampleTypes() -> [HKSampleType] {
|
|
var sampleTypes: [HKSampleType] = []
|
|
if let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) {
|
|
sampleTypes.append(sleepType)
|
|
}
|
|
for identifier in [
|
|
HKQuantityTypeIdentifier.heartRate,
|
|
HKQuantityTypeIdentifier.restingHeartRate,
|
|
HKQuantityTypeIdentifier.heartRateVariabilitySDNN,
|
|
HKQuantityTypeIdentifier.respiratoryRate,
|
|
HKQuantityTypeIdentifier.oxygenSaturation,
|
|
] {
|
|
if let qt = HKObjectType.quantityType(forIdentifier: identifier) {
|
|
sampleTypes.append(qt)
|
|
}
|
|
}
|
|
return sampleTypes
|
|
}
|
|
|
|
private static func logBackgroundDeliveryFailure(
|
|
_ sampleType: HKSampleType,
|
|
_ error: Error?
|
|
) {
|
|
NSLog(
|
|
"[MobileSignalsPlugin] enableBackgroundDelivery(%@) failed: %@",
|
|
sampleType.identifier,
|
|
error?.localizedDescription ?? "unknown"
|
|
)
|
|
}
|
|
|
|
private func requestNotificationPermissions(_ call: CAPPluginCall) {
|
|
readNotificationPermission { [weak self] notification in
|
|
guard let self = self else { return }
|
|
guard notification.canRequest else {
|
|
self.buildPermissionResult(reason: notification.reason) { result in
|
|
call.resolve(result)
|
|
}
|
|
return
|
|
}
|
|
|
|
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { [weak self] _, error in
|
|
DispatchQueue.main.async {
|
|
guard let self = self else { return }
|
|
self.buildPermissionResult(reason: error?.localizedDescription) { result in
|
|
call.resolve(result)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc func openSettings(_ call: CAPPluginCall) {
|
|
let target = call.getString("target") ?? "app"
|
|
let reason: String?
|
|
let actualTarget: String
|
|
let urlString: String
|
|
|
|
if target == "notification", #available(iOS 16.0, *) {
|
|
actualTarget = "notification"
|
|
urlString = UIApplication.openNotificationSettingsURLString
|
|
reason = nil
|
|
} else {
|
|
actualTarget = "app"
|
|
urlString = UIApplication.openSettingsURLString
|
|
reason = target == "app" || target == "health" || target == "localNetwork"
|
|
? nil
|
|
: "iOS only supports stable public deep links to this app's Settings screen."
|
|
}
|
|
|
|
guard let url = URL(string: urlString) else {
|
|
call.resolve([
|
|
"opened": false,
|
|
"target": target,
|
|
"actualTarget": actualTarget,
|
|
"reason": "Unable to build iOS settings URL.",
|
|
])
|
|
return
|
|
}
|
|
|
|
DispatchQueue.main.async {
|
|
UIApplication.shared.open(url, options: [:]) { opened in
|
|
let resolvedReason: Any
|
|
if opened {
|
|
if let reason {
|
|
resolvedReason = reason
|
|
} else {
|
|
resolvedReason = NSNull()
|
|
}
|
|
} else {
|
|
resolvedReason = "iOS declined to open Settings."
|
|
}
|
|
call.resolve([
|
|
"opened": opened,
|
|
"target": target,
|
|
"actualTarget": actualTarget,
|
|
"reason": resolvedReason,
|
|
])
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc func getSnapshot(_ call: CAPPluginCall) {
|
|
let device = buildSnapshot(reason: "snapshot")
|
|
buildHealthSnapshot(reason: "snapshot") { health in
|
|
call.resolve([
|
|
"supported": true,
|
|
"snapshot": device,
|
|
"healthSnapshot": health,
|
|
])
|
|
}
|
|
}
|
|
|
|
private func registerObservers() {
|
|
let center = NotificationCenter.default
|
|
let names: [Notification.Name] = [
|
|
UIApplication.didBecomeActiveNotification,
|
|
UIApplication.willResignActiveNotification,
|
|
UIApplication.didEnterBackgroundNotification,
|
|
UIApplication.willEnterForegroundNotification,
|
|
UIApplication.protectedDataDidBecomeAvailableNotification,
|
|
UIApplication.protectedDataWillBecomeUnavailableNotification,
|
|
Notification.Name.NSProcessInfoPowerStateDidChange,
|
|
UIDevice.batteryStateDidChangeNotification,
|
|
]
|
|
|
|
for name in names {
|
|
let observer = center.addObserver(
|
|
forName: name,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
self?.emitSignal(reason: name.rawValue)
|
|
if name == UIApplication.didBecomeActiveNotification ||
|
|
name == UIApplication.willEnterForegroundNotification ||
|
|
name == UIApplication.protectedDataDidBecomeAvailableNotification {
|
|
self?.emitHealthSignal(reason: name.rawValue)
|
|
}
|
|
}
|
|
observers.append(observer)
|
|
}
|
|
}
|
|
|
|
private func stopInternal() {
|
|
let center = NotificationCenter.default
|
|
for observer in observers {
|
|
center.removeObserver(observer)
|
|
}
|
|
observers.removeAll()
|
|
monitoring = false
|
|
}
|
|
|
|
private func buildStartResult() -> [String: Any] {
|
|
[
|
|
"enabled": monitoring,
|
|
"supported": true,
|
|
"platform": "ios",
|
|
"snapshot": buildSnapshot(reason: "start"),
|
|
"healthSnapshot": NSNull(),
|
|
]
|
|
}
|
|
|
|
private func requestedHealthTypes() -> [HKObjectType] {
|
|
var types: [HKObjectType] = []
|
|
if let sleepType = self.sleepHealthType() {
|
|
types.append(sleepType)
|
|
}
|
|
types.append(contentsOf: biometricHealthTypes())
|
|
return types
|
|
}
|
|
|
|
private func sleepHealthType() -> HKObjectType? {
|
|
HKObjectType.categoryType(forIdentifier: .sleepAnalysis)
|
|
}
|
|
|
|
private func biometricHealthTypes() -> [HKObjectType] {
|
|
let biometricIdentifiers: [HKQuantityTypeIdentifier] = [
|
|
.heartRate,
|
|
.restingHeartRate,
|
|
.heartRateVariabilitySDNN,
|
|
.respiratoryRate,
|
|
.oxygenSaturation,
|
|
]
|
|
return biometricIdentifiers.compactMap {
|
|
HKObjectType.quantityType(forIdentifier: $0)
|
|
}
|
|
}
|
|
|
|
private struct NotificationPermissionCapture {
|
|
let status: String
|
|
let canRequest: Bool
|
|
let reason: String?
|
|
}
|
|
|
|
private func readNotificationPermission(
|
|
completion: @escaping (NotificationPermissionCapture) -> Void
|
|
) {
|
|
UNUserNotificationCenter.current().getNotificationSettings { settings in
|
|
let capture: NotificationPermissionCapture
|
|
switch settings.authorizationStatus {
|
|
case .authorized, .provisional, .ephemeral:
|
|
capture = NotificationPermissionCapture(
|
|
status: "granted",
|
|
canRequest: false,
|
|
reason: nil
|
|
)
|
|
case .denied:
|
|
capture = NotificationPermissionCapture(
|
|
status: "denied",
|
|
canRequest: false,
|
|
reason: "Notifications are disabled for Eliza. Open Settings to enable reminders and prompts."
|
|
)
|
|
case .notDetermined:
|
|
capture = NotificationPermissionCapture(
|
|
status: "not-determined",
|
|
canRequest: true,
|
|
reason: "Allow notifications when LifeOps needs to remind or prompt you."
|
|
)
|
|
@unknown default:
|
|
capture = NotificationPermissionCapture(
|
|
status: "restricted",
|
|
canRequest: false,
|
|
reason: "iOS notification authorization is restricted by this device."
|
|
)
|
|
}
|
|
DispatchQueue.main.async {
|
|
completion(capture)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func buildPermissionResult(
|
|
status overrideStatus: String? = nil,
|
|
canRequest overrideCanRequest: Bool? = nil,
|
|
reason overrideReason: String? = nil,
|
|
completion: @escaping ([String: Any]) -> Void
|
|
) {
|
|
readNotificationPermission { [weak self] notification in
|
|
guard let self = self else { return }
|
|
completion(self.buildPermissionResultPayload(
|
|
status: overrideStatus,
|
|
canRequest: overrideCanRequest,
|
|
reason: overrideReason,
|
|
notification: notification
|
|
))
|
|
}
|
|
}
|
|
|
|
private func buildPermissionResultPayload(
|
|
status overrideStatus: String? = nil,
|
|
canRequest overrideCanRequest: Bool? = nil,
|
|
reason overrideReason: String? = nil,
|
|
notification: NotificationPermissionCapture
|
|
) -> [String: Any] {
|
|
let screenTimeStatus = ScreenTimeSupport.buildStatus()
|
|
guard HKHealthStore.isHealthDataAvailable() else {
|
|
return [
|
|
"status": overrideStatus ?? "not-applicable",
|
|
"canRequest": overrideCanRequest ?? false,
|
|
"canOpenSettings": true,
|
|
"settingsTarget": "app",
|
|
"engine": "healthkit-screen-time",
|
|
"capabilities": mobileSignalsCapabilities(),
|
|
"reason": overrideReason ?? "HealthKit is not available on this device.",
|
|
"permissions": [
|
|
"sleep": false,
|
|
"biometrics": false,
|
|
],
|
|
"screenTime": screenTimeStatus,
|
|
"setupActions": buildSetupActions(
|
|
healthStatus: overrideStatus ?? "not-applicable",
|
|
healthCanRequest: overrideCanRequest ?? false,
|
|
screenTimeStatus: screenTimeStatus,
|
|
notification: notification
|
|
),
|
|
]
|
|
}
|
|
|
|
let sleepType = sleepHealthType()
|
|
let biometricTypes = biometricHealthTypes()
|
|
let sleepGranted = sleepType.map { healthStore.authorizationStatus(for: $0) == .sharingAuthorized } ?? false
|
|
let biometricGranted = biometricTypes.isEmpty
|
|
? false
|
|
: biometricTypes.allSatisfy { healthStore.authorizationStatus(for: $0) == .sharingAuthorized }
|
|
let hasRequestedTypes = sleepType != nil || !biometricTypes.isEmpty
|
|
let hasDenied = (sleepType.map { healthStore.authorizationStatus(for: $0) == .sharingDenied } ?? false) ||
|
|
biometricTypes.contains { healthStore.authorizationStatus(for: $0) == .sharingDenied }
|
|
let hasPending = (sleepType.map { healthStore.authorizationStatus(for: $0) == .notDetermined } ?? false) ||
|
|
biometricTypes.contains { healthStore.authorizationStatus(for: $0) == .notDetermined }
|
|
let status = overrideStatus ?? {
|
|
if !hasRequestedTypes {
|
|
return "not-applicable"
|
|
}
|
|
if sleepGranted || biometricGranted {
|
|
return "granted"
|
|
}
|
|
if hasDenied {
|
|
return "denied"
|
|
}
|
|
if hasPending {
|
|
return "not-determined"
|
|
}
|
|
return "not-determined"
|
|
}()
|
|
let settingsTarget: Any = status == "granted" ? NSNull() : "health"
|
|
|
|
return [
|
|
"status": status,
|
|
"canRequest": overrideCanRequest ?? (status != "granted" && hasRequestedTypes),
|
|
"canOpenSettings": true,
|
|
"settingsTarget": settingsTarget,
|
|
"engine": "healthkit-screen-time",
|
|
"capabilities": mobileSignalsCapabilities(),
|
|
"reason": overrideReason ?? NSNull(),
|
|
"screenTime": screenTimeStatus,
|
|
"setupActions": buildSetupActions(
|
|
healthStatus: status,
|
|
healthCanRequest: overrideCanRequest ?? (status != "granted" && hasRequestedTypes),
|
|
screenTimeStatus: screenTimeStatus,
|
|
notification: notification
|
|
),
|
|
"permissions": [
|
|
"sleep": sleepGranted,
|
|
"biometrics": biometricGranted,
|
|
],
|
|
]
|
|
}
|
|
|
|
private func mobileSignalsCapabilities() -> [String: Any] {
|
|
[
|
|
"health": HKHealthStore.isHealthDataAvailable(),
|
|
"screenTime": true,
|
|
"notifications": true,
|
|
"settings": true,
|
|
]
|
|
}
|
|
|
|
private func resolvePermissionAfterScreenTimeRequest(
|
|
_ call: CAPPluginCall,
|
|
status: String? = nil,
|
|
canRequest: Bool? = nil,
|
|
reason: String? = nil
|
|
) {
|
|
ScreenTimeSupport.requestAuthorizationIfAvailable { [weak self] screenTimeReason in
|
|
guard let self = self else { return }
|
|
self.buildPermissionResult(
|
|
status: status,
|
|
canRequest: canRequest,
|
|
reason: reason
|
|
) { result in
|
|
var next = result
|
|
if let screenTimeReason {
|
|
if let existingReason = next["reason"] as? String, !existingReason.isEmpty {
|
|
next["reason"] = "\(existingReason) \(screenTimeReason)"
|
|
} else {
|
|
next["reason"] = screenTimeReason
|
|
}
|
|
}
|
|
call.resolve(next)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func resolvePermissionResult(
|
|
_ call: CAPPluginCall,
|
|
status: String? = nil,
|
|
canRequest: Bool? = nil,
|
|
reason: String? = nil,
|
|
requestScreenTime: Bool
|
|
) {
|
|
if requestScreenTime {
|
|
resolvePermissionAfterScreenTimeRequest(
|
|
call,
|
|
status: status,
|
|
canRequest: canRequest,
|
|
reason: reason
|
|
)
|
|
return
|
|
}
|
|
|
|
buildPermissionResult(
|
|
status: status,
|
|
canRequest: canRequest,
|
|
reason: reason
|
|
) { result in
|
|
call.resolve(result)
|
|
}
|
|
}
|
|
|
|
private func buildSetupActions(
|
|
healthStatus: String,
|
|
healthCanRequest: Bool,
|
|
screenTimeStatus: [String: Any],
|
|
notification: NotificationPermissionCapture
|
|
) -> [[String: Any]] {
|
|
let healthReady = healthStatus == "granted"
|
|
let authorization = screenTimeStatus["authorization"] as? [String: Any] ?? [:]
|
|
let screenTimeAuthStatus = authorization["status"] as? String ?? "unavailable"
|
|
let screenTimeCanRequest = authorization["canRequest"] as? Bool ?? false
|
|
let screenTimeSupported = screenTimeStatus["supported"] as? Bool ?? false
|
|
let screenTimeReady = screenTimeAuthStatus == "approved"
|
|
let screenTimeReason = screenTimeStatus["reason"] ?? NSNull()
|
|
let notificationsReady = notification.status == "granted"
|
|
|
|
return [
|
|
[
|
|
"id": "health_permissions",
|
|
"label": "HealthKit",
|
|
"status": healthReady
|
|
? "ready"
|
|
: (healthStatus == "not-applicable" ? "unavailable" : "needs-action"),
|
|
"canRequest": healthCanRequest,
|
|
"canOpenSettings": true,
|
|
"settingsTarget": "health",
|
|
"reason": healthReady
|
|
? NSNull()
|
|
: "Grant Health read access for sleep, heart rate, HRV, respiratory rate, and oxygen saturation.",
|
|
],
|
|
[
|
|
"id": "screen_time_authorization",
|
|
"label": "Screen Time",
|
|
"status": screenTimeReady
|
|
? "ready"
|
|
: (screenTimeSupported ? "needs-action" : "unavailable"),
|
|
"canRequest": screenTimeCanRequest,
|
|
"canOpenSettings": true,
|
|
"settingsTarget": "screenTime",
|
|
"reason": screenTimeReady ? NSNull() : screenTimeReason,
|
|
],
|
|
[
|
|
"id": "local_network",
|
|
"label": "Local Network",
|
|
"status": "needs-action",
|
|
"canRequest": false,
|
|
"canOpenSettings": true,
|
|
"settingsTarget": "localNetwork",
|
|
"reason": "Allow Local Network when this phone sends data to a Mac or LAN agent.",
|
|
],
|
|
[
|
|
"id": "notification_settings",
|
|
"label": "Notifications",
|
|
"status": notificationsReady ? "ready" : "needs-action",
|
|
"canRequest": notification.canRequest,
|
|
"canOpenSettings": true,
|
|
"settingsTarget": "notification",
|
|
"reason": notificationsReady ? NSNull() : (notification.reason ?? "Open notification settings if reminders or telemetry prompts are muted."),
|
|
],
|
|
]
|
|
}
|
|
|
|
private func buildSnapshot(reason: String) -> [String: Any] {
|
|
let app = UIApplication.shared
|
|
let protectedAvailable = app.isProtectedDataAvailable
|
|
let lowPower = ProcessInfo.processInfo.isLowPowerModeEnabled
|
|
let batteryState = UIDevice.current.batteryState
|
|
let batteryLevel = UIDevice.current.batteryLevel
|
|
let onBattery: Bool? = {
|
|
switch batteryState {
|
|
case .charging, .full:
|
|
return false
|
|
case .unplugged:
|
|
return true
|
|
case .unknown:
|
|
return nil
|
|
@unknown default:
|
|
return nil
|
|
}
|
|
}()
|
|
let state: String = {
|
|
if !protectedAvailable {
|
|
return "locked"
|
|
}
|
|
switch app.applicationState {
|
|
case .active:
|
|
return lowPower ? "idle" : "active"
|
|
case .inactive:
|
|
return "idle"
|
|
case .background:
|
|
return "background"
|
|
@unknown default:
|
|
return "background"
|
|
}
|
|
}()
|
|
let idleState: String = {
|
|
if !protectedAvailable {
|
|
return "locked"
|
|
}
|
|
if lowPower {
|
|
return "idle"
|
|
}
|
|
return state == "active" ? "active" : "idle"
|
|
}()
|
|
let level = batteryLevel >= 0 ? Double(batteryLevel) : nil
|
|
let onBatteryValue: Any = onBattery ?? NSNull()
|
|
let levelValue: Any = level ?? NSNull()
|
|
|
|
return [
|
|
"source": "mobile_device",
|
|
"platform": "ios",
|
|
"state": state,
|
|
"observedAt": Int64(Date().timeIntervalSince1970 * 1000),
|
|
"idleState": idleState,
|
|
"idleTimeSeconds": NSNull(),
|
|
"onBattery": onBatteryValue,
|
|
"metadata": [
|
|
"reason": reason,
|
|
"applicationState": app.applicationState.rawValue,
|
|
"isProtectedDataAvailable": protectedAvailable,
|
|
"isLowPowerModeEnabled": lowPower,
|
|
"batteryState": batteryState.rawValue,
|
|
"batteryLevel": levelValue,
|
|
],
|
|
]
|
|
}
|
|
|
|
private func emitSignal(reason: String) {
|
|
guard monitoring else { return }
|
|
notifyListeners("signal", data: buildSnapshot(reason: reason))
|
|
}
|
|
|
|
private func emitHealthSignal(reason: String) {
|
|
guard monitoring else { return }
|
|
buildHealthSnapshot(reason: reason) { [weak self] healthSnapshot in
|
|
guard let self = self, self.monitoring else { return }
|
|
self.notifyListeners("signal", data: healthSnapshot)
|
|
}
|
|
}
|
|
|
|
private func buildHealthSnapshot(
|
|
reason: String,
|
|
completion: @escaping ([String: Any]) -> Void
|
|
) {
|
|
guard HKHealthStore.isHealthDataAvailable() else {
|
|
completion(makeHealthSnapshot(
|
|
reason: reason,
|
|
capture: HealthCapture(
|
|
source: "healthkit",
|
|
screenTime: ScreenTimeSupport.buildStatus(),
|
|
permissions: ["sleep": false, "biometrics": false],
|
|
sleep: [
|
|
"available": false,
|
|
"isSleeping": false,
|
|
"asleepAt": NSNull(),
|
|
"awakeAt": NSNull(),
|
|
"durationMinutes": NSNull(),
|
|
"stage": NSNull(),
|
|
],
|
|
biometrics: [
|
|
"sampleAt": NSNull(),
|
|
"heartRateBpm": NSNull(),
|
|
"restingHeartRateBpm": NSNull(),
|
|
"heartRateVariabilityMs": NSNull(),
|
|
"respiratoryRate": NSNull(),
|
|
"bloodOxygenPercent": NSNull(),
|
|
],
|
|
warnings: ["HealthKit is not available on this device"]
|
|
)
|
|
))
|
|
return
|
|
}
|
|
|
|
healthQueue.async {
|
|
let group = DispatchGroup()
|
|
var sleepSummary: HealthCapture?
|
|
var biometricsSummary: HealthCapture?
|
|
var warnings: [String] = []
|
|
|
|
group.enter()
|
|
self.fetchSleepSummary { capture, fetchWarning in
|
|
sleepSummary = capture
|
|
if let fetchWarning {
|
|
warnings.append(fetchWarning)
|
|
}
|
|
group.leave()
|
|
}
|
|
|
|
group.enter()
|
|
self.fetchBiometrics { capture, fetchWarning in
|
|
biometricsSummary = capture
|
|
if let fetchWarning {
|
|
warnings.append(fetchWarning)
|
|
}
|
|
group.leave()
|
|
}
|
|
|
|
group.notify(queue: .main) {
|
|
let capture = HealthCapture(
|
|
source: "healthkit",
|
|
screenTime: ScreenTimeSupport.buildStatus(),
|
|
permissions: [
|
|
"sleep": sleepSummary?.permissions["sleep"] ?? false,
|
|
"biometrics": biometricsSummary?.permissions["biometrics"] ?? false,
|
|
],
|
|
sleep: sleepSummary?.sleep ?? [
|
|
"available": false,
|
|
"isSleeping": false,
|
|
"asleepAt": NSNull(),
|
|
"awakeAt": NSNull(),
|
|
"durationMinutes": NSNull(),
|
|
"stage": NSNull(),
|
|
],
|
|
biometrics: biometricsSummary?.biometrics ?? [
|
|
"sampleAt": NSNull(),
|
|
"heartRateBpm": NSNull(),
|
|
"restingHeartRateBpm": NSNull(),
|
|
"heartRateVariabilityMs": NSNull(),
|
|
"respiratoryRate": NSNull(),
|
|
"bloodOxygenPercent": NSNull(),
|
|
],
|
|
warnings: warnings
|
|
)
|
|
completion(
|
|
self.makeHealthSnapshot(
|
|
reason: reason,
|
|
capture: capture
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func makeHealthSnapshot(
|
|
reason: String,
|
|
capture: HealthCapture
|
|
) -> [String: Any] {
|
|
let deviceBatteryState = UIDevice.current.batteryState
|
|
let onBattery: Bool? = {
|
|
switch deviceBatteryState {
|
|
case .charging, .full:
|
|
return false
|
|
case .unplugged:
|
|
return true
|
|
case .unknown:
|
|
return nil
|
|
@unknown default:
|
|
return nil
|
|
}
|
|
}()
|
|
let state = (capture.sleep["isSleeping"] as? Bool) == true ? "sleeping" : "idle"
|
|
return [
|
|
"source": "mobile_health",
|
|
"platform": "ios",
|
|
"state": state,
|
|
"observedAt": Int64(Date().timeIntervalSince1970 * 1000),
|
|
"idleState": NSNull(),
|
|
"idleTimeSeconds": NSNull(),
|
|
"onBattery": onBattery ?? NSNull(),
|
|
"healthSource": capture.source,
|
|
"screenTime": capture.screenTime,
|
|
"permissions": capture.permissions,
|
|
"sleep": capture.sleep,
|
|
"biometrics": capture.biometrics,
|
|
"warnings": capture.warnings,
|
|
"metadata": [
|
|
"reason": reason,
|
|
"healthSource": capture.source,
|
|
"deviceState": UIApplication.shared.applicationState.rawValue,
|
|
],
|
|
]
|
|
}
|
|
|
|
private func fetchSleepSummary(
|
|
completion: @escaping (HealthCapture?, String?) -> Void
|
|
) {
|
|
guard let sampleType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) else {
|
|
completion(nil, "Sleep analysis type unavailable")
|
|
return
|
|
}
|
|
|
|
let startDate = Calendar.current.date(byAdding: .day, value: -7, to: Date()) ?? Date().addingTimeInterval(-7 * 24 * 60 * 60)
|
|
let predicate = HKQuery.predicateForSamples(
|
|
withStart: startDate,
|
|
end: nil,
|
|
options: .strictStartDate
|
|
)
|
|
let sortDescriptors = [
|
|
NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
|
|
]
|
|
|
|
let query = HKSampleQuery(
|
|
sampleType: sampleType,
|
|
predicate: predicate,
|
|
limit: HKObjectQueryNoLimit,
|
|
sortDescriptors: sortDescriptors
|
|
) { _, samples, error in
|
|
guard error == nil else {
|
|
completion(nil, "Sleep analysis query failed")
|
|
return
|
|
}
|
|
let categories = (samples as? [HKCategorySample]) ?? []
|
|
guard !categories.isEmpty else {
|
|
completion(
|
|
HealthCapture(
|
|
source: "healthkit",
|
|
screenTime: ScreenTimeSupport.buildStatus(),
|
|
permissions: ["sleep": false, "biometrics": false],
|
|
sleep: [
|
|
"available": false,
|
|
"isSleeping": false,
|
|
"asleepAt": NSNull(),
|
|
"awakeAt": NSNull(),
|
|
"durationMinutes": NSNull(),
|
|
"stage": NSNull(),
|
|
],
|
|
biometrics: [
|
|
"sampleAt": NSNull(),
|
|
"heartRateBpm": NSNull(),
|
|
"restingHeartRateBpm": NSNull(),
|
|
"heartRateVariabilityMs": NSNull(),
|
|
"respiratoryRate": NSNull(),
|
|
"bloodOxygenPercent": NSNull(),
|
|
],
|
|
warnings: []
|
|
),
|
|
nil
|
|
)
|
|
return
|
|
}
|
|
|
|
let latestEpisode = Self.latestSleepEpisode(from: categories)
|
|
let latestAwake = categories.last(where: { $0.value == HKCategoryValueSleepAnalysis.awake.rawValue })
|
|
let now = Date()
|
|
let sleepFreshnessWindow: TimeInterval = 15 * 60
|
|
let isSleeping =
|
|
latestEpisode != nil &&
|
|
latestEpisode!.endDate >= now.addingTimeInterval(-sleepFreshnessWindow) &&
|
|
(latestAwake == nil || latestAwake!.endDate <= latestEpisode!.endDate)
|
|
let asleepAt = latestEpisode?.startDate
|
|
let awakeAt = isSleeping ? nil : latestEpisode?.endDate
|
|
let durationMinutes = latestEpisode?.durationMinutes
|
|
let stage = latestEpisode.map { episode in
|
|
isSleeping ? Self.sleepStageName(for: episode.latestStageValue) : "awake"
|
|
} ?? "awake"
|
|
completion(
|
|
HealthCapture(
|
|
source: "healthkit",
|
|
screenTime: ScreenTimeSupport.buildStatus(),
|
|
permissions: ["sleep": true, "biometrics": false],
|
|
sleep: [
|
|
"available": true,
|
|
"isSleeping": isSleeping,
|
|
"asleepAt": asleepAt.map { Int64($0.timeIntervalSince1970 * 1000) } ?? NSNull(),
|
|
"awakeAt": awakeAt.map { Int64($0.timeIntervalSince1970 * 1000) } ?? NSNull(),
|
|
"durationMinutes": durationMinutes.map { Int64($0.rounded()) } ?? NSNull(),
|
|
"stage": stage,
|
|
],
|
|
biometrics: [
|
|
"sampleAt": NSNull(),
|
|
"heartRateBpm": NSNull(),
|
|
"restingHeartRateBpm": NSNull(),
|
|
"heartRateVariabilityMs": NSNull(),
|
|
"respiratoryRate": NSNull(),
|
|
"bloodOxygenPercent": NSNull(),
|
|
],
|
|
warnings: []
|
|
),
|
|
nil
|
|
)
|
|
}
|
|
healthStore.execute(query)
|
|
}
|
|
|
|
private func fetchBiometrics(
|
|
completion: @escaping (HealthCapture?, String?) -> Void
|
|
) {
|
|
let startDate = Calendar.current.date(byAdding: .day, value: -7, to: Date()) ?? Date().addingTimeInterval(-7 * 24 * 60 * 60)
|
|
let endDate = Date()
|
|
let predicate = HKQuery.predicateForSamples(
|
|
withStart: startDate,
|
|
end: endDate,
|
|
options: .strictStartDate
|
|
)
|
|
|
|
let group = DispatchGroup()
|
|
var latestHeartRate: (value: Double, at: Date)?
|
|
var latestRestingHeartRate: (value: Double, at: Date)?
|
|
var latestHrv: (value: Double, at: Date)?
|
|
var latestRespiratoryRate: (value: Double, at: Date)?
|
|
var latestBloodOxygen: (value: Double, at: Date)?
|
|
|
|
func fetchLatest(
|
|
identifier: HKQuantityTypeIdentifier,
|
|
unit: HKUnit,
|
|
assign: @escaping (Double, Date) -> Void
|
|
) {
|
|
guard let sampleType = HKObjectType.quantityType(forIdentifier: identifier) else {
|
|
return
|
|
}
|
|
group.enter()
|
|
let query = HKSampleQuery(
|
|
sampleType: sampleType,
|
|
predicate: predicate,
|
|
limit: 1,
|
|
sortDescriptors: [
|
|
NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)
|
|
]
|
|
) { _, samples, error in
|
|
defer { group.leave() }
|
|
guard error == nil,
|
|
let sample = samples?.first as? HKQuantitySample else {
|
|
return
|
|
}
|
|
assign(sample.quantity.doubleValue(for: unit), sample.startDate)
|
|
}
|
|
healthStore.execute(query)
|
|
}
|
|
|
|
fetchLatest(identifier: .heartRate, unit: HKUnit(from: "count/min")) { value, at in
|
|
latestHeartRate = (value, at)
|
|
}
|
|
fetchLatest(identifier: .restingHeartRate, unit: HKUnit(from: "count/min")) { value, at in
|
|
latestRestingHeartRate = (value, at)
|
|
}
|
|
fetchLatest(identifier: .heartRateVariabilitySDNN, unit: HKUnit.secondUnit(with: .milli)) { value, at in
|
|
latestHrv = (value, at)
|
|
}
|
|
fetchLatest(identifier: .respiratoryRate, unit: HKUnit(from: "count/min")) { value, at in
|
|
latestRespiratoryRate = (value, at)
|
|
}
|
|
fetchLatest(identifier: .oxygenSaturation, unit: HKUnit.percent()) { value, at in
|
|
latestBloodOxygen = (value * 100.0, at)
|
|
}
|
|
|
|
group.notify(queue: .main) {
|
|
let sampleAt = [
|
|
latestHeartRate?.at,
|
|
latestRestingHeartRate?.at,
|
|
latestHrv?.at,
|
|
latestRespiratoryRate?.at,
|
|
latestBloodOxygen?.at,
|
|
].compactMap { $0 }.sorted().last
|
|
let hasBiometrics =
|
|
latestHeartRate != nil ||
|
|
latestRestingHeartRate != nil ||
|
|
latestHrv != nil ||
|
|
latestRespiratoryRate != nil ||
|
|
latestBloodOxygen != nil
|
|
let sleep: [String: Any] = [
|
|
"available": false,
|
|
"isSleeping": false,
|
|
"asleepAt": NSNull(),
|
|
"awakeAt": NSNull(),
|
|
"durationMinutes": NSNull(),
|
|
"stage": NSNull(),
|
|
]
|
|
let biometrics: [String: Any] = [
|
|
"sampleAt": sampleAt.map { Int64($0.timeIntervalSince1970 * 1000) } ?? NSNull(),
|
|
"heartRateBpm": latestHeartRate.map { Int64($0.value.rounded()) } ?? NSNull(),
|
|
"restingHeartRateBpm": latestRestingHeartRate.map { Int64($0.value.rounded()) } ?? NSNull(),
|
|
"heartRateVariabilityMs": latestHrv?.value ?? NSNull(),
|
|
"respiratoryRate": latestRespiratoryRate?.value ?? NSNull(),
|
|
"bloodOxygenPercent": latestBloodOxygen?.value ?? NSNull(),
|
|
]
|
|
|
|
completion(
|
|
HealthCapture(
|
|
source: "healthkit",
|
|
screenTime: ScreenTimeSupport.buildStatus(),
|
|
permissions: [
|
|
"sleep": false,
|
|
"biometrics": hasBiometrics,
|
|
],
|
|
sleep: sleep,
|
|
biometrics: biometrics,
|
|
warnings: []
|
|
),
|
|
nil
|
|
)
|
|
}
|
|
}
|
|
|
|
private static func isSleepSample(_ value: Int) -> Bool {
|
|
value != HKCategoryValueSleepAnalysis.awake.rawValue &&
|
|
value != HKCategoryValueSleepAnalysis.inBed.rawValue
|
|
}
|
|
|
|
private static func latestSleepEpisode(from categories: [HKCategorySample]) -> SleepEpisode? {
|
|
let sleepSamples = categories
|
|
.filter { isSleepSample($0.value) }
|
|
.sorted { left, right in left.startDate < right.startDate }
|
|
guard let first = sleepSamples.first else {
|
|
return nil
|
|
}
|
|
|
|
let maxStageGap: TimeInterval = 90 * 60
|
|
var episodes: [SleepEpisode] = []
|
|
var episodeStart = first.startDate
|
|
var episodeEnd = first.endDate
|
|
var episodeDuration = first.endDate.timeIntervalSince(first.startDate) / 60.0
|
|
var latestStageValue = first.value
|
|
|
|
for sample in sleepSamples.dropFirst() {
|
|
if sample.startDate.timeIntervalSince(episodeEnd) <= maxStageGap {
|
|
episodeEnd = max(episodeEnd, sample.endDate)
|
|
episodeDuration += sample.endDate.timeIntervalSince(sample.startDate) / 60.0
|
|
latestStageValue = sample.value
|
|
continue
|
|
}
|
|
episodes.append(SleepEpisode(
|
|
startDate: episodeStart,
|
|
endDate: episodeEnd,
|
|
durationMinutes: episodeDuration,
|
|
latestStageValue: latestStageValue
|
|
))
|
|
episodeStart = sample.startDate
|
|
episodeEnd = sample.endDate
|
|
episodeDuration = sample.endDate.timeIntervalSince(sample.startDate) / 60.0
|
|
latestStageValue = sample.value
|
|
}
|
|
|
|
episodes.append(SleepEpisode(
|
|
startDate: episodeStart,
|
|
endDate: episodeEnd,
|
|
durationMinutes: episodeDuration,
|
|
latestStageValue: latestStageValue
|
|
))
|
|
return episodes.sorted { left, right in left.endDate < right.endDate }.last
|
|
}
|
|
|
|
private static func sleepStageName(for value: Int) -> String {
|
|
switch value {
|
|
case HKCategoryValueSleepAnalysis.awake.rawValue:
|
|
return "awake"
|
|
case HKCategoryValueSleepAnalysis.inBed.rawValue:
|
|
return "in_bed"
|
|
default:
|
|
return "asleep"
|
|
}
|
|
}
|
|
}
|