Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/protected-data-main-actor-code6-retry.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 5 additions & 1 deletion packages/react-native-healthkit/ios/CoreModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,11 @@ class CoreModule: HybridCoreModuleSpec {
}

func isProtectedDataAvailableAsync() -> Promise<Bool> {
return Promise.resolved(withResult: UIApplication.shared.isProtectedDataAvailable)
return Promise.async {
return await MainActor.run {
UIApplication.shared.isProtectedDataAvailable
}
}
}

func isHealthDataAvailable() -> Bool {
Expand Down
28 changes: 28 additions & 0 deletions packages/react-native-healthkit/ios/Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(
_ 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()
}
}
56 changes: 32 additions & 24 deletions packages/react-native-healthkit/ios/QuantityTypeModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/react-native-healthkit/src/healthkit.ios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -461,6 +463,7 @@ export default {
subscribeToQuantitySamples,
startWatchApp,
isProtectedDataAvailable,
isProtectedDataAvailableAsync,
queryStateOfMindSamples,
queryStateOfMindSamplesWithAnchor,
saveStateOfMindSample,
Expand Down
5 changes: 5 additions & 0 deletions packages/react-native-healthkit/src/healthkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,10 @@ export const isProtectedDataAvailable = UnavailableFnFromModule(
'isProtectedDataAvailable',
false,
)
export const isProtectedDataAvailableAsync = UnavailableFnFromModule(
Comment thread
robertherber marked this conversation as resolved.
'isProtectedDataAvailableAsync',
Promise.resolve(false),
)
export const isObjectTypeAvailable = UnavailableFnFromModule(
'isObjectTypeAvailable',
false,
Expand Down Expand Up @@ -630,6 +634,7 @@ const HealthkitModule = {
subscribeToChanges,
startWatchApp,
isProtectedDataAvailable,
isProtectedDataAvailableAsync,
queryStateOfMindSamples,
queryStateOfMindSamplesWithAnchor,
saveStateOfMindSample,
Expand Down
Loading