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..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,6 +34,49 @@ import java.security.MessageDigest import java.time.Instant import java.time.ZoneId +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 DEFAULT_HEART_RATE_AVERAGE_INTERVAL_SECONDS = 60 +internal const val HEART_RATE_EXPORT_INTERVAL_STEP_SECONDS = 30 + +internal fun normalizeHeartRateExportIntervalSeconds(seconds: Int): Int { + val clamped = seconds.coerceIn( + MIN_HEART_RATE_EXPORT_INTERVAL_SECONDS, + MAX_HEART_RATE_EXPORT_INTERVAL_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_EXPORT_INTERVAL_SECONDS, + MAX_HEART_RATE_EXPORT_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, @@ -45,11 +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 detailedSamples: Boolean = false, + val batchDetailedSamples: Boolean = false, + 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. @@ -74,7 +140,15 @@ 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, + val averageIntervalSeconds: Int ) private val appContext = context.applicationContext @@ -82,14 +156,30 @@ 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 = 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 + ) + ) ) ) val state: StateFlow get() = _state @@ -97,9 +187,19 @@ 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, + averageIntervalSeconds: Int = _state.value.averageIntervalSeconds ) { - _state.value = HealthConnectExportState(enabled, status, detailedSamples) + _state.value = HealthConnectExportState( + enabled = enabled, + status = status, + detailedSamples = detailedSamples, + batchDetailedSamples = batchDetailedSamples, + batchIntervalSeconds = batchIntervalSeconds, + averageIntervalSeconds = averageIntervalSeconds + ) } fun refresh() { @@ -188,15 +288,48 @@ class HealthConnectHeartRateExporter( } } - fun setDetailedSamples(detailed: Boolean) { + fun setMode(mode: HealthConnectExportMode) { + requestExportOptionsChange { options -> + when (mode) { + HealthConnectExportMode.EVERY_SECOND -> options.copy( + detailedSamples = true, + batchDetailedSamples = false + ) + + HealthConnectExportMode.BATCHED -> options.copy( + detailedSamples = true, + batchDetailedSamples = true + ) + + HealthConnectExportMode.AVERAGED -> options.copy( + detailedSamples = false, + batchDetailedSamples = false + ) + } + } + } + + fun setBatchIntervalSeconds(seconds: Int) { + 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 { - 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 +337,7 @@ class HealthConnectHeartRateExporter( ) { return@withLock } - applyRequestedDetailLocked() + applyRequestedExportOptionsLocked() } } } @@ -271,14 +404,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 +453,7 @@ class HealthConnectHeartRateExporter( } } - applyRequestedDetailLocked() + applyRequestedExportOptionsLocked() return true } @@ -330,14 +463,24 @@ 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) + putInt(AVERAGE_INTERVAL_SECONDS_PREFERENCE, options.averageIntervalSeconds) + } + updateState( + detailedSamples = options.detailedSamples, + batchDetailedSamples = options.batchDetailedSamples, + batchIntervalSeconds = options.batchIntervalSeconds, + averageIntervalSeconds = options.averageIntervalSeconds + ) + requestedExportOptions = null } private fun scheduleFlushLocked(delayMillis: Long) { @@ -405,7 +548,8 @@ class HealthConnectHeartRateExporter( ), startTimeMillis = recordStartTime, endTimeMillis = recordEndTime, - partialInterval = partialInterval + partialInterval = partialInterval, + preservesSamples = usesDetailedBatching() ).also { pendingRecord = it } } @@ -419,14 +563,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 +611,34 @@ class HealthConnectHeartRateExporter( } } - private fun exportIntervalMillis(): Long = if (_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 + + private fun currentExportOptions(): ExportOptions = ExportOptions( + detailedSamples = _state.value.detailedSamples, + batchDetailedSamples = _state.value.batchDetailedSamples, + batchIntervalSeconds = _state.value.batchIntervalSeconds, + averageIntervalSeconds = _state.value.averageIntervalSeconds + ) private fun trimBufferLocked() { - while (bufferedSampleCountLocked() > MAX_BUFFERED_SAMPLES) { + val maxBufferedSamples = if ( + usesDetailedBatching() || !_state.value.detailedSamples || + pendingRecord?.preservesSamples == true + ) { + MAX_INTERVAL_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,10 +692,18 @@ 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 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 + // 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/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/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..d25cdbd25 --- /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 = "Heart-rate monitoring affects AirPods battery life equally in every mode." + ) { + StyledListItem( + name = "Every second", + description = "Store readings immediately for live data, using more phone battery.", + 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 to save phone battery.", + 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 92b237d6c..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 @@ -69,6 +69,8 @@ import me.kavishdevar.librepods.health.HealthConnectExportState import me.kavishdevar.librepods.health.HealthConnectExportStatus import me.kavishdevar.librepods.health.HealthConnectHeartRateExporter import me.kavishdevar.librepods.presentation.components.HeartRateStatusChip +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 @@ -84,7 +86,11 @@ import kotlin.math.floor 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()) } @@ -188,7 +194,7 @@ fun HeartRateTestScreen(viewModel: AirPodsViewModel, navigateToWorkout: () -> Un ) } }, - onDetailedSamplesChanged = viewModel::setHealthConnectDetailedSamples + onOpenSettings = navigateToHealthConnectSettings ) Spacer(modifier = Modifier.height(12.dp)) @@ -338,33 +344,26 @@ private fun HeartRateSummaryCard( private fun HealthConnectControls( state: HealthConnectExportState, onExportChanged: (Boolean) -> Unit, - onDetailedSamplesChanged: (Boolean) -> Unit + onOpenSettings: () -> Unit ) { val available = state.status.isAvailable - StyledToggle( - title = "Health Connect", - label = "Save heart-rate samples", - description = healthConnectDescription(state.status, state.detailedSamples), - checked = state.enabled, - enabled = available, - onCheckedChange = onExportChanged - ) - - Spacer(modifier = Modifier.height(8.dp)) + StyledList(title = "Health Connect") { + StyledToggle( + label = "Save heart-rate data", + description = healthConnectDescription(state.status), + checked = state.enabled, + enabled = available, + onCheckedChange = onExportChanged + ) - StyledToggle( - title = null, - label = "Detailed samples", - description = if (state.detailedSamples) { - "Save one BPM record every second." - } else { - "Save one average BPM record every minute." - }, - checked = state.detailedSamples, - enabled = available, - onCheckedChange = onDetailedSamplesChanged - ) + StyledListItem( + name = "Storage mode", + description = healthConnectExportSummary(state), + enabled = available && state.enabled, + onClick = onOpenSettings + ) + } } @Composable @@ -426,10 +425,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(status: HealthConnectExportStatus): String = when (status) { HealthConnectExportStatus.UNAVAILABLE -> "Health Connect is not available on this device." @@ -437,22 +433,17 @@ private fun healthConnectDescription( "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." + "Health Connect access was denied. Turn this on to try again." - 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.READY, + HealthConnectExportStatus.ENABLED -> + "Save validated heart-rate readings to Health Connect." HealthConnectExportStatus.ERROR -> - "A write failed. Buffered samples will be retried without creating duplicates." + "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 144f4c95f..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,17 +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.setHealthConnectExportMode(mode) + } + + fun setHealthConnectBatchIntervalSeconds(seconds: Int) { + if (!isReady) return + if (isDemoMode) { + _uiState.update { + it.copy( + healthConnect = it.healthConnect.copy(batchIntervalSeconds = seconds) + ) + } + return + } + service.setHealthConnectBatchIntervalSeconds(seconds) + } + + fun setHealthConnectAverageIntervalSeconds(seconds: Int) { + if (!isReady) return + if (isDemoMode) { + _uiState.update { + it.copy( + healthConnect = it.healthConnect.copy(averageIntervalSeconds = seconds) ) } return } - service.setHealthConnectDetailedSamples(detailed) + 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 e562bcc6c..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,8 +3753,20 @@ 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 setHealthConnectBatchIntervalSeconds(seconds: Int) { + if (::heartRateExporter.isInitialized) { + heartRateExporter.setBatchIntervalSeconds(seconds) + } + } + + fun setHealthConnectAverageIntervalSeconds(seconds: Int) { + if (::heartRateExporter.isInitialized) { + heartRateExporter.setAverageIntervalSeconds(seconds) + } } fun markHealthConnectPermissionDenied() { 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..68f0b9899 --- /dev/null +++ b/android/app/src/test/java/me/kavishdevar/librepods/health/HealthConnectHeartRateExporterTest.kt @@ -0,0 +1,110 @@ +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 averageModeCreatesOneRoundedSample() { + 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 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( + bpm = bpm, + sequence = sequence, + receivedAtMillis = timestamp, + receivedAtElapsedRealtime = timestamp + ) +}