feat(train): setting up and keeping the Polar Loop (ADR 0030, D34–D36)

On Android, the Loop through Polar's BLE SDK 8.3.0 (from JitPack, asked
for Polar's group alone, its files pinned by SHA-256 in Gradle's
verification metadata): LoopBridge.kt over app.halkyon.train/loop, the
first-time use's figures mapped and checked in LoopSetup.kt, tested on
the JVM. PolarLoop per person remembers the band in loop.json, connects
while the app is open, sets its clock at each connection, keeps what it
tells (battery, charging, firmware), offers a changed profile and sends
it only when asked (D35: first-time use writes a new user identifier
each time), and lets the band go by a factory reset after asking. The
setup sheet finds Loops nearest first and asks only for what the
profile lacks; Polar's defaults (60, 220 less the age, a VO2max from
Polar's fitness tables) go to the band alone.

Polar's SDK is not free software (D36, correcting D32): the app's AGPL
gains a section 7 permission for linking it, LICENSE.md names it, and
its licence and bundled notices join the licence list on Android.

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 18:58:02 +01:00
commit cd72cf6ccc
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
7 changed files with 3485 additions and 99 deletions

View file

@ -55,6 +55,8 @@ dependencies {
implementation("androidx.core:core:1.18.0")
// Health Connect (ADR 0030, D30, D31): AndroidX's client, no Play services.
implementation("androidx.health.connect:connect-client:1.1.0")
// The Polar Loop (ADR 0030, D32, D34): Polar's BLE SDK, under Polar's licence.
implementation("com.github.polarofficial:polar-ble-sdk:8.3.0")
// The recorder's pure Kotlin parts (auto-pause) tested on the JVM.
testImplementation("junit:junit:4.13.2")
}

View file

