feat(train): recording a workout on Android (ADR 0030, D13, D16, D19, D21)

RecorderService, a foreground service of the types location and health
started from the open app, takes fixes from LocationManager's GPS and
steps from the step detector, pauses by itself (AutoPause, tested on the
JVM) and appends each sample to the person's recording file, flushed
every five seconds. MainActivity asks for location, notifications and,
for auto-pause on foot, physical activity, never background location
(D19), and bridges the service to the app.

The app follows the file's lines with RecordingBloc and shows the moving
time, the distance, the pace or speed and GPS's state; the notification
shows the time and the distance with Pause or Resume. Stop is held down;
a stopped recording is saved as a workout and its series, or discarded
after asking, and one cut off or left unsaved is offered as the app
opens. Home has the Record button where the device records.

Three choices of the implementation's are recorded under D21 for review:
rowing waits for 5f, auto-pause on foot without steps goes by GPS speed
(below 2 km/h, on above 3 km/h), and Stop's hint shows above the button.

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 13:24:56 +01:00
commit 7df1cb1740
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
7 changed files with 3360 additions and 27 deletions

View file

@ -47,3 +47,11 @@ kotlin {
flutter {
source = "../.."
}
dependencies {
// NotificationCompat and ServiceCompat for the recording's foreground service,
// the version Learn's build uses.
implementation("androidx.core:core:1.18.0")
// The recorder's pure Kotlin parts (auto-pause) tested on the JVM.
testImplementation("junit:junit:4.13.2")
}

View file

@ -2,6 +2,20 @@
<!-- 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"/>
<!-- Recording a workout (ADR 0030, D13, D19): GPS while the app records in
the foreground, never in the background; the step detector for
auto-pause on foot; the recording's notification; the processor kept
awake with the screen off. -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<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"/>
<uses-feature android:name="android.hardware.location.gps" android:required="false"/>
<uses-feature android:name="android.hardware.sensor.stepdetector" android:required="false"/>
<application
android:label="Halkyon Train"
android:name="${applicationName}"
@ -40,6 +54,10 @@
<data android:scheme="app.halkyon.train" />
</intent-filter>
</activity>
<service
android:name=".RecorderService"
android:exported="false"
android:foregroundServiceType="location|health" />
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data

View file

@ -0,0 +1,103 @@
package app.halkyon.train
// Auto-pause by each activity's best signal (ADR 0030, D16), decided in the
// service, which runs while the app may not: steps on foot, confirmed by GPS;
// GPS speed on a bike; GPS speed on foot where steps aren't allowed (D19).
// Times in milliseconds; each call returns true where it paused, false where
// it went on, null where nothing changed.
/** Decides when a recording pauses by itself, and when it goes on. */
class AutoPause(private val signal: Signal) {
enum class Signal {
/** On foot, by the step detector. */
STEPS,
/** On foot, by GPS speed: paused below 2 km/h, going on above 3 km/h. */
FOOT_SPEED,
/** By bike, by GPS speed: paused below 3 km/h, going on above 5 km/h. */
BIKE_SPEED,
}
/** Whether it paused by itself. */
var paused = false
private set
private var byHand = false
private var lastStep: Long? = null
private var slowSince: Long? = null
private var lastFixAt: Long? = null
private var lastFixFast = false
/** A step at [at]: two close together go on again (a lone one is a jostle). */
fun step(at: Long): Boolean? {
if (signal != Signal.STEPS || byHand) return null
val before = lastStep
lastStep = at
if (paused && before != null && at - before <= STEPS_TO_GO_ON_MS) {
paused = false
return false
}
return null
}
/** A fix at [at], moving at [metresPerSecond]. */
fun speed(at: Long, metresPerSecond: Double): Boolean? {
val kmh = metresPerSecond * 3.6
lastFixAt = at
if (signal == Signal.STEPS) {
// Faster than walking: moving, whatever the steps say (a phone in a
// bag counts fewer).
lastFixFast = kmh > 5
return null
}
if (byHand) return null
val (below, above) = if (signal == Signal.BIKE_SPEED) 3.0 to 5.0 else 2.0 to 3.0
if (paused) {
if (kmh > above) {
paused = false
slowSince = null
return false
}
return null
}
slowSince = if (kmh < below) slowSince ?: at else null
return null
}
/** Time passing, at [at]: where it pauses for having stood still long enough. */
fun tick(at: Long): Boolean? {
if (byHand || paused) return null
val still =
when (signal) {
Signal.STEPS -> {
// Starting counts as a step.
val last = lastStep ?: at.also { lastStep = it }
val fixFresh = lastFixAt?.let { at - it <= FRESH_FIX_MS } ?: false
at - last >= STILL_MS && !(lastFixFast && fixFresh)
}
else -> slowSince?.let { at - it >= STILL_MS } ?: false
}
if (still) {
paused = true
return true
}
return null
}
/** Paused, or gone on, by hand at [at]: left alone while paused, starting afresh after. */
fun byHand(paused: Boolean, at: Long = 0) {
byHand = paused
if (!paused) {
this.paused = false
lastStep = at
slowSince = null
}
}
private companion object {
const val STILL_MS = 5_000L
const val STEPS_TO_GO_ON_MS = 2_000L
const val FRESH_FIX_MS = 3_000L
}
}

View file

@ -1,12 +1,120 @@
package app.halkyon.train
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() {
/** The app's question for permissions, answered once Android has asked. */
private var asking: Pair<List<String>, MethodChannel.Result>? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
val messenger = flutterEngine.dartExecutor.binaryMessenger
// Secrets halkyon_app shows kept out of screenshots and recents.
ScreenGuard.attach(this, flutterEngine.dartExecutor.binaryMessenger)
ScreenGuard.attach(this, messenger)
// The recording's side of the system (RecorderService).
MethodChannel(messenger, "app.halkyon.train/recorder").setMethodCallHandler { call, result ->
when (call.method) {
"ask" -> ask(call.arguments<List<String>>() ?: emptyList(), result)
"start" -> {
RecorderService.start(
this,
path = call.argument<String>("path")!!,
activity = call.argument<String>("activity")!!,
autoPause = call.argument<String>("autoPause"),
notice = call.argument<String>("notice")!!,
)
result.success(null)
}
"pause" -> result.success(RecorderService.pause().let { null })
"resume" -> result.success(RecorderService.resume().let { null })
"stop" -> result.success(RecorderService.stop().let { null })
"show" -> result.success(RecorderService.show(call.arguments as String).let { null })
"isRecording" -> result.success(RecorderService.isRecording())
else -> result.notImplemented()
}
}
EventChannel(messenger, "app.halkyon.train/recorder/lines").setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
// The service writes on this thread too, so no line comes between
// the file read back and the ones after.
RecorderService.replay(events::success)
RecorderService.lines = { line -> runOnUiThread { events.success(line) } }
}
override fun onCancel(arguments: Any?) {
RecorderService.lines = null
}
},
)
}
override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
RecorderService.lines = null
super.cleanUpFlutterEngine(flutterEngine)
}
/**
* Asks for the permissions [wanted] names (location, notifications,
* activity) that aren't granted yet (ADR 0030, D19), and answers which are
* granted afterwards.
*/
private fun ask(wanted: List<String>, result: MethodChannel.Result) {
val permissions = wanted.flatMap(::permissionsOf)
val missing = permissions.filter { checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED }
if (missing.isEmpty() || asking != null) {
result.success(grantedOf(wanted))
return
}
asking = wanted to result
requestPermissions(missing.toTypedArray(), RECORDER_REQUEST)
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray,
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode != RECORDER_REQUEST) return
asking?.let { (wanted, result) ->
asking = null
result.success(grantedOf(wanted))
}
}
private fun grantedOf(wanted: List<String>): Map<String, Boolean> =
wanted.associateWith { name ->
permissionsOf(name).all { checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }
}
/** Android's permissions behind each of the app's names; none where the system grants it by itself. */
private fun permissionsOf(name: String): List<String> =
when (name) {
// Android 12 on asks for coarse with fine, and lets the person pick.
"location" -> listOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)
"notifications" ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
listOf(Manifest.permission.POST_NOTIFICATIONS)
} else {
emptyList()
}
"activity" ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
listOf(Manifest.permission.ACTIVITY_RECOGNITION)
} else {
emptyList()
}
else -> emptyList()
}
private companion object {
const val RECORDER_REQUEST = 1
}
}

