feat(train): the heart-rate strap, recorded by the service itself (ADR 0030, D25-D27)

The Record sheet gains a Heart rate row: a sheet looks for straps
advertising the Heart Rate Service, the nearest first, once nearby
devices are allowed, and the one chosen is remembered in the person's
folder; it records with every workout unless switched off for one.
The recorder service connects to it with Android's own Bluetooth API,
no package (D26), parses Heart Rate Measurement in Kotlin (tested on
the JVM), writes each rate and the strap's state to the recording
file, and asks for a lost strap again for as long as it records. The
recording screen shows the heart rate, and "Strap lost" while it is
gone; a workout's page shows the heart's average and highest rate,
each split's average and its chart.

iOS's recording waits for a Mac (D25), after the rower.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-28 15:50:36 +01:00
commit de5fd43e06
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
7 changed files with 1782 additions and 42 deletions

View file

@ -1,4 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- The app talks to its instance: release builds need this too (Flutter's
template grants it to debug and profile builds only). -->
<uses-permission android:name="android.permission.INTERNET"/>
@ -14,8 +15,18 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_HEALTH"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<!-- The heart-rate strap (ADR 0030, D26, D27): nearby devices from Android
12 on, never used to locate; before 12, Bluetooth's own permissions,
and scanning needs location, which the app has for GPS anyway. -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="s"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30"/>
<uses-feature android:name="android.hardware.location.gps" android:required="false"/>
<uses-feature android:name="android.hardware.sensor.stepdetector" android:required="false"/>
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false"/>
<application
android:label="Halkyon Train"
android:name="${applicationName}"

View file

@ -0,0 +1,39 @@
package app.halkyon.train
import java.util.UUID
/**
* The Heart Rate Service's measurement (Bluetooth SIG's Heart Rate Service
* 1.0, characteristic 0x2A37), as a strap notifies it (ADR 0030, D26).
*/
object HeartRateMeasurement {
val SERVICE: UUID = UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb")
val CHARACTERISTIC: UUID = UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb")
/** The Client Characteristic Configuration descriptor, written to have it notify. */
val CONFIG: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
private const val UINT16 = 0x01
private const val CONTACT_DETECTED = 0x02
private const val CONTACT_SUPPORTED = 0x04
/**
* The rate in beats a minute [value] holds; null where the strap says it
* isn't on the skin, the rate is 0, or the frame is cut short. The energy
* expended and the R-R intervals after it are left aside (D27).
*/
fun bpmOf(value: ByteArray): Int? {
if (value.isEmpty()) return null
val flags = value[0].toInt() and 0xFF
if (flags and CONTACT_SUPPORTED != 0 && flags and CONTACT_DETECTED == 0) return null
val bpm =
if (flags and UINT16 != 0) {
if (value.size < 3) return null
(value[1].toInt() and 0xFF) or ((value[2].toInt() and 0xFF) shl 8)
} else {
if (value.size < 2) return null
value[1].toInt() and 0xFF
}
return bpm.takeIf { it > 0 }
}
}

View file

@ -28,6 +28,7 @@ class MainActivity : FlutterActivity() {
activity = call.argument<String>("activity")!!,
autoPause = call.argument<String>("autoPause"),
notice = call.argument<String>("notice")!!,
strap = call.argument<String>("strap"),
)
result.success(null)
}
@ -39,6 +40,8 @@ class MainActivity : FlutterActivity() {
else -> result.notImplemented()
}
}
// Heart-rate straps nearby, while the strap sheet looks (D27).
EventChannel(messenger, "app.halkyon.train/recorder/straps").setStreamHandler(StrapScanner(this))
EventChannel(messenger, "app.halkyon.train/recorder/lines").setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
@ -62,7 +65,7 @@ class MainActivity : FlutterActivity() {
/**
* Asks for the permissions [wanted] names (location, notifications,
* activity) that aren't granted yet (ADR 0030, D19), and answers which are
* activity, bluetooth) that aren't granted yet (ADR 0030, D19), and answers which are
* granted afterwards.
*/
private fun ask(wanted: List<String>, result: MethodChannel.Result) {
@ -111,6 +114,15 @@ class MainActivity : FlutterActivity() {
} else {
emptyList()
}
// Finding and connecting to a strap (D27): nearby devices from Android
// 12 on, declared as never for location; before, scanning needs
// location.
"bluetooth" ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
listOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
} else {
listOf(Manifest.permission.ACCESS_FINE_LOCATION)
}
else -> emptyList()
}

View file

