feat(android): add persistent heart rate capture

This commit is contained in:
fy59 2026-09-23 10:33:02 +02:00
parent 5bf90899bd
commit 1ca354af53
20 changed files with 841 additions and 8 deletions

View file

@ -15,8 +15,15 @@
HRS-filtered and expanded BLE scans, GATT service discovery and notification
subscription without turning desktop/Web into sensor clients. Android 12+
scan/connect permissions and the Android 11-and-older scan-location boundary
are explicit. Real armband notification characterization remains a hardware
validation gate before persistent acquisition.
are explicit. Real CYCPLUS H2 characterization then confirmed the standard
service/measurement path, Battery Service, ~1 Hz notifications and raw RR.
- Added persistent Android heart-rate acquisition and the global `♥ BPM`
indicator. A validated sensor is remembered, a dedicated connected-device
foreground service reconnects across app restarts and Bluetooth loss, stale
measurements are cleared instead of displayed indefinitely, and the heart
uses grey/orange/green for disconnected/reconnecting/fresh states. The real
CYCPLUS H2 was validated through force-stop/restart and Bluetooth off/on
recovery on the private 0.1.5 versionCode 40 installation.
Development opened after stable v0.1.4 release.

View file

@ -36,7 +36,7 @@ Programs. Sessions → Programmes remains the technical administration/import,
list, detail, archive, and delete surface.
Web Exercises administration, the factual Dashboard/Analyse read model, and the
synchronized Sleep Diary workspace are the stable v0.1.4 baseline. Version
0.1.5 development has the Android/desktop heart-rate persistence and synchronization foundation in place; Android BLE sensor acquisition is the current implementation cursor.
0.1.5 development now has Android/desktop heart-rate persistence and sync plus validated persistent CYCPLUS acquisition and the global `♥ BPM` indicator; training exercise timestamps are the current implementation cursor.
## Releases

View file

@ -50,5 +50,9 @@
android:name=".data.SyncBackgroundService"
android:exported="false"
android:foregroundServiceType="connectedDevice|dataSync" />
<service
android:name=".data.HeartRateSensorService"
android:exported="false"
android:foregroundServiceType="connectedDevice" />
</application>
</manifest>

View file