@ -0,0 +1,180 @@
package app.halkyon.train
import android.content.Context
import com.polar.androidcommunications.api.ble.model.DisInfo
import com.polar.androidcommunications.api.ble.model.gatt.client.ChargeState
import com.polar.androidcommunications.api.ble.model.gatt.client.PowerSourcesState
import com.polar.sdk.api.PolarBleApi
import com.polar.sdk.api.PolarBleApiCallbackProvider
import com.polar.sdk.api.PolarBleApiDefaultImpl
import com.polar.sdk.api.model.PolarDeviceInfo
import com.polar.sdk.api.model.PolarHealthThermometerData
import com.polar.sdk.api.model.PolarHrData
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.time.LocalDateTime
import java.time.ZonedDateTime
import java.util.UUID
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
/**
* The Polar Loop for the app (ADR 0030, D32, D34), through Polar's BLE SDK,
* over `app.halkyon.train/loop`: Loops nearby (`…/loop/search`), connecting
* to the one the person chose, its first-time use, its clock, letting it go,
* and what it tells of itself as it connects (`…/loop/state`). The app
* decides when; this only speaks to the band.
*/
class LoopBridge(private val context: Context) : MethodChannel.MethodCallHandler {
private val scope: CoroutineScope = MainScope()
private var events: EventChannel.EventSink? = null
// The SDK made on first use, so that an app without a Loop never starts it.
// Its telemetry stays off: FEATURE_TELEMETRY is never asked for.
private val made = lazy {
PolarBleApiDefaultImpl.defaultImplementation(
context.applicationContext,
setOf(
PolarBleApi.PolarBleSdkFeature.FEATURE_DEVICE_INFO,
PolarBleApi.PolarBleSdkFeature.FEATURE_BATTERY_INFO,
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_DEVICE_TIME_SETUP,
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_FILE_TRANSFER,
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_DEVICE_CONTROL,
),
).apply {
setAutomaticReconnection(true)
setApiCallback(callback)
}
}
private val api: PolarBleApi by made
/** What the band tells, sent on as `{id, event, …}`. */
val state =
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, sink: EventChannel.EventSink) {
events = sink
}
override fun onCancel(arguments: Any?) {
events = null
}
}
/** Loops nearby while the setup sheet looks, each as `{id, name, rssi}`. */
val search =
object : EventChannel.StreamHandler {
private var looking: Job? = null
override fun onListen(arguments: Any?, sink: EventChannel.EventSink) {
looking?.cancel()
looking =
scope.launch {
try {
api.searchForDevice("Polar").collect { device ->
if (LoopSetup.isLoop(device.name)) {
sink.success(mapOf("id" to device.deviceId, "name" to device.name, "rssi" to device.rssi))
}
}
} catch (error: SecurityException) {
sink.error("refused", error.message, null)
} catch (error: Exception) {
sink.error("failed", error.message, null)
}
}
}
override fun onCancel(arguments: Any?) {
looking?.cancel()
looking = null
}
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
scope.launch {
try {
val id = call.argument<String>("id")!!
when (call.method) {
"connect" -> result.success(api.connectToDevice(id).let { null })
"disconnect" -> result.success(api.disconnectFromDevice(id).let { null })
"isSetUp" -> result.success(api.isFtuDone(id))
"setUp" -> {
val config = LoopSetup.configOf(call.argument<Map<String, Any?>>("figures")!!, ZonedDateTime.now())
api.doFirstTimeUse(id, config)
api.setLocalTime(id, LocalDateTime.now())
result.success(null)
}
"setClock" -> result.success(api.setLocalTime(id, LocalDateTime.now()).let { null })
"reset" -> result.success(api.doFactoryReset(id).let { null })
else -> result.notImplemented()
}
} catch (error: SecurityException) {
result.error("refused", error.message, null)
} catch (error: Exception) {
result.error("failed", error.message, null)
}
}
}
/** Lets the band go from the SDK as the app closes. */
fun dispose() {
scope.cancel()
events = null
if (made.isInitialized()) api.shutDown()
}
private fun send(id: String, event: String, vararg values: Pair<String, Any?>) {
events?.success(mapOf("id" to id, "event" to event, *values))
}
private val callback =
object : PolarBleApiCallbackProvider {
override fun blePowerStateChanged(powered: Boolean) {}
override fun deviceConnecting(polarDeviceInfo: PolarDeviceInfo) =
send(polarDeviceInfo.deviceId, "connecting")
override fun deviceConnected(polarDeviceInfo: PolarDeviceInfo) =
send(polarDeviceInfo.deviceId, "connected", "name" to polarDeviceInfo.name)
override fun deviceDisconnected(polarDeviceInfo: PolarDeviceInfo) =
send(polarDeviceInfo.deviceId, "disconnected")
override fun bleSdkFeatureReady(identifier: String, feature: PolarBleApi.PolarBleSdkFeature) {
// The clock and the first-time use go through the band's files.
if (feature == PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_DEVICE_TIME_SETUP) send(identifier, "ready")
}
override fun bleSdkFeaturesReadiness(
identifier: String,
ready: List<PolarBleApi.PolarBleSdkFeature>,
unavailable: List<PolarBleApi.PolarBleSdkFeature>,
) {}
override fun disInformationReceived(identifier: String, uuid: UUID, value: String) {
if (uuid == FIRMWARE_REVISION) send(identifier, "firmware", "version" to value)
}
override fun disInformationReceived(identifier: String, disInfo: DisInfo) {}
override fun batteryLevelReceived(identifier: String, level: Int) =
send(identifier, "battery", "level" to level)
override fun batteryChargingStatusReceived(identifier: String, chargingStatus: ChargeState) =
send(identifier, "charging", "charging" to (chargingStatus == ChargeState.CHARGING))
override fun powerSourcesStateReceived(identifier: String, powerSourcesState: PowerSourcesState) {}
override fun hrNotificationReceived(identifier: String, data: PolarHrData.PolarHrSample) {}
override fun htsNotificationReceived(identifier: String, data: PolarHealthThermometerData) {}
}
private companion object {
/** Device Information's Firmware Revision String (0x2A26). */
val FIRMWARE_REVISION: UUID = UUID.fromString("00002a26-0000-1000-8000-00805f9b34fb")
}
}

View file

