Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<HeartRateSample>,
preserveSamples: Boolean,
averageSampleTimeMillis: Long
): List<HeartRateRecord.Sample> {
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,
Expand All @@ -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.
Expand All @@ -74,32 +140,66 @@ 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
private val mutex = Mutex()
private val pendingSamples = linkedMapOf<String, PendingSample>()
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<HealthConnectExportState> get() = _state

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() {
Expand Down Expand Up @@ -188,23 +288,56 @@ 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() &&
(!_state.value.enabled || !flushLocked(forcePartialInterval = true))
) {
return@withLock
}
applyRequestedDetailLocked()
applyRequestedExportOptionsLocked()
}
}
}
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -320,7 +453,7 @@ class HealthConnectHeartRateExporter(
}
}

applyRequestedDetailLocked()
applyRequestedExportOptionsLocked()
return true
}

Expand All @@ -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) {
Expand Down Expand Up @@ -405,7 +548,8 @@ class HealthConnectHeartRateExporter(
),
startTimeMillis = recordStartTime,
endTimeMillis = recordEndTime,
partialInterval = partialInterval
partialInterval = partialInterval,
preservesSamples = usesDetailedBatching()
).also { pendingRecord = it }
}

Expand All @@ -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(
Expand Down Expand Up @@ -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<PendingSample>): Long {
val total = samples.sumOf { it.sample.bpm.toLong() }
return (total + samples.size / 2L) / samples.size
}

private fun recordClientRecordId(
samples: List<PendingSample>,
startTimeMillis: Long,
Expand Down Expand Up @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ fun StyledSlider(
label: String? = null,
value: Float,
onValueChange: (Float) -> Unit,
onValueChangeFinished: () -> Unit = {},
valueRange: ClosedFloatingPointRange<Float>,
backdrop: Backdrop = rememberLayerBackdrop(),
snapPoints: List<Float> = emptyList(),
Expand Down Expand Up @@ -339,6 +340,7 @@ fun StyledSlider(

onValueChange(snapped)
},
onValueChangeFinished = onValueChangeFinished,
valueRange = valueRange,
enabled = enabled
)
Expand Down Expand Up @@ -635,6 +637,7 @@ fun StyledSlider(
},
onDragStopped = {
onValueChange((value * 100).roundToInt() / 100f)
onValueChangeFinished()
}
)
.then(momentumAnimation.modifier)
Expand Down
Loading