feat(train): the Loop's days read on the phone (ADR 0030, D37, D38)

The server gains Train's synced `day`, `day_series` (a day's heart
rate, deleted with its day) and `night`, each a sealed payload alone;
the migration `days` only adds their tables, indexes and foreign keys
(to their sync space, and a chunk's to its day, deferred), and the
person's deletion takes them with their spaces.

On Android, `LoopDays.kt` reads a band's days from a date to today,
framed by the SDK's sync start and stop, into lines of JSON, then
deletes on the band what the app kept up to the date it names, never
today; `LoopBridge` answers `readDays` with them, one band call at a
time, and `background` schedules `LoopWorker`, a WorkManager job every
four hours that appends the same lines to the person's
`loop_days.txt` while the app doesn't hold the band.

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 19:53:04 +01:00
commit 88e8693025
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
7 changed files with 24637 additions and 52 deletions

View file

@ -57,6 +57,8 @@ dependencies {
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 Loop read every few hours while Train is closed (ADR 0030, D37).
implementation("androidx.work:work-runtime-ktx:2.10.1")
// The recorder's pure Kotlin parts (auto-pause) tested on the JVM.
testImplementation("junit:junit:4.13.2")
}

View file

@ -0,0 +1,64 @@
package app.halkyon.train
/**
* Maps, lists, strings, numbers, booleans and nulls as one line of JSON, for
* the Loop's file (ADR 0030, D37): Android's `org.json` is a stub in JVM
* tests, and nothing more is needed.
*/
object JsonText {
fun of(value: Any?): String = StringBuilder().also { write(it, value) }.toString()
private fun write(out: StringBuilder, value: Any?) {
when (value) {
null -> out.append("null")
is Boolean -> out.append(value)
is Float -> number(out, value.toDouble())
is Double -> number(out, value)
is Number -> out.append(value.toLong())
is String -> string(out, value)
is Map<*, *> -> {
out.append('{')
var first = true
for ((key, item) in value) {
if (!first) out.append(',')
first = false
string(out, key.toString())
out.append(':')
write(out, item)
}
out.append('}')
}
is Iterable<*> -> {
out.append('[')
var first = true
for (item in value) {
if (!first) out.append(',')
first = false
write(out, item)
}
out.append(']')
}
else -> string(out, value.toString())
}
}
private fun number(out: StringBuilder, value: Double) {
if (value.isNaN() || value.isInfinite()) out.append("null") else out.append(value)
}
private fun string(out: StringBuilder, value: String) {
out.append('"')
for (char in value) {
when {
char == '"' -> out.append("\\\"")
char == '\\' -> out.append("\\\\")
char == '\n' -> out.append("\\n")
char == '\r' -> out.append("\\r")
char == '\t' -> out.append("\\t")
char < ' ' -> out.append(String.format("\\u%04x", char.code))
else -> out.append(char)
}
}
out.append('"')
}
}

View file

@ -13,6 +13,7 @@ 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.LocalDate
import java.time.LocalDateTime
import java.time.ZonedDateTime
import java.util.UUID
@ -21,6 +22,8 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* The Polar Loop for the app (ADR 0030, D32, D34), through Polar's BLE SDK,
@ -31,6 +34,7 @@ import kotlinx.coroutines.launch
*/
class LoopBridge(private val context: Context) : MethodChannel.MethodCallHandler {
private val scope: CoroutineScope = MainScope()
private val calls = Mutex()
private var events: EventChannel.EventSink? = null
// The SDK made on first use, so that an app without a Loop never starts it.
@ -44,8 +48,12 @@ class LoopBridge(private val context: Context) : MethodChannel.MethodCallHandler
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_DEVICE_TIME_SETUP,
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_FILE_TRANSFER,
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_DEVICE_CONTROL,
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_ACTIVITY_DATA,
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_SLEEP_DATA,
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_TEMPERATURE_DATA,
),
).apply {
holding = true
setAutomaticReconnection(true)
setApiCallback(callback)
}
@ -94,22 +102,40 @@ class LoopBridge(private val context: Context) : MethodChannel.MethodCallHandler
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
if (call.method == "background") {
// What the background job reads, and where (D37); none to stop it.
LoopWorker.schedule(context, call.argument<Map<String, Any?>>("config"))
result.success(null)
return
}
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)
// One call to the band at a time, as Polar's sync guideline asks.
calls.withLock {
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 })
"readDays" ->
result.success(
LoopDays.read(
PolarLoopSource(api, id),
from = LocalDate.parse(call.argument<String>("from")!!),
today = LocalDate.now(),
keptUntil = call.argument<String>("keptUntil")?.let(LocalDate::parse),
),
)
else -> result.notImplemented()
}
"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)
@ -123,7 +149,10 @@ class LoopBridge(private val context: Context) : MethodChannel.MethodCallHandler
fun dispose() {
scope.cancel()
events = null
if (made.isInitialized()) api.shutDown()
if (made.isInitialized()) {
api.shutDown()
holding = false
}
}
private fun send(id: String, event: String, vararg values: Pair<String, Any?>) {
@ -173,8 +202,12 @@ class LoopBridge(private val context: Context) : MethodChannel.MethodCallHandler
override fun htsNotificationReceived(identifier: String, data: PolarHealthThermometerData) {}
}
private companion object {
companion object {
/** Whether the open app holds the SDK, so that the background job leaves the band alone. */
@Volatile
var holding = false
/** Device Information's Firmware Revision String (0x2A26). */
val FIRMWARE_REVISION: UUID = UUID.fromString("00002a26-0000-1000-8000-00805f9b34fb")
private val FIRMWARE_REVISION: UUID = UUID.fromString("00002a26-0000-1000-8000-00805f9b34fb")
}
}