@ -0,0 +1,57 @@
package app.halkyon.train
import com.polar.sdk.api.model.PolarFirstTimeUseConfig
import java.time.LocalDate
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
/**
* The app's figures for a Loop's first-time use (ADR 0030, D34) as Polar's
* SDK takes them: the app sends every one, its defaults filled in already;
* this only maps names and checks what the SDK would refuse.
*/
object LoopSetup {
/** Polar's six training backgrounds, by the app's names. */
val trainingBackgrounds =
mapOf(
"occasional" to 10,
"regular" to 20,
"frequent" to 30,
"heavy" to 40,
"semiPro" to 50,
"pro" to 60,
)
/** Whether a device found under [name] is a Loop. */
fun isLoop(name: String?): Boolean = name != null && name.trim().startsWith("Polar Loop", ignoreCase = true)
/** [figures] as the SDK's configuration, the band's clock at [now]; throws IllegalArgumentException where one is missing or out of range. */
fun configOf(figures: Map<String, Any?>, now: ZonedDateTime): PolarFirstTimeUseConfig {
fun number(key: String): Number = figures[key] as? Number ?: throw IllegalArgumentException("$key missing")
return PolarFirstTimeUseConfig(
gender =
when (figures["sex"]) {
"female" -> PolarFirstTimeUseConfig.Gender.FEMALE
"male" -> PolarFirstTimeUseConfig.Gender.MALE
else -> throw IllegalArgumentException("sex missing")
},
birthDate = LocalDate.parse(figures["birthDate"] as? String ?: throw IllegalArgumentException("birthDate missing")),
height = number("heightCm").toFloat(),
weight = number("weightKg").toFloat(),
maxHeartRate = number("maxHeartRate").toInt(),
vo2Max = number("vo2Max").toInt(),
restingHeartRate = number("restingHeartRate").toInt(),
trainingBackground =
trainingBackgrounds[figures["trainingBackground"]] ?: throw IllegalArgumentException("trainingBackground missing"),
deviceTime = now.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME),
typicalDay =
when (figures["typicalDay"]) {
"mostlySitting" -> PolarFirstTimeUseConfig.TypicalDay.MOSTLY_SITTING
"mostlyStanding" -> PolarFirstTimeUseConfig.TypicalDay.MOSTLY_STANDING
"mostlyMoving" -> PolarFirstTimeUseConfig.TypicalDay.MOSTLY_MOVING
else -> throw IllegalArgumentException("typicalDay missing")
},
sleepGoalMinutes = number("sleepGoalMinutes").toInt(),
)
}
}

View file

