On Android, the Loop through Polar's BLE SDK 8.3.0 (from JitPack, asked for Polar's group alone, its files pinned by SHA-256 in Gradle's verification metadata): LoopBridge.kt over app.halkyon.train/loop, the first-time use's figures mapped and checked in LoopSetup.kt, tested on the JVM. PolarLoop per person remembers the band in loop.json, connects while the app is open, sets its clock at each connection, keeps what it tells (battery, charging, firmware), offers a changed profile and sends it only when asked (D35: first-time use writes a new user identifier each time), and lets the band go by a factory reset after asking. The setup sheet finds Loops nearest first and asks only for what the profile lacks; Polar's defaults (60, 220 less the age, a VO2max from Polar's fitness tables) go to the band alone. Polar's SDK is not free software (D36, correcting D32): the app's AGPL gains a section 7 permission for linking it, LICENSE.md names it, and its licence and bundled notices join the licence list on Android. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
12 KiB
12 KiB
Halkyon Train
What holds for every app and package of this repository is in the root AGENTS.md; it applies here in full, and Learn's patterns (apps/learn/AGENTS.md) wherever Train needs the same thing.
- A shell on the shared packages, Todo's copied and renamed (decided 2026-10-28, ADR 0030, D12; what Train is to do: ADR 0030): signing in, the instances, the account and the look work, and since step 5a (28 Oct 2026) a person's workouts are kept and synced with
serverpod_offline_sync, every row sealed (ADR 0030, D7, D14, D15), and on Android a workout is recorded (5b: ADR 0030, D13, D16, D18 to D21), and since 5c home lists them and each has its page with its map (D22 to D24). Packages:halkyon_train_server,halkyon_train_client(generated, never edited),halkyon_train_flutter; app IDapp.halkyon.trainon every platform; no web build. Licences as Learn's (server SSPL, client and app AGPL;LICENSE.md). - The server includes
halkyon_base(ADR 0014) and configures it inserver.dart(trainBase(),lib/src/train_base.dart): its basics say it servestrain(never change it once apps use it), sign-in mails name Halkyon Train, the source is this repository,minimumAppBuild1, anddeleteTrainData(lib/src/train_data.dart) as itsdeletePersonDatahook, which deletes the person's sync spaces and so, by the sync engine's cascades, every row of theirs (test/integration/workout/person_deletion_test.dart). Authentication through the module's builders: a password, SimpleLogin on the register, email codes proving an address, server-side sessions; no anonymous accounts (ADR 0020, D25), nor GitHub (D36). Accounts (ADR 0016): Train holds its own register when it runs alone, and is a member of the instance's register (Learn's server on learn.halkyon.eu) where the operator setsACCOUNT_REGISTER_URL(trainBase(accounts: AccountsMode.fromEnvironment())) with the instance's sharedINSTANCE_SECRET(instanceSecret); then sign-in, handles and profiles are the register's, the email settings are the register's alone, and an identified person's deletion goes to the register first.addInstanceRoutesserves the calls between the services on its web server, and the server doesn't start without the secret where it needs it.schedulePruning(lib/src/pruning.dart) keeps the module's pruning scheduled, one entry each however often the server starts (and cancels the one under the pruning's name before ADR 0020, D25): the anonymous accounts left from before daily, sign-up attempts and codes hourly, as the register the deletions its members haven't confirmed hourly, as a member the people the register no longer knows daily (each a no-op in the other mode). Session logs are never persisted, in any run mode. Migrations (2026-10-28): an initial one with Serverpod's tables, its auth modules' and the module's as they stand (Todo's grew one by one with the module; Train starts where they ended), thenworkouts, purely additive: the sync engine's tables and Train's first three. Synced models (database: sync, the moduleoffline_sync,experimental_features: databaseSyncinconfig/generator.yaml):content_key(the person's key sealed to the account),workoutandworkout_series(a workout's series in chunks of at most ten minutes, deleted with it), each a sealedpayloadwith itskeyIdandnonce, nothing else readable. Tests (test/integration/train_server_test.dart) call the module's endpoints through the server's own dispatch (halkyon_base.<endpoint>), since the generated test tools expose the project's endpoints alone. - The app is halkyon_app's
InstanceGateandSessionGatewith Train's frame (trainFrame: "Train" under "Halkyon", its icon,train.example.org, its own sentence) andServerpodInstanceChecker(app: 'train');TrainDependencies(AppDependencies<TrainPerson>) on one instance,TrainPersonper person: their own sync database in their folder (sync.sqlite, halkyon_app'sPersonFolders, one person's at a time), halkyon_app'sSyncServicebehindAppCompatibility(the module's basics'minimumAppBuild, against the buildpackage_info_plusreads), their key (TrainKeys,lib/src/core/privacy/: one key per person, IDstrain1.<ms>.<nonce>, the oldest-made writing, kept incontent_keyand opened only in Train's form, ADR 0030, D15) and their workouts (WorkoutRepository,lib/src/feature/workout/: halkyon_app'sPayloadEnvelopeseals a workout for its row and a series chunk for its row and its workout; payloads are versioned JSON, a series' columns delta-coded, gzipped before sealing; a deletion blanks every payload first); the gateways' tests run on a sync database opened without a server, the repository's on memory gateways and real libsodium; a person's app whose home lists their workouts (WorkoutsCubit, withTrainPerson.deletions, halkyon_app'sDelayedDeletions) and opens each one's page (WorkoutCubit:analyseinworkout_analysis.dartreads its series into the track's pieces, splits, elevation and charts, drawn byworkout_chart.dart; the map with flutter_map on the instance's source,MapSourceRepositoryaskingmap.source, its tiles fromTrainPerson.mapTiles, vector_map_tiles in the app and none in widget tests), and the account sheet: halkyon_app'sAccountTiles(whose account on which instance, Switch account, Sign out, Delete account, which asks first) in Train's words, and halkyon_app's About (showAppAbout: the source of what the instance runs, fromTrainDependencies.sourceCodeUrl, and the licence list). Texts through gen-l10n in British English (the template), German (informal du), an emptyen_USanden_GB(test/l10n/arb_files_test.dartkeeps them in step). The look follows the system's theme mode and language until Train has settings. Countries in where an instance keeps its data are named by halkyon_app's CLDR names (AssetCountryNames), its language picked by halkyon_app'sresolveAppLocale. Widget tests start Train's whole tree with halkyon_app's fakes (test/app/train_app_test.dart). - Recording (ADR 0030, D13, D16, D18 to D21, D26 to D28) is Android's alone for now (iOS waits for a Mac, D25) (
TrainPerson.recordingnull elsewhere):RecorderService(Kotlin,android/app/src/main/kotlin/app/halkyon/train/), a foreground service of the typeslocationandhealthstarted from the open app, takes fixes from LocationManager's GPS (no Play services), steps from the step detector the heart rate from the strap and a row from the rower the person chose (ADR 0030, D26 to D28:NotifyLink.kton Android's own Bluetooth API, no package,HeartRateMeasurement.ktparsing 0x2A37 andRowerData.ktFTMS's Rower Data 0x2AD1;DeviceScanner.ktfinds them for the sheets over…/recorder/strapsand…/recorder/rowers; each remembered in the person'sstrap.jsonandrower.json,DeviceStore), decides auto-pause (AutoPause.kt) and appends each sample as a line to the person'srecording.txt(format inrecording_file.dart), flushed every 5 s;MainActivityasks the permissions (never background location) and bridgesapp.halkyon.train/recorder(methods) and…/recorder/lines(the file so far, then each line). The app (lib/src/feature/recording/) follows those lines withRecordingBloc, works out what the screen and the notification show (liveOf, the saved totals' functions), and saves a recording as a workout and its series (recordedWorkout) or throws it away; one cut off, or stopped and not saved, is found as the person's app opens. Tests: the Dart side withtest/support/fake_recorder_platform.dart; the Kotlin auto-pause and the strap's and the rower's parsers on the JVM,JAVA_HOME=<Android Studio's jbr> ./gradlew :app:testDebugUnitTestinandroid/after a firstflutter build apk(which writesgradlew), not part oftool/check.dart, as CI's runner has no Android SDK. - Health Connect (ADR 0030, D30, D31) is Android's (
TrainPerson.healthnull elsewhere):HealthConnectBridge.kton AndroidX'sconnect-client1.1.0 overapp.halkyon.train/health(its records' mapping inHealthRecords.kt, tested on the JVM; the rationale's links from Health Connect in the manifest,…/health/rationaleto the app),HealthPlatformin Dart (lib/src/feature/health/),HealthConnectper person: with writing on, every workout written ashealthRecordsOfmakes it (client IDs from the workout's ID, a version counted up as it changes, a fingerprint deciding whether it did), written again or deleted as the workouts change and as the app opens, what was written kept in the person'shealth_connect.json(HealthStore, never synced); with reading on, the sessions of the apps the person turned on brought in as workouts through the changes token (importedWorkoutinhealth_import.dart, the workout'soriginin its payload; a double of one Train has brings at most its heart rate,isDouble; one deleted in Train is remembered so it stays out; a track only when asked for on its page,bringInTrack), as the app opens and comes to the front (HealthConnectListener, which opens the rationale too); the screen from the account sheet (HealthScreen,HealthCubit). Tests ontest/support/fake_health_platform.dart. Train needs Android 8 (API 26) for it. - The Polar Loop (ADR 0030, D32 to D36) is Android's (
TrainPerson.loopnull elsewhere):LoopBridge.kton Polar's BLE SDK 8.3.0 (from JitPack, a repository asked for Polar's group alone, its files pinned by SHA-256 inandroid/gradle/verification-metadata.xml; a new version needs its checksums there) overapp.halkyon.train/loop,…/loop/stateand…/loop/search, the first-time use's figures mapped and checked inLoopSetup.kt(tested on the JVM);LoopPlatformin Dart (lib/src/feature/loop/),PolarLoopper person: the band remembered in the person'sloop.json(LoopStore, never synced) with what it was set up with and what it told, connected while the app is open, its clock set as each connection is ready, a changed profile offered on the band's screen and sent only when the person says (D35), Let the band go a factory reset after asking;loopFiguresOffills Polar's defaults for the band alone (loop_figures.dart), the setup sheet asks for what the profile lacks withProfileForm(only: …). The person's body's figures are the synced, sealedprofile(D33, Your body,lib/src/feature/profile/). Polar's SDK isn't free software (D36): the app's AGPL carries a section 7 permission for it (halkyon_train_flutter/LICENSE-polar-exception.md), its notices are in the licence list (registerPolarLicense, on Android), and an F-Droid listing of Train declaresNonFreeDep. Never ask the SDK forFEATURE_TELEMETRY. Tests ontest/support/fake_loop_platform.dart. - The icon is Lucide's
bikefading from cobalt through violet and strawberry into carrot (ADR 0031), drawn with the other apps' by the repository'stool/app_icons.py(python3 -I tool/app_icons.py trainfrom the root):assets/icon/halkyon_train_icon.png, Lucide's ISC licence beside it, and every platform's launcher icon through flutter_launcher_icons.registerLucideLicenseputs Lucide's notice in the licence list. serverpod startruns inapps/train/halkyon_train_server(it launches the app fromlib/driver.dart);.mcp.json's--server-dirnames it when a session works on Train. Its tests need throwaway passwords in its git-ignoredconfig/passwords.yaml(database,serviceSecret,serverSideSessionKeyHashPepper,emailSignInPepper) or from the environment, as CI gives them. Train's development server has ports of its own (API 8180, Insights 8181, web 8182, PostgreSQL 8190; the app'sassets/config.json), so it runs beside Learn's, as a member of Learn's register; its tests useconfig/test.yaml's ports, as Learn's and Todo's do, which Melos runs one package at a time.