feat(train): reminders to move and workouts suggested from the steps (ADR 0037)

Settings' Moving more, on Android: a reminder to get up after an idle
time, and a suggested walk or run, each off until turned on (then every
30 minutes, weekdays 9:00 to 18:00, not 12:00 to 13:00, each value the
person's), turning on asking for the activity and notification
permissions.

A WorkManager job every 15 minutes reads the phone's step counter and
keeps two hours of readings. It reminds where fewer than 50 steps came
over the idle time, all of it within the hours and outside the break,
on the person's days, never while Train records or the phone is in a
car (Android's car mode, or a car's Bluetooth audio or hands-free). It
suggests a walk or a run where the last span averaged a walking or
running cadence, once an hour; tapped, the Record sheet for it opens.
The decisions are pure Kotlin (Movement.kt), tested on the JVM; the
notifications' words are the app's, written into the job's
configuration as the person's app opens.

Polar's SDK sets neither the band's inactivity alert nor tells of one
without an all-day connection, hence the phone's steps (ADR 0037).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-02 14:32:02 +01:00
commit e4585b478d
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 1882 additions and 30 deletions

View file

@ -36,12 +36,16 @@ class MainActivity : FlutterActivity() {
/** The recording's notification tapped: the recording's screen (ADR 0030, D21). */
private val recordingOpenings = RecordingOpenings()
/** A suggested workout's notification tapped: the Record sheet for it (ADR 0037, D2). */
private val movementOpenings = MovementOpenings()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
health.onIntent(intent)
// Not again where Android makes the activity anew with the same intent.
if (savedInstanceState == null) files.onIntent(intent)
recordingOpenings.onIntent(intent)
movementOpenings.onIntent(intent)
}
override fun onNewIntent(intent: Intent) {
@ -49,6 +53,7 @@ class MainActivity : FlutterActivity() {
health.onIntent(intent)
files.onIntent(intent)
recordingOpenings.onIntent(intent)
movementOpenings.onIntent(intent)
}
@Deprecated("FlutterActivity has no result registry")
@ -125,6 +130,15 @@ class MainActivity : FlutterActivity() {
EventChannel(messenger, "app.halkyon.train/files/opened").setStreamHandler(files.opened)
EventChannel(messenger, "app.halkyon.train/recorder/opened")
.setStreamHandler(recordingOpenings.opened)
// Reminders to move and suggested workouts (ADR 0037).
MethodChannel(messenger, "app.halkyon.train/movement").setMethodCallHandler { call, result ->
when (call.method) {
"available" -> result.success(MovementWorker.available(this))
"configure" -> result.success(MovementWorker.schedule(this, call.arguments<Map<String, Any?>>()).let { null })
else -> result.notImplemented()
}
}
EventChannel(messenger, "app.halkyon.train/movement/opened").setStreamHandler(movementOpenings.opened)
MethodChannel(messenger, "app.halkyon.train/music").setMethodCallHandler(music)
EventChannel(messenger, "app.halkyon.train/music/fetched").setStreamHandler(music.fetched)
EventChannel(messenger, "app.halkyon.train/recorder/lines").setStreamHandler(

View file

@ -0,0 +1,133 @@
package app.halkyon.train
import java.time.Instant
import java.time.ZoneId
/**
* What the person chose for reminders to move and suggested workouts (ADR
* 0037, D3): the times are minutes of the day, the days ISO's (1 Monday to
* 7 Sunday).
*/
data class MovementSettings(
val remind: Boolean,
val idleMinutes: Int,
val from: Int,
val until: Int,
val breakFrom: Int,
val breakUntil: Int,
val days: Set<Int>,
val suggest: Boolean,
)
/** The step counter's count at a moment (it counts up from the phone's start). */
data class StepReading(val atMs: Long, val steps: Long)
/** What the job keeps between its runs: the last readings, and when it last reminded and suggested. */
data class MovementState(
val readings: List<StepReading> = emptyList(),
val remindedMs: Long? = null,
val suggestedMs: Long? = null,
)
/** A nudge to the person: get up, or record what they seem to be doing. */
sealed interface MovementNudge {
data object Remind : MovementNudge
/** [activity] as the app names it: `walking` or `running`. */
data class Suggest(val activity: String) : MovementNudge
}
/**
* Whether to remind or suggest, from the step counter's readings (ADR 0037,
* D1, D2): pure, so it is tested on the JVM.
*/
object Movement {
/** Fewer steps than this over the idle time is idle. */
const val IDLE_STEPS = 50L
/** Steps a minute, averaged over the last span, for a walk and for a run. */
const val WALKING = 80.0
const val RUNNING = 140.0
/** The span a suggestion averages over: about one run of the job. */
private const val SPAN_MIN_MS = 10 * 60_000L
private const val SPAN_MAX_MS = 25 * 60_000L
/** At most one suggestion an hour. */
const val SUGGEST_GAP_MS = 60 * 60_000L
/** How long readings are kept. */
const val KEEP_MS = 2 * 60 * 60_000L
/** The job's runs drift: a reading this much short of the idle time still counts. */
private const val SLACK_MS = 2 * 60_000L
/**
* [state] with [reading] added, those older than [KEEP_MS] let go, and all
* before it where the counter went back (the phone started again).
*/
fun withReading(state: MovementState, reading: StepReading): MovementState {
val restarted = state.readings.lastOrNull()?.let { it.steps > reading.steps } == true
val kept = if (restarted) emptyList() else state.readings.filter { reading.atMs - it.atMs in 0..KEEP_MS }
return state.copy(readings = kept + reading)
}
/**
* The nudge for now, the newest of [state]'s readings being now's; null for
* none. A suggestion goes before a reminder.
*/
fun decide(
state: MovementState,
settings: MovementSettings,
zone: ZoneId,
recording: Boolean,
inCar: Boolean,
): MovementNudge? {
val now = state.readings.lastOrNull() ?: return null
if (recording || inCar) return null
return suggestion(state, settings, now) ?: reminder(state, settings, zone, now)
}
/** [state] once [nudge] was given at [atMs]. */
fun after(state: MovementState, nudge: MovementNudge, atMs: Long): MovementState =
when (nudge) {
MovementNudge.Remind -> state.copy(remindedMs = atMs)
is MovementNudge.Suggest -> state.copy(suggestedMs = atMs)
}
private fun suggestion(state: MovementState, settings: MovementSettings, now: StepReading): MovementNudge? {
if (!settings.suggest) return null
if (state.suggestedMs?.let { now.atMs - it < SUGGEST_GAP_MS } == true) return null
val before =
state.readings.lastOrNull { now.atMs - it.atMs in SPAN_MIN_MS..SPAN_MAX_MS } ?: return null
val perMinute = (now.steps - before.steps) / ((now.atMs - before.atMs) / 60_000.0)
return when {
perMinute >= RUNNING -> MovementNudge.Suggest("running")
perMinute >= WALKING -> MovementNudge.Suggest("walking")
else -> null
}
}
private fun reminder(
state: MovementState,
settings: MovementSettings,
zone: ZoneId,
now: StepReading,
): MovementNudge? {
if (!settings.remind) return null
val idleMs = settings.idleMinutes * 60_000L
if (state.remindedMs?.let { now.atMs - it < idleMs } == true) return null
val since = state.readings.lastOrNull { it.atMs <= now.atMs - idleMs + SLACK_MS } ?: return null
if (now.steps - since.steps >= IDLE_STEPS) return null
// All of the idle time within the person's hours, on their day, and none of it in their break.
val start = Instant.ofEpochMilli(since.atMs).atZone(zone)
val end = Instant.ofEpochMilli(now.atMs).atZone(zone)
if (start.toLocalDate() != end.toLocalDate()) return null
if (end.dayOfWeek.value !in settings.days) return null
val startMinute = start.hour * 60 + start.minute
val endMinute = end.hour * 60 + end.minute
if (startMinute < settings.from || endMinute > settings.until) return null
if (startMinute < settings.breakUntil && endMinute > settings.breakFrom) return null
return MovementNudge.Remind
}
}

View file

@ -0,0 +1,37 @@
package app.halkyon.train
import android.content.Intent
import io.flutter.plugin.common.EventChannel
/**
* A suggested workout's notification tapped (ADR 0037, D2): the app opens the
* Record sheet for its activity. The tap is handed to the app as it listens
* (now, or once it does, after a cold start).
*/
class MovementOpenings {
private var sink: EventChannel.EventSink? = null
private var waiting: String? = null
val opened =
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
sink = events
waiting?.let {
waiting = null
events.success(it)
}
}
override fun onCancel(arguments: Any?) {
sink = null
}
}
/** Takes in [intent]'s suggested activity, if it has one. */
fun onIntent(intent: Intent?) {
val activity = intent?.getStringExtra(MovementWorker.EXTRA_SUGGESTED) ?: return
// Asked once: the activity made anew with the same intent asks no more.
intent.removeExtra(MovementWorker.EXTRA_SUGGESTED)
sink?.success(activity) ?: run { waiting = activity }
}
}

View file

@ -0,0 +1,279 @@
package app.halkyon.train
import android.Manifest
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.UiModeManager
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothClass
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.content.Context
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import java.io.File
import java.time.ZoneId
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import org.json.JSONArray
import org.json.JSONObject
/**
* Every 15 minutes while reminders or suggestions are on (ADR 0037, D1,
* D2): reads the step counter, keeps the last readings, and reminds the
* person to move or suggests the workout they seem to be doing, as
* [Movement] decides, in the words the app wrote into its configuration (D4).
*/
class MovementWorker(context: Context, parameters: WorkerParameters) : CoroutineWorker(context, parameters) {
override suspend fun doWork(): Result {
val context = applicationContext
val config = configOf(context) ?: return Result.success()
val steps = stepCount(context) ?: return Result.success()
val stateFile = File(context.filesDir, STATE)
val now = System.currentTimeMillis()
var state = Movement.withReading(stateOf(stateFile), StepReading(now, steps))
val nudge =
Movement.decide(
state,
config.settings,
ZoneId.systemDefault(),
recording = RecorderService.isRecording(),
inCar = inCar(context),
)
if (nudge != null && notify(context, config.texts, nudge)) {
state = Movement.after(state, nudge, now)
}
stateFile.writeText(jsonOf(state).toString())
return Result.success()
}
/** The person's choices and the app's words for the notifications. */
private class Config(val settings: MovementSettings, val texts: JSONObject)
companion object {
private const val CONFIG = "movement.json"
private const val STATE = "movement_state.json"
private const val WORK = "movement"
private const val CHANNEL = "movement"
private const val REMINDER_ID = 4101
private const val SUGGESTION_ID = 4102
/** The extra of a suggestion's intent: the activity to open the Record sheet for. */
const val EXTRA_SUGGESTED = "app.halkyon.train.SUGGESTED_ACTIVITY"
/** Whether the phone counts steps at all. */
fun available(context: Context): Boolean =
context.getSystemService(SensorManager::class.java)?.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) != null
/**
* Runs every 15 minutes with [config] (`settings`, `texts`), or no
* longer where it is null or turns both off.
*/
fun schedule(context: Context, config: Map<String, Any?>?) {
val file = File(context.filesDir, CONFIG)
val work = WorkManager.getInstance(context)
@Suppress("UNCHECKED_CAST")
val settings = config?.get("settings") as Map<String, Any?>?
if (settings == null || (settings["remind"] != true && settings["suggest"] != true)) {
file.delete()
File(context.filesDir, STATE).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<MovementWorker>(15, TimeUnit.MINUTES).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())
val settings = json.getJSONObject("settings")
val days = settings.getJSONArray("days")
Config(
MovementSettings(
remind = settings.getBoolean("remind"),
idleMinutes = settings.getInt("idleMinutes"),
from = settings.getInt("from"),
until = settings.getInt("until"),
breakFrom = settings.getInt("breakFrom"),
breakUntil = settings.getInt("breakUntil"),
days = (0 until days.length()).map(days::getInt).toSet(),
suggest = settings.getBoolean("suggest"),
),
json.getJSONObject("texts"),
)
}.getOrNull()
}
private fun stateOf(file: File): MovementState =
runCatching {
val json = JSONObject(file.readText())
val readings = json.getJSONArray("readings")
MovementState(
readings =
(0 until readings.length()).map {
val reading = readings.getJSONArray(it)
StepReading(reading.getLong(0), reading.getLong(1))
},
remindedMs = json.optLong("remindedMs").takeIf { json.has("remindedMs") },
suggestedMs = json.optLong("suggestedMs").takeIf { json.has("suggestedMs") },
)
}.getOrDefault(MovementState())
private fun jsonOf(state: MovementState): JSONObject =
JSONObject().apply {
put("readings", JSONArray(state.readings.map { JSONArray(listOf(it.atMs, it.steps)) }))
state.remindedMs?.let { put("remindedMs", it) }
state.suggestedMs?.let { put("suggestedMs", it) }
}
/** The step counter's count now; null without the sensor, the permission or an answer within 10 s. */
private suspend fun stepCount(context: Context): Long? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && !granted(context, Manifest.permission.ACTIVITY_RECOGNITION)) {
return null
}
val manager = context.getSystemService(SensorManager::class.java) ?: return null
val sensor = manager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) ?: return null
return withTimeoutOrNull(TimeUnit.SECONDS.toMillis(10)) {
suspendCancellableCoroutine { continuation ->
val listener =
object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent) {
manager.unregisterListener(this)
if (continuation.isActive) continuation.resume(event.values[0].toLong())
}
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {}
}
manager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_NORMAL)
continuation.invokeOnCancellation { manager.unregisterListener(listener) }
}
}
}
/**
* Whether the phone is in a car (D1): Android's car mode, or a car's
* audio or hands-free connected over Bluetooth.
*/
private suspend fun inCar(context: Context): Boolean {
val ui = context.getSystemService(UiModeManager::class.java)
if (ui?.currentModeType == Configuration.UI_MODE_TYPE_CAR) return true
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !granted(context, Manifest.permission.BLUETOOTH_CONNECT)) {
return false
}
val adapter = context.getSystemService(BluetoothManager::class.java)?.adapter ?: return false
if (!adapter.isEnabled) return false
for (profile in listOf(BluetoothProfile.A2DP, BluetoothProfile.HEADSET)) {
val devices = withTimeoutOrNull(TimeUnit.SECONDS.toMillis(3)) { connected(context, adapter, profile) }
if (devices.orEmpty().any(::isCar)) return true
}
return false
}
@SuppressLint("MissingPermission") // Checked by inCar.
private fun isCar(device: BluetoothDevice): Boolean =
when (device.bluetoothClass?.deviceClass) {
BluetoothClass.Device.AUDIO_VIDEO_CAR_AUDIO,
BluetoothClass.Device.AUDIO_VIDEO_HANDSFREE -> true
else -> false
}
@SuppressLint("MissingPermission") // Checked by inCar.
private suspend fun connected(context: Context, adapter: BluetoothAdapter, profile: Int): List<BluetoothDevice> =
suspendCancellableCoroutine { continuation ->
val asked =
adapter.getProfileProxy(
context,
object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
val devices = runCatching { proxy.connectedDevices }.getOrDefault(emptyList())
adapter.closeProfileProxy(profile, proxy)
if (continuation.isActive) continuation.resume(devices)
}
override fun onServiceDisconnected(profile: Int) {}
},
profile,
)
if (!asked && continuation.isActive) continuation.resume(emptyList())
}
/** Shows [nudge]; whether it could (notifications allowed). */
@SuppressLint("MissingPermission") // areNotificationsEnabled is checked.
private fun notify(context: Context, texts: JSONObject, nudge: MovementNudge): Boolean {
val notifications = NotificationManagerCompat.from(context)
if (!notifications.areNotificationsEnabled()) return false
context
.getSystemService(NotificationManager::class.java)
.createNotificationChannel(
NotificationChannel(CHANNEL, texts.optString("channel", "Moving more"), NotificationManager.IMPORTANCE_DEFAULT),
)
val (id, title, text, activity) =
when (nudge) {
MovementNudge.Remind -> Quad(REMINDER_ID, texts.optString("remindTitle"), texts.optString("remindText"), null)
is MovementNudge.Suggest ->
Quad(
SUGGESTION_ID,
texts.optString("${nudge.activity}Title"),
texts.optString("suggestText"),
nudge.activity,
)
}
val open =
context.packageManager.getLaunchIntentForPackage(context.packageName)?.let { intent ->
if (activity != null) intent.putExtra(EXTRA_SUGGESTED, activity)
PendingIntent.getActivity(
context,
id,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
}
runCatching {
notifications.notify(
id,
NotificationCompat.Builder(context, CHANNEL)
.setSmallIcon(R.drawable.ic_stat_recording)
.setContentTitle(title)
.setContentText(text)
.setContentIntent(open)
.setAutoCancel(true)
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.build(),
)
}.onFailure { Log.i("Movement", "Not shown: ${it.javaClass.simpleName}") }
return true
}
private fun granted(context: Context, permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
}
private data class Quad(val id: Int, val title: String, val text: String, val activity: String?)
}

