feat(train): a recording's file read and saved as a workout (ADR 0030, D18, D20)

The recorder's file is text, one sample a line (D18): positions with
their accuracy, heart rates, steps, pauses and the end. Reading it drops
a last line cut short and skips any line it can't read; a file without
its end is a recording cut off, which ends at its last sample.

A recording becomes its workout and series: every fix kept with its
accuracy (D20), the distance through those within 20 m and never across
a pause, the moving time without the pauses (D16), each kind of sample
in chunks of ten minutes (D14). D18 to D20 are recorded in ADR 0030.

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 12:31:09 +01:00
commit aab25bf932
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
6 changed files with 658 additions and 2 deletions

View file

@ -0,0 +1,129 @@
import 'dart:math';
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/series_chunk.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// The least accurate fix that counts for distance (ADR 0030, D20), as
/// OpenTracks' default; adjustable once Train has settings.
const maxAccuracyCm = 2000;
/// [recording] as the workout it saves (D14) and its series: every sample
/// kept (D16, D20), the distance through the fixes precise enough and never
/// across a pause, the moving time without the pauses. One cut off ends at
/// its last sample (D18).
(WorkoutContent, List<SeriesChunk>) recordedWorkout(Recording recording) {
final endedAt = recording.endedAt ?? recording.lastSampleAt;
return (
WorkoutContent(
activity: recording.activity,
startedAt: recording.startedAt,
endedAt: endedAt,
distanceMetres: _distance(recording),
movingTime: _movingTime(recording, endedAt),
),
_series(recording),
);
}
/// The track's length in metres, null without fixes: from fix to fix while
/// it moves, the imprecise ones left out, starting afresh after each pause.
int? _distance(Recording recording) {
if (recording.fixes.isEmpty) {
return null;
}
final pauses = recording.pauses;
var next = 0;
var paused = false;
Fix? last;
var metres = 0.0;
for (final fix in recording.fixes) {
while (next < pauses.length && !pauses[next].time.isAfter(fix.time)) {
paused = pauses[next].paused;
next++;
last = null;
}
if (paused || fix.accuracyCm > maxAccuracyCm) {
continue;
}
if (last != null) {
metres += _metresBetween(last, fix);
}
last = fix;
}
return metres.round();
}
/// How long it moved: from start to [endedAt], each pause's time left out, one
/// still open at the end running to it.
Duration _movingTime(Recording recording, DateTime endedAt) {
var moving = endedAt.difference(recording.startedAt);
DateTime? pausedAt;
for (final mark in recording.pauses) {
if (mark.paused) {
pausedAt ??= mark.time;
} else if (pausedAt != null) {
moving -= mark.time.difference(pausedAt);
pausedAt = null;
}
}
if (pausedAt != null) {
moving -= endedAt.difference(pausedAt);
}
return moving;
}
/// The great-circle distance between two fixes (haversine), on the mean
/// earth radius.
double _metresBetween(Fix a, Fix b) {
const radius = 6371008.8;
const perUnit = pi / 180 / 1e7;
final (lat1, lat2) = (a.latE7 * perUnit, b.latE7 * perUnit);
final dLat = lat2 - lat1;
final dLon = (b.lonE7 - a.lonE7) * perUnit;
final h =
pow(sin(dLat / 2), 2) + cos(lat1) * cos(lat2) * pow(sin(dLon / 2), 2);
return 2 * radius * asin(sqrt(h));
}
/// Every sample as its series' chunks, a kind without samples left out.
List<SeriesChunk> _series(Recording recording) {
final start = recording.startedAt;
List<int> times(Iterable<DateTime> of) => [
for (final time in of) time.difference(start).inMilliseconds,
];
int flag(bool value) => value ? 1 : 0;
final fixes = recording.fixes;
final rates = recording.heartRates;
final pauses = recording.pauses;
return [
...chunksOf(
SeriesKind.position,
start: start,
times: times(fixes.map((fix) => fix.time)),
columns: {
'lat': [for (final fix in fixes) fix.latE7],
'lon': [for (final fix in fixes) fix.lonE7],
'alt': [for (final fix in fixes) fix.altitudeCm],
'acc': [for (final fix in fixes) fix.accuracyCm],
},
),
...chunksOf(
SeriesKind.heartRate,
start: start,
times: times(rates.map((rate) => rate.time)),
columns: {
'bpm': [for (final rate in rates) rate.bpm],
},
),
...chunksOf(
SeriesKind.pauses,
start: start,
times: times(pauses.map((mark) => mark.time)),
columns: {
'paused': [for (final mark in pauses) flag(mark.paused)],
'auto': [for (final mark in pauses) flag(mark.auto)],
},
),
];
}

