The skin temperature's line ran backwards in places: the band's samples aren't always in time order, and neither the Loop's lines, nor the kept day, nor the chart sorted them. They are sorted now, a time given twice kept once, as the lines are read and as a kept day is opened, and WorkoutChart draws any points in the order of their x, from the least to the most. The heart rate spiked down: the band writes 0 where it lost contact, and each run's mean, the chart's one point a run, counted those in. A value that isn't a heart's rate is 0, none read, in its place (so the samples after keep their times), left out of every mean, days kept before included; the chart draws the samples a point a minute, averaged across runs, rather than one mean a run, which zigzagged where runs lay close. ADR 0030, D65. Whats-New: A day's skin temperature no longer runs backwards, and its heart rate no longer drops where the band lost contact. Whats-New-De: Die Hauttemperatur eines Tages läuft nicht mehr rückwärts, und sein Puls fällt nicht mehr ab, wo das Band den Kontakt verlor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
1756 lines
136 KiB
Markdown
1756 lines
136 KiB
Markdown
# ADR 0030: Halkyon Train, what it does and where its data comes from
|
||
|
||
- **Status:** decided 27–28 Oct 2026, by the developer (D1–D46 below). The shell (D12)
|
||
is built; recording (D13–D28) is built on Android, iOS waiting for a Mac; Health
|
||
Connect (D29–D31) is built; the Loop's setup and keeping (7a, D34–D36) and its
|
||
daily data (7b, D37, D38), its sessions (7c, D39) and its firmware (D10, D40) are
|
||
built, and its live heart rate (7d, D41); intervals (D42, D43) are built: the plans
|
||
(8a), the service running them (8b) and the laps a workout keeps (8c); routes (D44,
|
||
D45) are being built.
|
||
- **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
|
||
free number so that neither is ever read as this one.
|
||
- **Followed by:** [ADR 0031](0031-the-apps-icons.md) (the apps' icons, Train's among them).
|
||
- **Relates to:** ADR 0014 (halkyon_base, which Train's server includes like every other),
|
||
ADR 0016 (one account across an operator's services; Train is one of them).
|
||
|
||
## Context
|
||
|
||
Train is the third of Halkyon's apps after Learn and Todo, so far only named. On 27 Oct
|
||
the developer asked for it to "encompass advanced features for running and cycling", to
|
||
connect to a Polar Loop, and for rowing machines' APIs to be looked into (WaterRower,
|
||
Kettler, Christopeit, BH Fitness, Tunturi), the Christopeit WP 5000 in detail. For running
|
||
they asked for "GPS tracking, automatic stop (*at least* the functionality of e.g.
|
||
F-Droid's OpenTracks)", HIIT, music in tandem with the pace, and planning tracks as on
|
||
Strava. What was found, 27 Oct:
|
||
|
||
- **Open Bluetooth profiles carry most of it.** Heart rate (`0x180D`, measurement `0x2A37`
|
||
with RR intervals, hence HRV), running speed and cadence (`0x1814`), cycling speed,
|
||
cadence and power, and the Fitness Machine Service (FTMS, `0x1826`; Rower Data
|
||
`0x2AD1`, Indoor Bike Data, Treadmill Data; resistance set through its control point).
|
||
Any strap, footpod, power meter, smart trainer or FTMS rower works without a vendor's
|
||
account or SDK, on every platform with Bluetooth LE. Pixels have no ANT+ radio, so on
|
||
GrapheneOS Train is Bluetooth LE only.
|
||
- **The Polar Loop** (Sept 2025, no screen, no GPS) is no Bluetooth standard: it speaks
|
||
Polar's own protocol, and doesn't broadcast its heart rate as a `0x180D` sensor (Polar:
|
||
it "cannot be used as a heart rate sensor for other devices"). Polar's BLE SDK
|
||
(Android and iOS, its own permissive licence, source-available) covers it: first-time
|
||
setup from one's own app (`doFirstTimeUse()`, no Polar account named anywhere), live
|
||
HR, acceleration, PPI, skin temperature and PPG, and the export of sleep, sessions,
|
||
daily activity, 24/7 HR and PPI and Nightly Recharge. It pairs with one peer only;
|
||
re-pairing needs a factory reset. Firmware updates fetch from Polar (a check sending
|
||
the device's UUID, firmware version and hardware code, no health data). Polar's cloud
|
||
(AccessLink, OAuth) gives much the same through Polar Flow, free for now and revocable.
|
||
- **Rowing machines**: none of the five brands publishes an API. WaterRower documents
|
||
its S4/S5 monitor's USB serial protocol and its ComModule speaks FTMS; BH's current
|
||
i.Concept range speaks FTMS; Kettler's Coach 2 very probably, Christopeit's and
|
||
Tunturi's unconfirmed. Concept2's PM5 is documented and speaks FTMS. Older machines on
|
||
Classic Bluetooth (SPP) are reachable neither on iOS nor well from Flutter.
|
||
- **The Christopeit WP 5000** (manual of 6 Nov 2025): Bluetooth 4.0, a built-in Bluetooth
|
||
heart-rate receiver, water with a 16-level magnetic brake the console moves itself (its
|
||
12 programmes change it), mains-powered. Its manual names Kinomap, picked by brand, not
|
||
as a generic FTMS device; an owner reports (mid-2025) that neither a Garmin's FTMS
|
||
rowing profile nor EXR could pair. Christopeit's own app, Christopeit Connect (Vescape,
|
||
`com.vescape.christopeit`), supports it, declares no data collected (App Store), and
|
||
writes workouts to Health Connect or Apple Health. Whether the console speaks FTMS is
|
||
unknown until it is scanned.
|
||
- **Health Connect** is part of Android since 14, so GrapheneOS has it without Google Play;
|
||
data stays on the device and each app is granted each type. Gadgetbridge (Garmin,
|
||
Huawei, Colmi rings, Amazfit and others, no account) and Christopeit Connect write to
|
||
it; OpenTracks writes sessions, distance, speed and routes there, but no heart rate.
|
||
- **OpenTracks** is Apache-2.0, so its logic may be taken into Train (AGPL) with its
|
||
notice; the open projects talking to rowers and trainers (qdomyos-zwift,
|
||
OpenRowingMonitor, pirowflo) are GPL, references for the protocols only.
|
||
- **Music by tempo**: Spotify closed its tempo data (audio features) to new apps on 27 Nov
|
||
2024; Qobuz grants its API to commercial partners only and refuses open-source
|
||
integrations. Another Android app's title can be read from its media session and its
|
||
tempo looked up (Deezer's `bpm`, often missing; AcousticBrainz, frozen since 2022), but
|
||
only shown, never chosen by. Jellyfin's API is open but has no tempo field.
|
||
|
||
## Decisions (27 Oct 2026, the developer's)
|
||
|
||
- **D1 What Train does**: running and cycling with "advanced features", rowing machines,
|
||
the Polar Loop. For running at least what OpenTracks does (GPS tracking, pausing by
|
||
itself), structured intervals (HIIT), music matching the pace, and planning routes as
|
||
on Strava.
|
||
- **D2 In this order** (recommended): recording first (GPS, auto-pause, a heart-rate
|
||
strap), then intervals, then route planning, then music. Recording is what everything
|
||
else builds on; route planning is the largest piece (maps and a router); music needs
|
||
the intervals' targets to follow.
|
||
- **D3 Train takes over the Polar Loop** through Polar's BLE SDK, with no Polar Flow and no
|
||
Polar account (recommended, over AccessLink, Polar's cloud, as the main way in): the
|
||
SDK gives nearly all AccessLink would, on the device and without Polar in between. Since
|
||
the Loop pairs with one peer, the band is Train's alone; the phone holding it syncs it,
|
||
and other devices see its data through Train's server, as the SDK runs on Android and
|
||
iOS only.
|
||
- **D4 Music comes from Jellyfin, with Train as the player** (recommended, over reading
|
||
what Spotify or Qobuz play, which the developer judged "a rather stale feature"): Train
|
||
chooses the next track by the cadence or pace an interval asks for, may speed a track
|
||
up or slow it down a few per cent, and downloads the chosen tracks before a run for
|
||
playing offline. Each track's tempo is worked out by Train from its audio, once, and
|
||
kept (Jellyfin has no tempo field; its MusicTags plugin only passes on a BPM tag files
|
||
already have). A metronome at the target cadence works where no library is set.
|
||
|
||
- **D5 Devices connect to phones only, through `flutter_reactive_ble`** (BSD-3, Android
|
||
and iOS, Philips Hue's; recommended once the desktop was out, over `universal_ble`,
|
||
BSD-3 on every platform but younger and less used, and over `flutter_blue_plus`, whose
|
||
own licence asks a fee for commercial use, which an AGPL app can't pass on): "I find it
|
||
exceedingly unlikely one owns or purchases a fitness band or smartwatch without
|
||
intending or even owning a smartphone", and "the open license is also of paramount
|
||
importance". Train talks to its own small interface (scan, connect, subscribe, write),
|
||
the profiles' parsers are pure Dart, tested on recorded frames, so another library can
|
||
take its place behind it.
|
||
|
||
- **D6 Health Connect both ways** (recommended): Train writes its workouts there (the
|
||
session with its type, route, heart rate, distance, speed and calories), off until the
|
||
person turns it on, its own store staying the source of truth; and reads from there
|
||
other apps' workout sessions (their heart rate, distance, power) and the person's weight
|
||
and resting heart rate, each source turned on by itself. Sources Train can't speak to
|
||
(Garmin and Colmi through Gadgetbridge, the WP 5000 through Christopeit Connect) come
|
||
in so; Train tells its own records by their origin and takes a session another app
|
||
wrote of a workout it recorded itself for the same one. Through the `health` package
|
||
(MIT, CARP's, Android and iOS; recommended was a Kotlin channel of our own): it has no
|
||
types for power or cadence, which Train therefore neither writes nor reads there, but
|
||
records from the sensors directly; they can be added upstream or by a channel of our
|
||
own when a source needs them.
|
||
- **D7 Train's data is sealed, always** (recommended): the payloads of its rows (sessions,
|
||
routes, every series, what is imported) are ADR 0017's sealed content under the
|
||
person's data key, as ADR 0019 seals a private studyset, so the instance syncs what it
|
||
can't read; imported data syncs as Train's own does. Health data is a special category
|
||
of the GDPR (Art. 9), and nothing on the server needs to read it. The instance still
|
||
sees that a row exists, its size, and when it changed; what Train works out (training
|
||
load, zones, records) is worked out on the device; a route shared later is a copy made
|
||
readable on purpose, as publishing is in Learn.
|
||
|
||
- **D8 The WP 5000 is an FTMS rower, reached by Train's FTMS client and nothing of its
|
||
own** (found on the developer's machine, 27 Oct, with nRF Connect): its console
|
||
(`MRK-950R-8021`) advertises the Fitness Machine service, with its service data saying
|
||
"rower, available" (`0x1826`, `01 10 00`), and offers Fitness Machine Feature, Status,
|
||
Training Status, the Control Point (write, indicate), Rower Data (notify) and Supported
|
||
Resistance Level Range. Its features: cadence, total distance, pace, resistance level,
|
||
stroke count, energy, heart rate, elapsed time, power, and setting a resistance target.
|
||
A frame while rowing: 25 strokes a minute, 6 strokes, 18 m, pace 439 s, 100 W,
|
||
resistance 1, 1 kcal, heart rate 0 (no strap), 17 s. The forum's report that Garmin and
|
||
EXR couldn't pair isn't explained by the machine as it is now (an older firmware, or a
|
||
console already connected elsewhere, which most allow one of, may be). Its other
|
||
services (`0xFFF0`, `0xF100`, `0xAE00`, and one whose UUID spells "YULU" and "MERACH",
|
||
presumably the console's maker's own protocol, which Kinomap and Christopeit Connect
|
||
may use) are left alone; Health Connect and working out a protocol aren't needed.
|
||
|
||
- **D9 No WaterRower USB driver for now** (recommended): it would serve S4 monitors
|
||
without the ComModule, on Android only (iOS allows no USB serial device outside Apple's
|
||
accessory programme), through an OTG cable during the workout, as a second transport
|
||
beside Bluetooth, with no WaterRower at hand to test on; a WaterRower with the
|
||
ComModule speaks FTMS. Its protocol is published, so a driver can be added behind
|
||
Train's sensor interface once someone asks for it with one at hand.
|
||
|
||
- **D10 The Loop's firmware: from Polar directly by default, or through the instance**
|
||
(the developer's choice, after option 1 alone was recommended for phones always on a
|
||
VPN; over updating never). Polar's SDK checks with Polar's Firmware Management API
|
||
(sending a UUID made from the band's ID, its firmware version, its hardware code and
|
||
a fixed client name, no health data) and flashes either what it fetched or a file on
|
||
the phone (`updateFirmware(identifier, firmwareUrl)` takes `file://`, read in its
|
||
source, 27 Oct); the band's settings are backed up over Bluetooth into the phone's
|
||
memory and written back, never sent anywhere. So:
|
||
- **Directly** (the default): the phone asks Polar and fetches the file. Polar learns
|
||
the phone's address, a VPN's exit if one is always on (on GrapheneOS with "Block
|
||
connections without VPN").
|
||
- **Through the instance** (a setting): the phone sends the band's UUID, firmware
|
||
version and hardware code to Train's server, which asks Polar, fetches the file once
|
||
for every band of that hardware and hands it to the phone, which flashes it from a
|
||
file. Polar learns the instance's address, which on a small instance points at its
|
||
operator nearly as well as a home's does.
|
||
Train says both where the setting is, and names what Polar learns where it offers an
|
||
update. An update never starts by itself: Train first syncs what is on the band and
|
||
deletes it there, says the band is erased and set up again, and asks.
|
||
|
||
- **D11 Maps and routes** (recommended, after looking at how open apps do it; 27 Oct):
|
||
- **The map widget is `flutter_map`** (BSD-3, Fleaflet's; the developer's choice, over
|
||
`maplibre`, BSD-3 and rendering vector tiles natively but at 0.3): drawing tracks,
|
||
heart rate's colours and waypoints over the map is ordinary Flutter. Vector tiles
|
||
come through `vector_map_tiles` and its PMTiles plugin, tested on the developer's
|
||
phone with a region's file before route planning is built; should it stutter,
|
||
`maplibre` takes the map screens' place.
|
||
- **The map's source is the instance's, one of three** (an operator's setting): a PMTiles
|
||
file of its region as a sidecar (recommended, seen by no one else); OpenFreeMap (no
|
||
key, no contract; the default without a sidecar); or a provider of the operator's
|
||
choice with its address and key (MapTiler, JawgMaps and the European others), whose
|
||
key is then readable by anyone using the app. OpenStreetMap's own tile servers forbid
|
||
such use. The app names the source in use and what it sees.
|
||
- **The router is BRouter, as a sidecar** (MIT; recommended over GraphHopper, which needs
|
||
gigabytes to import a country, and Valhalla, with fewer sport profiles): its own image,
|
||
128–512 MB, the 5°×5° segments of the operator's region downloaded from BRouter's
|
||
project (built from OSM with elevation) and refreshed monthly, its profiles text files
|
||
a running one can be written for. As LanguageTool: no site of its own, reached by
|
||
Train's server only, for people signed in, limited, no waypoint kept or logged. A
|
||
route saved is Train's data, sealed (D7). Searching for places (geocoding) is
|
||
decided when planning is built; until then a route is planned by tapping the map.
|
||
- **Offline**: recording, following a route (synced beforehand with BRouter's turn
|
||
hints), noticing a detour and guiding back to the route, music. Maps offline along a
|
||
route (a corridor of a few kilometres downloaded with it), whole regions optional:
|
||
a phone with CoMaps holds them already. Planning, rerouting and searching need the
|
||
instance; BRouter on Android (its app, or its engine inside Train) may come later,
|
||
never on iOS (no Java).
|
||
- **With CoMaps** (Apache-2.0, OSM's data; read in its source at `63a53413`, 27 Oct, by
|
||
an agent, nothing tried on a device): its maps (MWM, its own format, in its private
|
||
storage) can't be read by another app, so the apps work together instead:
|
||
1. a route planned in Train goes to CoMaps as GPX (VIEW or SEND, a new list each;
|
||
tracks, routes, waypoints, elevation, times, colours kept; no GeoJSON), to be
|
||
seen there offline;
|
||
2. turn hints come from Train, which announces them with the workout's own, since
|
||
CoMaps navigates along no imported track (its issue #251, open);
|
||
3. optional: a route to a point is handed to CoMaps (`cm://route?sll=…&saddr=…&dll=…
|
||
&daddr=…&type=pedestrian|bicycle`), which builds it offline and the person starts;
|
||
on Android, Train may read along through CoMaps' navigation provider (since mid
|
||
2026; a runtime permission the person grants), for the route and what is left;
|
||
4. tracks and routes from CoMaps reach Train as GPX when the person shares them.
|
||
Train builds the `cm://` links itself: Organic Maps' API library and `om://` don't
|
||
reach CoMaps on Android. On iOS (`iphone/Maps/CoMaps.plist`, `DeepLinkHandler.swift`,
|
||
read 28 Oct): GPX, KML and KMZ open in CoMaps (1, 4), `cm://route` works with one
|
||
point between start and finish (3), its `backurl` leads back to Train; picking a
|
||
point (`crosshair`) is "not supported on iOS", and iOS has no navigation provider.
|
||
|
||
- **D12 Train's shell is Todo's** (recommended, 27 Oct; the icon the developer's choice,
|
||
the rest taken without objection on 28 Oct):
|
||
- **a server of its own**, `apps/train/` with `halkyon_train_server`, `_client`,
|
||
`_flutter`, including halkyon_base (`trainBase()`), its own register when it runs
|
||
alone and a member of the instance's where the operator names one (ADR 0016); over
|
||
Train's endpoints in Learn's server, whose deploys, migrations and failures would
|
||
then be Train's, and which an operator couldn't run Train without;
|
||
- **synced with `serverpod_offline_sync`** (ADR 0001), its rows sealed (D7), since
|
||
recording happens offline;
|
||
- **ports of its own in development**, 8180–8191 where Learn and Todo share
|
||
8080–8091, so that Train runs beside Learn's server as a member of its register;
|
||
- **Android and iOS** record; **Linux, macOS and Windows** show and plan (D5); no web,
|
||
as for Learn and Todo, whose keys (ADR 0017) need libsodium natively;
|
||
- **Halkyon Train**, `app.halkyon.train`, in English and German alike;
|
||
- **its icon is Lucide's `bike`** (Lucide's `dumbbell` since 2 Nov 2026, ADR 0031, D7), in the family of [ADR 0031](0031-the-apps-icons.md)
|
||
("activity" and "heart-pulse" look like basic health apps, "a defibrillator or ADAC
|
||
health application"; "this is more about training"; Lucide has no runner);
|
||
- **deployed once recording works** and the developer wants to use it, not as an
|
||
empty shell first.
|
||
|
||
## Recording (28 Oct 2026, the developer's)
|
||
|
||
- **D13 Positions are recorded by Train's own platform code** (recommended, over the
|
||
`geolocator` package and paid background-tracking packages): on Android a foreground
|
||
service of its own, as Learn's Pomodoro timer has, reading `LocationManager`'s GPS
|
||
provider as OpenTracks does and appending every fix to a recording file in the app's
|
||
folder, which the app follows while open and takes in when it opens again; on iOS
|
||
CoreLocation with background location; both behind one interface (`TrackRecorder`),
|
||
faked in tests. So a recording goes on with the app swiped away, and nothing of Google
|
||
Play services is in the app: `geolocator`'s Android part depends on Play services'
|
||
location library even where its `forceLocationManager` keeps it from being used, a
|
||
non-free library that F-Droid's main repository refuses, and its positions arrive in
|
||
Dart, which a swipe ends. The strap and the rower join the same service later.
|
||
- **D14 A workout and its series are kept apart** (recommended, over one row holding
|
||
every sample and over sealed GPX or FIT files in the instance's storage): `workout`
|
||
(type, start, end, name, notes, totals; changed when edited, the last writer winning, as
|
||
a studyset's payload) and `workout_series` (a chunk of one series, positions, heart rate,
|
||
strokes, pauses, of up to 10 minutes; written once as the workout is saved, never
|
||
changed, deleted with its workout, as Learn's review log), every payload sealed (D7)
|
||
and nothing readable in a column, not the type nor the start. Editing a workout touches
|
||
one small row, two devices never conflict over samples, and every source is one more
|
||
kind of series. A chunk's payload is JSON as every payload, each column (time,
|
||
latitude, longitude, altitude, accuracy, …) a list of whole-number differences from the
|
||
value before, gzipped before it is sealed, since sealed bytes compress no more. The
|
||
recording file is the device's own and never synced. UUID v7 ids carry their creation
|
||
time, which the sync engine's change times tell anyway (D7's list).
|
||
- **D15 Sealing: the envelope moves into halkyon_app, Train holds its key itself**
|
||
(recommended, over moving all of Learn's key management and over a copy of the
|
||
envelope in Train): Learn's `PayloadEnvelope` (a payload sealed for its table, row and
|
||
column; readable payloads signed by the account; what doesn't open `Locked`) moves
|
||
unchanged into halkyon_app, Learn importing it from there; Train has one key per person
|
||
in a synced `content_key` row, its sealed ID saying it is the person's, and a device
|
||
without the account's keys writes nothing, as in Learn. Learn's tree (private or
|
||
readable studysets, their records, rewrites) stays Learn's. Publishing a workout later
|
||
doesn't change it (asked by the developer): a published workout is a readable copy made
|
||
on the device (D7), which may leave out the heart rate or the first and last few hundred
|
||
metres, as Strava's privacy zones do, the sealed workout staying as it is; the envelope
|
||
can sign the copy with the account's key.
|
||
- **D16 Auto-pause by each activity's best signal, never discarding samples**
|
||
(recommended, over GPS alone and over manual pauses only): a pause, automatic or by
|
||
hand, is a marker in a series of its own, and moving time, pace and the totals leave
|
||
paused stretches out, so a misjudged pause is corrected from the data afterwards.
|
||
Running and walking stop with the phone's step detector (Android's step sensor, iOS's
|
||
pedometer; its permission asked for, GPS alone without it), confirmed by GPS; cycling
|
||
by GPS speed, paused below 3 km/h for 5 s and going on above 5 km/h; rowing by the
|
||
rower's stroke rate, no stroke for 5 s. On by default for running and cycling, the
|
||
thresholds adjustable once Train has settings.
|
||
- **D17 Running, walking, hiking, cycling and indoor rowing first, in this order of
|
||
parts** (recommended): 5a the envelope moved, Train's key holder, the two models and
|
||
their migration, the chunk codec and the first synced repository; 5b recording on
|
||
Android (the service, the file taken in after a swipe, the recording screen,
|
||
auto-pause, saving); 5c the workouts and a workout's page with its track on a map
|
||
(D11's `flutter_map` and the instance's tile source brought forward from route
|
||
planning, the developer's choice: a run without its track looks unfinished), totals,
|
||
splits and charts; 5d recording on iOS; 5e the heart-rate strap; 5f the WP 5000 over
|
||
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
|
||
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.
|
||
- **D21 The recording screen** (proposed, the developer's): home's Record button opens a
|
||
sheet of the activities, the last one recorded chosen, with auto-pause's switch as D16
|
||
sets it; Start asks the permissions (D19). Until the first fix precise enough the
|
||
screen says it waits for GPS, showing the accuracy, while it records already, the time
|
||
from Start. Recording, it shows the moving time, the distance and the pace now (min/km
|
||
running, walking and hiking, km/h cycling; rowing time alone until 5f; heart rate once
|
||
a strap is there, 5e), an automatic pause told apart from one by hand, no map until 5c.
|
||
Stop is a long press, so that no pocket ends a run (over a tap and a confirmation,
|
||
quicker and easier to hit by mistake), then Save or Discard, Discard asked again. The
|
||
notification shows time and distance with Pause or Resume and opens the app; stopping
|
||
only in the app, so that no stray touch on the lock screen ends a workout. A recording
|
||
found cut off as Train opens (D18) is offered to be saved up to its last sample or
|
||
discarded.
|
||
|
||
Built on 28 Oct with three choices of the implementation's, for the developer to look
|
||
at: rowing indoors isn't offered yet, as a foreground service on Android 14 and later
|
||
needs a type its permissions allow and rowing without the rower has none (5f's
|
||
Bluetooth gives it `connectedDevice`); on foot without the step detector auto-pause
|
||
goes by GPS speed below 2 km/h for 5 s, going on above 3 km/h (D16 names only the
|
||
bike's thresholds); and Stop's hint shows above the button for three seconds rather
|
||
than in a snack bar, which would cover the button it speaks of. The service holds a
|
||
partial wake lock while it records (at most 12 hours), so that its clock and file run
|
||
with the screen off where no GPS fix wakes the processor.
|
||
**Opened from Train's frame** (1 Nov 2026, the developer's report: an indoor row
|
||
started, its notification showed, its screen didn't): a recording started opens its
|
||
screen from `TrainShell`, as the recording's phase turns from idle or cut off to
|
||
recording, not from the Record tab's card, which had pushed it only after the
|
||
service had started and gave up silently where it was gone by then; one found
|
||
running as the app opens leaves the tabs as they were. The notification tapped opens
|
||
the recording's screen (`RecordingOpenings.kt`, `RecorderPlatform.openings`), not
|
||
merely the app, and never a second over the first.
|
||
- **D22 OpenFreeMap first, the PMTiles sidecar with route planning** (recommended, over
|
||
the sidecar now, which no one else ever sees the areas viewed through but needs Train's
|
||
server and the sidecar deployed on learn.halkyon.eu before anything else of Train is
|
||
ready, and a region chosen, gigabytes on the server's disk; and over a source fixed in
|
||
the app, which skips D11's operator's setting and would be undone): Train's server names
|
||
the instance's map source, as its operator sets it, OpenFreeMap where nothing is set
|
||
(D11), so 5c needs no deploy; a workout's page names the source drawing it and what it
|
||
sees (the device's address and the area viewed, not the track). The sidecar comes when
|
||
route planning gives Train's server a reason to run on the instance: the region's
|
||
PMTiles file served as ordinary tiles (D23).
|
||
- **D23 `flutter_map` 8 with `vector_map_tiles` 9's beta** (recommended; 28 Oct): D11's
|
||
combination doesn't exist as released, as `vector_map_tiles`' stable line (8) needs
|
||
`flutter_map` 7 and its PMTiles plugin (1.5.0, October 2024) `vector_map_tiles` 8,
|
||
while the 9 line, beta since November 2024 (beta.13 on 1 September 2026), is where its
|
||
maintainer works and needs `flutter_map` 8. Over the stable line, all three packages
|
||
of 2024 and held there until 9 is out, and over `maplibre` (0.3.7), native and smooth
|
||
but drawing tracks and their colours as its layers rather than Flutter's widgets, the
|
||
reason for D11's choice. The beta is pinned exactly and moves to 9.0.0 once released;
|
||
the sidecar serves the region's PMTiles file as ordinary vector tiles with Protomaps'
|
||
`pmtiles serve` (go-pmtiles, BSD-3-Clause) instead of the app reading it, so the app
|
||
holds nothing of PMTiles'.
|
||
- **D24 The workouts and a workout's page** (the developer's; 28 Oct): home lists
|
||
the workouts newest first under their months, each with its activity's icon, its name
|
||
(else the time of day's and the activity's, "Morning run"), its day and start, distance,
|
||
moving time and pace (km/h cycling); one this device can't open shows locked; Record
|
||
stays. A workout's page: the map on its upper part, the track in cobalt, its pauses
|
||
gaps rather than straight jumps, start and end marked, the source's credit on it, fit
|
||
to the track (heart rate's colours with the strap, 5e); its totals (distance, moving
|
||
time, pace or speed, the whole time, the elevation gained, counting climbs above 3 m
|
||
of the fixes precise enough, as GPS's altitude is noisy); its splits, per kilometre on
|
||
foot, per 5 km cycling; charts of pace or speed and of elevation over the distance,
|
||
drawn by the app's own small painter (over `fl_chart`, MIT, larger than two lines
|
||
need and one more dependency to follow); under the map, which source draws it and what
|
||
that sees, with its page where the operator names one (D22). Its name and notes are
|
||
edited; deleting asks first and offers an undo, as Learn's deletions do. Without GPS no
|
||
map and no splits.
|
||
|
||
Built so (28 Oct), with choices of mine for the developer to look at: the day's parts
|
||
start at 5, 12, 17 and 22 on the device's clock; locked workouts come last under no
|
||
month, as when they were is sealed too; the map stays fixed above the page as it
|
||
scrolls, pans and zooms but doesn't turn, and folds the source's credit into
|
||
flutter_map's attribution button, the credit being wider than a phone; start green,
|
||
end cerise, the track cobalt in either theme (the map's colours are the source's);
|
||
the charts show their lowest and highest value beside them and nothing else; where
|
||
the instance can't be asked for its map source, the page says so and shows the rest;
|
||
the elevation counts a climb once it passes 3 m above where it last turned, so a
|
||
steady climb's last metres short of the next 3 are not counted (a 20 m hill reads as
|
||
18 or 19 m): the simple filter, to be compared with recorded runs.
|
||
- **D25 iOS recording waits for a Mac** (the developer's; 28 Oct): 5e and 5f come before
|
||
5d, which moves to the end of step 5. iOS's side (CoreLocation with background
|
||
location, the pedometer, a Live Activity for the notification) needs Xcode, which runs
|
||
on macOS alone, and this machine has none; written unbuilt it would look done and not
|
||
compile, and CI's runner has no Xcode either. The heart-rate strap and the WP 5000 are
|
||
built and tested on Android here meanwhile.
|
||
- **D26 The recorder service talks to the strap itself** (recommended, the developer's;
|
||
28 Oct; over `flutter_reactive_ble` for finding the strap beside it, two stacks touching
|
||
one strap for one screen's sake, and over D5's Dart alone, whose heart rate would stop
|
||
where a swipe stops the Flutter engine, D18, while GPS records on): Android's own
|
||
Bluetooth API in Kotlin, as OpenTracks has it, connects during a recording and writes
|
||
the `h` lines, so heart rate is recorded as long as positions are; finding and choosing
|
||
a strap goes through the recorder's channel too, so Train has one Bluetooth stack and
|
||
no Bluetooth package. This revises D5: its open licence holds, as nothing third-party
|
||
is left, and the platform's API sits behind `RecorderPlatform` as the library would
|
||
have behind Train's own interface. The profiles' parsers (Heart Rate Measurement now,
|
||
FTMS's Rower Data in 5f) are Kotlin, tested on the JVM on recorded frames, and iOS (5d)
|
||
needs them again in Swift.
|
||
- **D27 The strap, from choosing it to the workout's page** (the developer's; 28 Oct): the
|
||
Record sheet's *Heart rate* row names the strap or "No strap" and opens a sheet that
|
||
looks for devices advertising the Heart Rate Service (0x180D), the nearest first;
|
||
the one chosen is remembered on the device, in the person's folder (`strap.json`, its
|
||
address and name, never synced: each phone finds it anew), *Forget strap* letting it
|
||
go. Nearby devices are asked for as the sheet first looks (D19), declared never to
|
||
locate (Android 12 on; before, scanning needs location). No bonding: the service
|
||
connects by address. A remembered strap records with every workout unless switched
|
||
off on the sheet for one; the recording doesn't wait for it. The recording screen
|
||
shows the heart rate ("–" while connecting), and "Strap lost" where GPS's state shows
|
||
while it is gone; the notification stays time and distance, as a rate changing each
|
||
second would repost it each second. A lost strap is asked for again in the background
|
||
for as long as the recording runs; the minutes without it stay a gap. Only the rate is
|
||
kept (`h` lines), not the R-R intervals, until something uses them. The workout's
|
||
page: the heart's average and highest rate while moving among the totals, each
|
||
split's average, and its chart over the distance (over the moving time without a
|
||
track). The track is coloured by heart-rate zones once Train's settings hold the
|
||
person's maximum rate, as colouring by each workout's own range paints an easy run
|
||
as red as a race.
|
||
|
||
Built so (28 Oct), with choices of mine for the developer to look at: the file gains
|
||
`strap <time> connecting|connected|lost` lines (the format's version stays 1, as a
|
||
reader before skipped lines it didn't know); a rate older than 5 s shows as "–"; the
|
||
first attempt connects directly, falling back on Android's background connection
|
||
once it fails and after every loss; a sighting stronger than −65 dBm is "close by";
|
||
the heart-rate chart averages the rates of each stretch (as the pace chart's
|
||
stretches, or 10 s, at least, over the time). Not yet tried with a real strap.
|
||
- **D28 The rower, from choosing it to the workout's page** (the developer's; 28 Oct): with
|
||
indoor rowing chosen, the Record sheet's *Rowing machine* row opens a sheet as the
|
||
strap's, looking for the Fitness Machine Service (0x1826) and listing the machines
|
||
whose advertisement says "rower"; the one chosen is remembered in the person's folder
|
||
(`rower.json`). Indoor rowing is offered again, Start asking for a rower. Train reads
|
||
Rower Data and takes no control of the console (its resistance through the Control
|
||
Point waits for a reason, as training plans); a console held by another app (one
|
||
connection at a time) is said so. The foreground service gets the `connectedDevice`
|
||
type, which nearby devices allow. Each frame becomes a line `r <time> <stroke rate>
|
||
<strokes> <metres> <watts>` and a `rowing` series; the console's running totals count
|
||
from its first frame, and its own heart rate is kept only without a strap.
|
||
Auto-pause: no new stroke for 5 s pauses (D16), the next one goes on, on by default.
|
||
The recording screen: moving time, metres, the split per 500 m of the last 30 s,
|
||
the stroke rate, watts, the heart rate with a strap. The list shows the split per
|
||
500 m; the page: distance, moving time, the average split, stroke rate and watts,
|
||
the highest watts, strokes, heart rate; splits per 500 m with their split, stroke rate
|
||
and watts; charts of the split, the stroke rate and watts over the distance; no map.
|
||
Written from FTMS 1.0 and D8's frame, checked on the developer's machine later (an
|
||
nRF Connect log of Rower Data and Fitness Machine Status, or a first row).
|
||
|
||
Built so (28 Oct), with choices of mine for the developer to look at: a machine whose
|
||
advertisement carries no Fitness Machine data is listed too, as it can't tell, and only
|
||
one that says it is something else is left out; a console reset to zero mid-row counts
|
||
on from there; metres rowed while paused count for nothing, as GPS's do (D16); the
|
||
strap's sheet and the rower's are one sheet, the same "close by" (−65 dBm); the
|
||
strap and the rower share one connection class (`NotifyLink.kt`), and a row asks for
|
||
nearby devices as it starts, as a run asks for location, since Android needs it for
|
||
the service's type; Fitness Machine Status (the console's own pause and reset) is
|
||
not read yet, the strokes deciding the pause; the notification shows a row's
|
||
distance in kilometres, as the others'. Not yet tried on the WP 5000.
|
||
|
||
## After recording (28 Oct 2026, the developer's)
|
||
|
||
- **D29 Health Connect next, the Loop after it** (recommended, the developer's; over the
|
||
Loop first, and over neither before the intervals): Health Connect is the smaller
|
||
piece and serves every workout Train records already; writing gives them to every
|
||
other app on the phone, reading brings what Train can't speak to itself (Gadgetbridge's
|
||
watches and rings, Christopeit Connect's rows), and the Loop's sessions can later go
|
||
there the same way. The Loop needs Polar's SDK, its first-time setup and D10's
|
||
firmware, and new kinds of data (daily activity, sleep, 24/7 heart rate, Nightly
|
||
Recharge) with synced models of their own; it is tried only with the band at hand.
|
||
The intervals (D2) need neither.
|
||
- **D30 Health Connect through Kotlin of our own** (recommended, the developer's; revises
|
||
D6's `health` package, as D26 revised D5): `androidx.health.connect:connect-client`
|
||
(Apache-2.0, AndroidX, no Play services) behind a channel of Train's, as the strap's.
|
||
The `health` package (13.3.2) lacks power, cadence, elevation gained, distance and
|
||
speed as records of their own, ties no heart rate to its session, has no changes API
|
||
(so each read would be a whole window read again) and sets no client record ID (so a
|
||
workout written again would be a second copy, not a replacement). Our own gets every
|
||
record type, the changes API, Train's workout ID as the client record ID (written
|
||
again replaces, deleted deletes) and each record's origin. Its logic is tested on the
|
||
JVM, the Dart side on a fake platform; Apple Health needs Swift of its own later,
|
||
as iOS waits for a Mac anyway (D25).
|
||
- **D31 Health Connect, from turning it on to the workouts** (the developer's; 28 Oct):
|
||
- *Turning it on*: a *Health Connect* row in the account sheet (Train has no settings
|
||
yet) opens a screen with two switches, both off: *Write my workouts* and *Read from
|
||
other apps*. They are this phone's, as Health Connect is, kept per person in their
|
||
folder (`health_connect.json`, with what was written and the changes token), not
|
||
synced. Each permission is asked as its switch is turned on. Built into Android from
|
||
14; below, the row says it needs the Health Connect app. `connect-client` 1.1.0, the
|
||
stable one. The rationale Health Connect asks for is a short screen of Train's,
|
||
linked from the system's permission dialog.
|
||
- *Writing*: every workout of the person this phone holds, synced and earlier ones too;
|
||
Train's workout ID is the record's client ID and a counter its version, so an edit
|
||
replaces and a deletion deletes; imported ones are never written back. As a workout
|
||
is saved, edited or deleted, and as the app opens (what a sync brought). The session
|
||
(type, name, notes, its pauses as pause segments), its route with altitude and
|
||
accuracy, heart rate, speed and the rower's watts as samples, distance and elevation
|
||
gained as totals. No calories (no weight, no energy model yet); stroke rate has no
|
||
type there.
|
||
- *Reading*: the screen lists each app that wrote workouts there (Gadgetbridge,
|
||
Christopeit Connect), each with its own switch, off, new ones appearing as they
|
||
write; Train's permissions are per type, the filtering by app its own. As the app
|
||
opens and comes to the front, through the changes token; nothing in the background.
|
||
Turning it on asks for the history too, so older than 30 days comes in; refused, the
|
||
last 30 days only. Each session becomes a workout of Train's own store (D7: sealed,
|
||
synced), named by its app, with the samples Health Connect holds for it (heart rate,
|
||
distance, power). Another app's route asks consent per session, so none comes by
|
||
itself: an imported workout's page offers *Bring in the track*. An imported one's
|
||
name and notes can be edited, and it can be deleted, remembered on this phone so it
|
||
doesn't come back. Doubles: a session mostly overlapping one of Train's own of the
|
||
same kind isn't added; where Train's has no heart rate, the other's is added to it,
|
||
otherwise it is left out. Weight and resting heart rate wait for what uses them
|
||
(calories, zones), with the synced model they need.
|
||
- No migration: an imported workout is a workout, its origin one more field of the
|
||
sealed, versioned payload.
|
||
|
||
Writing built so (28 Oct), with choices of mine for the developer to look at: turning
|
||
writing off leaves what was written there; a session's title is the workout's name
|
||
only where the person gave one (Health Connect names it by the app otherwise); the
|
||
route is the track as the page draws it (the precise fixes while moving), the speed
|
||
a sample at each of its fixes after a piece's first; steps aren't written, as Train
|
||
keeps none (the step detector only decides auto-pause); a workout is written again
|
||
only where its payload's fingerprint changed; a write Health Connect refuses later
|
||
(the permission taken back there) turns writing off and the screen says so; Android
|
||
names only the apps the manifest lists (Gadgetbridge, Christopeit Connect,
|
||
OpenTracks), any other shows by its package; the rationale opens only while someone
|
||
is signed in. Train needs Android 8 (API 26) now, as `connect-client` 1.1.0 does.
|
||
Not yet tried on a phone.
|
||
|
||
Reading built so (28 Oct), with choices of mine for the developer to look at: a
|
||
session another app deletes there stays in Train (Train's store is what counts); a
|
||
treadmill's run is a run and a stationary bike's ride a ride, any other kind stays out;
|
||
a double is the same kind overlapping for at least half the shorter of the two, and a
|
||
session doubling one brought in from another app is left out too (two apps writing
|
||
the same row); a ride's power isn't kept, having no series of its own yet (only the
|
||
rower's has); an app turned off later keeps what it brought; a route location
|
||
without its accuracy counts as precise; *Bring in the track* shows on every imported
|
||
workout without one but rows, as whether a route is there isn't kept. Reading runs as
|
||
the app opens and comes to the front, and an app turned on brings its earlier
|
||
sessions at once. Not yet tried on a phone.
|
||
|
||
## The Polar Loop (28 Oct 2026, the developer's)
|
||
|
||
- **D32 The band's own days first** (recommended, the developer's; over its live heart
|
||
rate first, and over its setup and sessions alone): 7a setting up and keeping the band
|
||
(first-time use from Train, its battery, its clock, its firmware as D10 has it); 7b its
|
||
daily data (steps, active time, calories, 24/7 heart rate, sleep, Nightly Recharge,
|
||
skin temperature), new synced, sealed models and a first screen of days and nights; 7c
|
||
its training sessions as workouts; 7d the Loop as live heart rate in a recording
|
||
without a strap. All-day tracking is what the Loop is for, and its memory must be read
|
||
regularly or it fills; a strap gives better heart rate in a workout than the wrist.
|
||
Through Polar's BLE SDK (8.3.0, its notice kept; from JitPack), which covers the Loop
|
||
with the Polar 360. Its licence isn't permissive, as this said first: D36.
|
||
- **D33 The body's figures in a sealed, synced profile** (recommended, the developer's;
|
||
over this phone alone, and over keeping them nowhere): one synced model, `profile`,
|
||
its payload sealed under the person's key as a workout's (D7), holding what the Loop's
|
||
first-time use needs (sex as Polar's formulas take it, birth date, height, weight,
|
||
resting and highest heart rate, VO2max, training background, typical day, sleep goal),
|
||
each optional; a *Your body* screen edits them, the band's setup asks only for what is
|
||
missing and offers Polar's defaults (60, 220 less the age, its VO2max table) to the
|
||
band alone, a blank staying blank in Train; a change reaches the band at its next
|
||
connection. The same figures serve calories and heart-rate zones later (D31), on every
|
||
device of the person's. Weight is one current value for now.
|
||
- **D34 Setting up and keeping the Loop** (the developer's; 28 Oct): `profile` one row
|
||
per person, its fields as D33's (sex female or male, birth date, height in cm, weight
|
||
in kg, resting and highest heart rate, VO2max, Polar's six training backgrounds, typical
|
||
day sitting, standing or moving, sleep goal 5 to 11 h), each optional, edited on *Your
|
||
body* from the account sheet. A *Polar Loop* row there (Android) opens the band's
|
||
screen; *Set up a Loop* opens a sheet looking for Loops nearby, nearest first; the one
|
||
chosen is connected through Polar's SDK (Android's pairing dialog may show; nearby
|
||
devices asked first where not granted), set up with the profile (a short form asks
|
||
for what is missing, Polar's defaults offered to the band alone), its clock set, and
|
||
remembered in the person's folder (`loop.json`, not synced: one phone holds it, D3).
|
||
The band's screen: its name, battery and charging, firmware version, connected or when
|
||
last seen; *Set up again*; *Let the band go* (a factory reset after asking, the only way
|
||
to pair it elsewhere). The SDK connects while Train is open and the band in reach, each
|
||
connection setting its clock and sending what changed in the profile; background
|
||
syncing is decided with 7b. Firmware moves after 7b, as D10 syncs the band before an
|
||
update erases it. A Kotlin class of `MainActivity`'s on `app.halkyon.train/loop`, the
|
||
SDK at 8.3.0 from JitPack (a repository for Polar's package alone, its checksums
|
||
verified), Polar's notice in the licence list.
|
||
|
||
- **D35 A changed profile reaches the band when the person says** (recommended, the
|
||
developer's; 28 Oct; over sending it at each connection, as D34 had it, and over only
|
||
at setup): in Polar's SDK 8.3.0 only the first-time use writes the body's figures
|
||
(`/U/0/S/PHYSDATA.BPB`), and it writes a new user identifier each time too
|
||
(`/U/0/USERID.BPB`, made from the time); no public call writes the figures alone. What
|
||
a new identifier does to what the band holds isn't known until it is tried on a real
|
||
Loop, so the band's screen offers *Send to the band* once the profile differs from
|
||
what the band was set up with, and that runs the first-time use again; should it
|
||
prove harmless, sending can become automatic.
|
||
- **D36 Polar's SDK under its own licence, in the one build** (the developer's; 28 Oct;
|
||
over a second Android flavour with the SDK alone, a protocol of Train's own worked
|
||
out from the band's traffic, and setting the Loop aside): D32 called the SDK's
|
||
licence permissive, wrongly. `Polar_SDK_License.txt` (its 8.3.0 tag) is Polar's
|
||
*Software Development Kit Limited License Agreement*: the object code may be
|
||
distributed only together with an app and only to move data between Polar's devices
|
||
and it (3.1); Polar may end it with 90 days' notice (10.2); neither Polar's name nor
|
||
its products' may be used in marketing, nor any association implied, without its
|
||
written consent (4.4); Finnish law. Train's Android app links it all the same, under
|
||
an added permission of section 7 of the AGPL for that library
|
||
(`apps/train/halkyon_train_flutter/LICENSE-polar-exception.md`; `LICENSE.md` names it);
|
||
F-Droid's main repository would refuse Train so, and a listing of Train in the
|
||
project's own repository is to declare `NonFreeDep`. Its sources on GitHub are under
|
||
the same agreement, so a protocol of Train's own couldn't be written from them.
|
||
Polar's licence and the notices of what the SDK bundles
|
||
(`ThirdPartySoftwareListing.txt`) are in the licence list, on Android.
|
||
|
||
7a built so (28 Oct), with choices of mine for the developer to look at: the band's
|
||
defaults for a VO2max not given are the middle of the Moderate class of Polar's
|
||
fitness tables (its manuals', after Shvartz and Reibold 1990), by sex and five-year
|
||
age rows from 20 to 65, the first row below 20 and the last above 65, since Polar
|
||
publishes no default of its own; a height above Polar's 240 cm goes to the band as
|
||
240 (Your body takes up to 250); the setup asks for sex, birth date, height, weight,
|
||
training background, typical day and sleep goal where the profile lacks them, each
|
||
needed, on a page over the sheet, and keeps the answers in Your body; Loops are found
|
||
by a name beginning *Polar Loop*; the SDK is made only once a person's app opens on
|
||
Android, reconnects by itself while the band is remembered, and never asks for its
|
||
telemetry feature; the clock is set as each connection is ready (and with each
|
||
setup), a failure only logged; *Let the band go* keeps the band where it isn't in
|
||
reach and says so; a band chosen that doesn't connect within 60 s, or refuses, isn't
|
||
remembered and the search goes on. The SDK's files are pinned by SHA-256 in Gradle's
|
||
`verification-metadata.xml` as JitPack served them on 28 Oct, every other group
|
||
trusted as before. Not yet tried with a Loop.
|
||
|
||
- **D37 The Loop's days and nights** (recommended, the developer's; 28 Oct): what Polar's
|
||
SDK reads of each day (its summary: steps, distance, calories of activity, training
|
||
and rest, time in each activity class; its 24/7 heart rate; its sleep with phases,
|
||
cycles, rating and skin temperature; Nightly Recharge; skin temperature) kept as
|
||
Train's own models, as workouts are (over the band's raw files sealed as they come,
|
||
which other devices could read only with Polar's protobuf definitions, under Polar's
|
||
licence, and over summaries alone): one sealed, synced `day` per date (summary,
|
||
Nightly Recharge, skin temperature), one `night` per sleep (its phases), and the day's
|
||
heart rate in chunks (`day_series`, as a workout's series), each versioned JSON,
|
||
gzipped and sealed. The band is read while Train is open and also in the background
|
||
(over only while open): a WorkManager job every few hours connects and reads the days
|
||
since the last read into a plain file of the person's folder (app-private, as
|
||
`recording.txt`), which the app seals and syncs as it opens, since only the Dart side
|
||
holds the person's key. Train deletes nothing on the band (over deleting the days
|
||
kept), whose memory is taken to roll over by itself until a real band shows. Home
|
||
gains *Days*: each day's steps and active time with the night before's sleep and
|
||
Nightly Recharge, a day's page its heart rate as a chart, the night's phases and its
|
||
skin temperature. Steps, sleep and heart rate to Health Connect wait (D31 writes
|
||
workouts). D35 stays: the SDK's low-level `writeFile` could write the figures alone
|
||
(`PHYSDATA.BPB`) without a new user identifier, to be weighed once a real band shows
|
||
what a new identifier does.
|
||
|
||
- **D38 What the band holds is deleted once Train keeps it** (recommended, the
|
||
developer's; 28 Oct; revises D37's third point, over deleting a day once it is in the
|
||
phone's file, and over never deleting): Polar's own sync guideline (the SDK's
|
||
`documentation/SyncImplementationGuideline.md`, 8.3.0) asks apps to remove what they
|
||
synced, as soon as possible, to free the device's memory and not lose data, sensors
|
||
such as the Polar 360 holding less than watches; D37 had assumed the band's memory
|
||
rolls over by itself. So once the app has sealed a day (yesterday or earlier) into
|
||
the person's database, the next read deletes it on the band, type by type (activity,
|
||
daily summary, 24/7 samples, sleep and its score, Nightly Recharge, skin temperature
|
||
and skin contact); the background job reads into the file alone and deletes only
|
||
what the app confirmed kept; today is never deleted. Each read is framed by the
|
||
SDK's sync start and stop calls, its calls to the band one after the other, as the
|
||
guideline asks. The VO2max defaults of 7a are Polar's table too: the SDK's
|
||
`documentation/FirstTimeUse.md` gives the same tables, without saying which class
|
||
to take.
|
||
|
||
7b built so (28 Oct), with choices of mine for the developer to look at: home has two
|
||
tabs, *Workouts* and *Days*, on every device, *Days* saying where days come from
|
||
while there are none; the first read of a band goes four weeks back, later ones from
|
||
the day after those kept; the band is read as each connection is ready, at most every
|
||
half hour, and when the person pulls the list; a later read replaces what it brought
|
||
of a date (its summary, Recharge, skin temperature, heart rate, night) and keeps the
|
||
rest, and rewrites nothing unchanged; a day's heart rate goes in chunks by the three
|
||
hours each measurement began in, every row's ID made from the date under the
|
||
account's key (D57: `train/day/<date>`, `train/night/<date>`, `…/heart/<index>`); a
|
||
row that doesn't open on a device is left out of *Days*, as its date is sealed with
|
||
it; what is kept goes up to the day before the latest read's (`keptUntil`, in
|
||
`loop.json` with the last read's time), kept over a new setup of the same band and
|
||
forgotten for another; the background job runs every four hours while the battery
|
||
isn't low and the open app doesn't hold the band, and reads from the same day until
|
||
the app has kept what it wrote, so the file can hold a day more than once, the last
|
||
line winning; the app moves the file aside before reading it and deletes it once
|
||
sealed, so lines written meanwhile wait; the schedule is the phone's, for the last
|
||
person whose app opened with a band, and *Let the band go* stops it. Polar's
|
||
NONREM12 and NONREM3 are shown as light and deep sleep; the chart draws each 24/7
|
||
measurement's mean at its start (a point a minute since D65); a night's times show
|
||
in the phone's time zone. Not yet tried with a Loop: whether the SDK's deletion includes its `until` day, and
|
||
whether the job connects to the band in the background, are for a real band.
|
||
|
||
- **D39 The Loop's training sessions are workouts** (recommended, the developer's; 28
|
||
Oct): the band records a session by itself when it detects one (at least ten minutes
|
||
of raised heart rate and movement, saved as *Other indoor* unless the person picks a
|
||
sport; Polar's manual, *Automatic training detection*), which Polar's SDK reads from
|
||
the band (`PolarTrainingSessionApi`: a summary with start, duration, sport,
|
||
calories, distance and ascent, and samples at an interval: heart rate, and speed,
|
||
distance and cadence where the band had them). Each becomes a sealed, synced
|
||
workout, its heart rate its series, its origin *Polar Loop* with the band's
|
||
reference of the session, as Health Connect's imports are (D31), so there is one
|
||
list and one page, written to Health Connect with the rest (over a model of its own
|
||
under *Days*). Train gains an activity *Other workout* for what it doesn't know,
|
||
which the person can change on the workout's page; Polar's running (sport 1),
|
||
cycling (2) and other outdoor (16, as *Other*) are the IDs the SDK itself names,
|
||
every other sport comes as *Other* until a band shows its IDs (over leaving out
|
||
what Train doesn't know, most of the band's sessions). A session overlapping a
|
||
workout Train recorded for at least half the shorter, whatever the sports, is that
|
||
workout's double: the recorded one stays and gains the band's heart rate where it
|
||
has none (over keeping both). Sessions are read with the days, by the open app and
|
||
the background job, and deleted on the band once sealed by the same rule (D38); one
|
||
deleted in Train is remembered, so it isn't brought back.
|
||
|
||
7c built so (28 Oct), with choices of mine for the developer to look at: a
|
||
multisport session is a workout per exercise, each with the band's reference and its
|
||
index as its ID; the workout's moving time is the session's length, its distance the
|
||
band's sum, its series the heart rate alone (speed and cadence samples are read but
|
||
not kept, as Train has no series for them yet, and a band without GPS has no
|
||
track); a heart-rate sample of 0 is none; every session the band holds is listed at
|
||
each read (they are few, as the kept ones go), those from the read's first day on
|
||
read and those up to the day kept deleted; the sessions taken in are remembered in
|
||
`loop.json` by ID with their date, forgotten a week after that date is kept, and a
|
||
session already kept as a workout (`loop.json` lost) isn't brought again either; a
|
||
double is looked for among the workouts Train recorded alone, not those brought in
|
||
from Health Connect; *Other workout* can't be recorded on the phone (the record sheet
|
||
offers it not), and the edit sheet changes any workout's activity. Polar's training
|
||
sessions are protobuf messages of the SDK's, so the app names protobuf-javalite
|
||
3.20.0, the SDK's own, for compiling (`compileOnly`), adding nothing to the APK.
|
||
Health Connect's writing takes the Loop's workouts as Train's own, its *bring in the
|
||
track* stays for Health Connect's. Not yet tried with a Loop: Polar's other sport
|
||
IDs, and whether the band keeps samples of a detected session, are for a real band.
|
||
|
||
- **D40 How D10's firmware updates go** (recommended, the developer's; 28 Oct): Polar's
|
||
SDK asks Polar's firmware service (`https://firmware-management.polar.com`, `POST
|
||
/api/v1/firmware-update/check` with a client name, the band's UUID made from its ID,
|
||
its firmware version and hardware code; read in the SDK's classes, 8.3.0) and gets a
|
||
version and a file's address. Train asks once a day, on the day's first connection,
|
||
and when the person taps *Check for updates* (over only when asked); an update found
|
||
shows on the Loop screen with its version and what Polar learns, and never starts by
|
||
itself. Through the instance, Train's server asks Polar for the person and fetches
|
||
the file, keeping the latest per hardware and version in memory for a day (over a
|
||
folder on disk, which the deploy would need a volume for); it offers this by default,
|
||
for the people signed in, and its operator turns it off with
|
||
`LOOP_FIRMWARE_RELAY=off` (over off by default, and over always on), the app then
|
||
offering the direct way alone. Before an update Train reads and keeps what is on the
|
||
band; after it, Train sets the band up again at once with the figures it had and sets
|
||
its clock (over asking first), the band getting a new user identifier as with every
|
||
setup (D35).
|
||
|
||
Built so (28 Oct), with choices of mine for the developer to look at: Train's server
|
||
has a `firmware` endpoint (`offered`, `check`, `download`, for the people signed in;
|
||
`LoopFirmwareRefused` where it is off, a figure isn't plain, a file isn't known or
|
||
Polar can't be reached), which names an update only where Polar gives an `https`
|
||
file, and keeps a fetched file under its hardware and version until a day after it
|
||
was fetched. On the phone, the band's date is the phone's today; a band just set up
|
||
is asked as a connection is; where the person chose the instance, Polar is never
|
||
asked directly in its place, even when the relay is off or out of reach (the
|
||
check fails then, and the screen says so), and the switch shows where the instance
|
||
relays or was chosen. An update through the instance asks the relay again, writes
|
||
the file into the person's folder (`loop_firmware.zip`), hands it to the SDK as a
|
||
`file://` address and deletes it afterwards. A read failing before the update
|
||
leaves the band untouched; after the writing, Train connects again, waits up to five
|
||
minutes for the band, sets it up with the figures it had (the profile's as they are
|
||
where none are known) and sets its clock, and takes the version offered as the
|
||
band's until it tells its own. The screen shows each stage the SDK tells while it
|
||
writes. Not yet tried with a Loop: whether the version in `/DEVICE.BPB`
|
||
(major.minor.patch) is what Polar's service expects, whether the band connects again
|
||
by itself after its restart, and whether the SDK takes the relayed file as it takes
|
||
the one it fetches.
|
||
|
||
- **D41 The Loop's live heart rate in a recording (7d)** (recommended, the developer's;
|
||
28 Oct): Polar's SDK names live heart rate (beats per minute) among the Loop's and
|
||
the Polar 360's features (its `Polar360.md`), with PPI, PPG, acceleration and skin
|
||
temperature streams. A recording with a Loop remembered takes its heart rate from
|
||
the strap where one is remembered and switched on, and from the Loop where there is
|
||
none, or while the strap is lost, so that the gap fills (over the strap alone where
|
||
remembered, and over choosing one for each recording): a chest strap reads a workout
|
||
more truly than the wrist (D32). The recording service reads the Loop through
|
||
Polar's SDK (`startHrStreaming`), which already holds the band's connection, the
|
||
SDK moving into a holder the open app and the service share, so that the band stays
|
||
connected with the screen off and the app gone (over the service's own connection
|
||
to the band's Heart Rate Service, as for a strap, a second client on the SDK's
|
||
connection, the two fighting over its notifications). It keeps the heart rate
|
||
alone, as `h` lines like the strap's (over pulse-to-pulse intervals too, which
|
||
nothing uses yet and which cost battery). The Heart rate row names the source.
|
||
|
||
Built so (28 Oct), with choices of mine for the developer to look at: Polar's SDK is
|
||
one for the app's process (`PolarSdk.kt`), held by the open app's bridge and by a
|
||
recording reading a Loop, shut down as the last lets go; the background job (D37)
|
||
leaves the band alone while either holds it, and the app opening again during a
|
||
recording finds the band connected and is told so rather than connecting anew. The
|
||
app doesn't disconnect a band a recording reads. The SDK now asks for `FEATURE_HR`
|
||
as well. A recording takes the Loop whenever the person has one set up, the strap's
|
||
switch on the Record sheet switching off the strap alone (so a strap left at home
|
||
is no gap); the Loop's rate is written only while the strap isn't connected, as
|
||
`h` lines, the band's state as `loop <time> connecting|connected|lost` lines (the
|
||
file's version stays 1, as `strap` lines did), and a rate read off the skin is
|
||
none. The stream is asked for again five seconds after it stops (`LoopHeart.kt`,
|
||
tested on the JVM). The screen shows *Strap lost* only while the Loop isn't
|
||
sending in its place. A rower's own heart rate is kept only where neither a strap
|
||
nor a Loop is there. Not yet tried with a Loop: whether its live rate needs the
|
||
band in a mode of its own, and how far the wrist's rate lags a strap's.
|
||
|
||
- **D42 Interval plans, and who runs them** (recommended, the developer's; 28 Oct): a plan
|
||
is a name and a list of steps (warm-up, work, recovery, cool-down) with repeat
|
||
blocks; each step ends after a time, after a distance, or with a lap tap, and may
|
||
aim at a target: a pace or speed range (from GPS), a heart-rate range in bpm or one
|
||
of five zones, and on the rower a split, watts or a stroke rate (over pace and heart
|
||
rate alone). The zones are Polar's and most watches': 50–60, 60–70, 70–80, 80–90 and
|
||
90–100 % of the profile's highest heart rate, its default from age where unset, as
|
||
for the band (over Karvonen's heart-rate reserve, which needs a resting rate). Plans
|
||
are Train's synced, sealed `plan` rows, so one made on the desktop reaches the
|
||
phone (over the person's folder alone), with an editor and a few templates to start
|
||
from (4×4 min, Tabata 8×20/10 s, 10×400 m; over none). The recording service runs
|
||
the plan, sent to it as the recording starts: it moves from step to step on its own
|
||
moving time and distance, pauses stopping a step's clock, and writes `step` lines
|
||
to the recording file, which the app reads (over the open app running it, which
|
||
Android may stop with the screen off). Every step change gives a tone, a vibration
|
||
and, where the phone has a text-to-speech engine (GrapheneOS ships none; RHVoice or
|
||
eSpeak from F-Droid give one), a spoken line naming the step, its length and its
|
||
target; a timed step counts its last three seconds down; off target, a short cue at
|
||
most every 30 s, switchable per plan (over tones alone, and over no off-target
|
||
cues). A step on the rower doesn't set its resistance yet (over setting it through
|
||
FTMS's control point): whether the WP 5000 speaks FTMS is still unknown (D8).
|
||
Built: the plans, their tab, their editor and the templates (8a, `c167e9b9`); the
|
||
service running one (8b). The app sends the run spelled out as the recording
|
||
starts, each step with its end, its target (a zone as the heart rates it spans for
|
||
the profile's highest rate, 220 less the age where unset, 180 bpm where neither is
|
||
given) and its spoken line in the app's language, which the service writes into the
|
||
file as a `plan` line with the plan itself; `PlanRunner.kt` moves through the steps
|
||
on the moving time and distance, the off-target cues after a step's first 15 s and
|
||
from a 30 s window of GPS (or the rower's frames, or the last 5 s of heart rate,
|
||
stroke rate or watts); `PlanCues.kt` gives the tone, the vibration and the voice,
|
||
ducking music. The recording screen shows the step, what is left of it, its target,
|
||
the next step and *Lap*. Not tried on a phone yet: the voice's timing over music,
|
||
and how a GPS pace off target feels at 30 s.
|
||
- **D43 What an interval workout keeps** (recommended, the developer's; 28 Oct): the
|
||
workout keeps the plan's name and its steps as run, a lap each (when, how long, how
|
||
far), sealed in its payload; its page lists each step with its time, distance,
|
||
average pace or speed and heart rate, on the rower its split and watts, marked where
|
||
it held its target, and shades the charts by step (over laps without targets, and
|
||
over no trace of the plan).
|
||
Built (8c): the laps are worked out as the recording is saved, from the file's
|
||
`step` lines, each step's moving time and distance counted as the workout's are
|
||
(a step cut short by Stop is kept as far as it went, one the plan never reached
|
||
isn't); the highest heart rate the zones ran with is kept beside them, so that a
|
||
zone held is judged on the rates of that day. A step held its target where its
|
||
average (its pace, speed or split over its moving time and distance, its heart
|
||
rate, stroke rate or watts) lies within it. The charts shade the work steps.
|
||
Health Connect gets the session as before, without laps for now.
|
||
- **D44 Planning a route** (recommended, the developer's, but the region; 28 Oct):
|
||
waypoints are tapped on the map, dragged, the last one undone, the first where the
|
||
phone is; searching for places comes later, as a Photon sidecar of its own (Apache-2.0;
|
||
over Photon now, Germany's database alone a 7.5 GB download and a few GB of RAM, and
|
||
over komoot's public instance, which sees the places searched and promises nothing).
|
||
The instance's BRouter and PMTiles sidecars cover **Europe** (the developer's choice,
|
||
over Germany alone, recommended for its few GB, and over Germany, Austria and
|
||
Switzerland): tens of GB of segments and tiles on the server, refreshed monthly; the
|
||
sizes are written into the deploy's notes when it is built. Six profiles: Run, a
|
||
profile Train writes (paths, parks and quiet roads first, any surface; BRouter has
|
||
none for running), Hike (`hiking-mountain`), Road bike (`fastbike`), Trekking bike
|
||
(`trekking`), Gravel (`gravel`) and MTB (`mtb`), the route's activity choosing one by
|
||
default (over BRouter's alone, its hiking profile sending runners up steps, and over
|
||
one profile an activity). As it is planned, a route shows its line, its distance, its climb and
|
||
descent, an elevation profile under the map and the surfaces it runs on as BRouter
|
||
tells them (over distance and climb alone, and over loops of a given length, which
|
||
BRouter can't make and Train would only guess at).
|
||
Built: the `routing` endpoint and the deploy (9a, `ab128550`, `7fcb91c6`); routes
|
||
as synced, sealed `route` rows and the planner (9b). A route's payload holds its
|
||
name, activity, profile, waypoints and the way planned (points to a micro-degree
|
||
and elevations to a decimetre, each delta-coded, its turns, its stretches),
|
||
gzipped as a workout's series are, since a planned route has thousands of points.
|
||
The server's model is `SavedRoute` on the table `route`, since Flutter's widgets
|
||
have a `Route` of their own. Home's fourth tab lists the routes; the planner is the
|
||
map above and the route's figures under it: a tap adds a waypoint, one held and
|
||
dragged moves it (held first, so that dragging the map never moves one), the
|
||
button above the map takes the last off, and the instance plans the way after each
|
||
change, an answer to an earlier change dropped once a later one is asked. A new
|
||
route starts where the phone is: the recorder's channel asks GPS for its last fix
|
||
of the past two minutes or a new one (15 s at most), the location permission asked
|
||
first (no package, as recording takes no other); on a device that can't tell it
|
||
starts empty. Surfaces are grouped from OpenStreetMap's tags as paved, compacted,
|
||
gravel, natural ground and not known. An instance without BRouter says so on the
|
||
planner.
|
||
- **D45 Following a route** (recommended, the developer's; 28 Oct): the recording
|
||
screen keeps its figures first and swipes to a map with the route, the track so far
|
||
and the position, north or heading up as tapped, with a route or without (over the
|
||
map always above the figures, and over no map). The recording service guides,
|
||
offline, as it runs a plan: BRouter's turn hints spoken about 50 m before a turn (on
|
||
a bike about 100 m) after the tone, the next turn shown; more than 40 m off the route
|
||
for 10 s, a cue and the way back (the direction and distance to the route's nearest
|
||
point), told again on rejoining (over cues off route alone, and over rerouting by
|
||
itself online). Maps offline along a route on request: *Keep offline* downloads its
|
||
corridor, 2 km each side at zooms 10 to 16, on that device alone, until switched
|
||
off (over every route's on every phone, and over none yet). Routes go out as GPX
|
||
(to CoMaps or anything, with waypoints and turn hints) and come in as GPX shared to
|
||
Train or opened in it, snapped to a profile or kept as drawn (over out alone, and
|
||
over neither yet).
|
||
Built: following in a recording (9c). The Record sheet offers the routes with a
|
||
way planned, for an activity with GPS, and a route for an activity chooses it, as a
|
||
plan does. The app hands the service the route's points, its turns each with its
|
||
line in the app's language ("In 50 metres: Turn left"; BRouter's beelines and
|
||
off-route marks aren't turns, and are left out), and the words off the route with
|
||
places for the figures, through a file beside the app's (`route.json`), since an
|
||
intent carries less than a route's thousands of points. `RouteGuide.kt` places each
|
||
fix precise enough on the route (among its next 500 m and last 100 m first, the
|
||
whole route where none is near, so that a shortcut or a figure eight finds its
|
||
place), none while paused; a turn is told once, 50 m (100 m on a bike) along the
|
||
route before it, a tone first and the line 0.4 s after; one passed unseen is
|
||
skipped. Off the route: past 40 m for 10 s, a double vibration and the way back
|
||
("It lies 80 metres to the north-east", to 10 m, to the nearest point), again
|
||
every minute while still off (a runner off for long would otherwise hear it once),
|
||
told again once back within 30 m (so that a fix wavering about 40 m doesn't cue
|
||
over and over); no turn is told while off. The end is told once within 30 m of it.
|
||
The file keeps the route (`route`, its payload) and what following it gave
|
||
(`guide … next|turn|off|on|arrived`), so a recording the app comes back to shows it
|
||
again. The recording screen shows the next turn with how far it is as the crow
|
||
flies (the service places the person, the app only reads its lines), or the way
|
||
back; it swipes to a map of the route, the track so far (the fixes precise enough
|
||
to count) and the position, which the map follows, zoomed with two fingers alone
|
||
so that a swipe goes back to the figures, north up by default and the heading up
|
||
(of the last 5 m or more) as tapped. A saved workout doesn't keep which route it
|
||
followed yet.
|
||
Built: maps offline (9d's first half). A saved route's page has *Keep the map
|
||
offline*; on, `OfflineMaps` (in the person's folder, `offline_maps/`, never synced)
|
||
asks the source for every tile within 2 km of the way (`corridorTiles`: the tiles
|
||
under a square of 2 km each way around a point every 250 m, a band at least 2 km
|
||
either side) at zooms 10 to 16, but never above the zoom the source has (OpenFreeMap
|
||
has 14, the map overzooms from there), four at a time, a tile shared by two routes
|
||
kept once and deleted only once neither keeps it; the page counts them as they come
|
||
and then says how much the device holds, and the list marks the route. The style,
|
||
its sources' TileJSON and its sprites are kept each time they are read, and the
|
||
instance's map source too (`map_source.json`), so that a map draws with no network
|
||
at all; the map reads a kept tile from the device before the network
|
||
(`keptVectorTiles`), which is why the app now builds vector_map_tiles' style itself
|
||
(with vector_tile_renderer's readers): its `StyleReader` reads only from the
|
||
network. A tile that didn't come is counted and asked for again the next time the
|
||
app opens online, and one the source has none of (204, 404: the sea) is nothing
|
||
missing. A kept route that changes, here or on another device, or whose map source
|
||
changes, is kept again along its new way, the old way's tiles deleted; one deleted
|
||
is let go; one that doesn't open on this device stays. Only a saved route can be
|
||
kept, since it is the saved way that is kept. Not done: a size limit or a warning
|
||
before a long route's download (a 200 km ride at OpenFreeMap's zooms is some 700
|
||
tiles, a few tens of MB), and keeping a map only on Wi-Fi.
|
||
Built: GPX out and in (9d's second half). A route's page hands it on as GPX 1.1
|
||
(`gpx.dart`): on Android to the share sheet (`RouteFiles.kt`, through a FileProvider
|
||
that shares only the one file written for it, in the cache's `shared/`), elsewhere
|
||
saved where the person chooses (halkyon_app's `FileSaver`); its way as a track with
|
||
the elevations, its waypoints as `wpt` of the type `waypoint` and its turns as `wpt`
|
||
of the type `turn`, named in the app's language (beelines left out), which CoMaps
|
||
and others show as points along the track. Routes come in from *Bring in a GPX
|
||
file* in the Routes tab (file_selector's dialog, as Learn's) and, on Android, from a
|
||
GPX file opened with Train or shared to it (by the type `application/gpx+xml` where
|
||
the sender names it, by a name ending `.gpx` where it says only that it is a file;
|
||
at most 20 MB, a file arriving before the app listens kept until it does). Any
|
||
version of GPX is read: the first track, its segments joined, else the first
|
||
route; elevations only where every point has one. The person then chooses: *Plan
|
||
it along the paths*, through Train's own waypoints where the file is Train's, a
|
||
route's points where it has a track as well, else up to 50 points spread along the
|
||
way at least a kilometre apart, planned at once with the running profile (changed
|
||
on the page, which plans it again); or *Keep it as drawn*, the file's way with its
|
||
distance and climb worked out as a recording's (climbs above 3 m), no turns told.
|
||
Either opens as a new route to be named and saved; a drawn route that is changed
|
||
is planned along the paths from then on. A file without a route says so.
|
||
- **D46 Train's server on the instance** (recommended, the developer's; 28 Oct): it
|
||
joins learn.halkyon.eu's server, as BRouter needs a server of Train's to ask it
|
||
(D11, D22), at **train.halkyon.eu** (over a path on learn.halkyon.eu, which would
|
||
carry Learn's name for good); its data in the instance's PostgreSQL as a database of
|
||
its own, `halkyon_train`, under a role that can't connect to Learn's (over a second
|
||
PostgreSQL, twice the memory and a second backup); a **member of the instance's
|
||
register** on Learn's server (ADR 0016: one account a person across the operator's
|
||
services, over a register of its own). It is a compose file of its own,
|
||
`deploy/compose.train.yaml`, switched on in `.env` as the sidecars are, so nothing
|
||
changes on the instance until the operator does (the steps in
|
||
`deploy/sidecars/README.md`, *Train*); `redeploy.sh` then builds, dumps, migrates
|
||
and waits for it as for Learn's server, and `backup.sh` backs its database up in the
|
||
same snapshots. BRouter (v1.7.10, its release checked by SHA-256) runs beside Train's
|
||
server alone, on a network without a way out, with no log (it writes the waypoints),
|
||
its segments refreshed monthly by a container of its own; Europe's took 3.3 GB on
|
||
28 Oct. Whether the server's disk has room is the developer's to check before
|
||
switching it on. The map stays OpenFreeMap's: the PMTiles sidecar needs a style for
|
||
Protomaps' schema, a step of its own (about 40 GB for Europe, estimated from the
|
||
planet's 120 GB). Learn's server signs in to PostgreSQL as its superuser, so it could
|
||
read Train's database; a role of its own for it is left for later. restic now forgets
|
||
snapshots grouped by host alone, so that the snapshots before Train's dump joined
|
||
them aren't kept for good as a group of their own.
|
||
- **D47 Music from Jellyfin** (recommended, the developer's; 29 Oct; D4's how):
|
||
- **Signing in**: with Jellyfin's Quick Connect (Train shows a code, the person
|
||
approves it in another Jellyfin client, Train gets a token of its own), else with
|
||
the user name and password where the server has Quick Connect off (over the
|
||
password alone, which always passes through Train, and over an API key, which only
|
||
an administrator makes and which carries an administrator's rights).
|
||
- **Which tracks**: those of the Jellyfin playlists the person marks for running in
|
||
Train's settings (over the whole library, hours of downloads and analysis on a large
|
||
one, and over genres, only as good as the library's tags).
|
||
- **Kept on this phone only**: the token in secure storage, the server's address, the
|
||
marked playlists and each track's tempo in a file of the person's on the device,
|
||
nothing on Train's server (over sealed, synced rows, a synced model and its
|
||
migration so that a second phone skips the analysis; music plays on phones only,
|
||
D5). A second phone signs in and analyses again.
|
||
- **Offline**: the marked playlists' tracks are downloaded on unmetered networks only
|
||
and kept up with the playlists; the settings show their size and delete them (over
|
||
fetching what a run needs as it starts, which makes the start wait on the network
|
||
and a free run guess its length, and over streaming, which D4 ruled out).
|
||
- **Tempo by Train's own analysis, in Kotlin** (over aubio, GPL-3 C, which needs a
|
||
native build per ABI; TarsosDSP, GPL-3 Java, unmaintained since 2021; Essentia,
|
||
AGPL-3 C++, tens of MB and a heavy build): Android's decoder reads a minute of the
|
||
downloaded track, an onset envelope and its autocorrelation give the beats per
|
||
minute, tested on the JVM with beats of known tempo. A BPM tag the file carries is
|
||
taken first.
|
||
- **Played by Media3's ExoPlayer in the recording service** (Apache-2.0, AndroidX;
|
||
over just_audio and audio_service in Flutter, whose engine may stop with the
|
||
activity and whose player would talk to the service's cues and plan runner across
|
||
the channel): beside the cues, which duck it, and the plan runner, it plays on with
|
||
the screen off and picks the next track there.
|
||
- **Cadence followed**: the one the step detector measures, smoothed over 30 s,
|
||
without a plan and for a step aiming at heart rate or the like; for a step aiming
|
||
at a pace, the cadence the person's own past runs show at that pace (steps against
|
||
speed), the measured one until three runs show it (over the measured cadence alone,
|
||
which never pulls the person towards the step's pace, and over a cadence set by
|
||
hand). A track fits at its tempo, its half or its double, sped up or slowed down by
|
||
at most **6 %** with the pitch kept (over 10 %, where voices start to sound off, and
|
||
over none, which needs a large playlist). A metronome at the cadence plays where no
|
||
library is set (D4).
|
||
- In parts: 10a signing in to Jellyfin and marking playlists in the settings; 10b
|
||
downloading the marked tracks and working out their tempo; 10c playing them in a
|
||
recording, by cadence, and the metronome.
|
||
Built: 10a. *Music* in the account sheet, on Android: the server's address (https
|
||
where none is typed; a path kept, as for a server under `/jellyfin`), then Quick
|
||
Connect's code, asked about every 3 s and given up after 9 minutes (Jellyfin forgets
|
||
a request after ten), or *Use a password*, which a server with Quick Connect off
|
||
asks for at once; Quick Connect as Jellyfin 10.9 and later have it. Train signs in
|
||
as the client "Halkyon Train" with a device ID of its own, made once and kept, so
|
||
that the server lists it as a device of the person's. The token is in the keyring
|
||
under the person's ID, and the tokens of people the device no longer keeps are
|
||
forgotten as the library opens; a token Jellyfin takes back signs the person out
|
||
on the phone too. Signing out ends the token on the server where it answers. The
|
||
person's playlists of music are listed (a playlist naming no type counts as one,
|
||
one of videos doesn't) and marked, the marked ones by ID and name in `music.json`.
|
||
Built: 10b. The marked playlists' tracks are kept in the person's `music/`, each
|
||
once whatever the playlists holding it, as Jellyfin keeps the file (`/Audio/…/stream`
|
||
with `static`, no transcoding; a smaller copy on request is left for when a library
|
||
shows the need), through a part file renamed once whole. Downloading is the app's
|
||
own, while it is open or resumed, where Android calls the network unmetered (its own
|
||
verdict, not merely Wi-Fi: a phone's hotspot is metered): the token is in the app's
|
||
keyring, which a background job of Android's couldn't read without a copy outside
|
||
it. What no marked playlist holds any more is deleted at once, offline too; a track
|
||
taken out of its playlist on the server goes at the next sync, a playlist that
|
||
can't be read keeps its tracks, a track that fails to download is tried again at
|
||
the next. Each track's tempo is worked out once after its download, on the phone
|
||
(`MusicBridge.kt`): an ID3v2 `TBPM` frame or a FLAC `BPM` comment where the file has
|
||
one (`BpmTag.kt`; from 40 to 250 only), else a minute from the middle decoded by
|
||
Android's MediaCodec, mixed down to mono, its spectral flux (about 86 frames a
|
||
second) autocorrelated over 50 to 220 beats per minute and weighed towards 120 by
|
||
an octave either side (`Tempo.kt`), none where the beat repeats too weakly; tested
|
||
on the JVM with kicks of known tempo (within a beat a minute, hi-hats on the
|
||
off-beat not doubling it), noise and silence having none, and tags cut short. The
|
||
music screen shows what the phone keeps (tracks, size, how many have a tempo, the
|
||
wait for an unmetered network) and *Delete*, which after asking unmarks every
|
||
playlist. Not tried on a phone yet: decoding real files, how often the analysis
|
||
lands an octave off (10c counts half and double as fitting).
|
||
- **D48 Music in a recording** (recommended, the developer's; 29 Oct; D47's 10c):
|
||
- **A workout keeps its cadence** as a series of its own, `cadence` (steps a minute,
|
||
a sample every 10 s while moving), sealed and synced as heart rate is; an app
|
||
before it skips the kind, so nothing migrates (over each step's time, some 50 KB an
|
||
hour for detail nothing needs, and over the total alone, too little to learn
|
||
cadence against pace). It is Health Connect's StepsCadenceRecord, to be written
|
||
there later.
|
||
- **The Record sheet offers Music, Metronome or Off** on foot (running, walking,
|
||
hiking), remembered for next time; Music once some kept track has a tempo (over a
|
||
switch with the metronome as its fallback, which leaves no metronome to a person
|
||
with a library).
|
||
- **A change of cadence retunes, else crossfades**: within 6 % the playing track is
|
||
sped up or slowed down at once, beyond it the player fades over 3 s into a track
|
||
that fits; a plan's new step picks a fitting track at once (over playing the track
|
||
to its end, minutes off the beat).
|
||
- **Media3 1.10.1 pinned by Gradle's own checksums** (`--write-verification-metadata
|
||
sha256`, trusted on first download from Google's Maven, as Polar's SDK was), the
|
||
entries added listed in the commit for the developer to review.
|
||
- Built: 10c. A recording's `cadence` series is its steps counted in 10 s windows
|
||
from its start, a window touching a pause left out (`cadenceOf`); the steps are now
|
||
counted on foot whatever auto-pause listens to. The cadence a run's music starts at
|
||
is learnt from the person's last ten recorded runs (`cadenceModelOf`, once three of
|
||
them have twelve cadence samples with a precise speed each): steps a minute as a
|
||
line in speed, its slope kept between 0 and 25, flat where the runs' speeds barely
|
||
differ, and a plan's steps get the cadence of their target pace or speed from it
|
||
(a step's `cadence`); a walk or a hike starts at 110, a run without the model at
|
||
165. In the service (`MusicConductor.kt`, tested on the JVM) the cadence is the
|
||
steps of the last 30 s once they span 10 s, forgotten after a pause or a mark,
|
||
asked every 5 s; a change under 2 steps a minute does nothing. A track fits at its
|
||
tempo, half or double it; among those fitting, each plays once before any plays
|
||
again; where none fits, the nearest plays as near as 6 % allows; without tracks the
|
||
metronome clicks at the cadence (an AudioTrack's static loop). `MusicPlayer.kt`
|
||
plays with Media3's ExoPlayer, the pitch kept, the playing player holding the
|
||
audio focus so that the cues duck it; a new track fades in by a second player over
|
||
3 s; a file that won't play counts as ended. A pause by hand pauses the music,
|
||
auto-pause doesn't (a red light shouldn't stop the song). Music chosen where the
|
||
phone no longer keeps a track with a tempo shows and plays nothing. Not tried on a
|
||
phone yet: the crossfade, the ducking under the cues, the metronome's click, how
|
||
steady the cadence feels.
|
||
- **D49 What OpenTracks does that Train didn't** (recommended, the developer's; 29 Oct;
|
||
step 11, D1's "at least what OpenTracks does"):
|
||
- **Announcements every km on foot, every 5 km by bike** (11a), never on a rower: the
|
||
time, the distance, the last split's pace or speed and the heart rate where a strap
|
||
or the Loop sends one, switched on the Record sheet and remembered for each activity;
|
||
one due while a plan's or a route's cue speaks waits for it (over an interval chosen
|
||
on the sheet, or the figures chosen on a settings page Train doesn't have).
|
||
- **A workout goes out as GPX, TCX or FIT** (11b) from its page through the share
|
||
sheet: GPX 1.1 with Garmin's TrackPointExtension (heart rate, cadence), TCX (laps,
|
||
heart rate, cadence, power, a row's distance) and FIT (binary, what Strava, Garmin
|
||
and intervals.icu read best, laps and power included), each written in Dart and
|
||
tested against the formats' specifications (over text formats alone, which lose
|
||
FIT's readers' best import).
|
||
- **The barometer's air pressure is kept raw** (11c) as a series of its own,
|
||
`pressure`, where the phone has a barometer; the page works out elevation from it,
|
||
its changes from the pressure, its level from the GPS altitudes' median, and the
|
||
climb from that elevation (over correcting each fix as it is recorded, which would
|
||
lose the readings a better filter could use later).
|
||
- **A bike's speed and cadence sensor (CSC, 0x1816) and power meter (CPS, 0x1818)**
|
||
(11d), one of each chosen on the Record sheet as the strap is and remembered: the
|
||
wheel's speed gives the distance where GPS can't (a turbo trainer, a tunnel), the
|
||
wheel's circumference learnt from GPS on the first rides or typed in; the crank's
|
||
cadence and the watts are series (`bikeCadence`, `power`), live and on the page with
|
||
average and normalised power.
|
||
- Built: 11a. The words come from the app (`recorderAnnouncementsOf`, in
|
||
`announce_speech.dart`) in its language: the first hundred distances spelled out
|
||
(further on as digits), what each count of hours, minutes and seconds is said as,
|
||
and each sentence with its placeholder; the service (`Announcer.kt`, tested on the
|
||
JVM) counts the moving time and the distance the plan's measures keep (paused fixes
|
||
left out), and on each distance passed says it, the moving time (hours and minutes
|
||
after the first hour, else minutes and seconds), the split's pace or speed (the
|
||
number in the app's language) and the heart rate of the last 5 s; a jump past
|
||
several (GPS lost in a tunnel) says the last. Spoken without a tone, queued after a
|
||
cue that speaks; a cue that comes meanwhile cuts it short, as cues come first. Off
|
||
for every activity until switched on (`announce.json`, `AnnounceStore`, never
|
||
synced). Not tried on a phone yet: how it sounds over music, eSpeak's and
|
||
RHVoice's reading of the German sentences.
|
||
- Built: 11b. *Export* on a workout's page (where the device hands files on) offers
|
||
GPX (only with a track), TCX and FIT, and hands the file to the share sheet (saved
|
||
where the person chooses on the desktop), named `halkyon-train-` and when it
|
||
started in UTC. Each is written from the same points (`workout_export.dart`): the
|
||
precise fixes while moving as the saved distance counts them, else the rower's
|
||
frames, else the heart rates; with the heart rate of the last 5 s and the cadence
|
||
of the window the point falls in, on foot in strides (half the steps, as Garmin's
|
||
devices write a run's cadence), a row's strokes, its watts; a plan's steps are the
|
||
laps (recovery *Resting* in TCX, FIT's intensities for warm-up and cool-down), else
|
||
one lap. GPX has a segment each piece between pauses; TCX writes the schema's order;
|
||
FIT (`fit_encoder.dart`, protocol 1.0, little-endian, profile 21.00, CRC-16/ARC) the
|
||
file ID as a development manufacturer's, the timer's start, stops and starts at
|
||
the pauses, a record each point (semicircles, centimetres), a lap each, the session
|
||
(a row as rowing indoors) and the activity. Tested by reading the files back: the
|
||
XML parsed, the FIT by a reader written from the specification in the tests
|
||
(`test/support/fit_reader.dart`), not by Garmin's SDK. Not tried yet: an upload to
|
||
Strava, intervals.icu or Garmin Connect.
|
||
- Built: 11c. Where a recording has GPS and the phone a barometer, the service averages
|
||
its readings over 5 s (`PressureAverage.kt`, tested on the JVM) into the file's `b`
|
||
lines and the workout's `pressure` series (`dpa`, tenths of a pascal, some 2 KB an
|
||
hour sealed). The page, Health Connect's totals and the files out read the fixes'
|
||
altitudes from it (`withBarometer`): each fix's height by the standard atmosphere at
|
||
the pressure then (between two readings as it went), moved by the median of GPS's
|
||
altitudes less those, so that its changes are the barometer's and its level GPS's;
|
||
its climbs count above 1 m rather than GPS's 3 m (the barometer's noise, averaged,
|
||
a fraction of a metre). A workout recorded before keeps GPS's altitudes. Not tried
|
||
on a phone yet: weather fronts over a long ride (a change of 1 hPa an hour reads as
|
||
some 8 m), and pockets' gusts.
|
||
- Built: 11d's sensors. A ride's Record sheet offers the speed and cadence sensor and the
|
||
power meter, each found nearby and remembered as the strap is (`speed_sensor.json`,
|
||
`power_meter.json`; the person's devices now one map, `DeviceKind` to `DeviceStore`).
|
||
The service reads them on Android's Bluetooth API as it reads the strap
|
||
(`CyclingSensors.kt`, tested on the JVM: CSC Measurement 0x2A5B, Cycling Power
|
||
Measurement 0x2A63 with the fields before the crank's skipped as the flags say, the
|
||
crank's cadence from two events over both counters' rollover, 0 after 3 s without a
|
||
turn, the wheel's revolutions counted from the first frame); the file's `w`, `c`,
|
||
`pw`, `csc` and `power` lines, the series `wheel`, `pedalling` and `power`. The crank's
|
||
cadence comes from the sensor, else from the power meter. A plan's watts target reads
|
||
the power meter too. The recording screen shows the cadence and the watts of the last
|
||
5 s; the page the power's chart, average and highest (as the rower's), the normalised
|
||
power (Coggan's: the watts a second, the last reading held 5 s, their 30 s rolling
|
||
mean, the fourth root of its fourth powers' mean; none under a minute) and the
|
||
cadence's chart and average (coasting left out of the average); the files out carry
|
||
the crank's cadence and the watts. Not yet: Health Connect's power and cadence
|
||
records. Not tried with a real sensor yet.
|
||
- Built: 11d's wheel. The service writes the wheel's circumference a ride starts with
|
||
(`wheelsize` line, the `wheel` series' `mm` column); a ride's distance, splits,
|
||
progress and live speed are the wheel's where GPS made less than 90 % of the wheel's
|
||
way (a turbo trainer, a tunnel, no fix), else GPS's (`usesWheel`, `rodeByWheel`).
|
||
The circumference starts at 2105 mm (700×25C) and is learnt from rides with at least
|
||
2 km of GPS covering the ride (D56) whose quotient falls between 1 and 3 m: the
|
||
first sets it, each later one moves it a quarter of the way (`measuredWheelMm`); one
|
||
typed on the Record sheet (1000 to 3000 mm) stays until cleared, which measures
|
||
again. Auto-pause still follows GPS alone on a trainer.
|
||
- **D50 The instance's own map and searching for places** (recommended, the developer's
|
||
but the search's region; 29 Oct; step 12, what D22, D23 and D44 left for later):
|
||
- **Tiles** (12a): Protomaps' basemap (schema v4, zooms 0 to 15, ODbL, OpenStreetMap's
|
||
credit) cut for Europe (the BRouter region's box) from Protomaps' daily build with
|
||
`pmtiles extract` and served as ordinary vector tiles by `pmtiles serve` (go-pmtiles
|
||
v1.31.2, BSD-3-Clause, its release checked by SHA-256), both in an image of the
|
||
deploy's (`deploy/maps/`), a sidecar switched on in `.env` as Train is
|
||
(`compose.maps.yaml`); the cut renewed monthly, the new file written beside the old
|
||
and moved over it. The planet's build was 139 GB on 29 Oct; Europe's cut is to be
|
||
measured on the server (some 45 GB estimated), twice that while a new one is cut.
|
||
Over OpenFreeMap, which stays the source where the sidecar is off: no one outside
|
||
the instance sees the areas viewed.
|
||
- **Train's own style** (the developer's, over Protomaps' basemaps style, which needs
|
||
Node to make, its sprites hosted, and uses expressions vector_map_tiles' renderer
|
||
may not draw): written in Dart in Train's server (`train_style.dart`), light and
|
||
dark (the app's theme choosing), for running and riding: land, woods, parks, water,
|
||
roads by class with paths, tracks and cycleways set apart, rail, buildings at high
|
||
zooms, boundaries, and the names of places, roads and water in the app's language
|
||
where the tiles have it; no points of interest' icons at first. The server serves
|
||
it (`/map/style/light.json`, `dark.json`, public: nothing in it is secret), and a
|
||
test holds it to what vector_tile_renderer's reader takes without a warning.
|
||
- **For people signed in alone** (the developer's, over public tiles, which anyone
|
||
could spend the server's bandwidth on): `map.source` hands a key with the source
|
||
(`MapSource.tileKey`, a day count and an HMAC of it under the instance's
|
||
`mapKeySecret`, valid 30 days, naming no one, so that a tile asked for points at no
|
||
person), which the app sends as `Authorization: Bearer` with each tile; Caddy asks
|
||
Train's web server about each tile (`forward_auth`, `/map/tile-key`) before passing
|
||
it on. Neither logs a tile: what was asked shows the area viewed.
|
||
- **Searching** (12b): a Photon sidecar (v1.3.0, Apache-2.0, Java 21) on GraphHopper's
|
||
weekly database of **Europe** (the developer's choice, over Germany alone,
|
||
recommended for its 7.5 GB, and over no search yet): a 32.6 GB download on 29 Oct,
|
||
unpacked larger (to be measured), renewed monthly, its memory several GB, the
|
||
server's disk and memory the developer's to check before switching it on. As
|
||
BRouter: no site, reached by Train's server alone (a `search` endpoint, for people
|
||
signed in, limited, nothing kept or logged), on a network without a way out.
|
||
- **In the planner** (the developer's): a search field over the map; results nearest
|
||
the map's centre first, in the app's language; one chosen becomes the next waypoint
|
||
and the map moves to it (over moving the map alone).
|
||
- Built: 12a. Train's server: `MAP_TILES_URL` (set by `compose.maps.yaml`) makes the
|
||
instance's own tiles the source, before `MAP_STYLE_URL`; `MapSource` gained
|
||
`darkStyleUrl` and `tileKey` (both null for other sources); `InstanceMap`
|
||
(`lib/src/map/instance_map.dart`) hands the key with `map.source` (`MapKeys`,
|
||
`m1.<day>.<HMAC>`, the same all day, refused once its day has passed or changed in
|
||
a byte, compared in constant time), serves the style at `/map/style/light.json` and
|
||
`dark.json` (`?lang=en|de`) and answers Caddy's check at `/map/tile-key` (204 or
|
||
401; never where the tiles aren't the instance's own); without a `mapKeySecret` of
|
||
32 characters it doesn't start where it serves its own tiles. The style
|
||
(`train_style.dart`): 28 layers, expressions vector_tile_renderer reads (`match`, `coalesce`,
|
||
`interpolate`, `geometry-type`), tracks brown and dashed, footpaths dotted in rust,
|
||
cycleways teal; a test keeps copies in the app's fixtures, which the app's tests
|
||
read with vector_tile_renderer and no warning. The app picks the dark style in a
|
||
dark theme, adds the language to the instance's own, and sends the key with each
|
||
tile and with a kept route's (`map_style.dart`); kept styles are now one folder each
|
||
(`style/<hash>/`), so a style kept before this is read anew once online. The page
|
||
names the instance's own map as keeping nothing of the area shown. The deploy:
|
||
`deploy/maps/` (go-pmtiles on Alpine, `tiles.sh` cutting Europe's box,
|
||
`-25,35,45,75`, from the newest build each month), `compose.maps.yaml` (the tiles
|
||
beside Caddy alone on an internal network, unlogged; the cut on the downloads
|
||
network), `sidecars/train-maps.caddy` imported into Train's site; the operator's
|
||
steps in `deploy/sidecars/README.md`, *Train's map*. Found on the way:
|
||
`.dockerignore` left all of `apps/train/` out of the build's context, so Train's
|
||
server's image and BRouter's (which copies Train's running profile) couldn't have
|
||
been built on the server; now only Train's app and client are left out, and
|
||
`test/deploy/images_test.dart` checks Train's image and the sidecars' copied
|
||
files. BRouter's segments and the tiles are kept out of git and the context. Not
|
||
run: any of the deploy (no Docker access here), Caddy's `forward_auth` and its
|
||
`import` of an empty folder, whether `pmtiles serve` reads a new cut moved over the
|
||
old one, the style drawn on a phone.
|
||
- Built: 12b. Train's server: the `search` endpoint (`lib/src/search/`, for people
|
||
signed in) asks the instance's Photon (`PHOTON_URL`, set by `compose.search.yaml`;
|
||
none: `offered` is false and searches are refused) for queries of 2 to 200
|
||
characters, 8 places at most, those nearest the map's centre first, named in
|
||
English or German (another language: Photon's own names), 60 searches a person a
|
||
minute (the planner asks as people type); Photon's GeoJSON read into `Place` (its
|
||
name, or the street and number of an address; its street, town and country as
|
||
the name doesn't say them; its kind), tested on its documented format, on a local
|
||
HTTP server and on a fake Photon; nothing kept or logged. The app: a field over the
|
||
planner's map where the instance searches (`PlaceSearchCubit`, asked once typing
|
||
pauses for 400 ms, a cleared or later query's answer dropped; `place_search_gateway.dart`),
|
||
the places found listed under it, one chosen the next waypoint and the map moved
|
||
to it (zoom 14 at least). The deploy: `deploy/photon/` (Photon 1.3.0 on Temurin 21;
|
||
`data.sh` downloads GraphHopper's `photon-db-<region>-1.0-latest` monthly, unpacked
|
||
as it streams and checked against its MD5, its archive never kept; `serve.sh` puts
|
||
a new database in place within a minute, Photon away a minute or two),
|
||
`compose.search.yaml` (Photon beside Train's server alone on an internal network,
|
||
unlogged; its downloads on the downloads' network, an ordinary bridge it shares with
|
||
BRouter's segments and the map's tiles; `PHOTON_HEAP` 6g, `PHOTON_MEMORY`
|
||
8g), the operator's steps in the README's *Train's search*. Not run: any of the
|
||
deploy, the size of Europe's database unpacked and Photon's memory on it, whether
|
||
1.3.0 reads GraphHopper's `1.0` build (the folder GraphHopper offers for Photon's
|
||
1.x releases), the field on a phone.
|
||
- **D51 What a ride's sensors and a run's steps give Health Connect, and the wheel's
|
||
auto-pause** (recommended, the developer's; 29 Oct; step 13, what D49 left open):
|
||
- **Health Connect** (13a): a ride's power meter's watts as `PowerRecord` samples (as
|
||
the rower's), its crank's cadence as `CyclingPedalingCadenceRecord`, and where its
|
||
distance is the wheel's (D49) the wheel's speed as `SpeedRecord` in place of GPS's;
|
||
a run's or walk's steps' cadence as `StepsCadenceRecord` (the cadence series, D48),
|
||
over a ride's alone. The two cadences' permissions are asked with writing's, but
|
||
writing stays on without them, so that those who switched it on before keep it:
|
||
records Health Connect hasn't allowed are left out. Workouts written before are
|
||
written again with them, as any change to what a workout gives is.
|
||
- **Auto-pause by the wheel** (13b): while the speed sensor sends, the wheel's speed
|
||
decides with a bike's thresholds (paused below 3 km/h for 5 s, going on above
|
||
5 km/h); GPS's again once the wheel has been silent 5 s (over moving while either
|
||
says so, which never pauses on a trainer where GPS drifts).
|
||
- Built: 13a and 13b. `healthRecordsOf` adds the power meter's watts to the rower's,
|
||
the crank's cadence (`pedalling`) and the steps' (`stepsCadence`, the series' 10 s
|
||
windows), and on a ride whose distance is the wheel's its speed over stretches of
|
||
5 s at least (`_wheelSpeeds`), pauses left out. `HealthRecords.kt` maps them to
|
||
`CyclingPedalingCadenceRecord` and `StepsCadenceRecord`; the bridge asks for their
|
||
permissions (`WRITE_EXERCISE` and `WRITE_STEPS`, as Health Connect names them,
|
||
which a JVM test holds the manifest to for every kind written) with writing's
|
||
(`ASKED_WITH`), and leaves out the records not allowed. So that workouts written
|
||
before are written again, the fingerprint deciding it now folds in
|
||
`healthRecordsVersion` (2): every workout is written once more after the update.
|
||
`AutoPause.wheel` takes the wheel's speed (`WheelSpeed` in `CyclingSensors.kt`,
|
||
over at least half a second, past the 32-bit rollover, by the circumference the
|
||
ride starts with), and GPS's speed is ignored while the wheel sent within 5 s; a
|
||
ride without a circumference (no speed sensor) pauses by GPS as before. Not tried
|
||
with a real sensor or Health Connect.
|
||
- **D52 Music kept in the background, and smaller copies** (recommended, the
|
||
developer's; 29 Oct; step 14, what D47's 10b left open):
|
||
- **Downloads by a WorkManager job of Train's, in Kotlin, on the app's list** (14b;
|
||
over the job also reading the marked playlists, which writes part of the Jellyfin
|
||
client a second time; over the workmanager plugin running today's Dart in a
|
||
background engine, a new plugin and a whole engine for each run; over Android's
|
||
DownloadManager, which keeps the token in the system's download database): the
|
||
app, as it opens and resumes, reads the playlists as before, deletes what they no
|
||
longer hold and writes which tracks are missing; the job downloads those and works
|
||
out their tempo, a line each in the person's music folder, which the app takes in
|
||
as it opens and, while open, as each line comes. A playlist changed on the server
|
||
while the app is closed is noticed as it opens. The job does every download, the
|
||
open app's too, so that one code path runs and two never fetch the same track.
|
||
- **The token in one store of Train's, in Kotlin** (14a; over a second copy beside
|
||
flutter_secure_storage's, two to keep in step, and over the job reading that
|
||
plugin's own files, whose format has changed between versions): a file per person
|
||
in the no-backup folder, sealed with AES-GCM under a key in Android's Keystore
|
||
that can't be exported; the app reads it over the music channel; a token kept
|
||
before is moved over once and deleted from the keyring.
|
||
- **When**: every 6 hours and once whenever the list changes, on an unmetered network,
|
||
with the battery and the storage not low (over daily while charging, which leaves
|
||
a new playlist waiting overnight, and over every 12 hours).
|
||
- **Smaller copies** (14c): the music screen chooses *As they are* or *Smaller*, the
|
||
default *Smaller*: files above 256 kbps (FLAC and other lossless) are transcoded by
|
||
Jellyfin to Opus at 128 kbps in WebM, which Android's extractor and decoder read
|
||
on every version Train runs on; MP3s and AACs come as they are (over *As they are*
|
||
by default, and over always transcoding, which loses an MP3's quality twice and
|
||
has the server's processor do every track). Switching downloads the tracks again;
|
||
a file is kept until its new copy is whole, its tempo kept.
|
||
- Built: 14a. `JellyfinTokens.kt` keeps each person's token in the no-backup
|
||
folder's `jellyfin/`, a file named by their ID: a version byte, the IV the
|
||
Keystore makes and the token sealed with AES-GCM under `train_jellyfin`, a
|
||
256-bit key of Android's Keystore made once, the person's ID its associated data;
|
||
a file that doesn't open is deleted and counts as none, so the person signs in
|
||
again. Tested on the JVM with a software key. `AndroidJellyfinTokenStore` asks it
|
||
over the music channel and moves a token the keyring holds under
|
||
`train_jellyfin:` over as it is first read; keeping one, and forgetting others',
|
||
clears the keyring's too.
|
||
- Built: 14b. `MusicDownloads` reads the playlists as before and hands the tracks
|
||
not yet downloaded, or without a tempo worked out, to `MusicPlatform.schedule`
|
||
(`MusicJob`: the person, their music folder, the server, Jellyfin's
|
||
`Authorization` header without the token, and each track's path), none where no
|
||
one is signed in or nothing is missing. `MusicWorker.kt` keeps the list in the
|
||
no-backup folder's `music_background.json` and enqueues a periodic run of 6 hours
|
||
and a run now, both unique and kept where one waits, under WorkManager's
|
||
unmetered network, battery and storage constraints; one run at a time in the
|
||
process (a mutex), which reads the list again as it ends and runs again where it
|
||
changed. `MusicFetcher.kt` (tested on the JVM against a server of the test's own)
|
||
downloads each track through a part file, adds the token from `JellyfinTokens`,
|
||
works out its tempo with `MusicBridge.of`, and appends a line to the folder's
|
||
`fetched.txt`, skipping what a line names already; a refused token stops the run,
|
||
a server out of reach has WorkManager try again later, a track the server fails is
|
||
left for the next run. The app takes the lines in as it opens and resumes, and as
|
||
`…/music/fetched` says one came while it listens (the file renamed first, so a
|
||
line written meanwhile lands in the next); a track no longer kept has its file
|
||
deleted. The app's own download (`JellyfinClient.download`) is gone, and so is
|
||
the tempo over the channel. Not tried on a phone: WorkManager's runs, the
|
||
downloads of large files over HttpURLConnection.
|
||
- Built: 14c. The playlists' items are asked with `fields=MediaSources`, which gives
|
||
each track's bitrate and its audio stream's codec (`MusicTrack.bitrate`, `codec`).
|
||
*Smaller copies* on the music screen (`MusicSettings.smaller`, on by default, kept
|
||
through signing out) has a file above 256 kbps fetched from
|
||
`/Audio/<id>/stream.webm?audioCodec=opus&audioBitRate=128000&maxAudioChannels=2`,
|
||
but for the lossy codecs `mp3`, `aac`, `vorbis` and `opus` at any rate: the
|
||
decision's "MP3s and AACs come as they are" taken over its threshold, which a
|
||
320 kbps MP3 is above. Each track keeps which copy its file is (`MusicCopy`, a
|
||
track kept before counting as the original); one kept as another copy than wanted
|
||
goes to the job again, without its tempo worked out again, and the job's line
|
||
names the copy, so that one fetched as the original doesn't stop the smaller from
|
||
coming. Not tried against a real Jellyfin: that its transcoding answers this path
|
||
with WebM, how long it takes for a track.
|
||
|
||
## After the branch's review (30 Oct 2026, the developer's)
|
||
|
||
- **D53 Nothing of Train's in a backup or a transfer to another phone** (the
|
||
developer's; 30 Oct): Train keeps unsealed on the phone what D7 seals everywhere
|
||
else: a recording while it runs (`recording.txt`, the track and the heart rate), the
|
||
Loop's days before they are sealed (`loop_days.txt`), the body's figures the band was
|
||
set up with (`loop.json`), the maps along routes. Android's backup (Google's, or
|
||
Seedvault on GrapheneOS and LineageOS) and its transfer to a new phone copied the
|
||
app's folders by default, outside the sealing. `allowBackup="false"` and
|
||
`fullBackupContent="false"` stop both through Android 11; from Android 12 on
|
||
`allowBackup` stops the backup alone, and `res/xml/data_extraction_rules.xml`
|
||
excludes every folder from the backup and from a transfer (`BackupRulesTest`). Over
|
||
excluding Train's unsealed files one by one, which a file added later would slip
|
||
through, and over moving them under `noBackupFilesDir`, which a transfer from
|
||
Android 12 on still takes; nothing is lost on a new phone that matters: what is
|
||
sealed comes back from the instance, the account's keys from signing in.
|
||
- **D54 Jellyfin over https alone, but on the phone itself** (the developer's; 30 Oct;
|
||
revises D47's addresses): an address with `http://` to another machine is refused as
|
||
typed (`jellyfinInClear`, `MusicFailure.inClear`, *Train connects to Jellyfin over
|
||
https only*): over http the password, Quick Connect's token and the person's
|
||
listening go through the network in clear. `http://localhost` and `http://127.0.0.1`
|
||
stay, for a Jellyfin on the phone, the network security config letting the
|
||
downloads (`MusicFetcher`, `HttpURLConnection`, which Android holds to it, where
|
||
Dart's client isn't) reach those two and nothing else in clear. Over allowing http
|
||
on the local network too, which Android's network security config can't scope to a
|
||
network (it names hosts), so that the downloads would have to be allowed cleartext
|
||
everywhere.
|
||
- **D55 Only the firmware the person agreed to** (the developer's; 30 Oct; D40):
|
||
relayed by the instance, the band gets the version the person was shown and agreed
|
||
to, or none: where the relay offers another by then, nothing is downloaded or
|
||
written, and the other version is offered instead (`LoopFailed('another update')`).
|
||
The relay's calls to Polar have timeouts, caps on their size and https on every
|
||
hop, one fetch per file at a time, and a limit per person.
|
||
What the phone can't check (30 Oct): Polar's SDK (8.3.0) unzips the file and writes
|
||
its parts to the band over PFTP as they come, with no hash or signature of its own
|
||
(its classes use neither `MessageDigest` nor `Signature`). Whether the band refuses
|
||
an image Polar didn't sign is to be seen from a file of Polar's; until then,
|
||
firmware through the instance trusts the instance as the direct way trusts Polar's
|
||
service, and the person choosing the relay chooses that.
|
||
- **D56 The wheel learnt from GPS that covered the ride** (recommended, the developer's;
|
||
30 Oct; revises D49's measure): a ride measures the wheel where precise fixes followed
|
||
it for 95 % of its moving time (gaps of 30 s at most count, a pause cuts), not where
|
||
GPS made 90 % of the wheel's way at the circumference taken: that held every wheel to
|
||
the one taken, so a real wheel under some 1.9 m (20", 24", a child's 16") was never
|
||
learnt from the 2105 mm a sensor starts with. Its quotient may fall between 1 and 3 m,
|
||
as one typed may (`measuredWheelMm`, `_gpsCovered`). A tunnel's straight line still
|
||
measures nothing. The distance still follows `usesWheel`, against the wheel as learnt.
|
||
- **D57 Row IDs that tell the instance no date** (recommended, the developer's; 30 Oct;
|
||
revises D37's IDs): a `day`, `night` and `day_series` row's ID was a UUID of version
|
||
5 of `https://halkyon.app/train/day/<person>/<date>`, so that every phone named a
|
||
day alike offline; but the instance knows the person and can hash every date, so
|
||
each ID told it which days the person wore the band, their sealing aside. The ID is
|
||
now the account's keyed hash of the name (halkyon_keys' `keyedRowId`: BLAKE2b keyed
|
||
with the account's X25519 secret key over a label and `train/day/<date>`, a UUID of
|
||
version 8; `ContentCipher.rowId`, `TrainKeys.rowId`), the same on every device of
|
||
the account, which all hold its keys, and nothing of the date read from it without
|
||
them. Over a key of Train's own (its data keys multiply offline, D15, so two
|
||
phones would name one day two ways until they met), random IDs with a sealed index
|
||
from date to ID (two phones offline each making a day's row, merged later: more
|
||
code for the same), and coarser rows (a week or month each, rewriting more with
|
||
every day). What the instance still sees: how many rows there are, their sizes and
|
||
when they are written; a reset of the account's keys (ADR 0017) renames every row,
|
||
as it leaves what was sealed unreadable anyway. Train had no release, so no row
|
||
needed moving.
|
||
- **D58 Train's releases** (recommended, the developer's; 30 Oct): as Learn's, from
|
||
`tool/release.dart train` on the developer's laptop (`apps/train/release.dart`), the
|
||
APK alone (Train is Android's), connecting to train.halkyon.eu, published on
|
||
code.halkyon.app and in the project's F-Droid repository beside Learn, signed with a
|
||
key of Train's own (a PKCS #12 keystore at `~/.local/share/halkyon-train/`, its
|
||
certificate pinned in `tool/release/train-android-certificate.sha256`): one key for
|
||
both apps would let either's build pass for the other's update. Its listing
|
||
(`tool/release/fdroid/metadata/app.halkyon.train.yml`) declares `NonFreeDep` (Polar's
|
||
SDK, D36) and `NonFreeNet` (Polar's firmware service, the direct way of D40, and the
|
||
server's SSPL, as Learn's says).
|
||
- **D59 Waypoints pulled out of the line, and any one taken off** (recommended, the
|
||
developer's; 30 Oct; D44): the planned line held and dragged pulls a waypoint out of
|
||
it, inserted between the two waypoints of the stretch it was held on, and the way is
|
||
planned again through it, as Komoot, brouter-web and Google Maps do; a waypoint tapped
|
||
offers to take it off, where undo took only the last. Held first, as a waypoint is
|
||
moved, so that dragging the map never pulls the line. Over a tap on the line
|
||
inserting a waypoint to be dragged after (two steps, and a line easily hit by a
|
||
finger meaning the map) and over a list of waypoints under the map (good for long
|
||
routes, clumsy for "a little closer to the lake"). The stretch is the one of the line
|
||
nearest where it was held, each waypoint's place on the line found from the one
|
||
before's on, so a way out and back the same streets keeps its stretches in order
|
||
(`insertionIndex`, `route_legs.dart`).
|
||
- **D60 Train's tabs, as Strava's and in Learn's frame** (the developer's; 30 Oct):
|
||
Home (the workouts), Maps (the routes), Record (recording, and the interval plans),
|
||
Groups (to come; for now a screen that says so) and Profile (the days, and the way to
|
||
the account), as Strava's five, where one screen with four tabs at its top had held
|
||
workouts, days, plans and routes, and a floating button recorded. Record is left out
|
||
where the device doesn't record (the desktop), which keeps the plans beside the days
|
||
in Profile. The frame is Learn's, moved into halkyon_ui for every app
|
||
(`NavigationFrame`, `TabsStack`): a rail with the app's icon on top from medium
|
||
windows on, a bar floating above the foot below, a tick on every choice. Driven by
|
||
go_router as Learn's (a `StatefulShellRoute`, each tab keeping its own stack): a
|
||
workout's page opens in Home and a day's in Profile with the tabs beside them, the
|
||
route and plan editors and the recording over them; over keeping `Navigator.push`
|
||
under hand-made nested navigators (a second pattern beside Learn's, Android's back
|
||
handled by hand) and over tabs whose every screen covers the bar (as before, unlike
|
||
Learn and Strava). Every screen washes down from the top in carrot, the warm end of
|
||
Train's icon, as Learn's in cobalt (`TopGradient` under the app's `ScreenColor`);
|
||
buttons and links stay cobalt. Over cobalt (Learn's, so the two apps would look
|
||
alike), strawberry and the icon's whole sweep.
|
||
Settings is a screen over the tabs, as Learn's, from a cog atop Home and Profile
|
||
(31 Oct, the developer's), where an account sheet had held the account and the
|
||
training's screens: grouped in cards, the training's screens first, the account near
|
||
the foot, About Halkyon Train last and the version small below it.
|
||
A recording under way, left for another tab, stands above the bar on every tab
|
||
(below the rail's screen from medium windows on), as Strava's: its activity, its
|
||
moving time and distance, paused or stopped, a tap leading back to it
|
||
(`RecordingBar`, `NavigationFrame.status`); over the Record tab's button alone, which
|
||
had been the one way back. A workout just saved opens its page, in Home's tab, where
|
||
the recording had gone back to the tab it started from (both the developer's, 31 Oct).
|
||
Home opens on this week, as Strava's (`WeekCard`, `weekSummary`): the time trained
|
||
(moving time, else start to end), the distance and the workouts, and a strip of its
|
||
seven days each as tall as its time, the week starting on the day the person's
|
||
language starts it on (Monday in British English and German, Sunday in American
|
||
English, Material's `firstDayOfWeekIndex`), each workout on the day it started, local
|
||
time; over the totals alone and over a chart of several weeks (the developer's,
|
||
31 Oct).
|
||
Maps shows every route on the instance's map (`RoutesMap`), the camera fitting them,
|
||
one tapped opening its planner: above the list below expanded windows, beside it from
|
||
expanded windows on, as Material's list–detail layout has two panes there; without a
|
||
route to draw, the list alone, as there is nothing to centre on without asking the
|
||
phone where it is (the developer's, 31 Oct).
|
||
Record has a card for each activity it records, its Start opening the Record sheet
|
||
for that activity, the interval plans below (`ActivityCard`); over Start recording
|
||
at once with the sheet's choices, an options button beside it, which hides what a
|
||
workout may need set (the developer's, 31 Oct). The sheet no longer chooses the
|
||
activity, so it offers the plans and routes for it or for any alone, where one for
|
||
another activity had chosen it; while a recording is under way or waits to be saved,
|
||
the cards' Start waits, the bar leading back.
|
||
Profile opens on *Your body* (`BodyCard`: weight, resting and maximal heart rate, as
|
||
far as given, tapped opening the screen, which Settings keeps too), the last 12
|
||
weeks' time trained as bars (`WeeksCard`, `weeksBack`: this week chosen, a week
|
||
tapped showing its time, distance and workouts) and this calendar year by activity
|
||
(`YearCard`, `yearSummary`), over the days; over the year's totals alone and over
|
||
progress from the Loop's days, which someone without a Loop wouldn't have (the
|
||
developer's, 31 Oct). Every duration reads as the days' do (`hoursMinutes`).
|
||
Home opens on today from the band (`TodayCard`: steps, active time, last night's sleep
|
||
and Nightly Recharge, as Profile lists a day, `dayFigures`; tapped, the day's page,
|
||
in Profile's tab), shown while the band gave figures for today, and so for someone
|
||
with a Loop alone; above the week, which waits for the first workout, and above
|
||
Home's message while there is none.
|
||
Profile's tabs fold into the title as Strava's (halkyon_ui's `FoldingTabs`, Learn's
|
||
Profile on it too): Progress (the cards above), Days, and Plans where the device
|
||
doesn't record; at the top the tab row shows under the title, scrolled down it
|
||
scrolls away with the content and the chosen tab's name shows small under the
|
||
title, which, tapped, scrolls back up to the row. The row alone is the scroll view's
|
||
header, nothing pinned, so the tabs' lists need no room kept for it and stay plain
|
||
`ListView`s.
|
||
- **D61 What's new, in every Halkyon app** (the developer's; 30 and 31 Oct): after an
|
||
update, a sheet lists what changed, once per device, and Settings shows it again
|
||
(`WhatsNewTile`, in the About card), shared in halkyon_app (`WhatsNew`,
|
||
`WhatsNewOnUpdate` around the tabs, `WhatsNewScope` around the app). Before it,
|
||
Forgejo's notes listed the feat/fix subjects, and F-Droid and the Snap Store
|
||
showed nothing (the Snap Store has no field for it).
|
||
- **One file a release and language** in the app's `assets/whats_new/`
|
||
(`<version>.<language>.txt`, English and German, a change a line as `- …`),
|
||
bundled with the app; over one changelog per language, which the app and the
|
||
tool would parse by sections and which grows with each release.
|
||
- **Drafted, then edited**: `tool/release.dart`, after *Go on?*, drafts the
|
||
missing files from the commits that count (features, then fixes, without types,
|
||
scopes and closing references; German the same lines marked `TRANSLATE:`) and
|
||
opens them in `$VISUAL`/`$EDITOR` (or waits for Enter) until each lists a change,
|
||
has no mark left and fits F-Droid's 500 characters; they go into the release
|
||
commit with the version (the first release's alone, where the pubspec keeps its
|
||
version). A run stopped there keeps them for the next, the only untracked files
|
||
a release starts with. The dry run prints them, or the drafts.
|
||
- **Written with the commit** (the developer's, 4 Nov 2026, on the agent's
|
||
recommendation, after three releases' drafts had to be rewritten whole: the
|
||
subjects are written for developers, listed other apps' commits touching a
|
||
shared package, and left German to translate): a commit people would notice
|
||
carries what it changes for them as trailers, `Whats-New: <sentence>` once a
|
||
change and `Whats-New-De: <Satz>` in the same order, and the draft is those lines
|
||
alone, the features' first, a missing translation marked `TRANSLATE:`; only
|
||
where no commit of the release has one does it fall back to the subjects. A
|
||
commit whose scope names apps (`feat(todo,meditate):`) counts for those alone,
|
||
any other (a shared package's, `feat(app):`) for every app it touches. Over a
|
||
running `next.<language>.txt` per app, which parallel branches would conflict on
|
||
at every merge, and over an assistant drafting at release time, which needs a
|
||
key, sends the commits to a provider and writes differently each run.
|
||
- **The same text everywhere**: the English as the Forgejo notes' *What's new*, in
|
||
place of the commits' list, and each language as F-Droid's changelog of the build
|
||
(`metadata/<app id>/<locale>/changelogs/<build>.txt`, `en-US` and `de`), written
|
||
by the tool at publishing and copied in by `tool/fdroid_repo.sh`
|
||
(`HALKYON_FDROID_METADATA`), so nothing is committed twice.
|
||
- **Every release since**: someone who skipped updates sees each release's changes
|
||
since the version the device showed last (`whats_new.json` in the support
|
||
folder), newest first; over the newest alone. Nothing on a fresh install; a device
|
||
used before (an instance chosen) is shown the running version's on its first start
|
||
with this. In the app's language, else English.
|
||
- **D62 Train on the desktop as a snap** (the developer's, 3 Nov 2026, on the agent's
|
||
recommendation): to the Snap Store's edge channel with each release, as Learn's and
|
||
Todo's, built as Todo's in a clone of its own (`apps/train/snap/`, Todo's ADR 0033,
|
||
step 5), for train.halkyon.eu. D58 had released the APK alone, before the release
|
||
tool could build a snap from an app's own folder. The desktop records nothing and
|
||
connects to no device (*Consequences*); it shows the workouts with their maps, plans
|
||
routes and strength sessions, and keeps the statistics, with what the phones
|
||
recorded coming through the server. Its listing says so.
|
||
- **D63 The maps after the developer's review** (the developer's, 3 Nov 2026; the
|
||
labels' language on the agent's recommendation, chosen by the developer):
|
||
- **A workout's page as Strava's**: with a track, the map runs to the foot of the
|
||
screen under a sheet (its name first, then what was there before), which starts at
|
||
55 % of the page, snaps back there, goes down to 15 % and up to the app bar's foot.
|
||
Once it settles (200 ms), the map fits the whole track anew, eased over 450 ms, into
|
||
what the sheet and the round buttons leave free, with 32 px around and at least
|
||
96 px high (`track_camera.dart`, tested); pulled down, the map is larger and the
|
||
track fitted closer, as Strava's, whose video showed the camera moving only after
|
||
the sheet stops. Pulled up, the round buttons over the map fold into an app bar over
|
||
the sheet's last stretch: its colour and title come in, their rounds go, and the
|
||
arrow back turns a quarter into a chevron down that lowers the sheet again. From
|
||
expanded windows on, the map and the rest side by side (a 400 px pane, Material's
|
||
supporting pane, as the Maps tab's list and map, D60). Without a track or without
|
||
the map, the rest alone, as before. The page stays in Home (back leads there) but
|
||
opens on the root navigator, over the shell, so without the floating bar (D60 had
|
||
it run on beneath the bar).
|
||
- **What the map's source sees** ("Map by OpenFreeMap: it sees…", and the link to what
|
||
it keeps) moves behind halkyon_ui's question mark (`HelpButton`, which gained an
|
||
action beside OK for the link) at the end of the page's first line: it explains,
|
||
rather than being the person's own (D23, D50). The instance's privacy page doesn't
|
||
name the map's source yet; it should, now that the note is one tap away (open).
|
||
That the map can't be loaded stays in view, as it is a state.
|
||
- **The map's credit readable**: flutter_map's credit is built on
|
||
`flutter/material`, whose `Theme` is not the app's (`material_ui`'s, a class of its
|
||
own), so it drew the theme's text on the fallback theme's white. Every Train map now
|
||
has `mapAttribution`: on the theme's `surfaceContainerHigh`, in `onSurface`, its
|
||
button a tonal one, with a tooltip. `map_tiles.dart` was the only file reading
|
||
`flutter/material`'s `Theme`, and so chose the dark style of the instance's own map
|
||
(D50) never; it reads the app's now.
|
||
- **Places in the app's language**: OpenFreeMap's styles ask for the English name
|
||
(`name_en`) before the name on the spot; the style is rewritten as it is read
|
||
(`labelledIn`) to ask for `name:<language>` instead (OpenMapTiles' fields), the name
|
||
on the spot where there is none. English stays as the style has it, and the
|
||
instance's own map is named in the app's language by its server already (D50). A
|
||
place named only in its own language keeps its name (a "Tennisplatz" whose
|
||
OpenStreetMap entry has no English name stays one in English).
|
||
- **The route planner**: its hint ("Tap the map to add a waypoint…") moves from a card
|
||
over the map to a question mark below Undo. A new route's map opens at zoom 14 (a
|
||
town's district, as map apps open on where one is; Google Maps' "my location" is
|
||
about 15, Strava's route builder 13 to 14) at once where the phone was last known to
|
||
be (`lastPosition`, the newest of Android's providers, never asking for the
|
||
permission), else where the newest workout with a track started
|
||
(`lastWorkoutStart`, of the 5 newest), else on the region as before; the phone's
|
||
fix then sets the first waypoint as before (D44). From expanded windows on, the
|
||
route's details beside its map (400 px). It ran without the bar already (D60).
|
||
- **D64 A day's page, its figures, held charts and the weight** (the developer's
|
||
feedback of 3 Nov 2026; the weight's sources the developer's answer, the rest the
|
||
agent's on that feedback):
|
||
- **A day over the tabs**: Home's day (`/days/<date>`) is a route of its own above
|
||
the shell, without the navigation bar, back at the top left, each section in a
|
||
`SectionCard` over the top gradient. A section's explanation (what Nightly
|
||
Recharge's figures mean, the nervous system's −10 to +10) sits behind
|
||
halkyon_ui's `HelpButton` beside its title; the person's own values stay in sight.
|
||
- **The night's chart**: the cause of the rows drawn over Nightly Recharge and the
|
||
skin temperature was a phase starting at or after the night's end: its right edge
|
||
was clamped to `left + 1 .. width` with `left + 1` above `width`, `clamp` threw
|
||
while painting, and the canvas was left translated for everything painted after it.
|
||
The left edge now stays inside the box first.
|
||
- **Polar's no-value marks**: Nightly Recharge writes −100 or −1 where it has no
|
||
value; `Recharge.known` drops what lies outside each figure's range (the nervous
|
||
system's status outside −10..10, the recovery level outside 1..6, rates, intervals
|
||
and deviations not above or at zero), and the page shows only what remains.
|
||
- **Charts held**: a long press on a line chart (`WorkoutChart`, shared by the day's,
|
||
the workouts' and the weight's) shows a line, a dot and the exact value under the
|
||
finger, and its time where the chart knows it, following the drag, with a tick of
|
||
haptic feedback as the press takes hold.
|
||
- **Sync in Settings**: Settings' first card says where syncing stands and offers
|
||
*Sync now*, halkyon_app's `SyncStatusCard` as Learn's and Todo's, the cog badged
|
||
when syncing fails.
|
||
- **Weight**: a sealed synced table, `body_weight`, as the days' and the places':
|
||
the weight, when it was measured, and where it came from are the payload (grams as
|
||
a whole number, the time in UTC, a Health Connect record's id and app), the
|
||
server sees only the space and the ciphertext. Its migration creates the table
|
||
(`id` a UUID v7 by default, `spaceId`, `keyId`, `payload` not null, `nonce`), an
|
||
index on `spaceId` for a space's rows, and a foreign key to `offline_sync_spaces`
|
||
cascading on delete, so that an account's deletion takes its weights with its
|
||
space; then the version rows of every module. Nothing existing is changed, so
|
||
nothing is lost. Entered on Progress's *Weight* card (the plus; a comma taken as
|
||
a point, 20 to 400 kg, when measured, today by default), with its line over time
|
||
and *All* to delete one; the latest weight is also *Your body*'s, which the
|
||
band's and the zones' figures work from. Health Connect's weight records are read
|
||
in when switched on in its screen (its own permission, asked as the others), each
|
||
record once (its id kept, also after deletion), not Train's own, and not one
|
||
within 10 minutes and 0.1 kg of a weight entered here. **In kilograms or pounds**
|
||
(the developer's call, 4 Nov 2026, weight alone): every weight is kept in
|
||
kilograms, and shown and entered in the person's unit, *Your body*'s *Weight in*
|
||
(kg | lb, the profile's `weightUnit`), else their region's (pounds in the US,
|
||
Liberia and Myanmar, `WeightUnit.ofRegion`, from the app's locale); a pound is
|
||
0.45359237 kg, the entry's range the same 20 to 400 kg (44 to 880 lb). A weight
|
||
left untouched in *Your body* is saved as it was, not rounded through pounds.
|
||
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).
|
||
|
||
- **The Record sheet's music on one line**: its choice (Music, Metronome, Off) sat in
|
||
the tile's subtitle beside the icon, where a phone wrapped "Metronome" in two. It
|
||
has a row of its own now, as wide as the list, each label on one line and scaled
|
||
down where it still doesn't fit (larger text), rather than shortened labels or a
|
||
menu, which would hide the choice behind a tap.
|
||
- **A workout's sheet pulled all the way up, as Strava's**: over the last 6 % of the
|
||
page below the highest it goes, its top corners square off (28 to 0) and its
|
||
handle folds away, so that the bar above it, opaque by then, meets it without the
|
||
map showing in the corners. The bar names the activity (Running), not the
|
||
workout, whose name is the sheet's first line: once in sight, not twice (D63 had
|
||
the bar take the sheet's title).
|
||
- **A dark map on OpenFreeMap too**: the maps followed the theme where the source
|
||
has a dark style (D50), which only the instance's own map had. OpenFreeMap's
|
||
default source now hands its Dark style beside Liberty, and an operator's
|
||
provider takes one from `MAP_DARK_STYLE_URL` (HTTPS, else none: the light style
|
||
in both themes). A dark Liberty made in the app (its colours inverted) was the
|
||
alternative, a style of the app's own for a provider's tiles it can't know.
|
||
- **The day's charts**: the skin temperature was drawn backwards in places, as the
|
||
band's samples aren't always in time order and nothing sorted them; they are
|
||
sorted, a time given twice kept once, as the Loop's lines are read and as a kept
|
||
day is opened (`skinInOrder`), and every line chart (`WorkoutChart`) draws its
|
||
points in the order of their x and spans their least to their most
|
||
(`chartInOrder`). The heart rate spiked down: the band writes 0 where it lost
|
||
contact, and a run's mean counted those in. A value that isn't a heart's rate
|
||
(0, or beyond 250) is 0, none read, as the lines are read, kept in its place so
|
||
the samples after keep their times, and left out of every mean, also of days
|
||
kept before. The chart draws the samples themselves a point a minute, averaged
|
||
across runs (`heartByMinute`, a second between a run's samples), not one mean a
|
||
run, which zigzagged where runs lay close. Dropping the zeros from the list was
|
||
the alternative; it would have moved the later samples' times.
|
||
## Consequences
|
||
|
||
- Train's sensors are one pattern: open profiles read by the app itself, on the phone;
|
||
the Loop through Polar's SDK, also on the phone.
|
||
- The recording runs in an Android foreground service from its first version, so that it
|
||
survives the screen going off.
|
||
- The Loop can't be used with Polar Flow while Train holds it; going back needs a factory
|
||
reset of the band.
|
||
- The desktop connects to no device and records nothing; it shows and plans, with what
|
||
the phones recorded coming through the server.
|