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..876be66 100644 --- a/.github/workflows/ipa.yml +++ b/.github/workflows/ipa.yml @@ -54,6 +54,25 @@ 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 + 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 + 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 +119,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..11a07e6 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) } @@ -34,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/HealthAuthorization.swift b/Pulso/Health/HealthAuthorization.swift new file mode 100644 index 0000000..64bbd9a --- /dev/null +++ b/Pulso/Health/HealthAuthorization.swift @@ -0,0 +1,99 @@ +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..459d687 --- /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: "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"], + "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..c28d418 --- /dev/null +++ b/Pulso/Health/HealthQueries.swift @@ -0,0 +1,295 @@ +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 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 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 optionalLink { 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 + } + + /// 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", + ] + + 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 = 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) + 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) + } +} + +/// 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 { + @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..f0dd2c2 --- /dev/null +++ b/Pulso/Health/Snapshots.swift @@ -0,0 +1,287 @@ +import CryptoKit +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? + /// 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] + + var id: String { key } +} + +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, *) { + 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, + complete: true + ) { 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)) + } + // 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)] + } + + // 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, + complete: false + ) { queries, context, delivered in + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = context.defaultTimeZone() + let today = calendar.startOfDay(for: Date()) + 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) + 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 + ) + } + } + + 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, *) + static var medications: SnapshotSource { SnapshotSource( + key: "medication", + displayName: "Medication List", + group: TypeRegistry.Group.medications, + readTypes: [], + perObjectType: HKObjectType.userAnnotatedMedicationType(), + complete: true + ) { 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 + ) + } + } } +} + +/// 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. +/// 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). + 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/Health/TypeRegistry.swift b/Pulso/Health/TypeRegistry.swift index ed95f33..1c6cb50 100644 --- a/Pulso/Health/TypeRegistry.swift +++ b/Pulso/Health/TypeRegistry.swift @@ -1,245 +1,372 @@ 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 + /// Refused in the read set; readable for the medications the user + /// shares object by object (medication dose events). + case withMedications + } + 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 = 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, + 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, access: .withMedications, + 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 - ), - ] + /// +[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] = - 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 { + let reason: String + if !isAvailable(entry.minimumOS) { + reason = "needs a newer iOS" + } else if HKObjectType.quantityType(forIdentifier: HKQuantityTypeIdentifier(rawValue: entry.identifier)) == nil { + reason = "declared in the SDK but not provided by HealthKit on this iOS" + } else { + reason = "unit \(entry.unit) unusable" + } + out.append((entry.identifier, reason)) + } + for entry in HealthKitCatalog.categories where byKey[entry.key] == nil { + out.append((entry.identifier, isAvailable(entry.minimumOS) + ? "declared in the SDK but not provided by HealthKit on this iOS" : "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..93bb693 100644 --- a/Pulso/Sync/SyncEngine.swift +++ b/Pulso/Sync/SyncEngine.swift @@ -53,6 +53,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 +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 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 @@ -77,6 +80,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 +90,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 +167,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 +219,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 +237,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 +265,71 @@ 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 } } } + /// 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). 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) + guard !send.isEmpty || !superseded.isEmpty else { + 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)) + } + 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? { @@ -250,6 +338,7 @@ actor SyncEngine { func resetAllAnchors() { anchorCache = [:] + snapshotCache = [:] authBlocked = [] latestFetch = nil } diff --git a/Pulso/Sync/TriggerHub.swift b/Pulso/Sync/TriggerHub.swift index a4bafd2..dda1af3 100644 --- a/Pulso/Sync/TriggerHub.swift +++ b/Pulso/Sync/TriggerHub.swift @@ -61,42 +61,57 @@ 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 } + // Only types in the main read set share the observer: one the app + // can't observe (a correlation, a type shared object by object) + // would fail the whole query. Full passes pick those up. + guard type.access == .read 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 log = log + let query = HKObserverQuery(queryDescriptors: descriptors) { [weak self] _, changed, completionHandler, error in + if let error { + completionHandler() + Task { await log.warn("observer query error: \(error.localizedDescription)") } + 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 +119,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..79c698f --- /dev/null +++ b/PulsoTests/HealthKitCatalogTests.swift @@ -0,0 +1,158 @@ +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] = [] + var unprovided: [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 { + unprovided.append(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 { + unprovided.append(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, []) + // Some identifiers stay in the headers after HealthKit stops + // providing them; those must be reported as skipped, not crash. + print("PULSO-UNPROVIDED \(unprovided)") + XCTAssertLessThan(unprovided.count, 5) + let skipped = Set(TypeRegistry.skipped.map(\.identifier)) + for identifier in unprovided { + XCTAssertTrue(skipped.contains(identifier), identifier) + } + 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) + // Accepted only together with HRV; must survive the probe. + XCTAssertTrue(readSet.accepted.contains(HKSeriesType.heartbeat())) + 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))) + } + + 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() { + 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, []) + } +} diff --git a/PulsoTests/SerializerTests.swift b/PulsoTests/SerializerTests.swift index 8793cc3..cf2c1ee 100644 --- a/PulsoTests/SerializerTests.swift +++ b/PulsoTests/SerializerTests.swift @@ -251,51 +251,38 @@ 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) + // Some types (audio exposure) require a non-zero duration. + let sample = HKQuantitySample( + type: quantityType, quantity: HKQuantity(unit: presentation.unit, doubleValue: 1), + start: start, end: start.addingTimeInterval(60)) + 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..3d11995 --- /dev/null +++ b/scripts/gen_healthkit_catalog.py @@ -0,0 +1,263 @@ +#!/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*(.*))?$") +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*(?://.*)?$") + + +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 = re.sub(r"//.*$", "", raw).strip() + if not raw or raw.startswith(("//", "/*", "*")): + continue + cm = re.match(r"^(\w+)([^=]*?)(?:=\s*(.+?))?\s*,?\s*$", raw) + if not cm: + continue + case, attrs, value = cm.groups() + 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 + 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])