fix(train): a recording followed whichever starts first, the app listening or the service

On a fresh install (GrapheneOS) a walk recorded no GPS on the screen,
which waited for GPS throughout though the file was whole. The app
listens to the recorder's lines as soon as it asked for the start, and
Android starts the service a moment later: listening first, it got
nothing of the file, and the service then wrote the header straight to
the file, past what hands each line on. The reader takes the first line
it gets as the header, found none, and dropped every line after.

The service now writes its header through write() as every line, and
the app takes the header from the file's first line where lines came
without one (RecordingReader.recover, on the next second), reading the
lines kept since after it, so either order and either recorder version
is followed. The fake recorder can now begin late, as the service does.

On foot, auto-pause by steps paused after 5 s and went on only with a
step where the phone has no step detector or one switched off
(GrapheneOS' Sensors): GPS speed decides there, and until the first
step and once the detector has been silent 30 s. A minute without any
fix, the recording screen asks whether location is on and precise.
ADR 0030, D65.

Whats-New: A recording follows its GPS again on a phone where it waited for GPS throughout.
Whats-New: Auto-pause on foot goes by GPS speed where the phone counts no steps.
Whats-New: After a minute without GPS, the recording screen asks whether location is on and precise.
Whats-New-De: Eine Aufzeichnung folgt ihrem GPS wieder auf Handys, auf denen sie die ganze Zeit auf GPS wartete.
Whats-New-De: Die automatische Pause zu Fuß richtet sich nach der GPS-Geschwindigkeit, wo das Handy keine Schritte zählt.
Whats-New-De: Nach einer Minute ohne GPS fragt die Aufzeichnung, ob der Standort an und genau ist.

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-04 20:38:07 +01:00
commit d305725bb5
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 252 additions and 21 deletions

View file

@ -4,7 +4,9 @@ package app.halkyon.train
// 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);
// the rower's strokes (D28); on a bike the wheel's speed while its sensor
// sends, GPS's once it has been silent 5 s (D51).
// sends, GPS's once it has been silent 5 s (D51). On foot by steps, GPS speed
// decides while the step detector hasn't sent yet or has been silent 30 s
// (D65): a detector switched off (GrapheneOS' Sensors) or missing sends none.
// Times in milliseconds; each call returns true where it paused, false where
// it went on, null where nothing changed.
@ -30,6 +32,9 @@ class AutoPause(private val signal: Signal) {
private var byHand = false
private var lastStep: Long? = null
/** The last step the detector counted, by hand or not. */
private var lastCounted: Long? = null
private var slowSince: Long? = null
private var lastFixAt: Long? = null
private var lastFixFast = false
@ -37,7 +42,10 @@ class AutoPause(private val signal: Signal) {
/** 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
if (signal != Signal.STEPS) return null
lastCounted = at
slowSince = null
if (byHand) return null
val before = lastStep
lastStep = at
if (paused && before != null && at - before <= STEPS_TO_GO_ON_MS) {
@ -66,7 +74,7 @@ class AutoPause(private val signal: Signal) {
// Faster than walking: moving, whatever the steps say (a phone in a
// bag counts fewer).
lastFixFast = kmh > 5
return null
return if (stepless(at)) decide(at, kmh) else null
}
// The wheel decides while it sends (D51).
if (signal == Signal.BIKE_SPEED && lastWheelAt?.let { at - it <= WHEEL_FRESH_MS } == true) return null
@ -100,13 +108,14 @@ class AutoPause(private val signal: Signal) {
fun tick(at: Long): Boolean? {
if (byHand || paused) return null
val still =
when (signal) {
Signal.STROKES -> {
when {
signal == Signal.STEPS && stepless(at) -> slowSince?.let { at - it >= STILL_MS } ?: false
signal == Signal.STROKES -> {
// Starting counts as a stroke.
val last = lastStep ?: at.also { lastStep = it }
at - last >= STILL_MS
}
Signal.STEPS -> {
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
@ -121,6 +130,9 @@ class AutoPause(private val signal: Signal) {
return null
}
/** Whether no step came yet, or none for [STEPLESS_MS]: then GPS speed decides on foot (D65). */
private fun stepless(at: Long): Boolean = lastCounted?.let { at - it >= STEPLESS_MS } ?: true
/** Paused, or gone on, by hand at [at]: left alone while paused, starting afresh after. */
fun byHand(paused: Boolean, at: Long = 0) {
byHand = paused
@ -136,5 +148,6 @@ class AutoPause(private val signal: Signal) {
const val STEPS_TO_GO_ON_MS = 2_000L
const val FRESH_FIX_MS = 3_000L
const val WHEEL_FRESH_MS = 5_000L
const val STEPLESS_MS = 30_000L
}
}

View file

@ -143,11 +143,16 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
/** 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. */
/**
* Every line of the file so far, then each new one as it is written.
* Called before the service has begun (the app listens as soon as it
* asked for the start, D65), it hands nothing: the header and every line
* after come through [lines], as [write] hands each one.
*/
fun replay(to: (String) -> Unit) {
running?.let { service ->
service.writer?.flush()
File(service.path).forEachLine(action = to)
runCatching { File(service.path).forEachLine(action = to) }
}
}
}
@ -284,15 +289,19 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
linked = listOf(strapAt, rowerAt, loopAt, speedSensorAt, powerMeterAt).any { it != null },
),
)
running = this
val now = System.currentTimeMillis()
writer = File(path).apply { parentFile?.mkdirs() }.bufferedWriter().also {
it.append("halkyon-train-recording 1 $activity $now\n")
}
writer = File(path).apply { parentFile?.mkdirs() }.bufferedWriter()
running = this
// Through write, as every line: an app that listened before the service
// began gets the header too, which it reads everything after by (D65).
write("halkyon-train-recording 1 $activity $now")
val detects =
getSystemService(SensorManager::class.java).getDefaultSensor(Sensor.TYPE_STEP_DETECTOR) != null
autoPause =
when (autoPauseBy) {
// Steps where they may be counted, GPS speed where not (D19).
"steps" -> AutoPause(if (counted) AutoPause.Signal.STEPS else AutoPause.Signal.FOOT_SPEED)
// Steps where they may be counted and the phone has a step detector,
// GPS speed where not (D19, D65).
"steps" -> AutoPause(if (counted && detects) AutoPause.Signal.STEPS else AutoPause.Signal.FOOT_SPEED)
"bike" -> AutoPause(AutoPause.Signal.BIKE_SPEED)
"strokes" -> AutoPause(AutoPause.Signal.STROKES)
else -> null

View file

@ -49,6 +49,24 @@ class AutoPauseTest {
assertEquals(emptyMap<Int, Boolean>(), changes)
}
@Test
fun `on foot a step detector that never sends leaves it to GPS speed from the start`() {
// GrapheneOS with Sensors off: steps allowed, none ever counted.
val walking = onFoot(steps = emptyList(), until = 40, fixes = (0..40).associateWith { 4.0 })
val standing = onFoot(steps = emptyList(), until = 10, fixes = (0..10).associateWith { 1.0 })
assertEquals(emptyMap<Int, Boolean>(), walking)
assertEquals(mapOf(5 to true), standing)
}
@Test
fun `on foot a step detector gone silent 30 s while GPS moves leaves it to GPS speed`() {
val changes = onFoot(steps = (0..10).toList(), until = 50, fixes = (0..50).associateWith { 4.0 })
// Paused 5 s after the last step, going on by GPS 30 s after it.
assertEquals(mapOf(15 to true, 40 to false), 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))