diff --git a/.changeset/protected-data-main-actor-code6-retry.md b/.changeset/protected-data-main-actor-code6-retry.md new file mode 100644 index 00000000..c445c354 --- /dev/null +++ b/.changeset/protected-data-main-actor-code6-retry.md @@ -0,0 +1,5 @@ +--- +"@kingstinct/react-native-healthkit": patch +--- + +iOS: read `isProtectedDataAvailable` on the main actor in `isProtectedDataAvailableAsync` (`UIApplication` is `@MainActor`-isolated), and retry a statistics query once when HealthKit refuses with `errorDatabaseInaccessible` while protected data is available — the store re-opens a moment after the device unlocks, and a query issued in that window was surfacing as an opaque `Code=6` string. diff --git a/packages/react-native-healthkit/ios/CoreModule.swift b/packages/react-native-healthkit/ios/CoreModule.swift index 4aa43164..0faab243 100644 --- a/packages/react-native-healthkit/ios/CoreModule.swift +++ b/packages/react-native-healthkit/ios/CoreModule.swift @@ -320,7 +320,11 @@ class CoreModule: HybridCoreModuleSpec { } func isProtectedDataAvailableAsync() -> Promise { - return Promise.resolved(withResult: UIApplication.shared.isProtectedDataAvailable) + return Promise.async { + return await MainActor.run { + UIApplication.shared.isProtectedDataAvailable + } + } } func isHealthDataAvailable() -> Bool { diff --git a/packages/react-native-healthkit/ios/Helpers.swift b/packages/react-native-healthkit/ios/Helpers.swift index 859de1b5..26118684 100644 --- a/packages/react-native-healthkit/ios/Helpers.swift +++ b/packages/react-native-healthkit/ios/Helpers.swift @@ -483,3 +483,31 @@ func buildStatisticsOptions(statistics: [StatisticsOptions], quantityType: HKQua } return opts } + +private let storeReopenRetryDelay: UInt64 = 1_000_000_000 + +/// HealthKit seals its store the moment the device locks and re-opens it a beat +/// after it unlocks, and iOS can resume the app inside that beat. A query that +/// hits the gap fails with `errorDatabaseInaccessible` although protected data is +/// reported available — the store is re-opening, not sealed. Runs `operation` +/// and, on exactly that refusal, gives it one more attempt after a short delay. +/// A refusal while protected data is unavailable is the sealed store itself and +/// is thrown as-is, as is every other error. +func retryingWhileStoreReopens( + _ operation: () async throws -> T +) async throws -> T { + do { + return try await operation() + } catch { + let nsError = error as NSError + guard nsError.domain == HKError.errorDomain, + nsError.code == HKError.Code.errorDatabaseInaccessible.rawValue + else { throw error } + let protectedDataAvailable = await MainActor.run { + UIApplication.shared.isProtectedDataAvailable + } + guard protectedDataAvailable else { throw error } + try await Task.sleep(nanoseconds: storeReopenRetryDelay) + return try await operation() + } +} diff --git a/packages/react-native-healthkit/ios/QuantityTypeModule.swift b/packages/react-native-healthkit/ios/QuantityTypeModule.swift index 9a3cc9bd..9c178462 100644 --- a/packages/react-native-healthkit/ios/QuantityTypeModule.swift +++ b/packages/react-native-healthkit/ios/QuantityTypeModule.swift @@ -313,11 +313,13 @@ class QuantityTypeModule: HybridQuantityTypeModuleSpec { return Promise.async { let quantityType = try initializeQuantityType(identifier.stringValue) - if let gottenStats = try await queryStatisticsForQuantityInternal( - quantityType: quantityType, - statistics: statistics, - options: options - ) { + if let gottenStats = try await retryingWhileStoreReopens({ + try await queryStatisticsForQuantityInternal( + quantityType: quantityType, + statistics: statistics, + options: options + ) + }) { let unit = try await getUnitToUse( unitOverride: options?.unit, quantityType: quantityType @@ -336,13 +338,15 @@ class QuantityTypeModule: HybridQuantityTypeModuleSpec { return Promise.async { let quantityType = try initializeQuantityType(identifier.stringValue) - if let statistics = try await queryStatisticsCollectionForQuantityInternal( - quantityType: quantityType, - statistics: statistics, - anchorDate: anchorDate, - intervalComponents: intervalComponents, - options: options - ) { + if let statistics = try await retryingWhileStoreReopens({ + try await queryStatisticsCollectionForQuantityInternal( + quantityType: quantityType, + statistics: statistics, + anchorDate: anchorDate, + intervalComponents: intervalComponents, + options: options + ) + }) { let unit = try await getUnitToUse( unitOverride: options?.unit, @@ -385,11 +389,13 @@ class QuantityTypeModule: HybridQuantityTypeModuleSpec { return Promise.async { let quantityType = try initializeQuantityType(identifier.stringValue) - if let gottenStats = try await queryStatisticsForQuantityInternal( - quantityType: quantityType, - statistics: statistics, - options: options - ) { + if let gottenStats = try await retryingWhileStoreReopens({ + try await queryStatisticsForQuantityInternal( + quantityType: quantityType, + statistics: statistics, + options: options + ) + }) { let unit = try await getUnitToUse( unitOverride: options?.unit, quantityType: quantityType @@ -412,13 +418,15 @@ class QuantityTypeModule: HybridQuantityTypeModuleSpec { return Promise.async { let quantityType = try initializeQuantityType(identifier.stringValue) - if let statistics = try await queryStatisticsCollectionForQuantityInternal( - quantityType: quantityType, - statistics: statistics, - anchorDate: anchorDate, - intervalComponents: intervalComponents, - options: options - ) { + if let statistics = try await retryingWhileStoreReopens({ + try await queryStatisticsCollectionForQuantityInternal( + quantityType: quantityType, + statistics: statistics, + anchorDate: anchorDate, + intervalComponents: intervalComponents, + options: options + ) + }) { let unit = try await getUnitToUse( unitOverride: options?.unit, quantityType: quantityType diff --git a/packages/react-native-healthkit/src/healthkit.ios.ts b/packages/react-native-healthkit/src/healthkit.ios.ts index d55c45a1..cc71c1d3 100644 --- a/packages/react-native-healthkit/src/healthkit.ios.ts +++ b/packages/react-native-healthkit/src/healthkit.ios.ts @@ -368,6 +368,8 @@ export const saveWorkoutSample = WorkoutBindings.saveWorkoutSample export const startWatchApp = Workouts.startWatchAppWithWorkoutConfiguration.bind(Workouts) export const isProtectedDataAvailable = Core.isProtectedDataAvailable.bind(Core) +export const isProtectedDataAvailableAsync = + Core.isProtectedDataAvailableAsync.bind(Core) export const queryStateOfMindSamples = StateOfMindBindings.queryStateOfMindSamples export const queryStateOfMindSamplesWithAnchor = @@ -461,6 +463,7 @@ export default { subscribeToQuantitySamples, startWatchApp, isProtectedDataAvailable, + isProtectedDataAvailableAsync, queryStateOfMindSamples, queryStateOfMindSamplesWithAnchor, saveStateOfMindSample, diff --git a/packages/react-native-healthkit/src/healthkit.ts b/packages/react-native-healthkit/src/healthkit.ts index 11df17f4..a8567b10 100644 --- a/packages/react-native-healthkit/src/healthkit.ts +++ b/packages/react-native-healthkit/src/healthkit.ts @@ -137,6 +137,10 @@ export const isProtectedDataAvailable = UnavailableFnFromModule( 'isProtectedDataAvailable', false, ) +export const isProtectedDataAvailableAsync = UnavailableFnFromModule( + 'isProtectedDataAvailableAsync', + Promise.resolve(false), +) export const isObjectTypeAvailable = UnavailableFnFromModule( 'isObjectTypeAvailable', false, @@ -630,6 +634,7 @@ const HealthkitModule = { subscribeToChanges, startWatchApp, isProtectedDataAvailable, + isProtectedDataAvailableAsync, queryStateOfMindSamples, queryStateOfMindSamplesWithAnchor, saveStateOfMindSample,