View file

@ -0,0 +1,181 @@
package app.halkyon.train
import com.polar.sdk.api.PolarBleApi
import com.polar.sdk.api.model.PolarSkinTemperatureData
import com.polar.sdk.api.model.activity.Polar247HrSamplesData
import com.polar.sdk.api.model.activity.PolarActiveTime
import com.polar.sdk.api.model.activity.PolarDailySummaryData
import com.polar.sdk.api.model.sleep.PolarNightlyRechargeData
import com.polar.sdk.api.model.sleep.PolarSleepData
import java.time.LocalDate
import java.time.format.DateTimeFormatter
/** What a read of the Loop asks of the band, one call after the other (Polar's sync guideline). */
interface LoopSource {
/** Readies the band: it writes what it holds in memory to its files. */
suspend fun startSync()
suspend fun stopSync()
suspend fun summaries(from: LocalDate, to: LocalDate): List<PolarDailySummaryData>
suspend fun heartRates(from: LocalDate, to: LocalDate): List<Polar247HrSamplesData>
suspend fun sleeps(from: LocalDate, to: LocalDate): List<PolarSleepData>
suspend fun recharges(from: LocalDate, to: LocalDate): List<PolarNightlyRechargeData>
suspend fun skinTemperatures(from: LocalDate, to: LocalDate): List<PolarSkinTemperatureData>
suspend fun delete(type: PolarBleApi.PolarStoredDataType, until: LocalDate)
}
/**
* The Loop's days and nights (ADR 0030, D37, D38): a read from a date to
* today, as plain values for the app (a line of the person's file each, or a
* method channel's answer), after which what the app has kept is deleted on
* the band, never today.
*/
object LoopDays {
/** What the band deletes of a day kept, type by type (D38). */
val deletedTypes =
listOf(
PolarBleApi.PolarStoredDataType.ACTIVITY,
PolarBleApi.PolarStoredDataType.DAILY_SUMMARY,
PolarBleApi.PolarStoredDataType.AUTO_SAMPLE,
PolarBleApi.PolarStoredDataType.SLEEP,
PolarBleApi.PolarStoredDataType.SLEEP_SCORE,
PolarBleApi.PolarStoredDataType.NIGHTLY_RECOVERY,
PolarBleApi.PolarStoredDataType.SKIN_TEMP,
PolarBleApi.PolarStoredDataType.SKIN_CONTACT_CHANGES,
)
/**
* Reads the days from [from] to [today] off [source], a `read` line first
* (when and what), then deletes on the band what the app kept up to
* [keptUntil], which never reaches today.
*/
suspend fun read(
source: LoopSource,
from: LocalDate,
today: LocalDate,
keptUntil: LocalDate?,
): List<Map<String, Any?>> {
val lines = mutableListOf<Map<String, Any?>>(
mapOf("kind" to "read", "on" to dateOf(today), "from" to dateOf(from), "to" to dateOf(today)),
)
source.startSync()
try {
if (!from.isAfter(today)) {
source.summaries(from, today).mapTo(lines, ::summaryOf)
source.heartRates(from, today).mapTo(lines, ::heartOf)
source.sleeps(from, today).mapNotNullTo(lines, ::sleepOf)
source.recharges(from, today).mapNotNullTo(lines, ::rechargeOf)
source.skinTemperatures(from, today).mapNotNullTo(lines, ::skinOf)
}
val until = keptUntil?.let { minOf(it, today.minusDays(1)) }
if (until != null) {
for (type in deletedTypes) source.delete(type, until)
}
} finally {
source.stopSync()
}
return lines
}
fun summaryOf(day: PolarDailySummaryData): Map<String, Any?> {
val times = day.activityClassTimes
return mapOf(
"kind" to "summary",
"date" to day.date?.let(::dateOf),
"steps" to day.steps,
"metres" to day.activityDistance,
"activityCalories" to day.activityCalories,
"trainingCalories" to day.trainingCalories,
"bmrCalories" to day.bmrCalories,
"goal" to day.activityGoalSummary?.activityGoal,
"achieved" to day.activityGoalSummary?.achievedActivity,
"times" to
times?.let {
mapOf(
"nonWear" to secondsOf(it.timeNonWear),
"sleep" to secondsOf(it.timeSleep),
"sedentary" to secondsOf(it.timeSedentary),
"light" to secondsOf(it.timeLightActivity),
"continuousModerate" to secondsOf(it.timeContinuousModerateActivity),
"intermittentModerate" to secondsOf(it.timeIntermittentModerateActivity),
"continuousVigorous" to secondsOf(it.timeContinuousVigorousActivity),
"intermittentVigorous" to secondsOf(it.timeIntermittentVigorousActivity),
)
},
)
}
/** A day's 24/7 heart rate: each measurement's start (seconds into the band's day), its kind and its samples. */
fun heartOf(day: Polar247HrSamplesData): Map<String, Any?> =
mapOf(
"kind" to "heart",
"date" to dateOf(day.date),
"runs" to
day.samples.map { run ->
mapOf(
"at" to run.startTime.toSecondOfDay(),
"trigger" to run.triggerType.name.removePrefix("TRIGGER_TYPE_").lowercase(),
"bpm" to run.hrSamples,
)
},
)
fun sleepOf(night: PolarSleepData): Map<String, Any?>? {
val result = night.result ?: return null
return mapOf(
"kind" to "sleep",
"date" to dateOf(result.sleepResultDate ?: night.date ?: return null),
"start" to result.sleepStartTime?.toOffsetDateTime()?.toString(),
"end" to result.sleepEndTime?.toOffsetDateTime()?.toString(),
"goalMinutes" to result.sleepGoalMinutes,
"phases" to result.sleepWakePhases?.map { listOf(it.secondsFromSleepStart, it.state.name.lowercase()) },
"cycles" to result.sleepCycles?.map { listOf(it.secondsFromSleepStart, it.sleepDepthStart) },
"rating" to result.userSleepRating?.name?.removePrefix("SLEPT_")?.lowercase(),
"batteryRanOut" to result.batteryRanOut,
"skinCelsius" to result.sleepSkinTemperatureResult?.sleepSkinTemperatureCelsius,
"skinDeviation" to result.sleepSkinTemperatureResult?.deviationFromBaseLine,
)
}
fun rechargeOf(recharge: PolarNightlyRechargeData): Map<String, Any?>? {
val date = recharge.sleepResultDate ?: return null
return mapOf(
"kind" to "recharge",
"date" to dateOf(date),
"ansStatus" to recharge.ansStatus,
"recovery" to recharge.recoveryIndicator,
"recoverySubLevel" to recharge.recoveryIndicatorSubLevel,
"ansRate" to recharge.ansRate,
"rri" to recharge.meanNightlyRecoveryRRI,
"rmssd" to recharge.meanNightlyRecoveryRMSSD,
"breathInterval" to recharge.meanNightlyRecoveryRespirationInterval,
"baselineRri" to recharge.meanBaselineRRI,
"baselineRriSd" to recharge.sdBaselineRRI,
"baselineRmssd" to recharge.meanBaselineRMSSD,
"baselineRmssdSd" to recharge.sdBaselineRMSSD,
"baselineBreathInterval" to recharge.meanBaselineRespirationInterval,
"baselineBreathIntervalSd" to recharge.sdBaselineRespirationInterval,
)
}
fun skinOf(skin: PolarSkinTemperatureData): Map<String, Any?>? {
val date = skin.date ?: return null
val samples = skin.result?.skinTemperatureList ?: return null
return mapOf(
"kind" to "skin",
"date" to dateOf(date),
"samples" to samples.map { listOf(it.recordingTimeDeltaMs, it.temperature) },
)
}
private fun secondsOf(time: PolarActiveTime?): Int? =
time?.let { it.hours * 3600 + it.minutes * 60 + it.seconds }
private fun dateOf(date: LocalDate): String = date.format(DateTimeFormatter.ISO_LOCAL_DATE)
}