View file

@ -0,0 +1,110 @@
import 'dart:developer';
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// The version of the recording file this app writes and reads.
const recordingFileVersion = 1;
/// The recording file's first word, which says what it is.
const recordingFileMagic = 'halkyon-train-recording';
/// The recording [text] holds (ADR 0030, D18), as the recorder appends it, a
/// line each, its fields apart by spaces, times in UTC milliseconds:
///
/// ```text
/// halkyon-train-recording 1 <activity> <started>
/// p <time> <lat e7> <lon e7> <accuracy cm> <altitude cm or ->
/// h <time> <bpm>
/// s <time>
/// pause <time> auto|manual
/// resume <time> auto|manual
/// end <time>
/// ```
///
/// A last line without its line break was cut short as it was written, and
/// is dropped; a line it can't read is skipped. Null where the header is
/// missing, of a newer version or names an activity this app doesn't know.
Recording? readRecording(String text) {
// What follows the last line break is a line cut short, or nothing.
final lines = text.split('\n')..removeLast();
if (lines.isEmpty) {
return null;
}
final header = lines.first.split(' ');
if (header case [
recordingFileMagic,
final version,
final activityName,
final started,
]) {
final activity = Activity.values.asNameMap()[activityName];
final startedAt = _time(started);
if (int.tryParse(version) != recordingFileVersion ||
activity == null ||
startedAt == null) {
log('A recording not read: $version, $activityName', name: 'Recording');
return null;
}
final fixes = <Fix>[];
final heartRates = <HeartRate>[];
final steps = <DateTime>[];
final pauses = <PauseMark>[];
DateTime? endedAt;
for (final line in lines.skip(1)) {
final fields = line.split(' ');
final time = fields.length > 1 ? _time(fields[1]) : null;
if (time == null) {
continue;
}
switch (fields) {
case ['p', _, final lat, final lon, final accuracy, final altitude]:
final (latE7, lonE7, accuracyCm) = (
int.tryParse(lat),
int.tryParse(lon),
int.tryParse(accuracy),
);
if (latE7 != null && lonE7 != null && accuracyCm != null) {
fixes.add(
Fix(
time: time,
latE7: latE7,
lonE7: lonE7,
accuracyCm: accuracyCm,
altitudeCm: int.tryParse(altitude),
),
);
}
case ['h', _, final bpm]:
if (int.tryParse(bpm) case final bpm?) {
heartRates.add(HeartRate(time: time, bpm: bpm));
}
case ['s', _]:
steps.add(time);
case [final mark, _, final by]
when (mark == 'pause' || mark == 'resume') &&
(by == 'auto' || by == 'manual'):
pauses.add(
PauseMark(time: time, paused: mark == 'pause', auto: by == 'auto'),
);
case ['end', _]:
endedAt = time;
}
}
return Recording(
activity: activity,
startedAt: startedAt,
fixes: fixes,
heartRates: heartRates,
steps: steps,
pauses: pauses,
endedAt: endedAt,
);
}
return null;
}
DateTime? _time(String field) => switch (int.tryParse(field)) {
final ms? => DateTime.fromMillisecondsSinceEpoch(ms, isUtc: true),
null => null,
};

View file

