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.