From 8114cf226fce03b2a9cff7ad28cb56f5697fe725 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 22 Oct 2026 10:22:02 +0000 Subject: [PATCH 1/8] =?UTF-8?q?=E2=9C=A8=20Read=20every=20HealthKit=20type?= =?UTF-8?q?=20the=20SDK=20declares?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generates the type table from the iOS SDK's HealthKit headers (scripts/gen_healthkit_catalog.py) instead of a hand-picked list: every quantity and category type with its canonical unit, the characteristics, and every HealthKit enum so values serialize by name. CI regenerates it against the runner's SDK before building the IPA. Adds the sample classes that need their own shape: workouts with events, activities, all statistics and links to their routes and heart-rate samples; workout routes with GPS locations; heartbeat series; ECGs with voltages; audiograms; state of mind; GAD-7/PHQ-9; vision prescriptions; iOS 26 medication dose events; blood pressure and food correlations. Characteristics, daily activity summaries and the medication list sync as snapshots that resend only what changed. Every sample now carries its source revision, device and full metadata. The read set is probed before requesting authorization so a type the OS refuses is logged and skipped instead of crashing the app, and one observer query covers all enabled types. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 31 +- .github/workflows/ipa.yml | 25 +- Pulso.xcodeproj/project.pbxproj | 34 ++ Pulso/App/AppServices.swift | 39 +- Pulso/Health/AnchorStore.swift | 6 + Pulso/Health/HealthAuthorization.swift | 89 +++ Pulso/Health/HealthKitCatalog.generated.swift | 340 ++++++++++++ Pulso/Health/HealthQueries.swift | 268 ++++++++++ Pulso/Health/Serializers.swift | 464 ++++++++++++++-- Pulso/Health/Snapshots.swift | 171 ++++++ Pulso/Health/TypeRegistry.swift | 506 +++++++++++------- Pulso/Health/WorkoutActivityName.swift | 91 +--- Pulso/Model/AppSettings.swift | 4 + Pulso/Model/SampleDTO.swift | 51 +- Pulso/Pulso-Bridging-Header.h | 1 + Pulso/Support/ObjC.swift | 18 + Pulso/Support/ObjCExceptions.h | 10 + Pulso/Support/ObjCExceptions.m | 10 + Pulso/Sync/SyncEngine.swift | 93 +++- Pulso/Sync/TriggerHub.swift | 65 ++- Pulso/UI/SettingsView.swift | 72 ++- Pulso/UI/StatusView.swift | 64 ++- PulsoTests/HealthKitCatalogTests.swift | 102 ++++ PulsoTests/SerializerTests.swift | 73 +-- scripts/gen_healthkit_catalog.py | 249 +++++++++ 25 files changed, 2411 insertions(+), 465 deletions(-) create mode 100644 Pulso/Health/HealthAuthorization.swift create mode 100644 Pulso/Health/HealthKitCatalog.generated.swift create mode 100644 Pulso/Health/HealthQueries.swift create mode 100644 Pulso/Health/Snapshots.swift create mode 100644 Pulso/Pulso-Bridging-Header.h create mode 100644 Pulso/Support/ObjC.swift create mode 100644 Pulso/Support/ObjCExceptions.h create mode 100644 Pulso/Support/ObjCExceptions.m create mode 100644 PulsoTests/HealthKitCatalogTests.swift create mode 100644 scripts/gen_healthkit_catalog.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3330f5b..d795361 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,9 +35,14 @@ jobs: app: name: App build & unit tests - runs-on: macos-15 + runs-on: macos-26 steps: - uses: actions/checkout@v4 + - name: Select the newest stable Xcode + run: | + XCODE=$(ls -d /Applications/Xcode_*.app | grep -viE 'beta|_rc|release_candidate' | sort -V | tail -1) + sudo xcode-select -s "$XCODE" + xcodebuild -version - name: Pick a simulator id: sim run: | @@ -49,10 +54,30 @@ jobs: print(sorted(names)[-1])') echo "name=$NAME" >> "$GITHUB_OUTPUT" echo "Using simulator: $NAME" + # Signed ad-hoc ("Sign to Run Locally") rather than unsigned, so the + # test host carries the HealthKit entitlement and the read-set probe + # exercises HealthKit's real validation. - name: Build and test + env: + SIMULATOR: ${{ steps.sim.outputs.name }} run: | + status=0 xcodebuild test \ -project Pulso.xcodeproj \ -scheme Pulso \ - -destination "platform=iOS Simulator,name=${{ steps.sim.outputs.name }}" \ - CODE_SIGNING_ALLOWED=NO + -destination "platform=iOS Simulator,name=$SIMULATOR" \ + CODE_SIGN_IDENTITY=- \ + CODE_SIGN_STYLE=Manual \ + DEVELOPMENT_TEAM= \ + > test.log 2>&1 || status=$? + grep -E '(error:|PULSO-|Test Case .* failed|Executed [0-9]+ tests|\*\* TEST)' test.log | head -200 || true + if [ "$status" -ne 0 ]; then + tail -80 test.log + exit "$status" + fi + - uses: actions/upload-artifact@v4 + if: always() + with: + name: app-test-log + path: test.log + if-no-files-found: ignore diff --git a/.github/workflows/ipa.yml b/.github/workflows/ipa.yml index 41f2dea..17a20d6 100644 --- a/.github/workflows/ipa.yml +++ b/.github/workflows/ipa.yml @@ -54,6 +54,23 @@ jobs: echo "BUILD_NUMBER=$GITHUB_RUN_NUMBER" >> "$GITHUB_ENV" echo "Version $MARKETING ($GITHUB_RUN_NUMBER), bundle id $BUNDLE_ID" + # The catalog of HealthKit types is generated from the SDK headers, so + # the IPA covers whatever this SDK declares. A stale committed copy + # only produces a warning: regenerate it with the same script. + - name: Generate the HealthKit type catalog from this SDK + run: | + SDK=$(xcrun --sdk iphoneos --show-sdk-path) + mkdir -p sdk-headers + cp -R "$SDK/System/Library/Frameworks/HealthKit.framework/Headers" sdk-headers/HealthKit + xcrun --sdk iphoneos --show-sdk-version > sdk-headers/SDK_VERSION + xcodebuild -version > sdk-headers/XCODE_VERSION + python3 scripts/gen_healthkit_catalog.py sdk-headers/HealthKit Pulso/Health/HealthKitCatalog.generated.swift + if ! git diff --quiet -- Pulso/Health/HealthKitCatalog.generated.swift; then + echo "::warning::HealthKitCatalog.generated.swift is stale for iOS SDK $(cat sdk-headers/SDK_VERSION); this build uses the regenerated copy (uploaded as an artifact)." + git diff --stat -- Pulso/Health/HealthKitCatalog.generated.swift + fi + cp Pulso/Health/HealthKitCatalog.generated.swift sdk-headers/ + - name: Build for device without signing run: | if ! xcodebuild build \ @@ -100,14 +117,6 @@ jobs: unzip -l "dist/$NAME" | tail -3 echo "IPA_NAME=$NAME" >> "$GITHUB_ENV" - - name: Dump the SDK's HealthKit headers - run: | - SDK=$(xcrun --sdk iphoneos --show-sdk-path) - mkdir -p sdk-headers - cp -R "$SDK/System/Library/Frameworks/HealthKit.framework/Headers" sdk-headers/HealthKit - xcrun --sdk iphoneos --show-sdk-version > sdk-headers/SDK_VERSION - xcodebuild -version > sdk-headers/XCODE_VERSION - - uses: actions/upload-artifact@v4 with: name: Pulso-unsigned-ipa diff --git a/Pulso.xcodeproj/project.pbxproj b/Pulso.xcodeproj/project.pbxproj index 32dc5e7..09add4b 100644 --- a/Pulso.xcodeproj/project.pbxproj +++ b/Pulso.xcodeproj/project.pbxproj @@ -36,6 +36,13 @@ B1000000000000000000001F /* AnchorStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000000000000000000001F /* AnchorStoreTests.swift */; }; B10000000000000000000020 /* WireFormatTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F10000000000000000000020 /* WireFormatTests.swift */; }; B10000000000000000000021 /* TestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = F10000000000000000000021 /* TestSupport.swift */; }; + B20000000000000000000001 /* HealthKitCatalog.generated.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000000000000000000001 /* HealthKitCatalog.generated.swift */; }; + B20000000000000000000002 /* HealthQueries.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000000000000000000002 /* HealthQueries.swift */; }; + B20000000000000000000003 /* Snapshots.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000000000000000000003 /* Snapshots.swift */; }; + B20000000000000000000004 /* HealthAuthorization.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000000000000000000004 /* HealthAuthorization.swift */; }; + B20000000000000000000005 /* ObjC.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000000000000000000005 /* ObjC.swift */; }; + B20000000000000000000006 /* ObjCExceptions.m in Sources */ = {isa = PBXBuildFile; fileRef = F20000000000000000000006 /* ObjCExceptions.m */; }; + B20000000000000000000007 /* HealthKitCatalogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000000000000000000009 /* HealthKitCatalogTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -82,6 +89,15 @@ F1000000000000000000001F /* AnchorStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnchorStoreTests.swift; sourceTree = ""; }; F10000000000000000000020 /* WireFormatTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WireFormatTests.swift; sourceTree = ""; }; F10000000000000000000021 /* TestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestSupport.swift; sourceTree = ""; }; + F20000000000000000000001 /* HealthKitCatalog.generated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitCatalog.generated.swift; sourceTree = ""; }; + F20000000000000000000002 /* HealthQueries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthQueries.swift; sourceTree = ""; }; + F20000000000000000000003 /* Snapshots.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Snapshots.swift; sourceTree = ""; }; + F20000000000000000000004 /* HealthAuthorization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthAuthorization.swift; sourceTree = ""; }; + F20000000000000000000005 /* ObjC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObjC.swift; sourceTree = ""; }; + F20000000000000000000006 /* ObjCExceptions.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ObjCExceptions.m; sourceTree = ""; }; + F20000000000000000000007 /* ObjCExceptions.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ObjCExceptions.h; sourceTree = ""; }; + F20000000000000000000008 /* Pulso-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Pulso-Bridging-Header.h; sourceTree = ""; }; + F20000000000000000000009 /* HealthKitCatalogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitCatalogTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -114,6 +130,7 @@ A10000000000000000000002 /* Pulso */ = { isa = PBXGroup; children = ( + F20000000000000000000008 /* Pulso-Bridging-Header.h */, A10000000000000000000003 /* App */, A10000000000000000000004 /* Model */, A10000000000000000000005 /* Health */, @@ -150,6 +167,10 @@ A10000000000000000000005 /* Health */ = { isa = PBXGroup; children = ( + F20000000000000000000004 /* HealthAuthorization.swift */, + F20000000000000000000003 /* Snapshots.swift */, + F20000000000000000000002 /* HealthQueries.swift */, + F20000000000000000000001 /* HealthKitCatalog.generated.swift */, F10000000000000000000007 /* TypeRegistry.swift */, F10000000000000000000008 /* Serializers.swift */, F10000000000000000000009 /* WorkoutActivityName.swift */, @@ -172,6 +193,9 @@ A10000000000000000000007 /* Support */ = { isa = PBXGroup; children = ( + F20000000000000000000007 /* ObjCExceptions.h */, + F20000000000000000000006 /* ObjCExceptions.m */, + F20000000000000000000005 /* ObjC.swift */, F1000000000000000000000F /* Gzip.swift */, F10000000000000000000010 /* Keychain.swift */, F10000000000000000000011 /* LogStore.swift */, @@ -194,6 +218,7 @@ A10000000000000000000009 /* PulsoTests */ = { isa = PBXGroup; children = ( + F20000000000000000000009 /* HealthKitCatalogTests.swift */, F1000000000000000000001C /* SerializerTests.swift */, F1000000000000000000001D /* OutboxTests.swift */, F1000000000000000000001E /* GzipTests.swift */, @@ -304,6 +329,12 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + B20000000000000000000006 /* ObjCExceptions.m in Sources */, + B20000000000000000000005 /* ObjC.swift in Sources */, + B20000000000000000000004 /* HealthAuthorization.swift in Sources */, + B20000000000000000000003 /* Snapshots.swift in Sources */, + B20000000000000000000002 /* HealthQueries.swift in Sources */, + B20000000000000000000001 /* HealthKitCatalog.generated.swift in Sources */, B10000000000000000000001 /* PulsoApp.swift in Sources */, B10000000000000000000002 /* AppServices.swift in Sources */, B10000000000000000000003 /* SampleDTO.swift in Sources */, @@ -333,6 +364,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + B20000000000000000000007 /* HealthKitCatalogTests.swift in Sources */, B1000000000000000000001C /* SerializerTests.swift in Sources */, B1000000000000000000001D /* OutboxTests.swift in Sources */, B1000000000000000000001E /* GzipTests.swift in Sources */, @@ -478,6 +510,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Pulso/Pulso.entitlements; + SWIFT_OBJC_BRIDGING_HEADER = "Pulso/Pulso-Bridging-Header.h"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; @@ -505,6 +538,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Pulso/Pulso.entitlements; + SWIFT_OBJC_BRIDGING_HEADER = "Pulso/Pulso-Bridging-Header.h"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; diff --git a/Pulso/App/AppServices.swift b/Pulso/App/AppServices.swift index 3ba0d0f..f4784e6 100644 --- a/Pulso/App/AppServices.swift +++ b/Pulso/App/AppServices.swift @@ -74,8 +74,8 @@ final class AppServices { hub.registerBackgroundRefresh() hub.scheduleNextRefresh() Task { - await hub.startObservers() await refreshAuthStatus() + await hub.startObservers() await refreshDerived() } } @@ -103,9 +103,23 @@ final class AppServices { await refreshDerived() } + /// The read set this OS accepts, probed once per launch (cheap, no UI). + private lazy var readSet: HealthAuthorization.ReadSet = { + let set = HealthAuthorization.readSet(TypeRegistry.readTypes, store: healthStore) + let log = log + let rejected = set.rejected + Task { + await log.info("Health read set: \(set.accepted.count) type(s) accepted") + for (identifier, reason) in rejected.sorted(by: { $0.key < $1.key }) { + await log.warn("HealthKit refuses read access to \(identifier) on this iOS — skipped (\(reason.prefix(160)))") + } + } + return set + }() + func requestHealthAccess() async { do { - try await healthStore.requestAuthorization(toShare: [], read: TypeRegistry.readTypes) + try await HealthAuthorization.request(readSet.accepted, store: healthStore) } catch { await log.error("authorization request failed: \(error.localizedDescription)") } @@ -113,10 +127,23 @@ final class AppServices { await syncNow(.manual) } + /// Vision prescriptions and medications are shared object by object: + /// HealthKit shows its own picker, which needs a user tap to open. + func requestPerObjectAccess(_ key: String) async { + guard let entry = TypeRegistry.perObjectTypes.first(where: { $0.key == key }) else { return } + do { + try await healthStore.requestPerObjectReadAuthorization(for: entry.type, predicate: nil) + await log.info("\(key): per-object access updated") + } catch { + await log.error("\(key): per-object access failed: \(error.localizedDescription)") + } + await syncNow(.manual) + } + func refreshAuthStatus() async { guard !status.healthUnavailable else { return } do { - let request = try await healthStore.statusForAuthorizationRequest(toShare: [], read: TypeRegistry.readTypes) + let request = try await HealthAuthorization.requestStatus(readSet.accepted, store: healthStore) status.authNeeded = request == .shouldRequest } catch { // Typically a signing problem (missing HealthKit entitlement) — @@ -128,7 +155,11 @@ final class AppServices { } func refreshDerived() async { - status.needsInitialImport = AppSettings.currentEnabledTypes().contains { !anchors.hasAnchor($0.key) } + let refused = HealthAuthorization.lastRejected() + let pendingKeys = AppSettings.currentEnabledTypes() + .filter { !refused.contains($0.sampleType.identifier) } + .map(\.key) + AppSettings.currentEnabledSnapshots().map(\.key) + status.needsInitialImport = pendingKeys.contains { !anchors.hasAnchor($0) } await outbox.refreshPendingStatus() } diff --git a/Pulso/Health/AnchorStore.swift b/Pulso/Health/AnchorStore.swift index 960e67d..24bb15f 100644 --- a/Pulso/Health/AnchorStore.swift +++ b/Pulso/Health/AnchorStore.swift @@ -21,6 +21,12 @@ struct AnchorStore: Sendable { return try? NSKeyedUnarchiver.unarchivedObject(ofClass: HKQueryAnchor.self, from: data) } + /// The stored bytes as-is: snapshot sources keep their delivered-state + /// fingerprints here instead of an HKQueryAnchor. + func loadRaw(_ key: String) -> Data? { + try? Data(contentsOf: url(for: key)) + } + func archive(_ anchor: HKQueryAnchor) -> Data? { try? NSKeyedArchiver.archivedData(withRootObject: anchor, requiringSecureCoding: true) } diff --git a/Pulso/Health/HealthAuthorization.swift b/Pulso/Health/HealthAuthorization.swift new file mode 100644 index 0000000..8c18090 --- /dev/null +++ b/Pulso/Health/HealthAuthorization.swift @@ -0,0 +1,89 @@ +import Foundation +import HealthKit + +/// HealthKit raises an Objective-C exception (which would crash the app) +/// when an authorization request names a type it won't let third-party +/// apps read on this OS. The read set is therefore probed first and any +/// refused type dropped, so one bad identifier can't cost all the others. +enum HealthAuthorization { + struct ReadSet { + let accepted: Set + /// Identifier -> the exception HealthKit raised for it alone. + let rejected: [String: String] + } + + static let rejectedKey = "auth.rejectedTypes" + + /// Types refused on the last probe, for SyncEngine to skip. + static func lastRejected() -> Set { + Set(UserDefaults.standard.stringArray(forKey: rejectedKey) ?? []) + } + + /// Probes `candidates` as one request; on an exception, splits the set + /// and probes the halves until each refused type is isolated. + static func readSet(_ candidates: Set, store: HKHealthStore) -> ReadSet { + var rejected: [String: String] = [:] + func accepted(_ types: [HKObjectType]) -> [HKObjectType] { + guard !types.isEmpty else { return [] } + guard let failure = probe(types, store: store) else { return types } + if types.count == 1 { + rejected[types[0].identifier] = failure + return [] + } + let middle = types.count / 2 + return accepted(Array(types[.. String? { + do { + try ObjC.catching { + store.getRequestStatusForAuthorization(toShare: [], read: Set(types)) { _, _ in } + } + return nil + } catch { + return String(describing: error) + } + } + + static func requestStatus(_ types: Set, store: HKHealthStore) async throws -> HKAuthorizationRequestStatus { + try await withCheckedThrowingContinuation { continuation in + do { + try ObjC.catching { + store.getRequestStatusForAuthorization(toShare: [], read: types) { status, error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: status) + } + } + } + } catch { + continuation.resume(throwing: error) + } + } + } + + static func request(_ types: Set, store: HKHealthStore) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + do { + try ObjC.catching { + store.requestAuthorization(toShare: [], read: types) { _, error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + } + } + } catch { + continuation.resume(throwing: error) + } + } + } +} diff --git a/Pulso/Health/HealthKitCatalog.generated.swift b/Pulso/Health/HealthKitCatalog.generated.swift new file mode 100644 index 0000000..8cf451e --- /dev/null +++ b/Pulso/Health/HealthKitCatalog.generated.swift @@ -0,0 +1,340 @@ +// Generated by scripts/gen_healthkit_catalog.py from the iOS 26.5 SDK headers. Do not edit. +// swiftlint:disable all +import Foundation + +/// Every HealthKit type identifier the SDK declares, with what the headers +/// say about it. Identifiers are raw strings so a type newer than the +/// running OS resolves to nil at runtime instead of failing to link. +enum HealthKitCatalog { + static let sdkVersion = "26.5" + + struct Quantity: Sendable { + let identifier: String + let key: String + let name: String + let section: String + /// Canonical unit from the header comment, in HKUnit(from:) syntax. + let unit: String + let aggregation: String + let minimumOS: OperatingSystemVersion + let deprecated: Bool + } + + struct Category: Sendable { + let identifier: String + let key: String + let name: String + let section: String + /// Name of the HKCategoryValue enum; nil when the value is notApplicable. + let valueEnum: String? + let minimumOS: OperatingSystemVersion + let deprecated: Bool + } + + struct Characteristic: Sendable { + let identifier: String + let key: String + let name: String + let minimumOS: OperatingSystemVersion + } + + struct Identifier: Sendable { + let identifier: String + let key: String + let name: String + let kind: String + let section: String + let minimumOS: OperatingSystemVersion + } + + static let quantities: [Quantity] = [ + Quantity(identifier: "HKQuantityTypeIdentifierAppleSleepingWristTemperature", key: "appleSleepingWristTemperature", name: "Apple Sleeping Wrist Temperature", section: "Body Measurements", unit: "degC", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 16, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierBodyFatPercentage", key: "bodyFatPercentage", name: "Body Fat Percentage", section: "Body Measurements", unit: "%", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierBodyMass", key: "bodyMass", name: "Body Mass", section: "Body Measurements", unit: "kg", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierBodyMassIndex", key: "bodyMassIndex", name: "Body Mass Index", section: "Body Measurements", unit: "count", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierElectrodermalActivity", key: "electrodermalActivity", name: "Electrodermal Activity", section: "Body Measurements", unit: "S", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierHeight", key: "height", name: "Height", section: "Body Measurements", unit: "m", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierLeanBodyMass", key: "leanBodyMass", name: "Lean Body Mass", section: "Body Measurements", unit: "kg", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierWaistCircumference", key: "waistCircumference", name: "Waist Circumference", section: "Body Measurements", unit: "m", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 11, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierActiveEnergyBurned", key: "activeEnergyBurned", name: "Active Energy Burned", section: "Fitness", unit: "kcal", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierAppleExerciseTime", key: "appleExerciseTime", name: "Apple Exercise Time", section: "Fitness", unit: "min", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 9, minorVersion: 3, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierAppleMoveTime", key: "appleMoveTime", name: "Apple Move Time", section: "Fitness", unit: "min", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 14, minorVersion: 5, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierAppleStandTime", key: "appleStandTime", name: "Apple Stand Time", section: "Fitness", unit: "min", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 13, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierBasalEnergyBurned", key: "basalEnergyBurned", name: "Basal Energy Burned", section: "Fitness", unit: "kcal", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierCrossCountrySkiingSpeed", key: "crossCountrySkiingSpeed", name: "Cross Country Skiing Speed", section: "Fitness", unit: "m/s", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 18, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierCyclingCadence", key: "cyclingCadence", name: "Cycling Cadence", section: "Fitness", unit: "count/min", aggregation: "Discrete (Temporally Weighted)", minimumOS: OperatingSystemVersion(majorVersion: 17, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierCyclingFunctionalThresholdPower", key: "cyclingFunctionalThresholdPower", name: "Cycling Functional Threshold Power", section: "Fitness", unit: "W", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 17, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierCyclingPower", key: "cyclingPower", name: "Cycling Power", section: "Fitness", unit: "W", aggregation: "Discrete (Temporally Weighted)", minimumOS: OperatingSystemVersion(majorVersion: 17, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierCyclingSpeed", key: "cyclingSpeed", name: "Cycling Speed", section: "Fitness", unit: "m/s", aggregation: "Discrete (Temporally Weighted)", minimumOS: OperatingSystemVersion(majorVersion: 17, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDistanceCrossCountrySkiing", key: "distanceCrossCountrySkiing", name: "Distance Cross Country Skiing", section: "Fitness", unit: "m", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 18, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDistanceCycling", key: "distanceCycling", name: "Distance Cycling", section: "Fitness", unit: "m", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDistanceDownhillSnowSports", key: "distanceDownhillSnowSports", name: "Distance Downhill Snow Sports", section: "Fitness", unit: "m", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 11, minorVersion: 2, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDistancePaddleSports", key: "distancePaddleSports", name: "Distance Paddle Sports", section: "Fitness", unit: "m", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 18, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDistanceRowing", key: "distanceRowing", name: "Distance Rowing", section: "Fitness", unit: "m", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 18, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDistanceSkatingSports", key: "distanceSkatingSports", name: "Distance Skating Sports", section: "Fitness", unit: "m", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 18, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDistanceSwimming", key: "distanceSwimming", name: "Distance Swimming", section: "Fitness", unit: "m", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 10, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDistanceWalkingRunning", key: "distanceWalkingRunning", name: "Distance Walking Running", section: "Fitness", unit: "m", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDistanceWheelchair", key: "distanceWheelchair", name: "Distance Wheelchair", section: "Fitness", unit: "m", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 10, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierEstimatedWorkoutEffortScore", key: "estimatedWorkoutEffortScore", name: "Estimated Workout Effort Score", section: "Fitness", unit: "appleEffortScore", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 18, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierFlightsClimbed", key: "flightsClimbed", name: "Flights Climbed", section: "Fitness", unit: "count", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierNikeFuel", key: "nikeFuel", name: "Nike Fuel", section: "Fitness", unit: "count", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierPaddleSportsSpeed", key: "paddleSportsSpeed", name: "Paddle Sports Speed", section: "Fitness", unit: "m/s", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 18, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierPhysicalEffort", key: "physicalEffort", name: "Physical Effort", section: "Fitness", unit: "kcal/(kg*hr)", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 17, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierPushCount", key: "pushCount", name: "Push Count", section: "Fitness", unit: "count", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 10, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierRowingSpeed", key: "rowingSpeed", name: "Rowing Speed", section: "Fitness", unit: "m/s", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 18, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierRunningPower", key: "runningPower", name: "Running Power", section: "Fitness", unit: "W", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 16, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierRunningSpeed", key: "runningSpeed", name: "Running Speed", section: "Fitness", unit: "m/s", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 16, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierStepCount", key: "stepCount", name: "Step Count", section: "Fitness", unit: "count", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierSwimmingStrokeCount", key: "swimmingStrokeCount", name: "Swimming Stroke Count", section: "Fitness", unit: "count", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 10, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierUnderwaterDepth", key: "underwaterDepth", name: "Underwater Depth", section: "Fitness", unit: "m", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 16, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierWorkoutEffortScore", key: "workoutEffortScore", name: "Workout Effort Score", section: "Fitness", unit: "appleEffortScore", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 18, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierEnvironmentalAudioExposure", key: "environmentalAudioExposure", name: "Environmental Audio Exposure", section: "Hearing Health", unit: "dBASPL", aggregation: "Discrete (Equivalent Continuous Level)", minimumOS: OperatingSystemVersion(majorVersion: 13, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierEnvironmentalSoundReduction", key: "environmentalSoundReduction", name: "Environmental Sound Reduction", section: "Hearing Health", unit: "dBASPL", aggregation: "Discrete (Equivalent Continuous Level)", minimumOS: OperatingSystemVersion(majorVersion: 16, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierHeadphoneAudioExposure", key: "headphoneAudioExposure", name: "Headphone Audio Exposure", section: "Hearing Health", unit: "dBASPL", aggregation: "Discrete (Equivalent Continuous Level)", minimumOS: OperatingSystemVersion(majorVersion: 13, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierAtrialFibrillationBurden", key: "atrialFibrillationBurden", name: "Atrial Fibrillation Burden", section: "Heart", unit: "%", aggregation: "Discrete (Temporally Weighted)", minimumOS: OperatingSystemVersion(majorVersion: 16, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierHeartRate", key: "heartRate", name: "Heart Rate", section: "Heart", unit: "count/s", aggregation: "Discrete (Temporally Weighted)", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierHeartRateRecoveryOneMinute", key: "heartRateRecoveryOneMinute", name: "Heart Rate Recovery One Minute", section: "Heart", unit: "count/min", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 16, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierHeartRateVariabilitySDNN", key: "heartRateVariabilitySDNN", name: "Heart Rate Variability SDNN", section: "Heart", unit: "ms", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 11, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierPeripheralPerfusionIndex", key: "peripheralPerfusionIndex", name: "Peripheral Perfusion Index", section: "Heart", unit: "%", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierRestingHeartRate", key: "restingHeartRate", name: "Resting Heart Rate", section: "Heart", unit: "count/min", aggregation: "Discrete (Temporally Weighted)", minimumOS: OperatingSystemVersion(majorVersion: 11, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierVO2Max", key: "vo2Max", name: "VO2 Max", section: "Heart", unit: "ml/(kg*min)", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 11, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierWalkingHeartRateAverage", key: "walkingHeartRateAverage", name: "Walking Heart Rate Average", section: "Heart", unit: "count/min", aggregation: "Discrete (Temporally Weighted)", minimumOS: OperatingSystemVersion(majorVersion: 11, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierAppleWalkingSteadiness", key: "appleWalkingSteadiness", name: "Apple Walking Steadiness", section: "Mobility", unit: "%", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 15, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierRunningGroundContactTime", key: "runningGroundContactTime", name: "Running Ground Contact Time", section: "Mobility", unit: "ms", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 16, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierRunningStrideLength", key: "runningStrideLength", name: "Running Stride Length", section: "Mobility", unit: "m", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 16, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierRunningVerticalOscillation", key: "runningVerticalOscillation", name: "Running Vertical Oscillation", section: "Mobility", unit: "cm", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 16, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierSixMinuteWalkTestDistance", key: "sixMinuteWalkTestDistance", name: "Six Minute Walk Test Distance", section: "Mobility", unit: "m", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 14, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierStairAscentSpeed", key: "stairAscentSpeed", name: "Stair Ascent Speed", section: "Mobility", unit: "m/s", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 14, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierStairDescentSpeed", key: "stairDescentSpeed", name: "Stair Descent Speed", section: "Mobility", unit: "m/s", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 14, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierWalkingAsymmetryPercentage", key: "walkingAsymmetryPercentage", name: "Walking Asymmetry Percentage", section: "Mobility", unit: "%", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 14, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierWalkingDoubleSupportPercentage", key: "walkingDoubleSupportPercentage", name: "Walking Double Support Percentage", section: "Mobility", unit: "%", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 14, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierWalkingSpeed", key: "walkingSpeed", name: "Walking Speed", section: "Mobility", unit: "m/s", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 14, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierWalkingStepLength", key: "walkingStepLength", name: "Walking Step Length", section: "Mobility", unit: "m", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 14, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDietaryBiotin", key: "dietaryBiotin", name: "Dietary Biotin", section: "Nutrition", unit: "g", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDietaryCaffeine", key: "dietaryCaffeine", name: "Dietary Caffeine", section: "Nutrition", unit: "g", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDietaryCalcium", key: "dietaryCalcium", name: "Dietary Calcium", section: "Nutrition", unit: "g", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDietaryCarbohydrates", key: "dietaryCarbohydrates", name: "Dietary Carbohydrates", section: "Nutrition", unit: "g", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDietaryChloride", key: "dietaryChloride", name: "Dietary Chloride", section: "Nutrition", unit: "g", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDietaryCholesterol", key: "dietaryCholesterol", name: "Dietary Cholesterol", section: "Nutrition", unit: "g", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDietaryChromium", key: "dietaryChromium", name: "Dietary Chromium", section: "Nutrition", unit: "g", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDietaryCopper", key: "dietaryCopper", name: "Dietary Copper", section: "Nutrition", unit: "g", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDietaryEnergyConsumed", key: "dietaryEnergyConsumed", name: "Dietary Energy Consumed", section: "Nutrition", unit: "kcal", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDietaryFatMonounsaturated", key: "dietaryFatMonounsaturated", name: "Dietary Fat Monounsaturated", section: "Nutrition", unit: "g", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDietaryFatPolyunsaturated", key: "dietaryFatPolyunsaturated", name: "Dietary Fat Polyunsaturated", section: "Nutrition", unit: "g", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDietaryFatSaturated", key: "dietaryFatSaturated", name: "Dietary Fat Saturated", section: "Nutrition", unit: "g", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDietaryFatTotal", key: "dietaryFatTotal", name: "Dietary Fat Total", section: "Nutrition", unit: "g", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDietaryFiber", key: "dietaryFiber", name: "Dietary Fiber", section: "Nutrition", unit: "g", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDietaryFolate", key: "dietaryFolate", name: "Dietary Folate", section: "Nutrition", unit: "g", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDietaryIodine", key: "dietaryIodine", name: "Dietary Iodine", section: "Nutrition", unit: "g", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDietaryIron", key: "dietaryIron", name: "Dietary Iron", section: "Nutrition", unit: "g", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDietaryMagnesium", key: "dietaryMagnesium", name: "Dietary Magnesium", section: "Nutrition", unit: "g", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDietaryManganese", key: "dietaryManganese", name: "Dietary Manganese", section: "Nutrition", unit: "g", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDietaryMolybdenum", key: "dietaryMolybdenum", name: "Dietary Molybdenum", section: "Nutrition", unit: "g", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDietaryNiacin", key: "dietaryNiacin", name: "Dietary Niacin", section: "Nutrition", unit: "g", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDietaryPantothenicAcid", key: "dietaryPantothenicAcid", name: "Dietary Pantothenic Acid", section: "Nutrition", unit: "g", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDietaryPhosphorus", key: "dietaryPhosphorus", name: "Dietary Phosphorus", section: "Nutrition", unit: "g", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDietaryPotassium", key: "dietaryPotassium", name: "Dietary Potassium", section: "Nutrition", unit: "g", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDietaryProtein", key: "dietaryProtein", name: "Dietary Protein", section: "Nutrition", unit: "g", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDietaryRiboflavin", key: "dietaryRiboflavin", name: "Dietary Riboflavin", section: "Nutrition", unit: "g", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDietarySelenium", key: "dietarySelenium", name: "Dietary Selenium", section: "Nutrition", unit: "g", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDietarySodium", key: "dietarySodium", name: "Dietary Sodium", section: "Nutrition", unit: "g", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDietarySugar", key: "dietarySugar", name: "Dietary Sugar", section: "Nutrition", unit: "g", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDietaryThiamin", key: "dietaryThiamin", name: "Dietary Thiamin", section: "Nutrition", unit: "g", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDietaryVitaminA", key: "dietaryVitaminA", name: "Dietary Vitamin A", section: "Nutrition", unit: "g", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDietaryVitaminB12", key: "dietaryVitaminB12", name: "Dietary Vitamin B12", section: "Nutrition", unit: "g", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDietaryVitaminB6", key: "dietaryVitaminB6", name: "Dietary Vitamin B6", section: "Nutrition", unit: "g", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDietaryVitaminC", key: "dietaryVitaminC", name: "Dietary Vitamin C", section: "Nutrition", unit: "g", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDietaryVitaminD", key: "dietaryVitaminD", name: "Dietary Vitamin D", section: "Nutrition", unit: "g", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDietaryVitaminE", key: "dietaryVitaminE", name: "Dietary Vitamin E", section: "Nutrition", unit: "g", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDietaryVitaminK", key: "dietaryVitaminK", name: "Dietary Vitamin K", section: "Nutrition", unit: "g", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDietaryWater", key: "dietaryWater", name: "Dietary Water", section: "Nutrition", unit: "mL", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 9, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierDietaryZinc", key: "dietaryZinc", name: "Dietary Zinc", section: "Nutrition", unit: "g", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierBloodAlcoholContent", key: "bloodAlcoholContent", name: "Blood Alcohol Content", section: "Other", unit: "%", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierBloodPressureDiastolic", key: "bloodPressureDiastolic", name: "Blood Pressure Diastolic", section: "Other", unit: "mmHg", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierBloodPressureSystolic", key: "bloodPressureSystolic", name: "Blood Pressure Systolic", section: "Other", unit: "mmHg", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierInsulinDelivery", key: "insulinDelivery", name: "Insulin Delivery", section: "Other", unit: "IU", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 11, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierNumberOfAlcoholicBeverages", key: "numberOfAlcoholicBeverages", name: "Number Of Alcoholic Beverages", section: "Other", unit: "count", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 15, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierNumberOfTimesFallen", key: "numberOfTimesFallen", name: "Number Of Times Fallen", section: "Other", unit: "count", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierTimeInDaylight", key: "timeInDaylight", name: "Time In Daylight", section: "Other", unit: "min", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 17, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierUVExposure", key: "uvExposure", name: "UV Exposure", section: "Other", unit: "count", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 9, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierWaterTemperature", key: "waterTemperature", name: "Water Temperature", section: "Other", unit: "degC", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 16, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierBasalBodyTemperature", key: "basalBodyTemperature", name: "Basal Body Temperature", section: "Reproductive Health", unit: "degC", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 9, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierAppleSleepingBreathingDisturbances", key: "appleSleepingBreathingDisturbances", name: "Apple Sleeping Breathing Disturbances", section: "Respiratory", unit: "count", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 18, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierForcedExpiratoryVolume1", key: "forcedExpiratoryVolume1", name: "Forced Expiratory Volume1", section: "Respiratory", unit: "L", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierForcedVitalCapacity", key: "forcedVitalCapacity", name: "Forced Vital Capacity", section: "Respiratory", unit: "L", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierInhalerUsage", key: "inhalerUsage", name: "Inhaler Usage", section: "Respiratory", unit: "count", aggregation: "Cumulative", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierOxygenSaturation", key: "oxygenSaturation", name: "Oxygen Saturation", section: "Respiratory", unit: "%", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierPeakExpiratoryFlowRate", key: "peakExpiratoryFlowRate", name: "Peak Expiratory Flow Rate", section: "Respiratory", unit: "L/min", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierRespiratoryRate", key: "respiratoryRate", name: "Respiratory Rate", section: "Respiratory", unit: "count/s", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierBloodGlucose", key: "bloodGlucose", name: "Blood Glucose", section: "Vital Signs", unit: "mg/dL", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Quantity(identifier: "HKQuantityTypeIdentifierBodyTemperature", key: "bodyTemperature", name: "Body Temperature", section: "Vital Signs", unit: "degC", aggregation: "Discrete (Arithmetic)", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + ] + + static let categories: [Category] = [ + Category(identifier: "HKCategoryTypeIdentifierAppleStandHour", key: "appleStandHour", name: "Apple Stand Hour", section: "Fitness", valueEnum: "HKCategoryValueAppleStandHour", minimumOS: OperatingSystemVersion(majorVersion: 9, minorVersion: 0, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent", key: "environmentalAudioExposureEvent", name: "Environmental Audio Exposure Event", section: "Hearing Health", valueEnum: "HKCategoryValueEnvironmentalAudioExposureEvent", minimumOS: OperatingSystemVersion(majorVersion: 14, minorVersion: 0, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierHeadphoneAudioExposureEvent", key: "headphoneAudioExposureEvent", name: "Headphone Audio Exposure Event", section: "Hearing Health", valueEnum: "HKCategoryValueHeadphoneAudioExposureEvent", minimumOS: OperatingSystemVersion(majorVersion: 14, minorVersion: 2, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierHighHeartRateEvent", key: "highHeartRateEvent", name: "High Heart Rate Event", section: "Heart", valueEnum: nil, minimumOS: OperatingSystemVersion(majorVersion: 12, minorVersion: 2, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierHypertensionEvent", key: "hypertensionEvent", name: "Hypertension Event", section: "Heart", valueEnum: nil, minimumOS: OperatingSystemVersion(majorVersion: 26, minorVersion: 2, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierIrregularHeartRhythmEvent", key: "irregularHeartRhythmEvent", name: "Irregular Heart Rhythm Event", section: "Heart", valueEnum: nil, minimumOS: OperatingSystemVersion(majorVersion: 12, minorVersion: 2, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierLowCardioFitnessEvent", key: "lowCardioFitnessEvent", name: "Low Cardio Fitness Event", section: "Heart", valueEnum: "HKCategoryValueLowCardioFitnessEvent", minimumOS: OperatingSystemVersion(majorVersion: 14, minorVersion: 3, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierLowHeartRateEvent", key: "lowHeartRateEvent", name: "Low Heart Rate Event", section: "Heart", valueEnum: nil, minimumOS: OperatingSystemVersion(majorVersion: 12, minorVersion: 2, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierMindfulSession", key: "mindfulSession", name: "Mindful Session", section: "Mental Wellbeing", valueEnum: nil, minimumOS: OperatingSystemVersion(majorVersion: 10, minorVersion: 0, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierAppleWalkingSteadinessEvent", key: "appleWalkingSteadinessEvent", name: "Apple Walking Steadiness Event", section: "Mobility", valueEnum: "HKCategoryValueAppleWalkingSteadinessEvent", minimumOS: OperatingSystemVersion(majorVersion: 15, minorVersion: 0, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierHandwashingEvent", key: "handwashingEvent", name: "Handwashing Event", section: "Other", valueEnum: nil, minimumOS: OperatingSystemVersion(majorVersion: 14, minorVersion: 0, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierToothbrushingEvent", key: "toothbrushingEvent", name: "Toothbrushing Event", section: "Other", valueEnum: nil, minimumOS: OperatingSystemVersion(majorVersion: 13, minorVersion: 0, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierBleedingAfterPregnancy", key: "bleedingAfterPregnancy", name: "Bleeding After Pregnancy", section: "Reproductive Health", valueEnum: "HKCategoryValueVaginalBleeding", minimumOS: OperatingSystemVersion(majorVersion: 18, minorVersion: 0, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierBleedingDuringPregnancy", key: "bleedingDuringPregnancy", name: "Bleeding During Pregnancy", section: "Reproductive Health", valueEnum: "HKCategoryValueVaginalBleeding", minimumOS: OperatingSystemVersion(majorVersion: 18, minorVersion: 0, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierCervicalMucusQuality", key: "cervicalMucusQuality", name: "Cervical Mucus Quality", section: "Reproductive Health", valueEnum: "HKCategoryValueCervicalMucusQuality", minimumOS: OperatingSystemVersion(majorVersion: 9, minorVersion: 0, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierContraceptive", key: "contraceptive", name: "Contraceptive", section: "Reproductive Health", valueEnum: "HKCategoryValueContraceptive", minimumOS: OperatingSystemVersion(majorVersion: 14, minorVersion: 3, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierInfrequentMenstrualCycles", key: "infrequentMenstrualCycles", name: "Infrequent Menstrual Cycles", section: "Reproductive Health", valueEnum: nil, minimumOS: OperatingSystemVersion(majorVersion: 16, minorVersion: 0, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierIntermenstrualBleeding", key: "intermenstrualBleeding", name: "Intermenstrual Bleeding", section: "Reproductive Health", valueEnum: nil, minimumOS: OperatingSystemVersion(majorVersion: 9, minorVersion: 0, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierIrregularMenstrualCycles", key: "irregularMenstrualCycles", name: "Irregular Menstrual Cycles", section: "Reproductive Health", valueEnum: nil, minimumOS: OperatingSystemVersion(majorVersion: 16, minorVersion: 0, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierLactation", key: "lactation", name: "Lactation", section: "Reproductive Health", valueEnum: nil, minimumOS: OperatingSystemVersion(majorVersion: 14, minorVersion: 3, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierMenstrualFlow", key: "menstrualFlow", name: "Menstrual Flow", section: "Reproductive Health", valueEnum: "HKCategoryValueVaginalBleeding", minimumOS: OperatingSystemVersion(majorVersion: 9, minorVersion: 0, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierOvulationTestResult", key: "ovulationTestResult", name: "Ovulation Test Result", section: "Reproductive Health", valueEnum: "HKCategoryValueOvulationTestResult", minimumOS: OperatingSystemVersion(majorVersion: 9, minorVersion: 0, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierPersistentIntermenstrualBleeding", key: "persistentIntermenstrualBleeding", name: "Persistent Intermenstrual Bleeding", section: "Reproductive Health", valueEnum: nil, minimumOS: OperatingSystemVersion(majorVersion: 16, minorVersion: 0, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierPregnancy", key: "pregnancy", name: "Pregnancy", section: "Reproductive Health", valueEnum: nil, minimumOS: OperatingSystemVersion(majorVersion: 14, minorVersion: 3, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierPregnancyTestResult", key: "pregnancyTestResult", name: "Pregnancy Test Result", section: "Reproductive Health", valueEnum: "HKCategoryValuePregnancyTestResult", minimumOS: OperatingSystemVersion(majorVersion: 15, minorVersion: 0, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierProgesteroneTestResult", key: "progesteroneTestResult", name: "Progesterone Test Result", section: "Reproductive Health", valueEnum: "HKCategoryValueProgesteroneTestResult", minimumOS: OperatingSystemVersion(majorVersion: 15, minorVersion: 0, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierProlongedMenstrualPeriods", key: "prolongedMenstrualPeriods", name: "Prolonged Menstrual Periods", section: "Reproductive Health", valueEnum: nil, minimumOS: OperatingSystemVersion(majorVersion: 16, minorVersion: 0, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierSexualActivity", key: "sexualActivity", name: "Sexual Activity", section: "Reproductive Health", valueEnum: nil, minimumOS: OperatingSystemVersion(majorVersion: 9, minorVersion: 0, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierSleepApneaEvent", key: "sleepApneaEvent", name: "Sleep Apnea Event", section: "Respiratory", valueEnum: nil, minimumOS: OperatingSystemVersion(majorVersion: 18, minorVersion: 0, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierSleepAnalysis", key: "sleepAnalysis", name: "Sleep Analysis", section: "Sleep", valueEnum: "HKCategoryValueSleepAnalysis", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierAbdominalCramps", key: "abdominalCramps", name: "Abdominal Cramps", section: "Symptoms", valueEnum: "HKCategoryValueSeverity", minimumOS: OperatingSystemVersion(majorVersion: 13, minorVersion: 6, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierAcne", key: "acne", name: "Acne", section: "Symptoms", valueEnum: "HKCategoryValueSeverity", minimumOS: OperatingSystemVersion(majorVersion: 13, minorVersion: 6, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierAppetiteChanges", key: "appetiteChanges", name: "Appetite Changes", section: "Symptoms", valueEnum: "HKCategoryValueAppetiteChanges", minimumOS: OperatingSystemVersion(majorVersion: 13, minorVersion: 6, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierBladderIncontinence", key: "bladderIncontinence", name: "Bladder Incontinence", section: "Symptoms", valueEnum: "HKCategoryValueSeverity", minimumOS: OperatingSystemVersion(majorVersion: 14, minorVersion: 0, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierBloating", key: "bloating", name: "Bloating", section: "Symptoms", valueEnum: "HKCategoryValueSeverity", minimumOS: OperatingSystemVersion(majorVersion: 13, minorVersion: 6, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierBreastPain", key: "breastPain", name: "Breast Pain", section: "Symptoms", valueEnum: "HKCategoryValueSeverity", minimumOS: OperatingSystemVersion(majorVersion: 13, minorVersion: 6, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierChestTightnessOrPain", key: "chestTightnessOrPain", name: "Chest Tightness Or Pain", section: "Symptoms", valueEnum: "HKCategoryValueSeverity", minimumOS: OperatingSystemVersion(majorVersion: 13, minorVersion: 6, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierChills", key: "chills", name: "Chills", section: "Symptoms", valueEnum: "HKCategoryValueSeverity", minimumOS: OperatingSystemVersion(majorVersion: 13, minorVersion: 6, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierConstipation", key: "constipation", name: "Constipation", section: "Symptoms", valueEnum: "HKCategoryValueSeverity", minimumOS: OperatingSystemVersion(majorVersion: 13, minorVersion: 6, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierCoughing", key: "coughing", name: "Coughing", section: "Symptoms", valueEnum: "HKCategoryValueSeverity", minimumOS: OperatingSystemVersion(majorVersion: 13, minorVersion: 6, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierDiarrhea", key: "diarrhea", name: "Diarrhea", section: "Symptoms", valueEnum: "HKCategoryValueSeverity", minimumOS: OperatingSystemVersion(majorVersion: 13, minorVersion: 6, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierDizziness", key: "dizziness", name: "Dizziness", section: "Symptoms", valueEnum: "HKCategoryValueSeverity", minimumOS: OperatingSystemVersion(majorVersion: 13, minorVersion: 6, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierDrySkin", key: "drySkin", name: "Dry Skin", section: "Symptoms", valueEnum: "HKCategoryValueSeverity", minimumOS: OperatingSystemVersion(majorVersion: 14, minorVersion: 0, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierFainting", key: "fainting", name: "Fainting", section: "Symptoms", valueEnum: "HKCategoryValueSeverity", minimumOS: OperatingSystemVersion(majorVersion: 13, minorVersion: 6, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierFatigue", key: "fatigue", name: "Fatigue", section: "Symptoms", valueEnum: "HKCategoryValueSeverity", minimumOS: OperatingSystemVersion(majorVersion: 13, minorVersion: 6, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierFever", key: "fever", name: "Fever", section: "Symptoms", valueEnum: "HKCategoryValueSeverity", minimumOS: OperatingSystemVersion(majorVersion: 13, minorVersion: 6, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierGeneralizedBodyAche", key: "generalizedBodyAche", name: "Generalized Body Ache", section: "Symptoms", valueEnum: "HKCategoryValueSeverity", minimumOS: OperatingSystemVersion(majorVersion: 13, minorVersion: 6, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierHairLoss", key: "hairLoss", name: "Hair Loss", section: "Symptoms", valueEnum: "HKCategoryValueSeverity", minimumOS: OperatingSystemVersion(majorVersion: 14, minorVersion: 0, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierHeadache", key: "headache", name: "Headache", section: "Symptoms", valueEnum: "HKCategoryValueSeverity", minimumOS: OperatingSystemVersion(majorVersion: 13, minorVersion: 6, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierHeartburn", key: "heartburn", name: "Heartburn", section: "Symptoms", valueEnum: "HKCategoryValueSeverity", minimumOS: OperatingSystemVersion(majorVersion: 13, minorVersion: 6, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierHotFlashes", key: "hotFlashes", name: "Hot Flashes", section: "Symptoms", valueEnum: "HKCategoryValueSeverity", minimumOS: OperatingSystemVersion(majorVersion: 13, minorVersion: 6, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierLossOfSmell", key: "lossOfSmell", name: "Loss Of Smell", section: "Symptoms", valueEnum: "HKCategoryValueSeverity", minimumOS: OperatingSystemVersion(majorVersion: 13, minorVersion: 6, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierLossOfTaste", key: "lossOfTaste", name: "Loss Of Taste", section: "Symptoms", valueEnum: "HKCategoryValueSeverity", minimumOS: OperatingSystemVersion(majorVersion: 13, minorVersion: 6, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierLowerBackPain", key: "lowerBackPain", name: "Lower Back Pain", section: "Symptoms", valueEnum: "HKCategoryValueSeverity", minimumOS: OperatingSystemVersion(majorVersion: 13, minorVersion: 6, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierMemoryLapse", key: "memoryLapse", name: "Memory Lapse", section: "Symptoms", valueEnum: "HKCategoryValueSeverity", minimumOS: OperatingSystemVersion(majorVersion: 14, minorVersion: 0, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierMoodChanges", key: "moodChanges", name: "Mood Changes", section: "Symptoms", valueEnum: "HKCategoryValuePresence", minimumOS: OperatingSystemVersion(majorVersion: 13, minorVersion: 6, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierNausea", key: "nausea", name: "Nausea", section: "Symptoms", valueEnum: "HKCategoryValueSeverity", minimumOS: OperatingSystemVersion(majorVersion: 13, minorVersion: 6, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierNightSweats", key: "nightSweats", name: "Night Sweats", section: "Symptoms", valueEnum: "HKCategoryValueSeverity", minimumOS: OperatingSystemVersion(majorVersion: 14, minorVersion: 0, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierPelvicPain", key: "pelvicPain", name: "Pelvic Pain", section: "Symptoms", valueEnum: "HKCategoryValueSeverity", minimumOS: OperatingSystemVersion(majorVersion: 13, minorVersion: 6, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierRapidPoundingOrFlutteringHeartbeat", key: "rapidPoundingOrFlutteringHeartbeat", name: "Rapid Pounding Or Fluttering Heartbeat", section: "Symptoms", valueEnum: "HKCategoryValueSeverity", minimumOS: OperatingSystemVersion(majorVersion: 13, minorVersion: 6, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierRunnyNose", key: "runnyNose", name: "Runny Nose", section: "Symptoms", valueEnum: "HKCategoryValueSeverity", minimumOS: OperatingSystemVersion(majorVersion: 13, minorVersion: 6, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierShortnessOfBreath", key: "shortnessOfBreath", name: "Shortness Of Breath", section: "Symptoms", valueEnum: "HKCategoryValueSeverity", minimumOS: OperatingSystemVersion(majorVersion: 13, minorVersion: 6, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierSinusCongestion", key: "sinusCongestion", name: "Sinus Congestion", section: "Symptoms", valueEnum: "HKCategoryValueSeverity", minimumOS: OperatingSystemVersion(majorVersion: 13, minorVersion: 6, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierSkippedHeartbeat", key: "skippedHeartbeat", name: "Skipped Heartbeat", section: "Symptoms", valueEnum: "HKCategoryValueSeverity", minimumOS: OperatingSystemVersion(majorVersion: 13, minorVersion: 6, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierSleepChanges", key: "sleepChanges", name: "Sleep Changes", section: "Symptoms", valueEnum: "HKCategoryValuePresence", minimumOS: OperatingSystemVersion(majorVersion: 13, minorVersion: 6, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierSoreThroat", key: "soreThroat", name: "Sore Throat", section: "Symptoms", valueEnum: "HKCategoryValueSeverity", minimumOS: OperatingSystemVersion(majorVersion: 13, minorVersion: 6, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierVaginalDryness", key: "vaginalDryness", name: "Vaginal Dryness", section: "Symptoms", valueEnum: "HKCategoryValueSeverity", minimumOS: OperatingSystemVersion(majorVersion: 14, minorVersion: 0, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierVomiting", key: "vomiting", name: "Vomiting", section: "Symptoms", valueEnum: "HKCategoryValueSeverity", minimumOS: OperatingSystemVersion(majorVersion: 13, minorVersion: 6, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierWheezing", key: "wheezing", name: "Wheezing", section: "Symptoms", valueEnum: "HKCategoryValueSeverity", minimumOS: OperatingSystemVersion(majorVersion: 13, minorVersion: 6, patchVersion: 0), deprecated: false), + Category(identifier: "HKCategoryTypeIdentifierAudioExposureEvent", key: "audioExposureEvent", name: "Audio Exposure Event", section: "Medication Tracking", valueEnum: nil, minimumOS: OperatingSystemVersion(majorVersion: 13, minorVersion: 0, patchVersion: 0), deprecated: true), + ] + + static let characteristics: [Characteristic] = [ + Characteristic(identifier: "HKCharacteristicTypeIdentifierActivityMoveMode", key: "activityMoveMode", name: "Activity Move Mode", minimumOS: OperatingSystemVersion(majorVersion: 14, minorVersion: 0, patchVersion: 0)), + Characteristic(identifier: "HKCharacteristicTypeIdentifierBiologicalSex", key: "biologicalSex", name: "Biological Sex", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0)), + Characteristic(identifier: "HKCharacteristicTypeIdentifierBloodType", key: "bloodType", name: "Blood Type", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0)), + Characteristic(identifier: "HKCharacteristicTypeIdentifierDateOfBirth", key: "dateOfBirth", name: "Date Of Birth", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0)), + Characteristic(identifier: "HKCharacteristicTypeIdentifierFitzpatrickSkinType", key: "fitzpatrickSkinType", name: "Fitzpatrick Skin Type", minimumOS: OperatingSystemVersion(majorVersion: 9, minorVersion: 0, patchVersion: 0)), + Characteristic(identifier: "HKCharacteristicTypeIdentifierWheelchairUse", key: "wheelchairUse", name: "Wheelchair Use", minimumOS: OperatingSystemVersion(majorVersion: 10, minorVersion: 0, patchVersion: 0)), + ] + + /// Correlation, document, scored-assessment and other identifiers. The app + /// handles these by hand; the list exists so coverage can be checked. + static let otherIdentifiers: [Identifier] = [ + Identifier(identifier: "HKCorrelationTypeIdentifierBloodPressure", key: "bloodPressure", name: "Blood Pressure", kind: "HKCorrelationTypeIdentifier", section: "Heart", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0)), + Identifier(identifier: "HKCorrelationTypeIdentifierFood", key: "food", name: "Food", kind: "HKCorrelationTypeIdentifier", section: "Other", minimumOS: OperatingSystemVersion(majorVersion: 8, minorVersion: 0, patchVersion: 0)), + Identifier(identifier: "HKDocumentTypeIdentifierCDA", key: "cda", name: "CDA", kind: "HKDocumentTypeIdentifier", section: "Clinical Documents", minimumOS: OperatingSystemVersion(majorVersion: 10, minorVersion: 0, patchVersion: 0)), + Identifier(identifier: "HKScoredAssessmentTypeIdentifierGAD7", key: "gad7", name: "GAD7", kind: "HKScoredAssessmentTypeIdentifier", section: "Mental Wellbeing", minimumOS: OperatingSystemVersion(majorVersion: 18, minorVersion: 0, patchVersion: 0)), + Identifier(identifier: "HKScoredAssessmentTypeIdentifierPHQ9", key: "phq9", name: "PHQ9", kind: "HKScoredAssessmentTypeIdentifier", section: "Mental Wellbeing", minimumOS: OperatingSystemVersion(majorVersion: 18, minorVersion: 0, patchVersion: 0)), + ] + + /// Every NS_ENUM in the HealthKit headers: enum name -> raw value -> wire + /// name (the Swift case name). Aliases resolve to the non-deprecated case. + static let enumNames: [String: [Int: String]] = [ + "HKActivityMoveMode": [1: "activeEnergy", 2: "appleMoveTime"], + "HKAppleECGAlgorithmVersion": [1: "1", 2: "2"], + "HKAppleSleepingBreathingDisturbancesClassification": [0: "notElevated", 1: "elevated"], + "HKAppleWalkingSteadinessClassification": [1: "ok", 2: "low", 3: "veryLow"], + "HKAudiogramConductionType": [0: "air"], + "HKAudiogramSensitivityTestSide": [0: "left", 1: "right"], + "HKAuthorizationRequestStatus": [0: "unknown", 1: "shouldRequest", 2: "unnecessary"], + "HKAuthorizationStatus": [0: "notDetermined", 1: "sharingDenied", 2: "sharingAuthorized"], + "HKBiologicalSex": [0: "notSet", 1: "female", 2: "male", 3: "other"], + "HKBloodGlucoseMealTime": [1: "preprandial", 2: "postprandial"], + "HKBloodType": [0: "notSet", 1: "aPositive", 2: "aNegative", 3: "bPositive", 4: "bNegative", 5: "abPositive", 6: "abNegative", 7: "oPositive", 8: "oNegative"], + "HKBodyTemperatureSensorLocation": [0: "other", 1: "armpit", 2: "body", 3: "ear", 4: "finger", 5: "gastroIntestinal", 6: "mouth", 7: "rectum", 8: "toe", 9: "earDrum", 10: "temporalArtery", 11: "forehead"], + "HKCategoryValue": [0: "notApplicable"], + "HKCategoryValueAppetiteChanges": [0: "unspecified", 1: "noChange", 2: "decreased", 3: "increased"], + "HKCategoryValueAppleStandHour": [0: "stood", 1: "idle"], + "HKCategoryValueAppleWalkingSteadinessEvent": [1: "initialLow", 2: "initialVeryLow", 3: "repeatLow", 4: "repeatVeryLow"], + "HKCategoryValueAudioExposureEvent": [1: "loudEnvironment"], + "HKCategoryValueCervicalMucusQuality": [1: "dry", 2: "sticky", 3: "creamy", 4: "watery", 5: "eggWhite"], + "HKCategoryValueContraceptive": [1: "unspecified", 2: "implant", 3: "injection", 4: "intrauterineDevice", 5: "intravaginalRing", 6: "oral", 7: "patch"], + "HKCategoryValueEnvironmentalAudioExposureEvent": [1: "momentaryLimit"], + "HKCategoryValueHeadphoneAudioExposureEvent": [1: "sevenDayLimit"], + "HKCategoryValueLowCardioFitnessEvent": [1: "lowFitness"], + "HKCategoryValueMenstrualFlow": [1: "unspecified", 2: "light", 3: "medium", 4: "heavy", 5: "none"], + "HKCategoryValueOvulationTestResult": [1: "negative", 2: "luteinizingHormoneSurge", 3: "indeterminate", 4: "estrogenSurge"], + "HKCategoryValuePregnancyTestResult": [1: "negative", 2: "positive", 3: "indeterminate"], + "HKCategoryValuePresence": [0: "present", 1: "notPresent"], + "HKCategoryValueProgesteroneTestResult": [1: "negative", 2: "positive", 3: "indeterminate"], + "HKCategoryValueSeverity": [0: "unspecified", 1: "notPresent", 2: "mild", 3: "moderate", 4: "severe"], + "HKCategoryValueSleepAnalysis": [0: "inBed", 1: "asleepUnspecified", 2: "awake", 3: "asleepCore", 4: "asleepDeep", 5: "asleepREM"], + "HKCategoryValueVaginalBleeding": [1: "unspecified", 2: "light", 3: "medium", 4: "heavy", 5: "none"], + "HKCyclingFunctionalThresholdPowerTestType": [1: "maxExercise60Minute", 2: "maxExercise20Minute", 3: "rampTest", 4: "predictionExercise"], + "HKDevicePlacementSide": [0: "unknown", 1: "left", 2: "right", 3: "central"], + "HKElectrocardiogramClassification": [0: "notSet", 1: "sinusRhythm", 2: "atrialFibrillation", 3: "inconclusiveLowHeartRate", 4: "inconclusiveHighHeartRate", 5: "inconclusivePoorReading", 6: "inconclusiveOther", 100: "unrecognized"], + "HKElectrocardiogramLead": [1: "appleWatchSimilarToLeadI"], + "HKElectrocardiogramSymptomsStatus": [0: "notSet", 1: "none", 2: "present"], + "HKErrorCode": [0: "hkUnknownError", 1: "hkErrorHealthDataUnavailable", 2: "hkErrorHealthDataRestricted", 3: "hkErrorInvalidArgument", 4: "hkErrorAuthorizationDenied", 5: "hkErrorAuthorizationNotDetermined", 6: "hkErrorDatabaseInaccessible", 7: "hkErrorUserCanceled", 8: "hkErrorAnotherWorkoutSessionStarted", 9: "hkErrorUserExitedWorkoutSession", 10: "hkErrorRequiredAuthorizationDenied", 11: "hkErrorNoData", 12: "hkErrorWorkoutActivityNotAllowed", 13: "hkErrorDataSizeExceeded", 14: "hkErrorBackgroundWorkoutSessionNotAllowed", 15: "hkErrorNotPermissibleForGuestUserMode"], + "HKFitzpatrickSkinType": [0: "notSet", 1: "i", 2: "ii", 3: "iii", 4: "iv", 5: "v", 6: "vi"], + "HKGAD7AssessmentAnswer": [0: "notAtAll", 1: "severalDays", 2: "moreThanHalfTheDays", 3: "nearlyEveryDay"], + "HKGAD7AssessmentRisk": [1: "noneToMinimal", 2: "mild", 3: "moderate", 4: "severe"], + "HKHeartRateMotionContext": [0: "notSet", 1: "sedentary", 2: "active"], + "HKHeartRateRecoveryTestType": [1: "maxExercise", 2: "predictionSubMaxExercise", 3: "predictionNonExercise"], + "HKHeartRateSensorLocation": [0: "other", 1: "chest", 2: "wrist", 3: "finger", 4: "hand", 5: "earLobe", 6: "foot"], + "HKInsulinDeliveryReason": [1: "basal", 2: "bolus"], + "HKMedicationDoseEventLogStatus": [1: "notInteracted", 2: "notificationNotSent", 3: "snoozed", 4: "taken", 5: "skipped", 6: "notLogged"], + "HKMedicationDoseEventScheduleType": [1: "asNeeded", 2: "schedule"], + "HKMetricPrefix": [0: "none", 1: "femto", 2: "pico", 3: "nano", 4: "micro", 5: "milli", 6: "centi", 7: "deci", 8: "deca", 9: "hecto", 10: "kilo", 11: "mega", 12: "giga", 13: "tera"], + "HKPHQ9AssessmentAnswer": [0: "notAtAll", 1: "severalDays", 2: "moreThanHalfTheDays", 3: "nearlyEveryDay", 4: "preferNotToAnswer"], + "HKPHQ9AssessmentRisk": [1: "noneToMinimal", 2: "mild", 3: "moderate", 4: "moderatelySevere", 5: "severe"], + "HKPhysicalEffortEstimationType": [1: "activityLookup", 2: "deviceSensed"], + "HKPrismBase": [0: "none", 1: "up", 2: "down", 3: "in", 4: "out"], + "HKQuantityAggregationStyle": [0: "cumulative", 1: "discreteArithmetic", 2: "discreteTemporallyWeighted", 3: "discreteEquivalentContinuousLevel"], + "HKStateOfMindAssociation": [1: "community", 2: "currentEvents", 3: "dating", 4: "education", 5: "family", 6: "fitness", 7: "friends", 8: "health", 9: "hobbies", 10: "identity", 11: "money", 12: "partner", 13: "selfCare", 14: "spirituality", 15: "tasks", 16: "travel", 17: "work", 18: "weather"], + "HKStateOfMindKind": [1: "momentaryEmotion", 2: "dailyMood"], + "HKStateOfMindLabel": [1: "amazed", 2: "amused", 3: "angry", 4: "anxious", 5: "ashamed", 6: "brave", 7: "calm", 8: "content", 9: "disappointed", 10: "discouraged", 11: "disgusted", 12: "embarrassed", 13: "excited", 14: "frustrated", 15: "grateful", 16: "guilty", 17: "happy", 18: "hopeless", 19: "irritated", 20: "jealous", 21: "joyful", 22: "lonely", 23: "passionate", 24: "peaceful", 25: "proud", 26: "relieved", 27: "sad", 28: "scared", 29: "stressed", 30: "surprised", 31: "worried", 32: "annoyed", 33: "confident", 34: "drained", 35: "hopeful", 36: "indifferent", 37: "overwhelmed", 38: "satisfied"], + "HKStateOfMindValenceClassification": [1: "veryUnpleasant", 2: "unpleasant", 3: "slightlyUnpleasant", 4: "neutral", 5: "slightlyPleasant", 6: "pleasant", 7: "veryPleasant"], + "HKSwimmingStrokeStyle": [0: "unknown", 1: "mixed", 2: "freestyle", 3: "backstroke", 4: "breaststroke", 5: "butterfly", 6: "kickboard"], + "HKUpdateFrequency": [1: "immediate", 2: "hourly", 3: "daily", 4: "weekly"], + "HKUserMotionContext": [0: "notSet", 1: "stationary", 2: "active"], + "HKVO2MaxTestType": [1: "maxExercise", 2: "predictionSubMaxExercise", 3: "predictionNonExercise", 4: "predictionStepTest"], + "HKVisionEye": [1: "left", 2: "right"], + "HKVisionPrescriptionType": [1: "glasses", 2: "contacts"], + "HKWaterSalinity": [1: "freshWater", 2: "saltWater"], + "HKWeatherCondition": [0: "none", 1: "clear", 2: "fair", 3: "partlyCloudy", 4: "mostlyCloudy", 5: "cloudy", 6: "foggy", 7: "haze", 8: "windy", 9: "blustery", 10: "smoky", 11: "dust", 12: "snow", 13: "hail", 14: "sleet", 15: "freezingDrizzle", 16: "freezingRain", 17: "mixedRainAndHail", 18: "mixedRainAndSnow", 19: "mixedRainAndSleet", 20: "mixedSnowAndSleet", 21: "drizzle", 22: "scatteredShowers", 23: "showers", 24: "thunderstorms", 25: "tropicalStorm", 26: "hurricane", 27: "tornado"], + "HKWheelchairUse": [0: "notSet", 1: "no", 2: "yes"], + "HKWorkoutActivityType": [1: "americanFootball", 2: "archery", 3: "australianFootball", 4: "badminton", 5: "baseball", 6: "basketball", 7: "bowling", 8: "boxing", 9: "climbing", 10: "cricket", 11: "crossTraining", 12: "curling", 13: "cycling", 14: "dance", 15: "danceInspiredTraining", 16: "elliptical", 17: "equestrianSports", 18: "fencing", 19: "fishing", 20: "functionalStrengthTraining", 21: "golf", 22: "gymnastics", 23: "handball", 24: "hiking", 25: "hockey", 26: "hunting", 27: "lacrosse", 28: "martialArts", 29: "mindAndBody", 30: "mixedMetabolicCardioTraining", 31: "paddleSports", 32: "play", 33: "preparationAndRecovery", 34: "racquetball", 35: "rowing", 36: "rugby", 37: "running", 38: "sailing", 39: "skatingSports", 40: "snowSports", 41: "soccer", 42: "softball", 43: "squash", 44: "stairClimbing", 45: "surfingSports", 46: "swimming", 47: "tableTennis", 48: "tennis", 49: "trackAndField", 50: "traditionalStrengthTraining", 51: "volleyball", 52: "walking", 53: "waterFitness", 54: "waterPolo", 55: "waterSports", 56: "wrestling", 57: "yoga", 58: "barre", 59: "coreTraining", 60: "crossCountrySkiing", 61: "downhillSkiing", 62: "flexibility", 63: "highIntensityIntervalTraining", 64: "jumpRope", 65: "kickboxing", 66: "pilates", 67: "snowboarding", 68: "stairs", 69: "stepTraining", 70: "wheelchairWalkPace", 71: "wheelchairRunPace", 72: "taiChi", 73: "mixedCardio", 74: "handCycling", 75: "discSports", 76: "fitnessGaming", 77: "cardioDance", 78: "socialDance", 79: "pickleball", 80: "cooldown", 82: "swimBikeRun", 83: "transition", 84: "underwaterDiving", 3000: "other"], + "HKWorkoutEffortRelationshipQueryOptions": [0: "default", 1: "mostRelevant"], + "HKWorkoutEventType": [1: "pause", 2: "resume", 3: "lap", 4: "marker", 5: "motionPaused", 6: "motionResumed", 7: "segment", 8: "pauseOrResumeRequest"], + "HKWorkoutSessionLocationType": [1: "unknown", 2: "indoor", 3: "outdoor"], + "HKWorkoutSessionState": [1: "notStarted", 2: "running", 3: "ended", 4: "paused", 5: "prepared", 6: "stopped"], + "HKWorkoutSessionType": [0: "primary", 1: "mirrored"], + "HKWorkoutSwimmingLocationType": [0: "unknown", 1: "pool", 2: "openWater"], + ] +} diff --git a/Pulso/Health/HealthQueries.swift b/Pulso/Health/HealthQueries.swift new file mode 100644 index 0000000..3d8fa96 --- /dev/null +++ b/Pulso/Health/HealthQueries.swift @@ -0,0 +1,268 @@ +import CoreLocation +import CryptoKit +import Foundation +import HealthKit + +/// Follow-up queries for samples whose payload doesn't live on the sample +/// object itself (route locations, heartbeats, ECG voltages, workout links), +/// and the enrich steps built on them. An enrich step throws rather than +/// ship a partial sample: the type's anchor then stays put and the sample +/// is read again on the next pass. +struct HealthQueries: @unchecked Sendable { + let store: HKHealthStore + + func samples(_ type: HKSampleType, predicate: NSPredicate?, limit: Int? = nil) async throws -> [HKSample] { + let descriptor = HKSampleQueryDescriptor( + predicates: [.sample(type: type, predicate: predicate)], + sortDescriptors: [], + limit: limit + ) + return try await descriptor.result(for: store) + } + + // MARK: - Series payloads + + func locations(of route: HKWorkoutRoute) async throws -> [CLLocation] { + let collector = Collector() + return try await withCheckedThrowingContinuation { continuation in + let query = HKWorkoutRouteQuery(route: route) { _, locations, done, error in + if let error { return collector.fail(error, continuation) } + collector.append(contentsOf: locations ?? []) + if done { collector.finish(continuation) } + } + store.execute(query) + } + } + + struct Beat: Sendable { + let secondsFromStart: TimeInterval + let precededByGap: Bool + } + + func beats(of series: HKHeartbeatSeriesSample) async throws -> [Beat] { + let collector = Collector() + return try await withCheckedThrowingContinuation { continuation in + let query = HKHeartbeatSeriesQuery(heartbeatSeries: series) { _, time, gap, done, error in + if let error { return collector.fail(error, continuation) } + collector.append(Beat(secondsFromStart: time, precededByGap: gap)) + if done { collector.finish(continuation) } + } + store.execute(query) + } + } + + struct Voltage: Sendable { + let secondsFromStart: TimeInterval + let microvolts: Double + } + + func voltages(of ecg: HKElectrocardiogram) async throws -> [Voltage] { + let collector = Collector() + let microvolts = HKUnit.voltUnit(with: .micro) + return try await withCheckedThrowingContinuation { continuation in + let query = HKElectrocardiogramQuery(ecg) { _, result in + switch result { + case .measurement(let measurement): + if let quantity = measurement.quantity(for: .appleWatchSimilarToLeadI), + quantity.is(compatibleWith: microvolts) { + collector.append(Voltage( + secondsFromStart: measurement.timeSinceSampleStart, + microvolts: quantity.doubleValue(for: microvolts) + )) + } + case .done: + collector.finish(continuation) + case .error(let error): + collector.fail(error, continuation) + @unknown default: + collector.fail(CocoaError(.featureUnsupported), continuation) + } + } + store.execute(query) + } + } + + // MARK: - Enrich steps + + /// Workout → the routes and heart-rate samples that belong to it. + /// Heart rate is linked by association when the recording app made + /// one, otherwise by the workout's time range. + func enrichWorkout(_ dto: SampleDTO, _ sample: HKSample) async throws -> SampleDTO { + guard let workout = sample as? HKWorkout else { return dto } + var dto = dto + var details = dto.details ?? [:] + let fromWorkout = HKQuery.predicateForObjects(from: workout) + let routes = try await samples(HKSeriesType.workoutRoute(), predicate: fromWorkout) + if !routes.isEmpty { + details["routeUUIDs"] = .array(routes.map { .string($0.uuid.uuidString) }) + } + let heartRate = HKQuantityType(.heartRate) + var heartRateSamples = try await samples(heartRate, predicate: fromWorkout) + var link = "associated" + if heartRateSamples.isEmpty { + let during = HKQuery.predicateForSamples( + withStart: workout.startDate, end: workout.endDate, options: .strictStartDate) + heartRateSamples = try await samples(heartRate, predicate: during) + link = "timeRange" + } + if !heartRateSamples.isEmpty { + details["heartRateSampleUUIDs"] = .array(heartRateSamples.map { .string($0.uuid.uuidString) }) + details["heartRateLink"] = .string(link) + } + dto.details = details.isEmpty ? nil : details + return dto + } + + static let locationFields: JSONValue = [ + "secondsFromStart", "latitude", "longitude", "altitude", "horizontalAccuracy", + "verticalAccuracy", "speed", "course", "speedAccuracy", "courseAccuracy", + ] + + func enrichRoute(_ dto: SampleDTO, _ sample: HKSample) async throws -> SampleDTO { + guard let route = sample as? HKWorkoutRoute else { return dto } + var dto = dto + var details = dto.details ?? [:] + let locations = try await locations(of: route) + details["locationFields"] = Self.locationFields + details["locations"] = .array(locations.map { location in + .array([ + rounded(location.timestamp.timeIntervalSince(route.startDate), 3), + rounded(location.coordinate.latitude, 7), + rounded(location.coordinate.longitude, 7), + rounded(location.altitude, 2), + rounded(location.horizontalAccuracy, 2), + rounded(location.verticalAccuracy, 2), + rounded(location.speed, 3), + rounded(location.course, 2), + rounded(location.speedAccuracy, 3), + rounded(location.courseAccuracy, 2), + ]) + }) + if let workout = try? await parentWorkout(of: route) { + details["workoutUUID"] = .string(workout) + } + dto.details = details + return dto + } + + /// Routes don't point at their workout; the workout (saved first, the + /// route attached after) is found among those overlapping the route. + private func parentWorkout(of route: HKWorkoutRoute) async throws -> String? { + let window = HKQuery.predicateForSamples( + withStart: route.startDate.addingTimeInterval(-3600), end: route.endDate.addingTimeInterval(3600)) + for case let workout as HKWorkout in try await samples(.workoutType(), predicate: window) { + let routes = try await samples(HKSeriesType.workoutRoute(), predicate: HKQuery.predicateForObjects(from: workout)) + if routes.contains(where: { $0.uuid == route.uuid }) { + return workout.uuid.uuidString + } + } + return nil + } + + func enrichHeartbeats(_ dto: SampleDTO, _ sample: HKSample) async throws -> SampleDTO { + guard let series = sample as? HKHeartbeatSeriesSample else { return dto } + var dto = dto + var details = dto.details ?? [:] + let beats = try await beats(of: series) + details["beatFields"] = ["secondsFromStart", "precededByGap"] + details["beats"] = .array(beats.map { .array([rounded($0.secondsFromStart, 4), .bool($0.precededByGap)]) }) + dto.details = details + return dto + } + + func enrichElectrocardiogram(_ dto: SampleDTO, _ sample: HKSample) async throws -> SampleDTO { + guard let ecg = sample as? HKElectrocardiogram else { return dto } + var dto = dto + var details = dto.details ?? [:] + let voltages = try await voltages(of: ecg) + details["lead"] = "appleWatchSimilarToLeadI" + details["voltageFields"] = ["secondsFromStart", "microvolts"] + details["voltages"] = .array(voltages.map { .array([rounded($0.secondsFromStart, 4), rounded($0.microvolts, 3)]) }) + dto.details = details + return dto + } + + @available(iOS 26.0, *) + func enrichDoseEvent(_ dto: SampleDTO, _ sample: HKSample) async throws -> SampleDTO { + guard let event = sample as? HKMedicationDoseEvent else { return dto } + var dto = dto + // Names need per-object medication access; without it the concept + // key alone still identifies the medication. + let medications = (try? await userAnnotatedMedications()) ?? [] + if let match = medications.first(where: { $0.medication.identifier == event.medicationConceptIdentifier }) { + var details = dto.details ?? [:] + details["medicationName"] = .string(match.medication.displayText) + if let nickname = match.nickname { details["medicationNickname"] = .string(nickname) } + dto.details = details + } + return dto + } + + @available(iOS 26.0, *) + func userAnnotatedMedications() async throws -> [HKUserAnnotatedMedication] { + let collector = Collector() + return try await withCheckedThrowingContinuation { continuation in + let query = HKUserAnnotatedMedicationQuery(predicate: nil, limit: HKObjectQueryNoLimit) { _, medication, done, error in + if let error { return collector.fail(error, continuation) } + if let medication { collector.append(medication) } + if done { collector.finish(continuation) } + } + store.execute(query) + } + } + + private func rounded(_ value: Double, _ digits: Int) -> JSONValue { + guard value.isFinite else { return .null } + let scale = pow(10, Double(digits)) + return .number((value * scale).rounded() / scale) + } +} + +/// A stable string for a medication concept. The identifier object exposes +/// only its domain publicly, so its archived form is hashed instead. +enum MedicationIdentity { + @available(iOS 26.0, *) + static func key(_ identifier: HKHealthConceptIdentifier) -> String { + guard let data = try? NSKeyedArchiver.archivedData(withRootObject: identifier, requiringSecureCoding: true) else { + return identifier.domain.rawValue + } + let digest = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + return "\(identifier.domain.rawValue):\(digest.prefix(24))" + } +} + +/// Gathers a streaming query's callbacks and resumes its continuation +/// exactly once, whichever of done / error arrives first. +private final class Collector: @unchecked Sendable { + private let lock = NSLock() + private var items: [Element] = [] + private var finished = false + + func append(_ item: Element) { + lock.lock() + defer { lock.unlock() } + items.append(item) + } + + func append(contentsOf batch: [Element]) { + lock.lock() + defer { lock.unlock() } + items.append(contentsOf: batch) + } + + func finish(_ continuation: CheckedContinuation<[Element], Error>) { + lock.lock() + defer { lock.unlock() } + guard !finished else { return } + finished = true + continuation.resume(returning: items) + } + + func fail(_ error: Error, _ continuation: CheckedContinuation<[Element], Error>) { + lock.lock() + defer { lock.unlock() } + guard !finished else { return } + finished = true + continuation.resume(throwing: error) + } +} diff --git a/Pulso/Health/Serializers.swift b/Pulso/Health/Serializers.swift index dae0ac4..92f3791 100644 --- a/Pulso/Health/Serializers.swift +++ b/Pulso/Health/Serializers.swift @@ -7,9 +7,18 @@ struct SerializerContext: Sendable { let formatter: TimestampFormatter static let live = SerializerContext(defaultTimeZone: { .current }, formatter: TimestampFormatter()) + + func timestamp(_ date: Date) -> String { + formatter.string(date, in: defaultTimeZone()) + } } enum Serializers { + /// Metadata keys folded into protocol keys instead of passing through raw. + private static let foldedMetadataKeys: Set = [ + HKMetadataKeyTimeZone, HKMetadataKeySexualActivityProtectionUsed, + ] + /// Fields shared by every sample type. Timestamps keep the sample's own /// timezone when it recorded one (HKMetadataKeyTimeZone); otherwise the /// device timezone is used and `metadata.timeZone` stays absent so @@ -19,17 +28,52 @@ enum Serializers { TimeZone(identifier: $0) ?? TimeZone(abbreviation: $0) } let zone = recordedZone ?? context.defaultTimeZone() - return SampleDTO( + let revision = sample.sourceRevision + var dto = SampleDTO( uuid: sample.uuid.uuidString, type: type, start: context.formatter.string(sample.startDate, in: zone), end: context.formatter.string(sample.endDate, in: zone), - source: sample.sourceRevision.source.name, - sourceBundleId: sample.sourceRevision.source.bundleIdentifier, - metadata: recordedZone.map { ["timeZone": .string($0.identifier)] } + source: revision.source.name, + sourceBundleId: revision.source.bundleIdentifier ) + dto.sourceVersion = revision.version + dto.sourceProductType = revision.productType + let os = revision.operatingSystemVersion + if os.majorVersion > 0 { + dto.sourceOSVersion = "\(os.majorVersion).\(os.minorVersion).\(os.patchVersion)" + } + dto.device = device(sample.device) + + var metadata: [String: JSONValue] = [:] + if let recordedZone { metadata["timeZone"] = .string(recordedZone.identifier) } + if let protectionUsed = sample.metadata?[HKMetadataKeySexualActivityProtectionUsed] as? Bool { + metadata["protectionUsed"] = .bool(protectionUsed) + } + for (key, value) in sample.metadata ?? [:] where !foldedMetadataKeys.contains(key) { + metadata[key] = jsonValue(value, context: context) + } + dto.metadata = metadata.isEmpty ? nil : metadata + return dto } + static func device(_ device: HKDevice?) -> [String: JSONValue]? { + guard let device else { return nil } + let fields: [(String, String?)] = [ + ("name", device.name), ("manufacturer", device.manufacturer), ("model", device.model), + ("hardwareVersion", device.hardwareVersion), ("firmwareVersion", device.firmwareVersion), + ("softwareVersion", device.softwareVersion), ("localIdentifier", device.localIdentifier), + ("udiDeviceIdentifier", device.udiDeviceIdentifier), + ] + var out: [String: JSONValue] = [:] + for case let (key, value?) in fields where !value.isEmpty { + out[key] = .string(value) + } + return out.isEmpty ? nil : out + } + + // MARK: - Quantities and categories + /// `scale` multiplies the converted value before it goes on the wire. /// Used for HealthKit's percent types, which read as fractions (0–1) /// via HKUnit.percent(): scale 100 delivers human-scale 0–100 under a @@ -40,12 +84,41 @@ enum Serializers { { sample, context in guard let quantitySample = sample as? HKQuantitySample else { return nil } var dto = base(quantitySample, type: type, context: context) - dto.value = .number(quantitySample.quantity.doubleValue(for: unit) * scale) + dto.kind = "quantity" dto.unit = unitLabel + if let value = number(quantitySample.quantity, unit, scale: scale) { + dto.value = .number(value) + } else { + // A unit this build didn't expect: keep the reading rather than drop it. + dto.value = .string(quantitySample.quantity.description) + dto.unit = nil + } + var details: [String: JSONValue] = [:] + if quantitySample.count > 1 { + details["count"] = .number(Double(quantitySample.count)) + } + if let discrete = quantitySample as? HKDiscreteQuantitySample, discrete.count > 1 { + details["minimum"] = json(discrete.minimumQuantity, unit, scale: scale) + details["maximum"] = json(discrete.maximumQuantity, unit, scale: scale) + details["average"] = json(discrete.averageQuantity, unit, scale: scale) + details["mostRecent"] = json(discrete.mostRecentQuantity, unit, scale: scale) + } + dto.details = details.isEmpty ? nil : details return dto } } + /// The quantity in `unit`, or nil when the units aren't compatible — + /// HealthKit raises an exception rather than failing on a mismatch. + static func number(_ quantity: HKQuantity?, _ unit: HKUnit, scale: Double = 1) -> Double? { + guard let quantity, quantity.is(compatibleWith: unit) else { return nil } + return quantity.doubleValue(for: unit) * scale + } + + static func json(_ quantity: HKQuantity?, _ unit: HKUnit, scale: Double = 1) -> JSONValue? { + number(quantity, unit, scale: scale).map(JSONValue.number) + } + /// Generic category-sample serializer. `valueName` maps the raw category /// value to its wire name; returning nil omits `value` entirely (for /// types like mindfulSession whose value is notApplicable — the interval @@ -56,57 +129,52 @@ enum Serializers { { sample, context in guard let categorySample = sample as? HKCategorySample else { return nil } var dto = base(categorySample, type: type, context: context) + dto.kind = "category" dto.value = valueName(categorySample.value).map(SampleValue.string) return dto } } + /// Category serializer whose value names come from the SDK's enum for + /// the type; nil `valueEnum` means the value is notApplicable. + static func category(type: String, valueEnum: String?) -> @Sendable (HKSample, SerializerContext) -> SampleDTO? { + category(type: type) { raw in + valueEnum.map { enumName($0, raw) } + } + } + + /// Wire name of an enum value, or "value_" for one this build doesn't know. + static func enumName(_ enumName: String, _ rawValue: Int) -> String { + HealthKitCatalog.enumNames[enumName]?[rawValue] ?? "value_\(rawValue)" + } + /// Value is notApplicable (the event itself is the data); the one /// meaningful HealthKit metadata field becomes metadata.protectionUsed. @Sendable static func sexualActivity(_ sample: HKSample, context: SerializerContext) -> SampleDTO? { - guard let categorySample = sample as? HKCategorySample else { return nil } - var dto = base(categorySample, type: "sexualActivity", context: context) - if let protectionUsed = categorySample.metadata?[HKMetadataKeySexualActivityProtectionUsed] as? Bool { - var metadata = dto.metadata ?? [:] - metadata["protectionUsed"] = .bool(protectionUsed) - dto.metadata = metadata - } - return dto + category(type: "sexualActivity", valueEnum: nil)(sample, context) } static func standHourName(_ rawValue: Int) -> String { - switch HKCategoryValueAppleStandHour(rawValue: rawValue) { - case .stood: "stood" - case .idle: "idle" - default: "value_\(rawValue)" - } + enumName("HKCategoryValueAppleStandHour", rawValue) } @Sendable static func sleep(_ sample: HKSample, context: SerializerContext) -> SampleDTO? { - guard let categorySample = sample as? HKCategorySample else { return nil } - var dto = base(categorySample, type: "sleepAnalysis", context: context) - dto.value = .string(sleepStageName(categorySample.value)) - return dto + category(type: "sleepAnalysis", valueEnum: "HKCategoryValueSleepAnalysis")(sample, context) } static func sleepStageName(_ rawValue: Int) -> String { - switch HKCategoryValueSleepAnalysis(rawValue: rawValue) { - case .inBed: "inBed" - case .asleepUnspecified: "asleepUnspecified" - case .awake: "awake" - case .asleepCore: "asleepCore" - case .asleepDeep: "asleepDeep" - case .asleepREM: "asleepREM" - default: "value_\(rawValue)" - } + enumName("HKCategoryValueSleepAnalysis", rawValue) } + // MARK: - Workouts + @Sendable static func workout(_ sample: HKSample, context: SerializerContext) -> SampleDTO? { guard let workout = sample as? HKWorkout else { return nil } var dto = base(workout, type: "workout", context: context) + dto.kind = "workout" dto.workoutActivityType = WorkoutActivityName.name(for: workout.workoutActivityType) dto.duration = workout.duration // Statistics first; legacy totals (pre-iOS-16 recordings) as fallback. @@ -125,33 +193,75 @@ enum Serializers { dto.maximumHeartRate = heartRate.maximumQuantity()?.doubleValue(for: bpm) } - // Workouts carry their full metadata (routine names from third-party - // apps, indoor flag, weather, …). HKTimeZone is folded into the - // protocol's "timeZone" key by base() rather than passed through raw. - var metadata = dto.metadata ?? [:] - for (key, value) in workout.metadata ?? [:] where key != HKMetadataKeyTimeZone { - metadata[key] = jsonValue(value, context: context) + var details: [String: JSONValue] = [:] + if let events = workout.workoutEvents, !events.isEmpty { + details["events"] = .array(events.map { workoutEvent($0, context: context) }) } - dto.metadata = metadata.isEmpty ? nil : metadata + if !workout.workoutActivities.isEmpty { + details["activities"] = .array(workout.workoutActivities.map { activity($0, context: context) }) + } + let statistics = statisticsJSON(workout.allStatistics) + if !statistics.isEmpty { details["statistics"] = .object(statistics) } + dto.details = details.isEmpty ? nil : details return dto } - /// Strings, numbers, and booleans pass through natively; HKQuantity, - /// dates, and anything else are stringified. - static func jsonValue(_ value: Any, context: SerializerContext) -> JSONValue { - switch value { - case let string as String: - return .string(string) - case let number as NSNumber: - if CFGetTypeID(number) == CFBooleanGetTypeID() { - return .bool(number.boolValue) - } - return .number(number.doubleValue) - case let date as Date: - return .string(context.formatter.string(date, in: context.defaultTimeZone())) - default: - return .string(String(describing: value)) + static func workoutEvent(_ event: HKWorkoutEvent, context: SerializerContext) -> JSONValue { + var out: [String: JSONValue] = [ + "type": .string(enumName("HKWorkoutEventType", event.type.rawValue)), + "start": .string(context.timestamp(event.dateInterval.start)), + "end": .string(context.timestamp(event.dateInterval.end)), + "duration": .number(event.dateInterval.duration), + ] + if let metadata = event.metadata, !metadata.isEmpty { + out["metadata"] = jsonValue(metadata, context: context) + } + return .object(out) + } + + static func activity(_ activity: HKWorkoutActivity, context: SerializerContext) -> JSONValue { + let configuration = activity.workoutConfiguration + var out: [String: JSONValue] = [ + "uuid": .string(activity.uuid.uuidString), + "activityType": .string(WorkoutActivityName.name(for: configuration.activityType)), + "locationType": .string(enumName("HKWorkoutSessionLocationType", configuration.locationType.rawValue)), + "start": .string(context.timestamp(activity.startDate)), + "duration": .number(activity.duration), + ] + if let end = activity.endDate { out["end"] = .string(context.timestamp(end)) } + if configuration.swimmingLocationType != .unknown { + out["swimmingLocationType"] = .string( + enumName("HKWorkoutSwimmingLocationType", configuration.swimmingLocationType.rawValue)) + } + out["lapLengthMeters"] = json(configuration.lapLength, .meter()) + if let metadata = activity.metadata, !metadata.isEmpty { + out["metadata"] = jsonValue(metadata, context: context) } + if !activity.workoutEvents.isEmpty { + out["events"] = .array(activity.workoutEvents.map { workoutEvent($0, context: context) }) + } + let statistics = statisticsJSON(activity.allStatistics) + if !statistics.isEmpty { out["statistics"] = .object(statistics) } + return .object(out) + } + + /// Per quantity type: sum / average / min / max / most recent, in the + /// same unit that type's samples use on the wire. + static func statisticsJSON(_ all: [HKQuantityType: HKStatistics]) -> [String: JSONValue] { + var out: [String: JSONValue] = [:] + for (type, statistics) in all { + guard let presentation = TypeRegistry.presentation(for: type) else { continue } + let unit = presentation.unit + let scale = presentation.scale + var entry: [String: JSONValue] = ["unit": .string(presentation.label)] + entry["sum"] = json(statistics.sumQuantity(), unit, scale: scale) + entry["average"] = json(statistics.averageQuantity(), unit, scale: scale) + entry["minimum"] = json(statistics.minimumQuantity(), unit, scale: scale) + entry["maximum"] = json(statistics.maximumQuantity(), unit, scale: scale) + entry["mostRecent"] = json(statistics.mostRecentQuantity(), unit, scale: scale) + if entry.count > 1 { out[presentation.key] = .object(entry) } + } + return out } private static let distanceTypes: [HKQuantityTypeIdentifier] = [ @@ -175,4 +285,252 @@ enum Serializers { let value = sum.doubleValue(for: unit) return value > 0 ? value : nil } + + // MARK: - Series and other sample classes + + /// Route, heartbeat and ECG samples: the bulk data (locations, beats, + /// voltages) is added by the type's enrich step, which needs a query. + static func plain(type: String, kind: String) -> @Sendable (HKSample, SerializerContext) -> SampleDTO? { + { sample, context in + var dto = base(sample, type: type, context: context) + dto.kind = kind + if let series = sample as? HKSeriesSample { + dto.value = .number(Double(series.count)) + dto.unit = "count" + } + return dto + } + } + + @Sendable + static func electrocardiogram(_ sample: HKSample, context: SerializerContext) -> SampleDTO? { + guard let ecg = sample as? HKElectrocardiogram else { return nil } + var dto = base(ecg, type: "electrocardiogram", context: context) + dto.kind = "electrocardiogram" + let classification = enumName("HKElectrocardiogramClassification", ecg.classification.rawValue) + dto.value = .string(classification) + var details: [String: JSONValue] = [ + "classification": .string(classification), + "symptomsStatus": .string(enumName("HKElectrocardiogramSymptomsStatus", ecg.symptomsStatus.rawValue)), + "numberOfVoltageMeasurements": .number(Double(ecg.numberOfVoltageMeasurements)), + ] + details["averageHeartRate"] = json(ecg.averageHeartRate, .count().unitDivided(by: .minute())) + details["samplingFrequencyHz"] = json(ecg.samplingFrequency, .hertz()) + dto.details = details + return dto + } + + @Sendable + static func audiogram(_ sample: HKSample, context: SerializerContext) -> SampleDTO? { + guard let audiogram = sample as? HKAudiogramSample else { return nil } + var dto = base(audiogram, type: "audiogram", context: context) + dto.kind = "audiogram" + let dBHL = HKUnit.decibelHearingLevel() + let points: [JSONValue] = audiogram.sensitivityPoints.map { point in + var out: [String: JSONValue] = [:] + out["frequencyHz"] = json(point.frequency, .hertz()) + if #available(iOS 18.1, *) { + out["tests"] = .array(point.tests.map { test in + var entry: [String: JSONValue] = [ + "side": .string(enumName("HKAudiogramSensitivityTestSide", test.side.rawValue)), + "conductionType": .string(enumName("HKAudiogramConductionType", test.type.rawValue)), + "masked": .bool(test.masked), + ] + entry["sensitivityDBHL"] = json(test.sensitivity, dBHL) + if let range = test.clampingRange { + entry["clampingLowerDBHL"] = json(range.lowerBound, dBHL) + entry["clampingUpperDBHL"] = json(range.upperBound, dBHL) + } + return .object(entry) + }) + } else { + out["leftEarDBHL"] = json(point.leftEarSensitivity, dBHL) + out["rightEarDBHL"] = json(point.rightEarSensitivity, dBHL) + } + return .object(out) + } + dto.details = ["points": .array(points)] + return dto + } + + @available(iOS 18.0, *) + @Sendable + static func stateOfMind(_ sample: HKSample, context: SerializerContext) -> SampleDTO? { + guard let state = sample as? HKStateOfMind else { return nil } + var dto = base(state, type: "stateOfMind", context: context) + dto.kind = "stateOfMind" + dto.value = .number(state.valence) + // labels and associations are NS_REFINED_FOR_SWIFT; KVC reads the + // underlying NSNumber arrays without depending on the overlay's shape. + let labels = (state.value(forKey: "labels") as? [NSNumber]) ?? [] + let associations = (state.value(forKey: "associations") as? [NSNumber]) ?? [] + dto.details = [ + "kind": .string(enumName("HKStateOfMindKind", state.kind.rawValue)), + "valence": .number(state.valence), + "valenceClassification": .string( + enumName("HKStateOfMindValenceClassification", state.valenceClassification.rawValue)), + "labels": .array(labels.map { .string(enumName("HKStateOfMindLabel", $0.intValue)) }), + "associations": .array(associations.map { .string(enumName("HKStateOfMindAssociation", $0.intValue)) }), + ] + return dto + } + + /// GAD-7 and PHQ-9 questionnaires: the score is the value; risk and + /// per-question answers go in details. + @available(iOS 18.0, *) + static func scoredAssessment(type: String, enumPrefix: String) -> @Sendable (HKSample, SerializerContext) -> SampleDTO? { + { sample, context in + guard let assessment = sample as? HKScoredAssessment else { return nil } + var dto = base(assessment, type: type, context: context) + dto.kind = "scoredAssessment" + dto.value = .number(Double(assessment.score)) + var details: [String: JSONValue] = ["score": .number(Double(assessment.score))] + if assessment.responds(to: Selector(("risk"))), let risk = assessment.value(forKey: "risk") as? NSNumber { + details["risk"] = .string(enumName("\(enumPrefix)Risk", risk.intValue)) + } + if assessment.responds(to: Selector(("answers"))), + let answers = assessment.value(forKey: "answers") as? [NSNumber] { + details["answers"] = .array(answers.map { .string(enumName("\(enumPrefix)Answer", $0.intValue)) }) + } + dto.details = details + return dto + } + } + + @Sendable + static func visionPrescription(_ sample: HKSample, context: SerializerContext) -> SampleDTO? { + guard let prescription = sample as? HKVisionPrescription else { return nil } + var dto = base(prescription, type: "visionPrescription", context: context) + dto.kind = "visionPrescription" + let prescriptionType = enumName("HKVisionPrescriptionType", Int(prescription.prescriptionType.rawValue)) + dto.value = .string(prescriptionType) + var details: [String: JSONValue] = [ + "prescriptionType": .string(prescriptionType), + "dateIssued": .string(context.timestamp(prescription.dateIssued)), + ] + if let expiration = prescription.expirationDate { + details["expirationDate"] = .string(context.timestamp(expiration)) + } + if let glasses = prescription as? HKGlassesPrescription { + details["rightEye"] = glasses.rightEye.map(lens) + details["leftEye"] = glasses.leftEye.map(lens) + } else if let contacts = prescription as? HKContactsPrescription { + details["brand"] = .string(contacts.brand) + details["rightEye"] = contacts.rightEye.map(lens) + details["leftEye"] = contacts.leftEye.map(lens) + } + dto.details = details + return dto + } + + private static func lens(_ spec: HKLensSpecification) -> JSONValue { + let diopter = HKUnit.diopter() + let degrees = HKUnit.degreeAngle() + let mm = HKUnit.meterUnit(with: .milli) + var out: [String: JSONValue] = [:] + out["sphere"] = json(spec.sphere, diopter) + out["cylinder"] = json(spec.cylinder, diopter) + out["axisDegrees"] = json(spec.axis, degrees) + out["addPower"] = json(spec.addPower, diopter) + if let glasses = spec as? HKGlassesLensSpecification { + out["vertexDistanceMM"] = json(glasses.vertexDistance, mm) + out["farPupillaryDistanceMM"] = json(glasses.farPupillaryDistance, mm) + out["nearPupillaryDistanceMM"] = json(glasses.nearPupillaryDistance, mm) + if let prism = glasses.prism { + var p: [String: JSONValue] = [ + "verticalBase": .string(enumName("HKPrismBase", prism.verticalBase.rawValue)), + "horizontalBase": .string(enumName("HKPrismBase", prism.horizontalBase.rawValue)), + "eye": .string(enumName("HKVisionEye", prism.eye.rawValue)), + ] + p["amount"] = json(prism.amount, .prismDiopter()) + p["angleDegrees"] = json(prism.angle, degrees) + p["verticalAmount"] = json(prism.verticalAmount, .prismDiopter()) + p["horizontalAmount"] = json(prism.horizontalAmount, .prismDiopter()) + out["prism"] = .object(p) + } + } + if let contacts = spec as? HKContactsLensSpecification { + out["baseCurveMM"] = json(contacts.baseCurve, mm) + out["diameterMM"] = json(contacts.diameter, mm) + } + return .object(out) + } + + @available(iOS 26.0, *) + @Sendable + static func medicationDoseEvent(_ sample: HKSample, context: SerializerContext) -> SampleDTO? { + guard let event = sample as? HKMedicationDoseEvent else { return nil } + var dto = base(event, type: "medicationDoseEvent", context: context) + dto.kind = "medicationDoseEvent" + let status = enumName("HKMedicationDoseEventLogStatus", event.logStatus.rawValue) + var details: [String: JSONValue] = [ + "logStatus": .string(status), + "scheduleType": .string(enumName("HKMedicationDoseEventScheduleType", event.scheduleType.rawValue)), + "medicationConcept": .string(MedicationIdentity.key(event.medicationConceptIdentifier)), + ] + if let scheduled = event.scheduledDate { details["scheduledDate"] = .string(context.timestamp(scheduled)) } + // NS_REFINED_FOR_SWIFT quantities, read through KVC as NSNumber. + if let dose = event.value(forKey: "doseQuantity") as? NSNumber { + details["doseQuantity"] = .number(dose.doubleValue) + dto.value = .number(dose.doubleValue) + dto.unit = event.unit.unitString + } else { + dto.value = .string(status) + } + if let scheduledDose = event.value(forKey: "scheduledDoseQuantity") as? NSNumber { + details["scheduledDoseQuantity"] = .number(scheduledDose.doubleValue) + } + details["unit"] = .string(event.unit.unitString) + dto.details = details + return dto + } + + /// Blood pressure and food correlations: the member samples (also + /// synced on their own) are listed with their values for convenience. + static func correlation(type: String) -> @Sendable (HKSample, SerializerContext) -> SampleDTO? { + { sample, context in + guard let correlation = sample as? HKCorrelation else { return nil } + var dto = base(correlation, type: type, context: context) + dto.kind = "correlation" + let members: [JSONValue] = correlation.objects.compactMap { member in + guard let memberType = TypeRegistry.type(for: member.sampleType), + let memberDTO = memberType.serialize(member, context) else { return nil } + var out: [String: JSONValue] = ["uuid": .string(memberDTO.uuid), "type": .string(memberDTO.type)] + switch memberDTO.value { + case .number(let n)?: out["value"] = .number(n) + case .string(let s)?: out["value"] = .string(s) + case nil: break + } + if let unit = memberDTO.unit { out["unit"] = .string(unit) } + return .object(out) + } + dto.details = ["objects": .array(members)] + return dto + } + } + + // MARK: - Metadata values + + /// Strings, numbers, and booleans pass through natively; arrays and + /// dictionaries recurse; HKQuantity, dates, and anything else are + /// stringified. + static func jsonValue(_ value: Any, context: SerializerContext) -> JSONValue { + switch value { + case let string as String: + return .string(string) + case let number as NSNumber: + if CFGetTypeID(number) == CFBooleanGetTypeID() { + return .bool(number.boolValue) + } + return .number(number.doubleValue) + case let date as Date: + return .string(context.timestamp(date)) + case let array as [Any]: + return .array(array.map { jsonValue($0, context: context) }) + case let dictionary as [String: Any]: + return .object(dictionary.mapValues { jsonValue($0, context: context) }) + default: + return .string(String(describing: value)) + } + } } diff --git a/Pulso/Health/Snapshots.swift b/Pulso/Health/Snapshots.swift new file mode 100644 index 0000000..0b1d87f --- /dev/null +++ b/Pulso/Health/Snapshots.swift @@ -0,0 +1,171 @@ +import Foundation +import HealthKit + +/// Health data that isn't a stream of samples, so anchored queries can't +/// follow it: characteristics, daily activity rings, and the medication +/// list. Each read returns records with stable ids; SyncEngine sends only +/// the ones whose content changed since the last delivered read. +struct SnapshotSource: Identifiable { + let key: String + let displayName: String + let group: String + let readTypes: Set + /// Types needing a per-object grant (a user tap) instead of the read set. + let perObjectType: HKObjectType? + /// `firstRead` is true when nothing was delivered yet, so the read + /// should cover all history rather than a recent window. + let read: @Sendable (_ queries: HealthQueries, _ context: SerializerContext, _ firstRead: Bool) async throws -> [SampleDTO] + + var id: String { key } +} + +enum Snapshots { + static let appleHealth = "Apple Health" + + static var all: [SnapshotSource] { + var sources = [characteristics, activitySummaries] + if #available(iOS 26.0, *) { + sources.append(medications) + } + return sources + } + + private static func record( + uuid: String, type: String, start: Date, end: Date, context: SerializerContext, + value: SampleValue? = nil, details: [String: JSONValue] + ) -> SampleDTO { + var dto = SampleDTO( + uuid: uuid, type: type, + start: context.timestamp(start), end: context.timestamp(end), + value: value, source: appleHealth, sourceBundleId: "com.apple.health" + ) + dto.kind = type + dto.details = details + return dto + } + + // MARK: - Characteristics + + static let characteristics = SnapshotSource( + key: "characteristics", + displayName: "Characteristics (birth date, sex, blood type, skin type, wheelchair, move mode)", + group: TypeRegistry.Group.characteristics, + readTypes: Set(HealthKitCatalog.characteristics.compactMap { entry -> HKObjectType? in + guard ProcessInfo.processInfo.isOperatingSystemAtLeast(entry.minimumOS) else { return nil } + return HKObjectType.characteristicType(forIdentifier: HKCharacteristicTypeIdentifier(rawValue: entry.identifier)) + }), + perObjectType: nil + ) { queries, context, _ in + let store = queries.store + var details: [String: JSONValue] = [:] + if let birth = try? store.dateOfBirthComponents(), let year = birth.year, let month = birth.month, let day = birth.day { + details["dateOfBirth"] = .string(String(format: "%04d-%02d-%02d", year, month, day)) + } + if let sex = try? store.biologicalSex() { + details["biologicalSex"] = .string(Serializers.enumName("HKBiologicalSex", sex.biologicalSex.rawValue)) + } + if let blood = try? store.bloodType() { + details["bloodType"] = .string(Serializers.enumName("HKBloodType", blood.bloodType.rawValue)) + } + if let skin = try? store.fitzpatrickSkinType() { + details["fitzpatrickSkinType"] = .string(Serializers.enumName("HKFitzpatrickSkinType", skin.skinType.rawValue)) + } + if let wheelchair = try? store.wheelchairUse() { + details["wheelchairUse"] = .string(Serializers.enumName("HKWheelchairUse", wheelchair.wheelchairUse.rawValue)) + } + if let moveMode = try? store.activityMoveMode() { + details["activityMoveMode"] = .string(Serializers.enumName("HKActivityMoveMode", moveMode.activityMoveMode.rawValue)) + } + let now = Date() + return [record(uuid: "characteristics", type: "characteristics", start: now, end: now, context: context, details: details)] + } + + // MARK: - Activity summaries + + /// HealthKit (and the first Apple Watch data) starts in 2014. + private static let historyStart = DateComponents(calendar: Calendar(identifier: .gregorian), year: 2014, month: 1, day: 1).date! + /// Rings for recent days keep changing (late Watch syncs, goal edits). + private static let recentDays = 35 + + static let activitySummaries = SnapshotSource( + key: "activitySummary", + displayName: "Activity Rings (daily summaries)", + group: TypeRegistry.Group.summaries, + readTypes: [HKObjectType.activitySummaryType()], + perObjectType: nil + ) { queries, context, firstRead in + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = context.defaultTimeZone() + let today = calendar.startOfDay(for: Date()) + let from = firstRead ? historyStart : calendar.date(byAdding: .day, value: -recentDays, to: today)! + var start = calendar.dateComponents([.era, .year, .month, .day], from: from) + start.calendar = calendar + var end = calendar.dateComponents([.era, .year, .month, .day], from: today) + end.calendar = calendar + let predicate = HKQuery.predicate(forActivitySummariesBetweenStart: start, end: end) + let summaries = try await HKActivitySummaryQueryDescriptor(predicate: predicate).result(for: queries.store) + + return summaries.compactMap { summary -> SampleDTO? in + let components = summary.dateComponents(for: calendar) + guard let day = calendar.date(from: components), + let next = calendar.date(byAdding: .day, value: 1, to: day), + let year = components.year, let month = components.month, let dayOfMonth = components.day else { return nil } + let kcal = HKUnit.kilocalorie() + let minutes = HKUnit.minute() + let count = HKUnit.count() + var details: [String: JSONValue] = [ + "date": .string(String(format: "%04d-%02d-%02d", year, month, dayOfMonth)), + "activityMoveMode": .string(Serializers.enumName("HKActivityMoveMode", summary.activityMoveMode.rawValue)), + ] + details["activeEnergyBurnedKcal"] = Serializers.json(summary.activeEnergyBurned, kcal) + details["activeEnergyBurnedGoalKcal"] = Serializers.json(summary.activeEnergyBurnedGoal, kcal) + details["appleMoveTimeMin"] = Serializers.json(summary.appleMoveTime, minutes) + details["appleMoveTimeGoalMin"] = Serializers.json(summary.appleMoveTimeGoal, minutes) + details["appleExerciseTimeMin"] = Serializers.json(summary.appleExerciseTime, minutes) + details["exerciseTimeGoalMin"] = Serializers.json(summary.exerciseTimeGoal, minutes) + details["appleStandHours"] = Serializers.json(summary.appleStandHours, count) + details["standHoursGoal"] = Serializers.json(summary.standHoursGoal, count) + if #available(iOS 18.0, *) { + details["paused"] = .bool(summary.isPaused) + } + return record( + uuid: "activitySummary-\(year)-\(String(format: "%02d-%02d", month, dayOfMonth))", + type: "activitySummary", start: day, end: next, context: context, details: details + ) + } + } + + // MARK: - Medications + + @available(iOS 26.0, *) + static var medications: SnapshotSource { SnapshotSource( + key: "medication", + displayName: "Medication List", + group: TypeRegistry.Group.medications, + readTypes: [], + perObjectType: HKObjectType.userAnnotatedMedicationType() + ) { queries, context, _ in + let now = Date() + return try await queries.userAnnotatedMedications().map { annotated in + let concept = annotated.medication + let key = MedicationIdentity.key(concept.identifier) + var details: [String: JSONValue] = [ + "medicationConcept": .string(key), + "displayText": .string(concept.displayText), + "generalForm": .string(concept.generalForm.rawValue), + "isArchived": .bool(annotated.isArchived), + "hasSchedule": .bool(annotated.hasSchedule), + "relatedCodings": .array(concept.relatedCodings.sorted { $0.code < $1.code }.map { coding in + var out: [String: JSONValue] = ["system": .string(coding.system), "code": .string(coding.code)] + if let version = coding.version { out["version"] = .string(version) } + return .object(out) + }), + ] + if let nickname = annotated.nickname { details["nickname"] = .string(nickname) } + return record( + uuid: "medication-\(key)", type: "medication", start: now, end: now, context: context, + value: .string(annotated.nickname ?? concept.displayText), details: details + ) + } + } } +} diff --git a/Pulso/Health/TypeRegistry.swift b/Pulso/Health/TypeRegistry.swift index ed95f33..80c770b 100644 --- a/Pulso/Health/TypeRegistry.swift +++ b/Pulso/Health/TypeRegistry.swift @@ -1,245 +1,353 @@ import Foundation import HealthKit -/// One syncable HealthKit type: its wire key, how eagerly HealthKit may wake -/// us for it, and how its samples serialize. Adding a type is a table row. +/// One syncable HealthKit sample type: its wire key, how eagerly HealthKit +/// may wake us for it, and how its samples serialize. struct SyncedType: Identifiable { + enum Access { + /// Requested in the read set with requestAuthorization. + case read + /// Needs requestPerObjectReadAuthorization from a user tap. + case perObject + /// Not authorizable itself; readable when its member types are + /// (blood pressure and food correlations). + case viaComponents + } + let key: String // wire "type" value and persistence key let displayName: String let group: String // UI section let sampleType: HKSampleType let frequency: HKUpdateFrequency + var access: Access = .read + /// Samples per anchored query page. Lower for types whose samples are + /// large (route locations, ECG voltages) or need follow-up queries. + var queryLimit: Int = SyncEngine.queryLimit let serialize: @Sendable (HKSample, SerializerContext) -> SampleDTO? + /// Follow-up queries that complete a sample (route locations, ...). + var enrich: (@Sendable (HealthQueries, SampleDTO, HKSample) async throws -> SampleDTO)? = nil var id: String { key } } +/// How one quantity type's values go on the wire. +struct QuantityPresentation: @unchecked Sendable { + let key: String + let unit: HKUnit + let label: String + let scale: Double +} + +/// Every type the app syncs. Quantity, category and characteristic types +/// come from HealthKitCatalog (generated from the SDK headers); the sample +/// classes that need their own serializer are listed by hand below. enum TypeRegistry { enum Group { - static let sleep = "Sleep & Circadian" - static let workouts = "Workouts" - static let energy = "Energy & Activity" - static let cardio = "Cardio & Recovery" - static let body = "Body" - static let gait = "Running & Gait" - static let audio = "Audio Exposure" + static let sleep = "Sleep" + static let fitness = "Fitness" + static let heart = "Heart" + static let vitals = "Vital Signs" + static let respiratory = "Respiratory" + static let body = "Body Measurements" + static let mobility = "Mobility" static let nutrition = "Nutrition" - static let lifestyle = "Lifestyle" + static let hearing = "Hearing Health" + static let mental = "Mental Wellbeing" + static let symptoms = "Symptoms" + static let reproductive = "Reproductive Health" + static let vision = "Vision" + static let medications = "Medications" + static let other = "Other" + static let characteristics = "Characteristics" + static let summaries = "Activity Summaries" + } + + private static let groupOrder = [ + Group.sleep, Group.fitness, Group.summaries, Group.heart, Group.vitals, Group.respiratory, + Group.body, Group.mobility, Group.nutrition, Group.hearing, Group.mental, Group.symptoms, + Group.reproductive, Group.vision, Group.medications, Group.other, Group.characteristics, + ] + + private static func group(forSection section: String) -> String { + switch section { + case "Me": Group.characteristics + case let known where groupOrder.contains(known): known + default: Group.other + } + } + + /// Units Pulso v1.1 put on the wire for its original types, kept so + /// existing consumers don't break. Every other type uses the SDK's + /// canonical unit, except heart-rate-like count/s, which reads as + /// count/min. + static let legacyUnits: [String: (unit: String, label: String)] = [ + "timeInDaylight": ("min", "min"), + "appleSleepingWristTemperature": ("degC", "°C"), + "appleSleepingBreathingDisturbances": ("count", "count"), + "stepCount": ("count", "count"), + "activeEnergyBurned": ("kcal", "kcal"), + "basalEnergyBurned": ("kcal", "kcal"), + "physicalEffort": ("kcal/(kg*hr)", "kcal/hr·kg"), + "appleExerciseTime": ("min", "min"), + "appleStandTime": ("min", "min"), + "flightsClimbed": ("count", "count"), + "distanceWalkingRunning": ("m", "m"), + "distanceCycling": ("m", "m"), + "distanceSwimming": ("m", "m"), + "swimmingStrokeCount": ("count", "count"), + "heartRate": ("count/min", "count/min"), + "restingHeartRate": ("count/min", "count/min"), + "heartRateVariabilitySDNN": ("ms", "ms"), + "vo2Max": ("ml/(kg*min)", "mL/(kg·min)"), + "heartRateRecoveryOneMinute": ("count/min", "count/min"), + "walkingHeartRateAverage": ("count/min", "count/min"), + "oxygenSaturation": ("%", "%"), + "respiratoryRate": ("count/min", "count/min"), + "bodyMass": ("kg", "kg"), + "bodyFatPercentage": ("%", "%"), + "leanBodyMass": ("kg", "kg"), + "bodyMassIndex": ("count", "count"), + "waistCircumference": ("cm", "cm"), + "height": ("cm", "cm"), + "runningPower": ("W", "W"), + "runningSpeed": ("m/s", "m/s"), + "runningStrideLength": ("m", "m"), + "runningVerticalOscillation": ("cm", "cm"), + "runningGroundContactTime": ("ms", "ms"), + "walkingSpeed": ("m/s", "m/s"), + "walkingStepLength": ("cm", "cm"), + "walkingAsymmetryPercentage": ("%", "%"), + "walkingDoubleSupportPercentage": ("%", "%"), + "appleWalkingSteadiness": ("%", "%"), + "sixMinuteWalkTestDistance": ("m", "m"), + "stairAscentSpeed": ("m/s", "m/s"), + "stairDescentSpeed": ("m/s", "m/s"), + "environmentalAudioExposure": ("dBASPL", "dBASPL"), + "headphoneAudioExposure": ("dBASPL", "dBASPL"), + "dietaryEnergyConsumed": ("kcal", "kcal"), + "dietaryProtein": ("g", "g"), + "dietaryCarbohydrates": ("g", "g"), + "dietaryFatTotal": ("g", "g"), + "dietaryFiber": ("g", "g"), + "dietarySugar": ("g", "g"), + "dietarySodium": ("mg", "mg"), + "dietaryWater": ("mL", "mL"), + "dietaryCaffeine": ("mg", "mg"), + "numberOfAlcoholicBeverages": ("count", "count"), + ] + + private static func isAvailable(_ minimum: OperatingSystemVersion) -> Bool { + ProcessInfo.processInfo.isOperatingSystemAtLeast(minimum) + } + + // MARK: - Quantities + + /// HKUnit(from:) raises on a string it can't parse; a type whose unit + /// doesn't parse or doesn't fit the type is left out (and reported in + /// `skipped`) rather than crashing the app. + private static func makePresentation(_ entry: HealthKitCatalog.Quantity, _ type: HKQuantityType) -> QuantityPresentation? { + var unitString = entry.unit + var label = entry.unit + if let legacy = legacyUnits[entry.key] { + (unitString, label) = legacy + } else if unitString == "count/s" { + (unitString, label) = ("count/min", "count/min") + } + guard let unit = try? ObjC.catching({ HKUnit(from: unitString) }), type.is(compatibleWith: unit) else { + return nil + } + return QuantityPresentation(key: entry.key, unit: unit, label: label, scale: unitString == "%" ? 100 : 1) } - private static let bpm = HKUnit.count().unitDivided(by: .minute()) - private static let mps = HKUnit.meter().unitDivided(by: .second()) - - /// Shorthand: one registry row per quantity type. Everything defaults to - /// .hourly — HealthKit's cap for most quantity types anyway. - private static func quantity( - _ identifier: HKQuantityTypeIdentifier, key: String, name: String, group: String, - unit: HKUnit, label: String, scale: Double = 1 - ) -> SyncedType { - SyncedType( - key: key, displayName: name, group: group, - sampleType: HKQuantityType(identifier), frequency: .hourly, - serialize: Serializers.quantity(type: key, unit: unit, unitLabel: label, scale: scale) + private static let quantityPresentations: [String: QuantityPresentation] = { + var out: [String: QuantityPresentation] = [:] + for entry in HealthKitCatalog.quantities where isAvailable(entry.minimumOS) { + guard let type = HKObjectType.quantityType(forIdentifier: HKQuantityTypeIdentifier(rawValue: entry.identifier)), + let presentation = makePresentation(entry, type) else { continue } + out[entry.identifier] = presentation + } + return out + }() + + static func presentation(for type: HKQuantityType) -> QuantityPresentation? { + quantityPresentations[type.identifier] + } + + private static let quantityTypes: [SyncedType] = HealthKitCatalog.quantities.compactMap { entry in + guard isAvailable(entry.minimumOS), + let type = HKObjectType.quantityType(forIdentifier: HKQuantityTypeIdentifier(rawValue: entry.identifier)), + let presentation = quantityPresentations[entry.identifier] else { return nil } + // HealthKit caps most quantity types at hourly background delivery. + return SyncedType( + key: entry.key, displayName: entry.name, group: group(forSection: entry.section), + sampleType: type, frequency: .hourly, + serialize: Serializers.quantity( + type: entry.key, unit: presentation.unit, unitLabel: presentation.label, scale: presentation.scale) + ) + } + + // MARK: - Categories + + private static let categoryTypes: [SyncedType] = HealthKitCatalog.categories.compactMap { entry in + guard isAvailable(entry.minimumOS), + let type = HKObjectType.categoryType(forIdentifier: HKCategoryTypeIdentifier(rawValue: entry.identifier)) + else { return nil } + let serialize: @Sendable (HKSample, SerializerContext) -> SampleDTO? = switch entry.key { + case "sleepAnalysis": Serializers.sleep + case "sexualActivity": Serializers.sexualActivity + default: Serializers.category(type: entry.key, valueEnum: entry.valueEnum) + } + return SyncedType( + key: entry.key, displayName: entry.name, group: group(forSection: entry.section), + sampleType: type, frequency: .immediate, serialize: serialize ) } - // MARK: - Groups - // Sleep and workouts are what this app exists for — they get .immediate - // background delivery. Everything else is capped at .hourly by HealthKit. + // MARK: - Sample classes with their own shape - private static let sleepAndCircadian: [SyncedType] = { + private static let specialTypes: [SyncedType] = { var types: [SyncedType] = [ SyncedType( - key: "sleepAnalysis", displayName: "Sleep", group: Group.sleep, - sampleType: HKCategoryType(.sleepAnalysis), - frequency: .immediate, serialize: Serializers.sleep + key: "workout", displayName: "Workouts", group: Group.fitness, + sampleType: .workoutType(), frequency: .immediate, queryLimit: 100, + serialize: Serializers.workout, enrich: { try await $0.enrichWorkout($1, $2) } + ), + SyncedType( + key: "workoutRoute", displayName: "Workout Routes (GPS)", group: Group.fitness, + sampleType: HKSeriesType.workoutRoute(), frequency: .immediate, queryLimit: 10, + serialize: Serializers.plain(type: "workoutRoute", kind: "route"), + enrich: { try await $0.enrichRoute($1, $2) } + ), + SyncedType( + key: "heartbeatSeries", displayName: "Heartbeat Series (beat-to-beat)", group: Group.heart, + sampleType: HKSeriesType.heartbeat(), frequency: .immediate, queryLimit: 100, + serialize: Serializers.plain(type: "heartbeatSeries", kind: "heartbeatSeries"), + enrich: { try await $0.enrichHeartbeats($1, $2) } + ), + SyncedType( + key: "electrocardiogram", displayName: "ECG (with voltages)", group: Group.heart, + sampleType: HKObjectType.electrocardiogramType(), frequency: .immediate, queryLimit: 5, + serialize: Serializers.electrocardiogram, enrich: { try await $0.enrichElectrocardiogram($1, $2) } ), - quantity(.timeInDaylight, key: "timeInDaylight", name: "Time in Daylight", - group: Group.sleep, unit: .minute(), label: "min"), - quantity(.appleSleepingWristTemperature, key: "appleSleepingWristTemperature", - name: "Sleeping Wrist Temperature", group: Group.sleep, - unit: .degreeCelsius(), label: "°C"), - ] - if #available(iOS 18.0, *) { - types.append(quantity(.appleSleepingBreathingDisturbances, key: "appleSleepingBreathingDisturbances", - name: "Breathing Disturbances", group: Group.sleep, - unit: .count(), label: "count")) - } - types += [ SyncedType( - key: "appleStandHour", displayName: "Stand Hours", group: Group.sleep, - sampleType: HKCategoryType(.appleStandHour), - frequency: .hourly, - serialize: Serializers.category(type: "appleStandHour", valueName: Serializers.standHourName) + key: "audiogram", displayName: "Audiograms", group: Group.hearing, + sampleType: HKObjectType.audiogramSampleType(), frequency: .immediate, serialize: Serializers.audiogram ), SyncedType( - key: "mindfulSession", displayName: "Mindful Sessions", group: Group.sleep, - sampleType: HKCategoryType(.mindfulSession), - frequency: .hourly, - // Value is notApplicable; the interval itself is the session. - serialize: Serializers.category(type: "mindfulSession", valueName: { _ in nil }) + key: "visionPrescription", displayName: "Vision Prescriptions", group: Group.vision, + sampleType: HKObjectType.visionPrescriptionType(), frequency: .immediate, access: .perObject, + serialize: Serializers.visionPrescription ), ] + if #available(iOS 18.0, *) { + types.append(SyncedType( + key: "stateOfMind", displayName: "State of Mind", group: Group.mental, + sampleType: HKObjectType.stateOfMindType(), frequency: .immediate, serialize: Serializers.stateOfMind + )) + for entry in HealthKitCatalog.otherIdentifiers where entry.kind == "HKScoredAssessmentTypeIdentifier" { + guard isAvailable(entry.minimumOS), + let type = HKObjectType.scoredAssessmentType( + forIdentifier: HKScoredAssessmentTypeIdentifier(rawValue: entry.identifier)) else { continue } + let stem = String(entry.identifier.dropFirst("HKScoredAssessmentTypeIdentifier".count)) + types.append(SyncedType( + key: entry.key, displayName: "\(entry.name) Assessment", group: Group.mental, + sampleType: type, frequency: .immediate, + serialize: Serializers.scoredAssessment(type: entry.key, enumPrefix: "HK\(stem)Assessment") + )) + } + } + if #available(iOS 26.0, *) { + types.append(SyncedType( + key: "medicationDoseEvent", displayName: "Medication Doses", group: Group.medications, + sampleType: HKObjectType.medicationDoseEventType(), frequency: .immediate, + serialize: Serializers.medicationDoseEvent, enrich: { try await $0.enrichDoseEvent($1, $2) } + )) + } + for entry in HealthKitCatalog.otherIdentifiers where entry.kind == "HKCorrelationTypeIdentifier" { + guard isAvailable(entry.minimumOS), + let type = HKObjectType.correlationType(forIdentifier: HKCorrelationTypeIdentifier(rawValue: entry.identifier)) + else { continue } + types.append(SyncedType( + key: entry.key, displayName: "\(entry.name) (correlation)", group: group(forSection: entry.section), + sampleType: type, frequency: .immediate, access: .viaComponents, + serialize: Serializers.correlation(type: entry.key) + )) + } return types }() - private static let workouts: [SyncedType] = [ - SyncedType( - key: "workout", displayName: "Workouts", group: Group.workouts, - sampleType: HKObjectType.workoutType(), - frequency: .immediate, serialize: Serializers.workout - ), - ] - - private static let energyAndActivity: [SyncedType] = [ - quantity(.stepCount, key: "stepCount", name: "Steps", - group: Group.energy, unit: .count(), label: "count"), - quantity(.activeEnergyBurned, key: "activeEnergyBurned", name: "Active Energy", - group: Group.energy, unit: .kilocalorie(), label: "kcal"), - quantity(.basalEnergyBurned, key: "basalEnergyBurned", name: "Basal Energy", - group: Group.energy, unit: .kilocalorie(), label: "kcal"), - quantity(.physicalEffort, key: "physicalEffort", name: "Physical Effort", - group: Group.energy, - unit: .kilocalorie().unitDivided(by: .hour().unitMultiplied(by: .gramUnit(with: .kilo))), - label: "kcal/hr·kg"), - quantity(.appleExerciseTime, key: "appleExerciseTime", name: "Exercise Time", - group: Group.energy, unit: .minute(), label: "min"), - quantity(.appleStandTime, key: "appleStandTime", name: "Stand Time", - group: Group.energy, unit: .minute(), label: "min"), - quantity(.flightsClimbed, key: "flightsClimbed", name: "Flights Climbed", - group: Group.energy, unit: .count(), label: "count"), - quantity(.distanceWalkingRunning, key: "distanceWalkingRunning", name: "Walking + Running Distance", - group: Group.energy, unit: .meter(), label: "m"), - quantity(.distanceCycling, key: "distanceCycling", name: "Cycling Distance", - group: Group.energy, unit: .meter(), label: "m"), - quantity(.distanceSwimming, key: "distanceSwimming", name: "Swimming Distance", - group: Group.energy, unit: .meter(), label: "m"), - quantity(.swimmingStrokeCount, key: "swimmingStrokeCount", name: "Swimming Strokes", - group: Group.energy, unit: .count(), label: "count"), - ] - - private static let cardioAndRecovery: [SyncedType] = [ - quantity(.heartRate, key: "heartRate", name: "Heart Rate", - group: Group.cardio, unit: bpm, label: "count/min"), - quantity(.restingHeartRate, key: "restingHeartRate", name: "Resting Heart Rate", - group: Group.cardio, unit: bpm, label: "count/min"), - quantity(.heartRateVariabilitySDNN, key: "heartRateVariabilitySDNN", name: "HRV (SDNN)", - group: Group.cardio, unit: .secondUnit(with: .milli), label: "ms"), - quantity(.vo2Max, key: "vo2Max", name: "VO₂ Max", - group: Group.cardio, - unit: .literUnit(with: .milli) - .unitDivided(by: .gramUnit(with: .kilo).unitMultiplied(by: .minute())), - label: "mL/(kg·min)"), - quantity(.heartRateRecoveryOneMinute, key: "heartRateRecoveryOneMinute", name: "Heart Rate Recovery", - group: Group.cardio, unit: bpm, label: "count/min"), - quantity(.walkingHeartRateAverage, key: "walkingHeartRateAverage", name: "Walking Heart Rate Avg", - group: Group.cardio, unit: bpm, label: "count/min"), - quantity(.oxygenSaturation, key: "oxygenSaturation", name: "Blood Oxygen", - group: Group.cardio, unit: .percent(), label: "%", scale: 100), - quantity(.respiratoryRate, key: "respiratoryRate", name: "Respiratory Rate", - group: Group.cardio, unit: bpm, label: "count/min"), - ] - - private static let body: [SyncedType] = [ - quantity(.bodyMass, key: "bodyMass", name: "Body Mass", - group: Group.body, unit: .gramUnit(with: .kilo), label: "kg"), - quantity(.bodyFatPercentage, key: "bodyFatPercentage", name: "Body Fat", - group: Group.body, unit: .percent(), label: "%", scale: 100), - quantity(.leanBodyMass, key: "leanBodyMass", name: "Lean Body Mass", - group: Group.body, unit: .gramUnit(with: .kilo), label: "kg"), - quantity(.bodyMassIndex, key: "bodyMassIndex", name: "Body Mass Index", - group: Group.body, unit: .count(), label: "count"), - quantity(.waistCircumference, key: "waistCircumference", name: "Waist Circumference", - group: Group.body, unit: .meterUnit(with: .centi), label: "cm"), - quantity(.height, key: "height", name: "Height", - group: Group.body, unit: .meterUnit(with: .centi), label: "cm"), - ] - - private static let runningAndGait: [SyncedType] = [ - quantity(.runningPower, key: "runningPower", name: "Running Power", - group: Group.gait, unit: .watt(), label: "W"), - quantity(.runningSpeed, key: "runningSpeed", name: "Running Speed", - group: Group.gait, unit: mps, label: "m/s"), - quantity(.runningStrideLength, key: "runningStrideLength", name: "Running Stride Length", - group: Group.gait, unit: .meter(), label: "m"), - quantity(.runningVerticalOscillation, key: "runningVerticalOscillation", name: "Vertical Oscillation", - group: Group.gait, unit: .meterUnit(with: .centi), label: "cm"), - quantity(.runningGroundContactTime, key: "runningGroundContactTime", name: "Ground Contact Time", - group: Group.gait, unit: .secondUnit(with: .milli), label: "ms"), - quantity(.walkingSpeed, key: "walkingSpeed", name: "Walking Speed", - group: Group.gait, unit: mps, label: "m/s"), - quantity(.walkingStepLength, key: "walkingStepLength", name: "Walking Step Length", - group: Group.gait, unit: .meterUnit(with: .centi), label: "cm"), - quantity(.walkingAsymmetryPercentage, key: "walkingAsymmetryPercentage", name: "Walking Asymmetry", - group: Group.gait, unit: .percent(), label: "%", scale: 100), - quantity(.walkingDoubleSupportPercentage, key: "walkingDoubleSupportPercentage", name: "Double Support", - group: Group.gait, unit: .percent(), label: "%", scale: 100), - quantity(.appleWalkingSteadiness, key: "appleWalkingSteadiness", name: "Walking Steadiness", - group: Group.gait, unit: .percent(), label: "%", scale: 100), - quantity(.sixMinuteWalkTestDistance, key: "sixMinuteWalkTestDistance", name: "Six-Minute Walk", - group: Group.gait, unit: .meter(), label: "m"), - quantity(.stairAscentSpeed, key: "stairAscentSpeed", name: "Stair Ascent Speed", - group: Group.gait, unit: mps, label: "m/s"), - quantity(.stairDescentSpeed, key: "stairDescentSpeed", name: "Stair Descent Speed", - group: Group.gait, unit: mps, label: "m/s"), - ] - - private static let audioExposure: [SyncedType] = [ - quantity(.environmentalAudioExposure, key: "environmentalAudioExposure", name: "Environmental Sound", - group: Group.audio, unit: .decibelAWeightedSoundPressureLevel(), label: "dBASPL"), - quantity(.headphoneAudioExposure, key: "headphoneAudioExposure", name: "Headphone Audio", - group: Group.audio, unit: .decibelAWeightedSoundPressureLevel(), label: "dBASPL"), - ] - - private static let nutrition: [SyncedType] = [ - quantity(.dietaryEnergyConsumed, key: "dietaryEnergyConsumed", name: "Dietary Energy", - group: Group.nutrition, unit: .kilocalorie(), label: "kcal"), - quantity(.dietaryProtein, key: "dietaryProtein", name: "Protein", - group: Group.nutrition, unit: .gram(), label: "g"), - quantity(.dietaryCarbohydrates, key: "dietaryCarbohydrates", name: "Carbohydrates", - group: Group.nutrition, unit: .gram(), label: "g"), - quantity(.dietaryFatTotal, key: "dietaryFatTotal", name: "Total Fat", - group: Group.nutrition, unit: .gram(), label: "g"), - quantity(.dietaryFiber, key: "dietaryFiber", name: "Fiber", - group: Group.nutrition, unit: .gram(), label: "g"), - quantity(.dietarySugar, key: "dietarySugar", name: "Sugar", - group: Group.nutrition, unit: .gram(), label: "g"), - quantity(.dietarySodium, key: "dietarySodium", name: "Sodium", - group: Group.nutrition, unit: .gramUnit(with: .milli), label: "mg"), - quantity(.dietaryWater, key: "dietaryWater", name: "Water", - group: Group.nutrition, unit: .literUnit(with: .milli), label: "mL"), - quantity(.dietaryCaffeine, key: "dietaryCaffeine", name: "Caffeine", - group: Group.nutrition, unit: .gramUnit(with: .milli), label: "mg"), - quantity(.numberOfAlcoholicBeverages, key: "numberOfAlcoholicBeverages", name: "Alcoholic Beverages", - group: Group.nutrition, unit: .count(), label: "count"), - ] - - private static let lifestyle: [SyncedType] = [ - SyncedType( - key: "sexualActivity", displayName: "Sexual Activity", group: Group.lifestyle, - sampleType: HKCategoryType(.sexualActivity), - frequency: .hourly, - serialize: Serializers.sexualActivity - ), - ] - // MARK: - Public surface - static let all: [SyncedType] = - sleepAndCircadian + workouts + energyAndActivity + cardioAndRecovery - + body + runningAndGait + audioExposure + nutrition + lifestyle + static let all: [SyncedType] = { + let combined = specialTypes + categoryTypes + quantityTypes + let rank = Dictionary(uniqueKeysWithValues: groupOrder.enumerated().map { ($1, $0) }) + // Stable sort: registry order within a group. + return combined.enumerated().sorted { a, b in + let ra = rank[a.element.group] ?? groupOrder.count + let rb = rank[b.element.group] ?? groupOrder.count + return ra != rb ? ra < rb : a.offset < b.offset + }.map(\.element) + }() + + static var snapshots: [SnapshotSource] { Snapshots.all } - /// Group names in registry order, for UI sections. + /// Group names in display order, for UI sections. static let groups: [String] = { - var seen = Set() - return all.compactMap { seen.insert($0.group).inserted ? $0.group : nil } + let present = Set(all.map(\.group) + Snapshots.all.map(\.group)) + return groupOrder.filter(present.contains) }() + private static let byKey: [String: SyncedType] = Dictionary(all.map { ($0.key, $0) }, uniquingKeysWith: { first, _ in first }) + private static let byIdentifier: [String: SyncedType] = Dictionary( + all.map { ($0.sampleType.identifier, $0) }, uniquingKeysWith: { first, _ in first }) + static func type(for key: String) -> SyncedType? { - all.first { $0.key == key } + byKey[key] + } + + static func type(for sampleType: HKSampleType) -> SyncedType? { + byIdentifier[sampleType.identifier] } + /// Everything requestAuthorization should be asked to read, before + /// HealthAuthorization drops what this OS refuses. static var readTypes: Set { - Set(all.map { $0.sampleType as HKObjectType }) + var types = Set(all.filter { $0.access == .read }.map { $0.sampleType as HKObjectType }) + for source in Snapshots.all { + types.formUnion(source.readTypes) + } + return types + } + + /// Types granted object by object from a user tap. + static var perObjectTypes: [(key: String, displayName: String, type: HKObjectType)] { + var out = all.filter { $0.access == .perObject }.map { ($0.key, $0.displayName, $0.sampleType as HKObjectType) } + for source in Snapshots.all { + if let type = source.perObjectType { + out.append((source.key, source.displayName, type)) + } + } + return out + } + + /// SDK identifiers this build declares but doesn't sync, with the reason, + /// for the coverage list in Settings and the docs. + static var skipped: [(identifier: String, reason: String)] { + var out: [(String, String)] = [] + for entry in HealthKitCatalog.quantities where byKey[entry.key] == nil { + out.append((entry.identifier, isAvailable(entry.minimumOS) ? "unit \(entry.unit) unusable" : "needs a newer iOS")) + } + for entry in HealthKitCatalog.categories where byKey[entry.key] == nil { + out.append((entry.identifier, "needs a newer iOS")) + } + for entry in HealthKitCatalog.otherIdentifiers where byKey[entry.key] == nil { + switch entry.kind { + case "HKDocumentTypeIdentifier": + out.append((entry.identifier, "clinical document: each document needs its own approval sheet")) + default: + out.append((entry.identifier, "needs a newer iOS")) + } + } + return out } } diff --git a/Pulso/Health/WorkoutActivityName.swift b/Pulso/Health/WorkoutActivityName.swift index 076f954..6421078 100644 --- a/Pulso/Health/WorkoutActivityName.swift +++ b/Pulso/Health/WorkoutActivityName.swift @@ -1,94 +1,11 @@ import HealthKit /// Stable, human-readable names for workout activity types, matching the -/// Swift case names. Types this build doesn't know (newer OS, or long- -/// deprecated cases) serialize as "activity_" rather than being -/// silently merged into "other". +/// Swift case names (generated from the SDK's HKWorkoutActivityType). Types +/// this build doesn't know (newer OS) serialize as "activity_" +/// rather than being silently merged into "other". enum WorkoutActivityName { static func name(for activity: HKWorkoutActivityType) -> String { - switch activity { - case .americanFootball: "americanFootball" - case .archery: "archery" - case .australianFootball: "australianFootball" - case .badminton: "badminton" - case .barre: "barre" - case .baseball: "baseball" - case .basketball: "basketball" - case .bowling: "bowling" - case .boxing: "boxing" - case .cardioDance: "cardioDance" - case .climbing: "climbing" - case .cooldown: "cooldown" - case .coreTraining: "coreTraining" - case .cricket: "cricket" - case .crossCountrySkiing: "crossCountrySkiing" - case .crossTraining: "crossTraining" - case .curling: "curling" - case .cycling: "cycling" - case .discSports: "discSports" - case .downhillSkiing: "downhillSkiing" - case .elliptical: "elliptical" - case .equestrianSports: "equestrianSports" - case .fencing: "fencing" - case .fishing: "fishing" - case .fitnessGaming: "fitnessGaming" - case .flexibility: "flexibility" - case .functionalStrengthTraining: "functionalStrengthTraining" - case .golf: "golf" - case .gymnastics: "gymnastics" - case .handball: "handball" - case .handCycling: "handCycling" - case .highIntensityIntervalTraining: "highIntensityIntervalTraining" - case .hiking: "hiking" - case .hockey: "hockey" - case .hunting: "hunting" - case .jumpRope: "jumpRope" - case .kickboxing: "kickboxing" - case .lacrosse: "lacrosse" - case .martialArts: "martialArts" - case .mindAndBody: "mindAndBody" - case .mixedCardio: "mixedCardio" - case .other: "other" - case .paddleSports: "paddleSports" - case .pickleball: "pickleball" - case .pilates: "pilates" - case .play: "play" - case .preparationAndRecovery: "preparationAndRecovery" - case .racquetball: "racquetball" - case .rowing: "rowing" - case .rugby: "rugby" - case .running: "running" - case .sailing: "sailing" - case .skatingSports: "skatingSports" - case .snowboarding: "snowboarding" - case .snowSports: "snowSports" - case .soccer: "soccer" - case .socialDance: "socialDance" - case .softball: "softball" - case .squash: "squash" - case .stairClimbing: "stairClimbing" - case .stairs: "stairs" - case .stepTraining: "stepTraining" - case .surfingSports: "surfingSports" - case .swimBikeRun: "swimBikeRun" - case .swimming: "swimming" - case .tableTennis: "tableTennis" - case .taiChi: "taiChi" - case .tennis: "tennis" - case .trackAndField: "trackAndField" - case .traditionalStrengthTraining: "traditionalStrengthTraining" - case .transition: "transition" - case .underwaterDiving: "underwaterDiving" - case .volleyball: "volleyball" - case .walking: "walking" - case .waterFitness: "waterFitness" - case .waterPolo: "waterPolo" - case .waterSports: "waterSports" - case .wheelchairRunPace: "wheelchairRunPace" - case .wheelchairWalkPace: "wheelchairWalkPace" - case .wrestling: "wrestling" - case .yoga: "yoga" - default: "activity_\(activity.rawValue)" - } + HealthKitCatalog.enumNames["HKWorkoutActivityType"]?[Int(activity.rawValue)] ?? "activity_\(activity.rawValue)" } } diff --git a/Pulso/Model/AppSettings.swift b/Pulso/Model/AppSettings.swift index f2f5407..b7255d3 100644 --- a/Pulso/Model/AppSettings.swift +++ b/Pulso/Model/AppSettings.swift @@ -75,6 +75,10 @@ final class AppSettings { TypeRegistry.all.filter { currentEnabled($0.key) } } + nonisolated static func currentEnabledSnapshots() -> [SnapshotSource] { + TypeRegistry.snapshots.filter { currentEnabled($0.key) } + } + nonisolated private static func storedEnabled() -> [String: Bool] { (UserDefaults.standard.dictionary(forKey: SettingsKeys.enabledTypes) ?? [:]) .compactMapValues { $0 as? Bool } diff --git a/Pulso/Model/SampleDTO.swift b/Pulso/Model/SampleDTO.swift index 106179a..90bbaf5 100644 --- a/Pulso/Model/SampleDTO.swift +++ b/Pulso/Model/SampleDTO.swift @@ -11,13 +11,24 @@ struct SampleDTO: Codable, Equatable, Sendable { var unit: String? = nil var source: String var sourceBundleId: String? = nil - /// For workouts: the sample's full HealthKit metadata dictionary. For - /// every other type the only key is "timeZone". In both cases "timeZone" - /// appears only when the sample itself recorded one (HKMetadataKeyTimeZone); + /// The sample's full HealthKit metadata dictionary. "timeZone" appears + /// only when the sample itself recorded one (HKMetadataKeyTimeZone); /// absent means the timestamps' offset is the device timezone at sync /// time — an assumption, not a recorded fact. var metadata: [String: JSONValue]? = nil + /// What sort of HealthKit object this is: quantity, category, workout, + /// route, heartbeatSeries, electrocardiogram, ... Absent on v1 senders. + var kind: String? = nil + var sourceVersion: String? = nil + var sourceProductType: String? = nil + var sourceOSVersion: String? = nil + /// HKDevice fields that are set (name, manufacturer, model, ...). + var device: [String: JSONValue]? = nil + /// Type-specific structure: route locations, ECG voltages, workout + /// events and activities, series statistics, prescription lenses, ... + var details: [String: JSONValue]? = nil + // Workouts only. var workoutActivityType: String? = nil var duration: Double? = nil @@ -29,26 +40,35 @@ struct SampleDTO: Codable, Equatable, Sendable { var maximumHeartRate: Double? = nil } -/// A metadata value on the wire. HealthKit metadata is heterogeneous; -/// strings, numbers, and booleans pass through natively and anything else -/// (HKQuantity, dates) is stringified by the serializer. +/// Any JSON value. HealthKit metadata is heterogeneous: strings, numbers, +/// and booleans pass through natively and anything else (HKQuantity, dates) +/// is stringified by the serializer. `details` uses arrays and objects. enum JSONValue: Codable, Equatable, Sendable { case string(String) case number(Double) case bool(Bool) + case array([JSONValue]) + case object([String: JSONValue]) + case null init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() - if let s = try? container.decode(String.self) { + if container.decodeNil() { + self = .null + } else if let s = try? container.decode(String.self) { self = .string(s) } else if let b = try? container.decode(Bool.self) { self = .bool(b) } else if let n = try? container.decode(Double.self) { self = .number(n) + } else if let a = try? container.decode([JSONValue].self) { + self = .array(a) + } else if let o = try? container.decode([String: JSONValue].self) { + self = .object(o) } else { throw DecodingError.typeMismatch( JSONValue.self, - .init(codingPath: decoder.codingPath, debugDescription: "metadata values must be string, number, or bool") + .init(codingPath: decoder.codingPath, debugDescription: "not a JSON value") ) } } @@ -59,10 +79,25 @@ enum JSONValue: Codable, Equatable, Sendable { case .string(let s): try container.encode(s) case .number(let n): try container.encode(n) case .bool(let b): try container.encode(b) + case .array(let a): try container.encode(a) + case .object(let o): try container.encode(o) + case .null: try container.encodeNil() } } } +extension JSONValue: ExpressibleByStringLiteral, ExpressibleByFloatLiteral, ExpressibleByIntegerLiteral, + ExpressibleByBooleanLiteral, ExpressibleByArrayLiteral, ExpressibleByDictionaryLiteral { + init(stringLiteral value: String) { self = .string(value) } + init(floatLiteral value: Double) { self = .number(value) } + init(integerLiteral value: Int) { self = .number(Double(value)) } + init(booleanLiteral value: Bool) { self = .bool(value) } + init(arrayLiteral elements: JSONValue...) { self = .array(elements) } + init(dictionaryLiteral elements: (String, JSONValue)...) { + self = .object(Dictionary(elements, uniquingKeysWith: { _, last in last })) + } +} + /// Category samples carry a string (e.g. "asleepREM"); quantity samples a number. enum SampleValue: Codable, Equatable, Sendable { case string(String) diff --git a/Pulso/Pulso-Bridging-Header.h b/Pulso/Pulso-Bridging-Header.h new file mode 100644 index 0000000..657e4ff --- /dev/null +++ b/Pulso/Pulso-Bridging-Header.h @@ -0,0 +1 @@ +#import "Support/ObjCExceptions.h" diff --git a/Pulso/Support/ObjC.swift b/Pulso/Support/ObjC.swift new file mode 100644 index 0000000..a372d21 --- /dev/null +++ b/Pulso/Support/ObjC.swift @@ -0,0 +1,18 @@ +import Foundation + +enum ObjC { + struct Exception: Error, CustomStringConvertible { + let name: String + let reason: String + var description: String { "\(name): \(reason)" } + } + + /// Runs `body`, turning an Objective-C exception into a Swift error. + static func catching(_ body: () -> T) throws -> T { + var result: T? + if let exception = PulsoCatchException({ result = body() }) { + throw Exception(name: exception.name.rawValue, reason: exception.reason ?? "") + } + return result! + } +} diff --git a/Pulso/Support/ObjCExceptions.h b/Pulso/Support/ObjCExceptions.h new file mode 100644 index 0000000..1d46eca --- /dev/null +++ b/Pulso/Support/ObjCExceptions.h @@ -0,0 +1,10 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +/// Runs `block` and returns the Objective-C exception it raised, or nil. +/// HealthKit reports some misuse (a type the OS won't authorize, a unit +/// string it can't parse) by raising, which Swift cannot catch. +NSException *_Nullable PulsoCatchException(NS_NOESCAPE void (^block)(void)); + +NS_ASSUME_NONNULL_END diff --git a/Pulso/Support/ObjCExceptions.m b/Pulso/Support/ObjCExceptions.m new file mode 100644 index 0000000..66afca7 --- /dev/null +++ b/Pulso/Support/ObjCExceptions.m @@ -0,0 +1,10 @@ +#import "ObjCExceptions.h" + +NSException *_Nullable PulsoCatchException(NS_NOESCAPE void (^block)(void)) { + @try { + block(); + return nil; + } @catch (NSException *exception) { + return exception; + } +} diff --git a/Pulso/Sync/SyncEngine.swift b/Pulso/Sync/SyncEngine.swift index 509bffc..8a2b302 100644 --- a/Pulso/Sync/SyncEngine.swift +++ b/Pulso/Sync/SyncEngine.swift @@ -1,3 +1,4 @@ +import CryptoKit import Foundation import HealthKit @@ -53,6 +54,7 @@ actor SyncEngine { static let queryLimit = 5000 private let healthStore: HKHealthStore + private let queries: HealthQueries private let anchors: AnchorStore private let outbox: Outbox private let log: LogStore @@ -60,6 +62,9 @@ actor SyncEngine { /// GET /latest from the configured server; nil when unavailable. private let latestHints: @Sendable () async -> [String: String]? private var anchorCache: [String: HKQueryAnchor] = [:] + /// Snapshot fingerprints already queued, leading the delivered ones the + /// same way anchorCache leads the persisted anchors. + private var snapshotCache: [String: [String: String]] = [:] private var chain: Task? /// Types whose query threw last time (typically authorization not yet /// granted for a newly added type). They must not hold the catch-up @@ -77,6 +82,7 @@ actor SyncEngine { latestHints: @escaping @Sendable () async -> [String: String]? = { nil } ) { self.healthStore = healthStore + queries = HealthQueries(store: healthStore) self.anchors = anchors self.outbox = outbox self.log = log @@ -86,23 +92,29 @@ actor SyncEngine { /// Every trigger funnels here. Passes are serialized; each caller returns /// after its own pass (and the outbox drain it triggers) completes. - func requestSync(_ reason: SyncReason) async { + /// `only` limits a pass to those type keys (observer wakes name the + /// types that changed); nil means every enabled type and snapshot. + func requestSync(_ reason: SyncReason, only keys: Set? = nil) async { let previous = chain let task = Task { await previous?.value - await self.performSync(reason) + await self.performSync(reason, only: keys) } chain = task await task.value } - private func performSync(_ reason: SyncReason) async { + private func performSync(_ reason: SyncReason, only keys: Set?) async { guard AppSettings.currentTarget() != nil else { await log.info("sync skipped (\(reason)): no server configured") return } await status.setSyncing(true) - let enabled = AppSettings.currentEnabledTypes() + // Types this OS refused at authorization would only fail their query. + let refused = HealthAuthorization.lastRejected() + let enabled = AppSettings.currentEnabledTypes().filter { + !refused.contains($0.sampleType.identifier) && (keys?.contains($0.key) ?? true) + } // A user-visible pass may follow a permission grant — retry types // that were failing. Background wakes keep them excluded. @@ -157,7 +169,19 @@ actor SyncEngine { await log.error("\(type.key): \(error.localizedDescription)") } } - if reimporting && !anyFailed { + // Snapshots have no observer of their own; they ride along on every + // full pass. + if keys == nil { + for source in AppSettings.currentEnabledSnapshots() { + do { + queued += try await syncSnapshot(source) + } catch { + anyFailed = true + await log.error("\(source.key): \(error.localizedDescription)") + } + } + } + if reimporting && !anyFailed && keys == nil { UserDefaults.standard.set(false, forKey: SettingsKeys.reimportPending) await log.info("full re-import pass completed for all enabled types") } @@ -197,7 +221,7 @@ actor SyncEngine { let descriptor = HKAnchoredObjectQueryDescriptor( predicates: [.sample(type: type.sampleType, predicate: datePredicate)], anchor: anchor, - limit: Self.queryLimit + limit: type.queryLimit ) let result = try await descriptor.result(for: healthStore) let added = result.addedSamples @@ -215,7 +239,14 @@ actor SyncEngine { return queued } - let dtos = added.compactMap { type.serialize($0, .live) } + var dtos: [SampleDTO] = [] + dtos.reserveCapacity(added.count) + for sample in added { + guard let dto = type.serialize(sample, .live) else { continue } + // A failed enrich query throws out of the pass: the anchor + // stays put and these samples are read again next time. + dtos.append(try await type.enrich?(queries, dto, sample) ?? dto) + } if dtos.count < added.count { await log.warn("\(type.key): \(added.count - dtos.count) sample(s) not serializable — skipped") } @@ -236,12 +267,57 @@ actor SyncEngine { } else { await log.info("\(type.key): queued \(dtos.count) sample(s)") } - if added.count < Self.queryLimit && deleted.count < Self.queryLimit { + if added.count < type.queryLimit && deleted.count < type.queryLimit { return queued } } } + /// Reads a snapshot source and queues the records whose content changed + /// since the last delivered read. The new fingerprints ride in the + /// batch's anchor slot, so they are persisted only once the server ACKs. + private func syncSnapshot(_ source: SnapshotSource) async throws -> Int { + let stored = anchors.loadRaw(source.key) + let delivered = snapshotCache[source.key] + ?? stored.flatMap { try? JSONDecoder().decode([String: String].self, from: $0) } + let records = try await source.read(queries, .live, delivered == nil) + var state = delivered ?? [:] + var changed: [SampleDTO] = [] + for record in records { + let fingerprint = Self.fingerprint(record) + if state[record.uuid] != fingerprint { + state[record.uuid] = fingerprint + changed.append(record) + } + } + await status.recordChecked(source.key) + let encoded = try JSONEncoder().encode(state) + guard !changed.isEmpty else { + if stored == nil { anchors.saveRaw(encoded, for: source.key) } + return 0 + } + try await outbox.enqueue(typeKey: source.key, elements: changed.map(BatchElement.sample), anchor: encoded) + snapshotCache[source.key] = state + await log.info("\(source.key): queued \(changed.count) changed record(s)") + return changed.count + } + + /// Content identity of a snapshot record; timestamps are left out + /// because characteristics and medications are stamped at read time. + static func fingerprint(_ record: SampleDTO) -> String { + let content: [String: JSONValue] = [ + "value": record.value.map { value -> JSONValue in + switch value { + case .string(let s): .string(s) + case .number(let n): .number(n) + } + } ?? .null, + "details": .object(record.details ?? [:]), + ] + let data = (try? Wire.bodyEncoder().encode(content)) ?? Data() + return SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + /// The SDK has flip-flopped on the optionality of `newAnchor`; funneling /// it through an optional parameter compiles either way. private func normalize(_ anchor: HKQueryAnchor?) -> HKQueryAnchor? { @@ -250,6 +326,7 @@ actor SyncEngine { func resetAllAnchors() { anchorCache = [:] + snapshotCache = [:] authBlocked = [] latestFetch = nil } diff --git a/Pulso/Sync/TriggerHub.swift b/Pulso/Sync/TriggerHub.swift index a4bafd2..ef05ba6 100644 --- a/Pulso/Sync/TriggerHub.swift +++ b/Pulso/Sync/TriggerHub.swift @@ -61,42 +61,54 @@ final class TriggerHub { // MARK: - Observer queries - /// (Re)registers observer queries and background delivery for the - /// currently enabled types. Called at launch and when toggles change. + /// (Re)registers background delivery and one observer query covering + /// every enabled type. Called at launch and when toggles change. func startObservers() async { stopObservers() + let refused = HealthAuthorization.lastRejected() + var descriptors: [HKQueryDescriptor] = [] + var unavailable: [String] = [] for type in TypeRegistry.all { - guard AppSettings.currentEnabled(type.key) else { + guard AppSettings.currentEnabled(type.key), !refused.contains(type.sampleType.identifier) else { try? await healthStore.disableBackgroundDelivery(for: type.sampleType) continue } + // Correlations change only through their member types, which + // have observers of their own; full passes pick them up. + guard type.access != .viaComponents else { continue } do { try await healthStore.enableBackgroundDelivery(for: type.sampleType, frequency: type.frequency) } catch { - await log.warn("background delivery unavailable for \(type.key): \(error.localizedDescription)") + unavailable.append(type.key) } - let key = type.key - let query = HKObserverQuery(sampleType: type.sampleType, predicate: nil) { [weak self] _, completionHandler, error in - if error != nil { + descriptors.append(HKQueryDescriptor(sampleType: type.sampleType, predicate: nil)) + } + if !unavailable.isEmpty { + await log.warn("background delivery unavailable for \(unavailable.count) type(s): \(unavailable.prefix(15).joined(separator: ", "))") + } + guard !descriptors.isEmpty else { return } + let query = HKObserverQuery(queryDescriptors: descriptors) { [weak self] _, changed, completionHandler, error in + if error != nil { + completionHandler() + return + } + let keys = Set((changed ?? []).compactMap { TypeRegistry.type(for: $0)?.key }) + Task { @MainActor [weak self] in + // HealthKit throttles future deliveries if the completion + // handler is called before the work is done — so it is + // called after the sync pass, from a background task with + // a watchdog in case the server hangs. + guard let self else { completionHandler() return } - Task { @MainActor [weak self] in - // HealthKit throttles future deliveries if the completion - // handler is called before the work is done — so it is - // called after the sync pass, from a background task with - // a watchdog in case the server hangs. - guard let self else { - completionHandler() - return - } - await self.observerFired(key) - completionHandler() - } + await self.observerFired(keys) + completionHandler() } - healthStore.execute(query) - observerQueries.append(query) } + healthStore.execute(query) + observerQueries.append(query) + await log.info("observing \(descriptors.count) type(s) for background delivery") } func stopObservers() { @@ -104,24 +116,27 @@ final class TriggerHub { observerQueries.removeAll() } - private func observerFired(_ key: String) async { + private func observerFired(_ keys: Set) async { let backgroundTask = UIApplication.shared.beginBackgroundTask(withName: "pulso-observer-sync") defer { if backgroundTask != .invalid { UIApplication.shared.endBackgroundTask(backgroundTask) } } + let label = keys.count == 1 ? keys.first! : "\(keys.count) types" if UIApplication.shared.isProtectedDataAvailable { - await log.info("observer wake: \(key)") + await log.info("observer wake: \(label)") } else { // F2: health data is unreadable while locked. The attempt is // still made — it fails cleanly without moving anchors — and the // next unlock-time trigger catches up. - await log.info("observer wake (\(key)) while device locked — will catch up after unlock") + await log.info("observer wake (\(label)) while device locked — will catch up after unlock") } let engine = engine + // An empty set (HealthKit didn't say which types) means a full pass. + let only = keys.isEmpty ? nil : keys await withTimeout(seconds: 25) { - await engine.requestSync(.observer(key)) + await engine.requestSync(.observer(label), only: only) } } } diff --git a/Pulso/UI/SettingsView.swift b/Pulso/UI/SettingsView.swift index 0b9f823..fa2129a 100644 --- a/Pulso/UI/SettingsView.swift +++ b/Pulso/UI/SettingsView.swift @@ -42,13 +42,41 @@ struct SettingsView: View { Text("Base URL of your Pulso server — every sample goes there and nowhere else. Plain HTTP is fine on a Tailscale network; the tunnel is already encrypted.") } + Section { + ForEach(TypeRegistry.perObjectTypes, id: \.key) { entry in + Button("Choose \(entry.displayName) to share") { + Task { await services.requestPerObjectAccess(entry.key) } + } + } + NavigationLink("Coverage: what is synced and what isn't") { + CoverageView() + } + } header: { + Text("Health Access") + } footer: { + Text("HealthKit shares these item by item from its own picker, so they need a tap here rather than the main permission sheet.") + } + ForEach(TypeRegistry.groups, id: \.self) { group in + let keys = TypeRegistry.all.filter { $0.group == group }.map(\.key) + + TypeRegistry.snapshots.filter { $0.group == group }.map(\.key) Section { + ForEach(TypeRegistry.snapshots.filter { $0.group == group }) { source in + Toggle(source.displayName, isOn: enabledBinding(source.key)) + } ForEach(TypeRegistry.all.filter { $0.group == group }) { type in Toggle(type.displayName, isOn: enabledBinding(type.key)) } } header: { - Text(group) + HStack { + Text(group) + Spacer() + Button("All") { setAll(keys, true) } + Text("·") + Button("None") { setAll(keys, false) } + } + .font(.caption) + .buttonStyle(.borderless) } footer: { if group == TypeRegistry.groups.last { Text("Disabling a type stops new deliveries; data already on your server stays there. Newly enabled types back-fill their full history on the next sync.") @@ -92,9 +120,51 @@ struct SettingsView: View { } } + private func setAll(_ keys: [String], _ value: Bool) { + for key in keys { + services.settings.setEnabled(key, value) + } + services.typeTogglesChanged() + } + private var appVersion: String { let short = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "?" let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "?" return "\(short) (\(build))" } } + +/// Every identifier in the SDK this build was compiled with, split into +/// what syncs and what doesn't (with the reason). +struct CoverageView: View { + var body: some View { + let refused = HealthAuthorization.lastRejected() + List { + Section { + LabeledContent("SDK", value: "iOS \(HealthKitCatalog.sdkVersion)") + LabeledContent("Sample types", value: "\(TypeRegistry.all.count)") + LabeledContent("Snapshot sources", value: "\(TypeRegistry.snapshots.count)") + } + if !refused.isEmpty { + Section("Refused by this iOS") { + ForEach(refused.sorted(), id: \.self) { Text($0).font(.caption.monospaced()) } + } + } + Section("Not synced") { + ForEach(TypeRegistry.skipped, id: \.identifier) { entry in + VStack(alignment: .leading) { + Text(entry.identifier).font(.caption.monospaced()) + Text(entry.reason).font(.caption2).foregroundStyle(.secondary) + } + } + Text("Clinical records (FHIR) are not requested: they need the health-records entitlement, which re-signing certificates generally don't carry.") + .font(.caption2).foregroundStyle(.secondary) + } + Section("Synced") { + ForEach(TypeRegistry.snapshots) { Text($0.key).font(.caption.monospaced()) } + ForEach(TypeRegistry.all) { Text($0.key).font(.caption.monospaced()) } + } + } + .navigationTitle("Coverage") + } +} diff --git a/Pulso/UI/StatusView.swift b/Pulso/UI/StatusView.swift index 94885c3..1e98158 100644 --- a/Pulso/UI/StatusView.swift +++ b/Pulso/UI/StatusView.swift @@ -50,12 +50,18 @@ struct StatusView: View { } } + Section { + LabeledContent("Types with data", value: "\(typesWithData.count) of \(enabledKeys.count) enabled") + } footer: { + Text("Types appear below once something was delivered. HealthKit won't say whether an empty type is unauthorized or simply has no data (Health app → Sharing).") + } + ForEach(TypeRegistry.groups, id: \.self) { group in - let types = enabledTypes.filter { $0.group == group } - if !types.isEmpty { + let rows = rowsWithData(in: group) + if !rows.isEmpty { Section(group) { - ForEach(types) { type in - typeRow(type) + ForEach(rows) { row in + typeRow(key: row.key, name: row.name) } } } @@ -91,34 +97,42 @@ struct StatusView: View { } } - private var enabledTypes: [SyncedType] { - TypeRegistry.all.filter { services.settings.isEnabled($0.key) } + private var enabledKeys: [String] { + (TypeRegistry.snapshots.map(\.key) + TypeRegistry.all.map(\.key)).filter { services.settings.isEnabled($0) } + } + + private var typesWithData: [String] { + enabledKeys.filter { (status.typeStatus[$0]?.totalDelivered ?? 0) > 0 } + } + + private struct Row: Identifiable { + let key: String + let name: String + var id: String { key } + } + + private func rowsWithData(in group: String) -> [Row] { + let rows = TypeRegistry.snapshots.filter { $0.group == group }.map { Row(key: $0.key, name: $0.displayName) } + + TypeRegistry.all.filter { $0.group == group }.map { Row(key: $0.key, name: $0.displayName) } + return rows.filter { services.settings.isEnabled($0.key) && (status.typeStatus[$0.key]?.totalDelivered ?? 0) > 0 } } @ViewBuilder - private func typeRow(_ type: SyncedType) -> some View { + private func typeRow(key: String, name: String) -> some View { VStack(alignment: .leading, spacing: 2) { - Text(type.displayName) - Group { - if let ts = status.typeStatus[type.key], ts.totalDelivered > 0 { - HStack(spacing: 4) { - Text("\(ts.totalDelivered) delivered") - if let checked = ts.lastChecked { - Text("· checked") - Text(checked, style: .relative) - Text("ago") - } + Text(name) + if let ts = status.typeStatus[key] { + HStack(spacing: 4) { + Text("\(ts.totalDelivered) delivered") + if let checked = ts.lastChecked { + Text("· checked") + Text(checked, style: .relative) + Text("ago") } - } else if status.typeStatus[type.key]?.lastChecked != nil { - // F1: HealthKit won't say whether read access was denied — - // "no data" and "no permission" look identical. - Text("No data yet — or Health access not granted (Health app → Sharing)") - } else { - Text("Waiting for first sync") } + .font(.caption) + .foregroundStyle(.secondary) } - .font(.caption) - .foregroundStyle(.secondary) } } } diff --git a/PulsoTests/HealthKitCatalogTests.swift b/PulsoTests/HealthKitCatalogTests.swift new file mode 100644 index 0000000..81537fc --- /dev/null +++ b/PulsoTests/HealthKitCatalogTests.swift @@ -0,0 +1,102 @@ +import HealthKit +import XCTest +@testable import Pulso + +final class HealthKitCatalogTests: XCTestCase { + private let context = SerializerContext( + defaultTimeZone: { TimeZone(identifier: "Asia/Dubai")! }, + formatter: TimestampFormatter() + ) + + /// Every identifier the generated catalog lists for this OS must resolve, + /// and every quantity must get a unit HKUnit parses and the type accepts. + func testEveryCatalogTypeResolvesWithAUsableUnit() { + var problems: [String] = [] + let info = ProcessInfo.processInfo + for entry in HealthKitCatalog.quantities where info.isOperatingSystemAtLeast(entry.minimumOS) { + guard let type = HKObjectType.quantityType(forIdentifier: HKQuantityTypeIdentifier(rawValue: entry.identifier)) else { + problems.append("unresolved \(entry.identifier)") + continue + } + if TypeRegistry.presentation(for: type) == nil { + problems.append("no usable unit for \(entry.identifier) (\(entry.unit))") + } + } + for entry in HealthKitCatalog.categories where info.isOperatingSystemAtLeast(entry.minimumOS) { + if HKObjectType.categoryType(forIdentifier: HKCategoryTypeIdentifier(rawValue: entry.identifier)) == nil { + problems.append("unresolved \(entry.identifier)") + } + if let valueEnum = entry.valueEnum, HealthKitCatalog.enumNames[valueEnum] == nil { + problems.append("no value names for \(entry.identifier) (\(valueEnum))") + } + } + for entry in HealthKitCatalog.characteristics where info.isOperatingSystemAtLeast(entry.minimumOS) { + if HKObjectType.characteristicType(forIdentifier: HKCharacteristicTypeIdentifier(rawValue: entry.identifier)) == nil { + problems.append("unresolved \(entry.identifier)") + } + } + XCTAssertEqual(problems, []) + XCTAssertGreaterThan(TypeRegistry.all.count, 180) + } + + /// The probe must leave a read set HealthKit accepts as a whole — + /// requesting it is what the Grant button does. + func testReadSetProbeLeavesAnAcceptedSet() throws { + let store = HKHealthStore() + let readSet = HealthAuthorization.readSet(TypeRegistry.readTypes, store: store) + print("PULSO-READSET accepted=\(readSet.accepted.count) refused=\(readSet.rejected.keys.sorted())") + for (identifier, reason) in readSet.rejected { + print("PULSO-REFUSED \(identifier): \(reason)") + } + XCTAssertGreaterThan(readSet.accepted.count, 150) + XCTAssertNoThrow(try ObjC.catching { + store.getRequestStatusForAuthorization(toShare: [], read: readSet.accepted) { _, _ in } + }) + } + + func testCategoryValuesSerializeByEnumName() throws { + let start = TestSupport.date(2026, 7, 6, 10, 0, zone: "Asia/Dubai") + let headache = HKCategorySample( + type: HKCategoryType(.headache), value: HKCategoryValueSeverity.moderate.rawValue, + start: start, end: start.addingTimeInterval(3600) + ) + let dto = try XCTUnwrap(TypeRegistry.type(for: "headache")?.serialize(headache, context)) + XCTAssertEqual(dto.value, .string("moderate")) + XCTAssertEqual(dto.kind, "category") + XCTAssertEqual(Serializers.enumName("HKCategoryValueSeverity", 999), "value_999") + } + + func testStateOfMindAndAssessmentsReadTheirRefinedArrays() throws { + guard #available(iOS 18.0, *) else { throw XCTSkip("needs iOS 18") } + let now = Date() + let state = HKStateOfMind( + date: now, kind: .momentaryEmotion, valence: 0.5, + labels: [.happy, .calm], associations: [.fitness] + ) + let mood = try XCTUnwrap(Serializers.stateOfMind(state, context: context)) + XCTAssertEqual(mood.details?["labels"], ["happy", "calm"]) + XCTAssertEqual(mood.details?["associations"], ["fitness"]) + XCTAssertEqual(mood.details?["kind"], "momentaryEmotion") + + let gad7 = HKGAD7Assessment(date: now, answers: Array(repeating: .severalDays, count: 7)) + let serialize = try XCTUnwrap(TypeRegistry.type(for: "gad7")).serialize + let dto = try XCTUnwrap(serialize(gad7, context)) + XCTAssertEqual(dto.value, .number(7)) + XCTAssertEqual(dto.details?["risk"], "mild") + XCTAssertEqual(dto.details?["answers"], .array(Array(repeating: "severalDays", count: 7))) + } + + /// Snapshot records are stamped at read time; only content may decide + /// whether a record is re-sent. + func testSnapshotFingerprintIgnoresTimestamps() { + var a = SampleDTO(uuid: "characteristics", type: "characteristics", start: "2026-01-01T00:00:00Z", + end: "2026-01-01T00:00:00Z", source: "Apple Health") + a.details = ["bloodType": "oPositive"] + var b = a + b.start = "2026-02-02T00:00:00Z" + b.end = b.start + XCTAssertEqual(SyncEngine.fingerprint(a), SyncEngine.fingerprint(b)) + b.details = ["bloodType": "aNegative"] + XCTAssertNotEqual(SyncEngine.fingerprint(a), SyncEngine.fingerprint(b)) + } +} diff --git a/PulsoTests/SerializerTests.swift b/PulsoTests/SerializerTests.swift index 8793cc3..410f248 100644 --- a/PulsoTests/SerializerTests.swift +++ b/PulsoTests/SerializerTests.swift @@ -251,51 +251,36 @@ final class SerializerTests: XCTestCase { XCTAssertNil(dto.metadata, "no metadata key means absent, not false") } - /// The wire type keys are protocol surface — pin the full v1.1 set. - func testRegistryCoversTheV11TypeSet() { - var expected = [ - // Sleep & Circadian - "sleepAnalysis", "timeInDaylight", "appleSleepingWristTemperature", + /// The v1.1 wire type keys are protocol surface: the full-coverage + /// registry must still carry every one, with the v1.1 unit label. + func testRegistryKeepsTheV11TypeSetAndUnits() { + var expected: [String: String?] = [ + "sleepAnalysis": nil, "appleStandHour": nil, "mindfulSession": nil, "workout": nil, "sexualActivity": nil, ] - if #available(iOS 18.0, *) { - expected.append("appleSleepingBreathingDisturbances") + for (key, legacy) in TypeRegistry.legacyUnits { + expected[key] = legacy.label } - expected += [ - "appleStandHour", "mindfulSession", - // Workouts - "workout", - // Energy & Activity - "stepCount", "activeEnergyBurned", "basalEnergyBurned", "physicalEffort", - "appleExerciseTime", "appleStandTime", "flightsClimbed", - "distanceWalkingRunning", "distanceCycling", "distanceSwimming", "swimmingStrokeCount", - // Cardio & Recovery - "heartRate", "restingHeartRate", "heartRateVariabilitySDNN", "vo2Max", - "heartRateRecoveryOneMinute", "walkingHeartRateAverage", "oxygenSaturation", "respiratoryRate", - // Body - "bodyMass", "bodyFatPercentage", "leanBodyMass", "bodyMassIndex", - "waistCircumference", "height", - // Running & Gait - "runningPower", "runningSpeed", "runningStrideLength", "runningVerticalOscillation", - "runningGroundContactTime", "walkingSpeed", "walkingStepLength", - "walkingAsymmetryPercentage", "walkingDoubleSupportPercentage", "appleWalkingSteadiness", - "sixMinuteWalkTestDistance", "stairAscentSpeed", "stairDescentSpeed", - // Audio Exposure - "environmentalAudioExposure", "headphoneAudioExposure", - // Nutrition - "dietaryEnergyConsumed", "dietaryProtein", "dietaryCarbohydrates", "dietaryFatTotal", - "dietaryFiber", "dietarySugar", "dietarySodium", "dietaryWater", "dietaryCaffeine", - "numberOfAlcoholicBeverages", - // Lifestyle - "sexualActivity", - ] - XCTAssertEqual(TypeRegistry.all.map(\.key), expected) - XCTAssertEqual(Set(TypeRegistry.all.map(\.key)).count, TypeRegistry.all.count, "keys must be unique") - XCTAssertEqual(TypeRegistry.readTypes.count, TypeRegistry.all.count) - XCTAssertEqual(TypeRegistry.groups.count, 9) - // Only the app's raison-d'être types warrant .immediate wakes. - XCTAssertEqual( - TypeRegistry.all.filter { $0.frequency == .immediate }.map(\.key), - ["sleepAnalysis", "workout"] - ) + if #unavailable(iOS 18.0) { + expected["appleSleepingBreathingDisturbances"] = nil + } + XCTAssertEqual(expected.count, 58 - (ProcessInfo.processInfo.isOperatingSystemAtLeast( + OperatingSystemVersion(majorVersion: 18, minorVersion: 0, patchVersion: 0)) ? 0 : 1)) + let start = Date() + for (key, label) in expected { + guard let type = TypeRegistry.type(for: key) else { + XCTFail("v1.1 type \(key) missing") + continue + } + guard let label, let quantityType = type.sampleType as? HKQuantityType, + let presentation = TypeRegistry.presentation(for: quantityType) else { continue } + XCTAssertEqual(presentation.label, label, key) + let sample = HKQuantitySample( + type: quantityType, quantity: HKQuantity(unit: presentation.unit, doubleValue: 1), start: start, end: start) + XCTAssertEqual(type.serialize(sample, context)?.unit, label, key) + } + let keys = TypeRegistry.all.map(\.key) + TypeRegistry.snapshots.map(\.key) + XCTAssertEqual(Set(keys).count, keys.count, "keys must be unique") + XCTAssertEqual(TypeRegistry.type(for: "sleepAnalysis")?.frequency, .immediate) + XCTAssertEqual(TypeRegistry.type(for: "workout")?.frequency, .immediate) } } diff --git a/scripts/gen_healthkit_catalog.py b/scripts/gen_healthkit_catalog.py new file mode 100644 index 0000000..5565cd8 --- /dev/null +++ b/scripts/gen_healthkit_catalog.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +"""Generates Pulso/Health/HealthKitCatalog.generated.swift from the iOS SDK's +HealthKit headers, so the app reads every type the SDK declares instead of a +hand-picked list. + + scripts/gen_healthkit_catalog.py + +CI runs it against the runner's SDK before building (so the IPA always covers +the SDK it was compiled with) and warns when the committed file is stale. + +What it reads: +- HKTypeIdentifiers.h: every quantity, category and characteristic identifier, + its iOS availability, header section, and the trailing comment that names + the canonical unit (quantities) or the value enum (categories). +- Every header's NS_ENUMs (category values, workout activity types, ECG + classifications, state-of-mind labels, characteristics, ...), so enum + values serialize by name. +""" +import os +import re +import sys + +ID_LINE = re.compile(r"HK_EXTERN\s+(\w+)\s+const\s+(\w+)\s+(.*?);\s*(?://\s*(.*))?$") +ENUM_START = re.compile(r"typedef\s+NS_ENUM\s*\(\s*\w+\s*,\s*(\w+)\s*\)\s*\{") +ENUM_CASE = re.compile(r"^\s*(\w+)\s*(.*?)\s*(?:=\s*([\w-]+))?\s*,?\s*(?://.*)?$") + + +def ios_version(attrs): + m = re.search(r"\bios\((\d+)(?:\.(\d+))?", attrs) + return (int(m.group(1)), int(m.group(2) or 0)) if m else None + + +def deprecated(attrs): + return "DEPRECATED" in attrs or "deprecated(" in attrs + + +def unavailable_on_ios(attrs): + return re.search(r"API_UNAVAILABLE\([^)]*\bios\b", attrs) is not None + + +def wire_key(stem): + """Swift-style lower camel case: StepCount -> stepCount, VO2Max -> vo2Max, + UVExposure -> uvExposure, GAD7 -> gad7.""" + m = re.match(r"^([A-Z]+)(.*)$", stem) + if not m: + return stem + caps, rest = m.groups() + if rest and rest[0].islower() and len(caps) > 1: + return caps[:-1].lower() + caps[-1] + rest + return caps.lower() + rest + + +def display_name(stem): + words = re.findall(r"[A-Z]+(?=[A-Z][a-z]|$)|[A-Z]?[a-z]+\d*|[A-Z]+\d*|\d+", stem) + return " ".join(words) + + +def parse_identifiers(path): + out = [] + section = "Other" + for raw in open(path, encoding="utf-8"): + line = raw.rstrip() + s = re.match(r"^//\s+([A-Z][A-Za-z &]+)$", line) + if s: + section = s.group(1).strip() + continue + m = ID_LINE.match(line) + if not m: + continue + kind, name, attrs, comment = m.groups() + if unavailable_on_ios(attrs): + continue + out.append({"kind": kind, "name": name, "ios": ios_version(attrs) or (8, 0), + "deprecated": deprecated(attrs), "comment": (comment or "").strip(), "section": section}) + return out + + +def parse_enums(path, wanted=None): + """Returns {enum name: [(value, case name without prefix, deprecated)]}. + Handles implicit increments and aliases (= OtherCase).""" + enums = {} + text = open(path, encoding="utf-8").read() + for m in ENUM_START.finditer(text): + name = m.group(1) + if wanted and name not in wanted: + continue + body = text[m.end():text.index("}", m.end())] + values = {} + cases = [] + nxt = 0 + for raw in body.split("\n"): + raw = raw.strip() + if not raw or raw.startswith(("//", "/*", "*")): + continue + cm = re.match(r"^(\w+)(.*?)(?:=\s*([\w-]+))?\s*,?\s*$", raw) + if not cm: + continue + case, attrs, value = cm.groups() + if value is None: + v = nxt + elif re.match(r"^-?\d+$", value): + v = int(value) + else: + v = values[value] + values[case] = v + nxt = v + 1 + short = case[len(name):] if case.startswith(name) else case + cases.append((v, short, deprecated(attrs))) + enums[name] = cases + return enums + + +def value_names(cases): + """value -> wire name, preferring the non-deprecated case for aliases.""" + names = {} + for v, short, dep in cases: + if v not in names or (names[v][1] and not dep): + names[v] = (wire_key(short), dep) + return {v: n for v, (n, _) in sorted(names.items())} + + +def swift_str(s): + return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"' + + +def os_lit(v): + return "OperatingSystemVersion(majorVersion: %d, minorVersion: %d, patchVersion: 0)" % v + + +def main(headers, output): + ids = parse_identifiers(os.path.join(headers, "HKTypeIdentifiers.h")) + enums = {} + for header in sorted(os.listdir(headers)): + if header.endswith(".h"): + enums.update(parse_enums(os.path.join(headers, header))) + cat_enums = {k: v for k, v in enums.items() if k.startswith("HKCategoryValue")} + sdk = open(os.path.join(headers, "..", "SDK_VERSION")).read().strip() if os.path.exists( + os.path.join(headers, "..", "SDK_VERSION")) else "unknown" + + q, c, ch, other = [], [], [], [] + for e in ids: + prefix = e["kind"] + stem = e["name"][len(prefix):] if e["name"].startswith(prefix) else e["name"] + if e["kind"] == "HKQuantityTypeIdentifier": + unit, _, agg = e["comment"].partition(",") + q.append(dict(e, stem=stem, unit=unit.strip() or "count", aggregation=agg.strip())) + elif e["kind"] == "HKCategoryTypeIdentifier": + enum = e["comment"].split()[0] if e["comment"] else "" + c.append(dict(e, stem=stem, enum=enum if enum in cat_enums and enum != "HKCategoryValue" else None)) + elif e["kind"] == "HKCharacteristicTypeIdentifier": + ch.append(dict(e, stem=stem)) + else: + other.append(e) + + lines = [ + "// Generated by scripts/gen_healthkit_catalog.py from the iOS %s SDK headers. Do not edit." % sdk, + "// swiftlint:disable all", + "import Foundation", + "", + "/// Every HealthKit type identifier the SDK declares, with what the headers", + "/// say about it. Identifiers are raw strings so a type newer than the", + "/// running OS resolves to nil at runtime instead of failing to link.", + "enum HealthKitCatalog {", + " static let sdkVersion = %s" % swift_str(sdk), + "", + " struct Quantity: Sendable {", + " let identifier: String", + " let key: String", + " let name: String", + " let section: String", + " /// Canonical unit from the header comment, in HKUnit(from:) syntax.", + " let unit: String", + " let aggregation: String", + " let minimumOS: OperatingSystemVersion", + " let deprecated: Bool", + " }", + "", + " struct Category: Sendable {", + " let identifier: String", + " let key: String", + " let name: String", + " let section: String", + " /// Name of the HKCategoryValue enum; nil when the value is notApplicable.", + " let valueEnum: String?", + " let minimumOS: OperatingSystemVersion", + " let deprecated: Bool", + " }", + "", + " struct Characteristic: Sendable {", + " let identifier: String", + " let key: String", + " let name: String", + " let minimumOS: OperatingSystemVersion", + " }", + "", + " struct Identifier: Sendable {", + " let identifier: String", + " let key: String", + " let name: String", + " let kind: String", + " let section: String", + " let minimumOS: OperatingSystemVersion", + " }", + "", + " static let quantities: [Quantity] = [", + ] + for e in q: + lines.append(" Quantity(identifier: %s, key: %s, name: %s, section: %s, unit: %s, aggregation: %s, minimumOS: %s, deprecated: %s)," % ( + swift_str(e["name"]), swift_str(wire_key(e["stem"])), swift_str(display_name(e["stem"])), + swift_str(e["section"]), swift_str(e["unit"]), swift_str(e["aggregation"]), os_lit(e["ios"]), + "true" if e["deprecated"] else "false")) + lines += [" ]", "", " static let categories: [Category] = ["] + for e in c: + lines.append(" Category(identifier: %s, key: %s, name: %s, section: %s, valueEnum: %s, minimumOS: %s, deprecated: %s)," % ( + swift_str(e["name"]), swift_str(wire_key(e["stem"])), swift_str(display_name(e["stem"])), + swift_str(e["section"]), swift_str(e["enum"]) if e["enum"] else "nil", os_lit(e["ios"]), + "true" if e["deprecated"] else "false")) + lines += [" ]", "", " static let characteristics: [Characteristic] = ["] + for e in ch: + lines.append(" Characteristic(identifier: %s, key: %s, name: %s, minimumOS: %s)," % ( + swift_str(e["name"]), swift_str(wire_key(e["stem"])), swift_str(display_name(e["stem"])), os_lit(e["ios"]))) + lines += [" ]", "", + " /// Correlation, document, scored-assessment and other identifiers. The app", + " /// handles these by hand; the list exists so coverage can be checked.", + " static let otherIdentifiers: [Identifier] = ["] + for e in other: + stem = e["name"][len(e["kind"]):] if e["name"].startswith(e["kind"]) else e["name"] + lines.append(" Identifier(identifier: %s, key: %s, name: %s, kind: %s, section: %s, minimumOS: %s)," % ( + swift_str(e["name"]), swift_str(wire_key(stem)), swift_str(display_name(stem)), swift_str(e["kind"]), + swift_str(e["section"]), os_lit(e["ios"]))) + lines += [" ]", "", + " /// Every NS_ENUM in the HealthKit headers: enum name -> raw value -> wire", + " /// name (the Swift case name). Aliases resolve to the non-deprecated case.", + " static let enumNames: [String: [Int: String]] = ["] + for name in sorted(enums): + pairs = ", ".join("%d: %s" % (v, swift_str(n)) for v, n in value_names(enums[name]).items()) + if pairs: + lines.append(" %s: [%s]," % (swift_str(name), pairs)) + lines += [" ]", "}", ""] + with open(output, "w", encoding="utf-8") as f: + f.write("\n".join(lines)) + print("quantities=%d categories=%d characteristics=%d other=%d enums=%d" % ( + len(q), len(c), len(ch), len(other), len(enums)), file=sys.stderr) + + +if __name__ == "__main__": + if len(sys.argv) != 3: + sys.exit(__doc__) + main(sys.argv[1], sys.argv[2]) From 168f43983713625178b484c6187a202ed024fe12 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 22 Oct 2026 10:23:56 +0000 Subject: [PATCH 2/8] =?UTF-8?q?=F0=9F=90=9B=20Reach=20scoredAssessmentType?= =?UTF-8?q?ForIdentifier:=20through=20the=20runtime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ipa.yml | 2 ++ Pulso/Health/TypeRegistry.swift | 13 ++++++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ipa.yml b/.github/workflows/ipa.yml index 17a20d6..876be66 100644 --- a/.github/workflows/ipa.yml +++ b/.github/workflows/ipa.yml @@ -62,6 +62,8 @@ jobs: SDK=$(xcrun --sdk iphoneos --show-sdk-path) mkdir -p sdk-headers cp -R "$SDK/System/Library/Frameworks/HealthKit.framework/Headers" sdk-headers/HealthKit + find "$SDK/System/Library/Frameworks/HealthKit.framework/Modules" -name 'arm64-apple-ios.swiftinterface' \ + -exec cp {} sdk-headers/HealthKit.swiftinterface \; || true xcrun --sdk iphoneos --show-sdk-version > sdk-headers/SDK_VERSION xcodebuild -version > sdk-headers/XCODE_VERSION python3 scripts/gen_healthkit_catalog.py sdk-headers/HealthKit Pulso/Health/HealthKitCatalog.generated.swift diff --git a/Pulso/Health/TypeRegistry.swift b/Pulso/Health/TypeRegistry.swift index 80c770b..f9aff9d 100644 --- a/Pulso/Health/TypeRegistry.swift +++ b/Pulso/Health/TypeRegistry.swift @@ -245,9 +245,7 @@ enum TypeRegistry { sampleType: HKObjectType.stateOfMindType(), frequency: .immediate, serialize: Serializers.stateOfMind )) for entry in HealthKitCatalog.otherIdentifiers where entry.kind == "HKScoredAssessmentTypeIdentifier" { - guard isAvailable(entry.minimumOS), - let type = HKObjectType.scoredAssessmentType( - forIdentifier: HKScoredAssessmentTypeIdentifier(rawValue: entry.identifier)) else { continue } + guard isAvailable(entry.minimumOS), let type = scoredAssessmentType(entry.identifier) else { continue } let stem = String(entry.identifier.dropFirst("HKScoredAssessmentTypeIdentifier".count)) types.append(SyncedType( key: entry.key, displayName: "\(entry.name) Assessment", group: Group.mental, @@ -276,6 +274,15 @@ enum TypeRegistry { return types }() + /// +[HKObjectType scoredAssessmentTypeForIdentifier:] isn't exposed to + /// Swift under a stable name, so it is called through the runtime. + private static func scoredAssessmentType(_ identifier: String) -> HKSampleType? { + let selector = NSSelectorFromString("scoredAssessmentTypeForIdentifier:") + let factory: AnyObject = HKObjectType.self + guard factory.responds(to: selector) else { return nil } + return factory.perform(selector, with: identifier)?.takeUnretainedValue() as? HKSampleType + } + // MARK: - Public surface static let all: [SyncedType] = { From d4c1d7b72a09ec414e4be7d6e68482a210624eae Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 22 Oct 2026 10:31:27 +0000 Subject: [PATCH 3/8] =?UTF-8?q?=F0=9F=90=9B=20Keep=20heartbeat=20series=20?= =?UTF-8?q?and=20move=20dose=20events=20to=20the=20medication=20grant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The read-set probe showed HealthKit accepts heartbeat series only when HRV is requested alongside it, and refuses medication dose events in the read set (they follow the per-object medication grant). Types HealthKit no longer provides are reported as skipped. Co-Authored-By: Claude Opus 5.5 --- Pulso/Health/HealthAuthorization.swift | 12 +++++++++++- Pulso/Health/TypeRegistry.swift | 18 +++++++++++++++--- Pulso/Sync/TriggerHub.swift | 7 ++++--- PulsoTests/HealthKitCatalogTests.swift | 15 +++++++++++++-- PulsoTests/SerializerTests.swift | 4 +++- 5 files changed, 46 insertions(+), 10 deletions(-) diff --git a/Pulso/Health/HealthAuthorization.swift b/Pulso/Health/HealthAuthorization.swift index 8c18090..64bbd9a 100644 --- a/Pulso/Health/HealthAuthorization.swift +++ b/Pulso/Health/HealthAuthorization.swift @@ -34,7 +34,17 @@ enum HealthAuthorization { return accepted(Array(types[.. Date: Thu, 22 Oct 2026 10:37:18 +0000 Subject: [PATCH 4/8] =?UTF-8?q?=F0=9F=90=9B=20Version=20snapshot=20uuids,?= =?UTF-8?q?=20close=20activity=20gaps,=20parse=20commented=20enums?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A changed snapshot record now gets a new uuid (stable key + content fingerprint, key kept in details.recordKey) and the version it replaces is tombstoned, so receivers that keep the first copy of a uuid still end up with the latest rings or medication. Activity summaries resume from the last day sent after a gap longer than 35 days. The generator strips trailing comments before reading enum initializers. Co-Authored-By: Claude Opus 5.5 --- Pulso/Health/HealthKitCatalog.generated.swift | 2 +- Pulso/Health/Snapshots.swift | 26 ++++++++++++--- Pulso/Sync/SyncEngine.swift | 32 ++++++++++++++++--- scripts/gen_healthkit_catalog.py | 2 +- 4 files changed, 50 insertions(+), 12 deletions(-) diff --git a/Pulso/Health/HealthKitCatalog.generated.swift b/Pulso/Health/HealthKitCatalog.generated.swift index 8cf451e..459d687 100644 --- a/Pulso/Health/HealthKitCatalog.generated.swift +++ b/Pulso/Health/HealthKitCatalog.generated.swift @@ -310,7 +310,7 @@ enum HealthKitCatalog { "HKInsulinDeliveryReason": [1: "basal", 2: "bolus"], "HKMedicationDoseEventLogStatus": [1: "notInteracted", 2: "notificationNotSent", 3: "snoozed", 4: "taken", 5: "skipped", 6: "notLogged"], "HKMedicationDoseEventScheduleType": [1: "asNeeded", 2: "schedule"], - "HKMetricPrefix": [0: "none", 1: "femto", 2: "pico", 3: "nano", 4: "micro", 5: "milli", 6: "centi", 7: "deci", 8: "deca", 9: "hecto", 10: "kilo", 11: "mega", 12: "giga", 13: "tera"], + "HKMetricPrefix": [0: "none", 1: "pico", 2: "nano", 3: "micro", 4: "milli", 5: "centi", 6: "deci", 7: "deca", 8: "hecto", 9: "kilo", 10: "mega", 11: "giga", 12: "tera", 13: "femto"], "HKPHQ9AssessmentAnswer": [0: "notAtAll", 1: "severalDays", 2: "moreThanHalfTheDays", 3: "nearlyEveryDay", 4: "preferNotToAnswer"], "HKPHQ9AssessmentRisk": [1: "noneToMinimal", 2: "mild", 3: "moderate", 4: "moderatelySevere", 5: "severe"], "HKPhysicalEffortEstimationType": [1: "activityLookup", 2: "deviceSensed"], diff --git a/Pulso/Health/Snapshots.swift b/Pulso/Health/Snapshots.swift index 0b1d87f..6468fd8 100644 --- a/Pulso/Health/Snapshots.swift +++ b/Pulso/Health/Snapshots.swift @@ -12,9 +12,9 @@ struct SnapshotSource: Identifiable { let readTypes: Set /// Types needing a per-object grant (a user tap) instead of the read set. let perObjectType: HKObjectType? - /// `firstRead` is true when nothing was delivered yet, so the read - /// should cover all history rather than a recent window. - let read: @Sendable (_ queries: HealthQueries, _ context: SerializerContext, _ firstRead: Bool) async throws -> [SampleDTO] + /// `delivered` holds the record keys (and fingerprints) already queued; + /// nil when nothing was, so the read should cover all history. + let read: @Sendable (_ queries: HealthQueries, _ context: SerializerContext, _ delivered: [String: String]?) async throws -> [SampleDTO] var id: String { key } } @@ -93,11 +93,20 @@ enum Snapshots { group: TypeRegistry.Group.summaries, readTypes: [HKObjectType.activitySummaryType()], perObjectType: nil - ) { queries, context, firstRead in + ) { queries, context, delivered in var calendar = Calendar(identifier: .gregorian) calendar.timeZone = context.defaultTimeZone() let today = calendar.startOfDay(for: Date()) - let from = firstRead ? historyStart : calendar.date(byAdding: .day, value: -recentDays, to: today)! + let recent = calendar.date(byAdding: .day, value: -recentDays, to: today)! + let from: Date + if let delivered { + // After a gap longer than the recent window, resume from the + // last day already sent so no day in between is skipped. + let lastSent = delivered.keys.compactMap { summaryDay(fromKey: $0, calendar: calendar) }.max() + from = min(lastSent ?? historyStart, recent) + } else { + from = historyStart + } var start = calendar.dateComponents([.era, .year, .month, .day], from: from) start.calendar = calendar var end = calendar.dateComponents([.era, .year, .month, .day], from: today) @@ -135,6 +144,13 @@ enum Snapshots { } } + private static func summaryDay(fromKey key: String, calendar: Calendar) -> Date? { + let parts = key.split(separator: "-") + guard parts.count == 4, parts[0] == "activitySummary", + let year = Int(parts[1]), let month = Int(parts[2]), let day = Int(parts[3]) else { return nil } + return calendar.date(from: DateComponents(year: year, month: month, day: day)) + } + // MARK: - Medications @available(iOS 26.0, *) diff --git a/Pulso/Sync/SyncEngine.swift b/Pulso/Sync/SyncEngine.swift index 8a2b302..a038c2b 100644 --- a/Pulso/Sync/SyncEngine.swift +++ b/Pulso/Sync/SyncEngine.swift @@ -276,19 +276,33 @@ actor SyncEngine { /// Reads a snapshot source and queues the records whose content changed /// since the last delivered read. The new fingerprints ride in the /// batch's anchor slot, so they are persisted only once the server ACKs. + /// + /// Each version of a record gets its own uuid (stable key + content + /// fingerprint) and the version it replaces is tombstoned, so receivers + /// that keep the first copy of a uuid still end up with the latest + /// content. `details.recordKey` carries the stable key. private func syncSnapshot(_ source: SnapshotSource) async throws -> Int { let stored = anchors.loadRaw(source.key) let delivered = snapshotCache[source.key] ?? stored.flatMap { try? JSONDecoder().decode([String: String].self, from: $0) } - let records = try await source.read(queries, .live, delivered == nil) + let records = try await source.read(queries, .live, delivered) var state = delivered ?? [:] var changed: [SampleDTO] = [] + var superseded: [String] = [] for record in records { + let key = record.uuid let fingerprint = Self.fingerprint(record) - if state[record.uuid] != fingerprint { - state[record.uuid] = fingerprint - changed.append(record) + guard state[key] != fingerprint else { continue } + if let previous = state[key] { + superseded.append(Self.versionedUUID(key, previous)) } + state[key] = fingerprint + var versioned = record + versioned.uuid = Self.versionedUUID(key, fingerprint) + var details = versioned.details ?? [:] + details["recordKey"] = .string(key) + versioned.details = details + changed.append(versioned) } await status.recordChecked(source.key) let encoded = try JSONEncoder().encode(state) @@ -296,12 +310,20 @@ actor SyncEngine { if stored == nil { anchors.saveRaw(encoded, for: source.key) } return 0 } - try await outbox.enqueue(typeKey: source.key, elements: changed.map(BatchElement.sample), anchor: encoded) + var elements = changed.map(BatchElement.sample) + if !superseded.isEmpty { + elements.append(.tombstone(superseded)) + } + try await outbox.enqueue(typeKey: source.key, elements: elements, anchor: encoded) snapshotCache[source.key] = state await log.info("\(source.key): queued \(changed.count) changed record(s)") return changed.count } + static func versionedUUID(_ key: String, _ fingerprint: String) -> String { + "\(key)-\(fingerprint.prefix(12))" + } + /// Content identity of a snapshot record; timestamps are left out /// because characteristics and medications are stamped at read time. static func fingerprint(_ record: SampleDTO) -> String { diff --git a/scripts/gen_healthkit_catalog.py b/scripts/gen_healthkit_catalog.py index 5565cd8..4a54279 100644 --- a/scripts/gen_healthkit_catalog.py +++ b/scripts/gen_healthkit_catalog.py @@ -89,7 +89,7 @@ def parse_enums(path, wanted=None): cases = [] nxt = 0 for raw in body.split("\n"): - raw = raw.strip() + raw = re.sub(r"//.*$", "", raw).strip() if not raw or raw.startswith(("//", "/*", "*")): continue cm = re.match(r"^(\w+)(.*?)(?:=\s*([\w-]+))?\s*,?\s*$", raw) From c797e21337c1e1988ae56cc115efdaa4ab6a2dc7 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 22 Oct 2026 10:50:44 +0000 Subject: [PATCH 5/8] =?UTF-8?q?=F0=9F=90=9B=20Never=20reuse=20a=20snapshot?= =?UTF-8?q?=20uuid;=20tombstone=20deleted=20records?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snapshot versions now take a counter per record and a random epoch per state instead of a content hash, so A -> B -> A sends three uuids and a receiver that tombstoned A still takes the change back. The state is saved once a batch is queued, so a restart can't lose the version a newer one must supersede. Records missing from a complete read (characteristics, medications) are tombstoned, unless the read came back empty, which is what HealthKit returns without access. Co-Authored-By: Claude Opus 5.5 --- Pulso/Health/Snapshots.swift | 97 +++++++++++++++++++++++++- Pulso/Sync/SyncEngine.swift | 77 +++++--------------- PulsoTests/HealthKitCatalogTests.swift | 63 ++++++++++++++--- 3 files changed, 165 insertions(+), 72 deletions(-) diff --git a/Pulso/Health/Snapshots.swift b/Pulso/Health/Snapshots.swift index 6468fd8..dcea86e 100644 --- a/Pulso/Health/Snapshots.swift +++ b/Pulso/Health/Snapshots.swift @@ -1,3 +1,4 @@ +import CryptoKit import Foundation import HealthKit @@ -12,6 +13,9 @@ struct SnapshotSource: Identifiable { let readTypes: Set /// Types needing a per-object grant (a user tap) instead of the read set. let perObjectType: HKObjectType? + /// True when a read returns every record that exists, so a record + /// missing from it was deleted. False for windowed reads. + let complete: Bool /// `delivered` holds the record keys (and fingerprints) already queued; /// nil when nothing was, so the read should cover all history. let read: @Sendable (_ queries: HealthQueries, _ context: SerializerContext, _ delivered: [String: String]?) async throws -> [SampleDTO] @@ -54,7 +58,8 @@ enum Snapshots { guard ProcessInfo.processInfo.isOperatingSystemAtLeast(entry.minimumOS) else { return nil } return HKObjectType.characteristicType(forIdentifier: HKCharacteristicTypeIdentifier(rawValue: entry.identifier)) }), - perObjectType: nil + perObjectType: nil, + complete: true ) { queries, context, _ in let store = queries.store var details: [String: JSONValue] = [:] @@ -92,7 +97,8 @@ enum Snapshots { displayName: "Activity Rings (daily summaries)", group: TypeRegistry.Group.summaries, readTypes: [HKObjectType.activitySummaryType()], - perObjectType: nil + perObjectType: nil, + complete: false ) { queries, context, delivered in var calendar = Calendar(identifier: .gregorian) calendar.timeZone = context.defaultTimeZone() @@ -159,7 +165,8 @@ enum Snapshots { displayName: "Medication List", group: TypeRegistry.Group.medications, readTypes: [], - perObjectType: HKObjectType.userAnnotatedMedicationType() + perObjectType: HKObjectType.userAnnotatedMedicationType(), + complete: true ) { queries, context, _ in let now = Date() return try await queries.userAnnotatedMedications().map { annotated in @@ -185,3 +192,87 @@ enum Snapshots { } } } } + +/// What has been queued for one snapshot source. Every change to a record +/// is sent as a new version with a uuid that is never reused (record key, +/// a random epoch for this state, a version counter), and the version it +/// replaces is tombstoned. Receivers that keep the first copy of a uuid and +/// receivers that upsert both end up with only the latest version. +struct SnapshotState: Codable, Equatable, Sendable { + struct Entry: Codable, Equatable, Sendable { + /// nil once the record was deleted (its last version tombstoned). + var fingerprint: String? + var version: Int + } + + var epoch: String + var entries: [String: Entry] = [:] + + static func fresh() -> SnapshotState { + SnapshotState(epoch: String(UUID().uuidString.prefix(8)).lowercased()) + } + + /// Fingerprints of the records that currently exist, by record key. + var fingerprints: [String: String] { + entries.compactMapValues(\.fingerprint) + } + + func uuid(_ key: String, version: Int) -> String { + "\(key)-\(epoch)-v\(version)" + } + + /// Takes one read (records keyed by their `uuid`) and returns the new + /// versions to send and the uuids of the versions they replace or that + /// were deleted. An empty read never deletes anything: HealthKit + /// returns nothing, not an error, when access is missing. + mutating func apply(_ records: [SampleDTO], complete: Bool) -> (send: [SampleDTO], superseded: [String]) { + var send: [SampleDTO] = [] + var superseded: [String] = [] + for record in records { + let key = record.uuid + let fingerprint = Self.fingerprint(record) + let previous = entries[key] + guard previous?.fingerprint != fingerprint else { continue } + if let previous, previous.fingerprint != nil { + superseded.append(uuid(key, version: previous.version)) + } + let version = (previous?.version ?? 0) + 1 + entries[key] = Entry(fingerprint: fingerprint, version: version) + send.append(versioned(record, version: version)) + } + if complete && !records.isEmpty { + let present = Set(records.map(\.uuid)) + for (key, entry) in entries where entry.fingerprint != nil && !present.contains(key) { + superseded.append(uuid(key, version: entry.version)) + entries[key]?.fingerprint = nil + } + } + return (send, superseded.sorted()) + } + + /// The record as sent: versioned uuid, stable key in details.recordKey. + func versioned(_ record: SampleDTO, version: Int) -> SampleDTO { + var out = record + out.uuid = uuid(record.uuid, version: version) + var details = out.details ?? [:] + details["recordKey"] = .string(record.uuid) + out.details = details + return out + } + + /// Content identity of a record; timestamps are left out because + /// characteristics and medications are stamped at read time. + static func fingerprint(_ record: SampleDTO) -> String { + let content: [String: JSONValue] = [ + "value": record.value.map { value -> JSONValue in + switch value { + case .string(let s): .string(s) + case .number(let n): .number(n) + } + } ?? .null, + "details": .object(record.details ?? [:]), + ] + let data = (try? Wire.bodyEncoder().encode(content)) ?? Data() + return SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } +} diff --git a/Pulso/Sync/SyncEngine.swift b/Pulso/Sync/SyncEngine.swift index a038c2b..8a2b39d 100644 --- a/Pulso/Sync/SyncEngine.swift +++ b/Pulso/Sync/SyncEngine.swift @@ -1,4 +1,3 @@ -import CryptoKit import Foundation import HealthKit @@ -62,9 +61,8 @@ actor SyncEngine { /// GET /latest from the configured server; nil when unavailable. private let latestHints: @Sendable () async -> [String: String]? private var anchorCache: [String: HKQueryAnchor] = [:] - /// Snapshot fingerprints already queued, leading the delivered ones the - /// same way anchorCache leads the persisted anchors. - private var snapshotCache: [String: [String: String]] = [:] + /// Snapshot state as last queued; survives in memory between passes. + private var snapshotCache: [String: SnapshotState] = [:] private var chain: Task? /// Types whose query threw last time (typically authorization not yet /// granted for a newly added type). They must not hold the catch-up @@ -273,71 +271,30 @@ actor SyncEngine { } } - /// Reads a snapshot source and queues the records whose content changed - /// since the last delivered read. The new fingerprints ride in the - /// batch's anchor slot, so they are persisted only once the server ACKs. - /// - /// Each version of a record gets its own uuid (stable key + content - /// fingerprint) and the version it replaces is tombstoned, so receivers - /// that keep the first copy of a uuid still end up with the latest - /// content. `details.recordKey` carries the stable key. + /// Reads a snapshot source and queues new versions of the records whose + /// content changed (see SnapshotState). The state is saved once the + /// batch is on disk, like an anchor once its samples are queued. private func syncSnapshot(_ source: SnapshotSource) async throws -> Int { - let stored = anchors.loadRaw(source.key) - let delivered = snapshotCache[source.key] - ?? stored.flatMap { try? JSONDecoder().decode([String: String].self, from: $0) } - let records = try await source.read(queries, .live, delivered) - var state = delivered ?? [:] - var changed: [SampleDTO] = [] - var superseded: [String] = [] - for record in records { - let key = record.uuid - let fingerprint = Self.fingerprint(record) - guard state[key] != fingerprint else { continue } - if let previous = state[key] { - superseded.append(Self.versionedUUID(key, previous)) - } - state[key] = fingerprint - var versioned = record - versioned.uuid = Self.versionedUUID(key, fingerprint) - var details = versioned.details ?? [:] - details["recordKey"] = .string(key) - versioned.details = details - changed.append(versioned) - } + let known = snapshotCache[source.key] + ?? anchors.loadRaw(source.key).flatMap { try? JSONDecoder().decode(SnapshotState.self, from: $0) } + let records = try await source.read(queries, .live, known?.fingerprints) + var state = known ?? .fresh() + let (send, superseded) = state.apply(records, complete: source.complete) await status.recordChecked(source.key) let encoded = try JSONEncoder().encode(state) - guard !changed.isEmpty else { - if stored == nil { anchors.saveRaw(encoded, for: source.key) } + guard !send.isEmpty || !superseded.isEmpty else { + if known == nil { anchors.saveRaw(encoded, for: source.key) } return 0 } - var elements = changed.map(BatchElement.sample) + var elements = send.map(BatchElement.sample) if !superseded.isEmpty { elements.append(.tombstone(superseded)) } - try await outbox.enqueue(typeKey: source.key, elements: elements, anchor: encoded) + try await outbox.enqueue(typeKey: source.key, elements: elements, anchor: nil) + anchors.saveRaw(encoded, for: source.key) snapshotCache[source.key] = state - await log.info("\(source.key): queued \(changed.count) changed record(s)") - return changed.count - } - - static func versionedUUID(_ key: String, _ fingerprint: String) -> String { - "\(key)-\(fingerprint.prefix(12))" - } - - /// Content identity of a snapshot record; timestamps are left out - /// because characteristics and medications are stamped at read time. - static func fingerprint(_ record: SampleDTO) -> String { - let content: [String: JSONValue] = [ - "value": record.value.map { value -> JSONValue in - switch value { - case .string(let s): .string(s) - case .number(let n): .number(n) - } - } ?? .null, - "details": .object(record.details ?? [:]), - ] - let data = (try? Wire.bodyEncoder().encode(content)) ?? Data() - return SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + await log.info("\(source.key): queued \(send.count) new version(s), \(superseded.count) superseded") + return send.count } /// The SDK has flip-flopped on the optionality of `newAnchor`; funneling diff --git a/PulsoTests/HealthKitCatalogTests.swift b/PulsoTests/HealthKitCatalogTests.swift index 15b0db7..79c698f 100644 --- a/PulsoTests/HealthKitCatalogTests.swift +++ b/PulsoTests/HealthKitCatalogTests.swift @@ -97,17 +97,62 @@ final class HealthKitCatalogTests: XCTestCase { XCTAssertEqual(dto.details?["answers"], .array(Array(repeating: "severalDays", count: 7))) } + private func medication(_ key: String, _ name: String, stamp: String = "2026-01-01T00:00:00Z") -> SampleDTO { + var dto = SampleDTO(uuid: key, type: "medication", start: stamp, end: stamp, source: "Apple Health") + dto.details = ["displayText": .string(name)] + return dto + } + /// Snapshot records are stamped at read time; only content may decide /// whether a record is re-sent. func testSnapshotFingerprintIgnoresTimestamps() { - var a = SampleDTO(uuid: "characteristics", type: "characteristics", start: "2026-01-01T00:00:00Z", - end: "2026-01-01T00:00:00Z", source: "Apple Health") - a.details = ["bloodType": "oPositive"] - var b = a - b.start = "2026-02-02T00:00:00Z" - b.end = b.start - XCTAssertEqual(SyncEngine.fingerprint(a), SyncEngine.fingerprint(b)) - b.details = ["bloodType": "aNegative"] - XCTAssertNotEqual(SyncEngine.fingerprint(a), SyncEngine.fingerprint(b)) + let a = medication("m", "Ibuprofen") + var b = medication("m", "Ibuprofen", stamp: "2026-02-02T00:00:00Z") + XCTAssertEqual(SnapshotState.fingerprint(a), SnapshotState.fingerprint(b)) + b.details = ["displayText": "Aspirin"] + XCTAssertNotEqual(SnapshotState.fingerprint(a), SnapshotState.fingerprint(b)) + } + + /// A -> B -> A must produce three distinct uuids, each superseding the + /// one before: a receiver that tombstoned A's uuid would otherwise + /// drop the change back. + func testSnapshotVersionsNeverReuseAUUID() { + var state = SnapshotState(epoch: "e") + var result = state.apply([medication("m", "A")], complete: true) + XCTAssertEqual(result.send.map(\.uuid), ["m-e-v1"]) + XCTAssertEqual(result.send.first?.details?["recordKey"], "m") + XCTAssertEqual(result.superseded, []) + + XCTAssertTrue(state.apply([medication("m", "A")], complete: true).send.isEmpty, "unchanged is not resent") + + result = state.apply([medication("m", "B")], complete: true) + XCTAssertEqual(result.send.map(\.uuid), ["m-e-v2"]) + XCTAssertEqual(result.superseded, ["m-e-v1"]) + + result = state.apply([medication("m", "A")], complete: true) + XCTAssertEqual(result.send.map(\.uuid), ["m-e-v3"]) + XCTAssertEqual(result.superseded, ["m-e-v2"]) + } + + func testCompleteSnapshotsTombstoneMissingRecordsButNotOnAnEmptyRead() { + var state = SnapshotState(epoch: "e") + _ = state.apply([medication("m1", "A"), medication("m2", "B")], complete: true) + + // An empty read (no access) deletes nothing. + XCTAssertEqual(state.apply([], complete: true).superseded, []) + + var result = state.apply([medication("m1", "A")], complete: true) + XCTAssertEqual(result.superseded, ["m2-e-v1"]) + XCTAssertEqual(state.fingerprints.keys.sorted(), ["m1"]) + + // Back again: a new version, nothing left to supersede. + result = state.apply([medication("m1", "A"), medication("m2", "B")], complete: true) + XCTAssertEqual(result.send.map(\.uuid), ["m2-e-v2"]) + XCTAssertEqual(result.superseded, []) + + // Windowed sources never infer deletions. + var window = SnapshotState(epoch: "w") + _ = window.apply([medication("d1", "x"), medication("d2", "y")], complete: false) + XCTAssertEqual(window.apply([medication("d2", "y")], complete: false).superseded, []) } } From 65507c57f88c4601502b3679168aa128e11e1349 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 22 Oct 2026 10:55:55 +0000 Subject: [PATCH 6/8] =?UTF-8?q?=F0=9F=90=9B=20Reserve=20snapshot=20version?= =?UTF-8?q?s=20before=20queueing;=20keep=20characteristics=20on=20a=20fail?= =?UTF-8?q?ed=20read?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saving the state and queueing the batch are two writes. The versions a batch uses are now reserved on disk first; after a crash between the two, the next read supersedes the reserved versions and moves past them instead of reusing a uuid. A characteristics read where every getter fails (phone locked) now throws instead of replacing the last good record with an empty one. Co-Authored-By: Claude Opus 5.5 --- Pulso/Health/Snapshots.swift | 55 ++++++++++++++++++++++---- Pulso/Sync/SyncEngine.swift | 3 ++ PulsoTests/HealthKitCatalogTests.swift | 23 +++++++++++ 3 files changed, 74 insertions(+), 7 deletions(-) diff --git a/Pulso/Health/Snapshots.swift b/Pulso/Health/Snapshots.swift index dcea86e..1602a58 100644 --- a/Pulso/Health/Snapshots.swift +++ b/Pulso/Health/Snapshots.swift @@ -26,6 +26,10 @@ struct SnapshotSource: Identifiable { enum Snapshots { static let appleHealth = "Apple Health" + struct Unavailable: LocalizedError { + var errorDescription: String? { "Health data unavailable (phone locked?); keeping the last snapshot" } + } + static var all: [SnapshotSource] { var sources = [characteristics, activitySummaries] if #available(iOS 26.0, *) { @@ -81,6 +85,9 @@ enum Snapshots { if let moveMode = try? store.activityMoveMode() { details["activityMoveMode"] = .string(Serializers.enumName("HKActivityMoveMode", moveMode.activityMoveMode.rawValue)) } + // Every getter throws while the phone is locked; an empty record + // would replace the last good one. + guard !details.isEmpty else { throw Snapshots.Unavailable() } let now = Date() return [record(uuid: "characteristics", type: "characteristics", start: now, end: now, context: context, details: details)] } @@ -207,6 +214,10 @@ struct SnapshotState: Codable, Equatable, Sendable { var epoch: String var entries: [String: Entry] = [:] + /// Highest version handed out per key, saved *before* a batch is + /// queued. A reservation above the committed version means a crash may + /// have queued that version: it is superseded and never reused. + var reserved: [String: Int] = [:] static func fresh() -> SnapshotState { SnapshotState(epoch: String(UUID().uuidString.prefix(8)).lowercased()) @@ -232,24 +243,54 @@ struct SnapshotState: Codable, Equatable, Sendable { let key = record.uuid let fingerprint = Self.fingerprint(record) let previous = entries[key] - guard previous?.fingerprint != fingerprint else { continue } - if let previous, previous.fingerprint != nil { - superseded.append(uuid(key, version: previous.version)) - } - let version = (previous?.version ?? 0) + 1 + let uncommitted = uncommittedVersions(key) + guard previous?.fingerprint != fingerprint || !uncommitted.isEmpty else { continue } + superseded += liveVersions(key) + let version = max(previous?.version ?? 0, reserved[key] ?? 0) + 1 entries[key] = Entry(fingerprint: fingerprint, version: version) + reserved[key] = version send.append(versioned(record, version: version)) } if complete && !records.isEmpty { let present = Set(records.map(\.uuid)) - for (key, entry) in entries where entry.fingerprint != nil && !present.contains(key) { - superseded.append(uuid(key, version: entry.version)) + for key in entries.keys where !present.contains(key) { + let live = liveVersions(key) + guard !live.isEmpty else { continue } + superseded += live entries[key]?.fingerprint = nil + reserved[key] = max(entries[key]?.version ?? 0, reserved[key] ?? 0) + entries[key]?.version = reserved[key] ?? 0 } } return (send, superseded.sorted()) } + /// Versions handed out but never committed (a crash between saving the + /// reservation and saving the state). + private func uncommittedVersions(_ key: String) -> [Int] { + let committed = entries[key]?.version ?? 0 + let highest = reserved[key] ?? committed + return highest > committed ? Array(committed + 1 ... highest) : [] + } + + /// uuids of versions a receiver may hold as current for `key`. + private func liveVersions(_ key: String) -> [String] { + var versions = uncommittedVersions(key) + if let entry = entries[key], entry.fingerprint != nil { + versions.insert(entry.version, at: 0) + } + return versions.map { uuid(key, version: $0) } + } + + /// What to save before queueing: the committed entries of `before` + /// with this state's reservations, so a crash before the state itself + /// is saved can't hand the same versions out again. + func reservation(committed before: SnapshotState?) -> SnapshotState { + var out = self + out.entries = before?.entries ?? [:] + return out + } + /// The record as sent: versioned uuid, stable key in details.recordKey. func versioned(_ record: SampleDTO, version: Int) -> SampleDTO { var out = record diff --git a/Pulso/Sync/SyncEngine.swift b/Pulso/Sync/SyncEngine.swift index 8a2b39d..3202bfa 100644 --- a/Pulso/Sync/SyncEngine.swift +++ b/Pulso/Sync/SyncEngine.swift @@ -290,6 +290,9 @@ actor SyncEngine { if !superseded.isEmpty { elements.append(.tombstone(superseded)) } + // Reserve the new versions durably first: the batch and the state + // are two writes, and a crash between them must not reuse a uuid. + anchors.saveRaw(try JSONEncoder().encode(state.reservation(committed: known)), for: source.key) try await outbox.enqueue(typeKey: source.key, elements: elements, anchor: nil) anchors.saveRaw(encoded, for: source.key) snapshotCache[source.key] = state diff --git a/PulsoTests/HealthKitCatalogTests.swift b/PulsoTests/HealthKitCatalogTests.swift index 79c698f..12e73e8 100644 --- a/PulsoTests/HealthKitCatalogTests.swift +++ b/PulsoTests/HealthKitCatalogTests.swift @@ -155,4 +155,27 @@ final class HealthKitCatalogTests: XCTestCase { _ = window.apply([medication("d1", "x"), medication("d2", "y")], complete: false) XCTAssertEqual(window.apply([medication("d2", "y")], complete: false).superseded, []) } + + /// A crash after the reservation was saved but before the state was: + /// the reserved version may have been queued, so it is superseded and + /// the next version skips past it, even if the content went back. + func testSnapshotReservationSurvivesACrashBeforeCommit() { + var committed = SnapshotState(epoch: "e") + _ = committed.apply([medication("m", "A")], complete: true) // m-e-v1 delivered + + var working = committed + let queued = working.apply([medication("m", "B")], complete: true) + XCTAssertEqual(queued.send.map(\.uuid), ["m-e-v2"]) + var afterCrash = working.reservation(committed: committed) + XCTAssertEqual(afterCrash.entries, committed.entries) + + let next = afterCrash.apply([medication("m", "C")], complete: true) + XCTAssertEqual(next.send.map(\.uuid), ["m-e-v3"], "v2 may exist; it must not be reused") + XCTAssertEqual(next.superseded, ["m-e-v1", "m-e-v2"]) + + var reverted = working.reservation(committed: committed) + let back = reverted.apply([medication("m", "A")], complete: true) + XCTAssertEqual(back.send.map(\.uuid), ["m-e-v3"], "unchanged content still re-sent over an uncommitted version") + XCTAssertEqual(back.superseded, ["m-e-v1", "m-e-v2"]) + } } From a968f3e6d1f32028fd09966531b14db074cb76ca Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 22 Oct 2026 11:15:14 +0000 Subject: [PATCH 7/8] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Journal=20snapshot=20b?= =?UTF-8?q?atches=20instead=20of=20reserving=20versions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Queueing a snapshot batch and saving the state it leads to are two writes. Reserving versions ahead of time left gaps (deletions, failed writes, a stale cache after a partial enqueue). Now the batch and its state are written together as one atomic journal file first; the batch is queued from it, then the state saved, then the journal removed. A failure or crash anywhere in between replays the same journal (same uuids, harmless to receivers) before the next read. Writes on this path throw instead of failing silently. Co-Authored-By: Claude Opus 5.5 --- Pulso/Health/AnchorStore.swift | 5 +++ Pulso/Health/Snapshots.swift | 50 +++++--------------------- Pulso/Sync/SyncEngine.swift | 50 ++++++++++++++++++++------ PulsoTests/HealthKitCatalogTests.swift | 23 ------------ 4 files changed, 54 insertions(+), 74 deletions(-) diff --git a/Pulso/Health/AnchorStore.swift b/Pulso/Health/AnchorStore.swift index 24bb15f..11a07e6 100644 --- a/Pulso/Health/AnchorStore.swift +++ b/Pulso/Health/AnchorStore.swift @@ -40,6 +40,11 @@ struct AnchorStore: Sendable { try? data.write(to: url(for: key), options: .atomic) } + /// Like saveRaw, but a failed write is the caller's to handle. + func writeRaw(_ data: Data, for key: String) throws { + try data.write(to: url(for: key), options: .atomic) + } + func hasAnchor(_ key: String) -> Bool { FileManager.default.fileExists(atPath: url(for: key).path) } diff --git a/Pulso/Health/Snapshots.swift b/Pulso/Health/Snapshots.swift index 1602a58..f0dd2c2 100644 --- a/Pulso/Health/Snapshots.swift +++ b/Pulso/Health/Snapshots.swift @@ -205,6 +205,8 @@ enum Snapshots { /// a random epoch for this state, a version counter), and the version it /// replaces is tombstoned. Receivers that keep the first copy of a uuid and /// receivers that upsert both end up with only the latest version. +/// SyncEngine journals each batch with the state it leads to, so the +/// counter can't move backwards across a crash. struct SnapshotState: Codable, Equatable, Sendable { struct Entry: Codable, Equatable, Sendable { /// nil once the record was deleted (its last version tombstoned). @@ -214,10 +216,6 @@ struct SnapshotState: Codable, Equatable, Sendable { var epoch: String var entries: [String: Entry] = [:] - /// Highest version handed out per key, saved *before* a batch is - /// queued. A reservation above the committed version means a crash may - /// have queued that version: it is superseded and never reused. - var reserved: [String: Int] = [:] static func fresh() -> SnapshotState { SnapshotState(epoch: String(UUID().uuidString.prefix(8)).lowercased()) @@ -243,54 +241,24 @@ struct SnapshotState: Codable, Equatable, Sendable { let key = record.uuid let fingerprint = Self.fingerprint(record) let previous = entries[key] - let uncommitted = uncommittedVersions(key) - guard previous?.fingerprint != fingerprint || !uncommitted.isEmpty else { continue } - superseded += liveVersions(key) - let version = max(previous?.version ?? 0, reserved[key] ?? 0) + 1 + guard previous?.fingerprint != fingerprint else { continue } + if let previous, previous.fingerprint != nil { + superseded.append(uuid(key, version: previous.version)) + } + let version = (previous?.version ?? 0) + 1 entries[key] = Entry(fingerprint: fingerprint, version: version) - reserved[key] = version send.append(versioned(record, version: version)) } if complete && !records.isEmpty { let present = Set(records.map(\.uuid)) - for key in entries.keys where !present.contains(key) { - let live = liveVersions(key) - guard !live.isEmpty else { continue } - superseded += live + for (key, entry) in entries where entry.fingerprint != nil && !present.contains(key) { + superseded.append(uuid(key, version: entry.version)) entries[key]?.fingerprint = nil - reserved[key] = max(entries[key]?.version ?? 0, reserved[key] ?? 0) - entries[key]?.version = reserved[key] ?? 0 } } return (send, superseded.sorted()) } - /// Versions handed out but never committed (a crash between saving the - /// reservation and saving the state). - private func uncommittedVersions(_ key: String) -> [Int] { - let committed = entries[key]?.version ?? 0 - let highest = reserved[key] ?? committed - return highest > committed ? Array(committed + 1 ... highest) : [] - } - - /// uuids of versions a receiver may hold as current for `key`. - private func liveVersions(_ key: String) -> [String] { - var versions = uncommittedVersions(key) - if let entry = entries[key], entry.fingerprint != nil { - versions.insert(entry.version, at: 0) - } - return versions.map { uuid(key, version: $0) } - } - - /// What to save before queueing: the committed entries of `before` - /// with this state's reservations, so a crash before the state itself - /// is saved can't hand the same versions out again. - func reservation(committed before: SnapshotState?) -> SnapshotState { - var out = self - out.entries = before?.entries ?? [:] - return out - } - /// The record as sent: versioned uuid, stable key in details.recordKey. func versioned(_ record: SampleDTO, version: Int) -> SampleDTO { var out = record diff --git a/Pulso/Sync/SyncEngine.swift b/Pulso/Sync/SyncEngine.swift index 3202bfa..93bb693 100644 --- a/Pulso/Sync/SyncEngine.swift +++ b/Pulso/Sync/SyncEngine.swift @@ -271,35 +271,65 @@ actor SyncEngine { } } + /// A snapshot batch and the state it leads to, written as one file + /// before anything is queued. + private struct SnapshotJournal: Codable { + let state: SnapshotState + let elements: [BatchElement] + } + + private func journalURL(_ key: String) -> URL { + anchors.directory.appendingPathComponent("\(key).journal") + } + /// Reads a snapshot source and queues new versions of the records whose - /// content changed (see SnapshotState). The state is saved once the - /// batch is on disk, like an anchor once its samples are queued. + /// content changed (see SnapshotState). Queueing the batch and saving + /// the state are separate writes, so both go through a journal: if + /// either fails, or the app dies between them, the next pass replays + /// the same batch (same uuids, harmless to receivers) and then saves + /// the state, before reading anything new. private func syncSnapshot(_ source: SnapshotSource) async throws -> Int { + try await replaySnapshotJournal(source.key) let known = snapshotCache[source.key] ?? anchors.loadRaw(source.key).flatMap { try? JSONDecoder().decode(SnapshotState.self, from: $0) } let records = try await source.read(queries, .live, known?.fingerprints) var state = known ?? .fresh() let (send, superseded) = state.apply(records, complete: source.complete) await status.recordChecked(source.key) - let encoded = try JSONEncoder().encode(state) guard !send.isEmpty || !superseded.isEmpty else { - if known == nil { anchors.saveRaw(encoded, for: source.key) } + if known == nil { + try anchors.writeRaw(JSONEncoder().encode(state), for: source.key) + } return 0 } var elements = send.map(BatchElement.sample) if !superseded.isEmpty { elements.append(.tombstone(superseded)) } - // Reserve the new versions durably first: the batch and the state - // are two writes, and a crash between them must not reuse a uuid. - anchors.saveRaw(try JSONEncoder().encode(state.reservation(committed: known)), for: source.key) - try await outbox.enqueue(typeKey: source.key, elements: elements, anchor: nil) - anchors.saveRaw(encoded, for: source.key) - snapshotCache[source.key] = state + let journal = SnapshotJournal(state: state, elements: elements) + try Wire.fileEncoder().encode(journal).write(to: journalURL(source.key), options: .atomic) + try await replaySnapshotJournal(source.key) await log.info("\(source.key): queued \(send.count) new version(s), \(superseded.count) superseded") return send.count } + private func replaySnapshotJournal(_ key: String) async throws { + let url = journalURL(key) + guard let data = try? Data(contentsOf: url) else { return } + let journal: SnapshotJournal + do { + journal = try Wire.fileDecoder().decode(SnapshotJournal.self, from: data) + } catch { + try? FileManager.default.removeItem(at: url) + await log.error("\(key): unreadable snapshot journal discarded") + return + } + try await outbox.enqueue(typeKey: key, elements: journal.elements, anchor: nil) + try anchors.writeRaw(JSONEncoder().encode(journal.state), for: key) + try FileManager.default.removeItem(at: url) + snapshotCache[key] = journal.state + } + /// The SDK has flip-flopped on the optionality of `newAnchor`; funneling /// it through an optional parameter compiles either way. private func normalize(_ anchor: HKQueryAnchor?) -> HKQueryAnchor? { diff --git a/PulsoTests/HealthKitCatalogTests.swift b/PulsoTests/HealthKitCatalogTests.swift index 12e73e8..79c698f 100644 --- a/PulsoTests/HealthKitCatalogTests.swift +++ b/PulsoTests/HealthKitCatalogTests.swift @@ -155,27 +155,4 @@ final class HealthKitCatalogTests: XCTestCase { _ = window.apply([medication("d1", "x"), medication("d2", "y")], complete: false) XCTAssertEqual(window.apply([medication("d2", "y")], complete: false).superseded, []) } - - /// A crash after the reservation was saved but before the state was: - /// the reserved version may have been queued, so it is superseded and - /// the next version skips past it, even if the content went back. - func testSnapshotReservationSurvivesACrashBeforeCommit() { - var committed = SnapshotState(epoch: "e") - _ = committed.apply([medication("m", "A")], complete: true) // m-e-v1 delivered - - var working = committed - let queued = working.apply([medication("m", "B")], complete: true) - XCTAssertEqual(queued.send.map(\.uuid), ["m-e-v2"]) - var afterCrash = working.reservation(committed: committed) - XCTAssertEqual(afterCrash.entries, committed.entries) - - let next = afterCrash.apply([medication("m", "C")], complete: true) - XCTAssertEqual(next.send.map(\.uuid), ["m-e-v3"], "v2 may exist; it must not be reused") - XCTAssertEqual(next.superseded, ["m-e-v1", "m-e-v2"]) - - var reverted = working.reservation(committed: committed) - let back = reverted.apply([medication("m", "A")], complete: true) - XCTAssertEqual(back.send.map(\.uuid), ["m-e-v3"], "unchanged content still re-sent over an uncommitted version") - XCTAssertEqual(back.superseded, ["m-e-v1", "m-e-v2"]) - } } From 56ff8c73369445036054b43f8feae8b8d028dd65 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 22 Oct 2026 11:27:46 +0000 Subject: [PATCH 8/8] =?UTF-8?q?=F0=9F=90=9B=20Keep=20workouts=20syncing=20?= =?UTF-8?q?without=20link=20access;=20harden=20the=20generator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route and heart-rate links on a workout are skipped when HealthKit reports those types unauthorized, instead of blocking the workout. Observer query errors are logged. The generator evaluates hex, shift and alias enum initializers and skips anything else with a warning rather than stopping the IPA build. The medication list is read at most once a minute while enriching dose events. Co-Authored-By: Claude Opus 5.5 --- Pulso/Health/HealthQueries.swift | 35 ++++++++++++++++++++++++++++---- Pulso/Sync/TriggerHub.swift | 4 +++- scripts/gen_healthkit_catalog.py | 28 ++++++++++++++++++------- 3 files changed, 55 insertions(+), 12 deletions(-) diff --git a/Pulso/Health/HealthQueries.swift b/Pulso/Health/HealthQueries.swift index 3d8fa96..c28d418 100644 --- a/Pulso/Health/HealthQueries.swift +++ b/Pulso/Health/HealthQueries.swift @@ -92,17 +92,17 @@ struct HealthQueries: @unchecked Sendable { var dto = dto var details = dto.details ?? [:] let fromWorkout = HKQuery.predicateForObjects(from: workout) - let routes = try await samples(HKSeriesType.workoutRoute(), predicate: fromWorkout) + let routes = try await optionalLink { try await samples(HKSeriesType.workoutRoute(), predicate: fromWorkout) } if !routes.isEmpty { details["routeUUIDs"] = .array(routes.map { .string($0.uuid.uuidString) }) } let heartRate = HKQuantityType(.heartRate) - var heartRateSamples = try await samples(heartRate, predicate: fromWorkout) + var heartRateSamples = try await optionalLink { try await samples(heartRate, predicate: fromWorkout) } var link = "associated" if heartRateSamples.isEmpty { let during = HKQuery.predicateForSamples( withStart: workout.startDate, end: workout.endDate, options: .strictStartDate) - heartRateSamples = try await samples(heartRate, predicate: during) + heartRateSamples = try await optionalLink { try await samples(heartRate, predicate: during) } link = "timeRange" } if !heartRateSamples.isEmpty { @@ -113,6 +113,18 @@ struct HealthQueries: @unchecked Sendable { return dto } + /// Links are extras: when the linked type isn't authorized the workout + /// still syncs without them. Any other failure (phone locked, ...) + /// throws, so the workout is read again with its links next time. + private func optionalLink(_ query: () async throws -> [HKSample]) async throws -> [HKSample] { + do { + return try await query() + } catch let error as HKError where error.code == .errorAuthorizationNotDetermined + || error.code == .errorAuthorizationDenied { + return [] + } + } + static let locationFields: JSONValue = [ "secondsFromStart", "latitude", "longitude", "altitude", "horizontalAccuracy", "verticalAccuracy", "speed", "course", "speedAccuracy", "courseAccuracy", @@ -188,7 +200,7 @@ struct HealthQueries: @unchecked Sendable { var dto = dto // Names need per-object medication access; without it the concept // key alone still identifies the medication. - let medications = (try? await userAnnotatedMedications()) ?? [] + let medications = await MedicationListCache.shared.list { try await userAnnotatedMedications() } if let match = medications.first(where: { $0.medication.identifier == event.medicationConceptIdentifier }) { var details = dto.details ?? [:] details["medicationName"] = .string(match.medication.displayText) @@ -218,6 +230,21 @@ struct HealthQueries: @unchecked Sendable { } } +/// The medication list, read at most once a minute: a backfill enriches +/// thousands of dose events and each would otherwise query it again. +@available(iOS 26.0, *) +actor MedicationListCache { + static let shared = MedicationListCache() + private var cached: (at: Date, list: [HKUserAnnotatedMedication])? + + func list(_ load: () async throws -> [HKUserAnnotatedMedication]) async -> [HKUserAnnotatedMedication] { + if let cached, Date().timeIntervalSince(cached.at) < 60 { return cached.list } + let list = (try? await load()) ?? [] + cached = (Date(), list) + return list + } +} + /// A stable string for a medication concept. The identifier object exposes /// only its domain publicly, so its archived form is hashed instead. enum MedicationIdentity { diff --git a/Pulso/Sync/TriggerHub.swift b/Pulso/Sync/TriggerHub.swift index e9fd6cf..dda1af3 100644 --- a/Pulso/Sync/TriggerHub.swift +++ b/Pulso/Sync/TriggerHub.swift @@ -88,9 +88,11 @@ final class TriggerHub { await log.warn("background delivery unavailable for \(unavailable.count) type(s): \(unavailable.prefix(15).joined(separator: ", "))") } guard !descriptors.isEmpty else { return } + let log = log let query = HKObserverQuery(queryDescriptors: descriptors) { [weak self] _, changed, completionHandler, error in - if error != nil { + if let error { completionHandler() + Task { await log.warn("observer query error: \(error.localizedDescription)") } return } let keys = Set((changed ?? []).compactMap { TypeRegistry.type(for: $0)?.key }) diff --git a/scripts/gen_healthkit_catalog.py b/scripts/gen_healthkit_catalog.py index 4a54279..3d11995 100644 --- a/scripts/gen_healthkit_catalog.py +++ b/scripts/gen_healthkit_catalog.py @@ -21,6 +21,22 @@ import sys ID_LINE = re.compile(r"HK_EXTERN\s+(\w+)\s+const\s+(\w+)\s+(.*?);\s*(?://\s*(.*))?$") +def enum_value(expr, known, implicit): + """Value of an enum initializer: decimal or hex literals, shifts and ors + of those, and earlier cases of the same enum. None when it is anything + else, so one odd header line can't stop the build.""" + expr = expr.strip() + for case_name, case_value in sorted(known.items(), key=lambda kv: -len(kv[0])): + expr = re.sub(r"\b%s\b" % re.escape(case_name), str(case_value), expr) + expr = re.sub(r"(?<=[0-9A-Fa-f])[uUlL]+\b", "", expr) + if not re.fullmatch(r"[0-9xXa-fA-F\s()<>|+-]+", expr): + return None + try: + return int(eval(expr, {"__builtins__": {}}, {})) + except Exception: + return None + + ENUM_START = re.compile(r"typedef\s+NS_ENUM\s*\(\s*\w+\s*,\s*(\w+)\s*\)\s*\{") ENUM_CASE = re.compile(r"^\s*(\w+)\s*(.*?)\s*(?:=\s*([\w-]+))?\s*,?\s*(?://.*)?$") @@ -92,16 +108,14 @@ def parse_enums(path, wanted=None): raw = re.sub(r"//.*$", "", raw).strip() if not raw or raw.startswith(("//", "/*", "*")): continue - cm = re.match(r"^(\w+)(.*?)(?:=\s*([\w-]+))?\s*,?\s*$", raw) + cm = re.match(r"^(\w+)([^=]*?)(?:=\s*(.+?))?\s*,?\s*$", raw) if not cm: continue case, attrs, value = cm.groups() - if value is None: - v = nxt - elif re.match(r"^-?\d+$", value): - v = int(value) - else: - v = values[value] + v = enum_value(value, values, nxt) if value is not None else nxt + if v is None: + print("warning: %s.%s = %s not understood; skipped" % (name, case, value), file=sys.stderr) + continue values[case] = v nxt = v + 1 short = case[len(name):] if case.startswith(name) else case