feat(android): add heart rate BLE diagnostics

This commit is contained in:
fy59 2026-09-23 10:07:24 +02:00
parent 14c3ac294e
commit 984b1e058e
15 changed files with 985 additions and 4 deletions

View file

@ -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.

View file

@ -12,6 +12,14 @@
<uses-permission
android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<!-- BLE cardio discovery does not derive physical location. Android 11 and
lower still require fine location for direct BLE scans. -->
<uses-permission
android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />

View file

@ -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<HeartRateDiagnosticDevice> = 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<String> =
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<String, BluetoothDevice>()
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<ScanResult>) {
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<HeartRateDiagnosticDevice> { 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
}
}

View file

@ -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<Int>,
)
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<Int>()
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,
),
)
}
}

View file

@ -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()

View file

@ -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) {

View file

@ -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) },
)
}
}
}
}

View file

@ -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)) })

View file

@ -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()
}}
}

View file

@ -480,4 +480,35 @@
<string name="sleep_unit">Unit</string>
<string name="sleep_deactivate">Deactivate</string>
<string name="sleep_edit">Edit</string>
<string name="yes">Yes</string>
<string name="no">No</string>
<string name="route_heart_rate_diagnostics">BLE heart-rate diagnostics</string>
<string name="settings_cardio_section">Cardio</string>
<string name="settings_cardio_diagnostics">Heart-rate sensor diagnostics</string>
<string name="settings_cardio_diagnostics_description">Find and verify a Bluetooth Heart Rate Service sensor.</string>
<string name="hr_diag_status">Sensor status</string>
<string name="hr_diag_idle">Ready</string>
<string name="hr_diag_scanning">Scanning…</string>
<string name="hr_diag_connecting">Connecting…</string>
<string name="hr_diag_discovering">Discovering services…</string>
<string name="hr_diag_subscribing">Enabling measurements…</string>
<string name="hr_diag_receiving">Measurements received</string>
<string name="hr_diag_disconnected">Disconnected</string>
<string name="hr_diag_error">BLE error</string>
<string name="hr_diag_permission_denied">Bluetooth permission denied.</string>
<string name="hr_diag_technical">Diagnostic: %1$s</string>
<string name="hr_diag_services">Heart Rate Service: %1$s · measurement: %2$s · battery: %3$s</string>
<string name="hr_diag_bpm">%1$d BPM</string>
<string name="hr_diag_measurement_meta">%1$d RR · contact %2$s · energy %3$s</string>
<string name="hr_diag_notifications">Notifications received: %1$d</string>
<string name="hr_diag_scan_section">BLE scan</string>
<string name="hr_diag_scan">Find heart-rate</string>
<string name="hr_diag_scan_all">Expanded scan</string>
<string name="hr_diag_scan_description">Heart-rate scan lasts at most 10 seconds. Expanded scan is for sensors that do not advertise their service in BLE advertisements.</string>
<string name="hr_diag_devices">BLE devices</string>
<string name="hr_diag_no_devices">No device found yet.</string>
<string name="hr_diag_unnamed_device">Unnamed device</string>
<string name="hr_diag_hrs_advertised">Heart Rate advertised</string>
<string name="hr_diag_hrs_not_advertised">Heart Rate not advertised</string>
<string name="hr_diag_device_description">%1$s · %2$d dBm · %3$s</string>
</resources>

View file

@ -490,4 +490,35 @@
<string name="sleep_unit">Unité</string>
<string name="sleep_deactivate">Désactiver</string>
<string name="sleep_edit">Modifier</string>
<string name="yes">Oui</string>
<string name="no">Non</string>
<string name="route_heart_rate_diagnostics">Diagnostic cardio BLE</string>
<string name="settings_cardio_section">Cardio</string>
<string name="settings_cardio_diagnostics">Diagnostic du capteur cardio</string>
<string name="settings_cardio_diagnostics_description">Rechercher et vérifier un capteur Bluetooth Heart Rate Service.</string>
<string name="hr_diag_status">État du capteur</string>
<string name="hr_diag_idle">Prêt</string>
<string name="hr_diag_scanning">Recherche en cours…</string>
<string name="hr_diag_connecting">Connexion…</string>
<string name="hr_diag_discovering">Découverte des services…</string>
<string name="hr_diag_subscribing">Activation des mesures…</string>
<string name="hr_diag_receiving">Mesures reçues</string>
<string name="hr_diag_disconnected">Déconnecté</string>
<string name="hr_diag_error">Erreur BLE</string>
<string name="hr_diag_permission_denied">Autorisation Bluetooth refusée.</string>
<string name="hr_diag_technical">Diagnostic : %1$s</string>
<string name="hr_diag_services">Heart Rate Service : %1$s · mesure : %2$s · batterie : %3$s</string>
<string name="hr_diag_bpm">%1$d BPM</string>
<string name="hr_diag_measurement_meta">%1$d RR · contact %2$s · énergie %3$s</string>
<string name="hr_diag_notifications">Notifications reçues : %1$d</string>
<string name="hr_diag_scan_section">Recherche BLE</string>
<string name="hr_diag_scan">Rechercher cardio</string>
<string name="hr_diag_scan_all">Recherche élargie</string>
<string name="hr_diag_scan_description">La recherche cardio dure au maximum 10 secondes. La recherche élargie sert aux capteurs qui n’annoncent pas leur service dans la publicité BLE.</string>
<string name="hr_diag_devices">Périphériques BLE</string>
<string name="hr_diag_no_devices">Aucun périphérique trouvé pour l’instant.</string>
<string name="hr_diag_unnamed_device">Périphérique sans nom</string>
<string name="hr_diag_hrs_advertised">Heart Rate annoncé</string>
<string name="hr_diag_hrs_not_advertised">Heart Rate non annoncé</string>
<string name="hr_diag_device_description">%1$s · %2$d dBm · %3$s</string>
</resources>

View file

@ -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)
}
}

View file

@ -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` |

View file

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

View file

@ -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.