A Kotlin Multiplatform library exposing Apple HealthKit (iOS) and Android Health Connect through one API. The common surface is the union of the two platforms' data types, mapped 1:1 to the native records with no additional abstraction; operations return Result<T> and real-time data is delivered as Kotlin Flows. Types one platform cannot serve fail with UnsupportedFeature rather than returning empty data.
dependencies {
implementation("io.github.crowded-libs:vitality:0.2.1")
}Requirements:
- iOS 13.0+ with the HealthKit capability. Newer data types degrade gracefully: each is gated on the OS version that introduced it (14/16/17/18/26).
- Android API 26+ with Health Connect installed. Clinical records require Android 16 (API 35). Mindfulness and skin temperature depend on the installed Health Connect version and are checked at runtime.
Initialize the context provider in your Application class and register the activity used for permission requests:
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
HealthConnectorContextProvider.initialize(applicationContext)
}
}
// In your Activity (before onCreate completes), either:
val connector = createHealthConnector(activity)
// or set HealthConnectorContextProvider.activity = this and use createHealthConnector().
// Call HealthConnectorContextProvider.clearActivity() from onDestroy().Declare the Health Connect permissions your app uses in AndroidManifest.xml:
<uses-permission android:name="android.permission.health.READ_HEART_RATE" />
<uses-permission android:name="android.permission.health.WRITE_HEART_RATE" />
<!-- one entry per record type you read or write -->Enable the HealthKit capability and add usage descriptions to Info.plist:
<key>NSHealthShareUsageDescription</key>
<string>This app reads your health data to provide insights</string>
<key>NSHealthUpdateUsageDescription</key>
<string>This app updates your health data from workouts</string>Background delivery is iOS-only (no Health Connect equivalent). Cast to HealthKitConnector:
(connector as? HealthKitConnector)?.enableBackgroundDelivery(
dataTypes = setOf(HealthDataType.HeartRate),
updateFrequency = BackgroundDeliveryFrequency.IMMEDIATE
)import vitality.*
import vitality.models.*
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
val connector = createHealthConnector()
suspend fun example() {
connector.initialize().getOrThrow()
val permissions = setOf(
HealthPermission(HealthDataType.HeartRate, HealthPermission.AccessType.READ),
HealthPermission(HealthDataType.Steps, HealthPermission.AccessType.READ),
)
val grantResult = connector.requestPermissions(permissions).getOrThrow()
println("granted=${grantResult.granted.size} denied=${grantResult.denied.size}")
val now = Clock.System.now()
connector.readHealthData(HealthDataType.HeartRate, now - 1.days, now)
.onSuccess { points ->
points.filterIsInstance<HeartRateData>().forEach { println("${it.timestamp}: ${it.bpm} bpm") }
}
connector.observe<HeartRateData>(HealthDataType.HeartRate).collect { println("live: ${it.bpm} bpm") }
}Permission semantics:
- Android: granular per-record-type permissions;
checkPermissionsreflects actual grants. - iOS: HealthKit reports write (share) authorization but never discloses read authorization.
requestPermissionsreports write denials accurately and setsreadPermissionsIndeterminate=trueinplatformSpecificInfo;checkPermissionsreturnsNotDeterminedfor read-only sets.
Column values are the native type backing each HealthDataType. "No" means the platform has no equivalent; reads fail with UnsupportedFeature.
| Vitality type | HealthKit (iOS) | Health Connect (Android) |
|---|---|---|
Steps |
stepCount |
StepsRecord |
Distance |
distanceWalkingRunning |
DistanceRecord |
Calories |
activeEnergyBurned |
TotalCaloriesBurnedRecord |
ActiveCalories |
activeEnergyBurned |
ActiveCaloriesBurnedRecord |
BasalCalories |
basalEnergyBurned (interval kcal) |
BasalMetabolicRateRecord (kcal/day rate) |
Floors |
flightsClimbed |
FloorsClimbedRecord |
Elevation |
No | ElevationGainedRecord |
Workout |
HKWorkoutType |
ExerciseSessionRecord |
VO2Max |
vo2Max |
Vo2MaxRecord |
WheelchairPushes |
No | WheelchairPushesRecord |
Speed |
runningSpeed (iOS 16+) |
SpeedRecord |
CyclingCadence |
cyclingCadence (iOS 17+) |
CyclingPedalingCadenceRecord |
Power |
cyclingPower (iOS 17+) |
PowerRecord (any activity) |
CyclingFunctionalThresholdPower |
cyclingFunctionalThresholdPower (iOS 17+) |
No |
RunningStrideLength |
runningStrideLength (iOS 16+) |
No |
RunningVerticalOscillation |
runningVerticalOscillation (iOS 16+) |
No |
RunningGroundContactTime |
runningGroundContactTime (iOS 16+) |
No |
WorkoutEffortScore |
workoutEffortScore (iOS 18+) |
No |
EstimatedWorkoutEffortScore |
estimatedWorkoutEffortScore (iOS 18+, read-only) |
No |
Energy: Calories, ActiveCalories, and BasalCalories are distinct native types — not merged. On CalorieData, use totalCalories for Android totals, activeCalories for active energy, and basalCalories for basal. Android basal is a kcal/day BMR rate (metadata["basalUnit"] = "kilocaloriesPerDay"); iOS basal is interval energy in kcal. Do not add a BMR rate to active energy to invent a total.
| Vitality type | HealthKit (iOS) | Health Connect (Android) |
|---|---|---|
HeartRate |
heartRate |
HeartRateRecord |
HeartRateVariability |
heartRateVariabilitySDNN (fills sdnn) |
HeartRateVariabilityRmssdRecord (fills rmssd) |
RestingHeartRate |
restingHeartRate |
RestingHeartRateRecord |
BloodPressure |
blood pressure correlation | BloodPressureRecord |
RespiratoryRate |
respiratoryRate |
RespiratoryRateRecord |
OxygenSaturation |
oxygenSaturation |
OxygenSaturationRecord |
BodyTemperature |
bodyTemperature |
BodyTemperatureRecord |
WristTemperature |
appleSleepingWristTemperature (iOS 16+, read-only) |
SkinTemperatureRecord (feature-gated) |
BloodGlucose |
bloodGlucose |
BloodGlucoseRecord |
PeripheralPerfusionIndex |
peripheralPerfusionIndex |
No |
IrregularHeartRhythmEvent |
irregularHeartRhythmEvent |
No |
Electrocardiogram |
HKElectrocardiogram (iOS 14+) |
No |
| Vitality type | HealthKit (iOS) | Health Connect (Android) |
|---|---|---|
Weight |
bodyMass |
WeightRecord |
Height |
height |
HeightRecord |
BMI |
bodyMassIndex |
No |
BodyFat |
bodyFatPercentage |
BodyFatRecord |
LeanBodyMass |
leanBodyMass |
LeanBodyMassRecord |
| Vitality type | HealthKit (iOS) | Health Connect (Android) |
|---|---|---|
Water |
dietaryWater |
HydrationRecord |
Protein |
dietaryProtein |
NutritionRecord.protein |
Carbohydrates |
dietaryCarbohydrates |
NutritionRecord.totalCarbohydrate |
Fat |
dietaryFatTotal |
NutritionRecord.totalFat |
Fiber |
dietaryFiber |
NutritionRecord.dietaryFiber |
Sugar |
dietarySugar |
NutritionRecord.sugar |
Caffeine |
dietaryCaffeine |
NutritionRecord.caffeine |
| Vitality type | HealthKit (iOS) | Health Connect (Android) |
|---|---|---|
Sleep |
sleepAnalysis |
SleepSessionRecord (with stages) |
SleepApneaEvent |
sleepApneaEvent (iOS 18+) |
No |
Mindfulness |
mindfulSession |
MindfulnessSessionRecord (feature-gated) |
StateOfMind |
HKStateOfMind (iOS 18+, read + write) |
No |
WalkingAsymmetry, WalkingDoubleSupportPercentage, WalkingSpeed, WalkingStepLength, StairAscentSpeed, StairDescentSpeed, SixMinuteWalkTestDistance, NumberOfTimesFallen, StandHours — HealthKit identifiers, read-only / device-generated. Not in Health Connect.
EnvironmentalAudioExposure, HeadphoneAudioExposure, UVExposure, TimeInDaylight (iOS 17+) — HealthKit identifiers. Not in Health Connect.
| Vitality type | HealthKit (iOS) | Health Connect (Android) |
|---|---|---|
MenstruationFlow |
menstrualFlow |
MenstruationFlowRecord |
MenstruationPeriod |
No | MenstruationPeriodRecord |
IntermenstrualBleeding |
intermenstrualBleeding |
IntermenstrualBleedingRecord |
CervicalMucus |
cervicalMucusQuality |
CervicalMucusRecord |
OvulationTest |
ovulationTestResult |
OvulationTestRecord |
SexualActivity |
sexualActivity |
SexualActivityRecord |
| Vitality type | HealthKit (iOS 12+) | Health Connect (Android 16+) |
|---|---|---|
ClinicalAllergies |
allergyRecord |
MedicalResource (allergies/intolerances) |
ClinicalConditions |
conditionRecord |
MedicalResource (conditions) |
ClinicalImmunizations |
immunizationRecord |
MedicalResource (vaccines) |
ClinicalLabResults |
labResultRecord |
MedicalResource (laboratory results) |
ClinicalMedications |
medicationRecord |
MedicalResource (medications) |
ClinicalProcedures |
procedureRecord |
MedicalResource (procedures) |
ClinicalVitalSigns |
vitalSignRecord |
MedicalResource (vital signs) |
Methods: readImmunizations, readMedications, readAllergies, readConditions, readLabResults, readProcedures, areClinicalRecordsAvailable. Parsed types: FHIRImmunization, FHIRMedicationStatement, FHIRMedicationRequest, FHIRAllergyIntolerance, FHIRCondition, FHIRObservation, FHIRProcedure. iOS: HKClinicalRecord FHIR payloads. Android: Personal Health Record API (FHIR R4).
User-logged medications (iOS 26+, distinct from FHIR clinical meds): readUserMedications, readMedicationDoseEvents, areUserMedicationsAvailable. Elsewhere: UnsupportedFeature.
observe(dataType, samplingInterval) returns a Flow of typed model instances. Unsupported or OS-gated types fail with UnsupportedFeature (they do not complete empty).
- iOS:
HKObserverQuerypush;samplingIntervalis ignored. - Android: Health Connect Changes API — initial snapshot, then
getChanges(token)everysamplingInterval(default 30s). Each record once; token expiry re-baselines; transient failures retry with backoff; permission failures fail the flow.
connector.observe<HeartRateData>(HealthDataType.HeartRate)
.collect { println("${it.timestamp}: ${it.bpm} bpm") }val session = connector.startWorkoutSession(WorkoutType.RUNNING).getOrThrow()
launch { session.observeHeartRate().collect { println("HR: ${it.bpm}") } }
launch { session.observeDistance().collect { println("distance: ${it.distance} m") } }
session.pause()
session.resume()
session.end() // persists (Android records pauses as ExerciseSegments)
// or connector.discardWorkoutSession(session.id)Read with readWorkouts(start, end); write with writeWorkout(workoutData). WorkoutData.segments mirrors Health Connect ExerciseSegment (Android-only; ignored on iOS write). Heart-rate min/avg/max on WorkoutData are summaries only — they are not expanded into sample series on write; write real samples via writeHealthData. Elevation is also a first-class type on Android (HealthDataType.Elevation).
readStatistics aggregates over a range. Without bucketDuration, use values; with it, use buckets.
val now = Clock.System.now()
val steps = connector.readStatistics(
dataType = HealthDataType.Steps,
startDate = now - 1.days,
endDate = now,
statisticOptions = setOf(StatisticOption.SUM)
).getOrThrow()
println("steps: ${steps.values[StatisticOption.SUM]}")Options: MINIMUM, MAXIMUM, AVERAGE, SUM. Cumulative types support SUM; discrete types support min/max/average. Incompatible combinations fail with InvalidDataException.
Apache License 2.0. See LICENSE.