react-native-health
react-native-health copied to clipboard
Handle permission and method availability for runtime iOS version
Each data type in HealthKit is tied to a specific version of iOS for which it is available. There are some version checks RCTAppleHealthKit+TypesAndPermissions
, but for the most part, it's up to the developer to keep track of which data types they can use. Additionally, where there are version checks in RCTAppleHealthKit+TypesAndPermissions
, if the getReadPermFromText
or getWritePermFromText
returns nil for every permission requested, then the app crashes.
Describe the solution you'd like It would be really nice if the developer didn't have to consider what versions the permissions and getters/setters are available for. And I think this could be handled really nicely right in JS (with some backup where needed in native code). Ideally, the solution would be non-breaking, and not require any API or documentation updates.
Additional context I've taken a very quick stab at a potential solution for this. Note the below code has not been tested or even ran, I just wanted to throw it out here to see what ya'll thought.
First, modify the Permissions.js
file to include a mapping of human-readable permission to the min and max iOS versions for which it is valid and export this new PermissionAvailability
object.
/**
* Apple Health Permissions Availability
* A map of human-readable permission strings to their supported OS versions.
*
* @type {Object}
*/
export const PermissionAvailability = {
// PermissionKey: [min iOS Version, max iOS Version (optional)]
Vo2Max: [11.0],
};
/**
* Apple Health Permissions
*
* @type {Object}
*/
export const Permissions = Object.keys(PermissionAvailability)
.reduce((ret, perm) => ({ ...ret, [perm]: perm }), {});
Next, create a new constants file called Functions.js
(name and location definitely up for debate) which maps each of the library's api methods to the permissions that are required. Some methods might require more than one permission (like the heartbeat series).
/**
* React Native Health Functions
* A map of library functions to their required permissions.
*
* @type {Object}
*/
export const Functions = {
// FunctionName: [Permission0, Permission1, ..., PermissionN]
getVo2MaxSamples: ['Vo2Max']
}
Finally, modify index.js
to wrap the initHealthKit
function and strip out any requested permissions that are not available for the running iOS version. Additionally, wrap each of the getters/setters so that if called, they short-circuit and call the suppled callback function with an error stating the requested function is not available for the current iOS version.
import { Platform } from 'react-native';
import { Activities, Functions, Observers, Permissions, PermissionAvailability, Units } from './src/constants'
const { AppleHealthKit } = require('react-native').NativeModules;
// Checks a given permission to determine if it is available for the
// current iOS version.
const isPermissionAvailable = (perm) => {
const osVersion = parseFloat(Platform.Version);
const [minVerion, maxVersion] = PermissionAvailability[perm] || [];
// minVersion is required, max version is optional, return true if the os version falls
// between min and max [inclusive]
return minVerion && osVersion >= minVerion && (!maxVersion || osVersion <= maxVersion);
}
// Wrap the initHealthKit call to pre-process the requested permissions
// and strip out any that are not available for the version of iOS that
// is running on the device. Error checking will be handled by the
// underlying native method.
const initHealthKit = (options, callback) => {
const { read, write } = options?.permissions || {};
const opts = {
permissions: {
read: read && read.filter(perm => isPermissionAvailable(perm)),
write: write && write.filter(perm => isPermissionAvailable(perm)),
}
}
return AppleHealthKit.initHealthKit(opts, callback);
};
// Wrap Function calls with a version check so that a consumer of this library
// doesn't need to keep track of which APIs are available for each iOS version.
const functions = Object.keys(Functions).reduce((ret, func) => {
const perms = Functions[func];
const isAvailable = perms.reduce((result, perm) => result && isPermissionAvailable(perm), true);
// If the function is not available, replace with a mock function that immediately calls the
// supplied callback function with an error.
return Object.assign(ret, {
[func]: isAvailable && AppleHealthKit[func] ? AppleHealthKit[func] : (_, callback) => {
if (callback) {
callback(new Error(`${func} is not available for this iOS version.`));
}
}
});
}, {});
export const HealthKit = Object.assign({}, AppleHealthKit, functions, {
initHealthKit,
Constants: {
Activities,
Observers,
Permissions,
Units,
},
})
module.exports = HealthKit
Hey @klandell, thanks for your time figuring this out, amazing!
Looks like a pretty good idea, I just have one question. Why do we need to wrap the functions calls, if we do this check on the permissions, wouldn't that be easier?
@macelai The purpose of wrapping the functions is so that it's impossible for the native function to be called if the permissions it relies on don't exist. If a developer tries to use a function that relies on permissions that don't exist, then the app will very likely crash. (wrap is probably not the right word, the native function is used if the permissions exist, otherwise the function is replaced with a no-op that immediately calls the callback).
Taking Vo2Max as an example. Vo2Max is only available in iOS 11+. If a developer calls the getVo2MaxSamples
function, the first line of code that they'd hit on the native side is HKQuantityType *vo2MaxType = [HKQuantityType quantityTypeForIdentifier:HKQuantityTypeIdentifierVO2Max];
. In iOS 10 this identifier does not exist and an EXC_BAD_ACCESS
exception will be thrown which can't be caught by JS.
The check can definitely be done on the permissions themselves as you suggest, so that permissions that are not valid for the currently iOS versions are not passed to the initHealthKit
function (above is code to do that, but it could potentially be moved to the permissions file instead). However, that doesn't prevent the possibly dangerous situation of a developer calling a function that relies on that permission. We could leave it up to the developer to check if they CAN call the function, but I don't like leaving open the possibility of crashes.
This can also be accomplished on the native side by adding availability checks to each of the permissions in getReadPermFromText
and getWritePermFromText
.
And adding something like this to each function:
if (@available(iOS 13.0, *)) {
// implementation
} else {
callback(@[RCTMakeError(@"This function is not available for this iOS version", nil, nil)]);
}