import { NativeEventEmitter, NativeModules, Platform } from 'react-native'; import { AndroidHealthProvider, IOSHealthProvider } from './health_config'; import type { HealthConfig, HealthProvider } from './health_config'; import type { AskConfig } from './ask_config'; import type { Subscription } from '@tryvital/vital-core-react-native'; // Reexports export * from './health_config'; const LINKING_ERROR = `The package 'vital-health-react-native' doesn't seem to be linked. Make sure: \n\n` + Platform.select({ ios: "- You have run 'pod install'\n", default: '' }) + '- You rebuilt the app after installing the package\n' + '- You are not using Expo Go\n'; const VitalHealthReactNative = NativeModules.VitalHealthReactNative ? NativeModules.VitalHealthReactNative : new Proxy( {}, { get() { throw new Error(LINKING_ERROR); }, } ); const eventNames = { syncStatus: { apple_health_kit: 'Status', health_connect: 'Status', samsung_health: 'SamsungHealthSyncStatus', } satisfies Record, connectionStatus: { apple_health_kit: 'VitalHealthConnectionStatus', health_connect: 'HealthConnectConnectionStatus', samsung_health: 'SamsungHealthConnectionStatus', } satisfies Record, }; function defaultHealthProvider(): HealthProvider { return Platform.OS === 'android' ? AndroidHealthProvider.HealthConnect : IOSHealthProvider.AppleHealthKit; } function isAndroidHealthProvider( provider: HealthProvider ): provider is AndroidHealthProvider { return ( provider === AndroidHealthProvider.HealthConnect || provider === AndroidHealthProvider.SamsungHealth ); } function validateHealthProvider(provider: HealthProvider): HealthProvider { if (Platform.OS === 'ios') { if (provider !== IOSHealthProvider.AppleHealthKit) { throw new Error( `Provider ${provider} is only supported on Android. Use ${IOSHealthProvider.AppleHealthKit} on iOS.` ); } return provider; } if (Platform.OS === 'android') { if (!isAndroidHealthProvider(provider)) { throw new Error( `Provider ${provider} is only supported on iOS. Use ${AndroidHealthProvider.HealthConnect} or ${AndroidHealthProvider.SamsungHealth} on Android.` ); } return provider; } throw new Error(`Unsupported platform: ${Platform.OS}.`); } export const VitalHealthReactNativeModule = VitalHealthReactNative; export const VitalHealthEvents = { statusEvent: eventNames.syncStatus[defaultHealthProvider()], }; export interface SyncNotificationContent { notificationTitle: string; notificationContent: string; channelName: string; channelDescription: string; } export type ConnectionStatus = | 'autoConnect' | 'connected' | 'disconnected' | 'connectionPaused'; export interface SyncStatus { status: | 'failedSyncing' | 'nothingToSync' | 'syncing' | 'successSyncing' | 'syncingCompleted' | 'completed' | 'unknown'; resource?: string; extra?: string; } export class VitalHealth { static status = new NativeEventEmitter(VitalHealthReactNative); private static eventEmitter = new NativeEventEmitter(VitalHealthReactNative); static setEventEmitter(emitter: NativeEventEmitter) { this.eventEmitter = emitter; } static get canEnableBackgroundSyncNoninteractively(): boolean { return Platform.OS !== 'android'; } static async getConnectionStatus( provider: HealthProvider = defaultHealthProvider() ): Promise { provider = validateHealthProvider(provider); return await VitalHealthReactNative.getConnectionStatus(provider); } /** * @deprecated Use `getConnectionStatus()` instead. */ static get connectionStatus(): Promise { return this.getConnectionStatus(); } static observeConnectionStatusChange( listener: (status: ConnectionStatus) => void, provider: HealthProvider = defaultHealthProvider() ): Subscription { provider = validateHealthProvider(provider); var isCancelled = false; const wrappedListener = (status: ConnectionStatus) => { if (isCancelled) { return; } listener(status); }; const subscription = this.eventEmitter.addListener( eventNames.connectionStatus[provider], wrappedListener ); this.getConnectionStatus(provider).then(wrappedListener); return { remove() { isCancelled = true; subscription.remove(); }, }; } static observeSyncStatusChange( listener: (status: SyncStatus) => void, provider: HealthProvider = defaultHealthProvider() ): Subscription { provider = validateHealthProvider(provider); var isCancelled = false; const wrappedListener = (status: SyncStatus) => { if (isCancelled) { return; } listener(status); }; const subscription = this.eventEmitter.addListener( eventNames.syncStatus[provider], wrappedListener ); return { remove() { isCancelled = true; subscription.remove(); }, }; } /** * Whether health data sync is paused at the moment. * * To pause or unpause synchronization client-side, use `setPauseSynchronization`. * * Note that this has no effect on the Junction API side. This is intended as a temporary pause switch. * Consider using [Explicit Connect mode](https://docs.junction.com/wearables/sdks/health/connection-policies#explicit-connect-mode) * if you need the Junction API to track and persist disconnections. */ static async isProviderSynchronizationPaused( provider: HealthProvider = defaultHealthProvider() ): Promise { provider = validateHealthProvider(provider); return await VitalHealthReactNative.getPauseSynchronization(provider); } /** * @deprecated Use `isProviderSynchronizationPaused()` instead. */ static get pauseSynchronization(): Promise { return this.isProviderSynchronizationPaused(); } /** * IMPORTANT: This is Android-only API. * * On iOS, this property always returns `true`. iOS HealthKit Background Delivery is * an app-level entitlement, and does not require explicit user consent. * * ## Overview * * Whether Background Sync on Android is enabled at the moment. */ static async isBackgroundSyncEnabledForProvider( provider: HealthProvider = defaultHealthProvider() ): Promise { provider = validateHealthProvider(provider); if (Platform.OS !== 'android') { return true; } return await VitalHealthReactNative.isBackgroundSyncEnabled(provider); } /** * @deprecated Use `isBackgroundSyncEnabledForProvider()` instead. */ static get isBackgroundSyncEnabled(): Promise { return this.isBackgroundSyncEnabledForProvider(); } /** * IMPORTANT: This is Android-only API. * * Returns the target frequency of Health Connect Background Sync. * * On iOS, this property always returns `3_600_000`. */ static async getBackgroundSyncMinimumInterval( provider: HealthProvider = defaultHealthProvider() ): Promise { provider = validateHealthProvider(provider); if (Platform.OS !== 'android') { return 3_600_000; } return await VitalHealthReactNative.backgroundSyncMinimumInterval(provider); } /** * @deprecated Use `getBackgroundSyncMinimumInterval()` instead. */ static get backgroundSyncMinimumInterval(): Promise { return this.getBackgroundSyncMinimumInterval(); } /** * IMPORTANT: This is Android-only API. * * Returns the minimum time that must have elapsed before a any automatic Health Connect * data sync attempt is permitted. * * Automatic sync attempts include recurring Background Sync, as well as Sync on App Launch/Resumption. * * On iOS, this property always returns `0`. */ static async getAutoSyncThrottle( provider: HealthProvider = defaultHealthProvider() ): Promise { provider = validateHealthProvider(provider); if (Platform.OS !== 'android') { return 0; } return await VitalHealthReactNative.autoSyncThrottle(provider); } /** * @deprecated Use `getAutoSyncThrottle()` instead. */ static get autoSyncThrottle(): Promise { return this.getAutoSyncThrottle(); } static async isAvailable( provider: HealthProvider = defaultHealthProvider() ): Promise { if (Platform.OS === 'android') { if (!isAndroidHealthProvider(provider)) { return false; } return await VitalHealthReactNative.isAvailable(provider); } else { return provider === 'apple_health_kit' && Platform.OS === 'ios'; } } static async configure( healthConfig: HealthConfig, provider: HealthProvider = defaultHealthProvider() ): Promise { provider = validateHealthProvider(provider); if (Platform.OS === 'android') { return await VitalHealthReactNative.configure( provider, healthConfig.androidConfig.syncOnAppStart, healthConfig.numberOfDaysToBackFill, healthConfig.logsEnabled, healthConfig.connectionPolicy ); } else { return await VitalHealthReactNative.configure( provider, healthConfig.iOSConfig.backgroundDeliveryEnabled, healthConfig.numberOfDaysToBackFill, healthConfig.logsEnabled, healthConfig.connectionPolicy ); } } /** * Setup a HealthKit (iOS) or Health Connect (Android) connection with this device. * * @precondition You must configure the Health SDK to use the `explicit` Connection Policy. */ static async connect( provider: HealthProvider = defaultHealthProvider() ): Promise { provider = validateHealthProvider(provider); return await VitalHealthReactNative.connect(provider); } /** * Disconnect the active HealthKit (iOS) or Health Connect (Android) connection with this device. * * @precondition You must configure the Health SDK to use the `explicit` Connection Policy. */ static async disconnect( provider: HealthProvider = defaultHealthProvider() ): Promise { provider = validateHealthProvider(provider); return await VitalHealthReactNative.disconnect(provider); } /** * IMPORTANT: This is Android-only API. * * On iOS, this method is a no-op returning `true`. iOS HealthKit Background Delivery is an app-level * entitlement, and does not require explicit user consent. * * If you intend to pause or unpause synchronization, use `pauseSynchronization` * and `setPauseSynchronization(_:)` instead. * * ## Overview * * Enable background sync on Android. This includes requesting permissions from the end user whenever necessary. * * Vital SDK achieves automatic data sync through Android [AlarmManager] exact alarms. * * Refer to the [Vital Health Connect guide for full context and setup instructions](https://docs.tryvital.io/wearables/guides/android_health_connect). * * ## Gist on Exact Alarms * * "Exact Alarm" here refers to the Android Exact Alarm mechanism. The Vital SDK would propose * to the Android OS to fire the next data sync with a T+60min wall clock target. The Android OS * may fulfill the request exactly as proposed, e.g., when the user happens to be actively using * the device. However, it may also choose to defer it arbitrarily, under the influence of * power-saving policies like [Doze mode](https://developer.android.com/training/monitoring-device-state/doze-standby#understand_doze). * * On Android 12 (API Level 31) or above, this contract would automatically initiate the OS-required * user consent flow for Exact Alarm usage. If the permission has been granted prior, this activity * contract shall return synchronously. * * On Android 13 (API Level 33) or above, you have the option to use (with platform policy caveats) * the [android.Manifest.permission.USE_EXACT_ALARM] permission instead, which does not require an * explicit consent flow. This contract would return synchronously in this scenario. * * Regardless of API Level, your App Manifest must declare [android.Manifest.permission.RECEIVE_BOOT_COMPLETED]. * Otherwise, background sync stops once the phone encounters a cold reboot or a quick restart. * * @return `true` if the background sync has been enabled successfully. `false` otherwise. */ static async enableBackgroundSync( provider: HealthProvider = defaultHealthProvider() ): Promise { provider = validateHealthProvider(provider); if (Platform.OS !== 'android') { // iOS background delivery does not require user explicit consent. // It requires only the app-level HealthKit Bgnd. Delivery entitlement. return true; } return await VitalHealthReactNative.enableBackgroundSync(provider); } /** * IMPORTANT: This is Android-only API. * * On iOS, this method is a no-op. iOS HealthKit Background Delivery is an app-level * entitlement, and does not require explicit user consent. * * If you intend to pause or unpause synchronization, use `pauseSynchronization` * and `setPauseSynchronization(_:)` instead. * * ## Overview * * Disable background sync on Android. */ static async disableBackgroundSync( provider: HealthProvider = defaultHealthProvider() ): Promise { provider = validateHealthProvider(provider); if (Platform.OS !== 'android') { // iOS background delivery does not require user explicit consent. // It requires only the app-level HealthKit Bgnd. Delivery entitlement. return; } return await VitalHealthReactNative.disableBackgroundSync(provider); } /** * IMPORTANT: This is Android-only API. * * On iOS, this method is a no-op. iOS does not require apps to show a user-visible * notification when performing extended work in background. * * ## Overview * Set the text content related to the Sync Notification. The OS has full discretion to present * this notification to the user, when any data sync work in background is taking longer than expected. * * Refer to the [Vital Health Connect guide for full context and setup instructions](https://docs.tryvital.io/wearables/guides/android_health_connect). */ static async setSyncNotificationContent( content: SyncNotificationContent, provider: HealthProvider = defaultHealthProvider() ): Promise { provider = validateHealthProvider(provider); if (Platform.OS !== 'android') { // iOS background delivery does not require user explicit consent. // It requires only the app-level HealthKit Bgnd. Delivery entitlement. return; } return await VitalHealthReactNative.setSyncNotificationContent( provider, JSON.stringify(content) ); } /** * IMPORTANT: This is Android-only API. * * On iOS, this method is a no-op. * * Set the minimum time that must have elapsed before a any automatic Health Connect * data sync attempt is permitted. * * A throttling threshold below 5 seconds is ignored. */ static async setAutoSyncThrottle( thresholdInMilliseconds: number, provider: HealthProvider = defaultHealthProvider() ): Promise { provider = validateHealthProvider(provider); if (Platform.OS !== 'android') { return; } return await VitalHealthReactNative.setAutoSyncThrottle( provider, thresholdInMilliseconds ); } /** * IMPORTANT: This is Android-only API. * * On iOS, this method is a no-op. * * Set the target frequency of Health Connect Background Sync. * * A minimum interval below 3600 seconds is ignored. */ static async setBackgroundSyncMinimumInterval( intervalInMilliseconds: number, provider: HealthProvider = defaultHealthProvider() ): Promise { provider = validateHealthProvider(provider); if (Platform.OS !== 'android') { return; } return await VitalHealthReactNative.setBackgroundSyncMinimumInterval( provider, intervalInMilliseconds ); } /** * Pause or unpause health data sync. * * Note that this has no effect on the Junction API side. This is intended as a temporary client-side pause switch. * Consider using [Explicit Connect mode](https://docs.junction.com/wearables/sdks/health/connection-policies#explicit-connect-mode) * if you need the Junction API to track and persist disconnections. */ static async setPauseSynchronization( paused: boolean, provider: HealthProvider = defaultHealthProvider() ) { provider = validateHealthProvider(provider); return await VitalHealthReactNative.setPauseSynchronization( provider, paused ); } static async askForResources( resources: VitalResource[], provider: HealthProvider = defaultHealthProvider() ): Promise { return await this.ask(resources, [], undefined, provider); } static async ask( readResources: VitalResource[], writeResources: VitalWriteResource[], config: AskConfig | undefined = undefined, provider: HealthProvider = defaultHealthProvider() ): Promise { provider = validateHealthProvider(provider); if (config && Platform.OS !== config.type) { throw new Error( `ask config is for ${config.type} but runtime is ${Platform.OS}.` ); } const result = await VitalHealthReactNative.ask( provider, readResources, writeResources, config ); if (Platform.OS !== 'android') { return 'success'; } else { return result as PermissionOutcome; } } static async writeHealthData( resource: VitalWriteResource, value: number, startDate: Date, endDate: Date, provider: HealthProvider = defaultHealthProvider() ): Promise { provider = validateHealthProvider(provider); return await VitalHealthReactNative.writeHealthData( provider, resource, value, startDate.getTime(), endDate.getTime() ); } static async hasAskedForPermission( resource: VitalResource, provider: HealthProvider = defaultHealthProvider() ): Promise { provider = validateHealthProvider(provider); return await VitalHealthReactNative.hasAskedForPermission( provider, resource ); } static async syncData( resources: VitalResource[] = [], provider: HealthProvider = defaultHealthProvider() ): Promise { provider = validateHealthProvider(provider); return await VitalHealthReactNative.syncData(provider, resources); } static async openPlatformHealthApp( provider: HealthProvider = defaultHealthProvider() ): Promise { provider = validateHealthProvider(provider); return await VitalHealthReactNative.openPlatformHealthApp(provider); } /** * On iOS, this opens the Junction SDK in-built Sync Progress UI as a modal over your top view controller. * On Android, this is a no-op. */ static async openSyncProgressView( provider: HealthProvider = defaultHealthProvider() ): Promise { provider = validateHealthProvider(provider); if (Platform.OS !== 'ios') { // Not supported on Android return; } return await VitalHealthReactNative.openSyncProgressView(provider); } /** * On iOS, this reports whether Junction SDK Persistent Logging is currently enabled. To enable or disable persistent * logging, use [setPersistentLoggingEnabled]. * * On Android, this always reports `false` since the feature is unavailable. */ static async isPersistentLoggingEnabled(provider: HealthProvider = defaultHealthProvider()): Promise { provider = validateHealthProvider(provider); if (Platform.OS !== 'ios') { // Not supported on Android return false; } return await VitalHealthReactNative.isPersistentLoggingEnabled(provider); } /** * On iOS, this enables or disables the Junction SDK Persistent Logging feature. The preference is persistent across application launches * until you change it again. * * On Android, this is a no-op. */ static async setPersistentLoggingEnabled(enabled: boolean, provider: HealthProvider = defaultHealthProvider()): Promise { provider = validateHealthProvider(provider); if (Platform.OS !== 'ios') { // Not supported on Android return; } return await VitalHealthReactNative.setPersistentLoggingEnabled(provider, enabled); } /** * On iOS, this packs the current Persistent Logging stash into an archive file, and opens the iOS Share Sheet. * * On Android, this is a no-op. */ static async sharePersistentLogArchive(provider: HealthProvider = defaultHealthProvider()): Promise { provider = validateHealthProvider(provider); if (Platform.OS !== 'ios') { // Not supported on Android return; } return await VitalHealthReactNative.sharePersistentLogArchive(provider); } } // noinspection JSUnusedGlobalSymbols export enum VitalResource { Profile = 'profile', Body = 'body', Workout = 'workout', Activity = 'activity', Sleep = 'sleep', Glucose = 'glucose', BloodPressure = 'bloodPressure', HeartRate = 'heartRate', HeartRateVariability = 'heartRateVariability', BloodOxygen = 'bloodOxygen', Steps = 'steps', ActiveEnergyBurned = 'activeEnergyBurned', BasalEnergyBurned = 'basalEnergyBurned', /** * @deprecated renamed to `Distance`. */ DistanceWalkingRunning = 'distance', Distance = 'distance', FloorsClimbed = 'floorsClimbed', VO2Max = 'vo2Max', Water = 'water', Caffeine = 'caffeine', MindfulSession = 'mindfulSession', Temperature = 'temperature', RespiratoryRate = 'respiratoryRate', MenstrualCycle = 'menstrualCycle', Meal = 'meal', /** iOS Only */ AFibBurden = 'afibBurden', /** iOS Only */ HeartRateAlert = 'heartRateAlert', /** iOS Only */ Electrocardiogram = 'electrocardiogram', /** iOS Only */ WheelchairPush = 'wheelchairPush', /** iOS Only */ LeanBodyMass = 'leanBodyMass', /** iOS Only */ WaistCircumference = 'waistCircumference', /** iOS Only */ BodyMassIndex = 'bodyMassIndex', /** iOS Only */ StandHour = 'standHour', /** iOS Only */ StandDuration = 'standDuration', /** iOS Only */ SleepApneaAlert = 'sleepApneaAlert', /** iOS Only */ SleepBreathingDisturbance = 'sleepBreathingDisturbance', /** iOS Only */ ForcedExpiratoryVolume1 = 'forcedExpiratoryVolume1', /** iOS Only */ ForcedVitalCapacity = 'forcedVitalCapacity', /** iOS Only */ PeakExpiratoryFlowRate = 'peakExpiratoryFlowRate', /** iOS Only */ InhalerUsage = 'inhalerUsage', /** iOS Only */ Fall = 'fall', /** iOS Only */ UvExposure = 'uvExposure', /** iOS Only */ DaylightExposure = 'daylightExposure', /** iOS Only */ Handwashing = 'handwashing', /** iOS Only */ BasalBodyTemperature = 'basalBodyTemperature', /** iOS Only */ HeartRateRecoveryOneMinute = 'heartRateRecoveryOneMinute', } export enum VitalWriteResource { Water = 'water', /** iOS Only */ Caffeine = 'caffeine', /** iOS Only */ MindfulSession = 'mindfulSession', } export type PermissionOutcome = | 'success' | 'cancelled' | 'unknownError' | 'notPrompted' | 'healthDataUnavailable';