View file

@ -0,0 +1,159 @@
package app.halkyon.train
import android.content.Context
import android.util.Log
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
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 java.io.File
import java.io.FileOutputStream
import java.time.LocalDate
import java.util.UUID
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.withTimeout
import org.json.JSONObject
/**
* Reads the person's Loop every few hours while Train is closed (ADR 0030,
* D37, D38): connects, reads the days since those the app kept into the
* person's `loop_days.txt` (a line of JSON each, as [LoopDays] makes them),
* deletes on the band what the app said it kept, and lets go. The app seals
* the lines as it opens; this never seals, as only the app holds the key.
* It leaves the band alone while the open app holds it.
*/
class LoopWorker(context: Context, parameters: WorkerParameters) : CoroutineWorker(context, parameters) {
override suspend fun doWork(): Result {
if (LoopBridge.holding) return Result.success()
val config = configOf(applicationContext) ?: return Result.success()
val ready = CompletableDeferred<Unit>()
val api =
PolarBleApiDefaultImpl.defaultImplementation(
applicationContext,
setOf(
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_FILE_TRANSFER,
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_DEVICE_CONTROL,
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_ACTIVITY_DATA,
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_SLEEP_DATA,
PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_TEMPERATURE_DATA,
),
)
api.setApiCallback(ReadyCallback(config.id, ready))
return try {
api.connectToDevice(config.id)
withTimeout(TimeUnit.SECONDS.toMillis(90)) { ready.await() }
val lines =
LoopDays.read(PolarLoopSource(api, config.id), config.from, LocalDate.now(), config.keptUntil)
FileOutputStream(File(config.folder, FILE), true).use { out ->
for (line in lines) out.write((JsonText.of(line) + "\n").toByteArray())
out.fd.sync()
}
Result.success()
} catch (error: Exception) {
// Out of reach, or the band busy: the next run tries again.
Log.i("Loop", "The Loop not read in the background: ${error.javaClass.simpleName}")
Result.success()
} finally {
runCatching { api.disconnectFromDevice(config.id) }
api.shutDown()
}
}
/** What is read and where: the band, the person's folder, from when, and what the app kept. */
private class Config(val folder: File, val id: String, val from: LocalDate, val keptUntil: LocalDate?)
/** Ready once the band's activity can be read. */
private class ReadyCallback(private val id: String, private val ready: CompletableDeferred<Unit>) :
PolarBleApiCallbackProvider {
override fun bleSdkFeatureReady(identifier: String, feature: PolarBleApi.PolarBleSdkFeature) {
if (identifier == id && feature == PolarBleApi.PolarBleSdkFeature.FEATURE_POLAR_ACTIVITY_DATA) {
ready.complete(Unit)
}
}
override fun blePowerStateChanged(powered: Boolean) {}
override fun deviceConnected(polarDeviceInfo: PolarDeviceInfo) {}
override fun deviceConnecting(polarDeviceInfo: PolarDeviceInfo) {}
override fun deviceDisconnected(polarDeviceInfo: PolarDeviceInfo) {}
override fun bleSdkFeaturesReadiness(
identifier: String,
ready: List<PolarBleApi.PolarBleSdkFeature>,
unavailable: List<PolarBleApi.PolarBleSdkFeature>,
) {}
override fun disInformationReceived(identifier: String, uuid: UUID, value: String) {}
override fun disInformationReceived(identifier: String, disInfo: DisInfo) {}
override fun batteryLevelReceived(identifier: String, level: Int) {}
override fun batteryChargingStatusReceived(identifier: String, chargingStatus: ChargeState) {}
override fun powerSourcesStateReceived(identifier: String, powerSourcesState: PowerSourcesState) {}
override fun hrNotificationReceived(identifier: String, data: PolarHrData.PolarHrSample) {}
override fun htsNotificationReceived(identifier: String, data: PolarHealthThermometerData) {}
}
companion object {
/** The lines read in the background, in the person's folder. */
const val FILE = "loop_days.txt"
private const val CONFIG = "loop_background.json"
private const val WORK = "loop-days"
/**
* Reads the band every four hours with [config] (`folder`, `id`, `from`,
* `keptUntil`), or no longer where it is null.
*/
fun schedule(context: Context, config: Map<String, Any?>?) {
val file = File(context.filesDir, CONFIG)
val work = WorkManager.getInstance(context)
if (config == null) {
file.delete()
work.cancelUniqueWork(WORK)
return
}
file.writeText(JsonText.of(config))
work.enqueueUniquePeriodicWork(
WORK,
// A new config is read by the next run; the schedule stays.
ExistingPeriodicWorkPolicy.KEEP,
PeriodicWorkRequestBuilder<LoopWorker>(4, TimeUnit.HOURS)
.setConstraints(Constraints.Builder().setRequiresBatteryNotLow(true).build())
.build(),
)
}
private fun configOf(context: Context): Config? {
val file = File(context.filesDir, CONFIG)
if (!file.exists()) return null
return runCatching {
val json = JSONObject(file.readText())
Config(
folder = File(json.getString("folder")),
id = json.getString("id"),
from = LocalDate.parse(json.getString("from")),
keptUntil = json.optString("keptUntil").takeIf { it.isNotEmpty() && it != "null" }?.let(LocalDate::parse),
)
}.getOrNull()
}
}
}