View file

@ -0,0 +1,333 @@
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.Service
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.PowerManager
import android.os.SystemClock
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import java.io.File
import java.io.Writer
import kotlin.math.roundToLong
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
// 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
// what its notification shows (a notice, as JSON, in the app's language).
/** Records a workout into its file, in the foreground. */
class RecorderService : Service(), LocationListener, SensorEventListener {
companion object {
private const val ACTION_START = "app.halkyon.train.recorder.START"
private const val ACTION_PAUSE = "app.halkyon.train.recorder.PAUSE"
private const val ACTION_RESUME = "app.halkyon.train.recorder.RESUME"
private const val EXTRA_PATH = "path"
private const val EXTRA_ACTIVITY = "activity"
private const val EXTRA_AUTO_PAUSE = "autoPause"
private const val EXTRA_NOTICE = "notice"
private const val CHANNEL = "recording"
private const val NOTIFICATION_ID = 5301
private const val FLUSH_EVERY_MS = 5_000L
/** Hands each line written to the app; null while no app listens. */
@Volatile var lines: ((String) -> Unit)? = null
/** The service while it records. */
@Volatile private var running: RecorderService? = null
/** Whether a recording runs. */
fun isRecording(): Boolean = running != null
/**
* Starts recording [activity] into the file at [path], pausing by itself
* by [autoPause] (null: never), its notification as [notice] says.
*/
fun start(context: Context, path: String, activity: String, autoPause: String?, notice: String) {
ContextCompat.startForegroundService(
context,
Intent(context, RecorderService::class.java)
.setAction(ACTION_START)
.putExtra(EXTRA_PATH, path)
.putExtra(EXTRA_ACTIVITY, activity)
.putExtra(EXTRA_AUTO_PAUSE, autoPause)
.putExtra(EXTRA_NOTICE, notice),
)
}
/** Pauses, or goes on, by hand. */
fun pause() = running?.byHand(paused = true)
fun resume() = running?.byHand(paused = false)
/** Ends the recording: its last line written, the file closed, the service gone. */
fun stop() = running?.finish()
/** Shows [notice] (JSON) in the recording's notification. */
fun show(notice: String) = running?.present(JSONObject(notice))
/** Every line of the file so far, then each new one as it is written. */
fun replay(to: (String) -> Unit) {
running?.let { service ->
service.writer?.flush()
File(service.path).forEachLine(action = to)
}
}
}
private lateinit var path: String
private var writer: Writer? = null
private var autoPause: AutoPause? = null
private var pausedByHand = false
private var notice: JSONObject? = null
private var wakeLock: PowerManager.WakeLock? = null
private val handler = Handler(Looper.getMainLooper())
private var lastFlush = 0L
private val ticker =
object : Runnable {
override fun run() {
val now = System.currentTimeMillis()
autoPause?.tick(now)?.let { paused -> mark(paused, auto = true, at = now) }
if (now - lastFlush >= FLUSH_EVERY_MS) {
writer?.flush()
lastFlush = now
}
handler.postDelayed(this, 1_000)
}
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START -> if (running == null) begin(intent)
ACTION_PAUSE -> byHand(paused = true)
ACTION_RESUME -> byHand(paused = false)
}
return START_NOT_STICKY
}
override fun onDestroy() {
if (running === this) finish()
super.onDestroy()
}
@SuppressLint("MissingPermission") // Each listener is asked for only where its permission is granted.
private fun begin(intent: Intent) {
path = intent.getStringExtra(EXTRA_PATH) ?: return stopSelf()
val activity = intent.getStringExtra(EXTRA_ACTIVITY) ?: return stopSelf()
val located = granted(Manifest.permission.ACCESS_FINE_LOCATION)
val counted =
Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || granted(Manifest.permission.ACTIVITY_RECOGNITION)
notice = intent.getStringExtra(EXTRA_NOTICE)?.let(::JSONObject)
channel(notice?.optString("channelName"))
// In the foreground first: Android allows a few seconds for it.
ServiceCompat.startForeground(this, NOTIFICATION_ID, notification(), types(located, counted))
running = this
val now = System.currentTimeMillis()
writer = File(path).apply { parentFile?.mkdirs() }.bufferedWriter().also {
it.append("halkyon-train-recording 1 $activity $now\n")
}
autoPause =
when (intent.getStringExtra(EXTRA_AUTO_PAUSE)) {
// Steps where they may be counted, GPS speed where not (D19).
"steps" -> AutoPause(if (counted) AutoPause.Signal.STEPS else AutoPause.Signal.FOOT_SPEED)
"bike" -> AutoPause(AutoPause.Signal.BIKE_SPEED)
else -> null
}
if (located) {
getSystemService(LocationManager::class.java)
.requestLocationUpdates(LocationManager.GPS_PROVIDER, 1_000L, 0f, this, Looper.getMainLooper())
}
if (counted && autoPause != null) {
getSystemService(SensorManager::class.java).let { sensors ->
sensors.getDefaultSensor(Sensor.TYPE_STEP_DETECTOR)?.let {
sensors.registerListener(this, it, SensorManager.SENSOR_DELAY_NORMAL)
}
}
}
// The ticker and the file need the processor with the screen off, as a
// rower without GPS gives nothing else to wake it.
wakeLock =
getSystemService(PowerManager::class.java)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "HalkyonTrain:recording")
.apply { acquire(12 * 60 * 60 * 1_000L) }
lastFlush = now
handler.postDelayed(ticker, 1_000)
}
/** The foreground service's types: location with GPS, health with the step detector (Android 14 on asks). */
private fun types(located: Boolean, counted: Boolean): Int {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return 0
var types = 0
if (located) types = types or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
if (counted && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
types = types or ServiceInfo.FOREGROUND_SERVICE_TYPE_HEALTH
}
return types
}
override fun onLocationChanged(location: Location) {
val at = wallTime(location.elapsedRealtimeNanos)
val altitude = if (location.hasAltitude()) (location.altitude * 100).roundToLong().toString() else "-"
val accuracy = if (location.hasAccuracy()) (location.accuracy * 100).roundToLong() else 99_999L
write(
"p $at ${(location.latitude * 1e7).roundToLong()} ${(location.longitude * 1e7).roundToLong()} " +
"$accuracy $altitude",
)
if (location.hasSpeed() && !pausedByHand) {
autoPause?.speed(at, location.speed.toDouble())?.let { paused -> mark(paused, auto = true, at = at) }
}
}
override fun onSensorChanged(event: SensorEvent) {
if (event.sensor.type != Sensor.TYPE_STEP_DETECTOR) return
val at = wallTime(event.timestamp)
write("s $at")
autoPause?.step(at)?.let { paused -> mark(paused, auto = true, at = at) }
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
/** Paused, or gone on, by hand. */
private fun byHand(paused: Boolean) {
if (running !== this) return
val pausedNow = pausedByHand || autoPause?.paused == true
if (paused == pausedNow) return
val now = System.currentTimeMillis()
pausedByHand = paused
autoPause?.byHand(paused, now)
mark(paused, auto = false, at = now)
}
/** Writes a pause or its end, and shows it at once, the app there or not. */
private fun mark(paused: Boolean, auto: Boolean, at: Long) {
write("${if (paused) "pause" else "resume"} $at ${if (auto) "auto" else "manual"}")
writer?.flush()
repost()
}
private fun write(line: String) {
val writer = writer ?: return
writer.append(line).append('\n')
lines?.invoke(line)
}
/** Ends the recording, once. */
private fun finish() {
if (running !== this) return
running = null
handler.removeCallbacks(ticker)
getSystemService(LocationManager::class.java).removeUpdates(this)
getSystemService(SensorManager::class.java).unregisterListener(this)
write("end ${System.currentTimeMillis()}")
writer?.close()
writer = null
wakeLock?.takeIf { it.isHeld }?.release()
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf()
}
private fun present(json: JSONObject) {
notice = json
repost()
}
private fun repost() {
NotificationManagerCompat.from(this).let {
if (it.areNotificationsEnabled()) {
try {
it.notify(NOTIFICATION_ID, notification())
} catch (_: SecurityException) {
// Notifications not allowed: the recording runs on, unseen (D19).
}
}
}
}
private fun notification(): android.app.Notification {
val notice = notice
val paused = pausedByHand || autoPause?.paused == true
val builder =
NotificationCompat.Builder(this, CHANNEL)
.setSmallIcon(R.drawable.ic_stat_recording)
.setContentTitle(notice?.optString("title") ?: "Halkyon Train")
.setOngoing(true)
.setSilent(true)
.setOnlyAlertOnce(true)
.setCategory(NotificationCompat.CATEGORY_WORKOUT)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.setContentIntent(openApp())
notice?.optString("text")?.let(builder::setContentText)
// The moving time counts on by itself while it moves, the app there or not.
if (!paused && notice != null && notice.has("movingMs")) {
builder
.setUsesChronometer(true)
.setWhen(notice.getLong("shownAt") - notice.getLong("movingMs"))
.setShowWhen(true)
} else {
builder.setShowWhen(false)
}
if (paused) {
builder.addAction(0, notice?.optString("resume") ?: "Resume", button(ACTION_RESUME))
} else {
builder.addAction(0, notice?.optString("pause") ?: "Pause", button(ACTION_PAUSE))
}
return builder.build()
}
private fun button(action: String): PendingIntent =
PendingIntent.getService(
this,
action.hashCode(),
Intent(this, RecorderService::class.java).setAction(action),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
private fun openApp(): PendingIntent? =
packageManager.getLaunchIntentForPackage(packageName)?.let {
PendingIntent.getActivity(this, 0, it, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
}
/** The recording's channel, silent, named in the app's language. */
private fun channel(name: String?) {
getSystemService(NotificationManager::class.java)
.createNotificationChannel(
NotificationChannel(CHANNEL, name ?: "Recording", NotificationManager.IMPORTANCE_LOW)
.apply { setShowBadge(false) },
)
}
private fun granted(permission: String): Boolean =
ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
/** The wall-clock time, in UTC milliseconds, of an instant on the elapsed-realtime clock. */
private fun wallTime(elapsedNanos: Long): Long =
System.currentTimeMillis() - (SystemClock.elapsedRealtimeNanos() - elapsedNanos) / 1_000_000
}

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The recording's status bar icon: Lucide's bike, Train's own (ADR 0031),
white on nothing, as Android tints it. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#00000000"
android:pathData="M18.5,14A3.5,3.5 0,1 1,18.5 21A3.5,3.5 0,1 1,18.5 14ZM5.5,14A3.5,3.5 0,1 1,5.5 21A3.5,3.5 0,1 1,5.5 14ZM15,4A1,1 0,1 1,15 6A1,1 0,1 1,15 4ZM12,17.5V14l-3,-3 4,-3 2,3h2"
android:strokeWidth="2"
android:strokeColor="#FFFFFFFF"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
</vector>

View file

@ -0,0 +1,87 @@
package app.halkyon.train
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
// Auto-pause by each activity's best signal (ADR 0030, D16): steps on foot,
// GPS speed on a bike, and GPS speed on foot where steps aren't allowed (D19).
class AutoPauseTest {
/** Feeds [steps] (seconds) to a pause on foot, ticking each second up to [until], and returns its changes by second. */
private fun onFoot(steps: List<Int>, until: Int, fixes: Map<Int, Double> = emptyMap()): Map<Int, Boolean> {
val pause = AutoPause(AutoPause.Signal.STEPS)
val changes = mutableMapOf<Int, Boolean>()
for (second in 0..until) {
val at = second * 1000L
fixes[second]?.let { kmh -> pause.speed(at, kmh / 3.6)?.let { changes[second] = it } }
if (second in steps) pause.step(at)?.let { changes[second] = it }
pause.tick(at)?.let { changes[second] = it }
}
return changes
}
private fun byBike(kmh: List<Double>, signal: AutoPause.Signal = AutoPause.Signal.BIKE_SPEED): Map<Int, Boolean> {
val pause = AutoPause(signal)
val changes = mutableMapOf<Int, Boolean>()
kmh.forEachIndexed { second, speed ->
pause.speed(second * 1000L, speed / 3.6)?.let { changes[second] = it }
pause.tick(second * 1000L)?.let { changes[second] = it }
}
return changes
}
@Test
fun `on foot it pauses five seconds after the last step and goes on with two steps close together`() {
val changes = onFoot(steps = listOf(0, 1, 2, 3, 12, 20, 21, 22), until = 25)
// Paused at 8 (no step since 3); a lone step at 12 is a jostle, not a start.
assertEquals(mapOf(8 to true, 21 to false), changes)
}
@Test
fun `on foot GPS moving faster than walking keeps it going without steps`() {
val changes = onFoot(
steps = listOf(0, 1),
until = 12,
fixes = (0..12).associateWith { 12.0 },
)
assertEquals(emptyMap<Int, Boolean>(), changes)
}
@Test
fun `by bike it pauses below 3 kmh for five seconds and goes on above 5 kmh`() {
val changes = byBike(listOf(20.0, 18.0, 2.0, 1.0, 0.0, 0.0, 0.0, 0.0, 4.0, 4.5, 6.0, 15.0))
assertEquals(mapOf(7 to true, 10 to false), changes)
}
@Test
fun `by bike slowing down a moment pauses nothing`() {
val changes = byBike(listOf(20.0, 2.0, 2.0, 2.0, 8.0, 2.0, 2.0, 2.0, 2.0, 9.0))
assertEquals(emptyMap<Int, Boolean>(), changes)
}
@Test
fun `on foot without steps it pauses below 2 kmh and goes on above 3 kmh`() {
val changes = byBike(listOf(6.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 2.5, 3.5), AutoPause.Signal.FOOT_SPEED)
assertEquals(mapOf(6 to true, 8 to false), changes)
}
@Test
fun `a pause by hand is left alone, and going on by hand starts afresh`() {
val pause = AutoPause(AutoPause.Signal.STEPS)
pause.step(0)
pause.byHand(paused = true)
assertNull(pause.tick(10_000))
assertNull(pause.step(11_000))
assertNull(pause.step(11_500))
pause.byHand(paused = false, at = 12_000)
assertNull(pause.tick(16_000))
assertEquals(true, pause.tick(17_000))
}
}