@ -0,0 +1,86 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// A position the phone delivered (ADR 0030, D20: every one, with its
/// accuracy), in the units of the position series.
final class const Fix({
required final DateTime time,
/// Where, in 1e-7 degrees.
required final int latE7,
required final int lonE7,
/// How far off it may be, in centimetres, as the phone reported it.
required final int accuracyCm,
/// How high, in centimetres; null where the fix had none.
final int? altitudeCm,
}) with Equatable {
@override
List<Object?> get props => [time, latE7, lonE7, accuracyCm, altitudeCm];
}
/// The heart's rate at [time].
final class const HeartRate({
required final DateTime time,
required final int bpm,
}) with Equatable {
@override
List<Object?> get props => [time, bpm];
}
/// Paused, or going on again (D16), by the person or by Train itself.
final class const PauseMark({
required final DateTime time,
/// Whether it paused here; false: it went on.
final bool paused = true,
/// Whether Train did it by itself (auto-pause).
final bool auto = false,
}) with Equatable {
@override
List<Object?> get props => [time, paused, auto];
}
/// A recording as its file holds it (D18): what the recorder took, in the
/// order it took it, finished or cut off.
final class const Recording({
required final Activity activity,
/// When it started, in UTC.
required final DateTime startedAt,
final List<Fix> fixes = const [],
final List<HeartRate> heartRates = const [],
/// Each step the step detector counted (D16).
final List<DateTime> steps = const [],
final List<PauseMark> pauses = const [],
/// When the person stopped it; null for one cut off (D18).
final DateTime? endedAt,
}) with Equatable {
/// Whether the person stopped it, rather than it being cut off.
bool get finished => endedAt != null;
/// When its last sample was taken, or it started where it has none: where
/// an unfinished one is saved up to (D18).
DateTime get lastSampleAt => [
startedAt,
for (final fix in fixes) fix.time,
for (final rate in heartRates) rate.time,
...steps,
for (final mark in pauses) mark.time,
].reduce((a, b) => b.isAfter(a) ? b : a);
@override
List<Object?> get props => [
activity,
startedAt,
fixes,
heartRates,
steps,
pauses,
endedAt,
];
}

View file

@ -0,0 +1,177 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/series_chunk.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// A recording saved as a workout (ADR 0030, D14, D16, D18, D20): its
/// distance from the fixes precise enough, its moving time without its
/// pauses, and every sample in its series.
void main() {
final start = DateTime.utc(2026, 10, 28, 7, 30);
DateTime at(int seconds) => start.add(Duration(seconds: seconds));
/// A fix [metres] north of the equator's crossing with Greenwich, 1e-7
/// degrees of latitude being 1.112 cm there.
Fix north(int seconds, int metres, {int accuracyCm = 500}) => Fix(
time: at(seconds),
latE7: (metres * 89.932).round(),
lonE7: 0,
accuracyCm: accuracyCm,
);
Recording run({
List<Fix> fixes = const [],
List<PauseMark> pauses = const [],
List<HeartRate> heartRates = const [],
DateTime? endedAt,
Activity activity = Activity.running,
}) => Recording(
activity: activity,
startedAt: start,
fixes: fixes,
heartRates: heartRates,
pauses: pauses,
endedAt: endedAt,
);
test('its distance is the track through its fixes', () {
final (content, _) = recordedWorkout(
run(
fixes: [north(0, 0), north(30, 100), north(60, 200)],
endedAt: at(60),
),
);
expect(content.activity, Activity.running);
expect(content.startedAt, start);
expect(content.endedAt, at(60));
expect(content.distanceMetres, 200);
expect(content.movingTime, const Duration(seconds: 60));
});
test('a fix less accurate than 20 m counts for no distance, though it is '
'kept', () {
final imprecise = Fix(
time: at(30),
latE7: (100 * 89.932).round(),
lonE7: 5000,
accuracyCm: 2001,
);
final (content, series) = recordedWorkout(
run(
fixes: [north(0, 0), imprecise, north(60, 200, accuracyCm: 2000)],
endedAt: at(60),
),
);
expect(content.distanceMetres, 200);
final positions = series.singleWhere((c) => c.kind == SeriesKind.position);
expect(positions.columns['acc'], [500, 2001, 2000]);
});
test('no distance bridges a pause, and its time is not moving time', () {
final (content, _) = recordedWorkout(
run(
fixes: [
north(0, 0),
north(10, 100),
// Carried on a tram while paused.
north(30, 900),
north(40, 1200),
north(60, 1500),
north(70, 1600),
],
pauses: [
PauseMark(time: at(15), auto: true),
PauseMark(time: at(55), paused: false, auto: true),
],
endedAt: at(70),
),
);
expect(content.distanceMetres, 200);
expect(content.movingTime, const Duration(seconds: 30));
});
test('a recording cut off ends at its last sample, a pause still open '
'running to its end', () {
final (content, _) = recordedWorkout(
run(
fixes: [north(0, 0), north(20, 100)],
pauses: [PauseMark(time: at(30))],
heartRates: [HeartRate(time: at(50), bpm: 101)],
),
);
expect(content.endedAt, at(50));
expect(content.movingTime, const Duration(seconds: 30));
expect(content.distanceMetres, 100);
});
test('a workout without fixes has no distance', () {
final (content, series) = recordedWorkout(
run(activity: Activity.indoorRowing, endedAt: at(600)),
);
expect(content.distanceMetres, isNull);
expect(content.movingTime, const Duration(minutes: 10));
expect(series, isEmpty);
});
test('every sample goes into its series, in chunks of ten minutes', () {
final (_, series) = recordedWorkout(
run(
fixes: [
for (var s = 0; s < 15 * 60; s += 60)
Fix(
time: at(s),
latE7: s,
lonE7: -s,
accuracyCm: 400,
altitudeCm: (s ~/ 60).isEven ? 3000 : null,
),
],
heartRates: [
HeartRate(time: at(5), bpm: 100),
HeartRate(time: at(65), bpm: 120),
],
pauses: [
PauseMark(time: at(100), auto: true),
PauseMark(time: at(130), paused: false),
],
endedAt: at(15 * 60),
),
);
final positions = series.where((c) => c.kind == SeriesKind.position);
expect(positions.map((c) => c.start), [start, at(600)]);
expect(positions.first.times, [for (var s = 0; s < 600; s += 60) s * 1000]);
expect(positions.first.columns['lat']!.take(2), [0, 60]);
expect(positions.first.columns['lon']!.take(2), [0, -60]);
expect(positions.first.columns['alt']!.take(2), [3000, null]);
expect(
series.singleWhere((c) => c.kind == SeriesKind.heartRate),
SeriesChunk(
kind: SeriesKind.heartRate,
start: at(5),
times: const [0, 60000],
columns: const {
'bpm': [100, 120],
},
),
);
expect(
series.singleWhere((c) => c.kind == SeriesKind.pauses),
SeriesChunk(
kind: SeriesKind.pauses,
start: at(100),
times: const [0, 30000],
columns: const {
'paused': [1, 0],
'auto': [1, 0],
},
),
);
});
}

