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.