
Offers unified access to health data, supporting over 66 health metrics, real-time monitoring, workout management, and FHIR medical records compliance with cross-platform API for seamless integration.
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:
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:
checkPermissions reflects actual grants.requestPermissions reports write denials accurately and sets readPermissionsIndeterminate=true in platformSpecificInfo; checkPermissions returns NotDetermined for 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).
HKObserverQuery push; samplingInterval is ignored.getChanges(token) every samplingInterval (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.
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:
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:
checkPermissions reflects actual grants.requestPermissions reports write denials accurately and sets readPermissionsIndeterminate=true in platformSpecificInfo; checkPermissions returns NotDetermined for 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).
HKObserverQuery push; samplingInterval is ignored.getChanges(token) every samplingInterval (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.