@ -35,7 +35,8 @@ import org.json.JSONObject
// The recording's side of the system (ADR 0030, D13, D16, D18, D19): a
// foreground service, started from the open app, that takes GPS fixes from
// Android's LocationManager (no Play services) and steps from the step
// detector, pauses by itself, and appends each sample as a line to the
// detector, reads the heart-rate strap where the person chose one (D26,
// D27), pauses by itself, and appends each sample as a line to the
// recording file, flushed every few seconds, so a recording survives the app
// and is found cut off if the process doesn't. Each line goes to the app as
// well while it listens; the app works out the totals and tells the service
@ -51,6 +52,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
private const val EXTRA_ACTIVITY = "activity"
private const val EXTRA_AUTO_PAUSE = "autoPause"
private const val EXTRA_NOTICE = "notice"
private const val EXTRA_STRAP = "strap"
private const val CHANNEL = "recording"
private const val NOTIFICATION_ID = 5301
private const val FLUSH_EVERY_MS = 5_000L
@ -66,9 +68,10 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
/**
* Starts recording [activity] into the file at [path], pausing by itself
* by [autoPause] (null: never), its notification as [notice] says.
* by [autoPause] (null: never), with the strap at [strap] (null: none),
* its notification as [notice] says.
*/
fun start(context: Context, path: String, activity: String, autoPause: String?, notice: String) {
fun start(context: Context, path: String, activity: String, autoPause: String?, notice: String, strap: String?) {
ContextCompat.startForegroundService(
context,
Intent(context, RecorderService::class.java)
@ -76,7 +79,8 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
.putExtra(EXTRA_PATH, path)
.putExtra(EXTRA_ACTIVITY, activity)
.putExtra(EXTRA_AUTO_PAUSE, autoPause)
.putExtra(EXTRA_NOTICE, notice),
.putExtra(EXTRA_NOTICE, notice)
.putExtra(EXTRA_STRAP, strap),
)
}
@ -106,6 +110,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
private var pausedByHand = false
private var notice: JSONObject? = null
private var wakeLock: PowerManager.WakeLock? = null
private var strap: StrapLink? = null
private val handler = Handler(Looper.getMainLooper())
private var lastFlush = 0L
@ -178,6 +183,20 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
getSystemService(PowerManager::class.java)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "HalkyonTrain:recording")
.apply { acquire(12 * 60 * 60 * 1_000L) }
// The strap where one was chosen and connecting is allowed (Android 12
// on asks; before, scanning's location stands for it).
val connects =
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || granted(Manifest.permission.BLUETOOTH_CONNECT)
intent.getStringExtra(EXTRA_STRAP)?.takeIf { connects }?.let { address ->
strap =
StrapLink(
this,
address,
handler,
onState = { state -> write("strap ${System.currentTimeMillis()} $state") },
onBpm = { bpm -> write("h ${System.currentTimeMillis()} $bpm") },
).also { it.open() }
}
lastFlush = now
handler.postDelayed(ticker, 1_000)
}
@ -246,6 +265,8 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
handler.removeCallbacks(ticker)
getSystemService(LocationManager::class.java).removeUpdates(this)
getSystemService(SensorManager::class.java).unregisterListener(this)
strap?.close()
strap = null
write("end ${System.currentTimeMillis()}")
writer?.close()
writer = null

View file

@ -0,0 +1,127 @@
package app.halkyon.train
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.content.Context
import android.os.Build
import android.os.Handler
/**
* The heart-rate strap during a recording (ADR 0030, D26, D27): connected
* by its address without bonding, its measurements notified, and asked for
* again by itself whenever it goes, until [close]. Each state and each rate
* reaches [onState] and [onBpm] on [handler]'s thread, the one the recorder
* writes its file on.
*/
@SuppressLint("MissingPermission") // The recorder opens it only where connecting is allowed.
class StrapLink(
private val context: Context,
private val address: String,
private val handler: Handler,
private val onState: (String) -> Unit,
private val onBpm: (Int) -> Unit,
) : BluetoothGattCallback() {
private var gatt: BluetoothGatt? = null
private var closed = false
private var connected = false
/** Asks for the strap: at once first, then in the background after each loss. */
fun open() {
val device = adapterDevice() ?: return onState("lost")
onState("connecting")
// A direct attempt finds a strap in reach within seconds; one that
// fails (out of reach, or off) falls back on the background one.
gatt = device.connectGatt(context, false, this, BluetoothDevice.TRANSPORT_LE)
}
/** Lets the strap go. */
fun close() {
handler.post {
closed = true
gatt?.disconnect()
gatt?.close()
gatt = null
}
}
private fun adapterDevice(): BluetoothDevice? =
try {
context.getSystemService(BluetoothManager::class.java)?.adapter?.getRemoteDevice(address)
} catch (_: IllegalArgumentException) {
null
}
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
handler.post {
if (closed) return@post
when (newState) {
BluetoothProfile.STATE_CONNECTED -> gatt.discoverServices()
BluetoothProfile.STATE_DISCONNECTED -> {
if (connected) onState("lost")
connected = false
gatt.close()
// In the background, for as long as the recording runs: Android
// connects once the strap is in reach again.
this.gatt = adapterDevice()?.connectGatt(context, true, this, BluetoothDevice.TRANSPORT_LE)
}
}
}
}
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
handler.post {
if (closed) return@post
val measurement =
gatt.getService(HeartRateMeasurement.SERVICE)?.getCharacteristic(HeartRateMeasurement.CHARACTERISTIC)
?: return@post gatt.disconnect()
gatt.setCharacteristicNotification(measurement, true)
val config = measurement.getDescriptor(HeartRateMeasurement.CONFIG) ?: return@post gatt.disconnect()
val enable = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
gatt.writeDescriptor(config, enable)
} else {
@Suppress("DEPRECATION")
config.value = enable
@Suppress("DEPRECATION")
gatt.writeDescriptor(config)
}
}
}
override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
handler.post {
if (closed || status != BluetoothGatt.GATT_SUCCESS) return@post
connected = true
onState("connected")
}
}
// Android 13 on.
override fun onCharacteristicChanged(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray,
) {
measured(characteristic, value)
}
// Before Android 13.
@Deprecated("Android 13 hands the value itself")
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) return
@Suppress("DEPRECATION")
measured(characteristic, characteristic.value ?: return)
}
private fun measured(characteristic: BluetoothGattCharacteristic, value: ByteArray) {
if (characteristic.uuid != HeartRateMeasurement.CHARACTERISTIC) return
val bpm = HeartRateMeasurement.bpmOf(value.copyOf()) ?: return
handler.post { if (!closed) onBpm(bpm) }
}
}

