diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7fefc4d..39deb25 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/README.md b/README.md
index a22c0b2..f8e7256 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 7facead..5070f02 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -50,5 +50,9 @@
android:name=".data.SyncBackgroundService"
android:exported="false"
android:foregroundServiceType="connectedDevice|dataSync" />
+
diff --git a/android/app/src/main/java/com/labfytools/trainlog/MainActivity.kt b/android/app/src/main/java/com/labfytools/trainlog/MainActivity.kt
index bc51bdd..0ab023b 100644
--- a/android/app/src/main/java/com/labfytools/trainlog/MainActivity.kt
+++ b/android/app/src/main/java/com/labfytools/trainlog/MainActivity.kt
@@ -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(
diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/HeartRateBleDiagnostics.kt b/android/app/src/main/java/com/labfytools/trainlog/data/HeartRateBleDiagnostics.kt
index 6c79927..b790744 100644
--- a/android/app/src/main/java/com/labfytools/trainlog/data/HeartRateBleDiagnostics.kt
+++ b/android/app/src/main/java/com/labfytools/trainlog/data/HeartRateBleDiagnostics.kt
@@ -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 {
diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/HeartRateLiveState.kt b/android/app/src/main/java/com/labfytools/trainlog/data/HeartRateLiveState.kt
new file mode 100644
index 0000000..efae3f1
--- /dev/null
+++ b/android/app/src/main/java/com/labfytools/trainlog/data/HeartRateLiveState.kt
@@ -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 = 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,
+ ),
+ )
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/HeartRateSensorPreferences.kt b/android/app/src/main/java/com/labfytools/trainlog/data/HeartRateSensorPreferences.kt
new file mode 100644
index 0000000..dda1d83
--- /dev/null
+++ b/android/app/src/main/java/com/labfytools/trainlog/data/HeartRateSensorPreferences.kt
@@ -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"
+ }
+}
diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/HeartRateSensorService.kt b/android/app/src/main/java/com/labfytools/trainlog/data/HeartRateSensorService.kt
new file mode 100644
index 0000000..99c6099
--- /dev/null
+++ b/android/app/src/main/java/com/labfytools/trainlog/data/HeartRateSensorService.kt
@@ -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()
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/AndroidAppShell.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/AndroidAppShell.kt
index e08e077..5bc46c5 100644
--- a/android/app/src/main/java/com/labfytools/trainlog/ui/AndroidAppShell.kt
+++ b/android/app/src/main/java/com/labfytools/trainlog/ui/AndroidAppShell.kt
@@ -146,6 +146,7 @@ fun AndroidAppShell(
}
},
actions = {
+ HeartRateIndicator()
if (!root) {
IconButton(
onClick = { scope.launch { drawerState.open() } },
diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/HeartRateDiagnosticsScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/HeartRateDiagnosticsScreen.kt
index a0b807b..2fcaaf4 100644
--- a/android/app/src/main/java/com/labfytools/trainlog/ui/HeartRateDiagnosticsScreen.kt
+++ b/android/app/src/main/java/com/labfytools/trainlog/ui/HeartRateDiagnosticsScreen.kt
@@ -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)) {
diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/HeartRateIndicator.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/HeartRateIndicator.kt
new file mode 100644
index 0000000..58e4b3e
--- /dev/null
+++ b/android/app/src/main/java/com/labfytools/trainlog/ui/HeartRateIndicator.kt
@@ -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,
+ )
+ }
+}
diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt
index 10e637e..4e1349d 100644
--- a/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt
+++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SectionScreens.kt
@@ -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(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)
diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt
index 20526ea..f8aa3b4 100644
--- a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt
+++ b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt
@@ -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() }
}}
}
diff --git a/android/app/src/main/res/values-en/strings.xml b/android/app/src/main/res/values-en/strings.xml
index a86f2e5..a53c26f 100644
--- a/android/app/src/main/res/values-en/strings.xml
+++ b/android/app/src/main/res/values-en/strings.xml
@@ -511,4 +511,18 @@
Heart Rate advertised
Heart Rate not advertised
%1$s · %2$d dBm · %3$s
+ Use this sensor
+ Heart-rate sensor
+ Trainlog · cardio
+ Connecting to sensor…
+ Waiting for heart-rate signal…
+ Heart-rate sensor disconnected
+ %1$d BPM
+ Heart rate %1$d BPM, sensor connected
+ Heart-rate sensor connecting
+ Heart-rate signal temporarily lost, reconnecting
+ Heart-rate sensor disconnected
+ Heart-rate sensor configured.
+ Forget sensor
+ Stop heart-rate connection and remove the remembered sensor.
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index d24b3bd..d967856 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -521,4 +521,18 @@
Heart Rate annoncé
Heart Rate non annoncé
%1$s · %2$d dBm · %3$s
+ Utiliser ce capteur
+ Capteur cardio
+ Trainlog · cardio
+ Connexion au capteur…
+ Signal cardio en attente…
+ Capteur cardio déconnecté
+ %1$d BPM
+ Fréquence cardiaque %1$d BPM, capteur connecté
+ Capteur cardio en connexion
+ Signal cardio temporairement perdu, reconnexion en cours
+ Capteur cardio déconnecté
+ Capteur cardio configuré.
+ Oublier le capteur
+ Arrêter la connexion cardio et supprimer le capteur mémorisé.
diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/HeartRateLiveStateTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/HeartRateLiveStateTest.kt
new file mode 100644
index 0000000..d2e08e3
--- /dev/null
+++ b/android/app/src/test/java/com/labfytools/trainlog/data/HeartRateLiveStateTest.kt
@@ -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()
+ 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()
+ }
+ }
+}
diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/HeartRateSensorPreferencesTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/HeartRateSensorPreferencesTest.kt
new file mode 100644
index 0000000..241b50a
--- /dev/null
+++ b/android/app/src/test/java/com/labfytools/trainlog/data/HeartRateSensorPreferencesTest.kt
@@ -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())
+ }
+}
diff --git a/docs/current_state.md b/docs/current_state.md
index 7a94a92..de6a198 100644
--- a/docs/current_state.md
+++ b/docs/current_state.md
@@ -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` |
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 3e41fd7..5422398 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -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
diff --git a/docs/tests.md b/docs/tests.md
index ec78b38..f9119cc 100644
--- a/docs/tests.md
+++ b/docs/tests.md
@@ -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.