feat(android): add persistent heart rate capture
This commit is contained in:
parent
5bf90899bd
commit
1ca354af53
20 changed files with 841 additions and 8 deletions
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -15,8 +15,15 @@
|
||||||
HRS-filtered and expanded BLE scans, GATT service discovery and notification
|
HRS-filtered and expanded BLE scans, GATT service discovery and notification
|
||||||
subscription without turning desktop/Web into sensor clients. Android 12+
|
subscription without turning desktop/Web into sensor clients. Android 12+
|
||||||
scan/connect permissions and the Android 11-and-older scan-location boundary
|
scan/connect permissions and the Android 11-and-older scan-location boundary
|
||||||
are explicit. Real armband notification characterization remains a hardware
|
are explicit. Real CYCPLUS H2 characterization then confirmed the standard
|
||||||
validation gate before persistent acquisition.
|
service/measurement path, Battery Service, ~1 Hz notifications and raw RR.
|
||||||
|
- Added persistent Android heart-rate acquisition and the global `♥ BPM`
|
||||||
|
indicator. A validated sensor is remembered, a dedicated connected-device
|
||||||
|
foreground service reconnects across app restarts and Bluetooth loss, stale
|
||||||
|
measurements are cleared instead of displayed indefinitely, and the heart
|
||||||
|
uses grey/orange/green for disconnected/reconnecting/fresh states. The real
|
||||||
|
CYCPLUS H2 was validated through force-stop/restart and Bluetooth off/on
|
||||||
|
recovery on the private 0.1.5 versionCode 40 installation.
|
||||||
|
|
||||||
Development opened after stable v0.1.4 release.
|
Development opened after stable v0.1.4 release.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ Programs. Sessions → Programmes remains the technical administration/import,
|
||||||
list, detail, archive, and delete surface.
|
list, detail, archive, and delete surface.
|
||||||
Web Exercises administration, the factual Dashboard/Analyse read model, and the
|
Web Exercises administration, the factual Dashboard/Analyse read model, and the
|
||||||
synchronized Sleep Diary workspace are the stable v0.1.4 baseline. Version
|
synchronized Sleep Diary workspace are the stable v0.1.4 baseline. Version
|
||||||
0.1.5 development has the Android/desktop heart-rate persistence and synchronization foundation in place; Android BLE sensor acquisition is the current implementation cursor.
|
0.1.5 development now has Android/desktop heart-rate persistence and sync plus validated persistent CYCPLUS acquisition and the global `♥ BPM` indicator; training exercise timestamps are the current implementation cursor.
|
||||||
|
|
||||||
## Releases
|
## Releases
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,5 +50,9 @@
|
||||||
android:name=".data.SyncBackgroundService"
|
android:name=".data.SyncBackgroundService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="connectedDevice|dataSync" />
|
android:foregroundServiceType="connectedDevice|dataSync" />
|
||||||
|
<service
|
||||||
|
android:name=".data.HeartRateSensorService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="connectedDevice" />
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,10 @@ import com.labfytools.trainlog.data.SyncCatalogInbox
|
||||||
import com.labfytools.trainlog.data.SyncRequestOutbox
|
import com.labfytools.trainlog.data.SyncRequestOutbox
|
||||||
import com.labfytools.trainlog.data.BackgroundSyncSettings
|
import com.labfytools.trainlog.data.BackgroundSyncSettings
|
||||||
import com.labfytools.trainlog.data.SyncBackgroundService
|
import com.labfytools.trainlog.data.SyncBackgroundService
|
||||||
|
import com.labfytools.trainlog.data.HeartRateSensorPreferences
|
||||||
|
import com.labfytools.trainlog.data.HeartRateSensorService
|
||||||
import com.labfytools.trainlog.data.hasBluetoothConnectPermission
|
import com.labfytools.trainlog.data.hasBluetoothConnectPermission
|
||||||
|
import com.labfytools.trainlog.data.hasHeartRateBleRuntimePermissions
|
||||||
import com.labfytools.trainlog.ui.TrainlogApp
|
import com.labfytools.trainlog.ui.TrainlogApp
|
||||||
import com.labfytools.trainlog.ui.TrainlogAppState
|
import com.labfytools.trainlog.ui.TrainlogAppState
|
||||||
import com.labfytools.trainlog.ui.LanguagePresentation
|
import com.labfytools.trainlog.ui.LanguagePresentation
|
||||||
|
|
@ -39,6 +42,12 @@ class MainActivity : ComponentActivity() {
|
||||||
) {
|
) {
|
||||||
SyncBackgroundService.start(applicationContext)
|
SyncBackgroundService.start(applicationContext)
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
HeartRateSensorPreferences(applicationContext).selected() != null &&
|
||||||
|
hasHeartRateBleRuntimePermissions(applicationContext)
|
||||||
|
) {
|
||||||
|
HeartRateSensorService.start(applicationContext)
|
||||||
|
}
|
||||||
|
|
||||||
val repository =
|
val repository =
|
||||||
TrainlogRepository(
|
TrainlogRepository(
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ internal val HEART_RATE_MEASUREMENT_UUID: UUID =
|
||||||
UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb")
|
UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb")
|
||||||
internal val BATTERY_SERVICE_UUID: UUID =
|
internal val BATTERY_SERVICE_UUID: UUID =
|
||||||
UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")
|
UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")
|
||||||
private val CLIENT_CHARACTERISTIC_CONFIG_UUID: UUID =
|
internal val CLIENT_CHARACTERISTIC_CONFIG_UUID: UUID =
|
||||||
UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
||||||
|
|
||||||
internal enum class HeartRateDiagnosticPhase {
|
internal enum class HeartRateDiagnosticPhase {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
|
import java.util.concurrent.CopyOnWriteArraySet
|
||||||
|
|
||||||
|
internal enum class HeartRateLivePhase {
|
||||||
|
DISCONNECTED,
|
||||||
|
CONNECTING,
|
||||||
|
CONNECTED,
|
||||||
|
STALE,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class HeartRateLiveSnapshot(
|
||||||
|
val phase: HeartRateLivePhase = HeartRateLivePhase.DISCONNECTED,
|
||||||
|
val bpm: Int? = null,
|
||||||
|
val rrIntervals1024: List<Int> = emptyList(),
|
||||||
|
val sensorContactDetected: Boolean? = null,
|
||||||
|
val energyExpended: Int? = null,
|
||||||
|
val notificationCount: Long = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal object HeartRateLiveState {
|
||||||
|
private val listeners =
|
||||||
|
CopyOnWriteArraySet<(HeartRateLiveSnapshot) -> Unit>()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var snapshot = HeartRateLiveSnapshot()
|
||||||
|
|
||||||
|
fun current(): HeartRateLiveSnapshot = snapshot
|
||||||
|
|
||||||
|
fun publish(next: HeartRateLiveSnapshot) {
|
||||||
|
snapshot = next
|
||||||
|
listeners.forEach { it(next) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun subscribe(listener: (HeartRateLiveSnapshot) -> Unit): AutoCloseable {
|
||||||
|
listeners += listener
|
||||||
|
listener(snapshot)
|
||||||
|
return AutoCloseable { listeners -= listener }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnected() {
|
||||||
|
publish(HeartRateLiveSnapshot(phase = HeartRateLivePhase.DISCONNECTED))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun connecting() {
|
||||||
|
publish(
|
||||||
|
snapshot.copy(
|
||||||
|
phase = HeartRateLivePhase.CONNECTING,
|
||||||
|
bpm = null,
|
||||||
|
rrIntervals1024 = emptyList(),
|
||||||
|
sensorContactDetected = null,
|
||||||
|
energyExpended = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun measurement(measurement: ParsedHeartRateMeasurement) {
|
||||||
|
publish(
|
||||||
|
HeartRateLiveSnapshot(
|
||||||
|
phase = HeartRateLivePhase.CONNECTED,
|
||||||
|
bpm = measurement.bpm,
|
||||||
|
rrIntervals1024 = measurement.rrIntervals1024,
|
||||||
|
sensorContactDetected = measurement.sensorContactDetected,
|
||||||
|
energyExpended = measurement.energyExpended,
|
||||||
|
notificationCount = snapshot.notificationCount + 1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stale() {
|
||||||
|
if (snapshot.phase == HeartRateLivePhase.CONNECTED) {
|
||||||
|
publish(
|
||||||
|
snapshot.copy(
|
||||||
|
phase = HeartRateLivePhase.STALE,
|
||||||
|
bpm = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
|
internal data class SelectedHeartRateSensor(
|
||||||
|
val address: String,
|
||||||
|
val name: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal class HeartRateSensorPreferences(context: Context) {
|
||||||
|
private val preferences =
|
||||||
|
context.applicationContext.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
fun selected(): SelectedHeartRateSensor? {
|
||||||
|
if (!preferences.getBoolean(ENABLED, false)) return null
|
||||||
|
val address = preferences.getString(ADDRESS, null)?.takeIf { it.isNotBlank() } ?: return null
|
||||||
|
return SelectedHeartRateSensor(
|
||||||
|
address = address,
|
||||||
|
name = preferences.getString(NAME, null)?.takeIf { it.isNotBlank() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun select(address: String, name: String?) {
|
||||||
|
require(address.isNotBlank())
|
||||||
|
preferences.edit()
|
||||||
|
.putBoolean(ENABLED, true)
|
||||||
|
.putString(ADDRESS, address)
|
||||||
|
.putString(NAME, name?.takeIf { it.isNotBlank() })
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
preferences.edit()
|
||||||
|
.putBoolean(ENABLED, false)
|
||||||
|
.remove(ADDRESS)
|
||||||
|
.remove(NAME)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val PREFERENCES = "trainlog-heart-rate-sensor"
|
||||||
|
private const val ENABLED = "enabled"
|
||||||
|
private const val ADDRESS = "address"
|
||||||
|
private const val NAME = "name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,415 @@
|
||||||
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.Service
|
||||||
|
import android.bluetooth.BluetoothDevice
|
||||||
|
import android.bluetooth.BluetoothGatt
|
||||||
|
import android.bluetooth.BluetoothGattCallback
|
||||||
|
import android.bluetooth.BluetoothGattCharacteristic
|
||||||
|
import android.bluetooth.BluetoothGattDescriptor
|
||||||
|
import android.bluetooth.BluetoothManager
|
||||||
|
import android.bluetooth.BluetoothProfile
|
||||||
|
import android.bluetooth.BluetoothStatusCodes
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.os.Looper
|
||||||
|
import android.os.SystemClock
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.labfytools.trainlog.R
|
||||||
|
|
||||||
|
class HeartRateSensorService : Service() {
|
||||||
|
private val main = Handler(Looper.getMainLooper())
|
||||||
|
private val preferences by lazy { HeartRateSensorPreferences(applicationContext) }
|
||||||
|
private val bluetoothManager by lazy {
|
||||||
|
applicationContext.getSystemService(BluetoothManager::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var selected: SelectedHeartRateSensor? = null
|
||||||
|
private var gatt: BluetoothGatt? = null
|
||||||
|
private var reconnectAttempt = 0
|
||||||
|
private var lastMeasurementElapsed = 0L
|
||||||
|
private var lastNotificationBpm: Int? = null
|
||||||
|
|
||||||
|
private val reconnectRunnable = Runnable { connectSelected() }
|
||||||
|
private val connectionTimeoutRunnable =
|
||||||
|
Runnable {
|
||||||
|
if (HeartRateLiveState.current().phase == HeartRateLivePhase.CONNECTING) {
|
||||||
|
closeGatt()
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private val staleRunnable =
|
||||||
|
object : Runnable {
|
||||||
|
override fun run() {
|
||||||
|
val last = lastMeasurementElapsed
|
||||||
|
if (last > 0L) {
|
||||||
|
val age = SystemClock.elapsedRealtime() - last
|
||||||
|
if (
|
||||||
|
age >= STALE_AFTER_MS &&
|
||||||
|
HeartRateLiveState.current().phase == HeartRateLivePhase.CONNECTED
|
||||||
|
) {
|
||||||
|
HeartRateLiveState.stale()
|
||||||
|
updateForegroundNotification()
|
||||||
|
}
|
||||||
|
if (age >= STALE_RECONNECT_AFTER_MS && gatt != null) {
|
||||||
|
lastMeasurementElapsed = 0L
|
||||||
|
closeGatt()
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
main.postDelayed(this, STALE_CHECK_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
createChannel()
|
||||||
|
promoteForeground()
|
||||||
|
main.post(staleRunnable)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
if (intent?.action == ACTION_STOP) {
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
selected = preferences.selected()
|
||||||
|
if (selected == null || !hasHeartRateBleRuntimePermissions(this)) {
|
||||||
|
HeartRateLiveState.disconnected()
|
||||||
|
updateForegroundNotification()
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
if (bluetoothManager.adapter?.isEnabled != true) {
|
||||||
|
scheduleReconnect()
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
promoteForeground()
|
||||||
|
if (gatt == null) connectSelected()
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private fun connectSelected() {
|
||||||
|
main.removeCallbacks(reconnectRunnable)
|
||||||
|
main.removeCallbacks(connectionTimeoutRunnable)
|
||||||
|
|
||||||
|
val sensor = preferences.selected()
|
||||||
|
selected = sensor
|
||||||
|
val adapter = bluetoothManager.adapter
|
||||||
|
if (sensor == null || !hasHeartRateBleRuntimePermissions(this)) {
|
||||||
|
HeartRateLiveState.disconnected()
|
||||||
|
updateForegroundNotification()
|
||||||
|
stopSelf()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (adapter?.isEnabled != true) {
|
||||||
|
scheduleReconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
closeGatt()
|
||||||
|
lastMeasurementElapsed = 0L
|
||||||
|
HeartRateLiveState.connecting()
|
||||||
|
updateForegroundNotification()
|
||||||
|
|
||||||
|
val device =
|
||||||
|
runCatching { adapter.getRemoteDevice(sensor.address) }.getOrNull()
|
||||||
|
?: run {
|
||||||
|
HeartRateLiveState.disconnected()
|
||||||
|
scheduleReconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
device.connectGatt(
|
||||||
|
applicationContext,
|
||||||
|
false,
|
||||||
|
gattCallback,
|
||||||
|
BluetoothDevice.TRANSPORT_LE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onSuccess {
|
||||||
|
gatt = it
|
||||||
|
main.postDelayed(connectionTimeoutRunnable, CONNECTION_TIMEOUT_MS)
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val gattCallback =
|
||||||
|
object : BluetoothGattCallback() {
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
override fun onConnectionStateChange(
|
||||||
|
callbackGatt: BluetoothGatt,
|
||||||
|
status: Int,
|
||||||
|
newState: Int,
|
||||||
|
) {
|
||||||
|
main.post {
|
||||||
|
if (callbackGatt !== gatt) return@post
|
||||||
|
if (
|
||||||
|
status == BluetoothGatt.GATT_SUCCESS &&
|
||||||
|
newState == BluetoothProfile.STATE_CONNECTED
|
||||||
|
) {
|
||||||
|
reconnectAttempt = 0
|
||||||
|
main.removeCallbacks(connectionTimeoutRunnable)
|
||||||
|
HeartRateLiveState.connecting()
|
||||||
|
updateForegroundNotification()
|
||||||
|
if (!callbackGatt.discoverServices()) {
|
||||||
|
failConnection()
|
||||||
|
} else {
|
||||||
|
main.postDelayed(connectionTimeoutRunnable, CONNECTION_TIMEOUT_MS)
|
||||||
|
}
|
||||||
|
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
|
||||||
|
failConnection()
|
||||||
|
} else if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||||
|
failConnection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
override fun onServicesDiscovered(callbackGatt: BluetoothGatt, status: Int) {
|
||||||
|
main.post {
|
||||||
|
if (callbackGatt !== gatt) return@post
|
||||||
|
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||||
|
failConnection()
|
||||||
|
return@post
|
||||||
|
}
|
||||||
|
val measurement =
|
||||||
|
callbackGatt
|
||||||
|
.getService(HEART_RATE_SERVICE_UUID)
|
||||||
|
?.getCharacteristic(HEART_RATE_MEASUREMENT_UUID)
|
||||||
|
if (measurement == null) {
|
||||||
|
failConnection()
|
||||||
|
return@post
|
||||||
|
}
|
||||||
|
main.removeCallbacks(connectionTimeoutRunnable)
|
||||||
|
if (!subscribe(callbackGatt, measurement)) {
|
||||||
|
failConnection()
|
||||||
|
} else {
|
||||||
|
main.postDelayed(connectionTimeoutRunnable, CONNECTION_TIMEOUT_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDescriptorWrite(
|
||||||
|
callbackGatt: BluetoothGatt,
|
||||||
|
descriptor: BluetoothGattDescriptor,
|
||||||
|
status: Int,
|
||||||
|
) {
|
||||||
|
if (descriptor.uuid != CLIENT_CHARACTERISTIC_CONFIG_UUID) return
|
||||||
|
main.post {
|
||||||
|
if (callbackGatt !== gatt) return@post
|
||||||
|
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||||
|
failConnection()
|
||||||
|
} else {
|
||||||
|
main.removeCallbacks(connectionTimeoutRunnable)
|
||||||
|
HeartRateLiveState.connecting()
|
||||||
|
updateForegroundNotification()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated("Deprecated by Android API 33 callback with explicit value")
|
||||||
|
override fun onCharacteristicChanged(
|
||||||
|
callbackGatt: BluetoothGatt,
|
||||||
|
characteristic: BluetoothGattCharacteristic,
|
||||||
|
) {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val value = characteristic.value ?: return
|
||||||
|
onHeartRateNotification(callbackGatt, characteristic.uuid, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCharacteristicChanged(
|
||||||
|
callbackGatt: BluetoothGatt,
|
||||||
|
characteristic: BluetoothGattCharacteristic,
|
||||||
|
value: ByteArray,
|
||||||
|
) {
|
||||||
|
onHeartRateNotification(callbackGatt, characteristic.uuid, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
private fun subscribe(
|
||||||
|
callbackGatt: BluetoothGatt,
|
||||||
|
measurement: BluetoothGattCharacteristic,
|
||||||
|
): Boolean {
|
||||||
|
if (!callbackGatt.setCharacteristicNotification(measurement, true)) return false
|
||||||
|
val descriptor = measurement.getDescriptor(CLIENT_CHARACTERISTIC_CONFIG_UUID) ?: return false
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
callbackGatt.writeDescriptor(
|
||||||
|
descriptor,
|
||||||
|
BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE,
|
||||||
|
) == BluetoothStatusCodes.SUCCESS
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
run {
|
||||||
|
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||||
|
callbackGatt.writeDescriptor(descriptor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onHeartRateNotification(
|
||||||
|
callbackGatt: BluetoothGatt,
|
||||||
|
uuid: java.util.UUID,
|
||||||
|
value: ByteArray,
|
||||||
|
) {
|
||||||
|
if (callbackGatt !== gatt || uuid != HEART_RATE_MEASUREMENT_UUID) return
|
||||||
|
val parsed = HeartRateMeasurementParser.parse(value)
|
||||||
|
if (parsed !is HeartRateMeasurementParseResult.Parsed) return
|
||||||
|
lastMeasurementElapsed = SystemClock.elapsedRealtime()
|
||||||
|
HeartRateLiveState.measurement(parsed.measurement)
|
||||||
|
if (lastNotificationBpm != parsed.measurement.bpm) {
|
||||||
|
lastNotificationBpm = parsed.measurement.bpm
|
||||||
|
updateForegroundNotification()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun failConnection() {
|
||||||
|
main.removeCallbacks(connectionTimeoutRunnable)
|
||||||
|
closeGatt()
|
||||||
|
lastMeasurementElapsed = 0L
|
||||||
|
lastNotificationBpm = null
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleReconnect() {
|
||||||
|
if (preferences.selected() == null) {
|
||||||
|
HeartRateLiveState.disconnected()
|
||||||
|
updateForegroundNotification()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
HeartRateLiveState.connecting()
|
||||||
|
updateForegroundNotification()
|
||||||
|
reconnectAttempt = (reconnectAttempt + 1).coerceAtMost(6)
|
||||||
|
val delay =
|
||||||
|
(RECONNECT_BASE_MS * reconnectAttempt.toLong()).coerceAtMost(RECONNECT_MAX_MS)
|
||||||
|
main.removeCallbacks(reconnectRunnable)
|
||||||
|
main.postDelayed(reconnectRunnable, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
private fun closeGatt() {
|
||||||
|
val current = gatt
|
||||||
|
gatt = null
|
||||||
|
runCatching { current?.disconnect() }
|
||||||
|
runCatching { current?.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
main.removeCallbacks(reconnectRunnable)
|
||||||
|
main.removeCallbacks(connectionTimeoutRunnable)
|
||||||
|
main.removeCallbacks(staleRunnable)
|
||||||
|
closeGatt()
|
||||||
|
HeartRateLiveState.disconnected()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
private fun createChannel() {
|
||||||
|
getSystemService(NotificationManager::class.java)
|
||||||
|
.createNotificationChannel(
|
||||||
|
NotificationChannel(
|
||||||
|
CHANNEL,
|
||||||
|
getString(R.string.heart_rate_service_channel),
|
||||||
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun promoteForeground() {
|
||||||
|
val notification = buildNotification()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
startForeground(
|
||||||
|
NOTIFICATION_ID,
|
||||||
|
notification,
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateForegroundNotification() {
|
||||||
|
runCatching {
|
||||||
|
getSystemService(NotificationManager::class.java)
|
||||||
|
.notify(NOTIFICATION_ID, buildNotification())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildNotification(): android.app.Notification {
|
||||||
|
val live = HeartRateLiveState.current()
|
||||||
|
val text =
|
||||||
|
when (live.phase) {
|
||||||
|
HeartRateLivePhase.CONNECTED ->
|
||||||
|
live.bpm?.let { getString(R.string.heart_rate_service_bpm, it) }
|
||||||
|
?: getString(R.string.heart_rate_service_connecting)
|
||||||
|
HeartRateLivePhase.CONNECTING ->
|
||||||
|
getString(R.string.heart_rate_service_connecting)
|
||||||
|
HeartRateLivePhase.STALE ->
|
||||||
|
getString(R.string.heart_rate_service_reconnecting)
|
||||||
|
HeartRateLivePhase.DISCONNECTED ->
|
||||||
|
getString(R.string.heart_rate_service_disconnected)
|
||||||
|
}
|
||||||
|
return NotificationCompat.Builder(this, CHANNEL)
|
||||||
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
|
.setContentTitle(getString(R.string.heart_rate_service_title))
|
||||||
|
.setContentText(text)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val CHANNEL = "trainlog_heart_rate"
|
||||||
|
private const val NOTIFICATION_ID = 1402
|
||||||
|
private const val ACTION_START = "com.labfytools.trainlog.heart_rate.START"
|
||||||
|
private const val ACTION_STOP = "com.labfytools.trainlog.heart_rate.STOP"
|
||||||
|
private const val CONNECTION_TIMEOUT_MS = 12_000L
|
||||||
|
private const val STALE_AFTER_MS = 3_000L
|
||||||
|
private const val STALE_RECONNECT_AFTER_MS = 8_000L
|
||||||
|
private const val STALE_CHECK_MS = 1_000L
|
||||||
|
private const val RECONNECT_BASE_MS = 2_000L
|
||||||
|
private const val RECONNECT_MAX_MS = 15_000L
|
||||||
|
|
||||||
|
fun start(context: Context) {
|
||||||
|
if (
|
||||||
|
HeartRateSensorPreferences(context).selected() == null ||
|
||||||
|
!hasHeartRateBleRuntimePermissions(context)
|
||||||
|
) {
|
||||||
|
HeartRateLiveState.disconnected()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ContextCompat.startForegroundService(
|
||||||
|
context,
|
||||||
|
Intent(context, HeartRateSensorService::class.java).setAction(ACTION_START),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(context: Context) {
|
||||||
|
context.startService(
|
||||||
|
Intent(context, HeartRateSensorService::class.java).setAction(ACTION_STOP),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun forget(context: Context) {
|
||||||
|
HeartRateSensorPreferences(context).clear()
|
||||||
|
stop(context)
|
||||||
|
HeartRateLiveState.disconnected()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -146,6 +146,7 @@ fun AndroidAppShell(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
HeartRateIndicator()
|
||||||
if (!root) {
|
if (!root) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { scope.launch { drawerState.open() } },
|
onClick = { scope.launch { drawerState.open() } },
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,14 @@ import com.labfytools.trainlog.R
|
||||||
import com.labfytools.trainlog.data.HeartRateBleDiagnostics
|
import com.labfytools.trainlog.data.HeartRateBleDiagnostics
|
||||||
import com.labfytools.trainlog.data.HeartRateDiagnosticPhase
|
import com.labfytools.trainlog.data.HeartRateDiagnosticPhase
|
||||||
import com.labfytools.trainlog.data.HeartRateDiagnosticSnapshot
|
import com.labfytools.trainlog.data.HeartRateDiagnosticSnapshot
|
||||||
|
import com.labfytools.trainlog.data.HeartRateSensorPreferences
|
||||||
|
import com.labfytools.trainlog.data.HeartRateSensorService
|
||||||
import com.labfytools.trainlog.data.hasHeartRateBleRuntimePermissions
|
import com.labfytools.trainlog.data.hasHeartRateBleRuntimePermissions
|
||||||
import com.labfytools.trainlog.data.heartRateBleRuntimePermissions
|
import com.labfytools.trainlog.data.heartRateBleRuntimePermissions
|
||||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun HeartRateDiagnosticsScreen() {
|
fun HeartRateDiagnosticsScreen(onSelected: () -> Unit) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val strings = localizedContext()
|
val strings = localizedContext()
|
||||||
val colors = LocalTrainlogColors.current
|
val colors = LocalTrainlogColors.current
|
||||||
|
|
@ -122,6 +124,27 @@ fun HeartRateDiagnosticsScreen() {
|
||||||
strings.getString(R.string.hr_diag_notifications, snapshot.notificationCount),
|
strings.getString(R.string.hr_diag_notifications, snapshot.notificationCount),
|
||||||
colors.muted,
|
colors.muted,
|
||||||
)
|
)
|
||||||
|
if (
|
||||||
|
snapshot.phase == HeartRateDiagnosticPhase.RECEIVING &&
|
||||||
|
snapshot.selectedAddress != null
|
||||||
|
) {
|
||||||
|
TrainlogButton(
|
||||||
|
strings.getString(R.string.hr_diag_use_sensor),
|
||||||
|
{
|
||||||
|
val address = checkNotNull(snapshot.selectedAddress)
|
||||||
|
val name =
|
||||||
|
snapshot.devices
|
||||||
|
.firstOrNull { it.address == address }
|
||||||
|
?.name
|
||||||
|
diagnostics.close()
|
||||||
|
HeartRateSensorPreferences(context).select(address, name)
|
||||||
|
HeartRateSensorService.start(context)
|
||||||
|
onSelected()
|
||||||
|
},
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
style = TrainlogButtonStyle.SUCCESS,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TrainlogFrame(strings.getString(R.string.hr_diag_scan_section)) {
|
TrainlogFrame(strings.getString(R.string.hr_diag_scan_section)) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
package com.labfytools.trainlog.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.scale
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.labfytools.trainlog.R
|
||||||
|
import com.labfytools.trainlog.data.HeartRateLivePhase
|
||||||
|
import com.labfytools.trainlog.data.HeartRateLiveState
|
||||||
|
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun HeartRateIndicator() {
|
||||||
|
var live by remember { mutableStateOf(HeartRateLiveState.current()) }
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
val subscription = HeartRateLiveState.subscribe { live = it }
|
||||||
|
onDispose { subscription.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
val colors = LocalTrainlogColors.current
|
||||||
|
val strings = localizedContext()
|
||||||
|
val bpm =
|
||||||
|
live.bpm?.takeIf {
|
||||||
|
live.phase == HeartRateLivePhase.CONNECTED
|
||||||
|
}
|
||||||
|
val color =
|
||||||
|
when (live.phase) {
|
||||||
|
HeartRateLivePhase.CONNECTED -> colors.success
|
||||||
|
HeartRateLivePhase.CONNECTING,
|
||||||
|
HeartRateLivePhase.STALE -> colors.warning
|
||||||
|
HeartRateLivePhase.DISCONNECTED -> colors.muted
|
||||||
|
}
|
||||||
|
|
||||||
|
val beatPeriodMs =
|
||||||
|
bpm?.takeIf { it > 0 }
|
||||||
|
?.let { (60_000 / it).coerceIn(300, 2_000) }
|
||||||
|
?: 1_000
|
||||||
|
val transition = rememberInfiniteTransition(label = "heart-rate-pulse")
|
||||||
|
val scale by
|
||||||
|
transition.animateFloat(
|
||||||
|
initialValue = 1f,
|
||||||
|
targetValue = if (bpm != null) 1.14f else 1f,
|
||||||
|
animationSpec =
|
||||||
|
infiniteRepeatable(
|
||||||
|
animation = tween(durationMillis = beatPeriodMs / 2),
|
||||||
|
repeatMode = RepeatMode.Reverse,
|
||||||
|
),
|
||||||
|
label = "heart-rate-scale",
|
||||||
|
)
|
||||||
|
|
||||||
|
val description =
|
||||||
|
when (live.phase) {
|
||||||
|
HeartRateLivePhase.CONNECTED ->
|
||||||
|
strings.getString(R.string.heart_rate_a11y_connected, bpm ?: 0)
|
||||||
|
HeartRateLivePhase.CONNECTING ->
|
||||||
|
strings.getString(R.string.heart_rate_a11y_connecting)
|
||||||
|
HeartRateLivePhase.STALE ->
|
||||||
|
strings.getString(R.string.heart_rate_a11y_reconnecting)
|
||||||
|
HeartRateLivePhase.DISCONNECTED ->
|
||||||
|
strings.getString(R.string.heart_rate_a11y_disconnected)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(horizontal = 4.dp)
|
||||||
|
.semantics { contentDescription = description },
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(5.dp),
|
||||||
|
) {
|
||||||
|
androidx.compose.material3.Text(
|
||||||
|
"♥",
|
||||||
|
color = color,
|
||||||
|
fontSize = 20.sp,
|
||||||
|
modifier = Modifier.scale(scale),
|
||||||
|
)
|
||||||
|
androidx.compose.material3.Text(
|
||||||
|
bpm?.let { "$it BPM" } ?: "-- BPM",
|
||||||
|
color = color,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
|
@ -39,6 +40,8 @@ import com.labfytools.trainlog.data.StartAiSessionDraftResult
|
||||||
import com.labfytools.trainlog.data.TrainlogRepository
|
import com.labfytools.trainlog.data.TrainlogRepository
|
||||||
import com.labfytools.trainlog.data.AndroidBackupService
|
import com.labfytools.trainlog.data.AndroidBackupService
|
||||||
import com.labfytools.trainlog.data.AndroidBackupResult
|
import com.labfytools.trainlog.data.AndroidBackupResult
|
||||||
|
import com.labfytools.trainlog.data.HeartRateSensorPreferences
|
||||||
|
import com.labfytools.trainlog.data.HeartRateSensorService
|
||||||
import com.labfytools.trainlog.data.directStoragePermissionIntent
|
import com.labfytools.trainlog.data.directStoragePermissionIntent
|
||||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||||
import com.labfytools.trainlog.model.SessionExerciseDraft
|
import com.labfytools.trainlog.model.SessionExerciseDraft
|
||||||
|
|
@ -484,6 +487,10 @@ fun SettingsScreen(
|
||||||
val strings = localizedContext()
|
val strings = localizedContext()
|
||||||
var authorized by remember { mutableStateOf(inbox.hasFolderAccess()) }
|
var authorized by remember { mutableStateOf(inbox.hasFolderAccess()) }
|
||||||
var message by remember { mutableStateOf<String?>(null) }
|
var message by remember { mutableStateOf<String?>(null) }
|
||||||
|
val heartRatePreferences = remember(context) { HeartRateSensorPreferences(context) }
|
||||||
|
var heartRateRevision by remember { mutableIntStateOf(0) }
|
||||||
|
val selectedHeartRateSensor =
|
||||||
|
remember(heartRateRevision) { heartRatePreferences.selected() }
|
||||||
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||||
authorized = inbox.hasFolderAccess()
|
authorized = inbox.hasFolderAccess()
|
||||||
message = strings.getString(if (authorized) R.string.settings_access_granted else R.string.settings_access_required)
|
message = strings.getString(if (authorized) R.string.settings_access_granted else R.string.settings_access_required)
|
||||||
|
|
@ -539,11 +546,27 @@ fun SettingsScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TrainlogFrame(strings.getString(R.string.settings_cardio_section)) {
|
TrainlogFrame(strings.getString(R.string.settings_cardio_section)) {
|
||||||
|
if (selectedHeartRateSensor != null) {
|
||||||
|
TrainlogInfo(
|
||||||
|
strings.getString(R.string.settings_cardio_configured),
|
||||||
|
colors.success,
|
||||||
|
)
|
||||||
|
}
|
||||||
TrainlogAction(
|
TrainlogAction(
|
||||||
strings.getString(R.string.settings_cardio_diagnostics),
|
strings.getString(R.string.settings_cardio_diagnostics),
|
||||||
strings.getString(R.string.settings_cardio_diagnostics_description),
|
strings.getString(R.string.settings_cardio_diagnostics_description),
|
||||||
onHeartRateDiagnostics,
|
onHeartRateDiagnostics,
|
||||||
)
|
)
|
||||||
|
if (selectedHeartRateSensor != null) {
|
||||||
|
TrainlogAction(
|
||||||
|
strings.getString(R.string.settings_cardio_forget),
|
||||||
|
strings.getString(R.string.settings_cardio_forget_description),
|
||||||
|
{
|
||||||
|
HeartRateSensorService.forget(context)
|
||||||
|
heartRateRevision++
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
TrainlogFrame(strings.getString(R.string.settings_exchange_folder)) {
|
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)
|
TrainlogInfo(strings.getString(if (authorized) R.string.settings_folder_granted else R.string.settings_folder_required), if (authorized) colors.success else colors.warning)
|
||||||
|
|
|
||||||
|
|
@ -196,7 +196,7 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
||||||
AppRoute.LatestMaxima -> LatestMaximaScreen(repository)
|
AppRoute.LatestMaxima -> LatestMaximaScreen(repository)
|
||||||
AppRoute.Sync -> SyncScreen(repository, inbox, exporter, requestOutbox, { exportSnapshot(); catalogRevision++ }, { back() })
|
AppRoute.Sync -> SyncScreen(repository, inbox, exporter, requestOutbox, { exportSnapshot(); catalogRevision++ }, { back() })
|
||||||
AppRoute.Settings -> SettingsScreen(repository, inbox, { open(AppRoute.HeartRateDiagnostics) }) { exportSnapshot(); catalogRevision++ }
|
AppRoute.Settings -> SettingsScreen(repository, inbox, { open(AppRoute.HeartRateDiagnostics) }) { exportSnapshot(); catalogRevision++ }
|
||||||
AppRoute.HeartRateDiagnostics -> HeartRateDiagnosticsScreen()
|
AppRoute.HeartRateDiagnostics -> HeartRateDiagnosticsScreen { back() }
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -511,4 +511,18 @@
|
||||||
<string name="hr_diag_hrs_advertised">Heart Rate advertised</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_hrs_not_advertised">Heart Rate not advertised</string>
|
||||||
<string name="hr_diag_device_description">%1$s · %2$d dBm · %3$s</string>
|
<string name="hr_diag_device_description">%1$s · %2$d dBm · %3$s</string>
|
||||||
|
<string name="hr_diag_use_sensor">Use this sensor</string>
|
||||||
|
<string name="heart_rate_service_channel">Heart-rate sensor</string>
|
||||||
|
<string name="heart_rate_service_title">Trainlog · cardio</string>
|
||||||
|
<string name="heart_rate_service_connecting">Connecting to sensor…</string>
|
||||||
|
<string name="heart_rate_service_reconnecting">Waiting for heart-rate signal…</string>
|
||||||
|
<string name="heart_rate_service_disconnected">Heart-rate sensor disconnected</string>
|
||||||
|
<string name="heart_rate_service_bpm">%1$d BPM</string>
|
||||||
|
<string name="heart_rate_a11y_connected">Heart rate %1$d BPM, sensor connected</string>
|
||||||
|
<string name="heart_rate_a11y_connecting">Heart-rate sensor connecting</string>
|
||||||
|
<string name="heart_rate_a11y_reconnecting">Heart-rate signal temporarily lost, reconnecting</string>
|
||||||
|
<string name="heart_rate_a11y_disconnected">Heart-rate sensor disconnected</string>
|
||||||
|
<string name="settings_cardio_configured">Heart-rate sensor configured.</string>
|
||||||
|
<string name="settings_cardio_forget">Forget sensor</string>
|
||||||
|
<string name="settings_cardio_forget_description">Stop heart-rate connection and remove the remembered sensor.</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -521,4 +521,18 @@
|
||||||
<string name="hr_diag_hrs_advertised">Heart Rate annoncé</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_hrs_not_advertised">Heart Rate non annoncé</string>
|
||||||
<string name="hr_diag_device_description">%1$s · %2$d dBm · %3$s</string>
|
<string name="hr_diag_device_description">%1$s · %2$d dBm · %3$s</string>
|
||||||
|
<string name="hr_diag_use_sensor">Utiliser ce capteur</string>
|
||||||
|
<string name="heart_rate_service_channel">Capteur cardio</string>
|
||||||
|
<string name="heart_rate_service_title">Trainlog · cardio</string>
|
||||||
|
<string name="heart_rate_service_connecting">Connexion au capteur…</string>
|
||||||
|
<string name="heart_rate_service_reconnecting">Signal cardio en attente…</string>
|
||||||
|
<string name="heart_rate_service_disconnected">Capteur cardio déconnecté</string>
|
||||||
|
<string name="heart_rate_service_bpm">%1$d BPM</string>
|
||||||
|
<string name="heart_rate_a11y_connected">Fréquence cardiaque %1$d BPM, capteur connecté</string>
|
||||||
|
<string name="heart_rate_a11y_connecting">Capteur cardio en connexion</string>
|
||||||
|
<string name="heart_rate_a11y_reconnecting">Signal cardio temporairement perdu, reconnexion en cours</string>
|
||||||
|
<string name="heart_rate_a11y_disconnected">Capteur cardio déconnecté</string>
|
||||||
|
<string name="settings_cardio_configured">Capteur cardio configuré.</string>
|
||||||
|
<string name="settings_cardio_forget">Oublier le capteur</string>
|
||||||
|
<string name="settings_cardio_forget_description">Arrêter la connexion cardio et supprimer le capteur mémorisé.</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class HeartRateLiveStateTest {
|
||||||
|
@Test
|
||||||
|
fun liveStateDropsStaleBpmWithoutInventingMeasurement() {
|
||||||
|
HeartRateLiveState.disconnected()
|
||||||
|
val observed = mutableListOf<HeartRateLiveSnapshot>()
|
||||||
|
val subscription = HeartRateLiveState.subscribe { observed += it }
|
||||||
|
try {
|
||||||
|
HeartRateLiveState.connecting()
|
||||||
|
HeartRateLiveState.measurement(
|
||||||
|
ParsedHeartRateMeasurement(
|
||||||
|
bpm = 82,
|
||||||
|
sensorContactDetected = null,
|
||||||
|
energyExpended = null,
|
||||||
|
rrIntervals1024 = listOf(768),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertEquals(HeartRateLivePhase.CONNECTED, HeartRateLiveState.current().phase)
|
||||||
|
assertEquals(82, HeartRateLiveState.current().bpm)
|
||||||
|
assertEquals(listOf(768), HeartRateLiveState.current().rrIntervals1024)
|
||||||
|
assertEquals(1L, HeartRateLiveState.current().notificationCount)
|
||||||
|
|
||||||
|
HeartRateLiveState.stale()
|
||||||
|
assertEquals(HeartRateLivePhase.STALE, HeartRateLiveState.current().phase)
|
||||||
|
assertNull(HeartRateLiveState.current().bpm)
|
||||||
|
assertEquals(listOf(768), HeartRateLiveState.current().rrIntervals1024)
|
||||||
|
|
||||||
|
HeartRateLiveState.disconnected()
|
||||||
|
assertEquals(HeartRateLivePhase.DISCONNECTED, HeartRateLiveState.current().phase)
|
||||||
|
assertNull(HeartRateLiveState.current().bpm)
|
||||||
|
assertEquals(5, observed.size)
|
||||||
|
} finally {
|
||||||
|
subscription.close()
|
||||||
|
HeartRateLiveState.disconnected()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [35])
|
||||||
|
class HeartRateSensorPreferencesTest {
|
||||||
|
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun selectionIsDurableAndClearable() {
|
||||||
|
val preferences = HeartRateSensorPreferences(context)
|
||||||
|
preferences.clear()
|
||||||
|
assertNull(preferences.selected())
|
||||||
|
|
||||||
|
preferences.select("AA:BB:CC:DD:EE:FF", "Synthetic HR")
|
||||||
|
assertEquals(
|
||||||
|
SelectedHeartRateSensor("AA:BB:CC:DD:EE:FF", "Synthetic HR"),
|
||||||
|
HeartRateSensorPreferences(context).selected(),
|
||||||
|
)
|
||||||
|
|
||||||
|
preferences.clear()
|
||||||
|
assertNull(HeartRateSensorPreferences(context).selected())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -149,7 +149,8 @@ parallel implementation of its rules.
|
||||||
| Web Exercises V1 | `TRAINLOG_WEB_EXERCISES_V1=PASS` |
|
| Web Exercises V1 | `TRAINLOG_WEB_EXERCISES_V1=PASS` |
|
||||||
| Cardio persistence/sync | `TRAINLOG_CARDIO_PERSISTENCE_SYNC_V1=PASS`: Android v27 + desktop v30, strict optional `trainlog-heart-rate` V1 Android→desktop companion, generation relation and correlated ACK |
|
| Cardio persistence/sync | `TRAINLOG_CARDIO_PERSISTENCE_SYNC_V1=PASS`: Android v27 + desktop v30, strict optional `trainlog-heart-rate` V1 Android→desktop companion, generation relation and correlated ACK |
|
||||||
| Cardio BLE diagnostics | `TRAINLOG_CARDIO_BLE_DIAGNOSTICS_V1=PASS`: real CYCPLUS H2 validated on Android; `0x180D` + `0x2A37`, Battery Service, approximately 1 Hz notifications and one raw RR interval per observed notification; contact and energy fields absent in the observed payloads |
|
| Cardio BLE diagnostics | `TRAINLOG_CARDIO_BLE_DIAGNOSTICS_V1=PASS`: real CYCPLUS H2 validated on Android; `0x180D` + `0x2A37`, Battery Service, approximately 1 Hz notifications and one raw RR interval per observed notification; contact and energy fields absent in the observed payloads |
|
||||||
| Current operational cursor | `TRAINLOG_CARDIO_PERSISTENT_ACQUISITION_V1`: remember the validated sensor, persistent reconnecting Android acquisition service, stale-signal handling and global grey/orange/green `♥ BPM` indicator |
|
| Cardio persistent acquisition | `TRAINLOG_CARDIO_PERSISTENT_ACQUISITION_V1=PASS`: selected sensor persists, dedicated connected-device foreground service reconnects, stale BPM is cleared, and the global Android `♥ BPM` indicator is grey/orange/green by connection state; real CYCPLUS restart and Bluetooth-off/on recovery validated on private vc40 |
|
||||||
|
| Current operational cursor | `TRAINLOG_CARDIO_TRAINING_TIMELINE_V1`: persist real session/exercise start/end boundaries and correlate live heart-rate samples with the active stable `entry_id` |
|
||||||
| Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered |
|
| 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` |
|
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
|
||||||
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |
|
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ sensor. Heart-rate ingestion must not reuse or overload the frozen Trainlog
|
||||||
Bluetooth synchronization transport contract.
|
Bluetooth synchronization transport contract.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_CARDIO_PERSISTENT_ACQUISITION_V1
|
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_CARDIO_TRAINING_TIMELINE_V1
|
||||||
```
|
```
|
||||||
|
|
||||||
The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this
|
The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this
|
||||||
|
|
|
||||||
|
|
@ -1703,3 +1703,27 @@ in project documentation.
|
||||||
|
|
||||||
The complete Android JVM inventory after this tranche reports **266 tests:
|
The complete Android JVM inventory after this tranche reports **266 tests:
|
||||||
261 passed, five skipped, zero failures/errors**, and `assembleDebug` succeeds.
|
261 passed, five skipped, zero failures/errors**, and `assembleDebug` succeeds.
|
||||||
|
|
||||||
|
## Cardio persistent acquisition V1 validation
|
||||||
|
|
||||||
|
`TRAINLOG_CARDIO_PERSISTENT_ACQUISITION_V1=PASS` adds a remembered Android
|
||||||
|
heart-rate sensor, a dedicated connected-device foreground service, process-wide
|
||||||
|
live state and the global app-bar `♥ BPM` indicator. A fresh measurement is
|
||||||
|
green; connection/reconnection or stale signal is orange; no configured/active
|
||||||
|
sensor is grey. A BPM older than three seconds is removed from presentation,
|
||||||
|
and a stale GATT stream is forcibly recycled after eight seconds.
|
||||||
|
|
||||||
|
Unit coverage proves durable sensor selection/clear and the live-state
|
||||||
|
transition from connecting to measured BPM/RR, stale `-- BPM`, and
|
||||||
|
disconnected. The complete Android JVM inventory after this tranche reports
|
||||||
|
**268 tests: 263 passed, five skipped, zero failures/errors**; `assembleDebug`
|
||||||
|
also passes.
|
||||||
|
|
||||||
|
Real-device validation used the authorized CYCPLUS H2 on the private Trainlog
|
||||||
|
0.1.5 versionCode 40 installation. With no selected sensor the global indicator
|
||||||
|
rendered `♥ -- BPM` and the disconnected accessibility state. Selecting the
|
||||||
|
validated sensor produced live `♥ 75 BPM`; a complete app force-stop/restart
|
||||||
|
reloaded the remembered sensor and returned automatically at `♥ 72 BPM`.
|
||||||
|
Disabling phone Bluetooth produced `♥ -- BPM` with the reconnecting state;
|
||||||
|
reenabling Bluetooth restored the same foreground service automatically and
|
||||||
|
returned to a fresh `♥ 79 BPM`. No uninstall or data clear occurred.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue