From d7c792d4f8ad51eef7f3dfa940dbaae5b4a09fe9 Mon Sep 17 00:00:00 2001 From: Max <33982882+maxboeer@users.noreply.github.com> Date: Sat, 15 Aug 2026 05:28:31 +0200 Subject: [PATCH 1/3] android: add batched Health Connect heart-rate export --- .../health/HealthConnectHeartRateExporter.kt | 181 ++++++++++++++---- .../presentation/components/StyledSlider.kt | 3 + .../screens/HeartRateTestScreen.kt | 114 +++++++++-- .../viewmodel/AirPodsViewModel.kt | 26 +++ .../librepods/services/AirPodsService.kt | 12 ++ .../HealthConnectHeartRateExporterTest.kt | 64 +++++++ 6 files changed, 348 insertions(+), 52 deletions(-) create mode 100644 android/app/src/test/java/me/kavishdevar/librepods/health/HealthConnectHeartRateExporterTest.kt diff --git a/android/app/src/main/java/me/kavishdevar/librepods/health/HealthConnectHeartRateExporter.kt b/android/app/src/main/java/me/kavishdevar/librepods/health/HealthConnectHeartRateExporter.kt index c257da64f..53761e5d1 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/health/HealthConnectHeartRateExporter.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/health/HealthConnectHeartRateExporter.kt @@ -34,6 +34,48 @@ import java.security.MessageDigest import java.time.Instant import java.time.ZoneId +internal const val MIN_HEART_RATE_BATCH_INTERVAL_SECONDS = 30 +internal const val MAX_HEART_RATE_BATCH_INTERVAL_SECONDS = 15 * 60 +internal const val DEFAULT_HEART_RATE_BATCH_INTERVAL_SECONDS = 5 * 60 +internal const val HEART_RATE_BATCH_INTERVAL_STEP_SECONDS = 30 + +internal fun normalizeHeartRateBatchIntervalSeconds(seconds: Int): Int { + val clamped = seconds.coerceIn( + MIN_HEART_RATE_BATCH_INTERVAL_SECONDS, + MAX_HEART_RATE_BATCH_INTERVAL_SECONDS + ) + return ((clamped + HEART_RATE_BATCH_INTERVAL_STEP_SECONDS / 2) / + HEART_RATE_BATCH_INTERVAL_STEP_SECONDS * HEART_RATE_BATCH_INTERVAL_STEP_SECONDS) + .coerceIn( + MIN_HEART_RATE_BATCH_INTERVAL_SECONDS, + MAX_HEART_RATE_BATCH_INTERVAL_SECONDS + ) +} + +internal fun buildHeartRateRecordSamples( + samples: List, + preserveSamples: Boolean, + averageSampleTimeMillis: Long +): List { + require(samples.isNotEmpty()) + if (preserveSamples) { + return samples.map { sample -> + HeartRateRecord.Sample( + time = Instant.ofEpochMilli(sample.receivedAtMillis), + beatsPerMinute = sample.bpm.toLong() + ) + } + } + + val total = samples.sumOf { it.bpm.toLong() } + return listOf( + HeartRateRecord.Sample( + time = Instant.ofEpochMilli(averageSampleTimeMillis), + beatsPerMinute = (total + samples.size / 2L) / samples.size + ) + ) +} + /** User-visible Health Connect state for the optional heart-rate export. */ enum class HealthConnectExportStatus { UNAVAILABLE, @@ -48,7 +90,9 @@ enum class HealthConnectExportStatus { data class HealthConnectExportState( val enabled: Boolean = false, val status: HealthConnectExportStatus = HealthConnectExportStatus.UNAVAILABLE, - val detailedSamples: Boolean = false + val detailedSamples: Boolean = false, + val batchDetailedSamples: Boolean = false, + val batchIntervalSeconds: Int = DEFAULT_HEART_RATE_BATCH_INTERVAL_SECONDS ) /** @@ -74,7 +118,14 @@ class HealthConnectHeartRateExporter( val clientRecordId: String, val startTimeMillis: Long, val endTimeMillis: Long, - val partialInterval: Boolean + val partialInterval: Boolean, + val preservesSamples: Boolean + ) + + private data class ExportOptions( + val detailedSamples: Boolean, + val batchDetailedSamples: Boolean, + val batchIntervalSeconds: Int ) private val appContext = context.applicationContext @@ -82,14 +133,24 @@ class HealthConnectHeartRateExporter( private val pendingSamples = linkedMapOf() private var pendingRecord: PendingRecord? = null private var intervalWindowStartMillis: Long? = null - private var requestedDetailedSamples: Boolean? = null + private var requestedExportOptions: ExportOptions? = null private var healthConnectClient: HealthConnectClient? = null private var scheduledFlush: Job? = null private val _state = MutableStateFlow( HealthConnectExportState( status = statusForSdk(), - detailedSamples = sharedPreferences.getBoolean(DETAILED_SAMPLES_PREFERENCE, false) + detailedSamples = sharedPreferences.getBoolean(DETAILED_SAMPLES_PREFERENCE, false), + batchDetailedSamples = sharedPreferences.getBoolean( + BATCH_DETAILED_SAMPLES_PREFERENCE, + false + ), + batchIntervalSeconds = normalizeHeartRateBatchIntervalSeconds( + sharedPreferences.getInt( + BATCH_INTERVAL_SECONDS_PREFERENCE, + DEFAULT_HEART_RATE_BATCH_INTERVAL_SECONDS + ) + ) ) ) val state: StateFlow get() = _state @@ -97,9 +158,17 @@ class HealthConnectHeartRateExporter( private fun updateState( enabled: Boolean = _state.value.enabled, status: HealthConnectExportStatus = _state.value.status, - detailedSamples: Boolean = _state.value.detailedSamples + detailedSamples: Boolean = _state.value.detailedSamples, + batchDetailedSamples: Boolean = _state.value.batchDetailedSamples, + batchIntervalSeconds: Int = _state.value.batchIntervalSeconds ) { - _state.value = HealthConnectExportState(enabled, status, detailedSamples) + _state.value = HealthConnectExportState( + enabled = enabled, + status = status, + detailedSamples = detailedSamples, + batchDetailedSamples = batchDetailedSamples, + batchIntervalSeconds = batchIntervalSeconds + ) } fun refresh() { @@ -189,14 +258,29 @@ class HealthConnectHeartRateExporter( } fun setDetailedSamples(detailed: Boolean) { + requestExportOptionsChange { it.copy(detailedSamples = detailed) } + } + + fun setBatchDetailedSamples(enabled: Boolean) { + requestExportOptionsChange { it.copy(batchDetailedSamples = enabled) } + } + + fun setBatchIntervalSeconds(seconds: Int) { + val normalizedSeconds = normalizeHeartRateBatchIntervalSeconds(seconds) + requestExportOptionsChange { it.copy(batchIntervalSeconds = normalizedSeconds) } + } + + private fun requestExportOptionsChange(transform: (ExportOptions) -> ExportOptions) { scope.launch { mutex.withLock { - if (_state.value.detailedSamples == detailed) { - requestedDetailedSamples = null + val currentOptions = currentExportOptions() + val requestedOptions = transform(requestedExportOptions ?: currentOptions) + if (requestedOptions == currentOptions) { + requestedExportOptions = null return@withLock } - requestedDetailedSamples = detailed + requestedExportOptions = requestedOptions scheduledFlush?.cancel() scheduledFlush = null if (hasPendingSamplesLocked() && @@ -204,7 +288,7 @@ class HealthConnectHeartRateExporter( ) { return@withLock } - applyRequestedDetailLocked() + applyRequestedExportOptionsLocked() } } } @@ -271,14 +355,14 @@ class HealthConnectHeartRateExporter( private suspend fun flushLocked(forcePartialInterval: Boolean = false): Boolean { if (!hasPendingSamplesLocked()) { - applyRequestedDetailLocked() + applyRequestedExportOptionsLocked() return true } if (!_state.value.enabled) return false while (_state.value.enabled && hasPendingSamplesLocked()) { val record = getOrCreatePendingRecordLocked( - forcePartialInterval || requestedDetailedSamples != null + forcePartialInterval || requestedExportOptions != null ) if (record == null) { scheduleNextFlushLocked() @@ -320,7 +404,7 @@ class HealthConnectHeartRateExporter( } } - applyRequestedDetailLocked() + applyRequestedExportOptionsLocked() return true } @@ -330,14 +414,22 @@ class HealthConnectHeartRateExporter( scheduleFlushLocked(RETRY_INTERVAL_MILLIS) } - private fun applyRequestedDetailLocked() { - val detailed = requestedDetailedSamples ?: return + private fun applyRequestedExportOptionsLocked() { + val options = requestedExportOptions ?: return if (hasPendingSamplesLocked()) return intervalWindowStartMillis = null - sharedPreferences.edit { putBoolean(DETAILED_SAMPLES_PREFERENCE, detailed) } - updateState(detailedSamples = detailed) - requestedDetailedSamples = null + sharedPreferences.edit { + putBoolean(DETAILED_SAMPLES_PREFERENCE, options.detailedSamples) + putBoolean(BATCH_DETAILED_SAMPLES_PREFERENCE, options.batchDetailedSamples) + putInt(BATCH_INTERVAL_SECONDS_PREFERENCE, options.batchIntervalSeconds) + } + updateState( + detailedSamples = options.detailedSamples, + batchDetailedSamples = options.batchDetailedSamples, + batchIntervalSeconds = options.batchIntervalSeconds + ) + requestedExportOptions = null } private fun scheduleFlushLocked(delayMillis: Long) { @@ -405,7 +497,8 @@ class HealthConnectHeartRateExporter( ), startTimeMillis = recordStartTime, endTimeMillis = recordEndTime, - partialInterval = partialInterval + partialInterval = partialInterval, + preservesSamples = usesDetailedBatching() ).also { pendingRecord = it } } @@ -419,14 +512,11 @@ class HealthConnectHeartRateExporter( val startTimestamp = Instant.ofEpochMilli(record.startTimeMillis) val endTimestamp = Instant.ofEpochMilli(record.endTimeMillis) val zoneRules = ZoneId.systemDefault().rules - val samples = listOf( - HeartRateRecord.Sample( - time = Instant.ofEpochMilli( - record.startTimeMillis + - (record.endTimeMillis - record.startTimeMillis) / 2L - ), - beatsPerMinute = averageBpm(record.samples) - ) + val samples = buildHeartRateRecordSamples( + samples = record.samples.map { it.sample }, + preserveSamples = record.preservesSamples, + averageSampleTimeMillis = record.startTimeMillis + + (record.endTimeMillis - record.startTimeMillis) / 2L ) return HeartRateRecord( @@ -470,24 +560,35 @@ class HealthConnectHeartRateExporter( } } - private fun exportIntervalMillis(): Long = if (_state.value.detailedSamples) { - SECOND_INTERVAL_MILLIS - } else { - MINUTE_INTERVAL_MILLIS + private fun exportIntervalMillis(): Long = when { + usesDetailedBatching() -> _state.value.batchIntervalSeconds * SECOND_INTERVAL_MILLIS + _state.value.detailedSamples -> SECOND_INTERVAL_MILLIS + else -> MINUTE_INTERVAL_MILLIS } + private fun usesDetailedBatching(): Boolean = + _state.value.detailedSamples && _state.value.batchDetailedSamples + + private fun currentExportOptions(): ExportOptions = ExportOptions( + detailedSamples = _state.value.detailedSamples, + batchDetailedSamples = _state.value.batchDetailedSamples, + batchIntervalSeconds = _state.value.batchIntervalSeconds + ) + private fun trimBufferLocked() { - while (bufferedSampleCountLocked() > MAX_BUFFERED_SAMPLES) { + val maxBufferedSamples = if ( + usesDetailedBatching() || pendingRecord?.preservesSamples == true + ) { + MAX_BATCH_BUFFERED_SAMPLES + } else { + MAX_BUFFERED_SAMPLES + } + while (bufferedSampleCountLocked() > maxBufferedSamples) { val oldestId = pendingSamples.keys.firstOrNull() ?: break pendingSamples.remove(oldestId) } } - private fun averageBpm(samples: List): Long { - val total = samples.sumOf { it.sample.bpm.toLong() } - return (total + samples.size / 2L) / samples.size - } - private fun recordClientRecordId( samples: List, startTimeMillis: Long, @@ -541,8 +642,14 @@ class HealthConnectHeartRateExporter( private const val EXPORT_PREFERENCE = "heart_rate_health_connect_export_enabled" private const val DETAILED_SAMPLES_PREFERENCE = "heart_rate_health_connect_detailed_samples" + private const val BATCH_DETAILED_SAMPLES_PREFERENCE = + "heart_rate_health_connect_batch_detailed_samples" + private const val BATCH_INTERVAL_SECONDS_PREFERENCE = + "heart_rate_health_connect_batch_interval_seconds" private const val RECORD_CLIENT_RECORD_ID_PREFIX = "librepods-heart-rate-record-v1-" private const val MAX_BUFFERED_SAMPLES = 300 + // Retain the record being retried plus another full window at the maximum interval. + private const val MAX_BATCH_BUFFERED_SAMPLES = MAX_HEART_RATE_BATCH_INTERVAL_SECONDS * 2 private const val SECOND_INTERVAL_MILLIS = 1_000L private const val MINUTE_INTERVAL_MILLIS = 60_000L private const val RETRY_INTERVAL_MILLIS = 30_000L diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledSlider.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledSlider.kt index 5ce29a1cb..caed85479 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledSlider.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledSlider.kt @@ -219,6 +219,7 @@ fun StyledSlider( label: String? = null, value: Float, onValueChange: (Float) -> Unit, + onValueChangeFinished: () -> Unit = {}, valueRange: ClosedFloatingPointRange, backdrop: Backdrop = rememberLayerBackdrop(), snapPoints: List = emptyList(), @@ -339,6 +340,7 @@ fun StyledSlider( onValueChange(snapped) }, + onValueChangeFinished = onValueChangeFinished, valueRange = valueRange, enabled = enabled ) @@ -635,6 +637,7 @@ fun StyledSlider( }, onDragStopped = { onValueChange((value * 100).roundToInt() / 100f) + onValueChangeFinished() } ) .then(momentumAnimation.modifier) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt index 92b237d6c..e5a94a732 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt @@ -44,6 +44,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -68,7 +69,11 @@ import me.kavishdevar.librepods.bluetooth.HeartRateSample import me.kavishdevar.librepods.health.HealthConnectExportState import me.kavishdevar.librepods.health.HealthConnectExportStatus import me.kavishdevar.librepods.health.HealthConnectHeartRateExporter +import me.kavishdevar.librepods.health.MAX_HEART_RATE_BATCH_INTERVAL_SECONDS +import me.kavishdevar.librepods.health.MIN_HEART_RATE_BATCH_INTERVAL_SECONDS +import me.kavishdevar.librepods.health.normalizeHeartRateBatchIntervalSeconds import me.kavishdevar.librepods.presentation.components.HeartRateStatusChip +import me.kavishdevar.librepods.presentation.components.StyledSlider import me.kavishdevar.librepods.presentation.components.StyledSwitch import me.kavishdevar.librepods.presentation.components.StyledToggle import me.kavishdevar.librepods.presentation.components.rememberHeartRateSampleIsDisplayable @@ -81,6 +86,7 @@ import java.text.DateFormat import java.util.Date import kotlin.math.ceil import kotlin.math.floor +import kotlin.math.roundToInt import kotlinx.coroutines.delay @Composable @@ -188,7 +194,9 @@ fun HeartRateTestScreen(viewModel: AirPodsViewModel, navigateToWorkout: () -> Un ) } }, - onDetailedSamplesChanged = viewModel::setHealthConnectDetailedSamples + onDetailedSamplesChanged = viewModel::setHealthConnectDetailedSamples, + onBatchDetailedSamplesChanged = viewModel::setHealthConnectBatchDetailedSamples, + onBatchIntervalChanged = viewModel::setHealthConnectBatchIntervalSeconds ) Spacer(modifier = Modifier.height(12.dp)) @@ -338,14 +346,16 @@ private fun HeartRateSummaryCard( private fun HealthConnectControls( state: HealthConnectExportState, onExportChanged: (Boolean) -> Unit, - onDetailedSamplesChanged: (Boolean) -> Unit + onDetailedSamplesChanged: (Boolean) -> Unit, + onBatchDetailedSamplesChanged: (Boolean) -> Unit, + onBatchIntervalChanged: (Int) -> Unit ) { val available = state.status.isAvailable StyledToggle( title = "Health Connect", label = "Save heart-rate samples", - description = healthConnectDescription(state.status, state.detailedSamples), + description = healthConnectDescription(state), checked = state.enabled, enabled = available, onCheckedChange = onExportChanged @@ -356,15 +366,77 @@ private fun HealthConnectControls( StyledToggle( title = null, label = "Detailed samples", - description = if (state.detailedSamples) { - "Save one BPM record every second." - } else { - "Save one average BPM record every minute." + description = when { + !state.detailedSamples -> "Save one average BPM record every minute." + state.batchDetailedSamples -> "Keep every validated one-second BPM sample." + else -> "Save one BPM record every second." }, checked = state.detailedSamples, enabled = available, onCheckedChange = onDetailedSamplesChanged ) + + Spacer(modifier = Modifier.height(8.dp)) + + StyledToggle( + title = null, + label = "Batch detailed samples", + description = when { + !state.detailedSamples -> "Enable Detailed samples to use batching." + state.batchDetailedSamples -> + "Buffer detailed samples and write them together every " + + "${formatBatchInterval(state.batchIntervalSeconds)}." + + else -> "Write detailed samples to Health Connect as they arrive." + }, + checked = state.batchDetailedSamples, + enabled = available && state.detailedSamples, + onCheckedChange = onBatchDetailedSamplesChanged + ) + + if (state.detailedSamples && state.batchDetailedSamples) { + Spacer(modifier = Modifier.height(8.dp)) + HealthConnectBatchIntervalControl( + intervalSeconds = state.batchIntervalSeconds, + enabled = available, + onIntervalChanged = onBatchIntervalChanged + ) + } +} + +@Composable +private fun HealthConnectBatchIntervalControl( + intervalSeconds: Int, + enabled: Boolean, + onIntervalChanged: (Int) -> Unit +) { + var sliderValue by remember(intervalSeconds) { + mutableFloatStateOf(intervalSeconds.toFloat()) + } + + StyledSlider( + label = "Batch interval · ${formatBatchInterval(sliderValue.roundToInt())}", + value = sliderValue, + onValueChange = { value -> + sliderValue = normalizeHeartRateBatchIntervalSeconds(value.roundToInt()).toFloat() + }, + onValueChangeFinished = { + onIntervalChanged(sliderValue.roundToInt()) + }, + valueRange = MIN_HEART_RATE_BATCH_INTERVAL_SECONDS.toFloat().. + MAX_HEART_RATE_BATCH_INTERVAL_SECONDS.toFloat(), + snapPoints = listOf( + MIN_HEART_RATE_BATCH_INTERVAL_SECONDS.toFloat(), + 5 * 60f, + MAX_HEART_RATE_BATCH_INTERVAL_SECONDS.toFloat() + ), + snapThreshold = 1f, + startLabel = "30 sec", + endLabel = "15 min", + independent = true, + description = "How often buffered samples are written to Health Connect.", + enabled = enabled + ) } @Composable @@ -426,10 +498,7 @@ private val HealthConnectExportStatus.requiresPermissionRequest: Boolean this == HealthConnectExportStatus.PERMISSION_DENIED || this == HealthConnectExportStatus.ERROR -private fun healthConnectDescription( - status: HealthConnectExportStatus, - detailedSamples: Boolean -): String = when (status) { +private fun healthConnectDescription(state: HealthConnectExportState): String = when (state.status) { HealthConnectExportStatus.UNAVAILABLE -> "Health Connect is not available on this device." @@ -445,16 +514,31 @@ private fun healthConnectDescription( HealthConnectExportStatus.READY -> "Available. Enable this to save validated samples on this device." - HealthConnectExportStatus.ENABLED -> if (detailedSamples) { - "Validated heart-rate data is saved every second." - } else { - "Validated samples are averaged into one Health Connect record per minute." + HealthConnectExportStatus.ENABLED -> when { + !state.detailedSamples -> + "Validated samples are averaged into one Health Connect record per minute." + + state.batchDetailedSamples -> + "Validated one-second samples are written together every " + + "${formatBatchInterval(state.batchIntervalSeconds)}." + + else -> "Validated heart-rate data is saved every second." } HealthConnectExportStatus.ERROR -> "A write failed. Buffered samples will be retried without creating duplicates." } +private fun formatBatchInterval(seconds: Int): String { + val minutes = seconds / 60 + val remainingSeconds = seconds % 60 + return when { + minutes == 0 -> "$remainingSeconds seconds" + remainingSeconds == 0 -> "$minutes ${if (minutes == 1) "minute" else "minutes"}" + else -> "$minutes min $remainingSeconds sec" + } +} + @Composable private fun HeartRateGraph(samples: List, nowMillis: Long) { val orderedSamples = remember(samples) { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt index 144f4c95f..0417771c1 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt @@ -767,6 +767,32 @@ class AirPodsViewModel( service.setHealthConnectDetailedSamples(detailed) } + fun setHealthConnectBatchDetailedSamples(enabled: Boolean) { + if (!isReady) return + if (isDemoMode) { + _uiState.update { + it.copy( + healthConnect = it.healthConnect.copy(batchDetailedSamples = enabled) + ) + } + return + } + service.setHealthConnectBatchDetailedSamples(enabled) + } + + fun setHealthConnectBatchIntervalSeconds(seconds: Int) { + if (!isReady) return + if (isDemoMode) { + _uiState.update { + it.copy( + healthConnect = it.healthConnect.copy(batchIntervalSeconds = seconds) + ) + } + return + } + service.setHealthConnectBatchIntervalSeconds(seconds) + } + fun markHealthConnectPermissionDenied() { if (!isReady || isDemoMode) return service.markHealthConnectPermissionDenied() diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt index e562bcc6c..81c75b1aa 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt @@ -3756,6 +3756,18 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList if (::heartRateExporter.isInitialized) heartRateExporter.setDetailedSamples(detailed) } + fun setHealthConnectBatchDetailedSamples(enabled: Boolean) { + if (::heartRateExporter.isInitialized) { + heartRateExporter.setBatchDetailedSamples(enabled) + } + } + + fun setHealthConnectBatchIntervalSeconds(seconds: Int) { + if (::heartRateExporter.isInitialized) { + heartRateExporter.setBatchIntervalSeconds(seconds) + } + } + fun markHealthConnectPermissionDenied() { if (::heartRateExporter.isInitialized) heartRateExporter.markPermissionDenied() } diff --git a/android/app/src/test/java/me/kavishdevar/librepods/health/HealthConnectHeartRateExporterTest.kt b/android/app/src/test/java/me/kavishdevar/librepods/health/HealthConnectHeartRateExporterTest.kt new file mode 100644 index 000000000..405d9145a --- /dev/null +++ b/android/app/src/test/java/me/kavishdevar/librepods/health/HealthConnectHeartRateExporterTest.kt @@ -0,0 +1,64 @@ +package me.kavishdevar.librepods.health + +import java.time.Instant +import me.kavishdevar.librepods.bluetooth.HeartRateSample +import org.junit.Assert.assertEquals +import org.junit.Test + +class HealthConnectHeartRateExporterTest { + @Test + fun detailedBatchPreservesEverySampleAndTimestamp() { + val samples = listOf( + sample(bpm = 72, sequence = 1, timestamp = 1_000L), + sample(bpm = 74, sequence = 2, timestamp = 2_000L), + sample(bpm = 73, sequence = 3, timestamp = 3_000L) + ) + + val recordSamples = buildHeartRateRecordSamples( + samples = samples, + preserveSamples = true, + averageSampleTimeMillis = 2_000L + ) + + assertEquals(listOf(72L, 74L, 73L), recordSamples.map { it.beatsPerMinute }) + assertEquals( + listOf( + Instant.ofEpochMilli(1_000L), + Instant.ofEpochMilli(2_000L), + Instant.ofEpochMilli(3_000L) + ), + recordSamples.map { it.time } + ) + } + + @Test + fun existingModesStillCreateOneRoundedAverage() { + val recordSamples = buildHeartRateRecordSamples( + samples = listOf( + sample(bpm = 70, sequence = 1, timestamp = 1_000L), + sample(bpm = 71, sequence = 2, timestamp = 2_000L) + ), + preserveSamples = false, + averageSampleTimeMillis = 1_500L + ) + + assertEquals(1, recordSamples.size) + assertEquals(71L, recordSamples.single().beatsPerMinute) + assertEquals(Instant.ofEpochMilli(1_500L), recordSamples.single().time) + } + + @Test + fun batchIntervalIsBoundedAndRoundedToThirtySeconds() { + assertEquals(30, normalizeHeartRateBatchIntervalSeconds(1)) + assertEquals(60, normalizeHeartRateBatchIntervalSeconds(46)) + assertEquals(330, normalizeHeartRateBatchIntervalSeconds(329)) + assertEquals(15 * 60, normalizeHeartRateBatchIntervalSeconds(60 * 60)) + } + + private fun sample(bpm: Int, sequence: Int, timestamp: Long) = HeartRateSample( + bpm = bpm, + sequence = sequence, + receivedAtMillis = timestamp, + receivedAtElapsedRealtime = timestamp + ) +} From 276b83f28dd64acc0e8ae43d33176a914e1cd5a5 Mon Sep 17 00:00:00 2001 From: Max <33982882+maxboeer@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:18:29 +0200 Subject: [PATCH 2/3] android: add configurable heart-rate export modes --- .../health/HealthConnectHeartRateExporter.kt | 120 +++++++--- .../presentation/navigation/AppNavGraph.kt | 10 + .../presentation/navigation/NavigationRoot.kt | 1 + .../presentation/navigation/Screen.kt | 3 + .../screens/HealthConnectSettingsScreen.kt | 206 ++++++++++++++++++ .../screens/HeartRateTestScreen.kt | 149 +++---------- .../viewmodel/AirPodsViewModel.kt | 22 +- .../librepods/services/AirPodsService.kt | 13 +- .../HealthConnectHeartRateExporterTest.kt | 58 ++++- 9 files changed, 406 insertions(+), 176 deletions(-) create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HealthConnectSettingsScreen.kt diff --git a/android/app/src/main/java/me/kavishdevar/librepods/health/HealthConnectHeartRateExporter.kt b/android/app/src/main/java/me/kavishdevar/librepods/health/HealthConnectHeartRateExporter.kt index 53761e5d1..a1211da39 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/health/HealthConnectHeartRateExporter.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/health/HealthConnectHeartRateExporter.kt @@ -34,21 +34,22 @@ import java.security.MessageDigest import java.time.Instant import java.time.ZoneId -internal const val MIN_HEART_RATE_BATCH_INTERVAL_SECONDS = 30 -internal const val MAX_HEART_RATE_BATCH_INTERVAL_SECONDS = 15 * 60 +internal const val MIN_HEART_RATE_EXPORT_INTERVAL_SECONDS = 30 +internal const val MAX_HEART_RATE_EXPORT_INTERVAL_SECONDS = 15 * 60 internal const val DEFAULT_HEART_RATE_BATCH_INTERVAL_SECONDS = 5 * 60 -internal const val HEART_RATE_BATCH_INTERVAL_STEP_SECONDS = 30 +internal const val DEFAULT_HEART_RATE_AVERAGE_INTERVAL_SECONDS = 60 +internal const val HEART_RATE_EXPORT_INTERVAL_STEP_SECONDS = 30 -internal fun normalizeHeartRateBatchIntervalSeconds(seconds: Int): Int { +internal fun normalizeHeartRateExportIntervalSeconds(seconds: Int): Int { val clamped = seconds.coerceIn( - MIN_HEART_RATE_BATCH_INTERVAL_SECONDS, - MAX_HEART_RATE_BATCH_INTERVAL_SECONDS + MIN_HEART_RATE_EXPORT_INTERVAL_SECONDS, + MAX_HEART_RATE_EXPORT_INTERVAL_SECONDS ) - return ((clamped + HEART_RATE_BATCH_INTERVAL_STEP_SECONDS / 2) / - HEART_RATE_BATCH_INTERVAL_STEP_SECONDS * HEART_RATE_BATCH_INTERVAL_STEP_SECONDS) + return ((clamped + HEART_RATE_EXPORT_INTERVAL_STEP_SECONDS / 2) / + HEART_RATE_EXPORT_INTERVAL_STEP_SECONDS * HEART_RATE_EXPORT_INTERVAL_STEP_SECONDS) .coerceIn( - MIN_HEART_RATE_BATCH_INTERVAL_SECONDS, - MAX_HEART_RATE_BATCH_INTERVAL_SECONDS + MIN_HEART_RATE_EXPORT_INTERVAL_SECONDS, + MAX_HEART_RATE_EXPORT_INTERVAL_SECONDS ) } @@ -87,13 +88,34 @@ enum class HealthConnectExportStatus { ERROR } +enum class HealthConnectExportMode { + EVERY_SECOND, + BATCHED, + AVERAGED +} + data class HealthConnectExportState( val enabled: Boolean = false, val status: HealthConnectExportStatus = HealthConnectExportStatus.UNAVAILABLE, val detailedSamples: Boolean = false, val batchDetailedSamples: Boolean = false, - val batchIntervalSeconds: Int = DEFAULT_HEART_RATE_BATCH_INTERVAL_SECONDS -) + val batchIntervalSeconds: Int = DEFAULT_HEART_RATE_BATCH_INTERVAL_SECONDS, + val averageIntervalSeconds: Int = DEFAULT_HEART_RATE_AVERAGE_INTERVAL_SECONDS +) { + val mode: HealthConnectExportMode + get() = when { + !detailedSamples -> HealthConnectExportMode.AVERAGED + batchDetailedSamples -> HealthConnectExportMode.BATCHED + else -> HealthConnectExportMode.EVERY_SECOND + } +} + +internal fun heartRateExportIntervalSeconds(state: HealthConnectExportState): Int = + when (state.mode) { + HealthConnectExportMode.EVERY_SECOND -> 1 + HealthConnectExportMode.BATCHED -> state.batchIntervalSeconds + HealthConnectExportMode.AVERAGED -> state.averageIntervalSeconds + } /** * Writes validated AirPods heart-rate samples to Health Connect at the selected interval. @@ -125,7 +147,8 @@ class HealthConnectHeartRateExporter( private data class ExportOptions( val detailedSamples: Boolean, val batchDetailedSamples: Boolean, - val batchIntervalSeconds: Int + val batchIntervalSeconds: Int, + val averageIntervalSeconds: Int ) private val appContext = context.applicationContext @@ -145,11 +168,17 @@ class HealthConnectHeartRateExporter( BATCH_DETAILED_SAMPLES_PREFERENCE, false ), - batchIntervalSeconds = normalizeHeartRateBatchIntervalSeconds( + batchIntervalSeconds = normalizeHeartRateExportIntervalSeconds( sharedPreferences.getInt( BATCH_INTERVAL_SECONDS_PREFERENCE, DEFAULT_HEART_RATE_BATCH_INTERVAL_SECONDS ) + ), + averageIntervalSeconds = normalizeHeartRateExportIntervalSeconds( + sharedPreferences.getInt( + AVERAGE_INTERVAL_SECONDS_PREFERENCE, + DEFAULT_HEART_RATE_AVERAGE_INTERVAL_SECONDS + ) ) ) ) @@ -160,14 +189,16 @@ class HealthConnectHeartRateExporter( status: HealthConnectExportStatus = _state.value.status, detailedSamples: Boolean = _state.value.detailedSamples, batchDetailedSamples: Boolean = _state.value.batchDetailedSamples, - batchIntervalSeconds: Int = _state.value.batchIntervalSeconds + batchIntervalSeconds: Int = _state.value.batchIntervalSeconds, + averageIntervalSeconds: Int = _state.value.averageIntervalSeconds ) { _state.value = HealthConnectExportState( enabled = enabled, status = status, detailedSamples = detailedSamples, batchDetailedSamples = batchDetailedSamples, - batchIntervalSeconds = batchIntervalSeconds + batchIntervalSeconds = batchIntervalSeconds, + averageIntervalSeconds = averageIntervalSeconds ) } @@ -257,19 +288,37 @@ class HealthConnectHeartRateExporter( } } - fun setDetailedSamples(detailed: Boolean) { - requestExportOptionsChange { it.copy(detailedSamples = detailed) } - } + fun setMode(mode: HealthConnectExportMode) { + requestExportOptionsChange { options -> + when (mode) { + HealthConnectExportMode.EVERY_SECOND -> options.copy( + detailedSamples = true, + batchDetailedSamples = false + ) - fun setBatchDetailedSamples(enabled: Boolean) { - requestExportOptionsChange { it.copy(batchDetailedSamples = enabled) } + HealthConnectExportMode.BATCHED -> options.copy( + detailedSamples = true, + batchDetailedSamples = true + ) + + HealthConnectExportMode.AVERAGED -> options.copy( + detailedSamples = false, + batchDetailedSamples = false + ) + } + } } fun setBatchIntervalSeconds(seconds: Int) { - val normalizedSeconds = normalizeHeartRateBatchIntervalSeconds(seconds) + val normalizedSeconds = normalizeHeartRateExportIntervalSeconds(seconds) requestExportOptionsChange { it.copy(batchIntervalSeconds = normalizedSeconds) } } + fun setAverageIntervalSeconds(seconds: Int) { + val normalizedSeconds = normalizeHeartRateExportIntervalSeconds(seconds) + requestExportOptionsChange { it.copy(averageIntervalSeconds = normalizedSeconds) } + } + private fun requestExportOptionsChange(transform: (ExportOptions) -> ExportOptions) { scope.launch { mutex.withLock { @@ -423,11 +472,13 @@ class HealthConnectHeartRateExporter( putBoolean(DETAILED_SAMPLES_PREFERENCE, options.detailedSamples) putBoolean(BATCH_DETAILED_SAMPLES_PREFERENCE, options.batchDetailedSamples) putInt(BATCH_INTERVAL_SECONDS_PREFERENCE, options.batchIntervalSeconds) + putInt(AVERAGE_INTERVAL_SECONDS_PREFERENCE, options.averageIntervalSeconds) } updateState( detailedSamples = options.detailedSamples, batchDetailedSamples = options.batchDetailedSamples, - batchIntervalSeconds = options.batchIntervalSeconds + batchIntervalSeconds = options.batchIntervalSeconds, + averageIntervalSeconds = options.averageIntervalSeconds ) requestedExportOptions = null } @@ -560,11 +611,8 @@ class HealthConnectHeartRateExporter( } } - private fun exportIntervalMillis(): Long = when { - usesDetailedBatching() -> _state.value.batchIntervalSeconds * SECOND_INTERVAL_MILLIS - _state.value.detailedSamples -> SECOND_INTERVAL_MILLIS - else -> MINUTE_INTERVAL_MILLIS - } + private fun exportIntervalMillis(): Long = + heartRateExportIntervalSeconds(_state.value) * SECOND_INTERVAL_MILLIS private fun usesDetailedBatching(): Boolean = _state.value.detailedSamples && _state.value.batchDetailedSamples @@ -572,14 +620,16 @@ class HealthConnectHeartRateExporter( private fun currentExportOptions(): ExportOptions = ExportOptions( detailedSamples = _state.value.detailedSamples, batchDetailedSamples = _state.value.batchDetailedSamples, - batchIntervalSeconds = _state.value.batchIntervalSeconds + batchIntervalSeconds = _state.value.batchIntervalSeconds, + averageIntervalSeconds = _state.value.averageIntervalSeconds ) private fun trimBufferLocked() { val maxBufferedSamples = if ( - usesDetailedBatching() || pendingRecord?.preservesSamples == true + usesDetailedBatching() || !_state.value.detailedSamples || + pendingRecord?.preservesSamples == true ) { - MAX_BATCH_BUFFERED_SAMPLES + MAX_INTERVAL_BUFFERED_SAMPLES } else { MAX_BUFFERED_SAMPLES } @@ -646,12 +696,14 @@ class HealthConnectHeartRateExporter( "heart_rate_health_connect_batch_detailed_samples" private const val BATCH_INTERVAL_SECONDS_PREFERENCE = "heart_rate_health_connect_batch_interval_seconds" + private const val AVERAGE_INTERVAL_SECONDS_PREFERENCE = + "heart_rate_health_connect_average_interval_seconds" private const val RECORD_CLIENT_RECORD_ID_PREFIX = "librepods-heart-rate-record-v1-" private const val MAX_BUFFERED_SAMPLES = 300 - // Retain the record being retried plus another full window at the maximum interval. - private const val MAX_BATCH_BUFFERED_SAMPLES = MAX_HEART_RATE_BATCH_INTERVAL_SECONDS * 2 + // At 1 Hz, retain a retry plus another full window at the maximum interval. + private const val MAX_INTERVAL_BUFFERED_SAMPLES = + MAX_HEART_RATE_EXPORT_INTERVAL_SECONDS * 2 private const val SECOND_INTERVAL_MILLIS = 1_000L - private const val MINUTE_INTERVAL_MILLIS = 60_000L private const val RETRY_INTERVAL_MILLIS = 30_000L val WRITE_HEART_RATE_PERMISSION: String = diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/AppNavGraph.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/AppNavGraph.kt index 0c59ffcf7..eb2bf5ddc 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/AppNavGraph.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/AppNavGraph.kt @@ -24,6 +24,7 @@ import me.kavishdevar.librepods.presentation.screens.AppSettingsScreen import me.kavishdevar.librepods.presentation.screens.CallControlScreen import me.kavishdevar.librepods.presentation.screens.EqualizerRoute import me.kavishdevar.librepods.presentation.screens.HeadTrackingScreen +import me.kavishdevar.librepods.presentation.screens.HealthConnectSettingsScreen import me.kavishdevar.librepods.presentation.screens.HeartRateTestScreen import me.kavishdevar.librepods.presentation.screens.HearingAidAdjustmentsScreen import me.kavishdevar.librepods.presentation.screens.HearingAidScreen @@ -157,9 +158,18 @@ fun AppNavGraph( HeartRateTestScreen( viewModel = airPodsViewModel, navigateToWorkout = { navigate(Screen.Workout) }, + navigateToHealthConnectSettings = { + navigate(Screen.HealthConnectSettings) + }, ) } + Screen.HealthConnectSettings -> + NavEntry(screen) { + if (!airPodsViewModel.isReady) LoadingScreen() + HealthConnectSettingsScreen(airPodsViewModel) + } + Screen.Workout -> NavEntry(screen) { if (!airPodsViewModel.isReady) LoadingScreen() diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/NavigationRoot.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/NavigationRoot.kt index 2e3cf7ecc..94f37ea46 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/NavigationRoot.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/NavigationRoot.kt @@ -60,6 +60,7 @@ fun NavigationRoot( Screen.Equalizer -> stringResource(R.string.equalizer) Screen.HeadTracking -> stringResource(R.string.head_tracking) Screen.HeartRateTest -> "Heart rate" + Screen.HealthConnectSettings -> "Health Connect" Screen.Workout -> "Workout" Screen.WorkoutHistory -> "Workout history" is Screen.WorkoutDetail -> "Workout details" diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/Screen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/Screen.kt index 906f32da5..943d13b9c 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/Screen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/Screen.kt @@ -31,6 +31,9 @@ sealed interface Screen: NavKey { @Serializable data object HeartRateTest: Screen + @Serializable + data object HealthConnectSettings: Screen + @Serializable data object Workout: Screen diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HealthConnectSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HealthConnectSettingsScreen.kt new file mode 100644 index 000000000..3ab569dc2 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HealthConnectSettingsScreen.kt @@ -0,0 +1,206 @@ +/* + LibrePods - AirPods liberated from Apple’s ecosystem + Copyright (C) 2025 LibrePods contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +package me.kavishdevar.librepods.presentation.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotlin.math.roundToInt +import me.kavishdevar.librepods.health.HealthConnectExportMode +import me.kavishdevar.librepods.health.HealthConnectExportState +import me.kavishdevar.librepods.health.MAX_HEART_RATE_EXPORT_INTERVAL_SECONDS +import me.kavishdevar.librepods.health.MIN_HEART_RATE_EXPORT_INTERVAL_SECONDS +import me.kavishdevar.librepods.health.normalizeHeartRateExportIntervalSeconds +import me.kavishdevar.librepods.presentation.components.ListItemOrientation +import me.kavishdevar.librepods.presentation.components.StyledList +import me.kavishdevar.librepods.presentation.components.StyledListItem +import me.kavishdevar.librepods.presentation.components.StyledSlider +import me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel + +@Composable +fun HealthConnectSettingsScreen(viewModel: AirPodsViewModel) { + val state by viewModel.uiState.collectAsState() + val healthConnect = state.healthConnect + val materialDesign = LocalDesignSystem.current == DesignSystem.Material + val topPadding = if (materialDesign) { + 0.dp + } else { + WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp + } + val bottomPadding = if (materialDesign) { + 0.dp + } else { + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainer) + .verticalScroll(rememberScrollState()) + .padding(top = 8.dp) + .padding(horizontal = 16.dp) + ) { + Spacer(modifier = Modifier.height(topPadding)) + + StyledList( + title = "Storage mode", + description = "Choose how heart-rate readings are stored in Health Connect." + ) { + StyledListItem( + name = "Every second", + description = "Write each validated reading as it arrives.", + selected = healthConnect.mode == HealthConnectExportMode.EVERY_SECOND, + onClick = { + viewModel.setHealthConnectExportMode(HealthConnectExportMode.EVERY_SECOND) + }, + orientation = ListItemOrientation.Vertical + ) + + StyledListItem( + name = "Batched", + description = "Keep every reading and write them together at intervals.", + selected = healthConnect.mode == HealthConnectExportMode.BATCHED, + onClick = { + viewModel.setHealthConnectExportMode(HealthConnectExportMode.BATCHED) + }, + orientation = ListItemOrientation.Vertical + ) + + StyledListItem( + name = "Average", + description = "Write one average value for each interval.", + selected = healthConnect.mode == HealthConnectExportMode.AVERAGED, + onClick = { + viewModel.setHealthConnectExportMode(HealthConnectExportMode.AVERAGED) + }, + orientation = ListItemOrientation.Vertical + ) + } + + when (healthConnect.mode) { + HealthConnectExportMode.EVERY_SECOND -> Unit + + HealthConnectExportMode.BATCHED -> { + Spacer(modifier = Modifier.height(24.dp)) + HealthConnectIntervalSlider( + label = "Batch interval", + description = "How often detailed readings are written together.", + intervalSeconds = healthConnect.batchIntervalSeconds, + snapPoints = listOf( + MIN_HEART_RATE_EXPORT_INTERVAL_SECONDS, + 5 * 60, + MAX_HEART_RATE_EXPORT_INTERVAL_SECONDS + ), + onIntervalChanged = viewModel::setHealthConnectBatchIntervalSeconds + ) + } + + HealthConnectExportMode.AVERAGED -> { + Spacer(modifier = Modifier.height(24.dp)) + HealthConnectIntervalSlider( + label = "Average interval", + description = "How much time is included in each average.", + intervalSeconds = healthConnect.averageIntervalSeconds, + snapPoints = listOf( + MIN_HEART_RATE_EXPORT_INTERVAL_SECONDS, + 60, + 5 * 60, + MAX_HEART_RATE_EXPORT_INTERVAL_SECONDS + ), + onIntervalChanged = viewModel::setHealthConnectAverageIntervalSeconds + ) + } + } + + Spacer(modifier = Modifier.height(bottomPadding)) + } +} + +@Composable +private fun HealthConnectIntervalSlider( + label: String, + description: String, + intervalSeconds: Int, + snapPoints: List, + onIntervalChanged: (Int) -> Unit +) { + var sliderValue by remember(intervalSeconds) { + mutableFloatStateOf(intervalSeconds.toFloat()) + } + + StyledSlider( + label = "$label · ${formatHealthConnectInterval(sliderValue.roundToInt())}", + description = description, + value = sliderValue, + onValueChange = { value -> + sliderValue = normalizeHeartRateExportIntervalSeconds(value.roundToInt()).toFloat() + }, + onValueChangeFinished = { + onIntervalChanged(sliderValue.roundToInt()) + }, + valueRange = MIN_HEART_RATE_EXPORT_INTERVAL_SECONDS.toFloat().. + MAX_HEART_RATE_EXPORT_INTERVAL_SECONDS.toFloat(), + snapPoints = snapPoints.map(Int::toFloat), + snapThreshold = 1f, + startLabel = "30 sec", + endLabel = "15 min", + independent = true + ) +} + +internal fun healthConnectExportSummary(state: HealthConnectExportState): String = + when (state.mode) { + HealthConnectExportMode.EVERY_SECOND -> "Every second" + HealthConnectExportMode.BATCHED -> + "Batched every ${formatHealthConnectInterval(state.batchIntervalSeconds)}" + + HealthConnectExportMode.AVERAGED -> + "${formatHealthConnectInterval(state.averageIntervalSeconds)} average" + } + +internal fun formatHealthConnectInterval(seconds: Int): String { + val minutes = seconds / 60 + val remainingSeconds = seconds % 60 + return when { + minutes == 0 -> "$remainingSeconds sec" + remainingSeconds == 0 -> "$minutes min" + else -> "$minutes min $remainingSeconds sec" + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt index e5a94a732..ecb2d9341 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeartRateTestScreen.kt @@ -44,7 +44,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -69,11 +68,9 @@ import me.kavishdevar.librepods.bluetooth.HeartRateSample import me.kavishdevar.librepods.health.HealthConnectExportState import me.kavishdevar.librepods.health.HealthConnectExportStatus import me.kavishdevar.librepods.health.HealthConnectHeartRateExporter -import me.kavishdevar.librepods.health.MAX_HEART_RATE_BATCH_INTERVAL_SECONDS -import me.kavishdevar.librepods.health.MIN_HEART_RATE_BATCH_INTERVAL_SECONDS -import me.kavishdevar.librepods.health.normalizeHeartRateBatchIntervalSeconds import me.kavishdevar.librepods.presentation.components.HeartRateStatusChip -import me.kavishdevar.librepods.presentation.components.StyledSlider +import me.kavishdevar.librepods.presentation.components.StyledList +import me.kavishdevar.librepods.presentation.components.StyledListItem import me.kavishdevar.librepods.presentation.components.StyledSwitch import me.kavishdevar.librepods.presentation.components.StyledToggle import me.kavishdevar.librepods.presentation.components.rememberHeartRateSampleIsDisplayable @@ -86,11 +83,14 @@ import java.text.DateFormat import java.util.Date import kotlin.math.ceil import kotlin.math.floor -import kotlin.math.roundToInt import kotlinx.coroutines.delay @Composable -fun HeartRateTestScreen(viewModel: AirPodsViewModel, navigateToWorkout: () -> Unit) { +fun HeartRateTestScreen( + viewModel: AirPodsViewModel, + navigateToWorkout: () -> Unit, + navigateToHealthConnectSettings: () -> Unit +) { val state by viewModel.uiState.collectAsState() val context = LocalContext.current var graphNowMillis by remember { mutableLongStateOf(System.currentTimeMillis()) } @@ -194,9 +194,7 @@ fun HeartRateTestScreen(viewModel: AirPodsViewModel, navigateToWorkout: () -> Un ) } }, - onDetailedSamplesChanged = viewModel::setHealthConnectDetailedSamples, - onBatchDetailedSamplesChanged = viewModel::setHealthConnectBatchDetailedSamples, - onBatchIntervalChanged = viewModel::setHealthConnectBatchIntervalSeconds + onOpenSettings = navigateToHealthConnectSettings ) Spacer(modifier = Modifier.height(12.dp)) @@ -346,97 +344,26 @@ private fun HeartRateSummaryCard( private fun HealthConnectControls( state: HealthConnectExportState, onExportChanged: (Boolean) -> Unit, - onDetailedSamplesChanged: (Boolean) -> Unit, - onBatchDetailedSamplesChanged: (Boolean) -> Unit, - onBatchIntervalChanged: (Int) -> Unit + onOpenSettings: () -> Unit ) { val available = state.status.isAvailable - StyledToggle( - title = "Health Connect", - label = "Save heart-rate samples", - description = healthConnectDescription(state), - checked = state.enabled, - enabled = available, - onCheckedChange = onExportChanged - ) - - Spacer(modifier = Modifier.height(8.dp)) - - StyledToggle( - title = null, - label = "Detailed samples", - description = when { - !state.detailedSamples -> "Save one average BPM record every minute." - state.batchDetailedSamples -> "Keep every validated one-second BPM sample." - else -> "Save one BPM record every second." - }, - checked = state.detailedSamples, - enabled = available, - onCheckedChange = onDetailedSamplesChanged - ) - - Spacer(modifier = Modifier.height(8.dp)) - - StyledToggle( - title = null, - label = "Batch detailed samples", - description = when { - !state.detailedSamples -> "Enable Detailed samples to use batching." - state.batchDetailedSamples -> - "Buffer detailed samples and write them together every " + - "${formatBatchInterval(state.batchIntervalSeconds)}." - - else -> "Write detailed samples to Health Connect as they arrive." - }, - checked = state.batchDetailedSamples, - enabled = available && state.detailedSamples, - onCheckedChange = onBatchDetailedSamplesChanged - ) - - if (state.detailedSamples && state.batchDetailedSamples) { - Spacer(modifier = Modifier.height(8.dp)) - HealthConnectBatchIntervalControl( - intervalSeconds = state.batchIntervalSeconds, + StyledList(title = "Health Connect") { + StyledToggle( + label = "Save heart-rate data", + description = healthConnectDescription(state.status), + checked = state.enabled, enabled = available, - onIntervalChanged = onBatchIntervalChanged + onCheckedChange = onExportChanged ) - } -} -@Composable -private fun HealthConnectBatchIntervalControl( - intervalSeconds: Int, - enabled: Boolean, - onIntervalChanged: (Int) -> Unit -) { - var sliderValue by remember(intervalSeconds) { - mutableFloatStateOf(intervalSeconds.toFloat()) + StyledListItem( + name = "Storage mode", + description = healthConnectExportSummary(state), + enabled = available && state.enabled, + onClick = onOpenSettings + ) } - - StyledSlider( - label = "Batch interval · ${formatBatchInterval(sliderValue.roundToInt())}", - value = sliderValue, - onValueChange = { value -> - sliderValue = normalizeHeartRateBatchIntervalSeconds(value.roundToInt()).toFloat() - }, - onValueChangeFinished = { - onIntervalChanged(sliderValue.roundToInt()) - }, - valueRange = MIN_HEART_RATE_BATCH_INTERVAL_SECONDS.toFloat().. - MAX_HEART_RATE_BATCH_INTERVAL_SECONDS.toFloat(), - snapPoints = listOf( - MIN_HEART_RATE_BATCH_INTERVAL_SECONDS.toFloat(), - 5 * 60f, - MAX_HEART_RATE_BATCH_INTERVAL_SECONDS.toFloat() - ), - snapThreshold = 1f, - startLabel = "30 sec", - endLabel = "15 min", - independent = true, - description = "How often buffered samples are written to Health Connect.", - enabled = enabled - ) } @Composable @@ -498,7 +425,7 @@ private val HealthConnectExportStatus.requiresPermissionRequest: Boolean this == HealthConnectExportStatus.PERMISSION_DENIED || this == HealthConnectExportStatus.ERROR -private fun healthConnectDescription(state: HealthConnectExportState): String = when (state.status) { +private fun healthConnectDescription(status: HealthConnectExportStatus): String = when (status) { HealthConnectExportStatus.UNAVAILABLE -> "Health Connect is not available on this device." @@ -506,37 +433,17 @@ private fun healthConnectDescription(state: HealthConnectExportState): String = "Install or update Health Connect to save heart-rate samples." HealthConnectExportStatus.PERMISSION_REQUIRED -> - "Write permission is required before samples can be saved." + "Allow Health Connect access to save heart-rate data." HealthConnectExportStatus.PERMISSION_DENIED -> - "Permission was denied. Turn this on to request it again." - - HealthConnectExportStatus.READY -> - "Available. Enable this to save validated samples on this device." + "Health Connect access was denied. Turn this on to try again." - HealthConnectExportStatus.ENABLED -> when { - !state.detailedSamples -> - "Validated samples are averaged into one Health Connect record per minute." - - state.batchDetailedSamples -> - "Validated one-second samples are written together every " + - "${formatBatchInterval(state.batchIntervalSeconds)}." - - else -> "Validated heart-rate data is saved every second." - } + HealthConnectExportStatus.READY, + HealthConnectExportStatus.ENABLED -> + "Save validated heart-rate readings to Health Connect." HealthConnectExportStatus.ERROR -> - "A write failed. Buffered samples will be retried without creating duplicates." -} - -private fun formatBatchInterval(seconds: Int): String { - val minutes = seconds / 60 - val remainingSeconds = seconds % 60 - return when { - minutes == 0 -> "$remainingSeconds seconds" - remainingSeconds == 0 -> "$minutes ${if (minutes == 1) "minute" else "minutes"}" - else -> "$minutes min $remainingSeconds sec" - } + "Health Connect could not be accessed. Buffered data will be retried." } @Composable diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt index 0417771c1..546010a86 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt @@ -56,6 +56,7 @@ import me.kavishdevar.librepods.data.StemAction import me.kavishdevar.librepods.data.XposedRemotePrefProvider import me.kavishdevar.librepods.health.HealthConnectExportState import me.kavishdevar.librepods.health.HealthConnectExportStatus +import me.kavishdevar.librepods.health.HealthConnectExportMode import me.kavishdevar.librepods.finder.NearbyFinderState import me.kavishdevar.librepods.bluetooth.HeartRateBlePeripheralState import me.kavishdevar.librepods.services.AirPodsService @@ -754,43 +755,46 @@ class AirPodsViewModel( service.setHealthConnectExportEnabled(enabled) } - fun setHealthConnectDetailedSamples(detailed: Boolean) { + fun setHealthConnectExportMode(mode: HealthConnectExportMode) { if (!isReady) return if (isDemoMode) { _uiState.update { it.copy( - healthConnect = it.healthConnect.copy(detailedSamples = detailed) + healthConnect = it.healthConnect.copy( + detailedSamples = mode != HealthConnectExportMode.AVERAGED, + batchDetailedSamples = mode == HealthConnectExportMode.BATCHED + ) ) } return } - service.setHealthConnectDetailedSamples(detailed) + service.setHealthConnectExportMode(mode) } - fun setHealthConnectBatchDetailedSamples(enabled: Boolean) { + fun setHealthConnectBatchIntervalSeconds(seconds: Int) { if (!isReady) return if (isDemoMode) { _uiState.update { it.copy( - healthConnect = it.healthConnect.copy(batchDetailedSamples = enabled) + healthConnect = it.healthConnect.copy(batchIntervalSeconds = seconds) ) } return } - service.setHealthConnectBatchDetailedSamples(enabled) + service.setHealthConnectBatchIntervalSeconds(seconds) } - fun setHealthConnectBatchIntervalSeconds(seconds: Int) { + fun setHealthConnectAverageIntervalSeconds(seconds: Int) { if (!isReady) return if (isDemoMode) { _uiState.update { it.copy( - healthConnect = it.healthConnect.copy(batchIntervalSeconds = seconds) + healthConnect = it.healthConnect.copy(averageIntervalSeconds = seconds) ) } return } - service.setHealthConnectBatchIntervalSeconds(seconds) + service.setHealthConnectAverageIntervalSeconds(seconds) } fun markHealthConnectPermissionDenied() { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt index 81c75b1aa..e6f16a168 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt @@ -114,6 +114,7 @@ import me.kavishdevar.librepods.health.HealthConnectExportState import me.kavishdevar.librepods.finder.NearbyAirPodsFinder import me.kavishdevar.librepods.finder.NearbyFinderState import me.kavishdevar.librepods.health.HealthConnectHeartRateExporter +import me.kavishdevar.librepods.health.HealthConnectExportMode import me.kavishdevar.librepods.presentation.overlays.IslandType import me.kavishdevar.librepods.presentation.overlays.IslandWindow import me.kavishdevar.librepods.presentation.overlays.PopupWindow @@ -3752,19 +3753,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList if (::heartRateExporter.isInitialized) heartRateExporter.setEnabled(enabled) } - fun setHealthConnectDetailedSamples(detailed: Boolean) { - if (::heartRateExporter.isInitialized) heartRateExporter.setDetailedSamples(detailed) + fun setHealthConnectExportMode(mode: HealthConnectExportMode) { + if (::heartRateExporter.isInitialized) heartRateExporter.setMode(mode) } - fun setHealthConnectBatchDetailedSamples(enabled: Boolean) { + fun setHealthConnectBatchIntervalSeconds(seconds: Int) { if (::heartRateExporter.isInitialized) { - heartRateExporter.setBatchDetailedSamples(enabled) + heartRateExporter.setBatchIntervalSeconds(seconds) } } - fun setHealthConnectBatchIntervalSeconds(seconds: Int) { + fun setHealthConnectAverageIntervalSeconds(seconds: Int) { if (::heartRateExporter.isInitialized) { - heartRateExporter.setBatchIntervalSeconds(seconds) + heartRateExporter.setAverageIntervalSeconds(seconds) } } diff --git a/android/app/src/test/java/me/kavishdevar/librepods/health/HealthConnectHeartRateExporterTest.kt b/android/app/src/test/java/me/kavishdevar/librepods/health/HealthConnectHeartRateExporterTest.kt index 405d9145a..68f0b9899 100644 --- a/android/app/src/test/java/me/kavishdevar/librepods/health/HealthConnectHeartRateExporterTest.kt +++ b/android/app/src/test/java/me/kavishdevar/librepods/health/HealthConnectHeartRateExporterTest.kt @@ -32,7 +32,7 @@ class HealthConnectHeartRateExporterTest { } @Test - fun existingModesStillCreateOneRoundedAverage() { + fun averageModeCreatesOneRoundedSample() { val recordSamples = buildHeartRateRecordSamples( samples = listOf( sample(bpm = 70, sequence = 1, timestamp = 1_000L), @@ -48,11 +48,57 @@ class HealthConnectHeartRateExporterTest { } @Test - fun batchIntervalIsBoundedAndRoundedToThirtySeconds() { - assertEquals(30, normalizeHeartRateBatchIntervalSeconds(1)) - assertEquals(60, normalizeHeartRateBatchIntervalSeconds(46)) - assertEquals(330, normalizeHeartRateBatchIntervalSeconds(329)) - assertEquals(15 * 60, normalizeHeartRateBatchIntervalSeconds(60 * 60)) + fun exportIntervalIsBoundedAndRoundedToThirtySeconds() { + assertEquals(30, normalizeHeartRateExportIntervalSeconds(1)) + assertEquals(60, normalizeHeartRateExportIntervalSeconds(46)) + assertEquals(330, normalizeHeartRateExportIntervalSeconds(329)) + assertEquals(15 * 60, normalizeHeartRateExportIntervalSeconds(60 * 60)) + } + + @Test + fun legacyDetailOptionsMapToTheThreeExportModes() { + assertEquals(HealthConnectExportMode.AVERAGED, HealthConnectExportState().mode) + assertEquals( + HealthConnectExportMode.EVERY_SECOND, + HealthConnectExportState(detailedSamples = true).mode + ) + assertEquals( + HealthConnectExportMode.BATCHED, + HealthConnectExportState( + detailedSamples = true, + batchDetailedSamples = true + ).mode + ) + } + + @Test + fun eachModeUsesItsConfiguredInterval() { + assertEquals( + DEFAULT_HEART_RATE_AVERAGE_INTERVAL_SECONDS, + HealthConnectExportState().averageIntervalSeconds + ) + assertEquals( + 1, + heartRateExportIntervalSeconds( + HealthConnectExportState(detailedSamples = true) + ) + ) + assertEquals( + 5 * 60, + heartRateExportIntervalSeconds( + HealthConnectExportState( + detailedSamples = true, + batchDetailedSamples = true, + batchIntervalSeconds = 5 * 60 + ) + ) + ) + assertEquals( + 60, + heartRateExportIntervalSeconds( + HealthConnectExportState(averageIntervalSeconds = 60) + ) + ) } private fun sample(bpm: Int, sequence: Int, timestamp: Long) = HeartRateSample( From 7d7303d45a948f72321414b8f8959cec65614e52 Mon Sep 17 00:00:00 2001 From: Max <33982882+maxboeer@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:38:57 +0200 Subject: [PATCH 3/3] android: clarify heart-rate export battery usage --- .../presentation/screens/HealthConnectSettingsScreen.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HealthConnectSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HealthConnectSettingsScreen.kt index 3ab569dc2..d25cdbd25 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HealthConnectSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HealthConnectSettingsScreen.kt @@ -81,11 +81,11 @@ fun HealthConnectSettingsScreen(viewModel: AirPodsViewModel) { StyledList( title = "Storage mode", - description = "Choose how heart-rate readings are stored in Health Connect." + description = "Heart-rate monitoring affects AirPods battery life equally in every mode." ) { StyledListItem( name = "Every second", - description = "Write each validated reading as it arrives.", + description = "Store readings immediately for live data, using more phone battery.", selected = healthConnect.mode == HealthConnectExportMode.EVERY_SECOND, onClick = { viewModel.setHealthConnectExportMode(HealthConnectExportMode.EVERY_SECOND) @@ -95,7 +95,7 @@ fun HealthConnectSettingsScreen(viewModel: AirPodsViewModel) { StyledListItem( name = "Batched", - description = "Keep every reading and write them together at intervals.", + description = "Keep every reading and write them together to save phone battery.", selected = healthConnect.mode == HealthConnectExportMode.BATCHED, onClick = { viewModel.setHealthConnectExportMode(HealthConnectExportMode.BATCHED)