@cariva-dev/exercise-sdk
v0.0.8-alpha-1
Published
Cariva exercise SDK
Readme
cariva-exercise-sdk
React Native SDK for reading exercise and activity data from Android Health Connect.
Published package: @cariva-dev/exercise-sdk
Platform Support
| Platform | Status | |----------|--------| | Android | Supported (Health Connect) | | iOS | Out of scope for this release |
Requirements
- React Native 0.68+ (New Architecture / Turbo Module compatible)
- Android app
minSdkVersion 24or higher - A device or emulator with Health Connect support
- Health Connect installed on Android 13 and lower, or available through Play Services / system components on Android 14+
Integration Guide
For consumer setup in an existing React Native Android app, start with docs/integration/react-native-android-setup.md.
The guide covers:
- package installation with
@cariva-dev/exercise-sdk - Android manifest merge expectations
- integration verification checklist
- minimal implementation example
- common troubleshooting paths
Architecture
The SDK now follows a Clean/Hexagonal structure:
android/.../domain: core models and algorithm servicesandroid/.../application: use-cases and portsandroid/.../infrastructure: Health Connect/Android adaptersandroid/.../HealthConnectModule.kt: thin React Native bridge/controllersrc/domain,src/application,src/infrastructure: mirrored TypeScript boundary layers
See docs/architecture/clean-hexagonal-migration.md for migration details.
Installation
1. Install the package
# npm
npm install @cariva-dev/exercise-sdk
# yarn
yarn add @cariva-dev/exercise-sdk2. Android setup
The SDK ships its own Health Connect permissions, package visibility queries, and permission rationale activities in the library manifest.
In a standard React Native Android app, these entries are merged automatically into your app during the Android build.
Follow the integration guide for the verification checklist and fallback steps if a custom Android setup prevents normal manifest merge behavior.
3. iOS
No additional setup is required because iOS is not a supported runtime path in this release. The repository currently ships only a minimal iOS stub, and the JavaScript adapter should still be treated as Android-only unless explicit iOS support is added.
Quick Start
import {
connect,
disconnect,
getExerciseData,
setAllowDatasource,
getAllowDatasource,
resetPermission,
} from '@cariva-dev/exercise-sdk';
// 1. Connect and request permissions
const status = await connect({
onMissingApp: 'status',
requestPermission: true,
permissionScope: ['steps', 'calories'],
});
if (!status.ready) {
console.log('Next action required:', status.nextAction);
// e.g. 'INSTALL_HEALTH_CONNECT' | 'REQUEST_PERMISSION' | ...
return;
}
// 2. Fetch exercise data
const data = await getExerciseData({
sinceMillis: Date.now() - 7 * 24 * 60 * 60 * 1000, // last 7 days
untilMillis: Date.now(),
type: 'all',
bucketPeriod: 'daily',
});
console.log('Records:', data.records);
console.log('Active kcal:', data.summary.activeKcal);
console.log('Time zone:', data.meta.timeZone);
// 3. Disconnect and clear granted Health Connect permissions
await disconnect();
// 4. Revoke then re-request permissions
await resetPermission({ permissionScope: ['steps', 'calories'] });API Reference
connect(options?)
Checks Health Connect availability and optionally requests permissions.
type ConnectOptions = {
onMissingApp?: 'status' | 'open'; // 'open' navigates to Play Store
requestPermission?: boolean; // default: true
permissionScope?: Array<'steps' | 'activetimes' | 'calories' | 'distances'>;
};
type ConnectResult = {
ready: boolean;
healthConnectInstalled: boolean;
healthConnectStatus: 'AVAILABLE' | 'PROVIDER_UPDATE_REQUIRED' | 'UNAVAILABLE' | 'UNKNOWN';
hasPermissions: boolean;
nextAction:
| 'INSTALL_HEALTH_CONNECT'
| 'UPDATE_HEALTH_CONNECT'
| 'REQUEST_PERMISSION'
| 'NONE';
requestedPermissions?: string[];
};| Option | Type | Default | Description |
|--------|------|---------|-------------|
| onMissingApp | 'status' \| 'open' | 'status' | 'open' opens Play Store for the missing app |
| requestPermission | boolean | true | Trigger the Health Connect permission dialog |
| permissionScope | PermissionScope[] | all scopes | Limit which permissions are requested |
activetimes may request both exercise-session and active-calories read permissions so the native fallback reader can still derive active duration when session records are unavailable.
disconnect()
Revokes granted Health Connect permissions and clears the SDK's in-memory datasource blacklist.
const result = await disconnect();
// => { success: true }resetPermission(options?)
Revokes existing Health Connect permissions and immediately runs the normal permission request flow again.
type ResetPermissionOptions = {
onMissingApp?: 'status' | 'open';
permissionScope?: Array<'steps' | 'activetimes' | 'calories' | 'distances'>;
};
const status = await resetPermission({
permissionScope: ['steps', 'calories'],
});resetPermission returns the same ConnectResult shape as connect.
getExerciseData(options)
Reads aggregated exercise data from Health Connect.
type GetExerciseDataOptions = {
sinceMillis: number;
untilMillis: number;
type?: 'steps' | 'activetimes' | 'calories' | 'distances' | 'all'; // default: 'all'
bucketPeriod?: 'hourly' | 'daily'; // default: 'hourly'
missingBasalPolicy?: 'strict' | 'latest_or_zero'; // default: 'strict'
debug?: boolean; // default: false
includeEmptyBuckets?: boolean; // default: true
};
type UnifiedIntervalRecord = {
steps: number;
activeCalories: number;
activeCaloriesUnit: 'kcal';
activeCaloriesSource: 'device' | 'app' | 'manual_input' | 'unknown' | 'fallback';
basalCalories: number;
basalCaloriesUnit: 'kcal';
distance: number;
distanceUnit: 'km';
activeTime: number;
activeTimeUnit: 'milliseconds';
startDate: string;
endDate: string;
};
type ExerciseData = {
summary: {
steps: number;
activeKcal: number;
distanceMeters: number;
activeTimeMillis: number;
};
meta: {
timeZone: string;
bucketPeriod: 'hourly' | 'daily';
units: {
calories: 'kcal';
distance: 'km';
activeTime: 'milliseconds';
};
};
notices?: Array<{
code: string;
severity: 'info' | 'warning';
}>; // debug only
debug?: {
integrity: {
projectionPreserved: boolean;
roundingScale: number;
};
warnings: Array<{
code: string;
severity: 'info' | 'warning' | 'critical';
message?: string;
context?: Record<string, string | number | boolean | null>;
}>;
diagnostics?: {
fallbackUsedSegments: number;
fallbackSuppressedSegments: number;
zeroBasalFallbackUsedSegments: number;
idleGateSuppressedSegments: number;
basalSeedApplied: boolean;
seededFromBeforeSince: boolean;
basalSeedAgeHours?: number;
directActiveCaloriesKcal: number;
totalCaloriesKcal: number;
basalCaloriesKcal: number;
mergedActiveCaloriesKcal: number;
fallbackContributionKcal: number;
};
};
records: UnifiedIntervalRecord[];
totalSteps?: number; // deprecated, debug only
totalKcal?: number; // deprecated, equivalent to summary.activeKcal, debug only
totalBasalKcal?: number; // deprecated, debug only
totalDistanceMeters?: number; // deprecated, debug only
totalActiveTimeMillis?: number; // deprecated, debug only
timeZone?: string; // deprecated, use meta.timeZone, debug only
warnings?: string[]; // deprecated legacy warning codes, debug only
integrity?: {
projectionPreserved: boolean;
roundingScale: number;
}; // deprecated, populated only when debug is enabled
diagnostics?: {
fallbackUsedSegments: number;
fallbackSuppressedSegments: number;
zeroBasalFallbackUsedSegments: number;
idleGateSuppressedSegments: number;
basalSeedApplied: boolean;
seededFromBeforeSince: boolean;
basalSeedAgeHours?: number;
directActiveCaloriesKcal: number;
totalCaloriesKcal: number;
basalCaloriesKcal: number;
mergedActiveCaloriesKcal: number;
fallbackContributionKcal: number;
}; // deprecated compatibility diagnostics, debug only
};Periods longer than 90 days reject with error code
E_INVALID_PERIOD.
Health Connect also applies runtime read quotas, with stricter limits for background work. The SDK keeps a short-lived exact-query cache for successful
getExerciseData()responses, so repeated identical refreshes within 15 seconds can reuse the last result instead of re-reading Health Connect.
To further reduce quota pressure, the Android reader now paces internal Health Connect sub-reads, including paginated reads and retry attempts, and performs one fixed 5-second cooldown retry when Health Connect reports a rate-limit error. If the retry still fails, the SDK surfaces the existing
E_GET_EXERCISE_DATAerror path.
To maximize cache hits and avoid unnecessary quota use, keep
sinceMillisanduntilMillisstable within a refresh cycle instead of shifting the window on every poll, and let your scheduler or caller back off repeated background sync attempts. A full changelog-based sync strategy is out of scope for this SDK patch.
Preferred response contract:
summary.activeKcalis the active-calorie total and matches the legacytotalKcalrecordsis the bucket-by-bucket array returned for the requestedbucketPeriodincludeEmptyBuckets: falsefilters only the projectedrecords; it does not affect cached/internal responses,summary, or debug-only deprecated totalsdebug: trueaddsdebug.integrity, structureddebug.warnings, anddebug.diagnosticswhen real diagnostics existdebug: truealso exposes legacy top-level totals,warnings,integrity, anddiagnosticsfor compatibility with older troubleshooting flows- absence of
debug.diagnosticsremains meaningful; the SDK does not synthesize zero-valued diagnostics objects
Why this split exists:
- Normal app flows usually need totals plus per-bucket activity metrics, not internal projection or audit details.
summaryandmetagive new consumers a cleaner default surface for totals and shared units without removing the existing per-bucket schema.debugkeeps troubleshooting and audit data opt-in for QA, investigations, and internal support work.recordsstays as the single per-bucket surface in this release so existing consumers do not need to switch arrays.
Recommended field usage:
- Use
summaryfor totals across the full query window. - Use
recordsfor charts, timelines, and bucket-by-bucket UI. - Use
metafor shared response metadata such astimeZone,bucketPeriod, and units. - Use
noticesonly in debug flows when you want lightweight warning summaries alongside the full debug payload. - Use
debugonly when investigating data quality, fallback behavior, or projection issues. - Use deprecated top-level totals only for backward compatibility in debug flows.
setAllowDatasource(config)
Configures which data sources to exclude from results.
type DatasourceType = 'manual_input' | 'device' | 'app' | 'unknown';
type DatasourceConfig = {
blacklist: DatasourceType[];
};
// Example: exclude manually-entered data
await setAllowDatasource({ blacklist: ['manual_input'] });getAllowDatasource()
Returns the current datasource blacklist configuration.
const config = await getAllowDatasource();
// => { blacklist: ['manual_input'] }Bucket Record Schema
Use data.records for per-bucket results:
type UnifiedIntervalRecord = {
steps: number;
activeCalories: number;
activeCaloriesUnit: 'kcal';
activeCaloriesSource: 'device' | 'app' | 'manual_input' | 'unknown' | 'fallback';
basalCalories: number;
basalCaloriesUnit: 'kcal';
distance: number;
distanceUnit: 'km';
activeTime: number;
activeTimeUnit: 'milliseconds';
startDate: string; // ISO offset string, e.g. "2026-02-16T08:00:00+07:00"
endDate: string;
};records are grouped by the requested bucketPeriod. The first and last record may be partial buckets when sinceMillis or untilMillis land mid-hour or mid-day.
data.meta.units still exposes the shared units once per response:
{
calories: 'kcal',
distance: 'km',
activeTime: 'milliseconds'
}The current record shape still includes per-record unit fields and basalCalories for compatibility with existing consumers.
When includeEmptyBuckets is false, the SDK filters records only at the response projection stage. Internal aggregation, cache entries, summary, and debug-only deprecated totals remain unchanged.
basalCalories reflects the bucketed basal energy for that same interval, and totalBasalKcal mirrors the summed basal total across the whole response window when debug: true exposes legacy totals.
Example record:
{
"steps": 1300,
"activeCalories": 60.2,
"activeCaloriesUnit": "kcal",
"activeCaloriesSource": "fallback",
"basalCalories": 42.5,
"basalCaloriesUnit": "kcal",
"distance": 1.0,
"distanceUnit": "km",
"activeTime": 1200000,
"activeTimeUnit": "milliseconds",
"startDate": "2026-02-16T08:00:00+07:00",
"endDate": "2026-02-16T09:00:00+07:00"
}Overlap Handling
The SDK automatically de-duplicates overlapping Health Connect records:
| Metric | Strategy |
|--------|----------|
| steps | Source priority: device > app > unknown > manual_input |
| activetimes, calories, distances | Proportional-by-time allocation |
Calories Fallback (Total - Basal)
When direct active calories are unavailable or lower quality in some windows, the SDK can derive fallback active calories:
active = max(totalCalories - basalCalories, 0)
Policy and guarantees:
- Pre-since basal seed lookup uses the latest
BasalMetabolicRateRecordbeforesinceMillis, with no age cap. - If basal coverage is missing for a window, fallback is strictly suppressed for that window.
- If the window has no movement/direct evidence, fallback is suppressed (idle gate).
- Direct active-calorie measurement is preferred when present; fallback fills only uncovered windows.
- Merge remains deterministic and avoids double counting across atomic windows.
- Warnings and diagnostics are emitted for auditability.
Typical warning keys:
calories_fallback_total_minus_basal_usedcalories_fallback_only_usedcalories_fallback_partial_usedcalories_fallback_zero_basal_usedbmr_seed_from_latest_before_since_usedbmr_missing_for_fallback_windowfallback_suppressed_due_to_missing_basalfallback_suppressed_due_to_idle_windowfallback_suppressed_due_to_direct_activemovement_evidence_partially_unavailable_for_fallback_gate
Notices, Integrity, and Debug
When debug: true is enabled, data.notices provides a lightweight warning surface:
type ExerciseNotice = {
code: string;
severity: 'info' | 'warning';
};data.warnings, data.integrity, and data.diagnostics are legacy compatibility fields that appear only when debug: true is passed.
data.debug is opt-in and only appears when debug: true is passed:
{
integrity: {
projectionPreserved: boolean;
roundingScale: number;
};
warnings: Array<{
code: string;
severity: 'info' | 'warning' | 'critical';
}>;
diagnostics?: {
// Only included when real diagnostics exist
};
}React Native UI pattern (non-blocking):
const hasNotices = !!data.notices?.length;
return (
<View>
{hasNotices && <Text>Data notices available</Text>}
<Text>{`Active kcal: ${data.summary.activeKcal}`}</Text>
<Text>{`Projection preserved: ${data.debug?.integrity.projectionPreserved ?? false}`}</Text>
</View>
);Examples
Normal mode
import {
connect,
getExerciseData,
setAllowDatasource,
} from '@cariva-dev/exercise-sdk';
async function loadHealthData() {
// Exclude manual entries
await setAllowDatasource({ blacklist: ['manual_input'] });
// Connect with minimal permission scope
const status = await connect({
onMissingApp: 'status',
requestPermission: true,
permissionScope: ['steps', 'calories'],
});
if (!status.ready) {
console.warn('Health Connect not ready. Next action:', status.nextAction);
return null;
}
// Query last 24 hours in hourly buckets
const data = await getExerciseData({
sinceMillis: Date.now() - 24 * 60 * 60 * 1000,
untilMillis: Date.now(),
type: 'all',
bucketPeriod: 'hourly',
});
console.log(data.summary.activeKcal);
console.log(data.records);
return data;
}Debug mode
const data = await getExerciseData({
sinceMillis: Date.now() - 24 * 60 * 60 * 1000,
untilMillis: Date.now(),
type: 'all',
bucketPeriod: 'hourly',
debug: true,
});
console.log(data.debug?.integrity);
console.log(data.debug?.warnings);
console.log(data.debug?.diagnostics);Filter empty records
const data = await getExerciseData({
sinceMillis: Date.now() - 24 * 60 * 60 * 1000,
untilMillis: Date.now(),
type: 'all',
bucketPeriod: 'hourly',
includeEmptyBuckets: false,
});
console.log(data.records); // filtered
console.log(data.summary); // totals remain unchangedMigration (2.0.0)
This release introduces a clean-break connect contract:
- Removed
ConnectResult.googleFitInstalled - Removed
nextAction: 'INSTALL_GOOGLE_FIT' connectreadiness flow is now Health Connect-only
If you previously handled Google Fit branches, delete those checks and rely on:
INSTALL_HEALTH_CONNECTUPDATE_HEALTH_CONNECTREQUEST_PERMISSIONNONE
Calories now may include optional diagnostics for fallback audit details (total - basal path).
Unified records now include activeCaloriesSource so consumers can distinguish direct vs fallback contribution at bucket level.
Additive migration notes for getExerciseData
- Prefer
summary+meta+recordsfor new integrations. summary.activeKcalis the explicit active-calorie total and corresponds to the legacytotalKcal.includeEmptyBuckets: falsenow filters the projectedrecordsview only; totals still come from the unfiltered aggregation result.recordscontinue to use the existingUnifiedIntervalRecordschema in this release.notices, legacy top-level totals,warnings,integrity, anddiagnosticsnow appear only whendebug: trueis enabled.
