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
18 changed files with 252 additions and 21 deletions

File diff suppressed because one or more lines are too long

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))

View file

@ -29,6 +29,7 @@
"recordingStepTapLap": "Bis du auf Runde tippst",
"recordLocationRefused": "Train braucht deinen Standort, um das aufzuzeichnen. Du kannst ihn in den Einstellungen des Systems erlauben.",
"recordingWaitingForGps": "Warte auf GPS",
"recordingGpsSilent": "Seit einer Minute kein GPS. Ist der Standort an und darf Train deinen genauen Standort verwenden?",
"recordingWaitingForGpsAccuracy": "Warte auf GPS · ±{metres} m",
"recordingGpsAccuracy": "GPS ±{metres} m",
"recordingMovingTime": "Zeit in Bewegung",

View file

@ -55,6 +55,10 @@
"recordingStepTapLap": "Until you tap Lap",
"recordLocationRefused": "Train needs your location to record this. You can allow it in the system's settings.",
"recordingWaitingForGps": "Waiting for GPS",
"recordingGpsSilent": "No GPS for a minute. Is location on, and is Train allowed your precise location?",
"@recordingGpsSilent": {
"description": "No fix at all for a minute while recording: location off, or allowed only approximately."
},
"recordingWaitingForGpsAccuracy": "Waiting for GPS · ±{metres} m",
"@recordingWaitingForGpsAccuracy": {
"description": "Before a fix precise enough: how far off the last one may be.",

View file

@ -274,6 +274,12 @@ abstract class AppLocalizations {
/// **'Waiting for GPS'**
String get recordingWaitingForGps;
/// No fix at all for a minute while recording: location off, or allowed only approximately.
///
/// In en, this message translates to:
/// **'No GPS for a minute. Is location on, and is Train allowed your precise location?'**
String get recordingGpsSilent;
/// Before a fix precise enough: how far off the last one may be.
///
/// In en, this message translates to:

View file

@ -105,6 +105,10 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get recordingWaitingForGps => 'Warte auf GPS';
@override
String get recordingGpsSilent =>
'Seit einer Minute kein GPS. Ist der Standort an und darf Train deinen genauen Standort verwenden?';
@override
String recordingWaitingForGpsAccuracy(int metres) {
return 'Warte auf GPS · ±$metres m';

View file

@ -104,6 +104,10 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get recordingWaitingForGps => 'Waiting for GPS';
@override
String get recordingGpsSilent =>
'No GPS for a minute. Is location on, and is Train allowed your precise location?';
@override
String recordingWaitingForGpsAccuracy(int metres) {
return 'Waiting for GPS · ±$metres m';

View file

@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
@ -113,7 +114,10 @@ class RecordingBloc(
_refresh(emit);
}
});
on<RecordingEventTicked>((event, emit) => _refresh(emit));
on<RecordingEventTicked>((event, emit) {
_recoverHeader();
_refresh(emit);
});
}
final RecorderNotice Function(LiveRecording live) _notice = notice;
@ -390,6 +394,34 @@ class RecordingBloc(
}
}
/// Takes the header from the file where the lines came without it: the
/// recorder began after the app listened, and wrote its header before
/// handing lines on (D65). The file has it once the recorder flushed, within
/// seconds.
void _recoverHeader() {
if (!_lines.headless || !_file.existsSync()) {
return;
}
final RandomAccessFile opened;
try {
opened = _file.openSync();
} on FileSystemException {
return;
}
try {
// The header is far shorter: its magic, a version, an activity, a time.
final start = utf8.decode(opened.readSync(256), allowMalformed: true);
final end = start.indexOf('\n');
if (end >= 0) {
_lines.recover(start.substring(0, end));
}
} on FileSystemException {
return;
} finally {
opened.closeSync();
}
}
/// What of a notice is worth telling: the time counts on by itself.
static (String, String, bool) _keyOf(RecorderNotice notice) =>
(notice.title, notice.text, notice.moving == null);

View file

