diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index e52257f3228..d885620a0f1 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -74,6 +74,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/notifications/MessagingManager.kt b/app/src/main/kotlin/io/homeassistant/companion/android/notifications/MessagingManager.kt
index 552f4ebbe3a..bf88481e494 100644
--- a/app/src/main/kotlin/io/homeassistant/companion/android/notifications/MessagingManager.kt
+++ b/app/src/main/kotlin/io/homeassistant/companion/android/notifications/MessagingManager.kt
@@ -81,6 +81,8 @@ import io.homeassistant.companion.android.database.settings.WebsocketSetting
import io.homeassistant.companion.android.sensors.LocationSensorManager
import io.homeassistant.companion.android.sensors.NotificationSensorManager
import io.homeassistant.companion.android.sensors.SensorReceiver
+import io.homeassistant.companion.android.sensors.healthconnect.HealthConnectWriteResult
+import io.homeassistant.companion.android.sensors.healthconnect.command.HealthConnectWriteCommandHandler
import io.homeassistant.companion.android.settings.SettingsActivity
import io.homeassistant.companion.android.settings.assist.AssistConfigManager
import io.homeassistant.companion.android.settings.assist.DefaultAssistantManager
@@ -125,6 +127,7 @@ class MessagingManager @Inject constructor(
private val permissionRequestMediator: PermissionRequestMediator,
private val assistConfigManager: AssistConfigManager,
private val defaultAssistantManager: DefaultAssistantManager,
+ private val healthConnectWriteCommandHandler: HealthConnectWriteCommandHandler,
) {
companion object {
const val APP_PREFIX = "app://"
@@ -190,6 +193,7 @@ class MessagingManager @Inject constructor(
const val COMMAND_FLASHLIGHT = "command_flashlight"
const val COMMAND_WAKE_WORD_DETECTION = "command_wake_word_detection"
+ const val COMMAND_HEALTH_CONNECT_WRITE = "command_health_connect_write"
// DND commands
const val DND_PRIORITY_ONLY = "priority_only"
@@ -247,6 +251,7 @@ class MessagingManager @Inject constructor(
COMMAND_SCREEN_OFF_TIMEOUT,
COMMAND_FLASHLIGHT,
COMMAND_WAKE_WORD_DETECTION,
+ COMMAND_HEALTH_CONNECT_WRITE,
)
val DND_COMMANDS = listOf(DND_ALARMS_ONLY, DND_ALL, DND_NONE, DND_PRIORITY_ONLY)
val RM_COMMANDS = listOf(RM_NORMAL, RM_SILENT, RM_VIBRATE)
@@ -615,6 +620,10 @@ class MessagingManager @Inject constructor(
}
}
+ COMMAND_HEALTH_CONNECT_WRITE -> {
+ handleHealthConnectWriteCommand(jsonData)
+ }
+
else -> Timber.d("No command received")
}
}
@@ -644,6 +653,41 @@ class MessagingManager @Inject constructor(
}
}
+ /**
+ * Forwards a `command_health_connect_write` payload to
+ * [HealthConnectWriteCommandHandler] and surfaces a notification when the write
+ * fails so an HA-side automation author can see why their write was dropped.
+ *
+ * The success path is intentionally silent — repeating an HA-driven write back
+ * to the user as a notification would be noise. Permission and validation
+ * errors do produce notifications because they almost always require user
+ * action (granting a WRITE permission, fixing the FCM payload).
+ *
+ * The fallback notification rewrites `title`/`message` so the user sees the
+ * actual failure reason (missing permission, invalid payload, …) instead of the
+ * literal `command_health_connect_write` string from the inbound FCM data.
+ */
+ private suspend fun handleHealthConnectWriteCommand(data: Map) {
+ val result = healthConnectWriteCommandHandler.handle(data)
+ val (title, body) = when (result) {
+ is HealthConnectWriteResult.Success,
+ is HealthConnectWriteResult.Unavailable,
+ -> return
+ is HealthConnectWriteResult.MissingPermission ->
+ "Health Connect write blocked" to
+ "Missing permission: ${result.permission}. Grant it from Settings → Sensors → Health Connect."
+ is HealthConnectWriteResult.InvalidPayload ->
+ "Health Connect write rejected" to result.reason
+ is HealthConnectWriteResult.Failure ->
+ "Health Connect write failed" to (result.cause.message ?: result.cause.javaClass.simpleName)
+ }
+ val rewritten = data.toMutableMap().apply {
+ put(NotificationData.TITLE, title)
+ put(NotificationData.MESSAGE, body)
+ }
+ sendNotification(rewritten)
+ }
+
private suspend fun handleDeviceCommands(data: Map) {
val message = data[NotificationData.MESSAGE]
val command = data[NotificationData.COMMAND]
diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/sensors/HealthConnectSensorManager.kt b/app/src/main/kotlin/io/homeassistant/companion/android/sensors/HealthConnectSensorManager.kt
index 13b5a324365..b23e75e0719 100644
--- a/app/src/main/kotlin/io/homeassistant/companion/android/sensors/HealthConnectSensorManager.kt
+++ b/app/src/main/kotlin/io/homeassistant/companion/android/sensors/HealthConnectSensorManager.kt
@@ -19,8 +19,10 @@ import androidx.health.connect.client.records.BodyTemperatureMeasurementLocation
import androidx.health.connect.client.records.BodyTemperatureRecord
import androidx.health.connect.client.records.BodyWaterMassRecord
import androidx.health.connect.client.records.BoneMassRecord
+import androidx.health.connect.client.records.CyclingPedalingCadenceRecord
import androidx.health.connect.client.records.DistanceRecord
import androidx.health.connect.client.records.ElevationGainedRecord
+import androidx.health.connect.client.records.ExerciseSessionRecord
import androidx.health.connect.client.records.FloorsClimbedRecord
import androidx.health.connect.client.records.HeartRateRecord
import androidx.health.connect.client.records.HeartRateVariabilityRmssdRecord
@@ -29,10 +31,12 @@ import androidx.health.connect.client.records.HydrationRecord
import androidx.health.connect.client.records.LeanBodyMassRecord
import androidx.health.connect.client.records.MealType
import androidx.health.connect.client.records.OxygenSaturationRecord
+import androidx.health.connect.client.records.PowerRecord
import androidx.health.connect.client.records.Record
import androidx.health.connect.client.records.RespiratoryRateRecord
import androidx.health.connect.client.records.RestingHeartRateRecord
import androidx.health.connect.client.records.SleepSessionRecord
+import androidx.health.connect.client.records.SpeedRecord
import androidx.health.connect.client.records.StepsRecord
import androidx.health.connect.client.records.TotalCaloriesBurnedRecord
import androidx.health.connect.client.records.Vo2MaxRecord
@@ -45,6 +49,8 @@ import io.homeassistant.companion.android.common.R as commonR
import io.homeassistant.companion.android.common.sensors.SensorManager
import io.homeassistant.companion.android.common.util.FailFast
import io.homeassistant.companion.android.common.util.STATE_UNKNOWN
+import io.homeassistant.companion.android.sensors.healthconnect.HealthConnectDataType
+import io.homeassistant.companion.android.sensors.healthconnect.HealthConnectExerciseTypes
import java.math.BigDecimal
import java.math.RoundingMode
import java.time.Instant
@@ -52,6 +58,7 @@ import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.temporal.ChronoUnit
+import java.util.concurrent.ConcurrentHashMap
import kotlin.reflect.KClass
import kotlin.time.DurationUnit
import kotlin.time.toDuration
@@ -185,6 +192,15 @@ class HealthConnectSensorManager : SensorManager {
entityCategory = SensorManager.ENTITY_CATEGORY_DIAGNOSTIC,
)
+ val exerciseSession = SensorManager.BasicSensor(
+ id = "health_connect_exercise_session",
+ type = "sensor",
+ commonR.string.basic_sensor_name_exercise_session,
+ commonR.string.sensor_description_exercise_session,
+ "mdi:run",
+ entityCategory = SensorManager.ENTITY_CATEGORY_DIAGNOSTIC,
+ )
+
val floorsClimbed = SensorManager.BasicSensor(
id = "health_connect_floors_climbed",
type = "sensor",
@@ -347,34 +363,23 @@ class HealthConnectSensorManager : SensorManager {
deviceClass = "weight",
)
- private val sensorPermissionMap = mapOf(
- activeCaloriesBurned.id to ActiveCaloriesBurnedRecord::class,
- basalBodyTemperature.id to BasalBodyTemperatureRecord::class,
- basalMetabolicRate.id to BasalMetabolicRateRecord::class,
- bloodGlucose.id to BloodGlucoseRecord::class,
- bodyFat.id to BodyFatRecord::class,
- bodyWaterMass.id to BodyWaterMassRecord::class,
- bodyTemperature.id to BodyTemperatureRecord::class,
- boneMass.id to BoneMassRecord::class,
- diastolicBloodPressure.id to BloodPressureRecord::class,
- distance.id to DistanceRecord::class,
- elevationGained.id to ElevationGainedRecord::class,
- floorsClimbed.id to FloorsClimbedRecord::class,
- heartRate.id to HeartRateRecord::class,
- heartRateVariability.id to HeartRateVariabilityRmssdRecord::class,
- height.id to HeightRecord::class,
- hydration.id to HydrationRecord::class,
- leanBodyMass.id to LeanBodyMassRecord::class,
- oxygenSaturation.id to OxygenSaturationRecord::class,
- respiratoryRate.id to RespiratoryRateRecord::class,
- restingHeartRate.id to RestingHeartRateRecord::class,
- sleepDuration.id to SleepSessionRecord::class,
- steps.id to StepsRecord::class,
- systolicBloodPressure.id to BloodPressureRecord::class,
- totalCaloriesBurned.id to TotalCaloriesBurnedRecord::class,
- vo2Max.id to Vo2MaxRecord::class,
- weight.id to WeightRecord::class,
- )
+ /**
+ * Toggle setting key for "allow Home Assistant to write to this Health Connect data type".
+ * One row per sensor; when ON, [requiredPermissions] for that sensor includes the
+ * matching `WRITE_*` permission so the existing grant flow asks the user for it.
+ */
+ const val SETTING_ALLOW_WRITES = "sensor_allow_writes"
+
+ /**
+ * Per-sensor cache of the [SETTING_ALLOW_WRITES] toggle, populated during
+ * [requestSensorUpdate] (which runs on the WorkManager IO dispatcher and again
+ * synchronously from `setSetting`). [requiredPermissions] is non-suspend by
+ * interface contract, so it cannot read the DB itself — caching keeps the call
+ * site cheap without forcing every sensor manager in the codebase to become
+ * suspend.
+ */
+ @androidx.annotation.VisibleForTesting
+ internal val allowWritesCache = ConcurrentHashMap()
}
override val name: Int
@@ -382,15 +387,19 @@ class HealthConnectSensorManager : SensorManager {
override fun requiredPermissions(context: Context, sensorId: String): Array {
return FailFast.failOnCatch({ "Unable to get required permissions for $sensorId" }, emptyArray()) {
- val permissions = sensorPermissionMap[sensorId]?.let { recordClass ->
- val readPermission = HealthPermission.getReadPermission(recordClass)
- if (getOrCreateHealthConnectClient(context)?.features
+ val permissions = HealthConnectDataType.fromSensorId(sensorId)?.let { dataType ->
+ val base = if (getOrCreateHealthConnectClient(context)?.features
?.getFeatureStatus(HealthConnectFeatures.FEATURE_READ_HEALTH_DATA_IN_BACKGROUND)
== HealthConnectFeatures.FEATURE_STATUS_AVAILABLE
) {
- arrayOf(readPermission, HealthPermission.PERMISSION_READ_HEALTH_DATA_IN_BACKGROUND)
+ arrayOf(dataType.readPermission, HealthPermission.PERMISSION_READ_HEALTH_DATA_IN_BACKGROUND)
+ } else {
+ arrayOf(dataType.readPermission)
+ }
+ if (allowWritesCache[sensorId] == true) {
+ base + dataType.writePermission
} else {
- arrayOf(readPermission)
+ base
}
}
FailFast.failWhen(permissions == null) { "Missing sensor mapping for $sensorId" }
@@ -399,6 +408,7 @@ class HealthConnectSensorManager : SensorManager {
}
override suspend fun requestSensorUpdate(context: Context) {
+ refreshAllowWritesCache(context)
if (isEnabled(context, activeCaloriesBurned)) {
updateActiveCaloriesBurnedSensor(context)
}
@@ -432,6 +442,9 @@ class HealthConnectSensorManager : SensorManager {
if (isEnabled(context, elevationGained)) {
updateElevationGainedSensor(context)
}
+ if (isEnabled(context, exerciseSession)) {
+ updateExerciseSessionSensor(context)
+ }
if (isEnabled(context, floorsClimbed)) {
updateFloorsClimbedSensor(context)
}
@@ -479,6 +492,28 @@ class HealthConnectSensorManager : SensorManager {
}
}
+ /**
+ * Persist a default-OFF [SETTING_ALLOW_WRITES] toggle for every available HC sensor and
+ * mirror the current values into [allowWritesCache]. Called at the top of
+ * [requestSensorUpdate] so that:
+ * - the row exists in the DB and shows up in the sensor detail screen the first time
+ * a user opens it after enabling the sensor, and
+ * - [requiredPermissions] sees the latest user choice within one update cycle of a
+ * toggle flip (which is fine — `setSetting` triggers a sensor update right after
+ * writing the row, so the cache is fresh before the user's next interaction).
+ */
+ private suspend fun refreshAllowWritesCache(context: Context) {
+ getAvailableSensors(context).forEach { basicSensor ->
+ val enabled = getToggleSetting(
+ context,
+ basicSensor,
+ SETTING_ALLOW_WRITES,
+ default = false,
+ )
+ allowWritesCache[basicSensor.id] = enabled
+ }
+ }
+
private suspend fun updateActiveCaloriesBurnedSensor(context: Context) {
val healthConnectClient = getOrCreateHealthConnectClient(context) ?: return
val activeCaloriesBurnedRequest = buildReadRecordsRequest(ActiveCaloriesBurnedRecord::class)
@@ -691,6 +726,157 @@ class HealthConnectSensorManager : SensorManager {
)
}
+ /**
+ * Surfaces the most-recent [ExerciseSessionRecord] (within the last 30 days, like the
+ * other read sensors) as a single sensor whose state is the exercise type slug
+ * ("running", "biking", …) and whose attributes carry the workout's start/end/duration
+ * plus optional title/notes. The Wear OS activity sensor is a better fit for live
+ * workout state — this one's main purpose is letting HA see *that* a workout was logged
+ * (by HC, by the wearable app, by HA itself via a write) so dashboards and automations
+ * can react after the fact.
+ */
+ private suspend fun updateExerciseSessionSensor(context: Context) {
+ val healthConnectClient = getOrCreateHealthConnectClient(context) ?: return
+ val request = buildReadRecordsRequest(ExerciseSessionRecord::class)
+ val response = healthConnectClient.readRecordsOrNull(request) ?: return
+ val record = response.records.lastOrNull() ?: return
+ val durationMillis = record.endTime.toEpochMilli() - record.startTime.toEpochMilli()
+ val durationMinutes = durationMillis.toDuration(DurationUnit.MILLISECONDS).inWholeMinutes
+ val durationSeconds = durationMillis.toDuration(DurationUnit.MILLISECONDS).inWholeSeconds
+ val typeSlug = HealthConnectExerciseTypes.INT_TO_SLUG[record.exerciseType] ?: STATE_UNKNOWN
+ val sessionRange = TimeRangeFilter.between(record.startTime, record.endTime)
+
+ val attributes = buildMap {
+ put("exercise_type", typeSlug)
+ put("exercise_type_int", record.exerciseType)
+ put("start_time", record.startTime)
+ put("end_time", record.endTime)
+ put("duration_minutes", durationMinutes)
+ put("duration_seconds", durationSeconds)
+ put("title", record.title)
+ put("notes", record.notes)
+ put("source", record.metadata.dataOrigin.packageName)
+
+ // Inline session data (always present, no extra HC call needed).
+ put("segment_count", record.segments.size)
+ put("lap_count", record.laps.size)
+ if (record.segments.isNotEmpty()) {
+ put(
+ "segments",
+ record.segments.map { seg ->
+ mapOf(
+ "start_time" to seg.startTime,
+ "end_time" to seg.endTime,
+ "segment_type" to seg.segmentType,
+ "repetitions" to seg.repetitions,
+ )
+ },
+ )
+ // Sum repetitions across segments — for swims this is total strokes/lengths.
+ put("total_segment_repetitions", record.segments.sumOf { it.repetitions })
+ }
+ if (record.laps.isNotEmpty()) {
+ val lapDistanceMeters = record.laps.sumOf { it.length?.inMeters ?: 0.0 }
+ put("total_lap_distance_m", lapDistanceMeters)
+ }
+
+ // Aggregate over the session window. Each block runs independently — a missing
+ // permission or empty record set silently drops only its own attributes.
+ val hr = healthConnectClient.aggregateOrNull(
+ AggregateRequest(
+ metrics = setOf(
+ HeartRateRecord.BPM_AVG,
+ HeartRateRecord.BPM_MIN,
+ HeartRateRecord.BPM_MAX,
+ ),
+ timeRangeFilter = sessionRange,
+ ),
+ )
+ hr?.get(HeartRateRecord.BPM_AVG)?.let { put("avg_hr", it) }
+ hr?.get(HeartRateRecord.BPM_MIN)?.let { put("min_hr", it) }
+ hr?.get(HeartRateRecord.BPM_MAX)?.let { put("max_hr", it) }
+
+ val distance = healthConnectClient.aggregateOrNull(
+ AggregateRequest(
+ metrics = setOf(DistanceRecord.DISTANCE_TOTAL),
+ timeRangeFilter = sessionRange,
+ ),
+ )?.get(DistanceRecord.DISTANCE_TOTAL)?.inMeters
+ distance?.let {
+ put("total_distance_m", it)
+ if (durationSeconds > 0 && it > 0) {
+ put("avg_speed_m_s", it / durationSeconds.toDouble())
+ put("avg_pace_min_per_km", durationMinutes.toDouble() / (it / 1000.0))
+ }
+ }
+
+ val speed = healthConnectClient.aggregateOrNull(
+ AggregateRequest(
+ metrics = setOf(SpeedRecord.SPEED_AVG, SpeedRecord.SPEED_MAX),
+ timeRangeFilter = sessionRange,
+ ),
+ )
+ speed?.get(SpeedRecord.SPEED_AVG)?.inMetersPerSecond?.let { put("recorded_avg_speed_m_s", it) }
+ speed?.get(SpeedRecord.SPEED_MAX)?.inMetersPerSecond?.let { put("recorded_max_speed_m_s", it) }
+
+ val power = healthConnectClient.aggregateOrNull(
+ AggregateRequest(
+ metrics = setOf(PowerRecord.POWER_AVG, PowerRecord.POWER_MAX),
+ timeRangeFilter = sessionRange,
+ ),
+ )
+ power?.get(PowerRecord.POWER_AVG)?.inWatts?.let { put("avg_power_watts", it) }
+ power?.get(PowerRecord.POWER_MAX)?.inWatts?.let { put("max_power_watts", it) }
+
+ val cadence = healthConnectClient.aggregateOrNull(
+ AggregateRequest(
+ metrics = setOf(
+ CyclingPedalingCadenceRecord.RPM_AVG,
+ CyclingPedalingCadenceRecord.RPM_MAX,
+ ),
+ timeRangeFilter = sessionRange,
+ ),
+ )
+ cadence?.get(CyclingPedalingCadenceRecord.RPM_AVG)?.let { put("avg_cadence_rpm", it) }
+ cadence?.get(CyclingPedalingCadenceRecord.RPM_MAX)?.let { put("max_cadence_rpm", it) }
+
+ val activeKcal = healthConnectClient.aggregateOrNull(
+ AggregateRequest(
+ metrics = setOf(ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL),
+ timeRangeFilter = sessionRange,
+ ),
+ )?.get(ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL)?.inKilocalories
+ activeKcal?.let { put("active_kcal", it) }
+
+ val totalKcal = healthConnectClient.aggregateOrNull(
+ AggregateRequest(
+ metrics = setOf(TotalCaloriesBurnedRecord.ENERGY_TOTAL),
+ timeRangeFilter = sessionRange,
+ ),
+ )?.get(TotalCaloriesBurnedRecord.ENERGY_TOTAL)?.inKilocalories
+ totalKcal?.let { put("total_kcal", it) }
+
+ val totalSteps = healthConnectClient.aggregateOrNull(
+ AggregateRequest(
+ metrics = setOf(StepsRecord.COUNT_TOTAL),
+ timeRangeFilter = sessionRange,
+ ),
+ )?.get(StepsRecord.COUNT_TOTAL)
+ totalSteps?.let {
+ put("total_steps", it)
+ if (durationMinutes > 0) put("avg_cadence_spm", it.toDouble() / durationMinutes)
+ }
+ }
+
+ onSensorUpdated(
+ context,
+ exerciseSession,
+ typeSlug,
+ exerciseSession.statelessIcon,
+ attributes = attributes,
+ )
+ }
+
private suspend fun updateFloorsClimbedSensor(context: Context) {
val healthConnectClient = getOrCreateHealthConnectClient(context) ?: return
val floorsClimbedRequest =
@@ -962,6 +1148,7 @@ class HealthConnectSensorManager : SensorManager {
diastolicBloodPressure,
distance,
elevationGained,
+ exerciseSession,
floorsClimbed,
heartRate,
heartRateVariability,
diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/sensors/healthconnect/HealthConnectChangesRepository.kt b/app/src/main/kotlin/io/homeassistant/companion/android/sensors/healthconnect/HealthConnectChangesRepository.kt
new file mode 100644
index 00000000000..decba732c75
--- /dev/null
+++ b/app/src/main/kotlin/io/homeassistant/companion/android/sensors/healthconnect/HealthConnectChangesRepository.kt
@@ -0,0 +1,113 @@
+package io.homeassistant.companion.android.sensors.healthconnect
+
+import androidx.health.connect.client.HealthConnectClient
+import androidx.health.connect.client.changes.DeletionChange
+import androidx.health.connect.client.changes.UpsertionChange
+import androidx.health.connect.client.permission.HealthPermission
+import androidx.health.connect.client.request.ChangesTokenRequest
+import javax.inject.Inject
+import javax.inject.Provider
+import javax.inject.Singleton
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import timber.log.Timber
+
+/**
+ * Polls the Health Connect Changes API for one or more data types, returning the set of
+ * data types that observed at least one upsertion or deletion since the last poll.
+ *
+ * The returned set is the trigger for the worker to call [io.homeassistant.companion.android.sensors.SensorReceiver.updateAllSensors]
+ * — re-reading and re-publishing the affected sensors. We deliberately don't try to push
+ * individual record diffs into HA: the existing sensor pipeline already reads the latest
+ * value per data type, so a single update broadcast is sufficient and avoids duplicating
+ * the sensor → HA mapping logic that already lives in [io.homeassistant.companion.android.sensors.HealthConnectSensorManager].
+ *
+ * Token lifecycle:
+ * - First poll for a data type: mint a fresh token via [HealthConnectClient.getChangesToken]
+ * and persist it. No changes are emitted from this call — we have no baseline yet.
+ * - Steady state: call [HealthConnectClient.getChanges] with the saved token, follow
+ * the `hasMore` pagination, persist the final `nextChangesToken`.
+ * - `changesTokenExpired = true`: the device has been offline / silent long enough that
+ * HC dropped our cursor. Clear the stored token, mint a new one, and tell the caller
+ * that this data type "changed" so the next sensor poll catches up.
+ */
+@Singleton
+class HealthConnectChangesRepository @Inject constructor(
+ private val clientProvider: Provider,
+ private val tokenStore: HealthConnectChangesTokenStore,
+) {
+
+ /**
+ * Returns the subset of [dataTypes] that observed at least one change since the last
+ * poll, or `null` when Health Connect is unavailable on this device. An empty set is
+ * a valid, common result — it means "nothing changed", not "failed".
+ */
+ suspend fun pollChanges(dataTypes: Collection): Set? {
+ val client = clientProvider.get() ?: return null
+ return withContext(Dispatchers.IO) {
+ val granted = runCatching { client.permissionController.getGrantedPermissions() }
+ .getOrElse { error ->
+ Timber.w(error, "Failed to read Health Connect permissions during changes poll")
+ return@withContext emptySet()
+ }
+
+ val changed = mutableSetOf()
+ for (dataType in dataTypes) {
+ val readPermission = HealthPermission.getReadPermission(dataType.recordClass)
+ if (readPermission !in granted) continue
+ if (pollOne(client, dataType)) {
+ changed += dataType
+ }
+ }
+ changed
+ }
+ }
+
+ private suspend fun pollOne(client: HealthConnectClient, dataType: HealthConnectDataType): Boolean {
+ val existing = tokenStore.get(dataType)
+ if (existing == null) {
+ // Mint a baseline token. No changes are reported for the very first poll —
+ // the existing 15-min SensorWorker already keeps initial values up to date.
+ val fresh = runCatching {
+ client.getChangesToken(ChangesTokenRequest(setOf(dataType.recordClass)))
+ }.onFailure { Timber.w(it, "getChangesToken failed for ${dataType.key}") }
+ .getOrNull()
+ ?: return false
+ tokenStore.put(dataType, fresh)
+ return false
+ }
+
+ var token: String = existing
+ var observed = false
+ // hasMore can return multiple pages; drain them so we don't leave events behind.
+ while (true) {
+ val response = runCatching { client.getChanges(token) }
+ .onFailure { Timber.w(it, "getChanges failed for ${dataType.key}") }
+ .getOrNull()
+ ?: return observed
+
+ if (response.changesTokenExpired) {
+ Timber.i("Changes token expired for ${dataType.key} — minting a fresh token")
+ tokenStore.clear(dataType)
+ val fresh = runCatching {
+ client.getChangesToken(ChangesTokenRequest(setOf(dataType.recordClass)))
+ }.getOrNull()
+ if (fresh != null) tokenStore.put(dataType, fresh)
+ // Treat token expiry as "something changed" so the next sensor poll
+ // re-publishes the latest value, even though we don't have the deltas.
+ return true
+ }
+
+ for (change in response.changes) {
+ when (change) {
+ is UpsertionChange, is DeletionChange -> observed = true
+ }
+ }
+
+ token = response.nextChangesToken
+ if (!response.hasMore) break
+ }
+ tokenStore.put(dataType, token)
+ return observed
+ }
+}
diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/sensors/healthconnect/HealthConnectChangesTokenStore.kt b/app/src/main/kotlin/io/homeassistant/companion/android/sensors/healthconnect/HealthConnectChangesTokenStore.kt
new file mode 100644
index 00000000000..902a82cfd57
--- /dev/null
+++ b/app/src/main/kotlin/io/homeassistant/companion/android/sensors/healthconnect/HealthConnectChangesTokenStore.kt
@@ -0,0 +1,58 @@
+package io.homeassistant.companion.android.sensors.healthconnect
+
+import io.homeassistant.companion.android.common.data.LocalStorage
+import io.homeassistant.companion.android.di.qualifiers.NamedHealthConnectStorage
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+
+/**
+ * Persists the Health Connect Changes API token for each [HealthConnectDataType].
+ *
+ * Tokens are opaque cursor strings minted by Health Connect; the changes worker uses
+ * them to fetch only what changed since the last poll (rather than re-reading the full
+ * sensor history every cycle). Persisting per-type means the worker can recover after
+ * a process death without losing or duplicating events.
+ *
+ * Implementation notes:
+ * - Backed by [LocalStorage] (a `SharedPreferences` wrapper). A token map is small
+ * enough that DataStore migration is unjustified.
+ * - All access is mutex-guarded. CLAUDE.md forbids `synchronized` blocks; the mutex
+ * also lets us await the suspending [LocalStorage] reads safely.
+ * - The store treats a missing or blank token identically: callers should mint a fresh
+ * `getChangesToken` call when [get] returns `null`.
+ */
+@Singleton
+class HealthConnectChangesTokenStore @Inject constructor(
+ @NamedHealthConnectStorage private val storage: LocalStorage,
+) {
+ private val mutex = Mutex()
+
+ suspend fun get(dataType: HealthConnectDataType): String? = mutex.withLock {
+ storage.getString(key(dataType))?.takeIf { it.isNotBlank() }
+ }
+
+ suspend fun put(dataType: HealthConnectDataType, token: String) = mutex.withLock {
+ storage.putString(key(dataType), token)
+ }
+
+ /**
+ * Drop the persisted token for [dataType]. Used after the Health Connect server
+ * reports `changesTokenExpired` — the next poll then mints a fresh token instead
+ * of re-sending the expired one in a tight loop.
+ */
+ suspend fun clear(dataType: HealthConnectDataType) = mutex.withLock {
+ storage.remove(key(dataType))
+ }
+
+ suspend fun clearAll() = mutex.withLock {
+ HealthConnectDataType.all.forEach { storage.remove(key(it)) }
+ }
+
+ private fun key(dataType: HealthConnectDataType): String = "$KEY_PREFIX${dataType.key}"
+
+ companion object {
+ const val KEY_PREFIX = "changes_token::"
+ }
+}
diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/sensors/healthconnect/HealthConnectChangesWorker.kt b/app/src/main/kotlin/io/homeassistant/companion/android/sensors/healthconnect/HealthConnectChangesWorker.kt
new file mode 100644
index 00000000000..d4a97f1df20
--- /dev/null
+++ b/app/src/main/kotlin/io/homeassistant/companion/android/sensors/healthconnect/HealthConnectChangesWorker.kt
@@ -0,0 +1,85 @@
+package io.homeassistant.companion.android.sensors.healthconnect
+
+import android.content.Context
+import androidx.work.CoroutineWorker
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.PeriodicWorkRequestBuilder
+import androidx.work.WorkManager
+import androidx.work.WorkerParameters
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.android.EntryPointAccessors
+import dagger.hilt.components.SingletonComponent
+import io.homeassistant.companion.android.sensors.SensorReceiver
+import java.util.concurrent.TimeUnit
+import timber.log.Timber
+
+/**
+ * Periodic WorkManager job that polls the Health Connect Changes API for deltas the
+ * 15-minute [io.homeassistant.companion.android.sensors.SensorWorker] would otherwise wait
+ * for. Triggers a sensor refresh whenever any subscribed data type observes a change.
+ *
+ * Opt-in: [start] is a no-op unless the user has flipped on
+ * [HealthConnectSyncPreferences.isRealtimeSyncEnabled], so devices that don't want the extra
+ * polling cadence pay nothing. The worker self-checks the flag on each run as well — that
+ * way, a flag flip racing with a fired worker resolves to "skip this cycle" rather than
+ * surprising the user with one extra poll.
+ *
+ * Cadence: 15 minutes is the lower bound WorkManager enforces for periodic jobs, so the
+ * "every 5 min" promised by the plan is delivered via flex intervals — WorkManager runs
+ * the worker at some point inside the last [FLEX_INTERVAL_MIN] minutes of each
+ * [REPEAT_INTERVAL_MIN]-minute window. In practice this gives ≤5-min latency for catching
+ * a third-party HC write, which is the user-visible goal.
+ */
+class HealthConnectChangesWorker(appContext: Context, workerParams: WorkerParameters) :
+ CoroutineWorker(appContext, workerParams) {
+
+ @EntryPoint
+ @InstallIn(SingletonComponent::class)
+ interface Entry {
+ fun changesRepository(): HealthConnectChangesRepository
+ fun preferences(): HealthConnectSyncPreferences
+ }
+
+ override suspend fun doWork(): Result {
+ val entry = EntryPointAccessors.fromApplication(applicationContext, Entry::class.java)
+ if (!entry.preferences().isRealtimeSyncEnabled()) {
+ Timber.d("Health Connect real-time sync disabled — skipping changes poll")
+ return Result.success()
+ }
+ val changed = entry.changesRepository().pollChanges(HealthConnectDataType.all)
+ return when {
+ changed == null -> {
+ // Health Connect unavailable on this device. Nothing to do; don't retry.
+ Result.success()
+ }
+ changed.isEmpty() -> Result.success()
+ else -> {
+ Timber.d("Health Connect changes detected for: ${changed.joinToString { it.key }}")
+ SensorReceiver.updateAllSensors(applicationContext)
+ Result.success()
+ }
+ }
+ }
+
+ companion object {
+ const val UNIQUE_WORK_NAME = "HealthConnectChangesWorker"
+ const val REPEAT_INTERVAL_MIN = 15L
+ const val FLEX_INTERVAL_MIN = 5L
+
+ fun start(context: Context) {
+ val request = PeriodicWorkRequestBuilder(
+ REPEAT_INTERVAL_MIN,
+ TimeUnit.MINUTES,
+ FLEX_INTERVAL_MIN,
+ TimeUnit.MINUTES,
+ ).build()
+ WorkManager.getInstance(context)
+ .enqueueUniquePeriodicWork(UNIQUE_WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request)
+ }
+
+ fun stop(context: Context) {
+ WorkManager.getInstance(context).cancelUniqueWork(UNIQUE_WORK_NAME)
+ }
+ }
+}
diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/sensors/healthconnect/HealthConnectDataType.kt b/app/src/main/kotlin/io/homeassistant/companion/android/sensors/healthconnect/HealthConnectDataType.kt
new file mode 100644
index 00000000000..2d6a8cbe4a2
--- /dev/null
+++ b/app/src/main/kotlin/io/homeassistant/companion/android/sensors/healthconnect/HealthConnectDataType.kt
@@ -0,0 +1,306 @@
+package io.homeassistant.companion.android.sensors.healthconnect
+
+import androidx.health.connect.client.permission.HealthPermission
+import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord
+import androidx.health.connect.client.records.BasalBodyTemperatureRecord
+import androidx.health.connect.client.records.BasalMetabolicRateRecord
+import androidx.health.connect.client.records.BloodGlucoseRecord
+import androidx.health.connect.client.records.BloodPressureRecord
+import androidx.health.connect.client.records.BodyFatRecord
+import androidx.health.connect.client.records.BodyTemperatureRecord
+import androidx.health.connect.client.records.BodyWaterMassRecord
+import androidx.health.connect.client.records.BoneMassRecord
+import androidx.health.connect.client.records.CyclingPedalingCadenceRecord
+import androidx.health.connect.client.records.DistanceRecord
+import androidx.health.connect.client.records.ElevationGainedRecord
+import androidx.health.connect.client.records.ExerciseSessionRecord
+import androidx.health.connect.client.records.FloorsClimbedRecord
+import androidx.health.connect.client.records.HeartRateRecord
+import androidx.health.connect.client.records.HeartRateVariabilityRmssdRecord
+import androidx.health.connect.client.records.HeightRecord
+import androidx.health.connect.client.records.HydrationRecord
+import androidx.health.connect.client.records.LeanBodyMassRecord
+import androidx.health.connect.client.records.OxygenSaturationRecord
+import androidx.health.connect.client.records.PowerRecord
+import androidx.health.connect.client.records.Record
+import androidx.health.connect.client.records.RespiratoryRateRecord
+import androidx.health.connect.client.records.RestingHeartRateRecord
+import androidx.health.connect.client.records.SleepSessionRecord
+import androidx.health.connect.client.records.SpeedRecord
+import androidx.health.connect.client.records.StepsRecord
+import androidx.health.connect.client.records.TotalCaloriesBurnedRecord
+import androidx.health.connect.client.records.Vo2MaxRecord
+import androidx.health.connect.client.records.WeightRecord
+import kotlin.reflect.KClass
+
+/**
+ * Strongly-typed catalogue of every Health Connect data type the companion app knows about.
+ *
+ * Each entry binds together:
+ * - [key]: the stable identifier used in the FCM `command_health_connect_write` payload's
+ * `data_type` field. Server-side automations write this value, so it must remain stable
+ * across releases.
+ * - [recordClass]: the [androidx.health.connect.client.records.Record] subclass produced
+ * when writing, and queried when reading via the Changes API.
+ * - [sensorIds]: the existing `HealthConnectSensorManager` sensor IDs that surface this
+ * data type to Home Assistant. Most data types have a 1:1 mapping; blood pressure is
+ * 1:N because it produces both a systolic and a diastolic sensor from the same record.
+ *
+ * The Health Connect read/write permission strings are derived from [recordClass] via
+ * [HealthPermission.getReadPermission] / [HealthPermission.getWritePermission] — there is
+ * no need to hard-code them.
+ */
+sealed class HealthConnectDataType(val key: String, val recordClass: KClass, val sensorIds: List) {
+ /** Permission string required to read this data type from Health Connect. */
+ val readPermission: String get() = HealthPermission.getReadPermission(recordClass)
+
+ /** Permission string required to write this data type to Health Connect. */
+ val writePermission: String get() = HealthPermission.getWritePermission(recordClass)
+
+ object ActiveCaloriesBurned : HealthConnectDataType(
+ key = "active_calories_burned",
+ recordClass = ActiveCaloriesBurnedRecord::class,
+ sensorIds = listOf("health_connect_active_calories_burned"),
+ )
+
+ object BasalBodyTemperature : HealthConnectDataType(
+ key = "basal_body_temperature",
+ recordClass = BasalBodyTemperatureRecord::class,
+ sensorIds = listOf("health_connect_basal_body_temperature"),
+ )
+
+ object BasalMetabolicRate : HealthConnectDataType(
+ key = "basal_metabolic_rate",
+ recordClass = BasalMetabolicRateRecord::class,
+ sensorIds = listOf("health_connect_basal_metabolic_rate"),
+ )
+
+ object BloodGlucose : HealthConnectDataType(
+ key = "blood_glucose",
+ recordClass = BloodGlucoseRecord::class,
+ sensorIds = listOf("health_connect_blood_glucose"),
+ )
+
+ object BloodPressure : HealthConnectDataType(
+ key = "blood_pressure",
+ recordClass = BloodPressureRecord::class,
+ sensorIds = listOf(
+ "health_connect_systolic_blood_pressure",
+ "health_connect_diastolic_blood_pressure",
+ ),
+ )
+
+ object BodyFat : HealthConnectDataType(
+ key = "body_fat",
+ recordClass = BodyFatRecord::class,
+ sensorIds = listOf("health_connect_body_fat"),
+ )
+
+ object BodyTemperature : HealthConnectDataType(
+ key = "body_temperature",
+ recordClass = BodyTemperatureRecord::class,
+ sensorIds = listOf("health_connect_body_temperature"),
+ )
+
+ object BodyWaterMass : HealthConnectDataType(
+ key = "body_water_mass",
+ recordClass = BodyWaterMassRecord::class,
+ sensorIds = listOf("health_connect_body_water_mass"),
+ )
+
+ object BoneMass : HealthConnectDataType(
+ key = "bone_mass",
+ recordClass = BoneMassRecord::class,
+ sensorIds = listOf("health_connect_bone_mass"),
+ )
+
+ /**
+ * Cycling pedal cadence (revolutions per minute). Series record without a standalone
+ * companion sensor — values surface as `avg_cadence_rpm` / `max_cadence_rpm` attributes
+ * on [ExerciseSession] when the session window overlaps recorded cadence samples.
+ */
+ object CyclingPedalingCadence : HealthConnectDataType(
+ key = "cycling_pedaling_cadence",
+ recordClass = CyclingPedalingCadenceRecord::class,
+ sensorIds = emptyList(),
+ )
+
+ object Distance : HealthConnectDataType(
+ key = "distance",
+ recordClass = DistanceRecord::class,
+ sensorIds = listOf("health_connect_distance"),
+ )
+
+ object ElevationGained : HealthConnectDataType(
+ key = "elevation_gained",
+ recordClass = ElevationGainedRecord::class,
+ sensorIds = listOf("health_connect_elevation_gained"),
+ )
+
+ object ExerciseSession : HealthConnectDataType(
+ key = "exercise_session",
+ recordClass = ExerciseSessionRecord::class,
+ sensorIds = listOf("health_connect_exercise_session"),
+ )
+
+ object FloorsClimbed : HealthConnectDataType(
+ key = "floors_climbed",
+ recordClass = FloorsClimbedRecord::class,
+ sensorIds = listOf("health_connect_floors_climbed"),
+ )
+
+ object HeartRate : HealthConnectDataType(
+ key = "heart_rate",
+ recordClass = HeartRateRecord::class,
+ sensorIds = listOf("health_connect_heart_rate"),
+ )
+
+ object HeartRateVariability : HealthConnectDataType(
+ key = "heart_rate_variability",
+ recordClass = HeartRateVariabilityRmssdRecord::class,
+ sensorIds = listOf("health_connect_heart_rate_variability"),
+ )
+
+ object Height : HealthConnectDataType(
+ key = "height",
+ recordClass = HeightRecord::class,
+ sensorIds = listOf("health_connect_height"),
+ )
+
+ object Hydration : HealthConnectDataType(
+ key = "hydration",
+ recordClass = HydrationRecord::class,
+ sensorIds = listOf("health_connect_hydration"),
+ )
+
+ object LeanBodyMass : HealthConnectDataType(
+ key = "lean_body_mass",
+ recordClass = LeanBodyMassRecord::class,
+ sensorIds = listOf("health_connect_lean_body_mass"),
+ )
+
+ object OxygenSaturation : HealthConnectDataType(
+ key = "oxygen_saturation",
+ recordClass = OxygenSaturationRecord::class,
+ sensorIds = listOf("health_connect_oxygen_saturation"),
+ )
+
+ /**
+ * Cycling / movement power output. Series record. Surfaces as `avg_power_watts` /
+ * `max_power_watts` attributes on [ExerciseSession]; no standalone sensor.
+ */
+ object Power : HealthConnectDataType(
+ key = "power",
+ recordClass = PowerRecord::class,
+ sensorIds = emptyList(),
+ )
+
+ object RespiratoryRate : HealthConnectDataType(
+ key = "respiratory_rate",
+ recordClass = RespiratoryRateRecord::class,
+ sensorIds = listOf("health_connect_respiratory_rate"),
+ )
+
+ object RestingHeartRate : HealthConnectDataType(
+ key = "resting_heart_rate",
+ recordClass = RestingHeartRateRecord::class,
+ sensorIds = listOf("health_connect_resting_heart_rate"),
+ )
+
+ object Sleep : HealthConnectDataType(
+ key = "sleep",
+ recordClass = SleepSessionRecord::class,
+ sensorIds = listOf("health_connect_sleep_duration"),
+ )
+
+ /**
+ * Linear speed / velocity. Series record. Surfaces as `avg_speed_m_s` /
+ * `max_speed_m_s` attributes on [ExerciseSession]; no standalone sensor.
+ */
+ object Speed : HealthConnectDataType(
+ key = "speed",
+ recordClass = SpeedRecord::class,
+ sensorIds = emptyList(),
+ )
+
+ object Steps : HealthConnectDataType(
+ key = "steps",
+ recordClass = StepsRecord::class,
+ sensorIds = listOf("health_connect_steps"),
+ )
+
+ object TotalCaloriesBurned : HealthConnectDataType(
+ key = "total_calories_burned",
+ recordClass = TotalCaloriesBurnedRecord::class,
+ sensorIds = listOf("health_connect_total_calories_burned"),
+ )
+
+ object Vo2Max : HealthConnectDataType(
+ key = "vo2_max",
+ recordClass = Vo2MaxRecord::class,
+ sensorIds = listOf("health_connect_vo2_max"),
+ )
+
+ object Weight : HealthConnectDataType(
+ key = "weight",
+ recordClass = WeightRecord::class,
+ sensorIds = listOf("health_connect_weight"),
+ )
+
+ companion object {
+ /**
+ * All known data types. Order is not significant.
+ *
+ * Wrapped in [lazy] because the sealed-class objects above initialize as the outer
+ * class loads, and a non-lazy `listOf(...)` here can race that ordering on the JVM
+ * — leaving null entries in the list when the companion is touched first (e.g. from
+ * a unit test that imports a single nested object). Deferring the reads until first
+ * access lets every nested object finish construction before we capture references.
+ */
+ val all: List by lazy {
+ listOf(
+ ActiveCaloriesBurned,
+ BasalBodyTemperature,
+ BasalMetabolicRate,
+ BloodGlucose,
+ BloodPressure,
+ BodyFat,
+ BodyTemperature,
+ BodyWaterMass,
+ BoneMass,
+ CyclingPedalingCadence,
+ Distance,
+ ElevationGained,
+ ExerciseSession,
+ FloorsClimbed,
+ HeartRate,
+ HeartRateVariability,
+ Height,
+ Hydration,
+ LeanBodyMass,
+ OxygenSaturation,
+ Power,
+ RespiratoryRate,
+ RestingHeartRate,
+ Sleep,
+ Speed,
+ Steps,
+ TotalCaloriesBurned,
+ Vo2Max,
+ Weight,
+ )
+ }
+
+ /**
+ * Resolve a data type by its FCM payload key, or `null` when the key is unknown.
+ * Used by [HealthConnectWriteCommandHandler] when parsing incoming commands.
+ */
+ fun fromKey(key: String): HealthConnectDataType? = all.firstOrNull { it.key == key }
+
+ /**
+ * Resolve a data type by one of its sensor IDs, or `null` when no sensor maps to
+ * this ID. A sensor ID always maps to at most one data type.
+ */
+ fun fromSensorId(sensorId: String): HealthConnectDataType? = all.firstOrNull { sensorId in it.sensorIds }
+ }
+}
diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/sensors/healthconnect/HealthConnectExerciseTypes.kt b/app/src/main/kotlin/io/homeassistant/companion/android/sensors/healthconnect/HealthConnectExerciseTypes.kt
new file mode 100644
index 00000000000..1cf83161a6b
--- /dev/null
+++ b/app/src/main/kotlin/io/homeassistant/companion/android/sensors/healthconnect/HealthConnectExerciseTypes.kt
@@ -0,0 +1,85 @@
+package io.homeassistant.companion.android.sensors.healthconnect
+
+import androidx.health.connect.client.records.ExerciseSessionRecord
+
+/**
+ * Local copy of `ExerciseSessionRecord.EXERCISE_TYPE_STRING_TO_INT_MAP` /
+ * `EXERCISE_TYPE_INT_TO_STRING_MAP`. Both SDK maps are `@RestrictTo(LIBRARY)`, so we mirror
+ * them here with the same string slugs the SDK uses internally (constant name minus the
+ * `EXERCISE_TYPE_` prefix, lowercased).
+ *
+ * Used in two places:
+ * - The HC → HA read sensor exposes the slug as the entity state.
+ * - The HA → HC write payload accepts the slug and resolves it to the int constant.
+ *
+ * Add new types when androidx.health.connect ships them — the test in
+ * `HealthConnectExerciseTypesTest` (if/when added) catches drift.
+ */
+internal object HealthConnectExerciseTypes {
+
+ val SLUG_TO_INT: Map = mapOf(
+ "other_workout" to ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT,
+ "badminton" to ExerciseSessionRecord.EXERCISE_TYPE_BADMINTON,
+ "baseball" to ExerciseSessionRecord.EXERCISE_TYPE_BASEBALL,
+ "basketball" to ExerciseSessionRecord.EXERCISE_TYPE_BASKETBALL,
+ "biking" to ExerciseSessionRecord.EXERCISE_TYPE_BIKING,
+ "biking_stationary" to ExerciseSessionRecord.EXERCISE_TYPE_BIKING_STATIONARY,
+ "boot_camp" to ExerciseSessionRecord.EXERCISE_TYPE_BOOT_CAMP,
+ "boxing" to ExerciseSessionRecord.EXERCISE_TYPE_BOXING,
+ "calisthenics" to ExerciseSessionRecord.EXERCISE_TYPE_CALISTHENICS,
+ "cricket" to ExerciseSessionRecord.EXERCISE_TYPE_CRICKET,
+ "dancing" to ExerciseSessionRecord.EXERCISE_TYPE_DANCING,
+ "elliptical" to ExerciseSessionRecord.EXERCISE_TYPE_ELLIPTICAL,
+ "exercise_class" to ExerciseSessionRecord.EXERCISE_TYPE_EXERCISE_CLASS,
+ "fencing" to ExerciseSessionRecord.EXERCISE_TYPE_FENCING,
+ "football_american" to ExerciseSessionRecord.EXERCISE_TYPE_FOOTBALL_AMERICAN,
+ "football_australian" to ExerciseSessionRecord.EXERCISE_TYPE_FOOTBALL_AUSTRALIAN,
+ "frisbee_disc" to ExerciseSessionRecord.EXERCISE_TYPE_FRISBEE_DISC,
+ "golf" to ExerciseSessionRecord.EXERCISE_TYPE_GOLF,
+ "guided_breathing" to ExerciseSessionRecord.EXERCISE_TYPE_GUIDED_BREATHING,
+ "gymnastics" to ExerciseSessionRecord.EXERCISE_TYPE_GYMNASTICS,
+ "handball" to ExerciseSessionRecord.EXERCISE_TYPE_HANDBALL,
+ "high_intensity_interval_training" to ExerciseSessionRecord.EXERCISE_TYPE_HIGH_INTENSITY_INTERVAL_TRAINING,
+ "hiking" to ExerciseSessionRecord.EXERCISE_TYPE_HIKING,
+ "ice_hockey" to ExerciseSessionRecord.EXERCISE_TYPE_ICE_HOCKEY,
+ "ice_skating" to ExerciseSessionRecord.EXERCISE_TYPE_ICE_SKATING,
+ "martial_arts" to ExerciseSessionRecord.EXERCISE_TYPE_MARTIAL_ARTS,
+ "paddling" to ExerciseSessionRecord.EXERCISE_TYPE_PADDLING,
+ "paragliding" to ExerciseSessionRecord.EXERCISE_TYPE_PARAGLIDING,
+ "pilates" to ExerciseSessionRecord.EXERCISE_TYPE_PILATES,
+ "racquetball" to ExerciseSessionRecord.EXERCISE_TYPE_RACQUETBALL,
+ "rock_climbing" to ExerciseSessionRecord.EXERCISE_TYPE_ROCK_CLIMBING,
+ "roller_hockey" to ExerciseSessionRecord.EXERCISE_TYPE_ROLLER_HOCKEY,
+ "rowing" to ExerciseSessionRecord.EXERCISE_TYPE_ROWING,
+ "rowing_machine" to ExerciseSessionRecord.EXERCISE_TYPE_ROWING_MACHINE,
+ "rugby" to ExerciseSessionRecord.EXERCISE_TYPE_RUGBY,
+ "running" to ExerciseSessionRecord.EXERCISE_TYPE_RUNNING,
+ "running_treadmill" to ExerciseSessionRecord.EXERCISE_TYPE_RUNNING_TREADMILL,
+ "sailing" to ExerciseSessionRecord.EXERCISE_TYPE_SAILING,
+ "scuba_diving" to ExerciseSessionRecord.EXERCISE_TYPE_SCUBA_DIVING,
+ "skating" to ExerciseSessionRecord.EXERCISE_TYPE_SKATING,
+ "skiing" to ExerciseSessionRecord.EXERCISE_TYPE_SKIING,
+ "snowboarding" to ExerciseSessionRecord.EXERCISE_TYPE_SNOWBOARDING,
+ "snowshoeing" to ExerciseSessionRecord.EXERCISE_TYPE_SNOWSHOEING,
+ "soccer" to ExerciseSessionRecord.EXERCISE_TYPE_SOCCER,
+ "softball" to ExerciseSessionRecord.EXERCISE_TYPE_SOFTBALL,
+ "squash" to ExerciseSessionRecord.EXERCISE_TYPE_SQUASH,
+ "stair_climbing" to ExerciseSessionRecord.EXERCISE_TYPE_STAIR_CLIMBING,
+ "stair_climbing_machine" to ExerciseSessionRecord.EXERCISE_TYPE_STAIR_CLIMBING_MACHINE,
+ "strength_training" to ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING,
+ "stretching" to ExerciseSessionRecord.EXERCISE_TYPE_STRETCHING,
+ "surfing" to ExerciseSessionRecord.EXERCISE_TYPE_SURFING,
+ "swimming_open_water" to ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_OPEN_WATER,
+ "swimming_pool" to ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_POOL,
+ "table_tennis" to ExerciseSessionRecord.EXERCISE_TYPE_TABLE_TENNIS,
+ "tennis" to ExerciseSessionRecord.EXERCISE_TYPE_TENNIS,
+ "volleyball" to ExerciseSessionRecord.EXERCISE_TYPE_VOLLEYBALL,
+ "walking" to ExerciseSessionRecord.EXERCISE_TYPE_WALKING,
+ "water_polo" to ExerciseSessionRecord.EXERCISE_TYPE_WATER_POLO,
+ "weightlifting" to ExerciseSessionRecord.EXERCISE_TYPE_WEIGHTLIFTING,
+ "wheelchair" to ExerciseSessionRecord.EXERCISE_TYPE_WHEELCHAIR,
+ "yoga" to ExerciseSessionRecord.EXERCISE_TYPE_YOGA,
+ )
+
+ val INT_TO_SLUG: Map = SLUG_TO_INT.entries.associate { (k, v) -> v to k }
+}
diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/sensors/healthconnect/HealthConnectModule.kt b/app/src/main/kotlin/io/homeassistant/companion/android/sensors/healthconnect/HealthConnectModule.kt
new file mode 100644
index 00000000000..9e67258cca2
--- /dev/null
+++ b/app/src/main/kotlin/io/homeassistant/companion/android/sensors/healthconnect/HealthConnectModule.kt
@@ -0,0 +1,53 @@
+package io.homeassistant.companion.android.sensors.healthconnect
+
+import android.content.Context
+import androidx.health.connect.client.HealthConnectClient
+import dagger.Binds
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+import timber.log.Timber
+
+/**
+ * Hilt bindings for Health Connect.
+ *
+ * Health Connect availability is dynamic: the SDK status can be unavailable, available, or
+ * "update required" depending on the device, the user's account, and whether the Health
+ * Connect APK is installed and current. The provider therefore returns a nullable client
+ * — callers must null-check before use rather than crashing on devices where Health
+ * Connect is not present.
+ *
+ * The provider is intentionally **not** `@Singleton`: callers receive `Provider`
+ * and treat each `provider.get()` as a fresh availability check. Caching as a singleton would
+ * pin the result of the first SDK-status query to the lifetime of the app process, so a user
+ * who installs or updates the Health Connect APK while the app is running would keep seeing
+ * `null` until the next cold start. `HealthConnectClient.getOrCreate` is itself idempotent
+ * and cheap on subsequent calls, so re-querying per `get()` is fine.
+ */
+@Module
+@InstallIn(SingletonComponent::class)
+object HealthConnectModule {
+ @Provides
+ fun providesHealthConnectClient(@ApplicationContext context: Context): HealthConnectClient? {
+ return runCatching {
+ if (HealthConnectClient.getSdkStatus(context) == HealthConnectClient.SDK_AVAILABLE) {
+ HealthConnectClient.getOrCreate(context)
+ } else {
+ null
+ }
+ }.onFailure {
+ Timber.w(it, "Failed to obtain HealthConnectClient — feature will be disabled")
+ }.getOrNull()
+ }
+}
+
+@Module
+@InstallIn(SingletonComponent::class)
+abstract class HealthConnectBindingsModule {
+ @Binds
+ @Singleton
+ abstract fun bindHealthConnectWriteRepository(impl: HealthConnectWriteRepositoryImpl): HealthConnectWriteRepository
+}
diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/sensors/healthconnect/HealthConnectSyncPreferences.kt b/app/src/main/kotlin/io/homeassistant/companion/android/sensors/healthconnect/HealthConnectSyncPreferences.kt
new file mode 100644
index 00000000000..57599746680
--- /dev/null
+++ b/app/src/main/kotlin/io/homeassistant/companion/android/sensors/healthconnect/HealthConnectSyncPreferences.kt
@@ -0,0 +1,29 @@
+package io.homeassistant.companion.android.sensors.healthconnect
+
+import io.homeassistant.companion.android.common.data.LocalStorage
+import io.homeassistant.companion.android.di.qualifiers.NamedHealthConnectStorage
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Lightweight read/write facade over the Health Connect [LocalStorage] for cross-cutting
+ * preferences that don't fit anywhere else (currently: the real-time-sync opt-in flag).
+ *
+ * Token persistence lives in [HealthConnectChangesTokenStore] — this class is intentionally
+ * separate so the storage interleaving stays readable when more knobs land (e.g. a per-data-type
+ * "allow writes from HA" flag in Commit D).
+ */
+@Singleton
+class HealthConnectSyncPreferences @Inject constructor(@NamedHealthConnectStorage private val storage: LocalStorage) {
+
+ /** Whether the user opted into the [HealthConnectChangesWorker] cadence. Default: false. */
+ suspend fun isRealtimeSyncEnabled(): Boolean = storage.getBoolean(KEY_REALTIME_SYNC)
+
+ suspend fun setRealtimeSyncEnabled(enabled: Boolean) {
+ storage.putBoolean(KEY_REALTIME_SYNC, enabled)
+ }
+
+ companion object {
+ const val KEY_REALTIME_SYNC = "realtime_sync_enabled"
+ }
+}
diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/sensors/healthconnect/HealthConnectWriteRepository.kt b/app/src/main/kotlin/io/homeassistant/companion/android/sensors/healthconnect/HealthConnectWriteRepository.kt
new file mode 100644
index 00000000000..901dd2e8d37
--- /dev/null
+++ b/app/src/main/kotlin/io/homeassistant/companion/android/sensors/healthconnect/HealthConnectWriteRepository.kt
@@ -0,0 +1,226 @@
+package io.homeassistant.companion.android.sensors.healthconnect
+
+import androidx.health.connect.client.records.CyclingPedalingCadenceRecord
+import androidx.health.connect.client.records.ExerciseSessionRecord
+import androidx.health.connect.client.records.HeartRateRecord
+import androidx.health.connect.client.records.PowerRecord
+import androidx.health.connect.client.records.Record
+import androidx.health.connect.client.records.SleepSessionRecord
+import androidx.health.connect.client.records.SpeedRecord
+import java.time.Instant
+
+/**
+ * Writes Health Connect records on behalf of HA → device automations.
+ *
+ * The interface exposes:
+ * - One typed `writeX` builder per supported data type so the command handler — and
+ * eventually a future scripting surface — can call into HC with strongly-typed
+ * primitives instead of building [Record] objects directly.
+ * - A single low-level [write] entry point used by the typed builders and by tests
+ * that want to construct a custom [Record].
+ *
+ * Implementations must:
+ * - Resolve the WRITE permission via [HealthConnectDataType.writePermission] and short-circuit
+ * with [HealthConnectWriteResult.MissingPermission] when the user has not granted it,
+ * rather than letting Health Connect throw `SecurityException`. Centralizing the check
+ * lets the handler emit a single, consistent notification.
+ * - Tolerate a `null` [androidx.health.connect.client.HealthConnectClient]
+ * (Hilt-provided when HC is unavailable on the device) by returning
+ * [HealthConnectWriteResult.Unavailable] without throwing.
+ */
+interface HealthConnectWriteRepository {
+
+ /**
+ * Persist [record] to Health Connect.
+ *
+ * The data type — and therefore the WRITE permission to check — is derived from
+ * `record::class` via [HealthConnectDataType.fromKey] on the matching record class.
+ * Callers should prefer the typed `writeX` methods below; this overload exists for
+ * unit tests and future code paths that already hold a fully-built [Record].
+ */
+ suspend fun write(record: Record): HealthConnectWriteResult
+
+ suspend fun writeActiveCaloriesBurned(
+ startTime: Instant,
+ endTime: Instant,
+ kilocalories: Double,
+ clientRecordId: String? = null,
+ ): HealthConnectWriteResult
+
+ suspend fun writeBasalBodyTemperature(
+ time: Instant,
+ celsius: Double,
+ clientRecordId: String? = null,
+ ): HealthConnectWriteResult
+
+ suspend fun writeBasalMetabolicRate(
+ time: Instant,
+ kilocaloriesPerDay: Double,
+ clientRecordId: String? = null,
+ ): HealthConnectWriteResult
+
+ suspend fun writeBloodGlucose(
+ time: Instant,
+ millimolesPerLiter: Double,
+ clientRecordId: String? = null,
+ ): HealthConnectWriteResult
+
+ suspend fun writeBloodPressure(
+ time: Instant,
+ systolicMmHg: Double,
+ diastolicMmHg: Double,
+ clientRecordId: String? = null,
+ ): HealthConnectWriteResult
+
+ suspend fun writeBodyFat(
+ time: Instant,
+ percentage: Double,
+ clientRecordId: String? = null,
+ ): HealthConnectWriteResult
+
+ suspend fun writeBodyTemperature(
+ time: Instant,
+ celsius: Double,
+ clientRecordId: String? = null,
+ ): HealthConnectWriteResult
+
+ suspend fun writeBodyWaterMass(
+ time: Instant,
+ kilograms: Double,
+ clientRecordId: String? = null,
+ ): HealthConnectWriteResult
+
+ suspend fun writeBoneMass(
+ time: Instant,
+ kilograms: Double,
+ clientRecordId: String? = null,
+ ): HealthConnectWriteResult
+
+ suspend fun writeDistance(
+ startTime: Instant,
+ endTime: Instant,
+ meters: Double,
+ clientRecordId: String? = null,
+ ): HealthConnectWriteResult
+
+ suspend fun writeElevationGained(
+ startTime: Instant,
+ endTime: Instant,
+ meters: Double,
+ clientRecordId: String? = null,
+ ): HealthConnectWriteResult
+
+ suspend fun writeExerciseSession(
+ startTime: Instant,
+ endTime: Instant,
+ exerciseType: Int = ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT,
+ title: String? = null,
+ notes: String? = null,
+ clientRecordId: String? = null,
+ ): HealthConnectWriteResult
+
+ suspend fun writeFloorsClimbed(
+ startTime: Instant,
+ endTime: Instant,
+ floors: Double,
+ clientRecordId: String? = null,
+ ): HealthConnectWriteResult
+
+ suspend fun writeHeartRate(
+ startTime: Instant,
+ endTime: Instant,
+ samples: List,
+ clientRecordId: String? = null,
+ ): HealthConnectWriteResult
+
+ suspend fun writeHeartRateVariability(
+ time: Instant,
+ rmssdMillis: Double,
+ clientRecordId: String? = null,
+ ): HealthConnectWriteResult
+
+ suspend fun writeHeight(time: Instant, meters: Double, clientRecordId: String? = null): HealthConnectWriteResult
+
+ suspend fun writeHydration(
+ startTime: Instant,
+ endTime: Instant,
+ liters: Double,
+ clientRecordId: String? = null,
+ ): HealthConnectWriteResult
+
+ suspend fun writeLeanBodyMass(
+ time: Instant,
+ kilograms: Double,
+ clientRecordId: String? = null,
+ ): HealthConnectWriteResult
+
+ suspend fun writeOxygenSaturation(
+ time: Instant,
+ percentage: Double,
+ clientRecordId: String? = null,
+ ): HealthConnectWriteResult
+
+ suspend fun writeRespiratoryRate(
+ time: Instant,
+ breathsPerMinute: Double,
+ clientRecordId: String? = null,
+ ): HealthConnectWriteResult
+
+ suspend fun writeRestingHeartRate(
+ time: Instant,
+ beatsPerMinute: Long,
+ clientRecordId: String? = null,
+ ): HealthConnectWriteResult
+
+ suspend fun writeSleep(
+ startTime: Instant,
+ endTime: Instant,
+ title: String? = null,
+ notes: String? = null,
+ stages: List = emptyList(),
+ clientRecordId: String? = null,
+ ): HealthConnectWriteResult
+
+ suspend fun writeSteps(
+ startTime: Instant,
+ endTime: Instant,
+ count: Long,
+ clientRecordId: String? = null,
+ ): HealthConnectWriteResult
+
+ suspend fun writeTotalCaloriesBurned(
+ startTime: Instant,
+ endTime: Instant,
+ kilocalories: Double,
+ clientRecordId: String? = null,
+ ): HealthConnectWriteResult
+
+ suspend fun writeVo2Max(
+ time: Instant,
+ millilitersPerMinuteKilogram: Double,
+ clientRecordId: String? = null,
+ ): HealthConnectWriteResult
+
+ suspend fun writeWeight(time: Instant, kilograms: Double, clientRecordId: String? = null): HealthConnectWriteResult
+
+ suspend fun writeSpeed(
+ startTime: Instant,
+ endTime: Instant,
+ samples: List,
+ clientRecordId: String? = null,
+ ): HealthConnectWriteResult
+
+ suspend fun writePower(
+ startTime: Instant,
+ endTime: Instant,
+ samples: List,
+ clientRecordId: String? = null,
+ ): HealthConnectWriteResult
+
+ suspend fun writeCyclingPedalingCadence(
+ startTime: Instant,
+ endTime: Instant,
+ samples: List,
+ clientRecordId: String? = null,
+ ): HealthConnectWriteResult
+}
diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/sensors/healthconnect/HealthConnectWriteRepositoryImpl.kt b/app/src/main/kotlin/io/homeassistant/companion/android/sensors/healthconnect/HealthConnectWriteRepositoryImpl.kt
new file mode 100644
index 00000000000..17acfe017f1
--- /dev/null
+++ b/app/src/main/kotlin/io/homeassistant/companion/android/sensors/healthconnect/HealthConnectWriteRepositoryImpl.kt
@@ -0,0 +1,658 @@
+package io.homeassistant.companion.android.sensors.healthconnect
+
+import androidx.health.connect.client.HealthConnectClient
+import androidx.health.connect.client.permission.HealthPermission
+import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord
+import androidx.health.connect.client.records.BasalBodyTemperatureRecord
+import androidx.health.connect.client.records.BasalMetabolicRateRecord
+import androidx.health.connect.client.records.BloodGlucoseRecord
+import androidx.health.connect.client.records.BloodPressureRecord
+import androidx.health.connect.client.records.BodyFatRecord
+import androidx.health.connect.client.records.BodyTemperatureMeasurementLocation
+import androidx.health.connect.client.records.BodyTemperatureRecord
+import androidx.health.connect.client.records.BodyWaterMassRecord
+import androidx.health.connect.client.records.BoneMassRecord
+import androidx.health.connect.client.records.CyclingPedalingCadenceRecord
+import androidx.health.connect.client.records.DistanceRecord
+import androidx.health.connect.client.records.ElevationGainedRecord
+import androidx.health.connect.client.records.ExerciseSessionRecord
+import androidx.health.connect.client.records.FloorsClimbedRecord
+import androidx.health.connect.client.records.HeartRateRecord
+import androidx.health.connect.client.records.HeartRateVariabilityRmssdRecord
+import androidx.health.connect.client.records.HeightRecord
+import androidx.health.connect.client.records.HydrationRecord
+import androidx.health.connect.client.records.LeanBodyMassRecord
+import androidx.health.connect.client.records.MealType
+import androidx.health.connect.client.records.OxygenSaturationRecord
+import androidx.health.connect.client.records.PowerRecord
+import androidx.health.connect.client.records.Record
+import androidx.health.connect.client.records.RespiratoryRateRecord
+import androidx.health.connect.client.records.RestingHeartRateRecord
+import androidx.health.connect.client.records.SleepSessionRecord
+import androidx.health.connect.client.records.SpeedRecord
+import androidx.health.connect.client.records.StepsRecord
+import androidx.health.connect.client.records.TotalCaloriesBurnedRecord
+import androidx.health.connect.client.records.Vo2MaxRecord
+import androidx.health.connect.client.records.WeightRecord
+import androidx.health.connect.client.records.metadata.Metadata
+import androidx.health.connect.client.units.BloodGlucose
+import androidx.health.connect.client.units.Energy
+import androidx.health.connect.client.units.Length
+import androidx.health.connect.client.units.Mass
+import androidx.health.connect.client.units.Percentage
+import androidx.health.connect.client.units.Power
+import androidx.health.connect.client.units.Pressure
+import androidx.health.connect.client.units.Temperature
+import androidx.health.connect.client.units.Volume
+import java.time.Instant
+import javax.inject.Inject
+import javax.inject.Provider
+import javax.inject.Singleton
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import timber.log.Timber
+
+/**
+ * Default [HealthConnectWriteRepository]. All record-building lives here so the typed
+ * `writeX` methods stay narrow and the unit tests don't need to wire SDK objects.
+ *
+ * The [HealthConnectClient] is injected through a [Provider] because Hilt provides
+ * the client as nullable (HC may be unavailable on the device); resolving lazily lets
+ * us return [HealthConnectWriteResult.Unavailable] without forcing every caller to
+ * do its own null-check.
+ */
+@Singleton
+class HealthConnectWriteRepositoryImpl @Inject constructor(
+ private val clientProvider: Provider,
+) : HealthConnectWriteRepository {
+
+ override suspend fun write(record: Record): HealthConnectWriteResult {
+ val dataType = HealthConnectDataType.all.firstOrNull { it.recordClass == record::class }
+ ?: return HealthConnectWriteResult.InvalidPayload(
+ "Unsupported record type: ${record::class.simpleName}",
+ )
+ return insert(dataType, listOf(record))
+ }
+
+ override suspend fun writeActiveCaloriesBurned(
+ startTime: Instant,
+ endTime: Instant,
+ kilocalories: Double,
+ clientRecordId: String?,
+ ): HealthConnectWriteResult = insert(
+ HealthConnectDataType.ActiveCaloriesBurned,
+ listOf(
+ ActiveCaloriesBurnedRecord(
+ startTime = startTime,
+ startZoneOffset = null,
+ endTime = endTime,
+ endZoneOffset = null,
+ energy = Energy.kilocalories(kilocalories),
+ metadata = metadata(clientRecordId),
+ ),
+ ),
+ )
+
+ override suspend fun writeBasalBodyTemperature(
+ time: Instant,
+ celsius: Double,
+ clientRecordId: String?,
+ ): HealthConnectWriteResult = insert(
+ HealthConnectDataType.BasalBodyTemperature,
+ listOf(
+ BasalBodyTemperatureRecord(
+ time = time,
+ zoneOffset = null,
+ temperature = Temperature.celsius(celsius),
+ measurementLocation = BodyTemperatureMeasurementLocation.MEASUREMENT_LOCATION_UNKNOWN,
+ metadata = metadata(clientRecordId),
+ ),
+ ),
+ )
+
+ override suspend fun writeBasalMetabolicRate(
+ time: Instant,
+ kilocaloriesPerDay: Double,
+ clientRecordId: String?,
+ ): HealthConnectWriteResult = insert(
+ HealthConnectDataType.BasalMetabolicRate,
+ listOf(
+ BasalMetabolicRateRecord(
+ time = time,
+ zoneOffset = null,
+ basalMetabolicRate = Power.kilocaloriesPerDay(kilocaloriesPerDay),
+ metadata = metadata(clientRecordId),
+ ),
+ ),
+ )
+
+ override suspend fun writeBloodGlucose(
+ time: Instant,
+ millimolesPerLiter: Double,
+ clientRecordId: String?,
+ ): HealthConnectWriteResult = insert(
+ HealthConnectDataType.BloodGlucose,
+ listOf(
+ BloodGlucoseRecord(
+ time = time,
+ zoneOffset = null,
+ level = BloodGlucose.millimolesPerLiter(millimolesPerLiter),
+ specimenSource = BloodGlucoseRecord.SPECIMEN_SOURCE_UNKNOWN,
+ mealType = MealType.MEAL_TYPE_UNKNOWN,
+ relationToMeal = BloodGlucoseRecord.RELATION_TO_MEAL_UNKNOWN,
+ metadata = metadata(clientRecordId),
+ ),
+ ),
+ )
+
+ override suspend fun writeBloodPressure(
+ time: Instant,
+ systolicMmHg: Double,
+ diastolicMmHg: Double,
+ clientRecordId: String?,
+ ): HealthConnectWriteResult = insert(
+ HealthConnectDataType.BloodPressure,
+ listOf(
+ BloodPressureRecord(
+ time = time,
+ zoneOffset = null,
+ systolic = Pressure.millimetersOfMercury(systolicMmHg),
+ diastolic = Pressure.millimetersOfMercury(diastolicMmHg),
+ bodyPosition = BloodPressureRecord.BODY_POSITION_UNKNOWN,
+ measurementLocation = BloodPressureRecord.MEASUREMENT_LOCATION_UNKNOWN,
+ metadata = metadata(clientRecordId),
+ ),
+ ),
+ )
+
+ override suspend fun writeBodyFat(
+ time: Instant,
+ percentage: Double,
+ clientRecordId: String?,
+ ): HealthConnectWriteResult = insert(
+ HealthConnectDataType.BodyFat,
+ listOf(
+ BodyFatRecord(
+ time = time,
+ zoneOffset = null,
+ percentage = Percentage(percentage),
+ metadata = metadata(clientRecordId),
+ ),
+ ),
+ )
+
+ override suspend fun writeBodyTemperature(
+ time: Instant,
+ celsius: Double,
+ clientRecordId: String?,
+ ): HealthConnectWriteResult = insert(
+ HealthConnectDataType.BodyTemperature,
+ listOf(
+ BodyTemperatureRecord(
+ time = time,
+ zoneOffset = null,
+ temperature = Temperature.celsius(celsius),
+ measurementLocation = BodyTemperatureMeasurementLocation.MEASUREMENT_LOCATION_UNKNOWN,
+ metadata = metadata(clientRecordId),
+ ),
+ ),
+ )
+
+ override suspend fun writeBodyWaterMass(
+ time: Instant,
+ kilograms: Double,
+ clientRecordId: String?,
+ ): HealthConnectWriteResult = insert(
+ HealthConnectDataType.BodyWaterMass,
+ listOf(
+ BodyWaterMassRecord(
+ time = time,
+ zoneOffset = null,
+ mass = Mass.kilograms(kilograms),
+ metadata = metadata(clientRecordId),
+ ),
+ ),
+ )
+
+ override suspend fun writeBoneMass(
+ time: Instant,
+ kilograms: Double,
+ clientRecordId: String?,
+ ): HealthConnectWriteResult = insert(
+ HealthConnectDataType.BoneMass,
+ listOf(
+ BoneMassRecord(
+ time = time,
+ zoneOffset = null,
+ mass = Mass.kilograms(kilograms),
+ metadata = metadata(clientRecordId),
+ ),
+ ),
+ )
+
+ override suspend fun writeDistance(
+ startTime: Instant,
+ endTime: Instant,
+ meters: Double,
+ clientRecordId: String?,
+ ): HealthConnectWriteResult = insert(
+ HealthConnectDataType.Distance,
+ listOf(
+ DistanceRecord(
+ startTime = startTime,
+ startZoneOffset = null,
+ endTime = endTime,
+ endZoneOffset = null,
+ distance = Length.meters(meters),
+ metadata = metadata(clientRecordId),
+ ),
+ ),
+ )
+
+ override suspend fun writeElevationGained(
+ startTime: Instant,
+ endTime: Instant,
+ meters: Double,
+ clientRecordId: String?,
+ ): HealthConnectWriteResult = insert(
+ HealthConnectDataType.ElevationGained,
+ listOf(
+ ElevationGainedRecord(
+ startTime = startTime,
+ startZoneOffset = null,
+ endTime = endTime,
+ endZoneOffset = null,
+ elevation = Length.meters(meters),
+ metadata = metadata(clientRecordId),
+ ),
+ ),
+ )
+
+ override suspend fun writeExerciseSession(
+ startTime: Instant,
+ endTime: Instant,
+ exerciseType: Int,
+ title: String?,
+ notes: String?,
+ clientRecordId: String?,
+ ): HealthConnectWriteResult = insert(
+ HealthConnectDataType.ExerciseSession,
+ listOf(
+ ExerciseSessionRecord(
+ startTime = startTime,
+ startZoneOffset = null,
+ endTime = endTime,
+ endZoneOffset = null,
+ metadata = metadata(clientRecordId),
+ exerciseType = exerciseType,
+ title = title,
+ notes = notes,
+ ),
+ ),
+ )
+
+ override suspend fun writeFloorsClimbed(
+ startTime: Instant,
+ endTime: Instant,
+ floors: Double,
+ clientRecordId: String?,
+ ): HealthConnectWriteResult = insert(
+ HealthConnectDataType.FloorsClimbed,
+ listOf(
+ FloorsClimbedRecord(
+ startTime = startTime,
+ startZoneOffset = null,
+ endTime = endTime,
+ endZoneOffset = null,
+ floors = floors,
+ metadata = metadata(clientRecordId),
+ ),
+ ),
+ )
+
+ override suspend fun writeHeartRate(
+ startTime: Instant,
+ endTime: Instant,
+ samples: List,
+ clientRecordId: String?,
+ ): HealthConnectWriteResult {
+ if (samples.isEmpty()) {
+ return HealthConnectWriteResult.InvalidPayload("heart_rate requires at least one sample")
+ }
+ return insert(
+ HealthConnectDataType.HeartRate,
+ listOf(
+ HeartRateRecord(
+ startTime = startTime,
+ startZoneOffset = null,
+ endTime = endTime,
+ endZoneOffset = null,
+ samples = samples,
+ metadata = metadata(clientRecordId),
+ ),
+ ),
+ )
+ }
+
+ override suspend fun writeHeartRateVariability(
+ time: Instant,
+ rmssdMillis: Double,
+ clientRecordId: String?,
+ ): HealthConnectWriteResult = insert(
+ HealthConnectDataType.HeartRateVariability,
+ listOf(
+ HeartRateVariabilityRmssdRecord(
+ time = time,
+ zoneOffset = null,
+ heartRateVariabilityMillis = rmssdMillis,
+ metadata = metadata(clientRecordId),
+ ),
+ ),
+ )
+
+ override suspend fun writeHeight(
+ time: Instant,
+ meters: Double,
+ clientRecordId: String?,
+ ): HealthConnectWriteResult = insert(
+ HealthConnectDataType.Height,
+ listOf(
+ HeightRecord(
+ time = time,
+ zoneOffset = null,
+ height = Length.meters(meters),
+ metadata = metadata(clientRecordId),
+ ),
+ ),
+ )
+
+ override suspend fun writeHydration(
+ startTime: Instant,
+ endTime: Instant,
+ liters: Double,
+ clientRecordId: String?,
+ ): HealthConnectWriteResult = insert(
+ HealthConnectDataType.Hydration,
+ listOf(
+ HydrationRecord(
+ startTime = startTime,
+ startZoneOffset = null,
+ endTime = endTime,
+ endZoneOffset = null,
+ volume = Volume.liters(liters),
+ metadata = metadata(clientRecordId),
+ ),
+ ),
+ )
+
+ override suspend fun writeLeanBodyMass(
+ time: Instant,
+ kilograms: Double,
+ clientRecordId: String?,
+ ): HealthConnectWriteResult = insert(
+ HealthConnectDataType.LeanBodyMass,
+ listOf(
+ LeanBodyMassRecord(
+ time = time,
+ zoneOffset = null,
+ mass = Mass.kilograms(kilograms),
+ metadata = metadata(clientRecordId),
+ ),
+ ),
+ )
+
+ override suspend fun writeOxygenSaturation(
+ time: Instant,
+ percentage: Double,
+ clientRecordId: String?,
+ ): HealthConnectWriteResult = insert(
+ HealthConnectDataType.OxygenSaturation,
+ listOf(
+ OxygenSaturationRecord(
+ time = time,
+ zoneOffset = null,
+ percentage = Percentage(percentage),
+ metadata = metadata(clientRecordId),
+ ),
+ ),
+ )
+
+ override suspend fun writeRespiratoryRate(
+ time: Instant,
+ breathsPerMinute: Double,
+ clientRecordId: String?,
+ ): HealthConnectWriteResult = insert(
+ HealthConnectDataType.RespiratoryRate,
+ listOf(
+ RespiratoryRateRecord(
+ time = time,
+ zoneOffset = null,
+ rate = breathsPerMinute,
+ metadata = metadata(clientRecordId),
+ ),
+ ),
+ )
+
+ override suspend fun writeRestingHeartRate(
+ time: Instant,
+ beatsPerMinute: Long,
+ clientRecordId: String?,
+ ): HealthConnectWriteResult = insert(
+ HealthConnectDataType.RestingHeartRate,
+ listOf(
+ RestingHeartRateRecord(
+ time = time,
+ zoneOffset = null,
+ beatsPerMinute = beatsPerMinute,
+ metadata = metadata(clientRecordId),
+ ),
+ ),
+ )
+
+ override suspend fun writeSleep(
+ startTime: Instant,
+ endTime: Instant,
+ title: String?,
+ notes: String?,
+ stages: List,
+ clientRecordId: String?,
+ ): HealthConnectWriteResult = insert(
+ HealthConnectDataType.Sleep,
+ listOf(
+ SleepSessionRecord(
+ startTime = startTime,
+ startZoneOffset = null,
+ endTime = endTime,
+ endZoneOffset = null,
+ metadata = metadata(clientRecordId),
+ title = title,
+ notes = notes,
+ stages = stages,
+ ),
+ ),
+ )
+
+ override suspend fun writeSteps(
+ startTime: Instant,
+ endTime: Instant,
+ count: Long,
+ clientRecordId: String?,
+ ): HealthConnectWriteResult = insert(
+ HealthConnectDataType.Steps,
+ listOf(
+ StepsRecord(
+ startTime = startTime,
+ startZoneOffset = null,
+ endTime = endTime,
+ endZoneOffset = null,
+ count = count,
+ metadata = metadata(clientRecordId),
+ ),
+ ),
+ )
+
+ override suspend fun writeTotalCaloriesBurned(
+ startTime: Instant,
+ endTime: Instant,
+ kilocalories: Double,
+ clientRecordId: String?,
+ ): HealthConnectWriteResult = insert(
+ HealthConnectDataType.TotalCaloriesBurned,
+ listOf(
+ TotalCaloriesBurnedRecord(
+ startTime = startTime,
+ startZoneOffset = null,
+ endTime = endTime,
+ endZoneOffset = null,
+ energy = Energy.kilocalories(kilocalories),
+ metadata = metadata(clientRecordId),
+ ),
+ ),
+ )
+
+ override suspend fun writeVo2Max(
+ time: Instant,
+ millilitersPerMinuteKilogram: Double,
+ clientRecordId: String?,
+ ): HealthConnectWriteResult = insert(
+ HealthConnectDataType.Vo2Max,
+ listOf(
+ Vo2MaxRecord(
+ time = time,
+ zoneOffset = null,
+ vo2MillilitersPerMinuteKilogram = millilitersPerMinuteKilogram,
+ measurementMethod = Vo2MaxRecord.MEASUREMENT_METHOD_OTHER,
+ metadata = metadata(clientRecordId),
+ ),
+ ),
+ )
+
+ override suspend fun writeWeight(
+ time: Instant,
+ kilograms: Double,
+ clientRecordId: String?,
+ ): HealthConnectWriteResult = insert(
+ HealthConnectDataType.Weight,
+ listOf(
+ WeightRecord(
+ time = time,
+ zoneOffset = null,
+ weight = Mass.kilograms(kilograms),
+ metadata = metadata(clientRecordId),
+ ),
+ ),
+ )
+
+ override suspend fun writeSpeed(
+ startTime: Instant,
+ endTime: Instant,
+ samples: List,
+ clientRecordId: String?,
+ ): HealthConnectWriteResult {
+ if (samples.isEmpty()) {
+ return HealthConnectWriteResult.InvalidPayload("speed requires at least one sample")
+ }
+ return insert(
+ HealthConnectDataType.Speed,
+ listOf(
+ SpeedRecord(
+ startTime = startTime,
+ startZoneOffset = null,
+ endTime = endTime,
+ endZoneOffset = null,
+ samples = samples,
+ metadata = metadata(clientRecordId),
+ ),
+ ),
+ )
+ }
+
+ override suspend fun writePower(
+ startTime: Instant,
+ endTime: Instant,
+ samples: List,
+ clientRecordId: String?,
+ ): HealthConnectWriteResult {
+ if (samples.isEmpty()) {
+ return HealthConnectWriteResult.InvalidPayload("power requires at least one sample")
+ }
+ return insert(
+ HealthConnectDataType.Power,
+ listOf(
+ PowerRecord(
+ startTime = startTime,
+ startZoneOffset = null,
+ endTime = endTime,
+ endZoneOffset = null,
+ samples = samples,
+ metadata = metadata(clientRecordId),
+ ),
+ ),
+ )
+ }
+
+ override suspend fun writeCyclingPedalingCadence(
+ startTime: Instant,
+ endTime: Instant,
+ samples: List,
+ clientRecordId: String?,
+ ): HealthConnectWriteResult {
+ if (samples.isEmpty()) {
+ return HealthConnectWriteResult.InvalidPayload(
+ "cycling_pedaling_cadence requires at least one sample",
+ )
+ }
+ return insert(
+ HealthConnectDataType.CyclingPedalingCadence,
+ listOf(
+ CyclingPedalingCadenceRecord(
+ startTime = startTime,
+ startZoneOffset = null,
+ endTime = endTime,
+ endZoneOffset = null,
+ samples = samples,
+ metadata = metadata(clientRecordId),
+ ),
+ ),
+ )
+ }
+
+ private suspend fun insert(dataType: HealthConnectDataType, records: List): HealthConnectWriteResult {
+ val client = clientProvider.get()
+ ?: return HealthConnectWriteResult.Unavailable
+
+ val granted = runCatching {
+ client.permissionController.getGrantedPermissions()
+ }.getOrElse { error ->
+ Timber.w(error, "Failed to read granted Health Connect permissions")
+ return HealthConnectWriteResult.Failure(error)
+ }
+ val writePermission = HealthPermission.getWritePermission(dataType.recordClass)
+ if (writePermission !in granted) {
+ return HealthConnectWriteResult.MissingPermission(writePermission)
+ }
+
+ return withContext(Dispatchers.IO) {
+ runCatching { client.insertRecords(records) }
+ .fold(
+ onSuccess = { response -> HealthConnectWriteResult.Success(response.recordIdsList) },
+ onFailure = { error ->
+ Timber.w(error, "Health Connect insertRecords failed for ${dataType.key}")
+ HealthConnectWriteResult.Failure(error)
+ },
+ )
+ }
+ }
+
+ /**
+ * Build a manual-entry [Metadata] tagged with [clientRecordId] when the caller supplied
+ * one, otherwise an empty manual-entry record. Manual-entry is the right
+ * `RECORDING_METHOD_*` for HA-driven writes since the value originated outside HC and
+ * was not auto-captured by a sensor.
+ */
+ private fun metadata(clientRecordId: String?): Metadata = if (clientRecordId.isNullOrBlank()) {
+ Metadata.manualEntry()
+ } else {
+ Metadata.manualEntry(clientRecordId = clientRecordId)
+ }
+}
diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/sensors/healthconnect/HealthConnectWriteResult.kt b/app/src/main/kotlin/io/homeassistant/companion/android/sensors/healthconnect/HealthConnectWriteResult.kt
new file mode 100644
index 00000000000..41c93f69b1c
--- /dev/null
+++ b/app/src/main/kotlin/io/homeassistant/companion/android/sensors/healthconnect/HealthConnectWriteResult.kt
@@ -0,0 +1,25 @@
+package io.homeassistant.companion.android.sensors.healthconnect
+
+/**
+ * Outcome of a Health Connect write attempt.
+ *
+ * Modeled as a sealed class (rather than throwing) so callers — primarily
+ * [io.homeassistant.companion.android.sensors.healthconnect.command.HealthConnectWriteCommandHandler] —
+ * can branch on the failure mode and surface a user-meaningful notification without
+ * unwrapping exceptions across coroutine boundaries.
+ */
+sealed class HealthConnectWriteResult {
+ data class Success(val insertedIds: List) : HealthConnectWriteResult()
+
+ /** Health Connect is not installed or the SDK reports unavailable on this device. */
+ object Unavailable : HealthConnectWriteResult()
+
+ /** The user has not granted the WRITE permission for this data type. */
+ data class MissingPermission(val permission: String) : HealthConnectWriteResult()
+
+ /** The payload was rejected before any HC client call was attempted. */
+ data class InvalidPayload(val reason: String) : HealthConnectWriteResult()
+
+ /** The HC client itself rejected or failed the call. */
+ data class Failure(val cause: Throwable) : HealthConnectWriteResult()
+}
diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/sensors/healthconnect/command/HealthConnectUnitConversion.kt b/app/src/main/kotlin/io/homeassistant/companion/android/sensors/healthconnect/command/HealthConnectUnitConversion.kt
new file mode 100644
index 00000000000..2955b568a56
--- /dev/null
+++ b/app/src/main/kotlin/io/homeassistant/companion/android/sensors/healthconnect/command/HealthConnectUnitConversion.kt
@@ -0,0 +1,177 @@
+package io.homeassistant.companion.android.sensors.healthconnect.command
+
+import androidx.health.connect.client.units.BloodGlucose
+import androidx.health.connect.client.units.Energy
+import androidx.health.connect.client.units.Length
+import androidx.health.connect.client.units.Mass
+import androidx.health.connect.client.units.Power
+import androidx.health.connect.client.units.Temperature
+import androidx.health.connect.client.units.Volume
+import io.homeassistant.companion.android.sensors.healthconnect.HealthConnectDataType
+
+/**
+ * Converts a `command_health_connect_write` payload's `(value, unit)` pair into the
+ * canonical-unit double the [io.homeassistant.companion.android.sensors.healthconnect.HealthConnectWriteRepository]
+ * expects per data type.
+ *
+ * The conversion math itself is delegated to Health Connect's own unit classes
+ * (`Mass.pounds(...).inKilograms`, `Temperature.fahrenheit(...).inCelsius`, etc.) — that
+ * way we don't keep a private copy of conversion constants that could drift from the
+ * SDK's source of truth, and we automatically pick up any precision tweaks HC ships.
+ *
+ * If `unit` is null or blank the value is returned unchanged (caller's responsibility
+ * to send the canonical unit). Unknown unit strings raise
+ * [HealthConnectWriteCommandPayload.InvalidPayloadException] so a typo in an automation
+ * surfaces as a clear notification rather than a silently-wrong record in HC.
+ */
+internal object HealthConnectUnitConversion {
+
+ fun toCanonical(dataType: HealthConnectDataType, value: Double, unit: String?): Double {
+ if (unit.isNullOrBlank()) return value
+ val key = unit.trim().lowercase().replace("/", "_per_").replace(" ", "")
+ val converter = converters[dataType]
+ ?: throw HealthConnectWriteCommandPayload.InvalidPayloadException(
+ "Data type ${dataType.key} does not accept a unit field",
+ )
+ return converter[key]?.invoke(value)
+ ?: throw HealthConnectWriteCommandPayload.InvalidPayloadException(
+ "Unknown unit '$unit' for ${dataType.key}. Accepted: ${converter.keys.sorted()}",
+ )
+ }
+
+ private val mass: Map Double> = mapOf(
+ "kg" to { it },
+ "kilograms" to { it },
+ "kilogram" to { it },
+ "g" to { Mass.grams(it).inKilograms },
+ "grams" to { Mass.grams(it).inKilograms },
+ "gram" to { Mass.grams(it).inKilograms },
+ "mg" to { Mass.milligrams(it).inKilograms },
+ "milligrams" to { Mass.milligrams(it).inKilograms },
+ "lb" to { Mass.pounds(it).inKilograms },
+ "lbs" to { Mass.pounds(it).inKilograms },
+ "pound" to { Mass.pounds(it).inKilograms },
+ "pounds" to { Mass.pounds(it).inKilograms },
+ "oz" to { Mass.ounces(it).inKilograms },
+ "ounce" to { Mass.ounces(it).inKilograms },
+ "ounces" to { Mass.ounces(it).inKilograms },
+ )
+
+ private val length: Map Double> = mapOf(
+ "m" to { it },
+ "meter" to { it },
+ "meters" to { it },
+ "metres" to { it },
+ "km" to { Length.kilometers(it).inMeters },
+ "kilometer" to { Length.kilometers(it).inMeters },
+ "kilometers" to { Length.kilometers(it).inMeters },
+ "cm" to { it / 100.0 },
+ "centimeter" to { it / 100.0 },
+ "centimeters" to { it / 100.0 },
+ "centimetre" to { it / 100.0 },
+ "centimetres" to { it / 100.0 },
+ "mi" to { Length.miles(it).inMeters },
+ "mile" to { Length.miles(it).inMeters },
+ "miles" to { Length.miles(it).inMeters },
+ "ft" to { Length.feet(it).inMeters },
+ "foot" to { Length.feet(it).inMeters },
+ "feet" to { Length.feet(it).inMeters },
+ "in" to { Length.inches(it).inMeters },
+ "inch" to { Length.inches(it).inMeters },
+ "inches" to { Length.inches(it).inMeters },
+ )
+
+ private val temperatureCelsius: Map Double> = mapOf(
+ "c" to { it },
+ "°c" to { it },
+ "celsius" to { it },
+ "f" to { Temperature.fahrenheit(it).inCelsius },
+ "°f" to { Temperature.fahrenheit(it).inCelsius },
+ "fahrenheit" to { Temperature.fahrenheit(it).inCelsius },
+ )
+
+ private val bloodGlucose: Map Double> = mapOf(
+ "mmol_per_l" to { it },
+ "mmol/l" to { it },
+ "mmoll" to { it },
+ "mg_per_dl" to { BloodGlucose.milligramsPerDeciliter(it).inMillimolesPerLiter },
+ "mg/dl" to { BloodGlucose.milligramsPerDeciliter(it).inMillimolesPerLiter },
+ "mgdl" to { BloodGlucose.milligramsPerDeciliter(it).inMillimolesPerLiter },
+ )
+
+ private val volume: Map Double> = mapOf(
+ "l" to { it },
+ "liter" to { it },
+ "liters" to { it },
+ "litre" to { it },
+ "litres" to { it },
+ "ml" to { Volume.milliliters(it).inLiters },
+ "milliliter" to { Volume.milliliters(it).inLiters },
+ "milliliters" to { Volume.milliliters(it).inLiters },
+ "fl_oz" to { Volume.fluidOuncesUs(it).inLiters },
+ "floz" to { Volume.fluidOuncesUs(it).inLiters },
+ "fl_oz_us" to { Volume.fluidOuncesUs(it).inLiters },
+ )
+
+ private val energy: Map Double> = mapOf(
+ "kcal" to { it },
+ "kilocalorie" to { it },
+ "kilocalories" to { it },
+ "cal" to { Energy.calories(it).inKilocalories },
+ "calorie" to { Energy.calories(it).inKilocalories },
+ "calories" to { Energy.calories(it).inKilocalories },
+ "j" to { Energy.joules(it).inKilocalories },
+ "joule" to { Energy.joules(it).inKilocalories },
+ "joules" to { Energy.joules(it).inKilocalories },
+ "kj" to { Energy.kilojoules(it).inKilocalories },
+ "kilojoule" to { Energy.kilojoules(it).inKilocalories },
+ "kilojoules" to { Energy.kilojoules(it).inKilocalories },
+ )
+
+ private val power: Map Double> = mapOf(
+ "kcal_per_day" to { it },
+ "kilocalories_per_day" to { it },
+ "w" to { Power.watts(it).inKilocaloriesPerDay },
+ "watts" to { Power.watts(it).inKilocaloriesPerDay },
+ "watt" to { Power.watts(it).inKilocaloriesPerDay },
+ )
+
+ /** Percentages are stored as plain doubles; HC's `Percentage(...)` expects 0..100. */
+ private val percentage: Map Double> = mapOf(
+ "%" to { it },
+ "percent" to { it },
+ "percentage" to { it },
+ "fraction" to { it * 100.0 },
+ "ratio" to { it * 100.0 },
+ )
+
+ /** HRV is a plain Double in HC; only ms is meaningful. */
+ private val milliseconds: Map Double> = mapOf(
+ "ms" to { it },
+ "millisecond" to { it },
+ "milliseconds" to { it },
+ "s" to { it * 1000.0 },
+ "second" to { it * 1000.0 },
+ "seconds" to { it * 1000.0 },
+ )
+
+ private val converters: Map Double>> = mapOf(
+ HealthConnectDataType.Weight to mass,
+ HealthConnectDataType.BodyWaterMass to mass,
+ HealthConnectDataType.BoneMass to mass,
+ HealthConnectDataType.LeanBodyMass to mass,
+ HealthConnectDataType.Height to length,
+ HealthConnectDataType.Distance to length,
+ HealthConnectDataType.ElevationGained to length,
+ HealthConnectDataType.BodyTemperature to temperatureCelsius,
+ HealthConnectDataType.BasalBodyTemperature to temperatureCelsius,
+ HealthConnectDataType.BloodGlucose to bloodGlucose,
+ HealthConnectDataType.Hydration to volume,
+ HealthConnectDataType.ActiveCaloriesBurned to energy,
+ HealthConnectDataType.TotalCaloriesBurned to energy,
+ HealthConnectDataType.BasalMetabolicRate to power,
+ HealthConnectDataType.BodyFat to percentage,
+ HealthConnectDataType.OxygenSaturation to percentage,
+ HealthConnectDataType.HeartRateVariability to milliseconds,
+ )
+}
diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/sensors/healthconnect/command/HealthConnectWriteCommandHandler.kt b/app/src/main/kotlin/io/homeassistant/companion/android/sensors/healthconnect/command/HealthConnectWriteCommandHandler.kt
new file mode 100644
index 00000000000..0345d5c3b43
--- /dev/null
+++ b/app/src/main/kotlin/io/homeassistant/companion/android/sensors/healthconnect/command/HealthConnectWriteCommandHandler.kt
@@ -0,0 +1,258 @@
+package io.homeassistant.companion.android.sensors.healthconnect.command
+
+import androidx.health.connect.client.records.CyclingPedalingCadenceRecord
+import androidx.health.connect.client.records.PowerRecord
+import androidx.health.connect.client.records.SpeedRecord
+import androidx.health.connect.client.units.Power as HcPower
+import androidx.health.connect.client.units.Velocity
+import io.homeassistant.companion.android.sensors.healthconnect.HealthConnectDataType
+import io.homeassistant.companion.android.sensors.healthconnect.HealthConnectWriteRepository
+import io.homeassistant.companion.android.sensors.healthconnect.HealthConnectWriteResult
+import java.time.Instant
+import javax.inject.Inject
+import kotlin.time.Clock
+import kotlin.time.ExperimentalTime
+import kotlin.time.toJavaInstant
+import timber.log.Timber
+
+/**
+ * Translates `command_health_connect_write` FCM payloads into typed
+ * [HealthConnectWriteRepository] calls.
+ *
+ * Lives separately from [io.homeassistant.companion.android.notifications.MessagingManager] to
+ * keep that already-large class from growing further and to make the parsing logic unit-testable
+ * without standing up the messaging stack.
+ *
+ * The handler never throws: parse / dispatch failures are returned as
+ * [HealthConnectWriteResult] so [MessagingManager] can decide whether to surface a notification,
+ * log, or both. Raw payload values (which may be PHI like weights or glucose levels) are
+ * deliberately omitted from log messages — only the data type key and outcome are recorded.
+ */
+class HealthConnectWriteCommandHandler @Inject constructor(
+ private val repository: HealthConnectWriteRepository,
+ @OptIn(ExperimentalTime::class)
+ private val clock: Clock,
+) {
+ @OptIn(ExperimentalTime::class)
+ suspend fun handle(data: Map): HealthConnectWriteResult {
+ val now = clock.now().toJavaInstant()
+ val payload = try {
+ HealthConnectWriteCommandPayload.parse(data, now)
+ } catch (e: HealthConnectWriteCommandPayload.InvalidPayloadException) {
+ Timber.w("Rejecting health_connect_write payload: ${e.message}")
+ return HealthConnectWriteResult.InvalidPayload(e.message ?: "Invalid payload")
+ }
+ val result = dispatch(payload)
+ when (result) {
+ is HealthConnectWriteResult.Success -> Timber.d(
+ "Wrote ${payload.dataType.key} (${result.insertedIds.size} record(s))",
+ )
+ is HealthConnectWriteResult.MissingPermission -> Timber.w(
+ "health_connect_write blocked, missing permission: ${result.permission}",
+ )
+ is HealthConnectWriteResult.Unavailable -> Timber.w(
+ "health_connect_write skipped — Health Connect unavailable on this device",
+ )
+ is HealthConnectWriteResult.InvalidPayload -> Timber.w(
+ "health_connect_write rejected: ${result.reason}",
+ )
+ is HealthConnectWriteResult.Failure -> Timber.w(
+ result.cause,
+ "health_connect_write failed for ${payload.dataType.key}",
+ )
+ }
+ return result
+ }
+
+ private suspend fun dispatch(payload: HealthConnectWriteCommandPayload): HealthConnectWriteResult = when (payload) {
+ is HealthConnectWriteCommandPayload.Instantaneous -> dispatchInstantaneous(payload)
+ is HealthConnectWriteCommandPayload.Interval -> dispatchInterval(payload)
+ is HealthConnectWriteCommandPayload.BloodPressure -> repository.writeBloodPressure(
+ time = payload.time,
+ systolicMmHg = payload.systolic,
+ diastolicMmHg = payload.diastolic,
+ clientRecordId = payload.clientRecordId,
+ )
+ is HealthConnectWriteCommandPayload.HeartRate -> repository.writeHeartRate(
+ startTime = payload.startTime,
+ endTime = payload.endTime,
+ samples = payload.samples,
+ clientRecordId = payload.clientRecordId,
+ )
+ is HealthConnectWriteCommandPayload.Sleep -> repository.writeSleep(
+ startTime = payload.startTime,
+ endTime = payload.endTime,
+ title = payload.title,
+ notes = payload.notes,
+ stages = payload.stages,
+ clientRecordId = payload.clientRecordId,
+ )
+ is HealthConnectWriteCommandPayload.ExerciseSession -> repository.writeExerciseSession(
+ startTime = payload.startTime,
+ endTime = payload.endTime,
+ exerciseType = payload.exerciseType,
+ title = payload.title,
+ notes = payload.notes,
+ clientRecordId = payload.clientRecordId,
+ )
+ is HealthConnectWriteCommandPayload.Series -> dispatchSeries(payload)
+ }
+
+ /**
+ * Translate the generic numeric series payload into the right HC typed series record.
+ * Each branch picks the matching unit factory so HC stores in its canonical unit
+ * (m/s for Velocity, watts for Power, raw double for cadence rpm).
+ */
+ private suspend fun dispatchSeries(payload: HealthConnectWriteCommandPayload.Series): HealthConnectWriteResult {
+ val unit = payload.unit?.lowercase()?.trim()
+ return when (payload.dataType) {
+ HealthConnectDataType.Speed -> {
+ val factory: (Double) -> Velocity = when (unit) {
+ null, "m_per_s", "m/s", "mps" -> Velocity::metersPerSecond
+ "km_per_h", "km/h", "kph" -> Velocity::kilometersPerHour
+ "mi_per_h", "mph", "mi/h" -> Velocity::milesPerHour
+ else -> return HealthConnectWriteResult.InvalidPayload(
+ "Unknown speed unit '${payload.unit}'. Accepted: m/s, km/h, mph",
+ )
+ }
+ repository.writeSpeed(
+ startTime = payload.startTime,
+ endTime = payload.endTime,
+ samples = payload.samples.map { SpeedRecord.Sample(it.time, factory(it.value)) },
+ clientRecordId = payload.clientRecordId,
+ )
+ }
+ HealthConnectDataType.Power -> {
+ val factory: (Double) -> HcPower = when (unit) {
+ null, "w", "watts", "watt" -> HcPower::watts
+ "kcal_per_day", "kilocalories_per_day" -> HcPower::kilocaloriesPerDay
+ else -> return HealthConnectWriteResult.InvalidPayload(
+ "Unknown power unit '${payload.unit}'. Accepted: W, kcal/day",
+ )
+ }
+ repository.writePower(
+ startTime = payload.startTime,
+ endTime = payload.endTime,
+ samples = payload.samples.map { PowerRecord.Sample(it.time, factory(it.value)) },
+ clientRecordId = payload.clientRecordId,
+ )
+ }
+ HealthConnectDataType.CyclingPedalingCadence -> {
+ if (unit != null && unit !in setOf("rpm", "revolutions_per_minute", "revs_per_minute")) {
+ return HealthConnectWriteResult.InvalidPayload(
+ "Unknown cadence unit '${payload.unit}'. Accepted: rpm",
+ )
+ }
+ repository.writeCyclingPedalingCadence(
+ startTime = payload.startTime,
+ endTime = payload.endTime,
+ samples = payload.samples.map {
+ CyclingPedalingCadenceRecord.Sample(it.time, it.value)
+ },
+ clientRecordId = payload.clientRecordId,
+ )
+ }
+ else -> HealthConnectWriteResult.InvalidPayload(
+ "Series payload not supported for ${payload.dataType.key}",
+ )
+ }
+ }
+
+ private suspend fun dispatchInstantaneous(
+ payload: HealthConnectWriteCommandPayload.Instantaneous,
+ ): HealthConnectWriteResult {
+ val time: Instant = payload.time
+ val crid = payload.clientRecordId
+ val v = try {
+ HealthConnectUnitConversion.toCanonical(payload.dataType, payload.value, payload.unit)
+ } catch (e: HealthConnectWriteCommandPayload.InvalidPayloadException) {
+ return HealthConnectWriteResult.InvalidPayload(e.message ?: "Invalid unit")
+ }
+ return when (payload.dataType) {
+ HealthConnectDataType.BasalBodyTemperature ->
+ repository.writeBasalBodyTemperature(time, v, crid)
+ HealthConnectDataType.BasalMetabolicRate ->
+ repository.writeBasalMetabolicRate(time, v, crid)
+ HealthConnectDataType.BloodGlucose ->
+ repository.writeBloodGlucose(time, v, crid)
+ HealthConnectDataType.BodyFat ->
+ repository.writeBodyFat(time, v, crid)
+ HealthConnectDataType.BodyTemperature ->
+ repository.writeBodyTemperature(time, v, crid)
+ HealthConnectDataType.BodyWaterMass ->
+ repository.writeBodyWaterMass(time, v, crid)
+ HealthConnectDataType.BoneMass ->
+ repository.writeBoneMass(time, v, crid)
+ HealthConnectDataType.HeartRateVariability ->
+ repository.writeHeartRateVariability(time, v, crid)
+ HealthConnectDataType.Height ->
+ repository.writeHeight(time, v, crid)
+ HealthConnectDataType.LeanBodyMass ->
+ repository.writeLeanBodyMass(time, v, crid)
+ HealthConnectDataType.OxygenSaturation ->
+ repository.writeOxygenSaturation(time, v, crid)
+ HealthConnectDataType.RespiratoryRate ->
+ repository.writeRespiratoryRate(time, v, crid)
+ HealthConnectDataType.RestingHeartRate ->
+ requireIntegral(v)?.let { bpm ->
+ repository.writeRestingHeartRate(time, bpm, crid)
+ } ?: HealthConnectWriteResult.InvalidPayload(
+ "resting_heart_rate must be an integer BPM (got $v)",
+ )
+ HealthConnectDataType.Vo2Max ->
+ repository.writeVo2Max(time, v, crid)
+ HealthConnectDataType.Weight ->
+ repository.writeWeight(time, v, crid)
+ else -> HealthConnectWriteResult.InvalidPayload(
+ "Data type ${payload.dataType.key} is not instantaneous",
+ )
+ }
+ }
+
+ private suspend fun dispatchInterval(
+ payload: HealthConnectWriteCommandPayload.Interval,
+ ): HealthConnectWriteResult {
+ val start = payload.startTime
+ val end = payload.endTime
+ val crid = payload.clientRecordId
+ val v = try {
+ HealthConnectUnitConversion.toCanonical(payload.dataType, payload.value, payload.unit)
+ } catch (e: HealthConnectWriteCommandPayload.InvalidPayloadException) {
+ return HealthConnectWriteResult.InvalidPayload(e.message ?: "Invalid unit")
+ }
+ return when (payload.dataType) {
+ HealthConnectDataType.ActiveCaloriesBurned ->
+ repository.writeActiveCaloriesBurned(start, end, v, crid)
+ HealthConnectDataType.Distance ->
+ repository.writeDistance(start, end, v, crid)
+ HealthConnectDataType.ElevationGained ->
+ repository.writeElevationGained(start, end, v, crid)
+ HealthConnectDataType.FloorsClimbed ->
+ repository.writeFloorsClimbed(start, end, v, crid)
+ HealthConnectDataType.Hydration ->
+ repository.writeHydration(start, end, v, crid)
+ HealthConnectDataType.Steps ->
+ requireIntegral(v)?.let { count ->
+ repository.writeSteps(start, end, count, crid)
+ } ?: HealthConnectWriteResult.InvalidPayload(
+ "steps must be an integer count (got $v)",
+ )
+ HealthConnectDataType.TotalCaloriesBurned ->
+ repository.writeTotalCaloriesBurned(start, end, v, crid)
+ else -> HealthConnectWriteResult.InvalidPayload(
+ "Data type ${payload.dataType.key} is not interval",
+ )
+ }
+ }
+
+ /**
+ * Returns [value] as a Long if it has no fractional component, otherwise null.
+ * Used to keep silent truncation out of integer-typed HC fields (steps, BPM, etc.) —
+ * a payload like `1234.9` should be rejected, not stored as `1234`.
+ */
+ private fun requireIntegral(value: Double): Long? {
+ if (value.isNaN() || value.isInfinite()) return null
+ val rounded = value.toLong()
+ return if (rounded.toDouble() == value) rounded else null
+ }
+}
diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/sensors/healthconnect/command/HealthConnectWriteCommandPayload.kt b/app/src/main/kotlin/io/homeassistant/companion/android/sensors/healthconnect/command/HealthConnectWriteCommandPayload.kt
new file mode 100644
index 00000000000..925316d9a52
--- /dev/null
+++ b/app/src/main/kotlin/io/homeassistant/companion/android/sensors/healthconnect/command/HealthConnectWriteCommandPayload.kt
@@ -0,0 +1,373 @@
+package io.homeassistant.companion.android.sensors.healthconnect.command
+
+import androidx.health.connect.client.records.ExerciseSessionRecord
+import androidx.health.connect.client.records.HeartRateRecord
+import androidx.health.connect.client.records.SleepSessionRecord
+import io.homeassistant.companion.android.sensors.healthconnect.HealthConnectDataType
+import io.homeassistant.companion.android.sensors.healthconnect.HealthConnectExerciseTypes
+import java.time.Instant
+import java.time.OffsetDateTime
+import java.time.format.DateTimeParseException
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonNamingStrategy
+
+/**
+ * Parsed, validated form of an FCM `command_health_connect_write` payload.
+ *
+ * The wire format is a flat `Map` (FCM data fields are always strings),
+ * so all coercion happens in [parse]. Numeric / temporal fields are reported as
+ * [InvalidPayloadException] rather than swallowed — the handler turns those into a
+ * user-visible notification so that broken automations are debuggable.
+ *
+ * Sub-payloads for sample-based records (heart rate, sleep) are encoded as JSON arrays
+ * inside their own field (`samples` / `stages`) — this keeps the rest of the payload
+ * comfortably flat for HA template authors.
+ */
+sealed class HealthConnectWriteCommandPayload {
+ abstract val dataType: HealthConnectDataType
+ abstract val clientRecordId: String?
+
+ data class Instantaneous(
+ override val dataType: HealthConnectDataType,
+ override val clientRecordId: String?,
+ val time: Instant,
+ val value: Double,
+ val unit: String?,
+ ) : HealthConnectWriteCommandPayload()
+
+ data class Interval(
+ override val dataType: HealthConnectDataType,
+ override val clientRecordId: String?,
+ val startTime: Instant,
+ val endTime: Instant,
+ val value: Double,
+ val unit: String?,
+ ) : HealthConnectWriteCommandPayload()
+
+ data class BloodPressure(
+ override val dataType: HealthConnectDataType,
+ override val clientRecordId: String?,
+ val time: Instant,
+ val systolic: Double,
+ val diastolic: Double,
+ ) : HealthConnectWriteCommandPayload()
+
+ data class HeartRate(
+ override val dataType: HealthConnectDataType,
+ override val clientRecordId: String?,
+ val startTime: Instant,
+ val endTime: Instant,
+ val samples: List,
+ ) : HealthConnectWriteCommandPayload()
+
+ data class Sleep(
+ override val dataType: HealthConnectDataType,
+ override val clientRecordId: String?,
+ val startTime: Instant,
+ val endTime: Instant,
+ val title: String?,
+ val notes: String?,
+ val stages: List,
+ ) : HealthConnectWriteCommandPayload()
+
+ data class ExerciseSession(
+ override val dataType: HealthConnectDataType,
+ override val clientRecordId: String?,
+ val startTime: Instant,
+ val endTime: Instant,
+ val exerciseType: Int,
+ val title: String?,
+ val notes: String?,
+ ) : HealthConnectWriteCommandPayload()
+
+ /**
+ * Series record carrying numeric samples over a window. Backs writes for [HealthConnectDataType.Speed],
+ * [HealthConnectDataType.Power], and [HealthConnectDataType.CyclingPedalingCadence] —
+ * the parser produces this for any of those three data types so the handler can switch
+ * on `dataType` and pick the right typed unit at dispatch time. The `value` units the
+ * payload's `unit` field nominates (m_per_s for speed, watts for power, rpm for
+ * cadence) get converted to canonical form there.
+ */
+ data class Series(
+ override val dataType: HealthConnectDataType,
+ override val clientRecordId: String?,
+ val startTime: Instant,
+ val endTime: Instant,
+ val samples: List,
+ val unit: String?,
+ ) : HealthConnectWriteCommandPayload() {
+ data class Sample(val time: Instant, val value: Double)
+ }
+
+ class InvalidPayloadException(message: String) : IllegalArgumentException(message)
+
+ companion object {
+ const val FIELD_DATA_TYPE = "data_type"
+ const val FIELD_VALUE = "value"
+ const val FIELD_TIME = "time"
+ const val FIELD_START_TIME = "start_time"
+ const val FIELD_END_TIME = "end_time"
+ const val FIELD_SYSTOLIC = "systolic"
+ const val FIELD_DIASTOLIC = "diastolic"
+ const val FIELD_CLIENT_RECORD_ID = "client_record_id"
+ const val FIELD_SAMPLES = "samples"
+ const val FIELD_STAGES = "stages"
+ const val FIELD_TITLE = "title"
+ const val FIELD_NOTES = "notes"
+ const val FIELD_UNIT = "unit"
+ const val FIELD_EXERCISE_TYPE = "exercise_type"
+
+ /**
+ * Sleep-stage string → HC integer constant. Mirrors the (`@RestrictTo`) map that
+ * `androidx.health.connect.client.records.SleepSessionRecord.STAGE_TYPE_STRING_TO_INT_MAP`
+ * exposes for library-internal use only. Kept as a local copy because the SDK
+ * doesn't expose a public alternative.
+ */
+ private val SLEEP_STAGE_NAME_TO_INT: Map = mapOf(
+ "awake" to SleepSessionRecord.STAGE_TYPE_AWAKE,
+ "sleeping" to SleepSessionRecord.STAGE_TYPE_SLEEPING,
+ "out_of_bed" to SleepSessionRecord.STAGE_TYPE_OUT_OF_BED,
+ "light" to SleepSessionRecord.STAGE_TYPE_LIGHT,
+ "deep" to SleepSessionRecord.STAGE_TYPE_DEEP,
+ "rem" to SleepSessionRecord.STAGE_TYPE_REM,
+ "awake_in_bed" to SleepSessionRecord.STAGE_TYPE_AWAKE_IN_BED,
+ "unknown" to SleepSessionRecord.STAGE_TYPE_UNKNOWN,
+ )
+
+ @OptIn(ExperimentalSerializationApi::class)
+ private val json = Json {
+ ignoreUnknownKeys = true
+ namingStrategy = JsonNamingStrategy.SnakeCase
+ }
+
+ /**
+ * Parse an FCM data map into a typed payload, or throw [InvalidPayloadException]
+ * with a human-readable reason. The exception is caught one level up so the
+ * handler can surface it as a notification instead of crashing the worker.
+ *
+ * @param now Clock-supplied "now" used when the payload omits an end time. Pulled
+ * from a parameter rather than [Instant.now] so tests are deterministic.
+ */
+ fun parse(data: Map, now: Instant): HealthConnectWriteCommandPayload {
+ val dataTypeKey = data[FIELD_DATA_TYPE]?.takeIf { it.isNotBlank() }
+ ?: throw InvalidPayloadException("Missing required field: $FIELD_DATA_TYPE")
+ val dataType = HealthConnectDataType.fromKey(dataTypeKey)
+ ?: throw InvalidPayloadException("Unknown data_type: $dataTypeKey")
+ val clientRecordId = data[FIELD_CLIENT_RECORD_ID]?.takeIf { it.isNotBlank() }
+
+ return when (dataType) {
+ HealthConnectDataType.BloodPressure -> BloodPressure(
+ dataType = dataType,
+ clientRecordId = clientRecordId,
+ time = parseInstant(data, FIELD_TIME, default = now),
+ systolic = parseDouble(data, FIELD_SYSTOLIC),
+ diastolic = parseDouble(data, FIELD_DIASTOLIC),
+ )
+ HealthConnectDataType.HeartRate -> {
+ val end = parseInstant(data, FIELD_END_TIME, default = now)
+ HeartRate(
+ dataType = dataType,
+ clientRecordId = clientRecordId,
+ startTime = parseInstant(data, FIELD_START_TIME, default = end),
+ endTime = end,
+ samples = parseHeartRateSamples(data),
+ )
+ }
+ HealthConnectDataType.Sleep -> {
+ val end = parseInstant(data, FIELD_END_TIME, default = now)
+ Sleep(
+ dataType = dataType,
+ clientRecordId = clientRecordId,
+ startTime = parseInstant(data, FIELD_START_TIME, default = end),
+ endTime = end,
+ title = data[FIELD_TITLE]?.takeIf { it.isNotBlank() },
+ notes = data[FIELD_NOTES]?.takeIf { it.isNotBlank() },
+ stages = parseSleepStages(data),
+ )
+ }
+ HealthConnectDataType.Speed,
+ HealthConnectDataType.Power,
+ HealthConnectDataType.CyclingPedalingCadence,
+ -> {
+ val end = parseInstant(data, FIELD_END_TIME, default = now)
+ Series(
+ dataType = dataType,
+ clientRecordId = clientRecordId,
+ startTime = parseInstant(data, FIELD_START_TIME, default = end),
+ endTime = end,
+ samples = parseSeriesSamples(data),
+ unit = data[FIELD_UNIT]?.takeIf { it.isNotBlank() },
+ )
+ }
+ HealthConnectDataType.ExerciseSession -> {
+ val end = parseInstant(data, FIELD_END_TIME, default = now)
+ ExerciseSession(
+ dataType = dataType,
+ clientRecordId = clientRecordId,
+ startTime = parseInstant(data, FIELD_START_TIME, default = end),
+ endTime = end,
+ exerciseType = parseExerciseType(data),
+ title = data[FIELD_TITLE]?.takeIf { it.isNotBlank() },
+ notes = data[FIELD_NOTES]?.takeIf { it.isNotBlank() },
+ )
+ }
+ else -> if (dataType in INTERVAL_TYPES) {
+ val end = parseInstant(data, FIELD_END_TIME, default = now)
+ Interval(
+ dataType = dataType,
+ clientRecordId = clientRecordId,
+ startTime = parseInstant(data, FIELD_START_TIME, default = end),
+ endTime = end,
+ value = parseDouble(data, FIELD_VALUE),
+ unit = data[FIELD_UNIT]?.takeIf { it.isNotBlank() },
+ )
+ } else {
+ Instantaneous(
+ dataType = dataType,
+ clientRecordId = clientRecordId,
+ time = parseInstant(data, FIELD_TIME, default = now),
+ value = parseDouble(data, FIELD_VALUE),
+ unit = data[FIELD_UNIT]?.takeIf { it.isNotBlank() },
+ )
+ }
+ }
+ }
+
+ private val INTERVAL_TYPES = setOf(
+ HealthConnectDataType.ActiveCaloriesBurned,
+ HealthConnectDataType.Distance,
+ HealthConnectDataType.ElevationGained,
+ HealthConnectDataType.FloorsClimbed,
+ HealthConnectDataType.Hydration,
+ HealthConnectDataType.Steps,
+ HealthConnectDataType.TotalCaloriesBurned,
+ )
+
+ private fun parseDouble(data: Map, field: String): Double {
+ val raw = data[field] ?: throw InvalidPayloadException("Missing required field: $field")
+ return raw.toDoubleOrNull()
+ ?: throw InvalidPayloadException("Field $field must be a number, got: $raw")
+ }
+
+ private fun parseInstant(data: Map, field: String, default: Instant): Instant {
+ val raw = data[field]?.takeIf { it.isNotBlank() } ?: return default
+ return parseIsoInstant(raw)
+ ?: throw InvalidPayloadException("Field $field must be ISO-8601 instant, got: $raw")
+ }
+
+ /**
+ * Parse an ISO-8601 timestamp accepting both `...Z` (what [Instant.parse] requires)
+ * and `...+HH:MM` offset forms. Home Assistant's `now().isoformat()` Jinja helper
+ * emits the offset form by default, so the strict [Instant.parse] alone would
+ * reject every payload built from the obvious template.
+ */
+ private fun parseIsoInstant(raw: String): Instant? {
+ return try {
+ Instant.parse(raw)
+ } catch (_: DateTimeParseException) {
+ try {
+ OffsetDateTime.parse(raw).toInstant()
+ } catch (_: DateTimeParseException) {
+ null
+ }
+ }
+ }
+
+ /**
+ * Parse the `exercise_type` field. Accepts either an int (the raw HC
+ * `EXERCISE_TYPE_*` constant) or a string slug like "running" / "biking" — the
+ * SDK already exposes the slug ↔ int map publicly via
+ * [ExerciseSessionRecord.EXERCISE_TYPE_STRING_TO_INT_MAP], so we just route through
+ * it. Defaults to `EXERCISE_TYPE_OTHER_WORKOUT` when missing, since "we did
+ * something" is more accurate than rejecting the whole payload for a typo.
+ */
+ private fun parseExerciseType(data: Map): Int {
+ val raw = data[FIELD_EXERCISE_TYPE]?.takeIf { it.isNotBlank() }
+ ?: return ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT
+ raw.toIntOrNull()?.let { return it }
+ return HealthConnectExerciseTypes.SLUG_TO_INT[raw.lowercase()]
+ ?: throw InvalidPayloadException(
+ "Unknown exercise_type '$raw'. Expected an int constant or one of: " +
+ HealthConnectExerciseTypes.SLUG_TO_INT.keys.sorted(),
+ )
+ }
+
+ /**
+ * Parse a generic numeric series payload: `samples = "[{time, value}, ...]"`.
+ * Reused for Speed / Power / CyclingPedalingCadence — each gets its unit
+ * conversion applied at dispatch time once the canonical type is known.
+ */
+ private fun parseSeriesSamples(data: Map): List {
+ val raw = data[FIELD_SAMPLES]
+ ?: throw InvalidPayloadException("Missing required field: $FIELD_SAMPLES")
+ val parsed = try {
+ json.decodeFromString>(raw)
+ } catch (e: Exception) {
+ throw InvalidPayloadException(
+ "Field $FIELD_SAMPLES must be a JSON array of {time, value}: ${e.message}",
+ )
+ }
+ if (parsed.isEmpty()) {
+ throw InvalidPayloadException("Field $FIELD_SAMPLES must contain at least one sample")
+ }
+ return parsed.map { dto ->
+ val time = parseIsoInstant(dto.time)
+ ?: throw InvalidPayloadException("Series sample time must be ISO-8601: ${dto.time}")
+ Series.Sample(time = time, value = dto.value)
+ }
+ }
+
+ private fun parseHeartRateSamples(data: Map): List {
+ val raw = data[FIELD_SAMPLES]
+ ?: throw InvalidPayloadException("Missing required field: $FIELD_SAMPLES")
+ val parsed = try {
+ json.decodeFromString>(raw)
+ } catch (e: Exception) {
+ throw InvalidPayloadException(
+ "Field $FIELD_SAMPLES must be a JSON array of {time, beats_per_minute}: ${e.message}",
+ )
+ }
+ if (parsed.isEmpty()) {
+ throw InvalidPayloadException("Field $FIELD_SAMPLES must contain at least one sample")
+ }
+ return parsed.map { dto ->
+ val time = parseIsoInstant(dto.time)
+ ?: throw InvalidPayloadException("Heart rate sample time must be ISO-8601: ${dto.time}")
+ HeartRateRecord.Sample(time = time, beatsPerMinute = dto.beatsPerMinute)
+ }
+ }
+
+ private fun parseSleepStages(data: Map): List {
+ val raw = data[FIELD_STAGES] ?: return emptyList()
+ val parsed = try {
+ json.decodeFromString>(raw)
+ } catch (e: Exception) {
+ throw InvalidPayloadException(
+ "Field $FIELD_STAGES must be a JSON array of {start_time, end_time, stage}: ${e.message}",
+ )
+ }
+ return parsed.map { dto ->
+ val start = parseIsoInstant(dto.startTime)
+ ?: throw InvalidPayloadException("Sleep stage start_time must be ISO-8601: ${dto.startTime}")
+ val end = parseIsoInstant(dto.endTime)
+ ?: throw InvalidPayloadException("Sleep stage end_time must be ISO-8601: ${dto.endTime}")
+ val stageInt = SLEEP_STAGE_NAME_TO_INT[dto.stage.lowercase()]
+ ?: throw InvalidPayloadException(
+ "Unknown sleep stage '${dto.stage}'. Expected one of: ${SLEEP_STAGE_NAME_TO_INT.keys}",
+ )
+ SleepSessionRecord.Stage(startTime = start, endTime = end, stage = stageInt)
+ }
+ }
+ }
+
+ @Serializable
+ private data class HeartRateSampleDto(val time: String, val beatsPerMinute: Long)
+
+ @Serializable
+ private data class NumericSampleDto(val time: String, val value: Double)
+
+ @Serializable
+ private data class SleepStageDto(val startTime: String, val endTime: String, val stage: String)
+}
diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsFragment.kt b/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsFragment.kt
index d547e264bae..53f1912cf88 100644
--- a/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsFragment.kt
+++ b/app/src/main/kotlin/io/homeassistant/companion/android/settings/SettingsFragment.kt
@@ -46,6 +46,7 @@ import io.homeassistant.companion.android.settings.notification.NotificationHist
import io.homeassistant.companion.android.settings.qs.ManageTilesFragment
import io.homeassistant.companion.android.settings.sensor.SensorSettingsFragment
import io.homeassistant.companion.android.settings.sensor.SensorUpdateFrequencyFragment
+import io.homeassistant.companion.android.settings.sensor.healthconnect.HealthConnectSettingsFragment
import io.homeassistant.companion.android.settings.server.ServerSettingsFragment
import io.homeassistant.companion.android.settings.shortcuts.ManageShortcutsSettingsFragment
import io.homeassistant.companion.android.settings.vehicle.ManageAndroidAutoSettingsFragment
@@ -167,6 +168,13 @@ class SettingsFragment(
return@setOnPreferenceClickListener true
}
}
+ findPreference("health_connect_settings")?.setOnPreferenceClickListener {
+ parentFragmentManager.commit {
+ replace(R.id.content, HealthConnectSettingsFragment::class.java, null)
+ addToBackStack(getString(commonR.string.health_connect_settings_title))
+ }
+ return@setOnPreferenceClickListener true
+ }
findPreference("assist_settings")?.setOnPreferenceClickListener {
parentFragmentManager.commit {
diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/settings/sensor/SensorDetailViewModel.kt b/app/src/main/kotlin/io/homeassistant/companion/android/settings/sensor/SensorDetailViewModel.kt
index 6b0ecb37fb6..9d6ef97027e 100644
--- a/app/src/main/kotlin/io/homeassistant/companion/android/settings/sensor/SensorDetailViewModel.kt
+++ b/app/src/main/kotlin/io/homeassistant/companion/android/settings/sensor/SensorDetailViewModel.kt
@@ -330,6 +330,18 @@ class SensorDetailViewModel @Inject constructor(
Timber.e(e, "Exception while requesting update for sensor $sensorId")
}
refreshSensorData()
+ // A toggle change can grow the sensor's required permission set (e.g. flipping
+ // "Allow writes from HA" on a Health Connect sensor adds the matching WRITE_*).
+ // If the sensor is currently enabled but the new perm union isn't fully
+ // granted, surface the same permission-request dialog the enable flow uses so
+ // the user doesn't have to re-tap "Enable" to get prompted.
+ sensorManager?.let { mgr ->
+ if (sensors.any { it.sensor.enabled } && !mgr.checkPermission(getApplication(), sensorId)) {
+ val perms = mgr.requiredPermissions(getApplication(), sensorId)
+ val serverId = sensors.firstOrNull { it.sensor.enabled }?.sensor?.serverId
+ permissionRequests.value = PermissionsDialog(serverId, perms)
+ }
+ }
}
}
diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/settings/sensor/healthconnect/HealthConnectSettingsFragment.kt b/app/src/main/kotlin/io/homeassistant/companion/android/settings/sensor/healthconnect/HealthConnectSettingsFragment.kt
new file mode 100644
index 00000000000..2ef3a066660
--- /dev/null
+++ b/app/src/main/kotlin/io/homeassistant/companion/android/settings/sensor/healthconnect/HealthConnectSettingsFragment.kt
@@ -0,0 +1,69 @@
+package io.homeassistant.companion.android.settings.sensor.healthconnect
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.activity.result.ActivityResultLauncher
+import androidx.compose.ui.platform.ComposeView
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import dagger.hilt.android.AndroidEntryPoint
+import io.homeassistant.companion.android.common.R as commonR
+import io.homeassistant.companion.android.common.compose.theme.HATheme
+import io.homeassistant.companion.android.sensors.HealthConnectSensorManager
+import io.homeassistant.companion.android.sensors.SensorReceiver
+import kotlinx.coroutines.launch
+
+@AndroidEntryPoint
+class HealthConnectSettingsFragment : Fragment() {
+
+ private val viewModel: HealthConnectSettingsViewModel by viewModels()
+
+ /**
+ * Activity-result launcher for the Health Connect bulk permission contract. Owned by
+ * the fragment because the contract requires an Activity host; the view-model emits
+ * the perm set on a SharedFlow which we observe and forward to this launcher.
+ */
+ private var permissionsLauncher: ActivityResultLauncher>? = null
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ HealthConnectSensorManager.getPermissionResultContract()?.let { contract ->
+ permissionsLauncher = registerForActivityResult(contract) {
+ // Trigger an immediate sensor refresh so newly-granted READ perms start
+ // pulling values right away instead of waiting for the next 15-min cycle.
+ SensorReceiver.updateAllSensors(requireContext())
+ }
+ }
+ }
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
+ return ComposeView(requireContext()).apply {
+ setContent {
+ HATheme {
+ HealthConnectSettingsScreen(viewModel = viewModel)
+ }
+ }
+ }
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ viewModel.enableAllRequested.collect { perms ->
+ permissionsLauncher?.launch(perms)
+ }
+ }
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ activity?.title = getString(commonR.string.health_connect_settings_title)
+ }
+}
diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/settings/sensor/healthconnect/HealthConnectSettingsScreen.kt b/app/src/main/kotlin/io/homeassistant/companion/android/settings/sensor/healthconnect/HealthConnectSettingsScreen.kt
new file mode 100644
index 00000000000..4dffbbe441a
--- /dev/null
+++ b/app/src/main/kotlin/io/homeassistant/companion/android/settings/sensor/healthconnect/HealthConnectSettingsScreen.kt
@@ -0,0 +1,226 @@
+package io.homeassistant.companion.android.settings.sensor.healthconnect
+
+import androidx.annotation.VisibleForTesting
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.style.TextAlign
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import io.homeassistant.companion.android.common.R as commonR
+import io.homeassistant.companion.android.common.compose.composable.HAHint
+import io.homeassistant.companion.android.common.compose.composable.HALoading
+import io.homeassistant.companion.android.common.compose.composable.HAPlainButton
+import io.homeassistant.companion.android.common.compose.composable.HASettingsCard
+import io.homeassistant.companion.android.common.compose.composable.HASwitch
+import io.homeassistant.companion.android.common.compose.theme.HADimens
+import io.homeassistant.companion.android.common.compose.theme.HARadius
+import io.homeassistant.companion.android.common.compose.theme.HATextStyle
+import io.homeassistant.companion.android.common.compose.theme.LocalHAColorScheme
+import io.homeassistant.companion.android.util.plus
+import io.homeassistant.companion.android.util.safeBottomPaddingValues
+
+@Composable
+fun HealthConnectSettingsScreen(viewModel: HealthConnectSettingsViewModel, modifier: Modifier = Modifier) {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ HealthConnectSettingsContent(
+ uiState = uiState,
+ onToggleRealtimeSync = viewModel::setRealtimeSyncEnabled,
+ onEnableAll = viewModel::enableAll,
+ modifier = modifier,
+ )
+}
+
+@Composable
+@VisibleForTesting
+internal fun HealthConnectSettingsContent(
+ uiState: HealthConnectSettingsUiState,
+ onToggleRealtimeSync: (Boolean) -> Unit,
+ onEnableAll: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var showEnableAllConfirm by remember { mutableStateOf(false) }
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(PaddingValues(all = HADimens.SPACE4) + safeBottomPaddingValues(applyHorizontal = false)),
+ verticalArrangement = Arrangement.spacedBy(HADimens.SPACE4),
+ ) {
+ when {
+ uiState.isLoading -> HALoading(modifier = Modifier.align(Alignment.CenterHorizontally))
+ !uiState.isAvailable -> HAHint(
+ text = stringResource(commonR.string.health_connect_unavailable),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ else -> {
+ RealtimeSyncSection(
+ enabled = uiState.realtimeSyncEnabled,
+ onToggle = onToggleRealtimeSync,
+ )
+ EnableAllSection(
+ inProgress = uiState.enableAllInProgress,
+ enabledCount = uiState.enabledSensorCount,
+ totalCount = uiState.totalSensorCount,
+ onClick = { showEnableAllConfirm = true },
+ )
+ }
+ }
+ }
+
+ if (showEnableAllConfirm) {
+ val haColors = LocalHAColorScheme.current
+ AlertDialog(
+ onDismissRequest = { showEnableAllConfirm = false },
+ // Use HATextStyle for title / body — both pull their color from
+ // LocalHAColorScheme.current.colorTextPrimary/Secondary, so dark mode
+ // contrast comes out right. M3 AlertDialog's default styling pulls from
+ // MaterialTheme.colorScheme which the HA theme doesn't override completely.
+ title = {
+ Text(
+ text = stringResource(commonR.string.health_connect_enable_all_confirm_title),
+ style = HATextStyle.HeadlineMedium,
+ color = haColors.colorTextPrimary,
+ )
+ },
+ text = {
+ Text(
+ text = stringResource(commonR.string.health_connect_enable_all_confirm_message),
+ style = HATextStyle.Body,
+ color = haColors.colorTextSecondary,
+ )
+ },
+ confirmButton = {
+ HAPlainButton(
+ text = stringResource(commonR.string.confirm),
+ onClick = {
+ showEnableAllConfirm = false
+ onEnableAll()
+ },
+ )
+ },
+ dismissButton = {
+ HAPlainButton(
+ text = stringResource(commonR.string.cancel),
+ onClick = { showEnableAllConfirm = false },
+ )
+ },
+ )
+ }
+}
+
+@Composable
+private fun EnableAllSection(inProgress: Boolean, enabledCount: Int, totalCount: Int, onClick: () -> Unit) {
+ val colorScheme = LocalHAColorScheme.current
+ Column(verticalArrangement = Arrangement.spacedBy(HADimens.SPACE2)) {
+ SectionHeader(text = stringResource(commonR.string.health_connect_enable_all_title))
+ HASettingsCard(
+ modifier = Modifier
+ .clip(RoundedCornerShape(HARadius.XL))
+ .clickable(role = Role.Button, enabled = !inProgress, onClick = onClick),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(HADimens.SPACE1),
+ ) {
+ Text(
+ text = stringResource(commonR.string.health_connect_enable_all_button),
+ style = HATextStyle.Body,
+ textAlign = TextAlign.Start,
+ color = colorScheme.colorTextPrimary,
+ )
+ Text(
+ text = stringResource(
+ commonR.string.health_connect_enable_all_status,
+ enabledCount,
+ totalCount,
+ ),
+ style = HATextStyle.BodyMedium,
+ textAlign = TextAlign.Start,
+ color = colorScheme.colorTextSecondary,
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun RealtimeSyncSection(enabled: Boolean, onToggle: (Boolean) -> Unit) {
+ Column(verticalArrangement = Arrangement.spacedBy(HADimens.SPACE2)) {
+ SectionHeader(text = stringResource(commonR.string.health_connect_realtime_sync_title))
+ SwitchRow(
+ title = stringResource(commonR.string.health_connect_realtime_sync_title),
+ summary = stringResource(commonR.string.health_connect_realtime_sync_summary),
+ checked = enabled,
+ onToggle = onToggle,
+ )
+ }
+}
+
+@Composable
+private fun SwitchRow(title: String, summary: String, checked: Boolean, onToggle: (Boolean) -> Unit) {
+ val colorScheme = LocalHAColorScheme.current
+ HASettingsCard(
+ modifier = Modifier
+ .clip(RoundedCornerShape(HARadius.XL))
+ .clickable(role = Role.Switch) { onToggle(!checked) },
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(HADimens.SPACE1)) {
+ Text(
+ text = title,
+ style = HATextStyle.Body,
+ textAlign = TextAlign.Start,
+ color = colorScheme.colorTextPrimary,
+ )
+ Text(
+ text = summary,
+ style = HATextStyle.BodyMedium,
+ textAlign = TextAlign.Start,
+ color = colorScheme.colorTextSecondary,
+ )
+ }
+ HASwitch(checked = checked, onCheckedChange = onToggle)
+ }
+ }
+}
+
+@Composable
+private fun SectionHeader(text: String, modifier: Modifier = Modifier) {
+ val colorScheme = LocalHAColorScheme.current
+ Text(
+ text = text,
+ style = HATextStyle.BodyMedium,
+ color = colorScheme.colorTextSecondary,
+ modifier = modifier,
+ )
+}
diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/settings/sensor/healthconnect/HealthConnectSettingsViewModel.kt b/app/src/main/kotlin/io/homeassistant/companion/android/settings/sensor/healthconnect/HealthConnectSettingsViewModel.kt
new file mode 100644
index 00000000000..3d217984225
--- /dev/null
+++ b/app/src/main/kotlin/io/homeassistant/companion/android/settings/sensor/healthconnect/HealthConnectSettingsViewModel.kt
@@ -0,0 +1,198 @@
+package io.homeassistant.companion.android.settings.sensor.healthconnect
+
+import android.app.Application
+import androidx.health.connect.client.HealthConnectClient
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import io.homeassistant.companion.android.common.data.servers.ServerManager
+import io.homeassistant.companion.android.database.sensor.SensorDao
+import io.homeassistant.companion.android.database.sensor.SensorSetting
+import io.homeassistant.companion.android.database.sensor.SensorSettingType
+import io.homeassistant.companion.android.sensors.HealthConnectSensorManager
+import io.homeassistant.companion.android.sensors.SensorReceiver
+import io.homeassistant.companion.android.sensors.healthconnect.HealthConnectChangesWorker
+import io.homeassistant.companion.android.sensors.healthconnect.HealthConnectDataType
+import io.homeassistant.companion.android.sensors.healthconnect.HealthConnectSyncPreferences
+import javax.inject.Inject
+import javax.inject.Provider
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import timber.log.Timber
+
+/**
+ * UI state for the Health Connect settings screen.
+ *
+ * @property isLoading whether the initial preference read is still in flight.
+ * @property isAvailable whether Health Connect itself is installed/usable on the device.
+ * When false the screen shows an explanation and disables the toggle so users don't
+ * flip a flag whose worker would never run.
+ * @property realtimeSyncEnabled the current state of the real-time-sync opt-in flag.
+ * @property enableAllInProgress whether the "enable all sensors" job is mid-flight.
+ * The button stays disabled while this is true so a double-tap can't fire two perm
+ * request flows at once.
+ */
+data class HealthConnectSettingsUiState(
+ val isLoading: Boolean = true,
+ val isAvailable: Boolean = false,
+ val realtimeSyncEnabled: Boolean = false,
+ val enableAllInProgress: Boolean = false,
+ /** Number of Health Connect sensors with at least one enabled row in the DB. */
+ val enabledSensorCount: Int = 0,
+ /** Total number of HC sensors the catalogue exposes (sum of sensorIds across data types). */
+ val totalSensorCount: Int = 0,
+)
+
+/**
+ * Backs the [HealthConnectSettingsFragment] Compose screen.
+ *
+ * Owns three side effects:
+ * - persisting the real-time-sync flag through [HealthConnectSyncPreferences],
+ * - starting/stopping [HealthConnectChangesWorker] to match it, and
+ * - the "enable everything" bulk action that flips on every HC sensor (across every
+ * server), enables write permissions per sensor, and emits the union of read+write
+ * permission strings on [enableAllRequested] so the fragment can launch the HC
+ * permission contract.
+ *
+ * The HC client provider is injected as a [Provider] so the view-model can poll
+ * availability without keeping a reference to a possibly-null client across the
+ * lifecycle.
+ */
+@HiltViewModel
+class HealthConnectSettingsViewModel @Inject constructor(
+ application: Application,
+ private val preferences: HealthConnectSyncPreferences,
+ private val clientProvider: Provider,
+ private val sensorDao: SensorDao,
+ private val serverManager: ServerManager,
+) : AndroidViewModel(application) {
+
+ private val _uiState = MutableStateFlow(HealthConnectSettingsUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ /**
+ * Emits the full set of read + write permission strings the fragment should hand to
+ * [HealthConnectSensorManager.getPermissionResultContract]. Replay = 0 because we
+ * don't want a stale request firing again on rotation; buffer = 1 with DROP_OLDEST so
+ * a rapid second click never queues a second launch.
+ */
+ private val _enableAllRequested = MutableSharedFlow>(
+ replay = 0,
+ extraBufferCapacity = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
+ )
+ val enableAllRequested: SharedFlow> = _enableAllRequested.asSharedFlow()
+
+ init {
+ val totalSensors = HealthConnectDataType.all.flatMap { it.sensorIds }.toSet()
+ viewModelScope.launch {
+ val available = clientProvider.get() != null
+ val enabled = preferences.isRealtimeSyncEnabled()
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ isAvailable = available,
+ realtimeSyncEnabled = enabled,
+ totalSensorCount = totalSensors.size,
+ )
+ }
+ }
+ // Live count of HC sensors that have an enabled row anywhere (any server). Drives
+ // the "X/Y enabled" indicator on the bulk-enable row so the user gets immediate
+ // confirmation after granting permissions / running the bulk action.
+ viewModelScope.launch {
+ sensorDao.getAllFlow()
+ .map { all ->
+ all.asSequence()
+ .filter { it.enabled && it.id in totalSensors }
+ .map { it.id }
+ .distinct()
+ .count()
+ }
+ .distinctUntilChanged()
+ .collect { count ->
+ _uiState.update { it.copy(enabledSensorCount = count) }
+ }
+ }
+ }
+
+ fun setRealtimeSyncEnabled(enabled: Boolean) {
+ // Update UI optimistically so the switch flips with no perceptible delay; the
+ // suspending writes / WorkManager calls finish on the IO dispatcher in the
+ // background. If WorkManager throws (it shouldn't on a healthy device) the worst
+ // case is the persisted flag and the scheduled work disagree until next launch —
+ // worth the snappier UX.
+ _uiState.update { it.copy(realtimeSyncEnabled = enabled) }
+ viewModelScope.launch {
+ preferences.setRealtimeSyncEnabled(enabled)
+ val context = getApplication()
+ if (enabled) {
+ HealthConnectChangesWorker.start(context)
+ } else {
+ HealthConnectChangesWorker.stop(context)
+ }
+ }
+ }
+
+ /**
+ * Bulk-enable every Health Connect sensor for every registered server, flip the
+ * per-sensor "Allow writes from HA" toggle on, and surface a single permission
+ * request that asks for both read and write access to all known data types at once.
+ *
+ * The intended audience is power users who already know they want the full surface;
+ * the screen pairs the button with a confirmation dialog so a casual tap doesn't
+ * silently dump 50+ permission requests into Health Connect.
+ */
+ fun enableAll() {
+ if (_uiState.value.enableAllInProgress) return
+ _uiState.update { it.copy(enableAllInProgress = true) }
+ viewModelScope.launch {
+ try {
+ val context = getApplication()
+ val hcManager = SensorReceiver.MANAGERS.firstOrNull { it is HealthConnectSensorManager }
+ ?: return@launch
+ val serverIds = serverManager.servers().map { it.id }
+ val sensors = hcManager.getAvailableSensors(context)
+ sensors.forEach { sensor ->
+ if (serverIds.isNotEmpty()) {
+ sensorDao.setSensorEnabled(sensor.id, serverIds, enabled = true)
+ }
+ sensorDao.add(
+ SensorSetting(
+ sensorId = sensor.id,
+ name = HealthConnectSensorManager.SETTING_ALLOW_WRITES,
+ value = "true",
+ valueType = SensorSettingType.TOGGLE,
+ enabled = true,
+ ),
+ )
+ }
+ // The write-permission cache (HealthConnectSensorManager.allowWritesCache)
+ // refreshes from the persisted SensorSetting rows on the next
+ // requestSensorUpdate. We don't poke it directly here — the fragment
+ // calls SensorReceiver.updateAllSensors() after the permission contract
+ // returns, which exercises that refresh automatically.
+ val perms = buildSet {
+ HealthConnectDataType.all.forEach { dataType ->
+ add(dataType.readPermission)
+ add(dataType.writePermission)
+ }
+ }
+ _enableAllRequested.tryEmit(perms)
+ } catch (e: Exception) {
+ Timber.w(e, "enableAll failed")
+ } finally {
+ _uiState.update { it.copy(enableAllInProgress = false) }
+ }
+ }
+ }
+}
diff --git a/app/src/main/res/xml/changelog_master.xml b/app/src/main/res/xml/changelog_master.xml
index 173ded12d41..fdecd0a2c02 100755
--- a/app/src/main/res/xml/changelog_master.xml
+++ b/app/src/main/res/xml/changelog_master.xml
@@ -3,6 +3,7 @@
tools:ignore="MissingDefaultResource">
Bug fixes and dependency updates
+ Two-way Health Connect sync: HA can now write health data via the new command_health_connect_write push command, and changes from other apps sync back faster when the new \"Real-time sync\" toggle is enabled.
Bug fixes and dependency updates
diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml
index 90faa41c188..c8ec20d110c 100644
--- a/app/src/main/res/xml/preferences.xml
+++ b/app/src/main/res/xml/preferences.xml
@@ -34,6 +34,11 @@
android:icon="@drawable/ic_clock_fast"
android:title="@string/sensor_update_frequency"
android:summary="@string/sensor_update_frequency_summary" />
+
diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/sensors/HealthConnectSensorManagerTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/sensors/HealthConnectSensorManagerTest.kt
index 210a68d0310..edf5af403a8 100644
--- a/app/src/test/kotlin/io/homeassistant/companion/android/sensors/HealthConnectSensorManagerTest.kt
+++ b/app/src/test/kotlin/io/homeassistant/companion/android/sensors/HealthConnectSensorManagerTest.kt
@@ -4,12 +4,17 @@ import android.content.Context
import androidx.health.connect.client.HealthConnectClient
import androidx.health.connect.client.HealthConnectFeatures
import androidx.health.connect.client.permission.HealthPermission
+import androidx.health.connect.client.records.WeightRecord
import io.homeassistant.companion.android.testing.unit.ConsoleLogExtension
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
+import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource
@@ -28,6 +33,12 @@ class HealthConnectSensorManagerTest {
fun setup() {
mockkObject(HealthConnectClient.Companion)
every { HealthConnectClient.getOrCreate(any()) } returns healthConnectClient
+ HealthConnectSensorManager.allowWritesCache.clear()
+ }
+
+ @AfterEach
+ fun tearDown() {
+ HealthConnectSensorManager.allowWritesCache.clear()
}
@ParameterizedTest
@@ -52,4 +63,40 @@ class HealthConnectSensorManagerTest {
permissions.contains(HealthPermission.PERMISSION_READ_HEALTH_DATA_IN_BACKGROUND),
)
}
+
+ @Test
+ fun `Write permission excluded when allow-writes toggle is off`() {
+ every {
+ healthConnectClient.features.getFeatureStatus(any())
+ } returns HealthConnectFeatures.FEATURE_STATUS_UNAVAILABLE
+
+ val perms = sensorManager.requiredPermissions(context, HealthConnectSensorManager.weight.id)
+
+ assertFalse(perms.contains(HealthPermission.getWritePermission(WeightRecord::class)))
+ }
+
+ @Test
+ fun `Write permission included once allow-writes toggle is enabled in cache`() {
+ every {
+ healthConnectClient.features.getFeatureStatus(any())
+ } returns HealthConnectFeatures.FEATURE_STATUS_UNAVAILABLE
+ HealthConnectSensorManager.allowWritesCache[HealthConnectSensorManager.weight.id] = true
+
+ val perms = sensorManager.requiredPermissions(context, HealthConnectSensorManager.weight.id)
+
+ assertTrue(perms.contains(HealthPermission.getReadPermission(WeightRecord::class)))
+ assertTrue(perms.contains(HealthPermission.getWritePermission(WeightRecord::class)))
+ }
+
+ @Test
+ fun `Allow-writes toggle is per-sensor (does not leak to others)`() {
+ every {
+ healthConnectClient.features.getFeatureStatus(any())
+ } returns HealthConnectFeatures.FEATURE_STATUS_UNAVAILABLE
+ HealthConnectSensorManager.allowWritesCache[HealthConnectSensorManager.weight.id] = true
+
+ val stepsPerms = sensorManager.requiredPermissions(context, HealthConnectSensorManager.steps.id)
+
+ assertFalse(stepsPerms.any { it.startsWith("android.permission.health.WRITE_") })
+ }
}
diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/sensors/healthconnect/HealthConnectChangesRepositoryTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/sensors/healthconnect/HealthConnectChangesRepositoryTest.kt
new file mode 100644
index 00000000000..71911a38e31
--- /dev/null
+++ b/app/src/test/kotlin/io/homeassistant/companion/android/sensors/healthconnect/HealthConnectChangesRepositoryTest.kt
@@ -0,0 +1,174 @@
+package io.homeassistant.companion.android.sensors.healthconnect
+
+import androidx.health.connect.client.HealthConnectClient
+import androidx.health.connect.client.PermissionController
+import androidx.health.connect.client.changes.DeletionChange
+import androidx.health.connect.client.changes.UpsertionChange
+import androidx.health.connect.client.permission.HealthPermission
+import androidx.health.connect.client.records.WeightRecord
+import androidx.health.connect.client.records.metadata.Metadata
+import androidx.health.connect.client.request.ChangesTokenRequest
+import androidx.health.connect.client.response.ChangesResponse
+import androidx.health.connect.client.units.Mass
+import io.homeassistant.companion.android.testing.unit.ConsoleLogExtension
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.slot
+import java.time.Instant
+import javax.inject.Provider
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+
+/**
+ * Covers the three behaviors that drive correctness of the changes-API loop:
+ * 1. First-poll baseline — mint a fresh token, emit no changes.
+ * 2. Steady state — surface upsertions/deletions and persist the new cursor.
+ * 3. Token expiry — clear stale token, re-mint, and force a sensor refresh.
+ */
+@ExtendWith(ConsoleLogExtension::class)
+class HealthConnectChangesRepositoryTest {
+
+ private lateinit var client: HealthConnectClient
+ private lateinit var permissionController: PermissionController
+ private lateinit var tokenStore: HealthConnectChangesTokenStore
+ private lateinit var repository: HealthConnectChangesRepository
+
+ @BeforeEach
+ fun setUp() {
+ permissionController = mockk(relaxed = true)
+ client = mockk {
+ every { permissionController } returns this@HealthConnectChangesRepositoryTest.permissionController
+ }
+ coEvery { permissionController.getGrantedPermissions() } returns
+ setOf(HealthPermission.getReadPermission(WeightRecord::class))
+ tokenStore = mockk(relaxed = true)
+ repository = HealthConnectChangesRepository(Provider { client }, tokenStore)
+ }
+
+ @Test
+ fun `pollChanges returns null when client is unavailable`() = runTest {
+ val nullRepo = HealthConnectChangesRepository(Provider { null }, tokenStore)
+
+ assertNull(nullRepo.pollChanges(listOf(HealthConnectDataType.Weight)))
+ }
+
+ @Test
+ fun `data types without READ permission are skipped`() = runTest {
+ coEvery { permissionController.getGrantedPermissions() } returns emptySet()
+
+ val result = repository.pollChanges(listOf(HealthConnectDataType.Weight))
+
+ assertEquals(emptySet(), result)
+ coVerify(exactly = 0) { client.getChangesToken(any()) }
+ }
+
+ @Test
+ fun `first poll mints a token and reports no changes`() = runTest {
+ coEvery { tokenStore.get(HealthConnectDataType.Weight) } returns null
+ coEvery { client.getChangesToken(any()) } returns "tok-1"
+
+ val result = repository.pollChanges(listOf(HealthConnectDataType.Weight))
+
+ assertEquals(emptySet(), result)
+ coVerify { tokenStore.put(HealthConnectDataType.Weight, "tok-1") }
+ coVerify(exactly = 0) { client.getChanges(any()) }
+ }
+
+ @Test
+ fun `upsertion change marks the data type as changed and rotates the token`() = runTest {
+ coEvery { tokenStore.get(HealthConnectDataType.Weight) } returns "tok-1"
+ coEvery { client.getChanges("tok-1") } returns ChangesResponse(
+ changes = listOf(UpsertionChange(weightRecord())),
+ nextChangesToken = "tok-2",
+ hasMore = false,
+ changesTokenExpired = false,
+ )
+
+ val result = repository.pollChanges(listOf(HealthConnectDataType.Weight))
+
+ assertEquals(setOf(HealthConnectDataType.Weight), result)
+ coVerify { tokenStore.put(HealthConnectDataType.Weight, "tok-2") }
+ }
+
+ @Test
+ fun `deletion change marks the data type as changed`() = runTest {
+ coEvery { tokenStore.get(HealthConnectDataType.Weight) } returns "tok-1"
+ coEvery { client.getChanges("tok-1") } returns ChangesResponse(
+ changes = listOf(DeletionChange("rec-id")),
+ nextChangesToken = "tok-2",
+ hasMore = false,
+ changesTokenExpired = false,
+ )
+
+ val result = repository.pollChanges(listOf(HealthConnectDataType.Weight))
+
+ assertEquals(setOf(HealthConnectDataType.Weight), result)
+ }
+
+ @Test
+ fun `expired token is cleared, refreshed, and reported as changed`() = runTest {
+ coEvery { tokenStore.get(HealthConnectDataType.Weight) } returns "tok-old"
+ coEvery { client.getChanges("tok-old") } returns ChangesResponse(
+ changes = emptyList(),
+ nextChangesToken = "",
+ hasMore = false,
+ changesTokenExpired = true,
+ )
+ coEvery { client.getChangesToken(any()) } returns "tok-new"
+
+ val result = repository.pollChanges(listOf(HealthConnectDataType.Weight))
+
+ assertEquals(setOf(HealthConnectDataType.Weight), result)
+ coVerify { tokenStore.clear(HealthConnectDataType.Weight) }
+ coVerify { tokenStore.put(HealthConnectDataType.Weight, "tok-new") }
+ }
+
+ @Test
+ fun `paginated changes drain hasMore until exhausted`() = runTest {
+ coEvery { tokenStore.get(HealthConnectDataType.Weight) } returns "tok-1"
+ coEvery { client.getChanges("tok-1") } returns ChangesResponse(
+ changes = listOf(UpsertionChange(weightRecord())),
+ nextChangesToken = "tok-2",
+ hasMore = true,
+ changesTokenExpired = false,
+ )
+ coEvery { client.getChanges("tok-2") } returns ChangesResponse(
+ changes = emptyList(),
+ nextChangesToken = "tok-3",
+ hasMore = false,
+ changesTokenExpired = false,
+ )
+
+ val result = repository.pollChanges(listOf(HealthConnectDataType.Weight))
+
+ assertTrue(HealthConnectDataType.Weight in result.orEmpty())
+ coVerify { client.getChanges("tok-1") }
+ coVerify { client.getChanges("tok-2") }
+ coVerify { tokenStore.put(HealthConnectDataType.Weight, "tok-3") }
+ }
+
+ @Test
+ fun `getChangesToken request includes the record class`() = runTest {
+ coEvery { tokenStore.get(any()) } returns null
+ val request = slot()
+ coEvery { client.getChangesToken(capture(request)) } returns "tok"
+
+ repository.pollChanges(listOf(HealthConnectDataType.Weight))
+
+ assertTrue(WeightRecord::class in request.captured.recordTypes)
+ }
+
+ private fun weightRecord(): WeightRecord = WeightRecord(
+ time = Instant.parse("2026-05-01T10:00:00Z"),
+ zoneOffset = null,
+ weight = Mass.kilograms(80.0),
+ metadata = Metadata.manualEntry(),
+ )
+}
diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/sensors/healthconnect/HealthConnectChangesTokenStoreTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/sensors/healthconnect/HealthConnectChangesTokenStoreTest.kt
new file mode 100644
index 00000000000..f552f4c5a0c
--- /dev/null
+++ b/app/src/test/kotlin/io/homeassistant/companion/android/sensors/healthconnect/HealthConnectChangesTokenStoreTest.kt
@@ -0,0 +1,58 @@
+package io.homeassistant.companion.android.sensors.healthconnect
+
+import io.homeassistant.companion.android.common.data.LocalStorage
+import io.homeassistant.companion.android.testing.unit.ConsoleLogExtension
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+
+@ExtendWith(ConsoleLogExtension::class)
+class HealthConnectChangesTokenStoreTest {
+
+ private val storage = mockk(relaxed = true)
+ private val store = HealthConnectChangesTokenStore(storage)
+
+ @Test
+ fun `get reads using prefixed key per data type`() = runTest {
+ coEvery { storage.getString("changes_token::weight") } returns "tok-w"
+
+ val result = store.get(HealthConnectDataType.Weight)
+
+ assertEquals("tok-w", result)
+ }
+
+ @Test
+ fun `blank token returns null so callers mint a fresh one`() = runTest {
+ coEvery { storage.getString(any()) } returns ""
+
+ assertNull(store.get(HealthConnectDataType.Weight))
+ }
+
+ @Test
+ fun `put writes through the prefixed key`() = runTest {
+ store.put(HealthConnectDataType.Steps, "tok-s")
+
+ coVerify { storage.putString("changes_token::steps", "tok-s") }
+ }
+
+ @Test
+ fun `clear removes only the targeted data type`() = runTest {
+ store.clear(HealthConnectDataType.Hydration)
+
+ coVerify(exactly = 1) { storage.remove("changes_token::hydration") }
+ }
+
+ @Test
+ fun `clearAll removes every known data type`() = runTest {
+ store.clearAll()
+
+ HealthConnectDataType.all.forEach {
+ coVerify { storage.remove("changes_token::${it.key}") }
+ }
+ }
+}
diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/sensors/healthconnect/HealthConnectExerciseTypesTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/sensors/healthconnect/HealthConnectExerciseTypesTest.kt
new file mode 100644
index 00000000000..a4106708d3c
--- /dev/null
+++ b/app/src/test/kotlin/io/homeassistant/companion/android/sensors/healthconnect/HealthConnectExerciseTypesTest.kt
@@ -0,0 +1,68 @@
+package io.homeassistant.companion.android.sensors.healthconnect
+
+import androidx.health.connect.client.records.ExerciseSessionRecord
+import io.homeassistant.companion.android.testing.unit.ConsoleLogExtension
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+
+/**
+ * Sanity checks for our local mirror of the HC SDK's exercise-type map.
+ *
+ * The SDK exposes the map as `@RestrictTo(LIBRARY)` so we can't reuse it. If the SDK adds
+ * a new type and we don't mirror it here, the read sensor falls back to "unknown" and the
+ * write payload rejects the slug with a helpful error — both are non-fatal degradations,
+ * but the map is dense enough that a typo would silently mis-route an entry.
+ */
+@ExtendWith(ConsoleLogExtension::class)
+class HealthConnectExerciseTypesTest {
+
+ @Test
+ fun `slug-to-int round-trips through int-to-slug`() {
+ HealthConnectExerciseTypes.SLUG_TO_INT.forEach { (slug, intValue) ->
+ assertEquals(
+ slug,
+ HealthConnectExerciseTypes.INT_TO_SLUG[intValue],
+ "Round-trip mismatch for slug=$slug int=$intValue",
+ )
+ }
+ }
+
+ @Test
+ fun `the most common slugs resolve to their HC constants`() {
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_RUNNING,
+ HealthConnectExerciseTypes.SLUG_TO_INT["running"],
+ )
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_BIKING,
+ HealthConnectExerciseTypes.SLUG_TO_INT["biking"],
+ )
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_POOL,
+ HealthConnectExerciseTypes.SLUG_TO_INT["swimming_pool"],
+ )
+ assertEquals(
+ ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT,
+ HealthConnectExerciseTypes.SLUG_TO_INT["other_workout"],
+ )
+ }
+
+ @Test
+ fun `slugs are unique and lowercase snake_case`() {
+ val slugs = HealthConnectExerciseTypes.SLUG_TO_INT.keys
+ assertEquals(slugs.size, slugs.toSet().size, "Duplicate slugs detected")
+ slugs.forEach { slug ->
+ assertTrue(
+ slug == slug.lowercase() && !slug.contains(' '),
+ "Slug '$slug' must be lowercase with no spaces",
+ )
+ }
+ }
+
+ @Test
+ fun `unknown slug resolves to null so the parser can reject it`() {
+ assertEquals(null, HealthConnectExerciseTypes.SLUG_TO_INT["telepathy"])
+ }
+}
diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/sensors/healthconnect/HealthConnectWriteRepositoryImplTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/sensors/healthconnect/HealthConnectWriteRepositoryImplTest.kt
new file mode 100644
index 00000000000..a9af0839bc7
--- /dev/null
+++ b/app/src/test/kotlin/io/homeassistant/companion/android/sensors/healthconnect/HealthConnectWriteRepositoryImplTest.kt
@@ -0,0 +1,299 @@
+package io.homeassistant.companion.android.sensors.healthconnect
+
+import androidx.health.connect.client.HealthConnectClient
+import androidx.health.connect.client.PermissionController
+import androidx.health.connect.client.permission.HealthPermission
+import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord
+import androidx.health.connect.client.records.BloodPressureRecord
+import androidx.health.connect.client.records.CyclingPedalingCadenceRecord
+import androidx.health.connect.client.records.ExerciseSessionRecord
+import androidx.health.connect.client.records.HeartRateRecord
+import androidx.health.connect.client.records.HydrationRecord
+import androidx.health.connect.client.records.PowerRecord
+import androidx.health.connect.client.records.Record
+import androidx.health.connect.client.records.SleepSessionRecord
+import androidx.health.connect.client.records.SpeedRecord
+import androidx.health.connect.client.records.StepsRecord
+import androidx.health.connect.client.records.WeightRecord
+import androidx.health.connect.client.response.InsertRecordsResponse
+import androidx.health.connect.client.units.Power
+import androidx.health.connect.client.units.Velocity
+import io.homeassistant.companion.android.testing.unit.ConsoleLogExtension
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.slot
+import java.time.Instant
+import javax.inject.Provider
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+
+/**
+ * Verifies that each typed `writeX` method on [HealthConnectWriteRepositoryImpl] builds the
+ * correct [Record], requests the matching WRITE permission, and forwards to
+ * [HealthConnectClient.insertRecords].
+ *
+ * The test set is intentionally shaped around the four record categories:
+ * - Instantaneous (Weight, BloodPressure)
+ * - Interval (ActiveCaloriesBurned, Hydration, Steps)
+ * - Series (HeartRate)
+ * - Session (Sleep)
+ *
+ * Each category exercises one or two representative data types end-to-end. The remaining
+ * data types share construction logic with their representative — adding 18 more
+ * near-identical assertions would not catch additional regressions and would obscure the
+ * architectural intent of the test file. New data types that introduce *new* construction
+ * logic (e.g. a new unit class, multi-field, or sample-based record) MUST add a case here.
+ */
+@ExtendWith(ConsoleLogExtension::class)
+class HealthConnectWriteRepositoryImplTest {
+
+ private lateinit var client: HealthConnectClient
+ private lateinit var permissionController: PermissionController
+ private lateinit var repository: HealthConnectWriteRepositoryImpl
+
+ private val now: Instant = Instant.parse("2026-05-01T10:00:00Z")
+ private val later: Instant = Instant.parse("2026-05-01T10:30:00Z")
+
+ @BeforeEach
+ fun setUp() {
+ permissionController = mockk(relaxed = true)
+ client = mockk {
+ every { permissionController } returns this@HealthConnectWriteRepositoryImplTest.permissionController
+ }
+ // Default: every permission is granted; individual tests override as needed.
+ coEvery { permissionController.getGrantedPermissions() } returns
+ HealthConnectDataType.all.map { HealthPermission.getWritePermission(it.recordClass) }.toSet()
+ coEvery { client.insertRecords(any()) } returns InsertRecordsResponse(listOf("id-1"))
+ repository = HealthConnectWriteRepositoryImpl(Provider { client })
+ }
+
+ @Test
+ fun `Unavailable when client provider returns null`() = runTest {
+ val nullRepo = HealthConnectWriteRepositoryImpl(Provider { null })
+ val result = nullRepo.writeWeight(time = now, kilograms = 80.0)
+ assertEquals(HealthConnectWriteResult.Unavailable, result)
+ }
+
+ @Test
+ fun `MissingPermission when WRITE permission not granted`() = runTest {
+ coEvery { permissionController.getGrantedPermissions() } returns emptySet()
+ val result = repository.writeWeight(time = now, kilograms = 80.0)
+ assertTrue(result is HealthConnectWriteResult.MissingPermission)
+ assertEquals(
+ HealthPermission.getWritePermission(WeightRecord::class),
+ (result as HealthConnectWriteResult.MissingPermission).permission,
+ )
+ }
+
+ @Test
+ fun `writeWeight builds WeightRecord and calls insertRecords`() = runTest {
+ val captured = slot>()
+ coEvery { client.insertRecords(capture(captured)) } returns InsertRecordsResponse(listOf("id-w"))
+
+ val result = repository.writeWeight(time = now, kilograms = 75.2, clientRecordId = "scale-42")
+
+ assertTrue(result is HealthConnectWriteResult.Success)
+ val record = captured.captured.single() as WeightRecord
+ assertEquals(now, record.time)
+ assertEquals(75.2, record.weight.inKilograms, 0.0001)
+ assertEquals("scale-42", record.metadata.clientRecordId)
+ }
+
+ @Test
+ fun `writeBloodPressure builds BloodPressureRecord with both pressures`() = runTest {
+ val captured = slot>()
+ coEvery { client.insertRecords(capture(captured)) } returns InsertRecordsResponse(listOf("id-bp"))
+
+ val result = repository.writeBloodPressure(
+ time = now,
+ systolicMmHg = 118.0,
+ diastolicMmHg = 76.0,
+ )
+
+ assertTrue(result is HealthConnectWriteResult.Success)
+ val record = captured.captured.single() as BloodPressureRecord
+ assertEquals(118.0, record.systolic.inMillimetersOfMercury, 0.0001)
+ assertEquals(76.0, record.diastolic.inMillimetersOfMercury, 0.0001)
+ }
+
+ @Test
+ fun `writeActiveCaloriesBurned uses kilocalories`() = runTest {
+ val captured = slot>()
+ coEvery { client.insertRecords(capture(captured)) } returns InsertRecordsResponse(listOf("id-c"))
+
+ repository.writeActiveCaloriesBurned(now, later, kilocalories = 250.0)
+
+ val record = captured.captured.single() as ActiveCaloriesBurnedRecord
+ assertEquals(now, record.startTime)
+ assertEquals(later, record.endTime)
+ assertEquals(250.0, record.energy.inKilocalories, 0.0001)
+ }
+
+ @Test
+ fun `writeHydration uses liters`() = runTest {
+ val captured = slot>()
+ coEvery { client.insertRecords(capture(captured)) } returns InsertRecordsResponse(listOf("id-h"))
+
+ repository.writeHydration(now, later, liters = 0.5)
+
+ val record = captured.captured.single() as HydrationRecord
+ assertEquals(0.5, record.volume.inLiters, 0.0001)
+ }
+
+ @Test
+ fun `writeSteps uses Long count`() = runTest {
+ val captured = slot>()
+ coEvery { client.insertRecords(capture(captured)) } returns InsertRecordsResponse(listOf("id-s"))
+
+ repository.writeSteps(now, later, count = 1234L)
+
+ val record = captured.captured.single() as StepsRecord
+ assertEquals(1234L, record.count)
+ }
+
+ @Test
+ fun `writeHeartRate forwards samples and rejects empty list`() = runTest {
+ val captured = slot>()
+ coEvery { client.insertRecords(capture(captured)) } returns InsertRecordsResponse(listOf("id-hr"))
+
+ val samples = listOf(
+ HeartRateRecord.Sample(time = now, beatsPerMinute = 72),
+ HeartRateRecord.Sample(time = now.plusSeconds(60), beatsPerMinute = 76),
+ )
+ repository.writeHeartRate(now, later, samples = samples)
+ val record = captured.captured.single() as HeartRateRecord
+ assertEquals(samples, record.samples)
+
+ val emptyResult = repository.writeHeartRate(now, later, samples = emptyList())
+ assertTrue(emptyResult is HealthConnectWriteResult.InvalidPayload)
+ }
+
+ @Test
+ fun `writeExerciseSession builds ExerciseSessionRecord with type and metadata`() = runTest {
+ val captured = slot>()
+ coEvery { client.insertRecords(capture(captured)) } returns InsertRecordsResponse(listOf("id-ex"))
+
+ repository.writeExerciseSession(
+ startTime = now,
+ endTime = later,
+ exerciseType = ExerciseSessionRecord.EXERCISE_TYPE_RUNNING,
+ title = "Evening run",
+ notes = "felt great",
+ clientRecordId = "ha-run-1",
+ )
+
+ val record = captured.captured.single() as ExerciseSessionRecord
+ assertEquals(now, record.startTime)
+ assertEquals(later, record.endTime)
+ assertEquals(ExerciseSessionRecord.EXERCISE_TYPE_RUNNING, record.exerciseType)
+ assertEquals("Evening run", record.title)
+ assertEquals("felt great", record.notes)
+ assertEquals("ha-run-1", record.metadata.clientRecordId)
+ }
+
+ @Test
+ fun `writeSleep forwards stages and metadata`() = runTest {
+ val captured = slot>()
+ coEvery { client.insertRecords(capture(captured)) } returns InsertRecordsResponse(listOf("id-sleep"))
+
+ val stages = listOf(
+ SleepSessionRecord.Stage(
+ startTime = now,
+ endTime = later,
+ stage = SleepSessionRecord.STAGE_TYPE_DEEP,
+ ),
+ )
+ repository.writeSleep(
+ startTime = now,
+ endTime = later,
+ title = "Nightly",
+ notes = "felt rested",
+ stages = stages,
+ clientRecordId = "sleep-1",
+ )
+
+ val record = captured.captured.single() as SleepSessionRecord
+ assertEquals("Nightly", record.title)
+ assertEquals("felt rested", record.notes)
+ assertEquals(stages, record.stages)
+ assertEquals("sleep-1", record.metadata.clientRecordId)
+ }
+
+ @Test
+ fun `writeSpeed builds SpeedRecord with samples`() = runTest {
+ val captured = slot>()
+ coEvery { client.insertRecords(capture(captured)) } returns InsertRecordsResponse(listOf("id-sp"))
+
+ val samples = listOf(
+ SpeedRecord.Sample(now, Velocity.metersPerSecond(3.0)),
+ SpeedRecord.Sample(now.plusSeconds(60), Velocity.metersPerSecond(4.5)),
+ )
+ repository.writeSpeed(now, later, samples)
+
+ val record = captured.captured.single() as SpeedRecord
+ assertEquals(samples, record.samples)
+ }
+
+ @Test
+ fun `writeSpeed rejects empty sample list`() = runTest {
+ val result = repository.writeSpeed(now, later, samples = emptyList())
+ assertTrue(result is HealthConnectWriteResult.InvalidPayload)
+ }
+
+ @Test
+ fun `writePower builds PowerRecord with samples`() = runTest {
+ val captured = slot>()
+ coEvery { client.insertRecords(capture(captured)) } returns InsertRecordsResponse(listOf("id-pw"))
+
+ val samples = listOf(PowerRecord.Sample(now, Power.watts(220.0)))
+ repository.writePower(now, later, samples)
+
+ val record = captured.captured.single() as PowerRecord
+ assertEquals(samples, record.samples)
+ }
+
+ @Test
+ fun `writePower rejects empty sample list`() = runTest {
+ val result = repository.writePower(now, later, samples = emptyList())
+ assertTrue(result is HealthConnectWriteResult.InvalidPayload)
+ }
+
+ @Test
+ fun `writeCyclingPedalingCadence builds the record with rpm samples`() = runTest {
+ val captured = slot>()
+ coEvery { client.insertRecords(capture(captured)) } returns InsertRecordsResponse(listOf("id-rpm"))
+
+ val samples = listOf(
+ CyclingPedalingCadenceRecord.Sample(now, 88.0),
+ CyclingPedalingCadenceRecord.Sample(now.plusSeconds(30), 92.0),
+ )
+ repository.writeCyclingPedalingCadence(now, later, samples)
+
+ val record = captured.captured.single() as CyclingPedalingCadenceRecord
+ assertEquals(samples, record.samples)
+ }
+
+ @Test
+ fun `writeCyclingPedalingCadence rejects empty sample list`() = runTest {
+ val result = repository.writeCyclingPedalingCadence(now, later, samples = emptyList())
+ assertTrue(result is HealthConnectWriteResult.InvalidPayload)
+ }
+
+ @Test
+ fun `Failure surfaces when insertRecords throws`() = runTest {
+ val boom = RuntimeException("boom")
+ coEvery { client.insertRecords(any()) } throws boom
+
+ val result = repository.writeWeight(time = now, kilograms = 80.0)
+
+ assertTrue(result is HealthConnectWriteResult.Failure)
+ assertEquals(boom, (result as HealthConnectWriteResult.Failure).cause)
+ coVerify { client.insertRecords(any()) }
+ }
+}
diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/sensors/healthconnect/command/HealthConnectUnitConversionTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/sensors/healthconnect/command/HealthConnectUnitConversionTest.kt
new file mode 100644
index 00000000000..75a3a702cb4
--- /dev/null
+++ b/app/src/test/kotlin/io/homeassistant/companion/android/sensors/healthconnect/command/HealthConnectUnitConversionTest.kt
@@ -0,0 +1,101 @@
+package io.homeassistant.companion.android.sensors.healthconnect.command
+
+import io.homeassistant.companion.android.sensors.healthconnect.HealthConnectDataType
+import io.homeassistant.companion.android.testing.unit.ConsoleLogExtension
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertThrows
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+
+@ExtendWith(ConsoleLogExtension::class)
+class HealthConnectUnitConversionTest {
+
+ @Test
+ fun `null or blank unit passes value through unchanged`() {
+ val v = HealthConnectUnitConversion.toCanonical(HealthConnectDataType.Weight, 75.2, null)
+ assertEquals(75.2, v, 0.0001)
+ assertEquals(75.2, HealthConnectUnitConversion.toCanonical(HealthConnectDataType.Weight, 75.2, ""), 0.0001)
+ assertEquals(75.2, HealthConnectUnitConversion.toCanonical(HealthConnectDataType.Weight, 75.2, " "), 0.0001)
+ }
+
+ @Test
+ fun `pounds convert to kilograms for Weight`() {
+ val v = HealthConnectUnitConversion.toCanonical(HealthConnectDataType.Weight, 165.0, "lb")
+ assertEquals(74.84, v, 0.01)
+ }
+
+ @Test
+ fun `grams convert to kilograms for mass types`() {
+ val v = HealthConnectUnitConversion.toCanonical(HealthConnectDataType.BoneMass, 2500.0, "g")
+ assertEquals(2.5, v, 0.0001)
+ }
+
+ @Test
+ fun `feet convert to meters for Height`() {
+ val v = HealthConnectUnitConversion.toCanonical(HealthConnectDataType.Height, 6.0, "ft")
+ assertEquals(1.8288, v, 0.0001)
+ }
+
+ @Test
+ fun `centimeters convert to meters for Height`() {
+ val v = HealthConnectUnitConversion.toCanonical(HealthConnectDataType.Height, 180.0, "cm")
+ assertEquals(1.80, v, 0.0001)
+ }
+
+ @Test
+ fun `kilometers convert to meters for Distance`() {
+ val v = HealthConnectUnitConversion.toCanonical(HealthConnectDataType.Distance, 5.0, "km")
+ assertEquals(5000.0, v, 0.0001)
+ }
+
+ @Test
+ fun `fahrenheit converts to celsius for BodyTemperature`() {
+ val v = HealthConnectUnitConversion.toCanonical(HealthConnectDataType.BodyTemperature, 98.6, "F")
+ assertEquals(37.0, v, 0.01)
+ }
+
+ @Test
+ fun `mg per dL converts to mmol per L for BloodGlucose`() {
+ val v = HealthConnectUnitConversion.toCanonical(HealthConnectDataType.BloodGlucose, 100.0, "mg/dL")
+ assertEquals(5.55, v, 0.01)
+ }
+
+ @Test
+ fun `milliliters convert to liters for Hydration`() {
+ val v = HealthConnectUnitConversion.toCanonical(HealthConnectDataType.Hydration, 500.0, "mL")
+ assertEquals(0.5, v, 0.0001)
+ }
+
+ @Test
+ fun `joules convert to kilocalories for energy`() {
+ val v = HealthConnectUnitConversion.toCanonical(HealthConnectDataType.ActiveCaloriesBurned, 4184.0, "J")
+ assertEquals(1.0, v, 0.0001)
+ }
+
+ @Test
+ fun `fraction converts to percent for OxygenSaturation`() {
+ val v = HealthConnectUnitConversion.toCanonical(HealthConnectDataType.OxygenSaturation, 0.97, "fraction")
+ assertEquals(97.0, v, 0.01)
+ }
+
+ @Test
+ fun `unit string is case insensitive`() {
+ val a = HealthConnectUnitConversion.toCanonical(HealthConnectDataType.Weight, 165.0, "LB")
+ val b = HealthConnectUnitConversion.toCanonical(HealthConnectDataType.Weight, 165.0, "lb")
+ assertEquals(a, b, 0.0001)
+ }
+
+ @Test
+ fun `unknown unit throws InvalidPayloadException`() {
+ assertThrows(HealthConnectWriteCommandPayload.InvalidPayloadException::class.java) {
+ HealthConnectUnitConversion.toCanonical(HealthConnectDataType.Weight, 1.0, "stones")
+ }
+ }
+
+ @Test
+ fun `data type without unit support rejects any unit`() {
+ assertThrows(HealthConnectWriteCommandPayload.InvalidPayloadException::class.java) {
+ HealthConnectUnitConversion.toCanonical(HealthConnectDataType.Steps, 1000.0, "kilosteps")
+ }
+ }
+}
diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/sensors/healthconnect/command/HealthConnectWriteCommandHandlerTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/sensors/healthconnect/command/HealthConnectWriteCommandHandlerTest.kt
new file mode 100644
index 00000000000..71c5f263ec2
--- /dev/null
+++ b/app/src/test/kotlin/io/homeassistant/companion/android/sensors/healthconnect/command/HealthConnectWriteCommandHandlerTest.kt
@@ -0,0 +1,390 @@
+package io.homeassistant.companion.android.sensors.healthconnect.command
+
+import io.homeassistant.companion.android.sensors.healthconnect.HealthConnectWriteRepository
+import io.homeassistant.companion.android.sensors.healthconnect.HealthConnectWriteResult
+import io.homeassistant.companion.android.testing.unit.ConsoleLogExtension
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import java.time.Instant
+import kotlin.time.ExperimentalTime
+import kotlin.time.Instant as KInstant
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+
+/**
+ * Tests for the FCM payload → repository call translation.
+ *
+ * These tests focus on the dispatching logic — picking the right repository method for each
+ * data type — and on payload validation. The actual record construction is the
+ * repository's job and is covered separately in `HealthConnectWriteRepositoryImplTest`.
+ */
+@ExtendWith(ConsoleLogExtension::class)
+@OptIn(ExperimentalTime::class)
+class HealthConnectWriteCommandHandlerTest {
+
+ private val fixedNow: KInstant = KInstant.parse("2026-05-01T12:00:00Z")
+ private val fixedNowJava: Instant = Instant.parse("2026-05-01T12:00:00Z")
+ private val clock = object : kotlin.time.Clock {
+ override fun now(): KInstant = fixedNow
+ }
+
+ private val repository = mockk(relaxed = true) {
+ coEvery { writeWeight(any(), any(), any()) } returns
+ HealthConnectWriteResult.Success(listOf("id-w"))
+ coEvery { writeBloodPressure(any(), any(), any(), any()) } returns
+ HealthConnectWriteResult.Success(listOf("id-bp"))
+ coEvery { writeSteps(any(), any(), any(), any()) } returns
+ HealthConnectWriteResult.Success(listOf("id-s"))
+ }
+ private val handler = HealthConnectWriteCommandHandler(repository, clock)
+
+ @Test
+ fun `weight payload dispatches to writeWeight`() = runTest {
+ val result = handler.handle(
+ mapOf(
+ "data_type" to "weight",
+ "value" to "75.2",
+ "time" to "2026-05-01T08:30:00Z",
+ "client_record_id" to "scale-1",
+ ),
+ )
+ assertTrue(result is HealthConnectWriteResult.Success)
+ coVerify {
+ repository.writeWeight(
+ time = Instant.parse("2026-05-01T08:30:00Z"),
+ kilograms = 75.2,
+ clientRecordId = "scale-1",
+ )
+ }
+ }
+
+ @Test
+ fun `weight payload defaults missing time to now`() = runTest {
+ handler.handle(mapOf("data_type" to "weight", "value" to "70.0"))
+ coVerify { repository.writeWeight(time = fixedNowJava, kilograms = 70.0, clientRecordId = null) }
+ }
+
+ @Test
+ fun `blood_pressure payload dispatches to writeBloodPressure`() = runTest {
+ handler.handle(
+ mapOf(
+ "data_type" to "blood_pressure",
+ "systolic" to "118",
+ "diastolic" to "76",
+ ),
+ )
+ coVerify {
+ repository.writeBloodPressure(
+ time = fixedNowJava,
+ systolicMmHg = 118.0,
+ diastolicMmHg = 76.0,
+ clientRecordId = null,
+ )
+ }
+ }
+
+ @Test
+ fun `steps payload uses Long count and defaults end_time to now`() = runTest {
+ handler.handle(
+ mapOf(
+ "data_type" to "steps",
+ "value" to "1234",
+ "start_time" to "2026-05-01T11:00:00Z",
+ ),
+ )
+ coVerify {
+ repository.writeSteps(
+ startTime = Instant.parse("2026-05-01T11:00:00Z"),
+ endTime = fixedNowJava,
+ count = 1234L,
+ clientRecordId = null,
+ )
+ }
+ }
+
+ @Test
+ fun `fractional steps payload is rejected instead of truncated`() = runTest {
+ val result = handler.handle(
+ mapOf(
+ "data_type" to "steps",
+ "value" to "1234.9",
+ "start_time" to "2026-05-01T11:00:00Z",
+ "end_time" to "2026-05-01T11:30:00Z",
+ ),
+ )
+ assertTrue(result is HealthConnectWriteResult.InvalidPayload)
+ coVerify(exactly = 0) { repository.writeSteps(any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun `fractional resting heart rate payload is rejected instead of truncated`() = runTest {
+ val result = handler.handle(
+ mapOf(
+ "data_type" to "resting_heart_rate",
+ "value" to "72.9",
+ "time" to "2026-05-01T11:00:00Z",
+ ),
+ )
+ assertTrue(result is HealthConnectWriteResult.InvalidPayload)
+ coVerify(exactly = 0) { repository.writeRestingHeartRate(any(), any(), any()) }
+ }
+
+ @Test
+ fun `unknown data_type returns InvalidPayload`() = runTest {
+ val result = handler.handle(mapOf("data_type" to "telepathy", "value" to "1"))
+ assertTrue(result is HealthConnectWriteResult.InvalidPayload)
+ }
+
+ @Test
+ fun `missing data_type returns InvalidPayload`() = runTest {
+ val result = handler.handle(mapOf("value" to "1"))
+ assertTrue(result is HealthConnectWriteResult.InvalidPayload)
+ }
+
+ @Test
+ fun `non-numeric value returns InvalidPayload`() = runTest {
+ val result = handler.handle(mapOf("data_type" to "weight", "value" to "heavy"))
+ assertTrue(result is HealthConnectWriteResult.InvalidPayload)
+ assertEquals(
+ "Field value must be a number, got: heavy",
+ (result as HealthConnectWriteResult.InvalidPayload).reason,
+ )
+ }
+
+ @Test
+ fun `bad ISO timestamp returns InvalidPayload`() = runTest {
+ val result = handler.handle(
+ mapOf("data_type" to "weight", "value" to "70", "time" to "yesterday"),
+ )
+ assertTrue(result is HealthConnectWriteResult.InvalidPayload)
+ }
+
+ @Test
+ fun `heart_rate parses samples JSON array`() = runTest {
+ coEvery { repository.writeHeartRate(any(), any(), any(), any()) } returns
+ HealthConnectWriteResult.Success(listOf("id-hr"))
+
+ handler.handle(
+ mapOf(
+ "data_type" to "heart_rate",
+ "start_time" to "2026-05-01T11:55:00Z",
+ "end_time" to "2026-05-01T12:00:00Z",
+ "samples" to """[{"time":"2026-05-01T11:55:00Z","beats_per_minute":72}]""",
+ ),
+ )
+
+ coVerify {
+ repository.writeHeartRate(
+ startTime = Instant.parse("2026-05-01T11:55:00Z"),
+ endTime = Instant.parse("2026-05-01T12:00:00Z"),
+ samples = match { it.size == 1 && it.single().beatsPerMinute == 72L },
+ clientRecordId = null,
+ )
+ }
+ }
+
+ @Test
+ fun `exercise_session payload dispatches with type slug parsed to int`() = runTest {
+ coEvery {
+ repository.writeExerciseSession(any(), any(), any(), any(), any(), any())
+ } returns HealthConnectWriteResult.Success(listOf("id-ex"))
+
+ handler.handle(
+ mapOf(
+ "data_type" to "exercise_session",
+ "exercise_type" to "running",
+ "start_time" to "2026-05-02T11:00:00Z",
+ "end_time" to "2026-05-02T11:30:00Z",
+ "title" to "Evening run",
+ ),
+ )
+
+ coVerify {
+ repository.writeExerciseSession(
+ startTime = Instant.parse("2026-05-02T11:00:00Z"),
+ endTime = Instant.parse("2026-05-02T11:30:00Z"),
+ exerciseType = androidx.health.connect.client.records.ExerciseSessionRecord.EXERCISE_TYPE_RUNNING,
+ title = "Evening run",
+ notes = null,
+ clientRecordId = null,
+ )
+ }
+ }
+
+ @Test
+ fun `exercise_session payload defaults exercise_type to other workout`() = runTest {
+ coEvery {
+ repository.writeExerciseSession(any(), any(), any(), any(), any(), any())
+ } returns HealthConnectWriteResult.Success(listOf("id-ex"))
+
+ handler.handle(
+ mapOf(
+ "data_type" to "exercise_session",
+ "start_time" to "2026-05-02T11:00:00Z",
+ "end_time" to "2026-05-02T11:30:00Z",
+ ),
+ )
+
+ coVerify {
+ repository.writeExerciseSession(
+ startTime = any(),
+ endTime = any(),
+ exerciseType = androidx.health.connect.client.records.ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT,
+ title = null,
+ notes = null,
+ clientRecordId = null,
+ )
+ }
+ }
+
+ @Test
+ fun `unknown exercise_type slug returns InvalidPayload`() = runTest {
+ val result = handler.handle(
+ mapOf(
+ "data_type" to "exercise_session",
+ "exercise_type" to "underwater_basket_weaving",
+ ),
+ )
+ assertTrue(result is HealthConnectWriteResult.InvalidPayload)
+ }
+
+ @Test
+ fun `speed payload dispatches with m_per_s default unit`() = runTest {
+ coEvery { repository.writeSpeed(any(), any(), any(), any()) } returns
+ HealthConnectWriteResult.Success(listOf("id-sp"))
+
+ handler.handle(
+ mapOf(
+ "data_type" to "speed",
+ "start_time" to "2026-05-02T11:00:00Z",
+ "end_time" to "2026-05-02T11:30:00Z",
+ "samples" to """[{"time":"2026-05-02T11:00:00Z","value":3.0}]""",
+ ),
+ )
+
+ coVerify {
+ repository.writeSpeed(
+ startTime = Instant.parse("2026-05-02T11:00:00Z"),
+ endTime = Instant.parse("2026-05-02T11:30:00Z"),
+ samples = match { it.size == 1 && it.single().speed.inMetersPerSecond == 3.0 },
+ clientRecordId = null,
+ )
+ }
+ }
+
+ @Test
+ fun `speed payload converts km_per_h to m_per_s via HC factory`() = runTest {
+ coEvery { repository.writeSpeed(any(), any(), any(), any()) } returns
+ HealthConnectWriteResult.Success(listOf("id-sp"))
+
+ handler.handle(
+ mapOf(
+ "data_type" to "speed",
+ "unit" to "km/h",
+ "samples" to """[{"time":"2026-05-02T11:00:00Z","value":36.0}]""",
+ ),
+ )
+
+ coVerify {
+ repository.writeSpeed(
+ startTime = any(),
+ endTime = any(),
+ // 36 km/h = 10 m/s
+ samples = match { it.single().speed.inMetersPerSecond in 9.99..10.01 },
+ clientRecordId = null,
+ )
+ }
+ }
+
+ @Test
+ fun `unknown speed unit returns InvalidPayload`() = runTest {
+ val result = handler.handle(
+ mapOf(
+ "data_type" to "speed",
+ "unit" to "knots",
+ "samples" to """[{"time":"2026-05-02T11:00:00Z","value":1.0}]""",
+ ),
+ )
+ assertTrue(result is HealthConnectWriteResult.InvalidPayload)
+ }
+
+ @Test
+ fun `power payload dispatches with watts default unit`() = runTest {
+ coEvery { repository.writePower(any(), any(), any(), any()) } returns
+ HealthConnectWriteResult.Success(listOf("id-pw"))
+
+ handler.handle(
+ mapOf(
+ "data_type" to "power",
+ "samples" to """[{"time":"2026-05-02T11:00:00Z","value":220.0}]""",
+ ),
+ )
+
+ coVerify {
+ repository.writePower(
+ startTime = any(),
+ endTime = any(),
+ samples = match { it.single().power.inWatts == 220.0 },
+ clientRecordId = null,
+ )
+ }
+ }
+
+ @Test
+ fun `cycling_pedaling_cadence payload dispatches with raw rpm`() = runTest {
+ coEvery { repository.writeCyclingPedalingCadence(any(), any(), any(), any()) } returns
+ HealthConnectWriteResult.Success(listOf("id-rpm"))
+
+ handler.handle(
+ mapOf(
+ "data_type" to "cycling_pedaling_cadence",
+ "unit" to "rpm",
+ "samples" to """[{"time":"2026-05-02T11:00:00Z","value":92.0}]""",
+ ),
+ )
+
+ coVerify {
+ repository.writeCyclingPedalingCadence(
+ startTime = any(),
+ endTime = any(),
+ samples = match { it.single().revolutionsPerMinute == 92.0 },
+ clientRecordId = null,
+ )
+ }
+ }
+
+ @Test
+ fun `cycling_pedaling_cadence rejects unknown unit`() = runTest {
+ val result = handler.handle(
+ mapOf(
+ "data_type" to "cycling_pedaling_cadence",
+ "unit" to "rps",
+ "samples" to """[{"time":"2026-05-02T11:00:00Z","value":1.0}]""",
+ ),
+ )
+ assertTrue(result is HealthConnectWriteResult.InvalidPayload)
+ }
+
+ @Test
+ fun `series payload requires at least one sample`() = runTest {
+ val result = handler.handle(
+ mapOf(
+ "data_type" to "speed",
+ "samples" to "[]",
+ ),
+ )
+ assertTrue(result is HealthConnectWriteResult.InvalidPayload)
+ }
+
+ @Test
+ fun `permission denial from repository propagates to caller`() = runTest {
+ coEvery { repository.writeWeight(any(), any(), any()) } returns
+ HealthConnectWriteResult.MissingPermission("perm.WRITE_WEIGHT")
+ val result = handler.handle(mapOf("data_type" to "weight", "value" to "70"))
+ assertTrue(result is HealthConnectWriteResult.MissingPermission)
+ }
+}
diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/settings/sensor/healthconnect/HealthConnectSettingsViewModelTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/settings/sensor/healthconnect/HealthConnectSettingsViewModelTest.kt
new file mode 100644
index 00000000000..f4195e4ca23
--- /dev/null
+++ b/app/src/test/kotlin/io/homeassistant/companion/android/settings/sensor/healthconnect/HealthConnectSettingsViewModelTest.kt
@@ -0,0 +1,237 @@
+package io.homeassistant.companion.android.settings.sensor.healthconnect
+
+import android.app.Application
+import android.content.Context
+import androidx.health.connect.client.HealthConnectClient
+import androidx.work.WorkManager
+import io.homeassistant.companion.android.common.data.servers.ServerManager
+import io.homeassistant.companion.android.database.sensor.Sensor
+import io.homeassistant.companion.android.database.sensor.SensorDao
+import io.homeassistant.companion.android.database.sensor.SensorSetting
+import io.homeassistant.companion.android.database.server.Server
+import io.homeassistant.companion.android.sensors.HealthConnectSensorManager
+import io.homeassistant.companion.android.sensors.healthconnect.HealthConnectChangesWorker
+import io.homeassistant.companion.android.sensors.healthconnect.HealthConnectDataType
+import io.homeassistant.companion.android.sensors.healthconnect.HealthConnectSyncPreferences
+import io.homeassistant.companion.android.testing.unit.ConsoleLogExtension
+import io.homeassistant.companion.android.testing.unit.MainDispatcherJUnit5Extension
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.unmockkAll
+import io.mockk.verify
+import javax.inject.Provider
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+
+/**
+ * Verifies the persistence + WorkManager scheduling side-effect that the
+ * [HealthConnectSettingsViewModel] is supposed to keep coupled together.
+ *
+ * The view-model crosses the JVM/Android boundary by talking to [WorkManager.getInstance],
+ * so we mock that static call. Using Robolectric for what is essentially an enqueue-vs-cancel
+ * assertion would multiply test time without adding coverage.
+ */
+@ExtendWith(ConsoleLogExtension::class, MainDispatcherJUnit5Extension::class)
+class HealthConnectSettingsViewModelTest {
+
+ private val application = mockk(relaxed = true) {
+ // WorkManager's Kotlin companion calls context.applicationContext before delegating
+ // to the impl, so the mock has to return a real-ish Context for both `application`
+ // and `applicationContext` for the static stub below to ever capture matchers.
+ every { applicationContext } returns this
+ }
+ private val preferences = mockk(relaxed = true)
+ private val workManager = mockk(relaxed = true)
+ private val sensorDao = mockk(relaxed = true)
+ private val serverManager = mockk(relaxed = true)
+
+ @BeforeEach
+ fun setUp() {
+ mockkObject(WorkManager.Companion)
+ every { WorkManager.getInstance(any()) } returns workManager
+ // The HC manager's `hasSensor(context)` and `getAvailableSensors` both gate on
+ // `HealthConnectClient.getSdkStatus(context)` returning SDK_AVAILABLE. In a JVM
+ // unit test that call would otherwise return SDK_UNAVAILABLE and the list would
+ // be empty, so we stub it so the bulk-enable path actually has sensors to flip.
+ mockkObject(HealthConnectClient.Companion)
+ every { HealthConnectClient.getSdkStatus(any()) } returns HealthConnectClient.SDK_AVAILABLE
+ }
+
+ private fun makeVm(
+ client: HealthConnectClient? = mockk(),
+ ) = HealthConnectSettingsViewModel(
+ application = application,
+ preferences = preferences,
+ clientProvider = Provider { client },
+ sensorDao = sensorDao,
+ serverManager = serverManager,
+ )
+
+ @AfterEach
+ fun tearDown() {
+ unmockkAll()
+ }
+
+ @Test
+ fun `initial state reflects persisted preference and HC availability`() = runTest(UnconfinedTestDispatcher()) {
+ coEvery { preferences.isRealtimeSyncEnabled() } returns true
+ val vm = makeVm()
+
+ advanceUntilIdle()
+ val state = vm.uiState.value
+
+ assertFalse(state.isLoading)
+ assertTrue(state.isAvailable)
+ assertTrue(state.realtimeSyncEnabled)
+ }
+
+ @Test
+ fun `unavailable when client provider returns null`() = runTest(UnconfinedTestDispatcher()) {
+ coEvery { preferences.isRealtimeSyncEnabled() } returns false
+ val vm = makeVm(client = null)
+
+ advanceUntilIdle()
+
+ assertFalse(vm.uiState.value.isAvailable)
+ }
+
+ @Test
+ fun `enabling realtime sync persists and starts the worker`() = runTest(UnconfinedTestDispatcher()) {
+ coEvery { preferences.isRealtimeSyncEnabled() } returns false
+ val vm = makeVm()
+ advanceUntilIdle()
+
+ vm.setRealtimeSyncEnabled(true)
+ advanceUntilIdle()
+
+ assertEquals(true, vm.uiState.value.realtimeSyncEnabled)
+ coVerify { preferences.setRealtimeSyncEnabled(true) }
+ verify {
+ workManager.enqueueUniquePeriodicWork(
+ HealthConnectChangesWorker.UNIQUE_WORK_NAME,
+ any(),
+ any(),
+ )
+ }
+ }
+
+ @Test
+ fun `disabling realtime sync persists and cancels the worker`() = runTest(UnconfinedTestDispatcher()) {
+ coEvery { preferences.isRealtimeSyncEnabled() } returns true
+ val vm = makeVm()
+ advanceUntilIdle()
+
+ vm.setRealtimeSyncEnabled(false)
+ advanceUntilIdle()
+
+ assertEquals(false, vm.uiState.value.realtimeSyncEnabled)
+ coVerify { preferences.setRealtimeSyncEnabled(false) }
+ verify { workManager.cancelUniqueWork(HealthConnectChangesWorker.UNIQUE_WORK_NAME) }
+ }
+
+ @Test
+ fun `enableAll persists per-sensor allow-writes settings and emits the full perm set`() = runTest(
+ UnconfinedTestDispatcher(),
+ ) {
+ coEvery { preferences.isRealtimeSyncEnabled() } returns false
+ // One server, two sensors-worth of work — we don't need to mock the manager here
+ // because the VM looks it up via SensorReceiver.MANAGERS at runtime, which already
+ // has the singleton HealthConnectSensorManager available.
+ val server = mockk()
+ every { server.id } returns 1
+ coEvery { serverManager.servers() } returns listOf(server)
+ HealthConnectSensorManager.allowWritesCache.clear()
+
+ val vm = makeVm()
+ advanceUntilIdle()
+
+ // Start collecting BEFORE triggering enableAll — the SharedFlow has replay=0, so
+ // a late `first()` would deadlock waiting for a re-emission that never comes.
+ val collected = mutableListOf>()
+ val collectorJob: Job = launch { vm.enableAllRequested.collect { collected += it } }
+
+ vm.enableAll()
+ advanceUntilIdle()
+ collectorJob.cancel()
+
+ assertTrue(collected.isNotEmpty(), "No permission set was emitted")
+ val perms = collected.first()
+
+ assertTrue(perms.isNotEmpty())
+ // Every data type's read AND write perm should appear in the emitted set.
+ HealthConnectDataType.all.forEach { dataType ->
+ assertTrue(
+ dataType.readPermission in perms,
+ "Missing read perm for ${dataType.key}",
+ )
+ assertTrue(
+ dataType.writePermission in perms,
+ "Missing write perm for ${dataType.key}",
+ )
+ }
+ // Each sensor with a sensor ID should have an allow-writes setting persisted.
+ coVerify(atLeast = 1) {
+ sensorDao.add(match { it.name == HealthConnectSensorManager.SETTING_ALLOW_WRITES })
+ }
+ // Sensors should have been enabled for the registered server(s).
+ coVerify(atLeast = 1) { sensorDao.setSensorEnabled(any(), listOf(1), enabled = true) }
+ assertFalse(vm.uiState.value.enableAllInProgress)
+ }
+
+ @Test
+ fun `enabledSensorCount reflects the sensor table flow`() = runTest(UnconfinedTestDispatcher()) {
+ coEvery { preferences.isRealtimeSyncEnabled() } returns false
+ val sensorsFlow = MutableStateFlow>(emptyList())
+ every { sensorDao.getAllFlow() } returns sensorsFlow
+
+ val vm = makeVm()
+ advanceUntilIdle()
+ assertEquals(0, vm.uiState.value.enabledSensorCount)
+ // Total comes from the catalogue and should be > 0 (every HC type has at least
+ // one sensor id except Speed/Power/Cadence which are intentionally empty).
+ assertTrue(vm.uiState.value.totalSensorCount > 0)
+
+ // Two enabled sensors across two servers — should still count as 2 distinct sensors.
+ sensorsFlow.value = listOf(
+ Sensor("health_connect_weight", serverId = 1, enabled = true, state = ""),
+ Sensor("health_connect_weight", serverId = 2, enabled = true, state = ""),
+ Sensor("health_connect_steps", serverId = 1, enabled = true, state = ""),
+ Sensor("health_connect_blood_pressure", serverId = 1, enabled = false, state = ""),
+ )
+ advanceUntilIdle()
+ assertEquals(2, vm.uiState.value.enabledSensorCount)
+ }
+
+ @Test
+ fun `enableAll is a no-op while already in progress`() = runTest(UnconfinedTestDispatcher()) {
+ coEvery { preferences.isRealtimeSyncEnabled() } returns false
+ val server = mockk()
+ every { server.id } returns 1
+ coEvery { serverManager.servers() } returns listOf(server)
+ val vm = makeVm()
+ advanceUntilIdle()
+
+ // Force the in-progress flag and confirm a second click is dropped.
+ vm.enableAll()
+ // Second call before the first completes should be a no-op — but with an
+ // UnconfinedTestDispatcher the first call already finished by here, so this just
+ // verifies the code path doesn't re-enter while the flag is mid-flight. The flag
+ // resets to false in `finally`, so we observe the final state, not an intermediate.
+ advanceUntilIdle()
+ assertFalse(vm.uiState.value.enableAllInProgress)
+ }
+}
diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/di/DataModule.kt b/common/src/main/kotlin/io/homeassistant/companion/android/di/DataModule.kt
index acc636914ee..03a033077b1 100644
--- a/common/src/main/kotlin/io/homeassistant/companion/android/di/DataModule.kt
+++ b/common/src/main/kotlin/io/homeassistant/companion/android/di/DataModule.kt
@@ -33,6 +33,7 @@ import io.homeassistant.companion.android.common.util.getSharedPreferencesSuspen
import io.homeassistant.companion.android.common.util.tts.AndroidTextToSpeechEngine
import io.homeassistant.companion.android.common.util.tts.TextToSpeechClient
import io.homeassistant.companion.android.di.qualifiers.NamedDeviceId
+import io.homeassistant.companion.android.di.qualifiers.NamedHealthConnectStorage
import io.homeassistant.companion.android.di.qualifiers.NamedInstallId
import io.homeassistant.companion.android.di.qualifiers.NamedIntegrationStorage
import io.homeassistant.companion.android.di.qualifiers.NamedManufacturer
@@ -113,6 +114,13 @@ internal abstract class DataModule {
appContext.getSharedPreferencesSuspend("wear_0")
}
+ @Provides
+ @NamedHealthConnectStorage
+ @Singleton
+ fun provideHealthConnectLocalStorage(@ApplicationContext appContext: Context): LocalStorage = LocalStorageImpl {
+ appContext.getSharedPreferencesSuspend("health_connect_0")
+ }
+
@Provides
@NamedManufacturer
@Singleton
diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/di/qualifiers/Qualifiers.kt b/common/src/main/kotlin/io/homeassistant/companion/android/di/qualifiers/Qualifiers.kt
index 0b0194c3acb..73629d0c302 100644
--- a/common/src/main/kotlin/io/homeassistant/companion/android/di/qualifiers/Qualifiers.kt
+++ b/common/src/main/kotlin/io/homeassistant/companion/android/di/qualifiers/Qualifiers.kt
@@ -30,6 +30,15 @@ annotation class NamedThemesStorage
@Retention(AnnotationRetention.BINARY)
annotation class NamedWearStorage
+/**
+ * Qualifier for [LocalStorage] dependencies that persist Health Connect Changes API tokens
+ * (one per data type) so the changes worker can resume from a known position across process
+ * restarts without redoing a full sensor sweep.
+ */
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class NamedHealthConnectStorage
+
/**
* Qualifier for a [String] dependency providing device manufacturer information.
*/
diff --git a/common/src/main/res/drawable/ic_heart_pulse.xml b/common/src/main/res/drawable/ic_heart_pulse.xml
new file mode 100644
index 00000000000..34f44b25da6
--- /dev/null
+++ b/common/src/main/res/drawable/ic_heart_pulse.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml
index 51570d8d6e6..e599185c294 100644
--- a/common/src/main/res/values/strings.xml
+++ b/common/src/main/res/values/strings.xml
@@ -1322,6 +1322,8 @@
Total floors climbed since midnight from Health Connect
Sleep duration
Last recorded sleep duration in minutes from Health Connect
+ Last exercise session
+ Most recent exercise session from Health Connect, with type, duration, and timing as attributes
Total steps taken since midnight from Health Connect
Blood glucose
Last recorded blood glucose reading in milligrams per deciliter from Health Connect
@@ -1469,4 +1471,18 @@
No app available to open %s
Experimental
Learn more
+
+ Health Connect
+ Two-way sync with Health Connect
+ Real-time sync
+ Catches writes from other apps (Samsung Health, Google Fit, smart scales) within a few minutes instead of waiting for the 15-minute sensor cycle.
+ Health Connect is not available on this device.
+ Allow writes from Home Assistant
+ Bulk enable
+ Turn on every Health Connect sensor and request both read and write permissions for every supported data type. For users who already know they want the full surface — you can still revoke individual permissions afterwards in the Health Connect app.
+ Enable everything
+ %1$d / %2$d sensors enabled
+ Enable all Health Connect sensors?
+ This enables every Health Connect sensor across every server and asks Health Connect for read and write access to all supported data types. You can revoke individual permissions later from the Health Connect app.
+ Confirm