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
|
||||
subscription without turning desktop/Web into sensor clients. Android 12+
|
||||
scan/connect permissions and the Android 11-and-older scan-location boundary
|
||||
are explicit. Real armband notification characterization remains a hardware
|
||||
validation gate before persistent acquisition.
|
||||
are explicit. Real CYCPLUS H2 characterization then confirmed the standard
|
||||
service/measurement path, Battery Service, ~1 Hz notifications and raw RR.
|
||||
- Added persistent Android heart-rate acquisition and the global `♥ BPM`
|
||||
indicator. A validated sensor is remembered, a dedicated connected-device
|
||||
foreground service reconnects across app restarts and Bluetooth loss, stale
|
||||
measurements are cleared instead of displayed indefinitely, and the heart
|
||||
uses grey/orange/green for disconnected/reconnecting/fresh states. The real
|
||||
CYCPLUS H2 was validated through force-stop/restart and Bluetooth off/on
|
||||
recovery on the private 0.1.5 versionCode 40 installation.
|
||||
|
||||
Development opened after stable v0.1.4 release.
|
||||
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ Programs. Sessions → Programmes remains the technical administration/import,
|
|||
list, detail, archive, and delete surface.
|
||||
Web Exercises administration, the factual Dashboard/Analyse read model, and the
|
||||
synchronized Sleep Diary workspace are the stable v0.1.4 baseline. Version
|
||||
0.1.5 development has the Android/desktop heart-rate persistence and synchronization foundation in place; Android BLE sensor acquisition is the current implementation cursor.
|
||||
0.1.5 development now has Android/desktop heart-rate persistence and sync plus validated persistent CYCPLUS acquisition and the global `♥ BPM` indicator; training exercise timestamps are the current implementation cursor.
|
||||
|
||||
## Releases
|
||||
|
||||
|
|
|
|||
|
|
@ -50,5 +50,9 @@
|
|||
android:name=".data.SyncBackgroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="connectedDevice|dataSync" />
|
||||
<service
|
||||
android:name=".data.HeartRateSensorService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="connectedDevice" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,10 @@ import com.labfytools.trainlog.data.SyncCatalogInbox
|
|||
import com.labfytools.trainlog.data.SyncRequestOutbox
|
||||
import com.labfytools.trainlog.data.BackgroundSyncSettings
|
||||
import com.labfytools.trainlog.data.SyncBackgroundService
|
||||
import com.labfytools.trainlog.data.HeartRateSensorPreferences
|
||||
import com.labfytools.trainlog.data.HeartRateSensorService
|
||||
import com.labfytools.trainlog.data.hasBluetoothConnectPermission
|
||||
import com.labfytools.trainlog.data.hasHeartRateBleRuntimePermissions
|
||||
import com.labfytools.trainlog.ui.TrainlogApp
|
||||
import com.labfytools.trainlog.ui.TrainlogAppState
|
||||
import com.labfytools.trainlog.ui.LanguagePresentation
|
||||
|
|
@ -39,6 +42,12 @@ class MainActivity : ComponentActivity() {
|
|||
) {
|
||||
SyncBackgroundService.start(applicationContext)
|
||||
}
|
||||
if (
|
||||
HeartRateSensorPreferences(applicationContext).selected() != null &&
|
||||
hasHeartRateBleRuntimePermissions(applicationContext)
|
||||
) {
|
||||
HeartRateSensorService.start(applicationContext)
|
||||
}
|
||||
|
||||
val repository =
|
||||
TrainlogRepository(
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ internal val HEART_RATE_MEASUREMENT_UUID: UUID =
|
|||
UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb")
|
||||
internal val BATTERY_SERVICE_UUID: UUID =
|
||||
UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")
|
||||
private val CLIENT_CHARACTERISTIC_CONFIG_UUID: UUID =
|
||||
internal val CLIENT_CHARACTERISTIC_CONFIG_UUID: UUID =
|
||||
UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
internal enum class HeartRateDiagnosticPhase {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
HeartRateIndicator()
|
||||
if (!root) {
|
||||
IconButton(
|
||||
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.HeartRateDiagnosticPhase
|
||||
import com.labfytools.trainlog.data.HeartRateDiagnosticSnapshot
|
||||
import com.labfytools.trainlog.data.HeartRateSensorPreferences
|
||||
import com.labfytools.trainlog.data.HeartRateSensorService
|
||||
import com.labfytools.trainlog.data.hasHeartRateBleRuntimePermissions
|
||||
import com.labfytools.trainlog.data.heartRateBleRuntimePermissions
|
||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||
|
||||
@Composable
|
||||
fun HeartRateDiagnosticsScreen() {
|
||||
fun HeartRateDiagnosticsScreen(onSelected: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val strings = localizedContext()
|
||||
val colors = LocalTrainlogColors.current
|
||||
|
|
@ -122,6 +124,27 @@ fun HeartRateDiagnosticsScreen() {
|
|||
strings.getString(R.string.hr_diag_notifications, snapshot.notificationCount),
|
||||
colors.muted,
|
||||
)
|
||||
if (
|
||||
snapshot.phase == HeartRateDiagnosticPhase.RECEIVING &&
|
||||
snapshot.selectedAddress != null
|
||||
) {
|
||||
TrainlogButton(
|
||||
strings.getString(R.string.hr_diag_use_sensor),
|
||||
{
|
||||
val address = checkNotNull(snapshot.selectedAddress)
|
||||
val name =
|
||||
snapshot.devices
|
||||
.firstOrNull { it.address == address }
|
||||
?.name
|
||||
diagnostics.close()
|
||||
HeartRateSensorPreferences(context).select(address, name)
|
||||
HeartRateSensorService.start(context)
|
||||
onSelected()
|
||||
},
|
||||
Modifier.fillMaxWidth(),
|
||||
style = TrainlogButtonStyle.SUCCESS,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TrainlogFrame(strings.getString(R.string.hr_diag_scan_section)) {
|
||||
|
|
|
|||
|
|
@ -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.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
|
|
@ -39,6 +40,8 @@ import com.labfytools.trainlog.data.StartAiSessionDraftResult
|
|||
import com.labfytools.trainlog.data.TrainlogRepository
|
||||
import com.labfytools.trainlog.data.AndroidBackupService
|
||||
import com.labfytools.trainlog.data.AndroidBackupResult
|
||||
import com.labfytools.trainlog.data.HeartRateSensorPreferences
|
||||
import com.labfytools.trainlog.data.HeartRateSensorService
|
||||
import com.labfytools.trainlog.data.directStoragePermissionIntent
|
||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||
import com.labfytools.trainlog.model.SessionExerciseDraft
|
||||
|
|
@ -484,6 +487,10 @@ fun SettingsScreen(
|
|||
val strings = localizedContext()
|
||||
var authorized by remember { mutableStateOf(inbox.hasFolderAccess()) }
|
||||
var message by remember { mutableStateOf<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()) {
|
||||
authorized = inbox.hasFolderAccess()
|
||||
message = strings.getString(if (authorized) R.string.settings_access_granted else R.string.settings_access_required)
|
||||
|
|
@ -539,11 +546,27 @@ fun SettingsScreen(
|
|||
}
|
||||
}
|
||||
TrainlogFrame(strings.getString(R.string.settings_cardio_section)) {
|
||||
if (selectedHeartRateSensor != null) {
|
||||
TrainlogInfo(
|
||||
strings.getString(R.string.settings_cardio_configured),
|
||||
colors.success,
|
||||
)
|
||||
}
|
||||
TrainlogAction(
|
||||
strings.getString(R.string.settings_cardio_diagnostics),
|
||||
strings.getString(R.string.settings_cardio_diagnostics_description),
|
||||
onHeartRateDiagnostics,
|
||||
)
|
||||
if (selectedHeartRateSensor != null) {
|
||||
TrainlogAction(
|
||||
strings.getString(R.string.settings_cardio_forget),
|
||||
strings.getString(R.string.settings_cardio_forget_description),
|
||||
{
|
||||
HeartRateSensorService.forget(context)
|
||||
heartRateRevision++
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
TrainlogFrame(strings.getString(R.string.settings_exchange_folder)) {
|
||||
TrainlogInfo(strings.getString(if (authorized) R.string.settings_folder_granted else R.string.settings_folder_required), if (authorized) colors.success else colors.warning)
|
||||
|
|
|
|||
|
|
@ -196,7 +196,7 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
|||
AppRoute.LatestMaxima -> LatestMaximaScreen(repository)
|
||||
AppRoute.Sync -> SyncScreen(repository, inbox, exporter, requestOutbox, { exportSnapshot(); catalogRevision++ }, { back() })
|
||||
AppRoute.Settings -> SettingsScreen(repository, inbox, { open(AppRoute.HeartRateDiagnostics) }) { exportSnapshot(); catalogRevision++ }
|
||||
AppRoute.HeartRateDiagnostics -> HeartRateDiagnosticsScreen()
|
||||
AppRoute.HeartRateDiagnostics -> HeartRateDiagnosticsScreen { back() }
|
||||
}}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -511,4 +511,18 @@
|
|||
<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>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -521,4 +521,18 @@
|
|||
<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>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
| Cardio persistence/sync | `TRAINLOG_CARDIO_PERSISTENCE_SYNC_V1=PASS`: Android v27 + desktop v30, strict optional `trainlog-heart-rate` V1 Android→desktop companion, generation relation and correlated ACK |
|
||||
| Cardio BLE diagnostics | `TRAINLOG_CARDIO_BLE_DIAGNOSTICS_V1=PASS`: real CYCPLUS H2 validated on Android; `0x180D` + `0x2A37`, Battery Service, approximately 1 Hz notifications and one raw RR interval per observed notification; contact and energy fields absent in the observed payloads |
|
||||
| Current operational cursor | `TRAINLOG_CARDIO_PERSISTENT_ACQUISITION_V1`: remember the validated sensor, persistent reconnecting Android acquisition service, stale-signal handling and global grey/orange/green `♥ BPM` indicator |
|
||||
| Cardio persistent acquisition | `TRAINLOG_CARDIO_PERSISTENT_ACQUISITION_V1=PASS`: selected sensor persists, dedicated connected-device foreground service reconnects, stale BPM is cleared, and the global Android `♥ BPM` indicator is grey/orange/green by connection state; real CYCPLUS restart and Bluetooth-off/on recovery validated on private vc40 |
|
||||
| Current operational cursor | `TRAINLOG_CARDIO_TRAINING_TIMELINE_V1`: persist real session/exercise start/end boundaries and correlate live heart-rate samples with the active stable `entry_id` |
|
||||
| Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered |
|
||||
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
|
||||
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ sensor. Heart-rate ingestion must not reuse or overload the frozen Trainlog
|
|||
Bluetooth synchronization transport contract.
|
||||
|
||||
```text
|
||||
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_CARDIO_PERSISTENT_ACQUISITION_V1
|
||||
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_CARDIO_TRAINING_TIMELINE_V1
|
||||
```
|
||||
|
||||
The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this
|
||||
|
|
|
|||
|
|
@ -1703,3 +1703,27 @@ in project documentation.
|
|||
|
||||
The complete Android JVM inventory after this tranche reports **266 tests:
|
||||
261 passed, five skipped, zero failures/errors**, and `assembleDebug` succeeds.
|
||||
|
||||
## Cardio persistent acquisition V1 validation
|
||||
|
||||
`TRAINLOG_CARDIO_PERSISTENT_ACQUISITION_V1=PASS` adds a remembered Android
|
||||
heart-rate sensor, a dedicated connected-device foreground service, process-wide
|
||||
live state and the global app-bar `♥ BPM` indicator. A fresh measurement is
|
||||
green; connection/reconnection or stale signal is orange; no configured/active
|
||||
sensor is grey. A BPM older than three seconds is removed from presentation,
|
||||
and a stale GATT stream is forcibly recycled after eight seconds.
|
||||
|
||||
Unit coverage proves durable sensor selection/clear and the live-state
|
||||
transition from connecting to measured BPM/RR, stale `-- BPM`, and
|
||||
disconnected. The complete Android JVM inventory after this tranche reports
|
||||
**268 tests: 263 passed, five skipped, zero failures/errors**; `assembleDebug`
|
||||
also passes.
|
||||
|
||||
Real-device validation used the authorized CYCPLUS H2 on the private Trainlog
|
||||
0.1.5 versionCode 40 installation. With no selected sensor the global indicator
|
||||
rendered `♥ -- BPM` and the disconnected accessibility state. Selecting the
|
||||
validated sensor produced live `♥ 75 BPM`; a complete app force-stop/restart
|
||||
reloaded the remembered sensor and returned automatically at `♥ 72 BPM`.
|
||||
Disabling phone Bluetooth produced `♥ -- BPM` with the reconnecting state;
|
||||
reenabling Bluetooth restored the same foreground service automatically and
|
||||
returned to a fresh `♥ 79 BPM`. No uninstall or data clear occurred.
|
||||
|
|
|
|||
Loading…
Reference in a new issue