diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6da76fe..7fefc4d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,14 @@
consumed ACK, after which those stopped captures remain local history but are
excluded from ordinary re-publication. Desktop/Web remain sensor consumers
only; Bluetooth Classic Trainlog synchronization is unchanged.
+- Added the Android Heart Rate Service diagnostic foundation. The independent
+ `0x2A37` parser covers 8/16-bit BPM, contact, energy and raw RR flags with
+ strict malformed-payload rejection. A settings diagnostic provides bounded
+ 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.
Development opened after stable v0.1.4 release.
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 7e5baf2..7facead 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -12,6 +12,14 @@
+
+
+
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
new file mode 100644
index 0000000..6c79927
--- /dev/null
+++ b/android/app/src/main/java/com/labfytools/trainlog/data/HeartRateBleDiagnostics.kt
@@ -0,0 +1,482 @@
+/*
+ * Android-only BLE Heart Rate diagnostics.
+ *
+ * This owner is intentionally bounded and screen-scoped. Long-running capture
+ * belongs to the later foreground acquisition service, not this diagnostic.
+ */
+package com.labfytools.trainlog.data
+
+import android.Manifest
+import android.annotation.SuppressLint
+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.bluetooth.le.ScanCallback
+import android.bluetooth.le.ScanFilter
+import android.bluetooth.le.ScanResult
+import android.bluetooth.le.ScanSettings
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.Handler
+import android.os.Looper
+import android.os.ParcelUuid
+import androidx.core.content.ContextCompat
+import java.util.UUID
+
+internal val HEART_RATE_SERVICE_UUID: UUID =
+ UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb")
+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 =
+ UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
+
+internal enum class HeartRateDiagnosticPhase {
+ IDLE,
+ SCANNING,
+ CONNECTING,
+ DISCOVERING,
+ SUBSCRIBING,
+ RECEIVING,
+ DISCONNECTED,
+ ERROR,
+}
+
+internal data class HeartRateDiagnosticDevice(
+ val address: String,
+ val name: String?,
+ val rssi: Int,
+ val advertisesHeartRate: Boolean,
+)
+
+internal data class HeartRateDiagnosticSnapshot(
+ val phase: HeartRateDiagnosticPhase = HeartRateDiagnosticPhase.IDLE,
+ val devices: List = emptyList(),
+ val selectedAddress: String? = null,
+ val serviceFound: Boolean = false,
+ val measurementFound: Boolean = false,
+ val batteryServiceFound: Boolean = false,
+ val notificationCount: Int = 0,
+ val latestMeasurement: ParsedHeartRateMeasurement? = null,
+ val diagnostic: String? = null,
+)
+
+internal fun heartRateBleRuntimePermissions(): Array =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
+ } else {
+ arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
+ }
+
+internal fun hasHeartRateBleRuntimePermissions(context: Context): Boolean =
+ heartRateBleRuntimePermissions().all {
+ ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
+ }
+
+internal class HeartRateBleDiagnostics(
+ context: Context,
+ private val onSnapshot: (HeartRateDiagnosticSnapshot) -> Unit,
+) : AutoCloseable {
+ private val appContext = context.applicationContext
+ private val main = Handler(Looper.getMainLooper())
+ private val bluetoothManager =
+ appContext.getSystemService(BluetoothManager::class.java)
+ private val adapter get() = bluetoothManager?.adapter
+ private val devices = linkedMapOf()
+ private var snapshot = HeartRateDiagnosticSnapshot()
+ private var scannerCallback: ScanCallback? = null
+ private var scanStop: Runnable? = null
+ private var gatt: BluetoothGatt? = null
+ private var closed = false
+
+ private fun update(transform: (HeartRateDiagnosticSnapshot) -> HeartRateDiagnosticSnapshot) {
+ if (closed) return
+ if (Looper.myLooper() != Looper.getMainLooper()) {
+ main.post { update(transform) }
+ return
+ }
+ snapshot = transform(snapshot)
+ onSnapshot(snapshot)
+ }
+
+ @SuppressLint("MissingPermission")
+ fun startScan(includeAllBle: Boolean = false) {
+ if (!hasHeartRateBleRuntimePermissions(appContext)) {
+ update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "permission") }
+ return
+ }
+ val currentAdapter = adapter
+ if (currentAdapter == null) {
+ update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "bluetooth_unavailable") }
+ return
+ }
+ if (!currentAdapter.isEnabled) {
+ update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "bluetooth_disabled") }
+ return
+ }
+ val scanner = currentAdapter.bluetoothLeScanner
+ if (scanner == null) {
+ update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "scanner_unavailable") }
+ return
+ }
+
+ stopScan()
+ devices.clear()
+ update {
+ HeartRateDiagnosticSnapshot(
+ phase = HeartRateDiagnosticPhase.SCANNING,
+ diagnostic = if (includeAllBle) "scan_all" else "scan_heart_rate",
+ )
+ }
+
+ val callback =
+ object : ScanCallback() {
+ override fun onScanResult(callbackType: Int, result: ScanResult) {
+ handleScanResult(result)
+ }
+
+ override fun onBatchScanResults(results: MutableList) {
+ results.forEach(::handleScanResult)
+ }
+
+ override fun onScanFailed(errorCode: Int) {
+ update {
+ it.copy(
+ phase = HeartRateDiagnosticPhase.ERROR,
+ diagnostic = "scan_failed:" + errorCode,
+ )
+ }
+ }
+ }
+ scannerCallback = callback
+ val filters =
+ if (includeAllBle) {
+ emptyList()
+ } else {
+ listOf(
+ ScanFilter.Builder()
+ .setServiceUuid(ParcelUuid(HEART_RATE_SERVICE_UUID))
+ .build(),
+ )
+ }
+ scanner.startScan(
+ filters,
+ ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build(),
+ callback,
+ )
+ val stopper =
+ Runnable {
+ if (snapshot.phase == HeartRateDiagnosticPhase.SCANNING) {
+ stopScan()
+ update { it.copy(phase = HeartRateDiagnosticPhase.IDLE, diagnostic = "scan_complete") }
+ }
+ }
+ scanStop = stopper
+ main.postDelayed(stopper, SCAN_DURATION_MS)
+ }
+
+ @SuppressLint("MissingPermission")
+ private fun handleScanResult(result: ScanResult) {
+ val record = result.scanRecord
+ val advertised =
+ record?.serviceUuids?.any { it.uuid == HEART_RATE_SERVICE_UUID } == true
+ val device = result.device
+ devices[device.address] = device
+ val row =
+ HeartRateDiagnosticDevice(
+ address = device.address,
+ name = record?.deviceName,
+ rssi = result.rssi,
+ advertisesHeartRate = advertised,
+ )
+ update { old ->
+ val rows =
+ (old.devices.filterNot { it.address == row.address } + row)
+ .sortedWith(
+ compareByDescending { it.advertisesHeartRate }
+ .thenByDescending { it.rssi },
+ )
+ old.copy(devices = rows)
+ }
+ }
+
+ @SuppressLint("MissingPermission")
+ fun connect(address: String) {
+ if (!hasHeartRateBleRuntimePermissions(appContext)) {
+ update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "permission") }
+ return
+ }
+ val device =
+ devices[address]
+ ?: runCatching { adapter?.getRemoteDevice(address) }.getOrNull()
+ ?: run {
+ update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "device_missing") }
+ return
+ }
+ stopScan()
+ closeGatt()
+ update {
+ it.copy(
+ phase = HeartRateDiagnosticPhase.CONNECTING,
+ selectedAddress = address,
+ serviceFound = false,
+ measurementFound = false,
+ batteryServiceFound = false,
+ notificationCount = 0,
+ latestMeasurement = null,
+ diagnostic = "connecting",
+ )
+ }
+ @Suppress("DEPRECATION")
+ runCatching {
+ device.connectGatt(
+ appContext,
+ false,
+ gattCallback,
+ BluetoothDevice.TRANSPORT_LE,
+ )
+ }
+ .onSuccess { gatt = it }
+ .onFailure { error ->
+ update {
+ it.copy(
+ phase = HeartRateDiagnosticPhase.ERROR,
+ diagnostic = "connect_failed:" + error.javaClass.simpleName,
+ )
+ }
+ }
+ }
+
+ private val gattCallback =
+ object : BluetoothGattCallback() {
+ @SuppressLint("MissingPermission")
+ override fun onConnectionStateChange(
+ callbackGatt: BluetoothGatt,
+ status: Int,
+ newState: Int,
+ ) {
+ main.post {
+ if (closed || callbackGatt !== gatt) return@post
+ if (status == BluetoothGatt.GATT_SUCCESS &&
+ newState == BluetoothProfile.STATE_CONNECTED
+ ) {
+ update {
+ it.copy(
+ phase = HeartRateDiagnosticPhase.DISCOVERING,
+ diagnostic = "connected",
+ )
+ }
+ if (!callbackGatt.discoverServices()) {
+ update {
+ it.copy(
+ phase = HeartRateDiagnosticPhase.ERROR,
+ diagnostic = "discover_start_failed",
+ )
+ }
+ }
+ } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
+ update {
+ it.copy(
+ phase = HeartRateDiagnosticPhase.DISCONNECTED,
+ diagnostic = "disconnected:" + status,
+ )
+ }
+ closeGatt()
+ } else if (status != BluetoothGatt.GATT_SUCCESS) {
+ update {
+ it.copy(
+ phase = HeartRateDiagnosticPhase.ERROR,
+ diagnostic = "gatt_status:" + status,
+ )
+ }
+ closeGatt()
+ }
+ }
+ }
+
+ @SuppressLint("MissingPermission")
+ override fun onServicesDiscovered(callbackGatt: BluetoothGatt, status: Int) {
+ main.post {
+ if (closed || callbackGatt !== gatt) return@post
+ if (status != BluetoothGatt.GATT_SUCCESS) {
+ update {
+ it.copy(
+ phase = HeartRateDiagnosticPhase.ERROR,
+ diagnostic = "service_discovery:" + status,
+ )
+ }
+ return@post
+ }
+ val service = callbackGatt.getService(HEART_RATE_SERVICE_UUID)
+ val measurement = service?.getCharacteristic(HEART_RATE_MEASUREMENT_UUID)
+ val battery = callbackGatt.getService(BATTERY_SERVICE_UUID) != null
+ update {
+ it.copy(
+ serviceFound = service != null,
+ measurementFound = measurement != null,
+ batteryServiceFound = battery,
+ )
+ }
+ if (measurement == null) {
+ update {
+ it.copy(
+ phase = HeartRateDiagnosticPhase.ERROR,
+ diagnostic = "heart_rate_measurement_missing",
+ )
+ }
+ return@post
+ }
+ subscribe(callbackGatt, measurement)
+ }
+ }
+
+ override fun onDescriptorWrite(
+ callbackGatt: BluetoothGatt,
+ descriptor: BluetoothGattDescriptor,
+ status: Int,
+ ) {
+ if (descriptor.uuid != CLIENT_CHARACTERISTIC_CONFIG_UUID) return
+ update {
+ if (status == BluetoothGatt.GATT_SUCCESS) {
+ it.copy(
+ phase = HeartRateDiagnosticPhase.RECEIVING,
+ diagnostic = "notifications_enabled",
+ )
+ } else {
+ it.copy(
+ phase = HeartRateDiagnosticPhase.ERROR,
+ diagnostic = "descriptor_write:" + status,
+ )
+ }
+ }
+ }
+
+ @Deprecated("Deprecated by Android API 33 callback with explicit value")
+ override fun onCharacteristicChanged(
+ callbackGatt: BluetoothGatt,
+ characteristic: BluetoothGattCharacteristic,
+ ) {
+ @Suppress("DEPRECATION")
+ val value = characteristic.value ?: return
+ handleMeasurement(characteristic.uuid, value)
+ }
+
+ override fun onCharacteristicChanged(
+ callbackGatt: BluetoothGatt,
+ characteristic: BluetoothGattCharacteristic,
+ value: ByteArray,
+ ) {
+ handleMeasurement(characteristic.uuid, value)
+ }
+ }
+
+ @SuppressLint("MissingPermission")
+ private fun subscribe(
+ callbackGatt: BluetoothGatt,
+ measurement: BluetoothGattCharacteristic,
+ ) {
+ if (!callbackGatt.setCharacteristicNotification(measurement, true)) {
+ update {
+ it.copy(
+ phase = HeartRateDiagnosticPhase.ERROR,
+ diagnostic = "notification_enable_failed",
+ )
+ }
+ return
+ }
+ val descriptor = measurement.getDescriptor(CLIENT_CHARACTERISTIC_CONFIG_UUID)
+ if (descriptor == null) {
+ update {
+ it.copy(
+ phase = HeartRateDiagnosticPhase.ERROR,
+ diagnostic = "ccc_descriptor_missing",
+ )
+ }
+ return
+ }
+ update {
+ it.copy(
+ phase = HeartRateDiagnosticPhase.SUBSCRIBING,
+ diagnostic = "enabling_notifications",
+ )
+ }
+ val started =
+ 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)
+ }
+ }
+ if (!started) {
+ update {
+ it.copy(
+ phase = HeartRateDiagnosticPhase.ERROR,
+ diagnostic = "descriptor_write_start_failed",
+ )
+ }
+ }
+ }
+
+ private fun handleMeasurement(uuid: UUID, value: ByteArray) {
+ if (uuid != HEART_RATE_MEASUREMENT_UUID) return
+ when (val parsed = HeartRateMeasurementParser.parse(value)) {
+ is HeartRateMeasurementParseResult.Invalid ->
+ update {
+ it.copy(
+ phase = HeartRateDiagnosticPhase.ERROR,
+ diagnostic = "measurement:" + parsed.reason,
+ )
+ }
+ is HeartRateMeasurementParseResult.Parsed ->
+ update {
+ it.copy(
+ phase = HeartRateDiagnosticPhase.RECEIVING,
+ notificationCount = it.notificationCount + 1,
+ latestMeasurement = parsed.measurement,
+ diagnostic = "measurement",
+ )
+ }
+ }
+ }
+
+ @SuppressLint("MissingPermission")
+ fun stopScan() {
+ val callback = scannerCallback ?: return
+ scanStop?.let(main::removeCallbacks)
+ scanStop = null
+ runCatching { adapter?.bluetoothLeScanner?.stopScan(callback) }
+ scannerCallback = null
+ }
+
+ @SuppressLint("MissingPermission")
+ private fun closeGatt() {
+ val current = gatt
+ gatt = null
+ runCatching { current?.disconnect() }
+ runCatching { current?.close() }
+ }
+
+ override fun close() {
+ if (closed) return
+ stopScan()
+ closeGatt()
+ closed = true
+ }
+
+ private companion object {
+ const val SCAN_DURATION_MS = 10_000L
+ }
+}
diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/HeartRateMeasurementParser.kt b/android/app/src/main/java/com/labfytools/trainlog/data/HeartRateMeasurementParser.kt
new file mode 100644
index 0000000..69d3bd1
--- /dev/null
+++ b/android/app/src/main/java/com/labfytools/trainlog/data/HeartRateMeasurementParser.kt
@@ -0,0 +1,103 @@
+/*
+ * Bluetooth Heart Rate Measurement parser.
+ *
+ * This file deliberately has no Android Bluetooth dependency so protocol
+ * decoding can be exhaustively unit-tested independently of GATT transport.
+ */
+package com.labfytools.trainlog.data
+
+internal data class ParsedHeartRateMeasurement(
+ val bpm: Int,
+ val sensorContactDetected: Boolean?,
+ val energyExpended: Int?,
+ /** Raw Bluetooth Heart Rate Service units: 1/1024 second. */
+ val rrIntervals1024: List,
+)
+
+internal sealed interface HeartRateMeasurementParseResult {
+ data class Parsed(val measurement: ParsedHeartRateMeasurement) :
+ HeartRateMeasurementParseResult
+ data class Invalid(val reason: String) : HeartRateMeasurementParseResult
+}
+
+internal object HeartRateMeasurementParser {
+ private const val FLAG_HEART_RATE_16_BIT = 1 shl 0
+ private const val FLAG_CONTACT_DETECTED = 1 shl 1
+ private const val FLAG_CONTACT_SUPPORTED = 1 shl 2
+ private const val FLAG_ENERGY_PRESENT = 1 shl 3
+ private const val FLAG_RR_PRESENT = 1 shl 4
+ private const val KNOWN_FLAGS =
+ FLAG_HEART_RATE_16_BIT or FLAG_CONTACT_DETECTED or FLAG_CONTACT_SUPPORTED or
+ FLAG_ENERGY_PRESENT or FLAG_RR_PRESENT
+
+ fun parse(payload: ByteArray): HeartRateMeasurementParseResult {
+ if (payload.isEmpty()) return HeartRateMeasurementParseResult.Invalid("missing flags")
+ val flags = payload[0].toInt() and 0xff
+ if ((flags and KNOWN_FLAGS.inv()) != 0) {
+ return HeartRateMeasurementParseResult.Invalid("reserved flags are set")
+ }
+
+ var offset = 1
+ fun uint8(): Int? =
+ payload.getOrNull(offset)?.let {
+ offset += 1
+ it.toInt() and 0xff
+ }
+ fun uint16(): Int? {
+ if (offset + 2 > payload.size) return null
+ val value =
+ (payload[offset].toInt() and 0xff) or
+ ((payload[offset + 1].toInt() and 0xff) shl 8)
+ offset += 2
+ return value
+ }
+
+ val bpm =
+ if ((flags and FLAG_HEART_RATE_16_BIT) != 0) {
+ uint16() ?: return HeartRateMeasurementParseResult.Invalid("truncated 16-bit BPM")
+ } else {
+ uint8() ?: return HeartRateMeasurementParseResult.Invalid("missing BPM")
+ }
+
+ val contact =
+ if ((flags and FLAG_CONTACT_SUPPORTED) == 0) {
+ null
+ } else {
+ (flags and FLAG_CONTACT_DETECTED) != 0
+ }
+
+ val energy =
+ if ((flags and FLAG_ENERGY_PRESENT) != 0) {
+ uint16()
+ ?: return HeartRateMeasurementParseResult.Invalid(
+ "truncated energy-expended value",
+ )
+ } else {
+ null
+ }
+
+ val rr = mutableListOf()
+ if ((flags and FLAG_RR_PRESENT) != 0) {
+ if (offset == payload.size) {
+ return HeartRateMeasurementParseResult.Invalid("RR flag has no interval")
+ }
+ if ((payload.size - offset) % 2 != 0) {
+ return HeartRateMeasurementParseResult.Invalid("truncated RR interval")
+ }
+ while (offset < payload.size) {
+ rr += checkNotNull(uint16())
+ }
+ } else if (offset != payload.size) {
+ return HeartRateMeasurementParseResult.Invalid("unexpected trailing bytes")
+ }
+
+ return HeartRateMeasurementParseResult.Parsed(
+ ParsedHeartRateMeasurement(
+ bpm = bpm,
+ sensorContactDetected = contact,
+ energyExpended = energy,
+ rrIntervals1024 = rr,
+ ),
+ )
+ }
+}
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 11e12e6..e08e077 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
@@ -77,6 +77,7 @@ fun AndroidAppShell(
AppRoute.BodyMeasurements -> R.string.route_body_measurements; AppRoute.LatestMaxima -> R.string.route_latest_maxima
AppRoute.SleepDiary -> R.string.nav_sleep
AppRoute.Sync -> R.string.nav_sync; AppRoute.Settings -> R.string.nav_settings
+ AppRoute.HeartRateDiagnostics -> R.string.route_heart_rate_diagnostics
})
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/AppNavigation.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/AppNavigation.kt
index 15eaa84..67097d4 100644
--- a/android/app/src/main/java/com/labfytools/trainlog/ui/AppNavigation.kt
+++ b/android/app/src/main/java/com/labfytools/trainlog/ui/AppNavigation.kt
@@ -39,6 +39,7 @@ sealed interface AppRoute {
data object LatestMaxima : AppRoute { override val section = AppSection.EXERCISES }
data object Sync : AppRoute { override val section = AppSection.SYNC }
data object Settings : AppRoute { override val section = AppSection.SETTINGS }
+ data object HeartRateDiagnostics : AppRoute { override val section = AppSection.SETTINGS }
}
fun AppSection.rootRoute(): AppRoute = when (this) {
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
new file mode 100644
index 0000000..a0b807b
--- /dev/null
+++ b/android/app/src/main/java/com/labfytools/trainlog/ui/HeartRateDiagnosticsScreen.kt
@@ -0,0 +1,180 @@
+package com.labfytools.trainlog.ui
+
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+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.platform.LocalContext
+import androidx.compose.ui.unit.dp
+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.hasHeartRateBleRuntimePermissions
+import com.labfytools.trainlog.data.heartRateBleRuntimePermissions
+import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
+
+@Composable
+fun HeartRateDiagnosticsScreen() {
+ val context = LocalContext.current
+ val strings = localizedContext()
+ val colors = LocalTrainlogColors.current
+ var snapshot by remember { mutableStateOf(HeartRateDiagnosticSnapshot()) }
+ var pendingExpandedScan by remember { mutableStateOf(false) }
+ var permissionDenied by remember { mutableStateOf(false) }
+ val diagnostics =
+ remember(context) {
+ HeartRateBleDiagnostics(context) { next -> snapshot = next }
+ }
+
+ DisposableEffect(diagnostics) {
+ onDispose { diagnostics.close() }
+ }
+
+ val permissionLauncher =
+ rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestMultiplePermissions(),
+ ) { results ->
+ if (heartRateBleRuntimePermissions().all { results[it] == true }) {
+ permissionDenied = false
+ diagnostics.startScan(pendingExpandedScan)
+ } else {
+ permissionDenied = true
+ }
+ }
+
+ fun scan(expanded: Boolean) {
+ pendingExpandedScan = expanded
+ if (hasHeartRateBleRuntimePermissions(context)) {
+ permissionDenied = false
+ diagnostics.startScan(expanded)
+ } else {
+ permissionLauncher.launch(heartRateBleRuntimePermissions())
+ }
+ }
+
+ fun phaseLabel(): String =
+ strings.getString(
+ when (snapshot.phase) {
+ HeartRateDiagnosticPhase.IDLE -> R.string.hr_diag_idle
+ HeartRateDiagnosticPhase.SCANNING -> R.string.hr_diag_scanning
+ HeartRateDiagnosticPhase.CONNECTING -> R.string.hr_diag_connecting
+ HeartRateDiagnosticPhase.DISCOVERING -> R.string.hr_diag_discovering
+ HeartRateDiagnosticPhase.SUBSCRIBING -> R.string.hr_diag_subscribing
+ HeartRateDiagnosticPhase.RECEIVING -> R.string.hr_diag_receiving
+ HeartRateDiagnosticPhase.DISCONNECTED -> R.string.hr_diag_disconnected
+ HeartRateDiagnosticPhase.ERROR -> R.string.hr_diag_error
+ },
+ )
+
+ TrainlogScreen(strings.getString(R.string.route_heart_rate_diagnostics)) {
+ TrainlogFrame(strings.getString(R.string.hr_diag_status)) {
+ TrainlogInfo(
+ phaseLabel(),
+ when (snapshot.phase) {
+ HeartRateDiagnosticPhase.RECEIVING -> colors.success
+ HeartRateDiagnosticPhase.ERROR -> colors.error
+ HeartRateDiagnosticPhase.CONNECTING,
+ HeartRateDiagnosticPhase.DISCOVERING,
+ HeartRateDiagnosticPhase.SUBSCRIBING,
+ HeartRateDiagnosticPhase.SCANNING -> colors.warning
+ else -> colors.muted
+ },
+ )
+ if (permissionDenied) {
+ TrainlogInfo(strings.getString(R.string.hr_diag_permission_denied), colors.error)
+ }
+ snapshot.diagnostic?.let {
+ TrainlogInfo(strings.getString(R.string.hr_diag_technical, it), colors.muted)
+ }
+ TrainlogInfo(
+ strings.getString(
+ R.string.hr_diag_services,
+ if (snapshot.serviceFound) strings.getString(R.string.yes) else strings.getString(R.string.no),
+ if (snapshot.measurementFound) strings.getString(R.string.yes) else strings.getString(R.string.no),
+ if (snapshot.batteryServiceFound) strings.getString(R.string.yes) else strings.getString(R.string.no),
+ ),
+ )
+ snapshot.latestMeasurement?.let { measurement ->
+ TrainlogInfo(
+ strings.getString(R.string.hr_diag_bpm, measurement.bpm),
+ colors.success,
+ )
+ TrainlogInfo(
+ strings.getString(
+ R.string.hr_diag_measurement_meta,
+ measurement.rrIntervals1024.size,
+ measurement.sensorContactDetected?.let { if (it) strings.getString(R.string.yes) else strings.getString(R.string.no) } ?: "—",
+ measurement.energyExpended?.toString() ?: "—",
+ ),
+ colors.muted,
+ )
+ }
+ TrainlogInfo(
+ strings.getString(R.string.hr_diag_notifications, snapshot.notificationCount),
+ colors.muted,
+ )
+ }
+
+ TrainlogFrame(strings.getString(R.string.hr_diag_scan_section)) {
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ TrainlogButton(
+ strings.getString(R.string.hr_diag_scan),
+ { scan(false) },
+ Modifier.weight(1f),
+ enabled = snapshot.phase != HeartRateDiagnosticPhase.SCANNING,
+ )
+ TrainlogButton(
+ strings.getString(R.string.hr_diag_scan_all),
+ { scan(true) },
+ Modifier.weight(1f),
+ enabled = snapshot.phase != HeartRateDiagnosticPhase.SCANNING,
+ )
+ }
+ TrainlogInfo(strings.getString(R.string.hr_diag_scan_description), colors.muted)
+ }
+
+ TrainlogFrame(
+ strings.getString(R.string.hr_diag_devices),
+ active = snapshot.devices.isNotEmpty(),
+ ) {
+ if (snapshot.devices.isEmpty()) {
+ TrainlogInfo(strings.getString(R.string.hr_diag_no_devices), colors.muted)
+ }
+ snapshot.devices.forEach { device ->
+ val title =
+ device.name?.takeIf { it.isNotBlank() }
+ ?: strings.getString(R.string.hr_diag_unnamed_device)
+ val advertised =
+ strings.getString(
+ if (device.advertisesHeartRate) {
+ R.string.hr_diag_hrs_advertised
+ } else {
+ R.string.hr_diag_hrs_not_advertised
+ },
+ )
+ TrainlogAction(
+ title,
+ strings.getString(
+ R.string.hr_diag_device_description,
+ device.address,
+ device.rssi,
+ advertised,
+ ),
+ { diagnostics.connect(device.address) },
+ )
+ }
+ }
+ }
+}
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 d308599..10e637e 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
@@ -472,7 +472,12 @@ private fun catalogCountsText(context: android.content.Context, imported: Int, r
).joinToString(", ")
@Composable
-fun SettingsScreen(repository: TrainlogRepository, inbox: SyncCatalogInbox, onCatalogChanged: () -> Unit) {
+fun SettingsScreen(
+ repository: TrainlogRepository,
+ inbox: SyncCatalogInbox,
+ onHeartRateDiagnostics: () -> Unit,
+ onCatalogChanged: () -> Unit,
+) {
val colors = LocalTrainlogColors.current
val context = LocalContext.current
val language = LocalLanguagePresentation.current
@@ -533,6 +538,13 @@ fun SettingsScreen(repository: TrainlogRepository, inbox: SyncCatalogInbox, onCa
)
}
}
+ TrainlogFrame(strings.getString(R.string.settings_cardio_section)) {
+ TrainlogAction(
+ strings.getString(R.string.settings_cardio_diagnostics),
+ strings.getString(R.string.settings_cardio_diagnostics_description),
+ onHeartRateDiagnostics,
+ )
+ }
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)
TrainlogAction(strings.getString(if (authorized) R.string.settings_open_access else R.string.settings_authorize), strings.getString(R.string.settings_authorize_description), { launcher.launch(directStoragePermissionIntent(context)) })
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 6c595ca..20526ea 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
@@ -195,7 +195,8 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
AppRoute.SleepDiary -> SleepDiaryScreen(repository)
AppRoute.LatestMaxima -> LatestMaximaScreen(repository)
AppRoute.Sync -> SyncScreen(repository, inbox, exporter, requestOutbox, { exportSnapshot(); catalogRevision++ }, { back() })
- AppRoute.Settings -> SettingsScreen(repository, inbox) { exportSnapshot(); catalogRevision++ }
+ AppRoute.Settings -> SettingsScreen(repository, inbox, { open(AppRoute.HeartRateDiagnostics) }) { exportSnapshot(); catalogRevision++ }
+ AppRoute.HeartRateDiagnostics -> HeartRateDiagnosticsScreen()
}}
}
diff --git a/android/app/src/main/res/values-en/strings.xml b/android/app/src/main/res/values-en/strings.xml
index 48593a1..a86f2e5 100644
--- a/android/app/src/main/res/values-en/strings.xml
+++ b/android/app/src/main/res/values-en/strings.xml
@@ -480,4 +480,35 @@
Unit
Deactivate
Edit
+ Yes
+ No
+ BLE heart-rate diagnostics
+ Cardio
+ Heart-rate sensor diagnostics
+ Find and verify a Bluetooth Heart Rate Service sensor.
+ Sensor status
+ Ready
+ Scanning…
+ Connecting…
+ Discovering services…
+ Enabling measurements…
+ Measurements received
+ Disconnected
+ BLE error
+ Bluetooth permission denied.
+ Diagnostic: %1$s
+ Heart Rate Service: %1$s · measurement: %2$s · battery: %3$s
+ %1$d BPM
+ %1$d RR · contact %2$s · energy %3$s
+ Notifications received: %1$d
+ BLE scan
+ Find heart-rate
+ Expanded scan
+ Heart-rate scan lasts at most 10 seconds. Expanded scan is for sensors that do not advertise their service in BLE advertisements.
+ BLE devices
+ No device found yet.
+ Unnamed device
+ Heart Rate advertised
+ Heart Rate not advertised
+ %1$s · %2$d dBm · %3$s
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index e93ac73..d24b3bd 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -490,4 +490,35 @@
Unité
Désactiver
Modifier
+ Oui
+ Non
+ Diagnostic cardio BLE
+ Cardio
+ Diagnostic du capteur cardio
+ Rechercher et vérifier un capteur Bluetooth Heart Rate Service.
+ État du capteur
+ Prêt
+ Recherche en cours…
+ Connexion…
+ Découverte des services…
+ Activation des mesures…
+ Mesures reçues
+ Déconnecté
+ Erreur BLE
+ Autorisation Bluetooth refusée.
+ Diagnostic : %1$s
+ Heart Rate Service : %1$s · mesure : %2$s · batterie : %3$s
+ %1$d BPM
+ %1$d RR · contact %2$s · énergie %3$s
+ Notifications reçues : %1$d
+ Recherche BLE
+ Rechercher cardio
+ Recherche élargie
+ La recherche cardio dure au maximum 10 secondes. La recherche élargie sert aux capteurs qui n’annoncent pas leur service dans la publicité BLE.
+ Périphériques BLE
+ Aucun périphérique trouvé pour l’instant.
+ Périphérique sans nom
+ Heart Rate annoncé
+ Heart Rate non annoncé
+ %1$s · %2$d dBm · %3$s
diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/HeartRateMeasurementParserTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/HeartRateMeasurementParserTest.kt
new file mode 100644
index 0000000..1f0dab8
--- /dev/null
+++ b/android/app/src/test/java/com/labfytools/trainlog/data/HeartRateMeasurementParserTest.kt
@@ -0,0 +1,95 @@
+package com.labfytools.trainlog.data
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class HeartRateMeasurementParserTest {
+ private fun parsed(vararg bytes: Int): ParsedHeartRateMeasurement {
+ val result =
+ HeartRateMeasurementParser.parse(
+ ByteArray(bytes.size) { index -> bytes[index].toByte() },
+ )
+ return (result as HeartRateMeasurementParseResult.Parsed).measurement
+ }
+
+ private fun invalid(vararg bytes: Int): HeartRateMeasurementParseResult.Invalid =
+ HeartRateMeasurementParser.parse(
+ ByteArray(bytes.size) { index -> bytes[index].toByte() },
+ ) as HeartRateMeasurementParseResult.Invalid
+
+ @Test
+ fun parsesEightBitBpm() {
+ val value = parsed(0x00, 72)
+ assertEquals(72, value.bpm)
+ assertNull(value.sensorContactDetected)
+ assertNull(value.energyExpended)
+ assertTrue(value.rrIntervals1024.isEmpty())
+ }
+
+ @Test
+ fun parsesSixteenBitBpmLittleEndian() {
+ assertEquals(300, parsed(0x01, 0x2c, 0x01).bpm)
+ assertEquals(65535, parsed(0x01, 0xff, 0xff).bpm)
+ }
+
+ @Test
+ fun contactIsNullableAndOnlyMeaningfulWhenSupported() {
+ assertNull(parsed(0x02, 80).sensorContactDetected)
+ assertEquals(false, parsed(0x04, 80).sensorContactDetected)
+ assertEquals(true, parsed(0x06, 80).sensorContactDetected)
+ }
+
+ @Test
+ fun parsesEnergyExpended() {
+ val value = parsed(0x08, 90, 0x34, 0x12)
+ assertEquals(0x1234, value.energyExpended)
+ }
+
+ @Test
+ fun parsesOneAndMultipleRawRrIntervals() {
+ assertEquals(listOf(1024), parsed(0x10, 60, 0x00, 0x04).rrIntervals1024)
+ assertEquals(
+ listOf(1024, 1000, 65535),
+ parsed(0x10, 60, 0x00, 0x04, 0xe8, 0x03, 0xff, 0xff).rrIntervals1024,
+ )
+ }
+
+ @Test
+ fun parsesCombinedKnownFlagsInWireOrder() {
+ val value =
+ parsed(
+ 0x1f,
+ 0x96,
+ 0x00,
+ 0x07,
+ 0x00,
+ 0xf0,
+ 0x03,
+ 0x00,
+ 0x04,
+ )
+ assertEquals(150, value.bpm)
+ assertEquals(true, value.sensorContactDetected)
+ assertEquals(7, value.energyExpended)
+ assertEquals(listOf(1008, 1024), value.rrIntervals1024)
+ }
+
+ @Test
+ fun rejectsTruncatedAndIncoherentPayloads() {
+ assertEquals("missing flags", invalid().reason)
+ assertEquals("truncated 16-bit BPM", invalid(0x01, 0x2c).reason)
+ assertEquals("truncated energy-expended value", invalid(0x08, 70, 0x01).reason)
+ assertEquals("RR flag has no interval", invalid(0x10, 70).reason)
+ assertEquals("truncated RR interval", invalid(0x10, 70, 0x01).reason)
+ assertEquals("unexpected trailing bytes", invalid(0x00, 70, 0x01).reason)
+ assertEquals("reserved flags are set", invalid(0x20, 70).reason)
+ }
+
+ @Test
+ fun preservesBoundaryBpmValues() {
+ assertEquals(0, parsed(0x00, 0).bpm)
+ assertEquals(255, parsed(0x00, 255).bpm)
+ }
+}
diff --git a/docs/current_state.md b/docs/current_state.md
index 3430316..d8ea7c2 100644
--- a/docs/current_state.md
+++ b/docs/current_state.md
@@ -148,7 +148,8 @@ parallel implementation of its rules.
| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` |
| 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 |
-| Current operational cursor | `TRAINLOG_CARDIO_BLE_DIAGNOSTICS_V1`: standard Heart Rate Service parser, Android BLE discovery and real-sensor characterization |
+| Cardio BLE diagnostics | `TRAINLOG_CARDIO_BLE_DIAGNOSTICS_V1=SOFTWARE_PASS / HARDWARE_VALIDATION_PENDING`: strict `0x2A37` parser, bounded HRS/expanded BLE scan, GATT service discovery and notification subscription are installed on Android 0.1.5 vc38; filtered real-device scan observed no advertising HRS device on 2026-09-23 |
+| Current operational cursor | `TRAINLOG_CARDIO_BLE_HARDWARE_VALIDATION_V1`: identify the real armband, confirm `0x180D/0x2A37`, notification cadence and optional RR/contact/energy fields before persistent acquisition |
| 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 26d0c99..dd7c046 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_BLE_DIAGNOSTICS_V1
+CURRENT_OPERATIONAL_CURSOR=TRAINLOG_CARDIO_BLE_HARDWARE_VALIDATION_V1
```
The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this
diff --git a/docs/tests.md b/docs/tests.md
index e6e0d5c..cc2b85c 100644
--- a/docs/tests.md
+++ b/docs/tests.md
@@ -1670,3 +1670,30 @@ The 2026-09-23 closeout passes the complete native Meson inventory:
**258 tests: 253 passed, five skipped, zero failures/errors**. The five skips
remain optional historical/external fixture gates and are not Cardio V1
failures.
+
+## Cardio BLE diagnostics V1 validation
+
+`TRAINLOG_CARDIO_BLE_DIAGNOSTICS_V1=SOFTWARE_PASS / HARDWARE_VALIDATION_PENDING`
+keeps BLE protocol parsing separate from Android GATT transport. Unit coverage
+exercises 8-bit and 16-bit BPM, nullable sensor-contact semantics, cumulative
+energy, one/multiple raw RR intervals, combined flags, boundary BPM values,
+reserved flags, missing fields, truncated RR/energy values and unexpected
+trailing bytes.
+
+The Android diagnostic uses a 10-second Heart Rate Service-filtered scan with
+an explicit expanded BLE fallback, then discovers `0x180D`, locates `0x2A37`,
+checks optional Battery Service and enables the CCC notification descriptor.
+It is screen-scoped and does not implement the later persistent foreground
+acquisition owner.
+
+On 2026-09-23 the diagnostic APK was installed non-destructively on the
+authorized SM_G990B as Trainlog 0.1.5 versionCode 38 using the same Android
+debug signing certificate as the existing private installation. Application
+startup completed without SQLite/crash errors and the diagnostic route opened.
+The first real 10-second HRS-filtered scan completed cleanly but found no
+device advertising Heart Rate Service. Real armband identification, GATT HRS
+confirmation and notification-field characterization therefore remain
+`HARDWARE_VALIDATION_PENDING` and are not represented as PASS.
+
+The complete Android JVM inventory after this tranche reports **266 tests:
+261 passed, five skipped, zero failures/errors**, and `assembleDebug` succeeds.