View file

@ -0,0 +1,29 @@
package app.halkyon.train
import com.polar.sdk.api.PolarBleApi
import java.time.LocalDate
/** [LoopSource] on Polar's SDK, for the band [id] (ADR 0030, D37). */
class PolarLoopSource(private val api: PolarBleApi, private val id: String) : LoopSource {
override suspend fun startSync() {
api.sendInitializationAndStartSyncNotifications(id)
}
override suspend fun stopSync() {
api.sendTerminateAndStopSyncNotifications(id)
}
override suspend fun summaries(from: LocalDate, to: LocalDate) = api.getDailySummaryData(id, from, to)
override suspend fun heartRates(from: LocalDate, to: LocalDate) = api.get247HrSamples(id, from, to)
override suspend fun sleeps(from: LocalDate, to: LocalDate) = api.getSleep(id, from, to)
override suspend fun recharges(from: LocalDate, to: LocalDate) = api.getNightlyRecharge(id, from, to)
override suspend fun skinTemperatures(from: LocalDate, to: LocalDate) = api.getSkinTemperature(id, from, to)
override suspend fun delete(type: PolarBleApi.PolarStoredDataType, until: LocalDate) {
api.deleteStoredDeviceData(id, type, until)
}
}

View file

@ -0,0 +1,264 @@
package app.halkyon.train
import com.polar.sdk.api.PolarBleApi
import com.polar.sdk.api.model.PolarSkinTemperatureData
import com.polar.sdk.api.model.PolarSkinTemperatureDataSample
import com.polar.sdk.api.model.PolarSkinTemperatureResult
import com.polar.sdk.api.model.SkinTemperatureMeasurementType
import com.polar.sdk.api.model.SkinTemperatureSensorLocation
import com.polar.sdk.api.model.activity.AutomaticSampleTriggerType
import com.polar.sdk.api.model.activity.Polar247HrSamples
import com.polar.sdk.api.model.activity.Polar247HrSamplesData
import com.polar.sdk.api.model.activity.PolarActiveTime
import com.polar.sdk.api.model.activity.PolarActiveTimeData
import com.polar.sdk.api.model.activity.PolarActivityGoalSummary
import com.polar.sdk.api.model.activity.PolarDailySummaryData
import com.polar.sdk.api.model.sleep.PolarNightlyRechargeData
import com.polar.sdk.api.model.sleep.PolarSleepAnalysisResult
import com.polar.sdk.api.model.sleep.PolarSleepData
import com.polar.sdk.api.model.sleep.SleepCycle
import com.polar.sdk.api.model.sleep.SleepRating
import com.polar.sdk.api.model.sleep.SleepSkinTemperatureResult
import com.polar.sdk.api.model.sleep.SleepWakePhase
import com.polar.sdk.api.model.sleep.SleepWakeState
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneOffset
import java.time.ZonedDateTime
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
// The Loop's days and nights as the app takes them (ADR 0030, D37), and what
// a read deletes on the band (D38): only what the app kept, never today.
class LoopDaysTest {
private val today = LocalDate.of(2026, 10, 28)
private val yesterday = today.minusDays(1)
private fun time(minutes: Int) = PolarActiveTime(minutes / 60, minutes % 60, 0, 0)
private val summary =
PolarDailySummaryData(
yesterday,
420,
120,
1500,
9876,
PolarActivityGoalSummary(100f, 87.5f, time(0), time(0), time(0)),
PolarActiveTimeData(
yesterday,
time(30),
time(450),
time(600),
time(180),
time(40),
time(20),
time(10),
time(5),
),
7012.5f,
null,
null,
)
private val heart =
Polar247HrSamplesData(
yesterday,
listOf(
Polar247HrSamples(LocalTime.of(0, 10), listOf(58, 57, 59), AutomaticSampleTriggerType.TRIGGER_TYPE_TIMED),
Polar247HrSamples(LocalTime.of(17, 30, 5), listOf(141), AutomaticSampleTriggerType.TRIGGER_TYPE_HIGH_ACTIVITY),
),
)
private val zone = ZoneOffset.ofHours(1)
private val sleep =
PolarSleepData(
today,
PolarSleepAnalysisResult(
ZonedDateTime.of(2026, 10, 27, 23, 15, 0, 0, zone),
ZonedDateTime.of(2026, 10, 28, 7, 5, 0, 0, zone),
null,
480,
listOf(SleepWakePhase(0, SleepWakeState.WAKE), SleepWakePhase(600, SleepWakeState.NONREM12)),
emptyList(),
null,
null,
null,
SleepRating.SLEPT_SOMEWHAT_WELL,
"A1",
false,
listOf(SleepCycle(600, 1.5f)),
today,
null,
SleepSkinTemperatureResult(today, 33.4f, -0.2f),
),
)
private val recharge =
PolarNightlyRechargeData(
LocalDateTime.of(2026, 10, 28, 7, 30),
null,
2.5f,
4,
1,
5,
null,
1050,
48,
3800,
1000,
60,
45,
8,
3700,
200,
null,
null,
null,
today,
)
private val skin =
PolarSkinTemperatureData(
today,
PolarSkinTemperatureResult(
"A1",
SkinTemperatureSensorLocation.values().first(),
SkinTemperatureMeasurementType.values().first(),
listOf(PolarSkinTemperatureDataSample(0, 33.1f), PolarSkinTemperatureDataSample(60000, 33.3f)),
),
)
private inner class FakeBand : LoopSource {
val calls = mutableListOf<String>()
override suspend fun startSync() {
calls += "start"
}
override suspend fun stopSync() {
calls += "stop"
}
override suspend fun summaries(from: LocalDate, to: LocalDate) = listOf(summary).also { calls += "summaries $from $to" }
override suspend fun heartRates(from: LocalDate, to: LocalDate) = listOf(heart).also { calls += "heart" }
override suspend fun sleeps(from: LocalDate, to: LocalDate) = listOf(sleep).also { calls += "sleep" }
override suspend fun recharges(from: LocalDate, to: LocalDate) = listOf(recharge).also { calls += "recharge" }
override suspend fun skinTemperatures(from: LocalDate, to: LocalDate) = listOf(skin).also { calls += "skin" }
override suspend fun delete(type: PolarBleApi.PolarStoredDataType, until: LocalDate) {
calls += "delete $type $until"
}
}
@Test
fun `a read says when it was and what it covers, framed by the sync calls`() {
val band = FakeBand()
val lines = runBlocking { LoopDays.read(band, yesterday, today, keptUntil = null) }
assertEquals(mapOf("kind" to "read", "on" to "2026-10-28", "from" to "2026-10-27", "to" to "2026-10-28"), lines.first())
assertEquals(listOf("summary", "heart", "sleep", "recharge", "skin"), lines.drop(1).map { it["kind"] })
assertEquals("start", band.calls.first())
assertEquals("summaries 2026-10-27 2026-10-28", band.calls[1])
assertEquals("stop", band.calls.last())
assertTrue("nothing kept, nothing deleted", band.calls.none { it.startsWith("delete") })
}
@Test
fun `the day's summary keeps its figures and its time in each class in seconds`() {
val line = LoopDays.summaryOf(summary)
assertEquals("2026-10-27", line["date"])
assertEquals(9876, line["steps"])
assertEquals(7012.5f, line["metres"])
assertEquals(420, line["activityCalories"])
assertEquals(87.5f, line["achieved"])
@Suppress("UNCHECKED_CAST")
val times = line["times"] as Map<String, Int?>
assertEquals(450 * 60, times["sleep"])
assertEquals(5 * 60, times["intermittentVigorous"])
}
@Test
fun `the day's heart rate keeps each measurement's start, kind and samples`() {
@Suppress("UNCHECKED_CAST")
val runs = LoopDays.heartOf(heart)["runs"] as List<Map<String, Any?>>
assertEquals(mapOf("at" to 600, "trigger" to "timed", "bpm" to listOf(58, 57, 59)), runs[0])
assertEquals(17 * 3600 + 30 * 60 + 5, runs[1]["at"])
assertEquals("high_activity", runs[1]["trigger"])
}
@Test
fun `a night counts for the day it ended, with its times, phases and skin`() {
val line = LoopDays.sleepOf(sleep)!!
assertEquals("2026-10-28", line["date"])
assertEquals("2026-10-27T23:15+01:00", line["start"])
assertEquals(listOf(listOf(0, "wake"), listOf(600, "nonrem12")), line["phases"])
assertEquals("somewhat_well", line["rating"])
assertEquals(33.4f, line["skinCelsius"])
}
@Test
fun `Nightly Recharge and skin temperature keep their figures`() {
val recharge = LoopDays.rechargeOf(recharge)!!
assertEquals("2026-10-28", recharge["date"])
assertEquals(4, recharge["recovery"])
assertEquals(48, recharge["rmssd"])
val skin = LoopDays.skinOf(skin)!!
assertEquals(listOf(listOf(0L, 33.1f), listOf(60000L, 33.3f)), skin["samples"])
}
@Test
fun `what the app kept is deleted after the read, type by type`() {
val band = FakeBand()
runBlocking { LoopDays.read(band, today, today, keptUntil = yesterday) }
val deleted = band.calls.filter { it.startsWith("delete") }
assertEquals(LoopDays.deletedTypes.map { "delete $it 2026-10-27" }, deleted)
assertTrue(band.calls.indexOf("skin") < band.calls.indexOf(deleted.first()))
}
@Test
fun `today is never deleted, whatever the app says it kept`() {
val band = FakeBand()
runBlocking { LoopDays.read(band, today, today, keptUntil = today) }
assertTrue(band.calls.filter { it.startsWith("delete") }.all { it.endsWith("2026-10-27") })
}
@Test
fun `the sync is stopped even when the band fails`() {
val band =
object : LoopSource by FakeBand() {
val calls = mutableListOf<String>()
override suspend fun stopSync() {
calls += "stop"
}
override suspend fun heartRates(from: LocalDate, to: LocalDate): List<Polar247HrSamplesData> =
throw IllegalStateException("gone")
}
runCatching { runBlocking { LoopDays.read(band, today, today, keptUntil = yesterday) } }
assertEquals(listOf("stop"), band.calls)
}
@Test
fun `lines are written as JSON`() {
assertEquals(
"""{"kind":"read","n":[1,2.5,null,true],"s":"a\"b"}""",
JsonText.of(mapOf("kind" to "read", "n" to listOf(1, 2.5, null, true), "s" to "a\"b")),
)
}
}