View file

@ -0,0 +1,123 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recording_file.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// The file the recorder appends to (ADR 0030, D18): a sample a line, read
/// back whole, a line cut short dropped.
void main() {
final start = DateTime.utc(2026, 10, 28, 7, 30);
int at(int seconds) =>
start.add(Duration(seconds: seconds)).millisecondsSinceEpoch;
final header = 'halkyon-train-recording 1 running ${at(0)}\n';
test('a finished recording reads with every sample, in its order', () {
final recording = readRecording(
'$header'
'p ${at(1)} 525200000 134050000 450 3400\n'
'h ${at(1)} 121\n'
's ${at(2)}\n'
'pause ${at(3)} auto\n'
'resume ${at(9)} auto\n'
'p ${at(10)} 525201000 134051000 2900 -\n'
'pause ${at(11)} manual\n'
'resume ${at(12)} manual\n'
'end ${at(20)}\n',
);
expect(
recording,
Recording(
activity: Activity.running,
startedAt: start,
fixes: [
Fix(
time: start.add(const Duration(seconds: 1)),
latE7: 525200000,
lonE7: 134050000,
accuracyCm: 450,
altitudeCm: 3400,
),
Fix(
time: start.add(const Duration(seconds: 10)),
latE7: 525201000,
lonE7: 134051000,
accuracyCm: 2900,
),
],
heartRates: [
HeartRate(time: start.add(const Duration(seconds: 1)), bpm: 121),
],
steps: [start.add(const Duration(seconds: 2))],
pauses: [
PauseMark(time: start.add(const Duration(seconds: 3)), auto: true),
PauseMark(
time: start.add(const Duration(seconds: 9)),
paused: false,
auto: true,
),
PauseMark(time: start.add(const Duration(seconds: 11))),
PauseMark(
time: start.add(const Duration(seconds: 12)),
paused: false,
),
],
endedAt: start.add(const Duration(seconds: 20)),
),
);
expect(recording!.finished, isTrue);
});
test('a recording cut off is unfinished, its last line dropped where it '
'was cut short', () {
final recording = readRecording(
'$header'
'p ${at(1)} 525200000 134050000 450 -\n'
'h ${at(4)} 130\n'
'p ${at(5)} 5252',
)!;
expect(recording.finished, isFalse);
expect(recording.endedAt, isNull);
expect(recording.fixes, hasLength(1));
expect(recording.lastSampleAt, start.add(const Duration(seconds: 4)));
});
test("a line it can't read is skipped, the rest kept", () {
final recording = readRecording(
'$header'
'p ${at(1)} 525200000 134050000 450 -\n'
'p ${at(2)} north east 450 -\n'
'cadence ${at(3)} 88\n'
'h ${at(4)}\n'
'h ${at(5)} 140\n',
)!;
expect(recording.fixes, hasLength(1));
expect(recording.heartRates, [
HeartRate(time: start.add(const Duration(seconds: 5)), bpm: 140),
]);
});
test('a file without its header, of a newer version or an activity this '
"app doesn't know, is no recording", () {
expect(readRecording(''), isNull);
expect(readRecording('p ${at(1)} 525200000 134050000 450 -\n'), isNull);
expect(
readRecording('halkyon-train-recording 2 running ${at(0)}\n'),
isNull,
);
expect(
readRecording('halkyon-train-recording 1 skating ${at(0)}\n'),
isNull,
);
});
test('a recording just begun has no sample and was last seen as it '
'started', () {
final recording = readRecording(header)!;
expect(recording.fixes, isEmpty);
expect(recording.lastSampleAt, start);
});
}

