@kingstinct/react-native-healthkit 14.0.2 → 15.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -2
- package/app.plugin.js +49 -94
- package/app.plugin.ts +6 -40
- package/ios/BackgroundDeliveryManager.swift +95 -7
- package/ios/BackgroundLaunchHook.mm +41 -0
- package/ios/Constants.swift +24 -0
- package/ios/CoreModule.swift +64 -18
- package/ios/ExceptionCatcher.h +1 -0
- package/ios/ExceptionCatcher.mm +15 -0
- package/ios/Helpers.swift +29 -1
- package/ios/MemoryEstimation.swift +83 -0
- package/ios/QuantityTypeModule.swift +34 -26
- package/ios/Serializers.swift +18 -3
- package/ios/SourceProxy.swift +14 -5
- package/ios/WorkoutProxy.swift +41 -12
- package/lib/commonjs/generated/healthkit.generated.js +13 -1
- package/lib/commonjs/healthkit.ios.js +3 -1
- package/lib/commonjs/healthkit.js +4 -2
- package/lib/module/generated/healthkit.generated.js +12 -0
- package/lib/module/healthkit.ios.js +2 -0
- package/lib/module/healthkit.js +2 -0
- package/lib/typescript/generated/healthkit.generated.d.ts +16 -3
- package/lib/typescript/healthkit.d.ts +1 -0
- package/lib/typescript/healthkit.ios.d.ts +2 -0
- package/lib/typescript/specs/CoreModule.nitro.d.ts +6 -4
- package/lib/typescript/specs/SourceProxy.nitro.d.ts +1 -2
- package/lib/typescript/types/QuantityType.d.ts +3 -3
- package/lib/typescript/types/Source.d.ts +2 -2
- package/lib/typescript/types/Workouts.d.ts +1 -0
- package/nitrogen/generated/ios/ReactNativeHealthkit-Swift-Cxx-Bridge.cpp +16 -16
- package/nitrogen/generated/ios/ReactNativeHealthkit-Swift-Cxx-Bridge.hpp +23 -12
- package/nitrogen/generated/ios/c++/HybridCategoryTypeModuleSpecSwift.hpp +7 -4
- package/nitrogen/generated/ios/c++/HybridCorrelationTypeModuleSpecSwift.hpp +7 -4
- package/nitrogen/generated/ios/c++/HybridElectrocardiogramModuleSpecSwift.hpp +7 -4
- package/nitrogen/generated/ios/c++/HybridHeartbeatSeriesModuleSpecSwift.hpp +7 -4
- package/nitrogen/generated/ios/c++/HybridMedicationModuleSpecSwift.hpp +7 -4
- package/nitrogen/generated/ios/c++/HybridQuantityTypeModuleSpecSwift.hpp +7 -4
- package/nitrogen/generated/ios/c++/HybridStateOfMindModuleSpecSwift.hpp +7 -4
- package/nitrogen/generated/ios/c++/HybridWorkoutProxySpecSwift.hpp +3 -4
- package/nitrogen/generated/ios/c++/HybridWorkoutsModuleSpecSwift.hpp +6 -3
- package/nitrogen/generated/ios/swift/CategoryTypeIdentifier.swift +8 -0
- package/nitrogen/generated/ios/swift/CategoryTypeIdentifierWriteable.swift +8 -0
- package/nitrogen/generated/ios/swift/ObjectTypeIdentifier.swift +8 -0
- package/nitrogen/generated/ios/swift/QueryStatisticsResponse.swift +6 -13
- package/nitrogen/generated/ios/swift/QueryStatisticsResponseFromSingleSource.swift +4 -11
- package/nitrogen/generated/ios/swift/SampleTypeIdentifier.swift +8 -0
- package/nitrogen/generated/ios/swift/SampleTypeIdentifierWriteable.swift +8 -0
- package/nitrogen/generated/ios/swift/SourceRevision.swift +4 -11
- package/nitrogen/generated/ios/swift/WorkoutActivity.swift +7 -2
- package/nitrogen/generated/ios/swift/WorkoutActivityType.swift +8 -0
- package/nitrogen/generated/shared/c++/CategoryTypeIdentifier.hpp +67 -59
- package/nitrogen/generated/shared/c++/CategoryTypeIdentifierWriteable.hpp +67 -59
- package/nitrogen/generated/shared/c++/ObjectTypeIdentifier.hpp +202 -194
- package/nitrogen/generated/shared/c++/QueryStatisticsResponse.hpp +8 -9
- package/nitrogen/generated/shared/c++/QueryStatisticsResponseFromSingleSource.hpp +8 -9
- package/nitrogen/generated/shared/c++/SampleTypeIdentifier.hpp +195 -187
- package/nitrogen/generated/shared/c++/SampleTypeIdentifierWriteable.hpp +189 -181
- package/nitrogen/generated/shared/c++/SourceRevision.hpp +8 -9
- package/nitrogen/generated/shared/c++/WorkoutActivity.hpp +9 -3
- package/nitrogen/generated/shared/c++/WorkoutActivityType.hpp +4 -0
- package/package.json +5 -3
- package/src/generated/healthkit-schema.json +50 -5
- package/src/generated/healthkit.generated.ts +18 -2
- package/src/healthkit.ios.ts +3 -0
- package/src/healthkit.ts +5 -0
- package/src/specs/CoreModule.nitro.ts +6 -4
- package/src/specs/SourceProxy.nitro.ts +1 -1
- package/src/type-tests/generated-typing.ts +36 -0
- package/src/types/QuantityType.ts +3 -3
- package/src/types/Source.ts +2 -2
- package/src/types/Workouts.ts +1 -0
package/README.md
CHANGED
|
@@ -121,13 +121,13 @@ Some imperative examples:
|
|
|
121
121
|
'HKQuantityTypeIdentifierInsulinDelivery',
|
|
122
122
|
'IU',
|
|
123
123
|
5.5,
|
|
124
|
+
new Date(),
|
|
125
|
+
new Date(),
|
|
124
126
|
{
|
|
125
|
-
metadata: {
|
|
126
127
|
// Metadata keys could be arbirtary string to store app-specific data.
|
|
127
128
|
// To use built-in types from https://developer.apple.com/documentation/healthkit/samples/metadata_keys
|
|
128
129
|
// you need to specify string values instead of variable names (by dropping MetadataKey from the name).
|
|
129
130
|
HKInsulinDeliveryReason: HKInsulinDeliveryReason.basal,
|
|
130
|
-
},
|
|
131
131
|
}
|
|
132
132
|
);
|
|
133
133
|
```
|
|
@@ -154,6 +154,36 @@ Example:
|
|
|
154
154
|
// etc..
|
|
155
155
|
```
|
|
156
156
|
|
|
157
|
+
### Memory considerations for workouts and other native objects
|
|
158
|
+
|
|
159
|
+
Workouts (and a few other results, like sources) are returned as *proxies*: native objects that keep the underlying `HKWorkout` alive so you can call follow-up functions such as `getWorkoutRoutes()`, `getStatistic()` or `getAllStatistics()` on them. The JavaScript garbage collector frees a proxy, and the HealthKit objects behind it, once it is no longer referenced. The library reports an estimate of each proxy's native size to the JS engine so it can schedule collections sensibly. Note that react-native-nitro-modules currently keeps a small fixed amount of bookkeeping (on the order of a hundred bytes) per native object it has handed to JS for the lifetime of the JS runtime, so memory will not return exactly to baseline even after collection. If you process large numbers of workouts in a loop you can release the HealthKit side eagerly by calling `dispose()` once you are done with a workout:
|
|
160
|
+
|
|
161
|
+
```TypeScript
|
|
162
|
+
const workouts = await queryWorkoutSamples({ limit: 0 })
|
|
163
|
+
|
|
164
|
+
for (const workout of workouts) {
|
|
165
|
+
const routes = await workout.getWorkoutRoutes()
|
|
166
|
+
// ... do something with the routes
|
|
167
|
+
workout.dispose()
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
After `dispose()` the proxy can no longer be used - calling any function or reading any property on it will throw. If you need to keep the data around, call `toJSON()` first to get a plain object and store that instead of the proxy:
|
|
172
|
+
|
|
173
|
+
```TypeScript
|
|
174
|
+
const plainWorkouts = workouts.map((workout) => {
|
|
175
|
+
const json = workout.toJSON()
|
|
176
|
+
workout.dispose()
|
|
177
|
+
return json
|
|
178
|
+
})
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Other tips for keeping memory in check when working with large datasets:
|
|
182
|
+
- Query in chunks (for example per month, or with `limit` and anchors) instead of fetching everything at once.
|
|
183
|
+
- Prefer statistics queries (`queryStatisticsForQuantity`, `queryStatisticsCollectionForQuantity`) over fetching every sample when you only need aggregates.
|
|
184
|
+
- Keep plain data (from `toJSON()` or the sample queries) in React state rather than proxies.
|
|
185
|
+
- `sourceRevision.source` on samples and `sources` on statistics are plain objects, not proxies. To filter queries by a source, get a `SourceProxy` from `querySources()` and match it by `bundleIdentifier`.
|
|
186
|
+
|
|
157
187
|
## Migration to 9.0.0
|
|
158
188
|
|
|
159
189
|
There are a lot of under-the-hood changes in version 9.0.0, some of them are breaking (although I've tried to reduce it as much as possible).
|
package/app.plugin.js
CHANGED
|
@@ -1,94 +1,49 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* @type {ConfigPlugin<InfoPlistConfig>}
|
|
52
|
-
*/
|
|
53
|
-
const withInfoPlistPlugin = (
|
|
54
|
-
config,
|
|
55
|
-
/**
|
|
56
|
-
* @type {{NSHealthShareUsageDescription?: string | true, NSHealthUpdateUsageDescription?: string | false} | undefined}
|
|
57
|
-
* */
|
|
58
|
-
props,
|
|
59
|
-
) =>
|
|
60
|
-
withInfoPlist(config, (config) => {
|
|
61
|
-
config.modResults.NSHealthShareUsageDescription =
|
|
62
|
-
typeof props?.NSHealthShareUsageDescription === 'string'
|
|
63
|
-
? props.NSHealthShareUsageDescription
|
|
64
|
-
: typeof config.modResults.NSHealthShareUsageDescription === 'string'
|
|
65
|
-
? config.modResults.NSHealthShareUsageDescription
|
|
66
|
-
: `${config.name} wants to read your health data`
|
|
67
|
-
|
|
68
|
-
if (props?.NSHealthUpdateUsageDescription !== false) {
|
|
69
|
-
config.modResults.NSHealthUpdateUsageDescription =
|
|
70
|
-
typeof props?.NSHealthUpdateUsageDescription === 'string'
|
|
71
|
-
? props.NSHealthUpdateUsageDescription
|
|
72
|
-
: typeof config.modResults.NSHealthUpdateUsageDescription === 'string'
|
|
73
|
-
? config.modResults.NSHealthUpdateUsageDescription
|
|
74
|
-
: `${config.name} wants to update your health data`
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
return config
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
const pkg = require('./package.json')
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* @type {ConfigPlugin<AppPluginConfig>}
|
|
84
|
-
*/
|
|
85
|
-
const healthkitAppPlugin = (config, props) =>
|
|
86
|
-
withPlugins(config, [
|
|
87
|
-
[withEntitlementsPlugin, props],
|
|
88
|
-
[withInfoPlistPlugin, props],
|
|
89
|
-
])
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* @type {ConfigPlugin<AppPluginConfig>}
|
|
93
|
-
*/
|
|
94
|
-
module.exports = createRunOncePlugin(healthkitAppPlugin, pkg.name, pkg.version)
|
|
1
|
+
// GENERATED FILE - do not edit by hand (gitignored).
|
|
2
|
+
// Compiled from app.plugin.ts by `bun run build:plugin`.
|
|
3
|
+
// Expo loads this file, not the .ts source, so edit app.plugin.ts and rebuild.
|
|
4
|
+
"use strict";
|
|
5
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
6
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
7
|
+
};
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
const config_plugins_1 = require("@expo/config-plugins");
|
|
10
|
+
const package_json_1 = __importDefault(require("./package.json"));
|
|
11
|
+
const withEntitlementsPlugin = (config, props) => {
|
|
12
|
+
return (0, config_plugins_1.withEntitlementsPlist)(config, (configPlist) => {
|
|
13
|
+
configPlist.modResults['com.apple.developer.healthkit'] = true;
|
|
14
|
+
// background is enabled by default, but possible to opt-out from
|
|
15
|
+
// (haven't seen any drawbacks from having it enabled)
|
|
16
|
+
if (props?.background !== false) {
|
|
17
|
+
configPlist.modResults['com.apple.developer.healthkit.background-delivery'] = true;
|
|
18
|
+
}
|
|
19
|
+
return configPlist;
|
|
20
|
+
});
|
|
21
|
+
};
|
|
22
|
+
const withInfoPlistPlugin = (config, props) => {
|
|
23
|
+
return (0, config_plugins_1.withInfoPlist)(config, (configPlist) => {
|
|
24
|
+
const existingShareDescription = configPlist.modResults.NSHealthShareUsageDescription;
|
|
25
|
+
configPlist.modResults.NSHealthShareUsageDescription =
|
|
26
|
+
typeof props?.NSHealthShareUsageDescription === 'string'
|
|
27
|
+
? props.NSHealthShareUsageDescription
|
|
28
|
+
: typeof existingShareDescription === 'string'
|
|
29
|
+
? existingShareDescription
|
|
30
|
+
: `${config.name ?? package_json_1.default.name} wants to read your health data`;
|
|
31
|
+
if (props?.NSHealthUpdateUsageDescription !== false) {
|
|
32
|
+
const existingUpdateDescription = configPlist.modResults.NSHealthUpdateUsageDescription;
|
|
33
|
+
configPlist.modResults.NSHealthUpdateUsageDescription =
|
|
34
|
+
typeof props?.NSHealthUpdateUsageDescription === 'string'
|
|
35
|
+
? props.NSHealthUpdateUsageDescription
|
|
36
|
+
: typeof existingUpdateDescription === 'string'
|
|
37
|
+
? existingUpdateDescription
|
|
38
|
+
: `${config.name ?? package_json_1.default.name} wants to update your health data`;
|
|
39
|
+
}
|
|
40
|
+
return configPlist;
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
const healthkitAppPlugin = (config, props) => {
|
|
44
|
+
return (0, config_plugins_1.withPlugins)(config, [
|
|
45
|
+
[withEntitlementsPlugin, props],
|
|
46
|
+
[withInfoPlistPlugin, props],
|
|
47
|
+
]);
|
|
48
|
+
};
|
|
49
|
+
exports.default = (0, config_plugins_1.createRunOncePlugin)(healthkitAppPlugin, package_json_1.default.name, package_json_1.default.version);
|
package/app.plugin.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type ConfigPlugin,
|
|
3
3
|
createRunOncePlugin,
|
|
4
|
-
withAppDelegate,
|
|
5
4
|
withEntitlementsPlist,
|
|
6
5
|
withInfoPlist,
|
|
7
6
|
withPlugins,
|
|
@@ -17,6 +16,12 @@ type InfoPlistConfig = {
|
|
|
17
16
|
}
|
|
18
17
|
|
|
19
18
|
type AppPluginConfig = InfoPlistConfig & {
|
|
19
|
+
/**
|
|
20
|
+
* Adds the background-delivery entitlement. Enabled by default; set to
|
|
21
|
+
* `false` to opt out. Launch-time observer registration needs no AppDelegate
|
|
22
|
+
* change: the pod hooks UIApplicationDidFinishLaunchingNotification itself
|
|
23
|
+
* (see ios/BackgroundLaunchHook.mm).
|
|
24
|
+
*/
|
|
20
25
|
background?: BackgroundConfig
|
|
21
26
|
}
|
|
22
27
|
|
|
@@ -64,49 +69,10 @@ const withInfoPlistPlugin: ConfigPlugin<InfoPlistConfig> = (config, props) => {
|
|
|
64
69
|
})
|
|
65
70
|
}
|
|
66
71
|
|
|
67
|
-
const withAppDelegatePlugin: ConfigPlugin<{
|
|
68
|
-
background?: BackgroundConfig
|
|
69
|
-
}> = (config, props) => {
|
|
70
|
-
if (props?.background === false) {
|
|
71
|
-
return config
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
return withAppDelegate(config, (configDelegate) => {
|
|
75
|
-
const contents = configDelegate.modResults.contents
|
|
76
|
-
|
|
77
|
-
// Add import for HealthKit if not already present
|
|
78
|
-
if (!contents.includes('import HealthKit')) {
|
|
79
|
-
configDelegate.modResults.contents =
|
|
80
|
-
configDelegate.modResults.contents.replace(
|
|
81
|
-
/^(import .+\n)/m,
|
|
82
|
-
'$1import HealthKit\n',
|
|
83
|
-
)
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Insert BackgroundDeliveryManager setup into didFinishLaunchingWithOptions
|
|
87
|
-
const setupCall =
|
|
88
|
-
' BackgroundDeliveryManager.shared.setupBackgroundObservers()\n'
|
|
89
|
-
|
|
90
|
-
if (
|
|
91
|
-
!configDelegate.modResults.contents.includes('BackgroundDeliveryManager')
|
|
92
|
-
) {
|
|
93
|
-
// Match the opening of didFinishLaunchingWithOptions and insert after the opening brace
|
|
94
|
-
configDelegate.modResults.contents =
|
|
95
|
-
configDelegate.modResults.contents.replace(
|
|
96
|
-
/(func application\(.+didFinishLaunchingWithOptions.+\{)\n/,
|
|
97
|
-
`$1\n${setupCall}`,
|
|
98
|
-
)
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
return configDelegate
|
|
102
|
-
})
|
|
103
|
-
}
|
|
104
|
-
|
|
105
72
|
const healthkitAppPlugin: ConfigPlugin<AppPluginConfig> = (config, props) => {
|
|
106
73
|
return withPlugins(config, [
|
|
107
74
|
[withEntitlementsPlugin, props],
|
|
108
75
|
[withInfoPlistPlugin, props],
|
|
109
|
-
[withAppDelegatePlugin, props],
|
|
110
76
|
])
|
|
111
77
|
}
|
|
112
78
|
|
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
import Foundation
|
|
2
2
|
import HealthKit
|
|
3
|
+
import os.log
|
|
4
|
+
|
|
5
|
+
private let bgLog = OSLog(subsystem: "com.kingstinct.healthkit", category: "BackgroundDelivery")
|
|
3
6
|
|
|
4
7
|
/// Manages HealthKit background delivery by registering observer queries at app launch,
|
|
5
8
|
/// before the JS bridge is available. This is required by Apple — observer queries must
|
|
6
9
|
/// be set up in `application(_:didFinishLaunchingWithOptions:)` to receive background
|
|
7
10
|
/// delivery callbacks after the app has been terminated.
|
|
8
11
|
///
|
|
9
|
-
///
|
|
10
|
-
///
|
|
12
|
+
/// `BackgroundLaunchHook.mm` calls `setupBackgroundObservers()` on
|
|
13
|
+
/// UIApplicationDidFinishLaunchingNotification, so no AppDelegate change is needed.
|
|
14
|
+
/// The `@objc(BackgroundDeliveryManager)` name is what that hook looks up at runtime.
|
|
11
15
|
///
|
|
12
16
|
/// The types to observe are persisted in UserDefaults by `configureBackgroundTypes()`
|
|
13
17
|
/// called from JS. On subsequent cold launches, the manager reads these and registers
|
|
14
18
|
/// observers immediately, queuing any events until JS subscribes via `drainPendingEvents()`.
|
|
15
|
-
@objc
|
|
19
|
+
@objc(BackgroundDeliveryManager)
|
|
20
|
+
public class BackgroundDeliveryManager: NSObject {
|
|
16
21
|
@objc public static let shared = BackgroundDeliveryManager()
|
|
17
22
|
|
|
18
23
|
private let healthStore = HKHealthStore()
|
|
@@ -20,6 +25,11 @@ import HealthKit
|
|
|
20
25
|
private var observerQueries: [String: HKObserverQuery] = [:]
|
|
21
26
|
private var pendingEvents: [(typeIdentifier: String, errorMessage: String?)] = []
|
|
22
27
|
private var jsCallback: ((String, String?) -> Void)?
|
|
28
|
+
// Per-type callbacks, checked before the global `jsCallback` above. Lets
|
|
29
|
+
// CoreModule.subscribeToObserverQuery route a JS subscription for a
|
|
30
|
+
// background-configured type through this manager's already-running
|
|
31
|
+
// HKObserverQuery instead of registering a second, independent one.
|
|
32
|
+
private var typeCallbacks: [String: (String, String?) -> Void] = [:]
|
|
23
33
|
private var isSetUp = false
|
|
24
34
|
|
|
25
35
|
static let typesKey = "com.kingstinct.healthkit.backgroundTypes"
|
|
@@ -29,18 +39,23 @@ import HealthKit
|
|
|
29
39
|
super.init()
|
|
30
40
|
}
|
|
31
41
|
|
|
32
|
-
///
|
|
33
|
-
///
|
|
42
|
+
/// Registers observer queries for any previously configured background delivery
|
|
43
|
+
/// types. Called at launch by `BackgroundLaunchHook.mm`; safe to call again.
|
|
34
44
|
@objc public func setupBackgroundObservers() {
|
|
35
45
|
guard HKHealthStore.isHealthDataAvailable() else { return }
|
|
36
46
|
|
|
37
47
|
guard let typeIdentifiers = UserDefaults.standard.stringArray(forKey: BackgroundDeliveryManager.typesKey) else {
|
|
48
|
+
os_log("launch: no background delivery types configured, nothing to register", log: bgLog, type: .debug)
|
|
38
49
|
return
|
|
39
50
|
}
|
|
40
51
|
|
|
41
52
|
let frequencyRaw = UserDefaults.standard.integer(forKey: BackgroundDeliveryManager.frequencyKey)
|
|
42
53
|
let frequency = HKUpdateFrequency(rawValue: frequencyRaw) ?? .immediate
|
|
43
54
|
|
|
55
|
+
os_log(
|
|
56
|
+
"launch: registering %d background observer(s): %{public}@", log: bgLog, type: .info,
|
|
57
|
+
typeIdentifiers.count, typeIdentifiers.joined(separator: ", "))
|
|
58
|
+
|
|
44
59
|
registerObservers(typeIdentifiers: typeIdentifiers, frequency: frequency)
|
|
45
60
|
}
|
|
46
61
|
|
|
@@ -85,6 +100,37 @@ import HealthKit
|
|
|
85
100
|
}
|
|
86
101
|
}
|
|
87
102
|
|
|
103
|
+
/// Subscribe a JS callback for a specific type. Any events for this type
|
|
104
|
+
/// that arrived before JS subscribed (e.g. from a background wake, queued
|
|
105
|
+
/// in `pendingEvents` above) are flushed immediately.
|
|
106
|
+
func setCallback(typeIdentifier: String, callback: @escaping (String, String?) -> Void) {
|
|
107
|
+
queue.sync(flags: .barrier) {
|
|
108
|
+
self.typeCallbacks[typeIdentifier] = callback
|
|
109
|
+
let matching = self.pendingEvents.filter { $0.typeIdentifier == typeIdentifier }
|
|
110
|
+
self.pendingEvents.removeAll { $0.typeIdentifier == typeIdentifier }
|
|
111
|
+
for event in matching {
|
|
112
|
+
callback(typeIdentifier, event.errorMessage)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/// Remove the per-type JS callback (e.g., on unsubscribe).
|
|
118
|
+
func removeCallback(typeIdentifier: String) {
|
|
119
|
+
queue.sync(flags: .barrier) {
|
|
120
|
+
self.typeCallbacks.removeValue(forKey: typeIdentifier)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/// True if `typeIdentifier` has a native HKObserverQuery registered by
|
|
125
|
+
/// `registerObservers` — i.e. background delivery is configured for it.
|
|
126
|
+
/// CoreModule.subscribeToObserverQuery checks this before deciding whether
|
|
127
|
+
/// to reuse this manager's observer or register its own.
|
|
128
|
+
func isBackgroundConfigured(typeIdentifier: String) -> Bool {
|
|
129
|
+
return queue.sync {
|
|
130
|
+
self.observerQueries[typeIdentifier] != nil
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
88
134
|
/// Stop all observer queries and clear state.
|
|
89
135
|
func tearDown() {
|
|
90
136
|
queue.sync(flags: .barrier) {
|
|
@@ -92,6 +138,7 @@ import HealthKit
|
|
|
92
138
|
self.healthStore.stop(query)
|
|
93
139
|
}
|
|
94
140
|
self.observerQueries = [:]
|
|
141
|
+
self.typeCallbacks = [:]
|
|
95
142
|
self.isSetUp = false
|
|
96
143
|
}
|
|
97
144
|
}
|
|
@@ -121,6 +168,11 @@ import HealthKit
|
|
|
121
168
|
sampleType: sampleType,
|
|
122
169
|
predicate: nil
|
|
123
170
|
) { [weak self] (_: HKObserverQuery, completionHandler: @escaping HKObserverQueryCompletionHandler, error: Error?) in
|
|
171
|
+
// handleObserverCallback opens a beginBackgroundTask() window as its
|
|
172
|
+
// first, synchronous action, before this returns to call
|
|
173
|
+
// completionHandler() — otherwise iOS is free to suspend the process
|
|
174
|
+
// the moment HealthKit's own completion handler fires, possibly
|
|
175
|
+
// before Hermes has finished booting to run the JS callback.
|
|
124
176
|
self?.handleObserverCallback(
|
|
125
177
|
typeIdentifier: typeIdentifier,
|
|
126
178
|
error: error
|
|
@@ -147,18 +199,54 @@ import HealthKit
|
|
|
147
199
|
|
|
148
200
|
private func handleObserverCallback(typeIdentifier: String, error: Error?) {
|
|
149
201
|
let errorMessage = error?.localizedDescription
|
|
202
|
+
os_log("observer fired: %{public}@", log: bgLog, type: .info, typeIdentifier)
|
|
203
|
+
|
|
204
|
+
// Ask iOS for real execution time before doing anything else, so the
|
|
205
|
+
// process survives long enough for Hermes to boot and the JS callback to
|
|
206
|
+
// run — see the comment at this query's registration above.
|
|
207
|
+
var backgroundTaskId = UIBackgroundTaskIdentifier.invalid
|
|
208
|
+
let endTask: () -> Void = { [weak self] in
|
|
209
|
+
self?.queue.sync(flags: .barrier) {
|
|
210
|
+
guard backgroundTaskId != .invalid else { return }
|
|
211
|
+
UIApplication.shared.endBackgroundTask(backgroundTaskId)
|
|
212
|
+
backgroundTaskId = .invalid
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
backgroundTaskId = UIApplication.shared.beginBackgroundTask(withName: "healthkit-observer-\(typeIdentifier)") {
|
|
216
|
+
os_log("background task expired: %{public}@", log: bgLog, type: .info, typeIdentifier)
|
|
217
|
+
endTask()
|
|
218
|
+
}
|
|
219
|
+
os_log("background task started: %{public}@", log: bgLog, type: .info, typeIdentifier)
|
|
150
220
|
|
|
151
221
|
queue.sync(flags: .barrier) {
|
|
152
|
-
if let callback = self.
|
|
222
|
+
if let callback = self.typeCallbacks[typeIdentifier] {
|
|
223
|
+
DispatchQueue.main.async {
|
|
224
|
+
os_log("dispatching to per-type JS callback: %{public}@", log: bgLog, type: .info, typeIdentifier)
|
|
225
|
+
callback(typeIdentifier, errorMessage)
|
|
226
|
+
}
|
|
227
|
+
} else if let callback = self.jsCallback {
|
|
153
228
|
// JS is connected — dispatch to main thread for JSI safety
|
|
154
229
|
DispatchQueue.main.async {
|
|
230
|
+
os_log("dispatching to global JS callback: %{public}@", log: bgLog, type: .info, typeIdentifier)
|
|
155
231
|
callback(typeIdentifier, errorMessage)
|
|
156
232
|
}
|
|
157
233
|
} else {
|
|
158
234
|
// JS not ready yet — queue the event for later
|
|
235
|
+
os_log("no JS callback yet, queuing: %{public}@", log: bgLog, type: .info, typeIdentifier)
|
|
159
236
|
self.pendingEvents.append((typeIdentifier: typeIdentifier, errorMessage: errorMessage))
|
|
160
237
|
}
|
|
161
238
|
}
|
|
239
|
+
|
|
240
|
+
// Hold the task open for a fixed, generous window rather than trying to
|
|
241
|
+
// ack precisely when JS finishes — Hermes boot plus the JS callback's own
|
|
242
|
+
// async work (HealthKit reads + Supabase upsert) is unpredictable, and a
|
|
243
|
+
// completion-ack channel back from JS isn't worth building on the first
|
|
244
|
+
// pass for a personal-use app. First place to revisit if verification
|
|
245
|
+
// shows this window is too tight.
|
|
246
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 25) {
|
|
247
|
+
os_log("background task window elapsed: %{public}@", log: bgLog, type: .info, typeIdentifier)
|
|
248
|
+
endTask()
|
|
249
|
+
}
|
|
162
250
|
}
|
|
163
251
|
|
|
164
252
|
// Local type resolution that doesn't depend on NitroModules (which isn't available at AppDelegate time).
|
|
@@ -169,7 +257,7 @@ import HealthKit
|
|
|
169
257
|
return HKSampleType.quantityType(forIdentifier: typeId)
|
|
170
258
|
}
|
|
171
259
|
if identifier.starts(with: "HKCategoryTypeIdentifier") {
|
|
172
|
-
let typeId =
|
|
260
|
+
let typeId = hkCategoryTypeIdentifier(fromIdentifierName: identifier)
|
|
173
261
|
return HKSampleType.categoryType(forIdentifier: typeId)
|
|
174
262
|
}
|
|
175
263
|
if identifier == "HKWorkoutTypeIdentifier" {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#import <Foundation/Foundation.h>
|
|
2
|
+
#import <UIKit/UIKit.h>
|
|
3
|
+
|
|
4
|
+
/// Registers the launch-time observer setup without any AppDelegate changes.
|
|
5
|
+
///
|
|
6
|
+
/// `+load` runs when the binary is loaded, before `main`. It subscribes to
|
|
7
|
+
/// UIApplicationDidFinishLaunchingNotification, which UIKit posts synchronously
|
|
8
|
+
/// right after `application(_:didFinishLaunchingWithOptions:)` returns and
|
|
9
|
+
/// before the run loop continues, so observer queries are still registered
|
|
10
|
+
/// during launch as HealthKit background delivery requires. The manager is
|
|
11
|
+
/// looked up by name at runtime: importing this pod's Swift module from the
|
|
12
|
+
/// app's AppDelegate would drag NitroModules' C++ headers into a target
|
|
13
|
+
/// compiled without C++ interop and fail to build.
|
|
14
|
+
@interface BackgroundLaunchHook : NSObject
|
|
15
|
+
@end
|
|
16
|
+
|
|
17
|
+
@implementation BackgroundLaunchHook
|
|
18
|
+
|
|
19
|
+
+ (void)load {
|
|
20
|
+
[[NSNotificationCenter defaultCenter]
|
|
21
|
+
addObserverForName:UIApplicationDidFinishLaunchingNotification
|
|
22
|
+
object:nil
|
|
23
|
+
queue:nil
|
|
24
|
+
usingBlock:^(NSNotification *_Nonnull note) {
|
|
25
|
+
Class managerClass = NSClassFromString(@"BackgroundDeliveryManager");
|
|
26
|
+
SEL sharedSelector = NSSelectorFromString(@"shared");
|
|
27
|
+
SEL setupSelector = NSSelectorFromString(@"setupBackgroundObservers");
|
|
28
|
+
if (managerClass == nil || ![managerClass respondsToSelector:sharedSelector]) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
#pragma clang diagnostic push
|
|
32
|
+
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
|
|
33
|
+
id shared = [managerClass performSelector:sharedSelector];
|
|
34
|
+
if ([shared respondsToSelector:setupSelector]) {
|
|
35
|
+
[shared performSelector:setupSelector];
|
|
36
|
+
}
|
|
37
|
+
#pragma clang diagnostic pop
|
|
38
|
+
}];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@end
|
package/ios/Constants.swift
CHANGED
|
@@ -34,3 +34,27 @@ let SpeedUnit = HKUnit(from: "m/s") // HKUnit.meter().unitDivided(by: HKUnit.se
|
|
|
34
34
|
let METUnit = HKUnit(from: "kcal/hr·kg")
|
|
35
35
|
|
|
36
36
|
let DEFAULT_QUERY_LIMIT = 20
|
|
37
|
+
|
|
38
|
+
/// Apple renamed `HKCategoryTypeIdentifierAudioExposureEvent` to
|
|
39
|
+
/// `HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent` in iOS 14, but the new
|
|
40
|
+
/// constant still carries the old raw string at runtime. Building the identifier from the
|
|
41
|
+
/// modern name as a raw string therefore makes `categoryType(forIdentifier:)` return nil,
|
|
42
|
+
/// and samples read back from HealthKit report the legacy name. These helpers translate
|
|
43
|
+
/// in both directions so JS only ever sees the modern identifier.
|
|
44
|
+
let HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent_NAME =
|
|
45
|
+
"HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent"
|
|
46
|
+
let HKCategoryTypeIdentifierAudioExposureEvent_NAME = "HKCategoryTypeIdentifierAudioExposureEvent"
|
|
47
|
+
|
|
48
|
+
func hkCategoryTypeIdentifier(fromIdentifierName name: String) -> HKCategoryTypeIdentifier {
|
|
49
|
+
if name == HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent_NAME {
|
|
50
|
+
return .environmentalAudioExposureEvent
|
|
51
|
+
}
|
|
52
|
+
return HKCategoryTypeIdentifier(rawValue: name)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
func categoryTypeIdentifierName(fromHealthKitIdentifier identifier: String) -> String {
|
|
56
|
+
if identifier == HKCategoryTypeIdentifierAudioExposureEvent_NAME {
|
|
57
|
+
return HKCategoryTypeIdentifierEnvironmentalAudioExposureEvent_NAME
|
|
58
|
+
}
|
|
59
|
+
return identifier
|
|
60
|
+
}
|
package/ios/CoreModule.swift
CHANGED
|
@@ -157,21 +157,27 @@ class CoreModule: HybridCoreModuleSpec {
|
|
|
157
157
|
returning: .unnecessary
|
|
158
158
|
)
|
|
159
159
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
continuation.resume(returning: authStatus)
|
|
160
|
+
var caughtError: NSError?
|
|
161
|
+
let started = RunBlockCatchingObjCExceptions({
|
|
162
|
+
store.getRequestStatusForAuthorization(toShare: toShare, read: toRead) {
|
|
163
|
+
status, error in
|
|
164
|
+
DispatchQueue.main.async {
|
|
165
|
+
if let error = error {
|
|
166
|
+
continuation.resume(throwing: error)
|
|
168
167
|
} else {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
168
|
+
if let authStatus = AuthorizationRequestStatus(rawValue: Int32(status.rawValue)) {
|
|
169
|
+
continuation.resume(returning: authStatus)
|
|
170
|
+
} else {
|
|
171
|
+
continuation.resume(
|
|
172
|
+
throwing: runtimeErrorWithPrefix(
|
|
173
|
+
"Unrecognized authStatus returned: \(status.rawValue)"))
|
|
174
|
+
}
|
|
172
175
|
}
|
|
173
176
|
}
|
|
174
177
|
}
|
|
178
|
+
}, &caughtError)
|
|
179
|
+
if !started, let caughtError {
|
|
180
|
+
continuation.resume(throwing: caughtError)
|
|
175
181
|
}
|
|
176
182
|
}
|
|
177
183
|
}
|
|
@@ -183,14 +189,20 @@ class CoreModule: HybridCoreModuleSpec {
|
|
|
183
189
|
let toRead = objectTypesFromArray(typeIdentifiers: toRequest.toRead ?? [])
|
|
184
190
|
|
|
185
191
|
return try await withCheckedThrowingContinuation { continuation in
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
+
var caughtError: NSError?
|
|
193
|
+
let started = RunBlockCatchingObjCExceptions({
|
|
194
|
+
store.requestAuthorization(toShare: share, read: toRead) { status, error in
|
|
195
|
+
DispatchQueue.main.async {
|
|
196
|
+
if let error = error {
|
|
197
|
+
continuation.resume(throwing: error)
|
|
198
|
+
} else {
|
|
199
|
+
continuation.resume(returning: status)
|
|
200
|
+
}
|
|
192
201
|
}
|
|
193
202
|
}
|
|
203
|
+
}, &caughtError)
|
|
204
|
+
if !started, let caughtError {
|
|
205
|
+
continuation.resume(throwing: caughtError)
|
|
194
206
|
}
|
|
195
207
|
}
|
|
196
208
|
}
|
|
@@ -308,7 +320,11 @@ class CoreModule: HybridCoreModuleSpec {
|
|
|
308
320
|
}
|
|
309
321
|
|
|
310
322
|
func isProtectedDataAvailableAsync() -> Promise<Bool> {
|
|
311
|
-
return Promise.
|
|
323
|
+
return Promise.async {
|
|
324
|
+
return await MainActor.run {
|
|
325
|
+
UIApplication.shared.isProtectedDataAvailable
|
|
326
|
+
}
|
|
327
|
+
}
|
|
312
328
|
}
|
|
313
329
|
|
|
314
330
|
func isHealthDataAvailable() -> Bool {
|
|
@@ -349,6 +365,10 @@ class CoreModule: HybridCoreModuleSpec {
|
|
|
349
365
|
}
|
|
350
366
|
|
|
351
367
|
var _runningQueries: [String: HKQuery] = [:]
|
|
368
|
+
// queryId -> typeIdentifier for subscriptions routed through
|
|
369
|
+
// BackgroundDeliveryManager instead of getting their own HKObserverQuery
|
|
370
|
+
// (see subscribeToObserverQuery below).
|
|
371
|
+
var _backgroundRoutedQueries: [String: String] = [:]
|
|
352
372
|
|
|
353
373
|
func deleteObjects(objectTypeIdentifier: SampleTypeIdentifierWriteable, filter: FilterForSamples)
|
|
354
374
|
-> Promise<Double> {
|
|
@@ -377,6 +397,25 @@ class CoreModule: HybridCoreModuleSpec {
|
|
|
377
397
|
typeIdentifier: SampleTypeIdentifier,
|
|
378
398
|
callback: @escaping (OnChangeCallbackArgs) -> Void
|
|
379
399
|
) throws -> String {
|
|
400
|
+
let typeIdString = typeIdentifier.stringValue
|
|
401
|
+
|
|
402
|
+
if BackgroundDeliveryManager.shared.isBackgroundConfigured(typeIdentifier: typeIdString) {
|
|
403
|
+
// BackgroundDeliveryManager already has an HKObserverQuery running for
|
|
404
|
+
// this type (registered at launch, survives termination) — route
|
|
405
|
+
// through it instead of registering a second, independent observer for
|
|
406
|
+
// the same type.
|
|
407
|
+
let queryId = UUID().uuidString
|
|
408
|
+
BackgroundDeliveryManager.shared.setCallback(typeIdentifier: typeIdString) {
|
|
409
|
+
(_, errorMessage) in
|
|
410
|
+
DispatchQueue.main.async {
|
|
411
|
+
callback(
|
|
412
|
+
OnChangeCallbackArgs(typeIdentifier: typeIdentifier, errorMessage: errorMessage))
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
self._backgroundRoutedQueries[queryId] = typeIdString
|
|
416
|
+
return queryId
|
|
417
|
+
}
|
|
418
|
+
|
|
380
419
|
let sampleType = try sampleTypeFrom(sampleTypeIdentifier: typeIdentifier)
|
|
381
420
|
|
|
382
421
|
let predicate = HKQuery.predicateForSamples(
|
|
@@ -411,6 +450,13 @@ class CoreModule: HybridCoreModuleSpec {
|
|
|
411
450
|
}
|
|
412
451
|
|
|
413
452
|
func unsubscribeQuery(queryId: String) throws -> Bool {
|
|
453
|
+
if let typeIdString = self._backgroundRoutedQueries[queryId] {
|
|
454
|
+
BackgroundDeliveryManager.shared.removeCallback(typeIdentifier: typeIdString)
|
|
455
|
+
self._backgroundRoutedQueries.removeValue(forKey: queryId)
|
|
456
|
+
|
|
457
|
+
return true
|
|
458
|
+
}
|
|
459
|
+
|
|
414
460
|
guard let query = self._runningQueries[queryId] else {
|
|
415
461
|
warnWithPrefix("unsubscribeQuery: Query with id \(queryId) not found")
|
|
416
462
|
|
package/ios/ExceptionCatcher.h
CHANGED
|
@@ -2,3 +2,4 @@
|
|
|
2
2
|
#import <HealthKit/HealthKit.h>
|
|
3
3
|
|
|
4
4
|
HKUnit * _Nullable HKUnitFromStringCatchingExceptions(NSString * _Nonnull unitString, NSError * _Nullable * _Nullable outError);
|
|
5
|
+
BOOL RunBlockCatchingObjCExceptions(void (NS_NOESCAPE ^_Nonnull block)(void), NSError * _Nullable * _Nullable outError);
|