@ -17,6 +17,9 @@ class MainActivity : FlutterActivity() {
/** Health Connect's side (ADR 0030, D30, D31). */
private val health by lazy { HealthConnectBridge(this) }
/** The Polar Loop's side (ADR 0030, D34). */
private val loop by lazy { LoopBridge(this) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
health.onIntent(intent)
@ -71,6 +74,9 @@ class MainActivity : FlutterActivity() {
.setStreamHandler(DeviceScanner(this, RowerData.SERVICE, ::mayBeRower))
MethodChannel(messenger, "app.halkyon.train/health").setMethodCallHandler(health)
EventChannel(messenger, "app.halkyon.train/health/rationale").setStreamHandler(health.rationaleRequests)
MethodChannel(messenger, "app.halkyon.train/loop").setMethodCallHandler(loop)
EventChannel(messenger, "app.halkyon.train/loop/state").setStreamHandler(loop.state)
EventChannel(messenger, "app.halkyon.train/loop/search").setStreamHandler(loop.search)
EventChannel(messenger, "app.halkyon.train/recorder/lines").setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
@ -90,6 +96,7 @@ class MainActivity : FlutterActivity() {
override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
RecorderService.lines = null
health.dispose()
loop.dispose()
super.cleanUpFlutterEngine(flutterEngine)
}

View file

@ -0,0 +1,84 @@
package app.halkyon.train
import com.polar.sdk.api.model.PolarFirstTimeUseConfig
import java.time.LocalDate
import java.time.ZoneOffset
import java.time.ZonedDateTime
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
// A Loop's first-time use from the app's figures (ADR 0030, D34): Polar's
// names for the app's, and nothing sent that the SDK would refuse.
class LoopSetupTest {
private val now = ZonedDateTime.of(2026, 10, 28, 18, 30, 0, 0, ZoneOffset.ofHours(1))
private val figures =
mapOf<String, Any?>(
"sex" to "female",
"birthDate" to "1990-04-02",
"heightCm" to 172,
"weightKg" to 64.5,
"restingHeartRate" to 58,
"maxHeartRate" to 186,
"vo2Max" to 42,
"trainingBackground" to "semiPro",
"typicalDay" to "mostlyStanding",
"sleepGoalMinutes" to 480,
)
@Test
fun `the app's figures become Polar's`() {
val config = LoopSetup.configOf(figures, now)
assertEquals(PolarFirstTimeUseConfig.Gender.FEMALE, config.gender)
assertEquals(LocalDate.of(1990, 4, 2), config.birthDate)
assertEquals(172f, config.height)
assertEquals(64.5f, config.weight)
assertEquals(58, config.restingHeartRate)
assertEquals(186, config.maxHeartRate)
assertEquals(42, config.vo2Max)
assertEquals(50, config.trainingBackground)
assertEquals(PolarFirstTimeUseConfig.TypicalDay.MOSTLY_STANDING, config.typicalDay)
assertEquals(480, config.sleepGoalMinutes)
assertEquals("2026-10-28T18:30:00+01:00", config.deviceTime)
}
@Test
fun `each of the six training backgrounds is one the SDK takes`() {
for ((name, value) in LoopSetup.trainingBackgrounds) {
val config = LoopSetup.configOf(figures + ("trainingBackground" to name), now)
assertEquals(value, config.trainingBackground)
}
assertEquals(6, LoopSetup.trainingBackgrounds.size)
}
@Test
fun `a figure missing is refused`() {
for (key in figures.keys) {
assertThrows(key, IllegalArgumentException::class.java) {
LoopSetup.configOf(figures - key, now)
}
}
}
@Test
fun `a figure out of Polar's range is refused`() {
for ((key, value) in listOf("heightCm" to 250, "restingHeartRate" to 15, "sleepGoalMinutes" to 720)) {
assertThrows(key, IllegalArgumentException::class.java) {
LoopSetup.configOf(figures + (key to value), now)
}
}
}
@Test
fun `only a Loop is taken for one`() {
assertTrue(LoopSetup.isLoop("Polar Loop 1A2B3C4D"))
assertTrue(LoopSetup.isLoop("POLAR LOOP"))
assertFalse(LoopSetup.isLoop("Polar 360 1A2B3C4D"))
assertFalse(LoopSetup.isLoop("Polar H10 1A2B3C4D"))
assertFalse(LoopSetup.isLoop(null))
}
}

View file

@ -2,6 +2,12 @@ allprojects {
repositories {
google()
mavenCentral()
// Polar's BLE SDK for the Loop (ADR 0030, D34), which Polar publishes
// through JitPack alone: asked for Polar's package and nothing else.
exclusiveContent {
forRepository { maven("https://jitpack.io") }
filter { includeGroup("com.github.polarofficial") }
}
}
}

View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Polar's BLE SDK comes from JitPack (ADR 0030, D34), which builds it from
Polar's repository itself: its files are pinned here by their SHA-256, as
JitPack served them on 28 Oct 2026, so that a file changed there fails the
build. Everything else comes from Google's and Maven Central's repositories
and is trusted as before.
-->
<verification-metadata xmlns="https://schema.gradle.org/dependency-verification"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://schema.gradle.org/dependency-verification https://schema.gradle.org/dependency-verification/dependency-verification-1.3.xsd">
<configuration>
<verify-metadata>true</verify-metadata>
<verify-signatures>false</verify-signatures>
<trusted-artifacts>
<trust group="^(?!com[.]github[.]polarofficial$).*$" regex="true"/>
</trusted-artifacts>
</configuration>
<components>
<component group="com.github.polarofficial" name="polar-ble-sdk" version="8.3.0">
<artifact name="polar-ble-sdk-8.3.0.aar">
<sha256 value="f207c8b4a1812890e4737ac5c72b46bbcb1889826ef89d82ddc66c5772a826d0" origin="JitPack, 28 Oct 2026"/>
</artifact>
<artifact name="polar-ble-sdk-8.3.0.module">
<sha256 value="00ddeeac9e681cdfa411dd4cb6834968c0b08c18efff7debbd9d6379200293fc" origin="JitPack, 28 Oct 2026"/>
</artifact>
<artifact name="polar-ble-sdk-8.3.0.pom">
<sha256 value="1bceb6bf7e24262b196a3a7d55b94dadbfbe9428fe5aa998e20d4e6b4a4e1285" origin="JitPack, 28 Oct 2026"/>
</artifact>
</component>
</components>
</verification-metadata>