View file

@ -1,7 +1,7 @@
# ADR 0030: Halkyon Train, what it does and where its data comes from # ADR 0030: Halkyon Train, what it does and where its data comes from
- **Status:** decided 27–28 Oct 2026, by the developer (D1–D17 below). The shell (D12) - **Status:** decided 27–28 Oct 2026, by the developer (D1–D20 below). The shell (D12)
is built; recording (D13–D17) is being built. is built; recording (D13–D20) is being built.
- **Numbering:** 0025 is held by the federation draft (stashed), 0026–0029 by the .NET - **Numbering:** 0025 is held by the federation draft (stashed), 0026–0029 by the .NET
rewrite on the local branch `rewrite-dotnet`, abandoned on 27 Oct; Train takes the next rewrite on the local branch `rewrite-dotnet`, abandoned on 27 Oct; Train takes the next
free number so that neither is ever read as this one. free number so that neither is ever read as this one.
@ -283,6 +283,37 @@ Strava. What was found, 27 Oct:
FTMS, from frames recorded with nRF Connect. Health Connect (D6) and the Loop (D3) FTMS, from frames recorded with nRF Connect. Health Connect (D6) and the Loop (D3)
follow recording. Each type maps onto one of Health Connect's exercise types, and is a follow recording. Each type maps onto one of Health Connect's exercise types, and is a
name in the sealed payload, so new ones need no migration. name in the sealed payload, so new ones need no migration.
- **D18 The recording file is text, one sample a line, appended** (recommended, over
fixed-size binary records, smaller only while recording, as a saved workout is
delta-coded JSON anyway, and over samples handed straight to Dart, lost when Android
stops the Flutter engine while the service runs, against D13): each line a sample with
its kind (a position, a heart rate, a step, a pause's start or end) and its time,
flushed every few seconds. A line cut short is dropped as it is read; an unfinished
file the app finds when it next opens is offered to be saved up to its last sample or
thrown away. It lies in the person's folder, in the app's private storage, unsealed,
as their local database does, until it is saved as a workout and sealed (D7).
- **D19 Permissions asked when first needed, never background location**
(recommended, over background location, which would let a recording start with Train
closed and go on after Android killed its process, but asks to be allowed all the time
on a settings page of its own since Android 11 and a declaration on Google Play, more
than Train's privacy fits; OpenTracks does without it too): precise location and, on
Android 13 and later, notifications as an activity with GPS first starts, not at
install or first launch (refused notifications hide the recording's notification and
leave it running); physical activity only as running, walking or hiking first starts
with auto-pause on, for the step detector (D16), auto-pause falling back to GPS speed
for that workout where it is refused. The service is a foreground service of the types
`location` and `health`, started from the app while it is open, which keeps its access
to location with the screen off; a process Android kills leaves its file (D18) to be
found when Train next opens.
- **D20 Every fix is kept with its accuracy, and filtered as it is read**
(recommended, over dropping imprecise fixes in the service, which keeps the series
smaller and the reading simpler but can't be undone for the workouts recorded under a
threshold that proves wrong): the file and the saved series hold each fix the phone
delivered with the accuracy it reported; distance, pace, splits and the track drawn
leave out the fixes less accurate than a threshold, 20 m to start with (as OpenTracks'
default), adjustable once Train has settings. A better filter later (smoothing, a
Kalman filter) reads old workouts anew, as nothing was thrown away. An hour at a fix a
second is 3,600 fixes, tens of kilobytes delta-coded and gzipped.
## Consequences ## Consequences