View file

@ -0,0 +1,69 @@
package app.halkyon.train
import android.annotation.SuppressLint
import android.bluetooth.BluetoothManager
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.os.ParcelUuid
import io.flutter.plugin.common.EventChannel
/**
* Looks for heart-rate straps nearby while the app listens (ADR 0030, D27):
* every device advertising the Heart Rate Service, as `{address, name,
* rssi}`; the error `off` where Bluetooth is, `refused` where scanning isn't
* allowed (the app asks first).
*/
class StrapScanner(private val context: Context) : EventChannel.StreamHandler {
private var callback: ScanCallback? = null
@SuppressLint("MissingPermission") // Refused scans end in the SecurityException below.
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
val adapter = context.getSystemService(BluetoothManager::class.java)?.adapter
val scanner = adapter?.takeIf { it.isEnabled }?.bluetoothLeScanner
if (scanner == null) {
events.error("off", "Bluetooth is off", null)
return
}
val found =
object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
val name =
result.scanRecord?.deviceName
?: try {
result.device.name
} catch (_: SecurityException) {
null
}
events.success(mapOf("address" to result.device.address, "name" to name, "rssi" to result.rssi))
}
override fun onScanFailed(errorCode: Int) {
events.error("failed", "Scan failed: $errorCode", null)
}
}
try {
scanner.startScan(
listOf(ScanFilter.Builder().setServiceUuid(ParcelUuid(HeartRateMeasurement.SERVICE)).build()),
ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build(),
found,
)
callback = found
} catch (_: SecurityException) {
events.error("refused", "Scanning not allowed", null)
}
}
@SuppressLint("MissingPermission")
override fun onCancel(arguments: Any?) {
val found = callback ?: return
callback = null
try {
context.getSystemService(BluetoothManager::class.java)?.adapter?.bluetoothLeScanner?.stopScan(found)
} catch (_: SecurityException) {
// Taken away while it looked: it stopped with it.
}
}
}

View file

@ -0,0 +1,51 @@
package app.halkyon.train
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
// Heart Rate Measurement (0x2A37, Bluetooth SIG's Heart Rate Service 1.0), as
// straps send it (ADR 0030, D26, D27): a flags byte, the rate in 8 or 16 bits,
// then the energy expended and R-R intervals where the flags say.
class HeartRateMeasurementTest {
private fun frame(vararg bytes: Int) = ByteArray(bytes.size) { bytes[it].toByte() }
@Test
fun `an 8-bit rate`() {
assertEquals(72, HeartRateMeasurement.bpmOf(frame(0x00, 72)))
}
@Test
fun `an 8-bit rate above 127 reads unsigned`() {
assertEquals(182, HeartRateMeasurement.bpmOf(frame(0x00, 182)))
}
@Test
fun `a 16-bit rate, little-endian`() {
assertEquals(300, HeartRateMeasurement.bpmOf(frame(0x01, 0x2C, 0x01)))
}
@Test
fun `R-R intervals and energy after the rate are left aside`() {
// As a Polar H10 sends it: contact detected, R-R present, two intervals.
assertEquals(64, HeartRateMeasurement.bpmOf(frame(0x16, 64, 0x9A, 0x03, 0x8F, 0x03)))
// Energy expended present (bit 3).
assertEquals(130, HeartRateMeasurement.bpmOf(frame(0x08, 130, 0x10, 0x00)))
}
@Test
fun `a strap that tells it isn't on the skin gives no rate`() {
// Contact supported (bit 2), not detected (bit 1 clear).
assertNull(HeartRateMeasurement.bpmOf(frame(0x04, 80)))
// Detected.
assertEquals(80, HeartRateMeasurement.bpmOf(frame(0x06, 80)))
}
@Test
fun `nothing, zero or a frame cut short gives no rate`() {
assertNull(HeartRateMeasurement.bpmOf(frame()))
assertNull(HeartRateMeasurement.bpmOf(frame(0x00)))
assertNull(HeartRateMeasurement.bpmOf(frame(0x00, 0)))
assertNull(HeartRateMeasurement.bpmOf(frame(0x01, 0x2C)))
}
}