View file

@ -0,0 +1,119 @@
package app.halkyon.train
import java.time.LocalDateTime
import java.time.ZoneId
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/** Reminders to move and workouts suggested from the step counter (ADR 0037). */
class MovementTest {
private val zone = ZoneId.of("Europe/Berlin")
private val settings =
MovementSettings(
remind = true,
idleMinutes = 30,
from = 9 * 60,
until = 18 * 60,
breakFrom = 12 * 60,
breakUntil = 13 * 60,
days = setOf(1, 2, 3, 4, 5),
suggest = true,
)
/** Monday 2 Nov 2026 at [hour]:[minute], Berlin's time. */
private fun at(hour: Int, minute: Int = 0, day: Int = 2): Long =
LocalDateTime.of(2026, 11, day, hour, minute).atZone(zone).toInstant().toEpochMilli()
/** The state after readings of [steps] at each of [times]. */
private fun read(vararg readings: Pair<Long, Long>, state: MovementState = MovementState()): MovementState =
readings.fold(state) { kept, (time, steps) -> Movement.withReading(kept, StepReading(time, steps)) }
private fun decide(
state: MovementState,
settings: MovementSettings = this.settings,
recording: Boolean = false,
inCar: Boolean = false,
) = Movement.decide(state, settings, zone, recording, inCar)
@Test
fun `still for the idle time within the hours, the person is reminded`() {
val state = read(at(10) to 5000, at(10, 15) to 5010, at(10, 30) to 5020)
assertEquals(MovementNudge.Remind, decide(state))
}
@Test
fun `a reading a little short of the idle time still counts, as the job drifts`() {
assertEquals(MovementNudge.Remind, decide(read(at(10, 1) to 5000, at(10, 30) to 5010)))
}
@Test
fun `steps over the idle time, or too little known yet, remind nobody`() {
assertNull(decide(read(at(10) to 5000, at(10, 30) to 5100)))
assertNull(decide(read(at(10, 15) to 5000, at(10, 30) to 5000)))
assertNull(decide(read(at(10, 30) to 5000)))
}
@Test
fun `reminded once each idle time, not at every run`() {
val first = read(at(10) to 5000, at(10, 30) to 5000)
val reminded = Movement.after(first, MovementNudge.Remind, at(10, 30))
assertNull(decide(read(at(10, 45) to 5000, state = reminded)))
assertEquals(MovementNudge.Remind, decide(read(at(10, 45) to 5000, at(11) to 5000, state = reminded)))
}
@Test
fun `outside the hours, over the break, on another day or switched off, nobody is reminded`() {
assertNull(decide(read(at(8, 30) to 0, at(9) to 0)), "idle since before nine")
assertEquals(MovementNudge.Remind, decide(read(at(9) to 0, at(9, 30) to 0)))
assertNull(decide(read(at(17, 45) to 0, at(18, 15) to 0)), "past six")
assertNull(decide(read(at(11, 45) to 0, at(12, 15) to 0)), "into the break")
assertNull(decide(read(at(12, 45) to 0, at(13, 15) to 0)), "out of the break")
assertEquals(MovementNudge.Remind, decide(read(at(13) to 0, at(13, 30) to 0)))
// Saturday 7 Nov.
assertNull(decide(read(at(10, day = 7) to 0, at(10, 30, day = 7) to 0)))
assertNull(decide(read(at(10) to 0, at(10, 30) to 0), settings.copy(remind = false)))
}
@Test
fun `while recording or in a car, nothing is said`() {
val idle = read(at(10) to 0, at(10, 30) to 0)
assertNull(decide(idle, recording = true))
assertNull(decide(idle, inCar = true))
val walking = read(at(10) to 0, at(10, 15) to 1500)
assertNull(decide(walking, recording = true))
}
@Test
fun `a walking or running cadence over the last span suggests the workout, once an hour`() {
val walking = read(at(10) to 0, at(10, 15) to 1500)
assertEquals(MovementNudge.Suggest("walking"), decide(walking))
val running = read(at(10) to 0, at(10, 15) to 2400)
assertEquals(MovementNudge.Suggest("running"), decide(running))
assertNull(decide(read(at(10) to 0, at(10, 15) to 900)), "60 a minute: pottering about")
assertNull(decide(walking, settings.copy(suggest = false)))
val suggested = Movement.after(walking, MovementNudge.Suggest("walking"), at(10, 15))
assertNull(decide(read(at(10, 30) to 3000, state = suggested)))
assertEquals(
MovementNudge.Suggest("walking"),
decide(read(at(10, 30) to 3000, at(11, 15) to 4500, at(11, 30) to 6000, state = suggested)),
)
}
@Test
fun `a span too short or too long suggests nothing`() {
assertNull(decide(read(at(10) to 0, at(10, 5) to 600)))
assertNull(decide(read(at(10) to 0, at(10, 40) to 4000)))
}
@Test
fun `readings older than two hours, and those before the phone started again, are let go`() {
val kept = read(at(8) to 100, at(9) to 200, at(10, 1) to 300)
assertEquals(listOf(at(9), at(10, 1)), kept.readings.map { it.atMs })
val restarted = read(at(10) to 5000, at(10, 15) to 20)
assertEquals(listOf(StepReading(at(10, 15), 20)), restarted.readings)
}
private fun assertNull(actual: Any?, message: String) = org.junit.Assert.assertNull(message, actual)
}