@ -12,6 +12,11 @@ bool usesGps(Activity activity) => activity != Activity.indoorRowing;
/// strap and a rower send about once a second.
const freshHeartRate = Duration(seconds: 5);
/// How long without any fix before the screen asks whether location is on
/// and precise (D65): a phone finds its first fix outdoors within seconds to
/// half a minute.
const gpsSilentAfter = Duration(minutes: 1);
/// The split per 500 m over the rower's last [window] up to [now], from its
/// metres; null without metres enough.
Duration? _splitOf(
@ -101,6 +106,9 @@ LiveRecording liveOf(Recording recording, DateTime now) {
},
waitingForGps:
gps && !recording.fixes.any((fix) => fix.accuracyCm <= maxAccuracyCm),
gpsSilent:
gps &&
now.difference(lastFix?.time ?? recording.startedAt) >= gpsSilentAfter,
paused: paused,
autoPaused: paused && last!.auto,
strap: strap,

View file

@ -67,6 +67,11 @@ final class RecordingReader() {
/// Whether its first line was read.
var _started = false;
/// The lines taken in while the header is missing, kept for [recover]:
/// an app listening before the recorder began may have been handed none
/// (ADR 0030, D65).
final _headless = <String>[];
final _fixes = <Fix>[];
final _heartRates = <HeartRate>[];
final _steps = <DateTime>[];
@ -94,9 +99,13 @@ final class RecordingReader() {
if (!_started) {
_started = true;
_header = _headerOf(line);
if (_header == null) {
_headless.add(line);
}
return;
}
if (_header == null) {
_headless.add(line);
return;
}
final fields = line.split(' ');
@ -224,6 +233,32 @@ final class RecordingReader() {
}
}
/// Whether lines came but no header before them, which [recover] may
/// take from the file.
bool get headless => _header == null && _headless.isNotEmpty;
/// Takes [header], the file's first line, where the lines came without it
/// (D65), and reads the lines kept since as following it; nothing where it
/// has its header, or [header] is none it reads.
void recover(String header) {
if (_header != null) {
return;
}
final read = _headerOf(header);
if (read == null) {
return;
}
_header = read;
final kept = [..._headless];
_headless.clear();
for (final line in kept) {
// A header it couldn't read came first; the file's own is the one.
if (!line.startsWith(recordingFileMagic)) {
add(line);
}
}
}
/// The recording read so far: null where the header is missing, of a
/// newer version or names an activity this app doesn't know.
Recording? get recording {

View file

@ -90,6 +90,10 @@ final class const LiveRecording({
/// Whether it waits for a fix precise enough (D20) to count distance.
final bool waitingForGps = false,
/// Whether no fix at all came for a minute, from Start or since the last
/// (D65): location off, or allowed only approximately.
final bool gpsSilent = false,
/// Whether it is paused now, and whether Train paused it by itself.
final bool paused = false,
final bool autoPaused = false,
@ -138,6 +142,7 @@ final class const LiveRecording({
kmh,
accuracyMetres,
waitingForGps,
gpsSilent,
paused,
autoPaused,
strap,

View file

@ -192,6 +192,7 @@ class const _Figures({required final LiveRecording live})
LiveRecording(strap: LinkState.lost, :final loop)
when loop != LinkState.connected =>
(l10n.recordingStrapLost, true),
LiveRecording(gpsSilent: true) => (l10n.recordingGpsSilent, true),
_ => (gps, false),
};
// Scrolled where a plan's and a route's panels leave too little room.

View file

@ -36,6 +36,24 @@ void main() {
expect(live.pace, isNull);
});
test('a minute without any fix, from Start or since the last, says GPS is '
'silent (D65); a row never', () {
Recording run(List<Fix> fixes) =>
Recording(activity: Activity.walking, startedAt: start, fixes: fixes);
expect(liveOf(run([]), at(59)).gpsSilent, isFalse);
expect(liveOf(run([]), at(60)).gpsSilent, isTrue);
expect(liveOf(run([north(30, 0)]), at(89)).gpsSilent, isFalse);
expect(liveOf(run([north(30, 0)]), at(90)).gpsSilent, isTrue);
expect(
liveOf(
Recording(activity: Activity.indoorRowing, startedAt: start),
at(600),
).gpsSilent,
isFalse,
);
});
test('moving, it shows the distance and the pace of the last 30 seconds', () {
final live = liveOf(
Recording(

View file

@ -202,6 +202,38 @@ void main() {
expect(platform.started, hasLength(1));
});
test('a service that began after the app listened, its header written to '
'the file alone, is followed all the same (D65)', () async {
platform.beginsLate = true;
var now = start;
await withClock(Clock(() => now), () async {
final bloc = await open();
bloc.add(const RecordingEventStartRequested(Activity.walking));
await bloc.stream.firstWhere((s) => s.phase == RecordingPhase.recording);
await pumpEventQueue();
platform
..begin()
..now = start.add(const Duration(seconds: 1))
..write('p @ 0 0 500 -')
..now = start.add(const Duration(seconds: 30))
..write('p @ ${(100 * 89.932).round()} 0 500 -');
now = start.add(const Duration(seconds: 30));
final live = (await tick(bloc)).live!;
expect(live.distanceMetres, 100);
expect(live.movingTime, const Duration(seconds: 30));
expect(platform.notices.last.text, '100 m');
// What comes after is taken in once, as it comes.
platform
..now = start.add(const Duration(seconds: 60))
..write('p @ ${(200 * 89.932).round()} 0 500 -');
now = start.add(const Duration(seconds: 60));
expect((await tick(bloc)).live!.distanceMetres, 200);
});
});
test('it follows the service: distance, an automatic pause, a pause by '
'hand, and shows each change in the notification', () async {
var now = start;

View file

@ -59,8 +59,14 @@ class FakeRecorderPlatform({
/// Pauses and resumes by hand, in order.
final byHand = <bool>[];
/// Whether the service begins only when told ([begin]), after the app
/// listened, and then writes its header to the file alone, unstreamed:
/// what Train up to 0.3.0 did on a fresh install (ADR 0030, D65).
bool beginsLate = false;
File? _file;
StreamController<String>? _lines;
String? _header;
/// The time the next line is written at.
DateTime now = DateTime.utc(2026, 10, 28, 7, 30);
@ -114,13 +120,18 @@ class FakeRecorderPlatform({
wheelMm: wheelMm,
));
notices.add(notice);
_file = File(path)
..writeAsStringSync(
_header =
'halkyon-train-recording 1 ${activity.name} '
'${now.millisecondsSinceEpoch}\n',
);
'${now.millisecondsSinceEpoch}';
_file = File(path);
if (!beginsLate) {
begin();
}
}
/// The service begins: its header written to the file, not to the lines.
void begin() => _file!.writeAsStringSync('$_header\n');
@override
Future<void> pause() async {
byHand.add(true);
@ -180,7 +191,10 @@ class FakeRecorderPlatform({
@override
Stream<String> lines() {
final controller = _lines = StreamController<String>();
_file!.readAsLinesSync().forEach(controller.add);
final file = _file!;
if (file.existsSync()) {
file.readAsLinesSync().forEach(controller.add);
}
return controller.stream;
}

View file

@ -1686,6 +1686,33 @@ Strava. What was found, 27 Oct:
The other figures stay metric; a units setting for all of them would come on
its own. A profile saved on Train 0.2.0, which doesn't know the field, loses
the unit, and the region's applies again until it is picked anew.
- **D65 The developer's feedback of 4 Nov 2026** (the developer's; the details the
agent's on that feedback):
- **A walk without GPS, found on a fresh install** (GrapheneOS): the app listens to
the recorder's lines as soon as it asked for the start, while Android starts the
service a moment later. Listening first, the app was handed nothing of the file
(the service didn't run yet), and the service then wrote the file's header
straight to the file, past what hands each line on; the app's reader takes the
first line it gets as the header, found none, and read nothing after: the
screen waited for GPS the whole walk, though the file was whole. The header now
goes through the service's `write` as every line, so a listener of either order
gets it, and the app, where lines came without a header, takes it from the
file's first line (`RecordingReader.recover`, at the next second), the lines
kept since read after it, so a recorder of either version is followed. Re-reading
the whole file instead would lose the lines handed on but not yet flushed (up to
5 s).
- **Auto-pause on foot without steps**: with activity recognition allowed but no
step detector (or one switched off, as GrapheneOS' *Sensors* permission does), the
pause by steps paused after 5 s and only a step went on. GPS speed (D16's 2 and
3 km/h on foot) now decides where the phone has no step detector, and, where it
has one, until its first step and once it has been silent 30 s; steps decide
again with the next one. Alternatives: asking the person to switch auto-pause off
(they don't know why it paused), or GPS speed alone on foot (a phone in a pocket
at a red light drifts, steps say better).
- **A minute without GPS says so**: no fix at all for a minute, from Start or since
the last, and the recording screen asks, in the warning's colour, whether location
is on and Train allowed the precise one (Android's approximate location gives no
GPS fixes).
## Consequences