@ -16,7 +16,10 @@ import com.labfytools.trainlog.data.SyncCatalogInbox
import com.labfytools.trainlog.data.SyncRequestOutbox
import com.labfytools.trainlog.data.BackgroundSyncSettings
import com.labfytools.trainlog.data.SyncBackgroundService
import com.labfytools.trainlog.data.HeartRateSensorPreferences
import com.labfytools.trainlog.data.HeartRateSensorService
import com.labfytools.trainlog.data.hasBluetoothConnectPermission
import com.labfytools.trainlog.data.hasHeartRateBleRuntimePermissions
import com.labfytools.trainlog.ui.TrainlogApp
import com.labfytools.trainlog.ui.TrainlogAppState
import com.labfytools.trainlog.ui.LanguagePresentation
@ -39,6 +42,12 @@ class MainActivity : ComponentActivity() {
) {
SyncBackgroundService.start(applicationContext)
}
if (
HeartRateSensorPreferences(applicationContext).selected() != null &&
hasHeartRateBleRuntimePermissions(applicationContext)
) {
HeartRateSensorService.start(applicationContext)
}
val repository =
TrainlogRepository(

View file

@ -35,7 +35,7 @@ internal val HEART_RATE_MEASUREMENT_UUID: UUID =
UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb")
internal val BATTERY_SERVICE_UUID: UUID =
UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")
private val CLIENT_CHARACTERISTIC_CONFIG_UUID: UUID =
internal val CLIENT_CHARACTERISTIC_CONFIG_UUID: UUID =
UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
internal enum class HeartRateDiagnosticPhase {

View file

@ -0,0 +1,80 @@
package com.labfytools.trainlog.data
import java.util.concurrent.CopyOnWriteArraySet
internal enum class HeartRateLivePhase {
DISCONNECTED,
CONNECTING,
CONNECTED,
STALE,
}
internal data class HeartRateLiveSnapshot(
val phase: HeartRateLivePhase = HeartRateLivePhase.DISCONNECTED,
val bpm: Int? = null,
val rrIntervals1024: List<Int> = emptyList(),
val sensorContactDetected: Boolean? = null,
val energyExpended: Int? = null,
val notificationCount: Long = 0,
)
internal object HeartRateLiveState {
private val listeners =
CopyOnWriteArraySet<(HeartRateLiveSnapshot) -> Unit>()
@Volatile
private var snapshot = HeartRateLiveSnapshot()
fun current(): HeartRateLiveSnapshot = snapshot
fun publish(next: HeartRateLiveSnapshot) {
snapshot = next
listeners.forEach { it(next) }
}
fun subscribe(listener: (HeartRateLiveSnapshot) -> Unit): AutoCloseable {
listeners += listener
listener(snapshot)
return AutoCloseable { listeners -= listener }
}
fun disconnected() {
publish(HeartRateLiveSnapshot(phase = HeartRateLivePhase.DISCONNECTED))
}
fun connecting() {
publish(
snapshot.copy(
phase = HeartRateLivePhase.CONNECTING,
bpm = null,
rrIntervals1024 = emptyList(),
sensorContactDetected = null,
energyExpended = null,
),
)
}
fun measurement(measurement: ParsedHeartRateMeasurement) {
publish(
HeartRateLiveSnapshot(
phase = HeartRateLivePhase.CONNECTED,
bpm = measurement.bpm,
rrIntervals1024 = measurement.rrIntervals1024,
sensorContactDetected = measurement.sensorContactDetected,
energyExpended = measurement.energyExpended,
notificationCount = snapshot.notificationCount + 1,
),
)
}
fun stale() {
if (snapshot.phase == HeartRateLivePhase.CONNECTED) {
publish(
snapshot.copy(
phase = HeartRateLivePhase.STALE,
bpm = null,
),
)
}
}
}

View file

@ -0,0 +1,46 @@
package com.labfytools.trainlog.data
import android.content.Context
internal data class SelectedHeartRateSensor(
val address: String,
val name: String?,
)
internal class HeartRateSensorPreferences(context: Context) {
private val preferences =
context.applicationContext.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
fun selected(): SelectedHeartRateSensor? {
if (!preferences.getBoolean(ENABLED, false)) return null
val address = preferences.getString(ADDRESS, null)?.takeIf { it.isNotBlank() } ?: return null
return SelectedHeartRateSensor(
address = address,
name = preferences.getString(NAME, null)?.takeIf { it.isNotBlank() },
)
}
fun select(address: String, name: String?) {
require(address.isNotBlank())
preferences.edit()
.putBoolean(ENABLED, true)
.putString(ADDRESS, address)
.putString(NAME, name?.takeIf { it.isNotBlank() })
.apply()
}
fun clear() {
preferences.edit()
.putBoolean(ENABLED, false)
.remove(ADDRESS)
.remove(NAME)
.apply()
}
companion object {
private const val PREFERENCES = "trainlog-heart-rate-sensor"
private const val ENABLED = "enabled"
private const val ADDRESS = "address"
private const val NAME = "name"
}
}

View file

@ -0,0 +1,415 @@
package com.labfytools.trainlog.data
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.bluetooth.BluetoothStatusCodes
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.SystemClock
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import com.labfytools.trainlog.R
class HeartRateSensorService : Service() {
private val main = Handler(Looper.getMainLooper())
private val preferences by lazy { HeartRateSensorPreferences(applicationContext) }
private val bluetoothManager by lazy {
applicationContext.getSystemService(BluetoothManager::class.java)
}
private var selected: SelectedHeartRateSensor? = null
private var gatt: BluetoothGatt? = null
private var reconnectAttempt = 0
private var lastMeasurementElapsed = 0L
private var lastNotificationBpm: Int? = null
private val reconnectRunnable = Runnable { connectSelected() }
private val connectionTimeoutRunnable =
Runnable {
if (HeartRateLiveState.current().phase == HeartRateLivePhase.CONNECTING) {
closeGatt()
scheduleReconnect()
}
}
private val staleRunnable =
object : Runnable {
override fun run() {
val last = lastMeasurementElapsed
if (last > 0L) {
val age = SystemClock.elapsedRealtime() - last
if (
age >= STALE_AFTER_MS &&
HeartRateLiveState.current().phase == HeartRateLivePhase.CONNECTED
) {
HeartRateLiveState.stale()
updateForegroundNotification()
}
if (age >= STALE_RECONNECT_AFTER_MS && gatt != null) {
lastMeasurementElapsed = 0L
closeGatt()
scheduleReconnect()
}
}
main.postDelayed(this, STALE_CHECK_MS)
}
}
override fun onCreate() {
super.onCreate()
createChannel()
promoteForeground()
main.post(staleRunnable)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) {
stopSelf()
return START_NOT_STICKY
}
selected = preferences.selected()
if (selected == null || !hasHeartRateBleRuntimePermissions(this)) {
HeartRateLiveState.disconnected()
updateForegroundNotification()
stopSelf()
return START_NOT_STICKY
}
if (bluetoothManager.adapter?.isEnabled != true) {
scheduleReconnect()
return START_STICKY
}
promoteForeground()
if (gatt == null) connectSelected()
return START_STICKY
}
@SuppressLint("MissingPermission")
@Suppress("DEPRECATION")
private fun connectSelected() {
main.removeCallbacks(reconnectRunnable)
main.removeCallbacks(connectionTimeoutRunnable)
val sensor = preferences.selected()
selected = sensor
val adapter = bluetoothManager.adapter
if (sensor == null || !hasHeartRateBleRuntimePermissions(this)) {
HeartRateLiveState.disconnected()
updateForegroundNotification()
stopSelf()
return
}
if (adapter?.isEnabled != true) {
scheduleReconnect()
return
}
closeGatt()
lastMeasurementElapsed = 0L
HeartRateLiveState.connecting()
updateForegroundNotification()
val device =
runCatching { adapter.getRemoteDevice(sensor.address) }.getOrNull()
?: run {
HeartRateLiveState.disconnected()
scheduleReconnect()
return
}
runCatching {
device.connectGatt(
applicationContext,
false,
gattCallback,
BluetoothDevice.TRANSPORT_LE,
)
}
.onSuccess {
gatt = it
main.postDelayed(connectionTimeoutRunnable, CONNECTION_TIMEOUT_MS)
}
.onFailure {
scheduleReconnect()
}
}
private val gattCallback =
object : BluetoothGattCallback() {
@SuppressLint("MissingPermission")
override fun onConnectionStateChange(
callbackGatt: BluetoothGatt,
status: Int,
newState: Int,
) {
main.post {
if (callbackGatt !== gatt) return@post
if (
status == BluetoothGatt.GATT_SUCCESS &&
newState == BluetoothProfile.STATE_CONNECTED
) {
reconnectAttempt = 0
main.removeCallbacks(connectionTimeoutRunnable)
HeartRateLiveState.connecting()
updateForegroundNotification()
if (!callbackGatt.discoverServices()) {
failConnection()
} else {
main.postDelayed(connectionTimeoutRunnable, CONNECTION_TIMEOUT_MS)
}
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
failConnection()
} else if (status != BluetoothGatt.GATT_SUCCESS) {
failConnection()
}
}
}
@SuppressLint("MissingPermission")
override fun onServicesDiscovered(callbackGatt: BluetoothGatt, status: Int) {
main.post {
if (callbackGatt !== gatt) return@post
if (status != BluetoothGatt.GATT_SUCCESS) {
failConnection()
return@post
}
val measurement =
callbackGatt
.getService(HEART_RATE_SERVICE_UUID)
?.getCharacteristic(HEART_RATE_MEASUREMENT_UUID)
if (measurement == null) {
failConnection()
return@post
}
main.removeCallbacks(connectionTimeoutRunnable)
if (!subscribe(callbackGatt, measurement)) {
failConnection()
} else {
main.postDelayed(connectionTimeoutRunnable, CONNECTION_TIMEOUT_MS)
}
}
}
override fun onDescriptorWrite(
callbackGatt: BluetoothGatt,
descriptor: BluetoothGattDescriptor,
status: Int,
) {
if (descriptor.uuid != CLIENT_CHARACTERISTIC_CONFIG_UUID) return
main.post {
if (callbackGatt !== gatt) return@post
if (status != BluetoothGatt.GATT_SUCCESS) {
failConnection()
} else {
main.removeCallbacks(connectionTimeoutRunnable)
HeartRateLiveState.connecting()
updateForegroundNotification()
}
}
}
@Deprecated("Deprecated by Android API 33 callback with explicit value")
override fun onCharacteristicChanged(
callbackGatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
) {
@Suppress("DEPRECATION")
val value = characteristic.value ?: return
onHeartRateNotification(callbackGatt, characteristic.uuid, value)
}
override fun onCharacteristicChanged(
callbackGatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray,
) {
onHeartRateNotification(callbackGatt, characteristic.uuid, value)
}
}
@SuppressLint("MissingPermission")
private fun subscribe(
callbackGatt: BluetoothGatt,
measurement: BluetoothGattCharacteristic,
): Boolean {
if (!callbackGatt.setCharacteristicNotification(measurement, true)) return false
val descriptor = measurement.getDescriptor(CLIENT_CHARACTERISTIC_CONFIG_UUID) ?: return false
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
callbackGatt.writeDescriptor(
descriptor,
BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE,
) == BluetoothStatusCodes.SUCCESS
} else {
@Suppress("DEPRECATION")
run {
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
callbackGatt.writeDescriptor(descriptor)
}
}
}
private fun onHeartRateNotification(
callbackGatt: BluetoothGatt,
uuid: java.util.UUID,
value: ByteArray,
) {
if (callbackGatt !== gatt || uuid != HEART_RATE_MEASUREMENT_UUID) return
val parsed = HeartRateMeasurementParser.parse(value)
if (parsed !is HeartRateMeasurementParseResult.Parsed) return
lastMeasurementElapsed = SystemClock.elapsedRealtime()
HeartRateLiveState.measurement(parsed.measurement)
if (lastNotificationBpm != parsed.measurement.bpm) {
lastNotificationBpm = parsed.measurement.bpm
updateForegroundNotification()
}
}
private fun failConnection() {
main.removeCallbacks(connectionTimeoutRunnable)
closeGatt()
lastMeasurementElapsed = 0L
lastNotificationBpm = null
scheduleReconnect()
}
private fun scheduleReconnect() {
if (preferences.selected() == null) {
HeartRateLiveState.disconnected()
updateForegroundNotification()
return
}
HeartRateLiveState.connecting()
updateForegroundNotification()
reconnectAttempt = (reconnectAttempt + 1).coerceAtMost(6)
val delay =
(RECONNECT_BASE_MS * reconnectAttempt.toLong()).coerceAtMost(RECONNECT_MAX_MS)
main.removeCallbacks(reconnectRunnable)
main.postDelayed(reconnectRunnable, delay)
}
@SuppressLint("MissingPermission")
private fun closeGatt() {
val current = gatt
gatt = null
runCatching { current?.disconnect() }
runCatching { current?.close() }
}
override fun onDestroy() {
main.removeCallbacks(reconnectRunnable)
main.removeCallbacks(connectionTimeoutRunnable)
main.removeCallbacks(staleRunnable)
closeGatt()
HeartRateLiveState.disconnected()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
private fun createChannel() {
getSystemService(NotificationManager::class.java)
.createNotificationChannel(
NotificationChannel(
CHANNEL,
getString(R.string.heart_rate_service_channel),
NotificationManager.IMPORTANCE_LOW,
),
)
}
private fun promoteForeground() {
val notification = buildNotification()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE,
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
private fun updateForegroundNotification() {
runCatching {
getSystemService(NotificationManager::class.java)
.notify(NOTIFICATION_ID, buildNotification())
}
}
private fun buildNotification(): android.app.Notification {
val live = HeartRateLiveState.current()
val text =
when (live.phase) {
HeartRateLivePhase.CONNECTED ->
live.bpm?.let { getString(R.string.heart_rate_service_bpm, it) }
?: getString(R.string.heart_rate_service_connecting)
HeartRateLivePhase.CONNECTING ->
getString(R.string.heart_rate_service_connecting)
HeartRateLivePhase.STALE ->
getString(R.string.heart_rate_service_reconnecting)
HeartRateLivePhase.DISCONNECTED ->
getString(R.string.heart_rate_service_disconnected)
}
return NotificationCompat.Builder(this, CHANNEL)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(getString(R.string.heart_rate_service_title))
.setContentText(text)
.setOngoing(true)
.setOnlyAlertOnce(true)
.build()
}
companion object {
private const val CHANNEL = "trainlog_heart_rate"
private const val NOTIFICATION_ID = 1402
private const val ACTION_START = "com.labfytools.trainlog.heart_rate.START"
private const val ACTION_STOP = "com.labfytools.trainlog.heart_rate.STOP"
private const val CONNECTION_TIMEOUT_MS = 12_000L
private const val STALE_AFTER_MS = 3_000L
private const val STALE_RECONNECT_AFTER_MS = 8_000L
private const val STALE_CHECK_MS = 1_000L
private const val RECONNECT_BASE_MS = 2_000L
private const val RECONNECT_MAX_MS = 15_000L
fun start(context: Context) {
if (
HeartRateSensorPreferences(context).selected() == null ||
!hasHeartRateBleRuntimePermissions(context)
) {
HeartRateLiveState.disconnected()
return
}
ContextCompat.startForegroundService(
context,
Intent(context, HeartRateSensorService::class.java).setAction(ACTION_START),
)
}
fun stop(context: Context) {
context.startService(
Intent(context, HeartRateSensorService::class.java).setAction(ACTION_STOP),
)
}
fun forget(context: Context) {
HeartRateSensorPreferences(context).clear()
stop(context)
HeartRateLiveState.disconnected()
}
}
}

View file

@ -146,6 +146,7 @@ fun AndroidAppShell(
}
},
actions = {
HeartRateIndicator()
if (!root) {
IconButton(
onClick = { scope.launch { drawerState.open() } },

View file

@ -18,12 +18,14 @@ import com.labfytools.trainlog.R
import com.labfytools.trainlog.data.HeartRateBleDiagnostics
import com.labfytools.trainlog.data.HeartRateDiagnosticPhase
import com.labfytools.trainlog.data.HeartRateDiagnosticSnapshot
import com.labfytools.trainlog.data.HeartRateSensorPreferences
import com.labfytools.trainlog.data.HeartRateSensorService
import com.labfytools.trainlog.data.hasHeartRateBleRuntimePermissions
import com.labfytools.trainlog.data.heartRateBleRuntimePermissions
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
@Composable
fun HeartRateDiagnosticsScreen() {
fun HeartRateDiagnosticsScreen(onSelected: () -> Unit) {
val context = LocalContext.current
val strings = localizedContext()
val colors = LocalTrainlogColors.current
@ -122,6 +124,27 @@ fun HeartRateDiagnosticsScreen() {
strings.getString(R.string.hr_diag_notifications, snapshot.notificationCount),
colors.muted,
)
if (
snapshot.phase == HeartRateDiagnosticPhase.RECEIVING &&
snapshot.selectedAddress != null
) {
TrainlogButton(
strings.getString(R.string.hr_diag_use_sensor),
{
val address = checkNotNull(snapshot.selectedAddress)
val name =
snapshot.devices
.firstOrNull { it.address == address }
?.name
diagnostics.close()
HeartRateSensorPreferences(context).select(address, name)
HeartRateSensorService.start(context)
onSelected()
},
Modifier.fillMaxWidth(),
style = TrainlogButtonStyle.SUCCESS,
)
}
}
TrainlogFrame(strings.getString(R.string.hr_diag_scan_section)) {

View file

@ -0,0 +1,98 @@
package com.labfytools.trainlog.ui
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.labfytools.trainlog.R
import com.labfytools.trainlog.data.HeartRateLivePhase
import com.labfytools.trainlog.data.HeartRateLiveState
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
@Composable
internal fun HeartRateIndicator() {
var live by remember { mutableStateOf(HeartRateLiveState.current()) }
DisposableEffect(Unit) {
val subscription = HeartRateLiveState.subscribe { live = it }
onDispose { subscription.close() }
}
val colors = LocalTrainlogColors.current
val strings = localizedContext()
val bpm =
live.bpm?.takeIf {
live.phase == HeartRateLivePhase.CONNECTED
}
val color =
when (live.phase) {
HeartRateLivePhase.CONNECTED -> colors.success
HeartRateLivePhase.CONNECTING,
HeartRateLivePhase.STALE -> colors.warning
HeartRateLivePhase.DISCONNECTED -> colors.muted
}
val beatPeriodMs =
bpm?.takeIf { it > 0 }
?.let { (60_000 / it).coerceIn(300, 2_000) }
?: 1_000
val transition = rememberInfiniteTransition(label = "heart-rate-pulse")
val scale by
transition.animateFloat(
initialValue = 1f,
targetValue = if (bpm != null) 1.14f else 1f,
animationSpec =
infiniteRepeatable(
animation = tween(durationMillis = beatPeriodMs / 2),
repeatMode = RepeatMode.Reverse,
),
label = "heart-rate-scale",
)
val description =
when (live.phase) {
HeartRateLivePhase.CONNECTED ->
strings.getString(R.string.heart_rate_a11y_connected, bpm ?: 0)
HeartRateLivePhase.CONNECTING ->
strings.getString(R.string.heart_rate_a11y_connecting)
HeartRateLivePhase.STALE ->
strings.getString(R.string.heart_rate_a11y_reconnecting)
HeartRateLivePhase.DISCONNECTED ->
strings.getString(R.string.heart_rate_a11y_disconnected)
}
Row(
modifier =
Modifier
.padding(horizontal = 4.dp)
.semantics { contentDescription = description },
horizontalArrangement = Arrangement.spacedBy(5.dp),
) {
androidx.compose.material3.Text(
"♥",
color = color,
fontSize = 20.sp,
modifier = Modifier.scale(scale),
)
androidx.compose.material3.Text(
bpm?.let { "$it BPM" } ?: "-- BPM",
color = color,
fontSize = 14.sp,
)
}
}

View file

@ -9,6 +9,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@ -39,6 +40,8 @@ import com.labfytools.trainlog.data.StartAiSessionDraftResult
import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.data.AndroidBackupService
import com.labfytools.trainlog.data.AndroidBackupResult
import com.labfytools.trainlog.data.HeartRateSensorPreferences
import com.labfytools.trainlog.data.HeartRateSensorService
import com.labfytools.trainlog.data.directStoragePermissionIntent
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.SessionExerciseDraft
@ -484,6 +487,10 @@ fun SettingsScreen(
val strings = localizedContext()
var authorized by remember { mutableStateOf(inbox.hasFolderAccess()) }
var message by remember { mutableStateOf<String?>(null) }
val heartRatePreferences = remember(context) { HeartRateSensorPreferences(context) }
var heartRateRevision by remember { mutableIntStateOf(0) }
val selectedHeartRateSensor =
remember(heartRateRevision) { heartRatePreferences.selected() }
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
authorized = inbox.hasFolderAccess()
message = strings.getString(if (authorized) R.string.settings_access_granted else R.string.settings_access_required)
@ -539,11 +546,27 @@ fun SettingsScreen(
}
}
TrainlogFrame(strings.getString(R.string.settings_cardio_section)) {
if (selectedHeartRateSensor != null) {
TrainlogInfo(
strings.getString(R.string.settings_cardio_configured),
colors.success,
)
}
TrainlogAction(
strings.getString(R.string.settings_cardio_diagnostics),
strings.getString(R.string.settings_cardio_diagnostics_description),
onHeartRateDiagnostics,
)
if (selectedHeartRateSensor != null) {
TrainlogAction(
strings.getString(R.string.settings_cardio_forget),
strings.getString(R.string.settings_cardio_forget_description),
{
HeartRateSensorService.forget(context)
heartRateRevision++
},
)
}
}
TrainlogFrame(strings.getString(R.string.settings_exchange_folder)) {
TrainlogInfo(strings.getString(if (authorized) R.string.settings_folder_granted else R.string.settings_folder_required), if (authorized) colors.success else colors.warning)

View file

@ -196,7 +196,7 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
AppRoute.LatestMaxima -> LatestMaximaScreen(repository)
AppRoute.Sync -> SyncScreen(repository, inbox, exporter, requestOutbox, { exportSnapshot(); catalogRevision++ }, { back() })
AppRoute.Settings -> SettingsScreen(repository, inbox, { open(AppRoute.HeartRateDiagnostics) }) { exportSnapshot(); catalogRevision++ }
AppRoute.HeartRateDiagnostics -> HeartRateDiagnosticsScreen()
AppRoute.HeartRateDiagnostics -> HeartRateDiagnosticsScreen { back() }
}}
}

View file

@ -511,4 +511,18 @@
<string name="hr_diag_hrs_advertised">Heart Rate advertised</string>
<string name="hr_diag_hrs_not_advertised">Heart Rate not advertised</string>
<string name="hr_diag_device_description">%1$s · %2$d dBm · %3$s</string>
<string name="hr_diag_use_sensor">Use this sensor</string>
<string name="heart_rate_service_channel">Heart-rate sensor</string>
<string name="heart_rate_service_title">Trainlog · cardio</string>
<string name="heart_rate_service_connecting">Connecting to sensor…</string>
<string name="heart_rate_service_reconnecting">Waiting for heart-rate signal…</string>
<string name="heart_rate_service_disconnected">Heart-rate sensor disconnected</string>
<string name="heart_rate_service_bpm">%1$d BPM</string>
<string name="heart_rate_a11y_connected">Heart rate %1$d BPM, sensor connected</string>
<string name="heart_rate_a11y_connecting">Heart-rate sensor connecting</string>
<string name="heart_rate_a11y_reconnecting">Heart-rate signal temporarily lost, reconnecting</string>
<string name="heart_rate_a11y_disconnected">Heart-rate sensor disconnected</string>
<string name="settings_cardio_configured">Heart-rate sensor configured.</string>
<string name="settings_cardio_forget">Forget sensor</string>
<string name="settings_cardio_forget_description">Stop heart-rate connection and remove the remembered sensor.</string>
</resources>

View file

@ -521,4 +521,18 @@
<string name="hr_diag_hrs_advertised">Heart Rate annoncé</string>
<string name="hr_diag_hrs_not_advertised">Heart Rate non annoncé</string>
<string name="hr_diag_device_description">%1$s · %2$d dBm · %3$s</string>
<string name="hr_diag_use_sensor">Utiliser ce capteur</string>
<string name="heart_rate_service_channel">Capteur cardio</string>
<string name="heart_rate_service_title">Trainlog · cardio</string>
<string name="heart_rate_service_connecting">Connexion au capteur…</string>
<string name="heart_rate_service_reconnecting">Signal cardio en attente…</string>
<string name="heart_rate_service_disconnected">Capteur cardio déconnecté</string>
<string name="heart_rate_service_bpm">%1$d BPM</string>
<string name="heart_rate_a11y_connected">Fréquence cardiaque %1$d BPM, capteur connecté</string>
<string name="heart_rate_a11y_connecting">Capteur cardio en connexion</string>
<string name="heart_rate_a11y_reconnecting">Signal cardio temporairement perdu, reconnexion en cours</string>
<string name="heart_rate_a11y_disconnected">Capteur cardio déconnecté</string>
<string name="settings_cardio_configured">Capteur cardio configuré.</string>
<string name="settings_cardio_forget">Oublier le capteur</string>
<string name="settings_cardio_forget_description">Arrêter la connexion cardio et supprimer le capteur mémorisé.</string>
</resources>

View file

@ -0,0 +1,42 @@
package com.labfytools.trainlog.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class HeartRateLiveStateTest {
@Test
fun liveStateDropsStaleBpmWithoutInventingMeasurement() {
HeartRateLiveState.disconnected()
val observed = mutableListOf<HeartRateLiveSnapshot>()
val subscription = HeartRateLiveState.subscribe { observed += it }
try {
HeartRateLiveState.connecting()
HeartRateLiveState.measurement(
ParsedHeartRateMeasurement(
bpm = 82,
sensorContactDetected = null,
energyExpended = null,
rrIntervals1024 = listOf(768),
),
)
assertEquals(HeartRateLivePhase.CONNECTED, HeartRateLiveState.current().phase)
assertEquals(82, HeartRateLiveState.current().bpm)
assertEquals(listOf(768), HeartRateLiveState.current().rrIntervals1024)
assertEquals(1L, HeartRateLiveState.current().notificationCount)
HeartRateLiveState.stale()
assertEquals(HeartRateLivePhase.STALE, HeartRateLiveState.current().phase)
assertNull(HeartRateLiveState.current().bpm)
assertEquals(listOf(768), HeartRateLiveState.current().rrIntervals1024)
HeartRateLiveState.disconnected()
assertEquals(HeartRateLivePhase.DISCONNECTED, HeartRateLiveState.current().phase)
assertNull(HeartRateLiveState.current().bpm)
assertEquals(5, observed.size)
} finally {
subscription.close()
HeartRateLiveState.disconnected()
}
}
}

View file

@ -0,0 +1,32 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class HeartRateSensorPreferencesTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun selectionIsDurableAndClearable() {
val preferences = HeartRateSensorPreferences(context)
preferences.clear()
assertNull(preferences.selected())
preferences.select("AA:BB:CC:DD:EE:FF", "Synthetic HR")
assertEquals(
SelectedHeartRateSensor("AA:BB:CC:DD:EE:FF", "Synthetic HR"),
HeartRateSensorPreferences(context).selected(),
)
preferences.clear()
assertNull(HeartRateSensorPreferences(context).selected())
}
}

View file

@ -149,7 +149,8 @@ parallel implementation of its rules.
| Web Exercises V1 | `TRAINLOG_WEB_EXERCISES_V1=PASS` |
| Cardio persistence/sync | `TRAINLOG_CARDIO_PERSISTENCE_SYNC_V1=PASS`: Android v27 + desktop v30, strict optional `trainlog-heart-rate` V1 Android→desktop companion, generation relation and correlated ACK |
| Cardio BLE diagnostics | `TRAINLOG_CARDIO_BLE_DIAGNOSTICS_V1=PASS`: real CYCPLUS H2 validated on Android; `0x180D` + `0x2A37`, Battery Service, approximately 1 Hz notifications and one raw RR interval per observed notification; contact and energy fields absent in the observed payloads |
| Current operational cursor | `TRAINLOG_CARDIO_PERSISTENT_ACQUISITION_V1`: remember the validated sensor, persistent reconnecting Android acquisition service, stale-signal handling and global grey/orange/green `♥ BPM` indicator |
| Cardio persistent acquisition | `TRAINLOG_CARDIO_PERSISTENT_ACQUISITION_V1=PASS`: selected sensor persists, dedicated connected-device foreground service reconnects, stale BPM is cleared, and the global Android `♥ BPM` indicator is grey/orange/green by connection state; real CYCPLUS restart and Bluetooth-off/on recovery validated on private vc40 |
| Current operational cursor | `TRAINLOG_CARDIO_TRAINING_TIMELINE_V1`: persist real session/exercise start/end boundaries and correlate live heart-rate samples with the active stable `entry_id` |
| Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered |
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |

View file

@ -69,7 +69,7 @@ sensor. Heart-rate ingestion must not reuse or overload the frozen Trainlog
Bluetooth synchronization transport contract.
```text
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_CARDIO_PERSISTENT_ACQUISITION_V1
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_CARDIO_TRAINING_TIMELINE_V1
```
The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this

View file

@ -1703,3 +1703,27 @@ in project documentation.
The complete Android JVM inventory after this tranche reports **266 tests:
261 passed, five skipped, zero failures/errors**, and `assembleDebug` succeeds.
## Cardio persistent acquisition V1 validation
`TRAINLOG_CARDIO_PERSISTENT_ACQUISITION_V1=PASS` adds a remembered Android
heart-rate sensor, a dedicated connected-device foreground service, process-wide
live state and the global app-bar `♥ BPM` indicator. A fresh measurement is
green; connection/reconnection or stale signal is orange; no configured/active
sensor is grey. A BPM older than three seconds is removed from presentation,
and a stale GATT stream is forcibly recycled after eight seconds.
Unit coverage proves durable sensor selection/clear and the live-state
transition from connecting to measured BPM/RR, stale `-- BPM`, and
disconnected. The complete Android JVM inventory after this tranche reports
**268 tests: 263 passed, five skipped, zero failures/errors**; `assembleDebug`
also passes.
Real-device validation used the authorized CYCPLUS H2 on the private Trainlog
0.1.5 versionCode 40 installation. With no selected sensor the global indicator
rendered `♥ -- BPM` and the disconnected accessibility state. Selecting the
validated sensor produced live `♥ 75 BPM`; a complete app force-stop/restart
reloaded the remembered sensor and returned automatically at `♥ 72 BPM`.
Disabling phone Bluetooth produced `♥ -- BPM` with the reconnecting state;
reenabling Bluetooth restored the same foreground service automatically and
returned to a fresh `♥ 79 BPM`. No uninstall or data clear occurred.