feat(train): the heart-rate strap, recorded by the service itself (ADR 0030, D25-D27)

The Record sheet gains a Heart rate row: a sheet looks for straps
advertising the Heart Rate Service, the nearest first, once nearby
devices are allowed, and the one chosen is remembered in the person's
folder; it records with every workout unless switched off for one.
The recorder service connects to it with Android's own Bluetooth API,
no package (D26), parses Heart Rate Measurement in Kotlin (tested on
the JVM), writes each rate and the strap's state to the recording
file, and asks for a lost strap again for as long as it records. The
recording screen shows the heart rate, and "Strap lost" while it is
gone; a workout's page shows the heart's average and highest rate,
each split's average and its chart.

iOS's recording waits for a Mac (D25), after the rower.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-28 15:50:36 +01:00
commit de5fd43e06
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
38 changed files with 1782 additions and 42 deletions

View file

@ -5,6 +5,6 @@ What holds for every app and package of this repository is in the [root AGENTS.m
- **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 ID `app.halkyon.train` on every platform; no web build. Licences as Learn's (server SSPL, client and app AGPL; `LICENSE.md`). - **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 ID `app.halkyon.train` on 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 in `server.dart` (`trainBase()`, `lib/src/train_base.dart`): its basics say it serves `train` (never change it once apps use it), sign-in mails name Halkyon Train, the source is this repository, `minimumAppBuild` 1, and `deleteTrainData` (`lib/src/train_data.dart`) as its `deletePersonData` hook, 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 sets `ACCOUNT_REGISTER_URL` (`trainBase(accounts: AccountsMode.fromEnvironment())`) with the instance's shared `INSTANCE_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. `addInstanceRoutes` serves 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), then `workouts`, purely additive: the sync engine's tables and Train's first three. **Synced models** (`database: sync`, the module `offline_sync`, `experimental_features: databaseSync` in `config/generator.yaml`): `content_key` (the person's key sealed to the account), `workout` and `workout_series` (a workout's series in chunks of at most ten minutes, deleted with it), each a sealed `payload` with its `keyId` and `nonce`, 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 server** includes `halkyon_base` (ADR 0014) and configures it in `server.dart` (`trainBase()`, `lib/src/train_base.dart`): its basics say it serves `train` (never change it once apps use it), sign-in mails name Halkyon Train, the source is this repository, `minimumAppBuild` 1, and `deleteTrainData` (`lib/src/train_data.dart`) as its `deletePersonData` hook, 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 sets `ACCOUNT_REGISTER_URL` (`trainBase(accounts: AccountsMode.fromEnvironment())`) with the instance's shared `INSTANCE_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. `addInstanceRoutes` serves 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), then `workouts`, purely additive: the sync engine's tables and Train's first three. **Synced models** (`database: sync`, the module `offline_sync`, `experimental_features: databaseSync` in `config/generator.yaml`): `content_key` (the person's key sealed to the account), `workout` and `workout_series` (a workout's series in chunks of at most ten minutes, deleted with it), each a sealed `payload` with its `keyId` and `nonce`, 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 `InstanceGate` and `SessionGate` with Train's frame (`trainFrame`: "Train" under "Halkyon", its icon, `train.example.org`, its own sentence) and `ServerpodInstanceChecker(app: 'train')`; `TrainDependencies` (`AppDependencies<TrainPerson>`) on one instance, `TrainPerson` per person: their own sync database in their folder (`sync.sqlite`, halkyon_app's `PersonFolders`, one person's at a time), halkyon_app's `SyncService` behind `AppCompatibility` (the module's basics' `minimumAppBuild`, against the build `package_info_plus` reads), their key (`TrainKeys`, `lib/src/core/privacy/`: one key per person, IDs `train1.<ms>.<nonce>`, the oldest-made writing, kept in `content_key` and opened only in Train's form, ADR 0030, D15) and their workouts (`WorkoutRepository`, `lib/src/feature/workout/`: halkyon_app's `PayloadEnvelope` seals 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`, with `TrainPerson.deletions`, halkyon_app's `DelayedDeletions`) and opens each one's page (`WorkoutCubit`: `analyse` in `workout_analysis.dart` reads its series into the track's pieces, splits, elevation and charts, drawn by `workout_chart.dart`; the map with flutter_map on the instance's source, `MapSourceRepository` asking `map.source`, its tiles from `TrainPerson.mapTiles`, vector_map_tiles in the app and none in widget tests), and the account sheet: halkyon_app's `AccountTiles` (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, from `TrainDependencies.sourceCodeUrl`, and the licence list). Texts through gen-l10n in British English (the template), German (informal du), an empty `en_US` and `en_GB` (`test/l10n/arb_files_test.dart` keeps 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's `resolveAppLocale`. Widget tests start Train's whole tree with halkyon_app's fakes (`test/app/train_app_test.dart`). - **The app** is halkyon_app's `InstanceGate` and `SessionGate` with Train's frame (`trainFrame`: "Train" under "Halkyon", its icon, `train.example.org`, its own sentence) and `ServerpodInstanceChecker(app: 'train')`; `TrainDependencies` (`AppDependencies<TrainPerson>`) on one instance, `TrainPerson` per person: their own sync database in their folder (`sync.sqlite`, halkyon_app's `PersonFolders`, one person's at a time), halkyon_app's `SyncService` behind `AppCompatibility` (the module's basics' `minimumAppBuild`, against the build `package_info_plus` reads), their key (`TrainKeys`, `lib/src/core/privacy/`: one key per person, IDs `train1.<ms>.<nonce>`, the oldest-made writing, kept in `content_key` and opened only in Train's form, ADR 0030, D15) and their workouts (`WorkoutRepository`, `lib/src/feature/workout/`: halkyon_app's `PayloadEnvelope` seals 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`, with `TrainPerson.deletions`, halkyon_app's `DelayedDeletions`) and opens each one's page (`WorkoutCubit`: `analyse` in `workout_analysis.dart` reads its series into the track's pieces, splits, elevation and charts, drawn by `workout_chart.dart`; the map with flutter_map on the instance's source, `MapSourceRepository` asking `map.source`, its tiles from `TrainPerson.mapTiles`, vector_map_tiles in the app and none in widget tests), and the account sheet: halkyon_app's `AccountTiles` (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, from `TrainDependencies.sourceCodeUrl`, and the licence list). Texts through gen-l10n in British English (the template), German (informal du), an empty `en_US` and `en_GB` (`test/l10n/arb_files_test.dart` keeps 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's `resolveAppLocale`. 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) is Android's alone for now (`TrainPerson.recording` null elsewhere): `RecorderService` (Kotlin, `android/app/src/main/kotlin/app/halkyon/train/`), a foreground service of the types `location` and `health` started from the open app, takes fixes from LocationManager's GPS (no Play services) and steps from the step detector, decides auto-pause (`AutoPause.kt`) and appends each sample as a line to the person's `recording.txt` (format in `recording_file.dart`), flushed every 5 s; `MainActivity` asks the permissions (never background location) and bridges `app.halkyon.train/recorder` (methods) and `…/recorder/lines` (the file so far, then each line). The app (`lib/src/feature/recording/`) follows those lines with `RecordingBloc`, 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 with `test/support/fake_recorder_platform.dart`; the Kotlin auto-pause on the JVM, `JAVA_HOME=<Android Studio's jbr> ./gradlew :app:testDebugUnitTest` in `android/` after a first `flutter build apk` (which writes `gradlew`), not part of `tool/check.dart`, as CI's runner has no Android SDK. - **Recording** (ADR 0030, D13, D16, D18 to D21, D26, D27) is Android's alone for now (iOS waits for a Mac, D25) (`TrainPerson.recording` null elsewhere): `RecorderService` (Kotlin, `android/app/src/main/kotlin/app/halkyon/train/`), a foreground service of the types `location` and `health` started from the open app, takes fixes from LocationManager's GPS (no Play services), steps from the step detector and the heart rate from the strap the person chose (ADR 0030, D26, D27: `StrapLink.kt` on Android's own Bluetooth API, no package, `HeartRateMeasurement.kt` parsing 0x2A37; `StrapScanner.kt` finds straps for the sheet over `…/recorder/straps`; the strap remembered in the person's `strap.json`, `StrapStore`), decides auto-pause (`AutoPause.kt`) and appends each sample as a line to the person's `recording.txt` (format in `recording_file.dart`), flushed every 5 s; `MainActivity` asks the permissions (never background location) and bridges `app.halkyon.train/recorder` (methods) and `…/recorder/lines` (the file so far, then each line). The app (`lib/src/feature/recording/`) follows those lines with `RecordingBloc`, 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 with `test/support/fake_recorder_platform.dart`; the Kotlin auto-pause and the heart-rate parser on the JVM, `JAVA_HOME=<Android Studio's jbr> ./gradlew :app:testDebugUnitTest` in `android/` after a first `flutter build apk` (which writes `gradlew`), not part of `tool/check.dart`, as CI's runner has no Android SDK.
- **The icon** is Lucide's `bike` fading from cobalt through violet and strawberry into carrot (ADR 0031), drawn with the other apps' by the repository's `tool/app_icons.py` (`python3 -I tool/app_icons.py train` from the root): `assets/icon/halkyon_train_icon.png`, Lucide's ISC licence beside it, and every platform's launcher icon through flutter_launcher_icons. `registerLucideLicense` puts Lucide's notice in the licence list. - **The icon** is Lucide's `bike` fading from cobalt through violet and strawberry into carrot (ADR 0031), drawn with the other apps' by the repository's `tool/app_icons.py` (`python3 -I tool/app_icons.py train` from the root): `assets/icon/halkyon_train_icon.png`, Lucide's ISC licence beside it, and every platform's launcher icon through flutter_launcher_icons. `registerLucideLicense` puts Lucide's notice in the licence list.
- **`serverpod start`** runs in `apps/train/halkyon_train_server` (it launches the app from `lib/driver.dart`); `.mcp.json`'s `--server-dir` names it when a session works on Train. Its tests need throwaway passwords in its git-ignored `config/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's `assets/config.json`), so it runs beside Learn's, as a member of Learn's register; its tests use `config/test.yaml`'s ports, as Learn's and Todo's do, which Melos runs one package at a time. - **`serverpod start`** runs in `apps/train/halkyon_train_server` (it launches the app from `lib/driver.dart`); `.mcp.json`'s `--server-dir` names it when a session works on Train. Its tests need throwaway passwords in its git-ignored `config/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's `assets/config.json`), so it runs beside Learn's, as a member of Learn's register; its tests use `config/test.yaml`'s ports, as Learn's and Todo's do, which Melos runs one package at a time.

View file

@ -1,4 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- The app talks to its instance: release builds need this too (Flutter's <!-- The app talks to its instance: release builds need this too (Flutter's
template grants it to debug and profile builds only). --> template grants it to debug and profile builds only). -->
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
@ -14,8 +15,18 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_HEALTH"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_HEALTH"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/> <uses-permission android:name="android.permission.WAKE_LOCK"/>
<!-- The heart-rate strap (ADR 0030, D26, D27): nearby devices from Android
12 on, never used to locate; before 12, Bluetooth's own permissions,
and scanning needs location, which the app has for GPS anyway. -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="s"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30"/>
<uses-feature android:name="android.hardware.location.gps" android:required="false"/> <uses-feature android:name="android.hardware.location.gps" android:required="false"/>
<uses-feature android:name="android.hardware.sensor.stepdetector" android:required="false"/> <uses-feature android:name="android.hardware.sensor.stepdetector" android:required="false"/>
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false"/>
<application <application
android:label="Halkyon Train" android:label="Halkyon Train"
android:name="${applicationName}" android:name="${applicationName}"

View file

@ -0,0 +1,39 @@
package app.halkyon.train
import java.util.UUID
/**
* The Heart Rate Service's measurement (Bluetooth SIG's Heart Rate Service
* 1.0, characteristic 0x2A37), as a strap notifies it (ADR 0030, D26).
*/
object HeartRateMeasurement {
val SERVICE: UUID = UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb")
val CHARACTERISTIC: UUID = UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb")
/** The Client Characteristic Configuration descriptor, written to have it notify. */
val CONFIG: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
private const val UINT16 = 0x01
private const val CONTACT_DETECTED = 0x02
private const val CONTACT_SUPPORTED = 0x04
/**
* The rate in beats a minute [value] holds; null where the strap says it
* isn't on the skin, the rate is 0, or the frame is cut short. The energy
* expended and the R-R intervals after it are left aside (D27).
*/
fun bpmOf(value: ByteArray): Int? {
if (value.isEmpty()) return null
val flags = value[0].toInt() and 0xFF
if (flags and CONTACT_SUPPORTED != 0 && flags and CONTACT_DETECTED == 0) return null
val bpm =
if (flags and UINT16 != 0) {
if (value.size < 3) return null
(value[1].toInt() and 0xFF) or ((value[2].toInt() and 0xFF) shl 8)
} else {
if (value.size < 2) return null
value[1].toInt() and 0xFF
}
return bpm.takeIf { it > 0 }
}
}

View file

@ -28,6 +28,7 @@ class MainActivity : FlutterActivity() {
activity = call.argument<String>("activity")!!, activity = call.argument<String>("activity")!!,
autoPause = call.argument<String>("autoPause"), autoPause = call.argument<String>("autoPause"),
notice = call.argument<String>("notice")!!, notice = call.argument<String>("notice")!!,
strap = call.argument<String>("strap"),
) )
result.success(null) result.success(null)
} }
@ -39,6 +40,8 @@ class MainActivity : FlutterActivity() {
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
// Heart-rate straps nearby, while the strap sheet looks (D27).
EventChannel(messenger, "app.halkyon.train/recorder/straps").setStreamHandler(StrapScanner(this))
EventChannel(messenger, "app.halkyon.train/recorder/lines").setStreamHandler( EventChannel(messenger, "app.halkyon.train/recorder/lines").setStreamHandler(
object : EventChannel.StreamHandler { object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink) { override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
@ -62,7 +65,7 @@ class MainActivity : FlutterActivity() {
/** /**
* Asks for the permissions [wanted] names (location, notifications, * Asks for the permissions [wanted] names (location, notifications,
* activity) that aren't granted yet (ADR 0030, D19), and answers which are * activity, bluetooth) that aren't granted yet (ADR 0030, D19), and answers which are
* granted afterwards. * granted afterwards.
*/ */
private fun ask(wanted: List<String>, result: MethodChannel.Result) { private fun ask(wanted: List<String>, result: MethodChannel.Result) {
@ -111,6 +114,15 @@ class MainActivity : FlutterActivity() {
} else { } else {
emptyList() emptyList()
} }
// Finding and connecting to a strap (D27): nearby devices from Android
// 12 on, declared as never for location; before, scanning needs
// location.
"bluetooth" ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
listOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
} else {
listOf(Manifest.permission.ACCESS_FINE_LOCATION)
}
else -> emptyList() else -> emptyList()
} }

View file

@ -35,7 +35,8 @@ import org.json.JSONObject
// The recording's side of the system (ADR 0030, D13, D16, D18, D19): a // The recording's side of the system (ADR 0030, D13, D16, D18, D19): a
// foreground service, started from the open app, that takes GPS fixes from // foreground service, started from the open app, that takes GPS fixes from
// Android's LocationManager (no Play services) and steps from the step // Android's LocationManager (no Play services) and steps from the step
// detector, pauses by itself, and appends each sample as a line to the // detector, reads the heart-rate strap where the person chose one (D26,
// D27), pauses by itself, and appends each sample as a line to the
// recording file, flushed every few seconds, so a recording survives the app // recording file, flushed every few seconds, so a recording survives the app
// and is found cut off if the process doesn't. Each line goes to the app as // and is found cut off if the process doesn't. Each line goes to the app as
// well while it listens; the app works out the totals and tells the service // well while it listens; the app works out the totals and tells the service
@ -51,6 +52,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
private const val EXTRA_ACTIVITY = "activity" private const val EXTRA_ACTIVITY = "activity"
private const val EXTRA_AUTO_PAUSE = "autoPause" private const val EXTRA_AUTO_PAUSE = "autoPause"
private const val EXTRA_NOTICE = "notice" private const val EXTRA_NOTICE = "notice"
private const val EXTRA_STRAP = "strap"
private const val CHANNEL = "recording" private const val CHANNEL = "recording"
private const val NOTIFICATION_ID = 5301 private const val NOTIFICATION_ID = 5301
private const val FLUSH_EVERY_MS = 5_000L private const val FLUSH_EVERY_MS = 5_000L
@ -66,9 +68,10 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
/** /**
* Starts recording [activity] into the file at [path], pausing by itself * Starts recording [activity] into the file at [path], pausing by itself
* by [autoPause] (null: never), its notification as [notice] says. * by [autoPause] (null: never), with the strap at [strap] (null: none),
* its notification as [notice] says.
*/ */
fun start(context: Context, path: String, activity: String, autoPause: String?, notice: String) { fun start(context: Context, path: String, activity: String, autoPause: String?, notice: String, strap: String?) {
ContextCompat.startForegroundService( ContextCompat.startForegroundService(
context, context,
Intent(context, RecorderService::class.java) Intent(context, RecorderService::class.java)
@ -76,7 +79,8 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
.putExtra(EXTRA_PATH, path) .putExtra(EXTRA_PATH, path)
.putExtra(EXTRA_ACTIVITY, activity) .putExtra(EXTRA_ACTIVITY, activity)
.putExtra(EXTRA_AUTO_PAUSE, autoPause) .putExtra(EXTRA_AUTO_PAUSE, autoPause)
.putExtra(EXTRA_NOTICE, notice), .putExtra(EXTRA_NOTICE, notice)
.putExtra(EXTRA_STRAP, strap),
) )
} }
@ -106,6 +110,7 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
private var pausedByHand = false private var pausedByHand = false
private var notice: JSONObject? = null private var notice: JSONObject? = null
private var wakeLock: PowerManager.WakeLock? = null private var wakeLock: PowerManager.WakeLock? = null
private var strap: StrapLink? = null
private val handler = Handler(Looper.getMainLooper()) private val handler = Handler(Looper.getMainLooper())
private var lastFlush = 0L private var lastFlush = 0L
@ -178,6 +183,20 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
getSystemService(PowerManager::class.java) getSystemService(PowerManager::class.java)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "HalkyonTrain:recording") .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "HalkyonTrain:recording")
.apply { acquire(12 * 60 * 60 * 1_000L) } .apply { acquire(12 * 60 * 60 * 1_000L) }
// The strap where one was chosen and connecting is allowed (Android 12
// on asks; before, scanning's location stands for it).
val connects =
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || granted(Manifest.permission.BLUETOOTH_CONNECT)
intent.getStringExtra(EXTRA_STRAP)?.takeIf { connects }?.let { address ->
strap =
StrapLink(
this,
address,
handler,
onState = { state -> write("strap ${System.currentTimeMillis()} $state") },
onBpm = { bpm -> write("h ${System.currentTimeMillis()} $bpm") },
).also { it.open() }
}
lastFlush = now lastFlush = now
handler.postDelayed(ticker, 1_000) handler.postDelayed(ticker, 1_000)
} }
@ -246,6 +265,8 @@ class RecorderService : Service(), LocationListener, SensorEventListener {
handler.removeCallbacks(ticker) handler.removeCallbacks(ticker)
getSystemService(LocationManager::class.java).removeUpdates(this) getSystemService(LocationManager::class.java).removeUpdates(this)
getSystemService(SensorManager::class.java).unregisterListener(this) getSystemService(SensorManager::class.java).unregisterListener(this)
strap?.close()
strap = null
write("end ${System.currentTimeMillis()}") write("end ${System.currentTimeMillis()}")
writer?.close() writer?.close()
writer = null writer = null

View file

@ -0,0 +1,127 @@
package app.halkyon.train
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.content.Context
import android.os.Build
import android.os.Handler
/**
* The heart-rate strap during a recording (ADR 0030, D26, D27): connected
* by its address without bonding, its measurements notified, and asked for
* again by itself whenever it goes, until [close]. Each state and each rate
* reaches [onState] and [onBpm] on [handler]'s thread, the one the recorder
* writes its file on.
*/
@SuppressLint("MissingPermission") // The recorder opens it only where connecting is allowed.
class StrapLink(
private val context: Context,
private val address: String,
private val handler: Handler,
private val onState: (String) -> Unit,
private val onBpm: (Int) -> Unit,
) : BluetoothGattCallback() {
private var gatt: BluetoothGatt? = null
private var closed = false
private var connected = false
/** Asks for the strap: at once first, then in the background after each loss. */
fun open() {
val device = adapterDevice() ?: return onState("lost")
onState("connecting")
// A direct attempt finds a strap in reach within seconds; one that
// fails (out of reach, or off) falls back on the background one.
gatt = device.connectGatt(context, false, this, BluetoothDevice.TRANSPORT_LE)
}
/** Lets the strap go. */
fun close() {
handler.post {
closed = true
gatt?.disconnect()
gatt?.close()
gatt = null
}
}
private fun adapterDevice(): BluetoothDevice? =
try {
context.getSystemService(BluetoothManager::class.java)?.adapter?.getRemoteDevice(address)
} catch (_: IllegalArgumentException) {
null
}
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
handler.post {
if (closed) return@post
when (newState) {
BluetoothProfile.STATE_CONNECTED -> gatt.discoverServices()
BluetoothProfile.STATE_DISCONNECTED -> {
if (connected) onState("lost")
connected = false
gatt.close()
// In the background, for as long as the recording runs: Android
// connects once the strap is in reach again.
this.gatt = adapterDevice()?.connectGatt(context, true, this, BluetoothDevice.TRANSPORT_LE)
}
}
}
}
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
handler.post {
if (closed) return@post
val measurement =
gatt.getService(HeartRateMeasurement.SERVICE)?.getCharacteristic(HeartRateMeasurement.CHARACTERISTIC)
?: return@post gatt.disconnect()
gatt.setCharacteristicNotification(measurement, true)
val config = measurement.getDescriptor(HeartRateMeasurement.CONFIG) ?: return@post gatt.disconnect()
val enable = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
gatt.writeDescriptor(config, enable)
} else {
@Suppress("DEPRECATION")
config.value = enable
@Suppress("DEPRECATION")
gatt.writeDescriptor(config)
}
}
}
override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
handler.post {
if (closed || status != BluetoothGatt.GATT_SUCCESS) return@post
connected = true
onState("connected")
}
}
// Android 13 on.
override fun onCharacteristicChanged(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray,
) {
measured(characteristic, value)
}
// Before Android 13.
@Deprecated("Android 13 hands the value itself")
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) return
@Suppress("DEPRECATION")
measured(characteristic, characteristic.value ?: return)
}
private fun measured(characteristic: BluetoothGattCharacteristic, value: ByteArray) {
if (characteristic.uuid != HeartRateMeasurement.CHARACTERISTIC) return
val bpm = HeartRateMeasurement.bpmOf(value.copyOf()) ?: return
handler.post { if (!closed) onBpm(bpm) }
}
}

View file

@ -0,0 +1,69 @@
package app.halkyon.train
import android.annotation.SuppressLint
import android.bluetooth.BluetoothManager
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.os.ParcelUuid
import io.flutter.plugin.common.EventChannel
/**
* Looks for heart-rate straps nearby while the app listens (ADR 0030, D27):
* every device advertising the Heart Rate Service, as `{address, name,
* rssi}`; the error `off` where Bluetooth is, `refused` where scanning isn't
* allowed (the app asks first).
*/
class StrapScanner(private val context: Context) : EventChannel.StreamHandler {
private var callback: ScanCallback? = null
@SuppressLint("MissingPermission") // Refused scans end in the SecurityException below.
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
val adapter = context.getSystemService(BluetoothManager::class.java)?.adapter
val scanner = adapter?.takeIf { it.isEnabled }?.bluetoothLeScanner
if (scanner == null) {
events.error("off", "Bluetooth is off", null)
return
}
val found =
object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
val name =
result.scanRecord?.deviceName
?: try {
result.device.name
} catch (_: SecurityException) {
null
}
events.success(mapOf("address" to result.device.address, "name" to name, "rssi" to result.rssi))
}
override fun onScanFailed(errorCode: Int) {
events.error("failed", "Scan failed: $errorCode", null)
}
}
try {
scanner.startScan(
listOf(ScanFilter.Builder().setServiceUuid(ParcelUuid(HeartRateMeasurement.SERVICE)).build()),
ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build(),
found,
)
callback = found
} catch (_: SecurityException) {
events.error("refused", "Scanning not allowed", null)
}
}
@SuppressLint("MissingPermission")
override fun onCancel(arguments: Any?) {
val found = callback ?: return
callback = null
try {
context.getSystemService(BluetoothManager::class.java)?.adapter?.bluetoothLeScanner?.stopScan(found)
} catch (_: SecurityException) {
// Taken away while it looked: it stopped with it.
}
}
}

View file

@ -0,0 +1,51 @@
package app.halkyon.train
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
// Heart Rate Measurement (0x2A37, Bluetooth SIG's Heart Rate Service 1.0), as
// straps send it (ADR 0030, D26, D27): a flags byte, the rate in 8 or 16 bits,
// then the energy expended and R-R intervals where the flags say.
class HeartRateMeasurementTest {
private fun frame(vararg bytes: Int) = ByteArray(bytes.size) { bytes[it].toByte() }
@Test
fun `an 8-bit rate`() {
assertEquals(72, HeartRateMeasurement.bpmOf(frame(0x00, 72)))
}
@Test
fun `an 8-bit rate above 127 reads unsigned`() {
assertEquals(182, HeartRateMeasurement.bpmOf(frame(0x00, 182)))
}
@Test
fun `a 16-bit rate, little-endian`() {
assertEquals(300, HeartRateMeasurement.bpmOf(frame(0x01, 0x2C, 0x01)))
}
@Test
fun `R-R intervals and energy after the rate are left aside`() {
// As a Polar H10 sends it: contact detected, R-R present, two intervals.
assertEquals(64, HeartRateMeasurement.bpmOf(frame(0x16, 64, 0x9A, 0x03, 0x8F, 0x03)))
// Energy expended present (bit 3).
assertEquals(130, HeartRateMeasurement.bpmOf(frame(0x08, 130, 0x10, 0x00)))
}
@Test
fun `a strap that tells it isn't on the skin gives no rate`() {
// Contact supported (bit 2), not detected (bit 1 clear).
assertNull(HeartRateMeasurement.bpmOf(frame(0x04, 80)))
// Detected.
assertEquals(80, HeartRateMeasurement.bpmOf(frame(0x06, 80)))
}
@Test
fun `nothing, zero or a frame cut short gives no rate`() {
assertNull(HeartRateMeasurement.bpmOf(frame()))
assertNull(HeartRateMeasurement.bpmOf(frame(0x00)))
assertNull(HeartRateMeasurement.bpmOf(frame(0x00, 0)))
assertNull(HeartRateMeasurement.bpmOf(frame(0x01, 0x2C)))
}
}

View file

@ -82,5 +82,23 @@
"workoutMapSource": "Karte von {name}: sieht die Adresse deines Geräts und den gezeigten Ausschnitt, nicht deine Strecke.", "workoutMapSource": "Karte von {name}: sieht die Adresse deines Geräts und den gezeigten Ausschnitt, nicht deine Strecke.",
"workoutMapSourceAbout": "Was {name} speichert", "workoutMapSourceAbout": "Was {name} speichert",
"workoutMapUnavailable": "Die Karte kann gerade nicht geladen werden.", "workoutMapUnavailable": "Die Karte kann gerade nicht geladen werden.",
"workoutNotFound": "Dieses Training gibt es nicht mehr." "workoutNotFound": "Dieses Training gibt es nicht mehr.",
"recordHeartRate": "Herzfrequenz",
"recordNoStrap": "Kein Brustgurt",
"strapSheetTitle": "Brustgurt",
"strapSearching": "Suche Brustgurte in der Nähe …",
"strapSearchingHint": "Leg den Gurt an: Die meisten wachen erst auf, wenn sie die Haut berühren.",
"strapNear": "ganz nah",
"strapFar": "weiter weg",
"strapUnnamed": "Brustgurt ohne Namen",
"strapForget": "Brustgurt vergessen",
"strapRefused": "Train braucht Geräte in der Nähe, um deinen Brustgurt zu finden. Du kannst das in den Systemeinstellungen erlauben.",
"strapBluetoothOff": "Bluetooth ist aus. Schalte es ein, um deinen Brustgurt zu finden.",
"recordingHeartRate": "Herzfrequenz",
"recordingStrapLost": "Brustgurt nicht verbunden",
"workoutAverageHeartRate": "mittlere Herzfrequenz",
"workoutMaxHeartRate": "höchste Herzfrequenz",
"workoutBpmValue": "{bpm} bpm",
"workoutHeartRateChart": "Herzfrequenz"
} }

View file

@ -104,5 +104,32 @@
"workoutMapSourceAbout": "What {name} keeps", "workoutMapSourceAbout": "What {name} keeps",
"@workoutMapSourceAbout": {"description": "A link to the map source's page about what it keeps.", "placeholders": {"name": {"type": "String", "example": "OpenFreeMap"}}}, "@workoutMapSourceAbout": {"description": "A link to the map source's page about what it keeps.", "placeholders": {"name": {"type": "String", "example": "OpenFreeMap"}}},
"workoutMapUnavailable": "The map can't be loaded now.", "workoutMapUnavailable": "The map can't be loaded now.",
"workoutNotFound": "This workout is gone." "workoutNotFound": "This workout is gone.",
"recordHeartRate": "Heart rate",
"recordNoStrap": "No strap",
"@recordNoStrap": {"description": "On the Record sheet, under Heart rate, where no strap is chosen."},
"strapSheetTitle": "Heart-rate strap",
"strapSearching": "Looking for straps nearby…",
"strapSearchingHint": "Put the strap on: most wake up only once they touch skin.",
"strapNear": "close by",
"@strapNear": {"description": "Under a strap found: its signal is strong."},
"strapFar": "further off",
"@strapFar": {"description": "Under a strap found: its signal is weak."},
"strapUnnamed": "Unnamed strap",
"@strapUnnamed": {"description": "A strap that sends no name."},
"strapForget": "Forget strap",
"strapRefused": "Train needs nearby devices to find your strap. You can allow it in the system's settings.",
"strapBluetoothOff": "Bluetooth is off. Turn it on to find your strap.",
"recordingHeartRate": "heart rate",
"@recordingHeartRate": {"description": "Under the heart rate, lower case."},
"recordingStrapLost": "Strap lost",
"@recordingStrapLost": {"description": "While recording: the strap is out of reach or off; Train keeps asking for it."},
"workoutAverageHeartRate": "average heart rate",
"@workoutAverageHeartRate": {"description": "Lower case, under a figure."},
"workoutMaxHeartRate": "highest heart rate",
"@workoutMaxHeartRate": {"description": "Lower case, under a figure."},
"workoutBpmValue": "{bpm} bpm",
"@workoutBpmValue": {"placeholders": {"bpm": {"type": "int"}}},
"workoutHeartRateChart": "Heart rate"
} }

View file

@ -555,6 +555,108 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'This workout is gone.'** /// **'This workout is gone.'**
String get workoutNotFound; String get workoutNotFound;
/// No description provided for @recordHeartRate.
///
/// In en, this message translates to:
/// **'Heart rate'**
String get recordHeartRate;
/// On the Record sheet, under Heart rate, where no strap is chosen.
///
/// In en, this message translates to:
/// **'No strap'**
String get recordNoStrap;
/// No description provided for @strapSheetTitle.
///
/// In en, this message translates to:
/// **'Heart-rate strap'**
String get strapSheetTitle;
/// No description provided for @strapSearching.
///
/// In en, this message translates to:
/// **'Looking for straps nearby…'**
String get strapSearching;
/// No description provided for @strapSearchingHint.
///
/// In en, this message translates to:
/// **'Put the strap on: most wake up only once they touch skin.'**
String get strapSearchingHint;
/// Under a strap found: its signal is strong.
///
/// In en, this message translates to:
/// **'close by'**
String get strapNear;
/// Under a strap found: its signal is weak.
///
/// In en, this message translates to:
/// **'further off'**
String get strapFar;
/// A strap that sends no name.
///
/// In en, this message translates to:
/// **'Unnamed strap'**
String get strapUnnamed;
/// No description provided for @strapForget.
///
/// In en, this message translates to:
/// **'Forget strap'**
String get strapForget;
/// No description provided for @strapRefused.
///
/// In en, this message translates to:
/// **'Train needs nearby devices to find your strap. You can allow it in the system\'s settings.'**
String get strapRefused;
/// No description provided for @strapBluetoothOff.
///
/// In en, this message translates to:
/// **'Bluetooth is off. Turn it on to find your strap.'**
String get strapBluetoothOff;
/// Under the heart rate, lower case.
///
/// In en, this message translates to:
/// **'heart rate'**
String get recordingHeartRate;
/// While recording: the strap is out of reach or off; Train keeps asking for it.
///
/// In en, this message translates to:
/// **'Strap lost'**
String get recordingStrapLost;
/// Lower case, under a figure.
///
/// In en, this message translates to:
/// **'average heart rate'**
String get workoutAverageHeartRate;
/// Lower case, under a figure.
///
/// In en, this message translates to:
/// **'highest heart rate'**
String get workoutMaxHeartRate;
/// No description provided for @workoutBpmValue.
///
/// In en, this message translates to:
/// **'{bpm} bpm'**
String workoutBpmValue(int bpm);
/// No description provided for @workoutHeartRateChart.
///
/// In en, this message translates to:
/// **'Heart rate'**
String get workoutHeartRateChart;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -298,4 +298,60 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get workoutNotFound => 'Dieses Training gibt es nicht mehr.'; String get workoutNotFound => 'Dieses Training gibt es nicht mehr.';
@override
String get recordHeartRate => 'Herzfrequenz';
@override
String get recordNoStrap => 'Kein Brustgurt';
@override
String get strapSheetTitle => 'Brustgurt';
@override
String get strapSearching => 'Suche Brustgurte in der Nähe …';
@override
String get strapSearchingHint =>
'Leg den Gurt an: Die meisten wachen erst auf, wenn sie die Haut berühren.';
@override
String get strapNear => 'ganz nah';
@override
String get strapFar => 'weiter weg';
@override
String get strapUnnamed => 'Brustgurt ohne Namen';
@override
String get strapForget => 'Brustgurt vergessen';
@override
String get strapRefused =>
'Train braucht Geräte in der Nähe, um deinen Brustgurt zu finden. Du kannst das in den Systemeinstellungen erlauben.';
@override
String get strapBluetoothOff =>
'Bluetooth ist aus. Schalte es ein, um deinen Brustgurt zu finden.';
@override
String get recordingHeartRate => 'Herzfrequenz';
@override
String get recordingStrapLost => 'Brustgurt nicht verbunden';
@override
String get workoutAverageHeartRate => 'mittlere Herzfrequenz';
@override
String get workoutMaxHeartRate => 'höchste Herzfrequenz';
@override
String workoutBpmValue(int bpm) {
return '$bpm bpm';
}
@override
String get workoutHeartRateChart => 'Herzfrequenz';
} }

View file

@ -295,6 +295,62 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get workoutNotFound => 'This workout is gone.'; String get workoutNotFound => 'This workout is gone.';
@override
String get recordHeartRate => 'Heart rate';
@override
String get recordNoStrap => 'No strap';
@override
String get strapSheetTitle => 'Heart-rate strap';
@override
String get strapSearching => 'Looking for straps nearby…';
@override
String get strapSearchingHint =>
'Put the strap on: most wake up only once they touch skin.';
@override
String get strapNear => 'close by';
@override
String get strapFar => 'further off';
@override
String get strapUnnamed => 'Unnamed strap';
@override
String get strapForget => 'Forget strap';
@override
String get strapRefused =>
'Train needs nearby devices to find your strap. You can allow it in the system\'s settings.';
@override
String get strapBluetoothOff =>
'Bluetooth is off. Turn it on to find your strap.';
@override
String get recordingHeartRate => 'heart rate';
@override
String get recordingStrapLost => 'Strap lost';
@override
String get workoutAverageHeartRate => 'average heart rate';
@override
String get workoutMaxHeartRate => 'highest heart rate';
@override
String workoutBpmValue(int bpm) {
return '$bpm bpm';
}
@override
String get workoutHeartRateChart => 'Heart rate';
} }
/// The translations for English, as used in the United Kingdom (`en_GB`). /// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -107,6 +107,7 @@ class const _Recording({required final Widget child}) extends StatelessWidget {
recording.platform, recording.platform,
train.workouts, train.workouts,
recording.file, recording.file,
recording.straps,
notice: (live) => noticeOf( notice: (live) => noticeOf(
AppLocalizations.of(context), AppLocalizations.of(context),
Localizations.localeOf(context).toString(), Localizations.localeOf(context).toString(),

View file

@ -12,6 +12,7 @@ import 'package:halkyon_train_flutter/src/feature/map/data/map_source_repository
import 'package:halkyon_train_flutter/src/feature/map/widget/map_tiles.dart'; import 'package:halkyon_train_flutter/src/feature/map/widget/map_tiles.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/android_recorder_platform.dart'; import 'package:halkyon_train_flutter/src/feature/recording/data/android_recorder_platform.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart'; import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/strap_store.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/sync_workout_gateway.dart'; import 'package:halkyon_train_flutter/src/feature/workout/data/sync_workout_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart'; import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
@ -21,9 +22,13 @@ import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart';
import 'package:serverpod_flutter/serverpod_flutter.dart'; import 'package:serverpod_flutter/serverpod_flutter.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart'; import 'package:serverpod_offline_sync_client/offline_sync.dart';
/// Where a device records workouts (ADR 0030, D13, D18): its recorder, and /// Where a device records workouts (ADR 0030, D13, D18, D27): its recorder,
/// the person's recording file in their folder. /// and the person's recording file and strap in their folder.
typedef RecordingDevice = ({RecorderPlatform platform, File file}); typedef RecordingDevice = ({
RecorderPlatform platform,
File file,
StrapStore straps,
});
/// A signed-in person's dependencies: who they are and their workouts, on /// A signed-in person's dependencies: who they are and their workouts, on
/// their own local database in their folder, which the sync engine keeps in /// their own local database in their folder, which the sync engine keeps in
@ -293,6 +298,7 @@ Future<TrainPerson> _openPersonData(
? ( ? (
platform: AndroidRecorderPlatform(), platform: AndroidRecorderPlatform(),
file: File(p.join(folder.path, 'recording.txt')), file: File(p.join(folder.path, 'recording.txt')),
straps: StrapStore(File(p.join(folder.path, 'strap.json'))),
) )
: null, : null,
onDispose: () async { onDispose: () async {

View file

@ -9,6 +9,7 @@ import 'package:halkyon_train_flutter/src/feature/recording/data/live_recording.
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart'; import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart'; import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recording_file.dart'; import 'package:halkyon_train_flutter/src/feature/recording/data/recording_file.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/strap_store.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/live_recording.dart'; import 'package:halkyon_train_flutter/src/feature/recording/model/live_recording.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart'; import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart'; import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
@ -19,6 +20,8 @@ import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// (D18), follows its lines, and saves the recording as a workout or throws /// (D18), follows its lines, and saves the recording as a workout or throws
/// it away once stopped. As it opens, it finds a recording still running (the /// it away once stopped. As it opens, it finds a recording still running (the
/// app was gone, the service not), one cut off, or one stopped and not saved. /// app was gone, the service not), one cut off, or one stopped and not saved.
/// It remembers the person's heart-rate strap, which a recording uses unless
/// it is switched off for it (D27).
/// ///
/// The recorder decides the pauses (D16); this works out what the screen and /// The recorder decides the pauses (D16); this works out what the screen and
/// the notification show from the same lines the file holds. /// the notification show from the same lines the file holds.
@ -27,7 +30,10 @@ class RecordingBloc(
final WorkoutRepository _workouts, final WorkoutRepository _workouts,
/// The person's recording file, in their folder. /// The person's recording file, in their folder.
final File _file, { final File _file,
/// The person's strap on this device, in their folder.
final StrapStore _straps, {
/// What the notification shows of [LiveRecording], in the app's language. /// What the notification shows of [LiveRecording], in the app's language.
required final RecorderNotice Function(LiveRecording live) notice, required final RecorderNotice Function(LiveRecording live) notice,
@ -43,6 +49,14 @@ class RecordingBloc(
on<RecordingEventStopRequested>(_onStop); on<RecordingEventStopRequested>(_onStop);
on<RecordingEventSaveRequested>(_onSave); on<RecordingEventSaveRequested>(_onSave);
on<RecordingEventDiscardRequested>(_onDiscard); on<RecordingEventDiscardRequested>(_onDiscard);
on<RecordingEventStrapChosen>((event, emit) {
_straps.save(event.strap);
emit(state._withStrap(event.strap));
});
on<RecordingEventStrapForgotten>((event, emit) {
_straps.forget();
emit(state._withStrap(null));
});
on<RecordingEventLineWritten>((event, emit) { on<RecordingEventLineWritten>((event, emit) {
_lines.writeln(event.line); _lines.writeln(event.line);
// A pause, its end or the end show at once; samples with the next // A pause, its end or the end show at once; samples with the next
@ -73,12 +87,14 @@ class RecordingBloc(
Emitter<RecordingState> emit, Emitter<RecordingState> emit,
) async { ) async {
final lastActivity = await _lastActivity(); final lastActivity = await _lastActivity();
final strap = _straps.read();
if (await _platform.isRecording()) { if (await _platform.isRecording()) {
_follow(); _follow();
emit( emit(
RecordingState( RecordingState(
phase: RecordingPhase.recording, phase: RecordingPhase.recording,
lastActivity: lastActivity, lastActivity: lastActivity,
strap: strap,
), ),
); );
return; return;
@ -93,6 +109,7 @@ class RecordingBloc(
}, },
lastActivity: lastActivity, lastActivity: lastActivity,
recording: kept, recording: kept,
strap: strap,
), ),
); );
} }
@ -132,6 +149,7 @@ class RecordingBloc(
activity: activity, activity: activity,
autoPause: autoPause, autoPause: autoPause,
notice: notice, notice: notice,
strap: event.useStrap ? state.strap?.address : null,
); );
_shown = _keyOf(notice); _shown = _keyOf(notice);
_follow(); _follow();
@ -140,6 +158,7 @@ class RecordingBloc(
phase: RecordingPhase.recording, phase: RecordingPhase.recording,
lastActivity: activity, lastActivity: activity,
live: liveOf(Recording(activity: activity, startedAt: now), now), live: liveOf(Recording(activity: activity, startedAt: now), now),
strap: state.strap,
), ),
); );
} }
@ -156,6 +175,7 @@ class RecordingBloc(
phase: recording == null ? RecordingPhase.idle : RecordingPhase.stopped, phase: recording == null ? RecordingPhase.idle : RecordingPhase.stopped,
lastActivity: state.lastActivity, lastActivity: state.lastActivity,
recording: recording, recording: recording,
strap: state.strap,
), ),
); );
} }
@ -182,6 +202,7 @@ class RecordingBloc(
RecordingState( RecordingState(
phase: RecordingPhase.idle, phase: RecordingPhase.idle,
lastActivity: recording.activity, lastActivity: recording.activity,
strap: state.strap,
), ),
); );
} }
@ -195,6 +216,7 @@ class RecordingBloc(
RecordingState( RecordingState(
phase: RecordingPhase.idle, phase: RecordingPhase.idle,
lastActivity: state.lastActivity, lastActivity: state.lastActivity,
strap: state.strap,
), ),
); );
} }
@ -314,6 +336,9 @@ final class const RecordingState({
/// Whether saving failed (as on a device without the account's keys). /// Whether saving failed (as on a device without the account's keys).
final bool saveFailed = false, final bool saveFailed = false,
/// The person's strap on this device, if they chose one (D27).
final Strap? strap,
}) with Equatable { }) with Equatable {
RecordingState copyWith({ RecordingState copyWith({
LiveRecording? live, LiveRecording? live,
@ -326,6 +351,17 @@ final class const RecordingState({
recording: recording, recording: recording,
locationRefused: locationRefused ?? this.locationRefused, locationRefused: locationRefused ?? this.locationRefused,
saveFailed: saveFailed ?? this.saveFailed, saveFailed: saveFailed ?? this.saveFailed,
strap: strap,
);
RecordingState _withStrap(Strap? strap) => RecordingState(
phase: phase,
lastActivity: lastActivity,
live: live,
recording: recording,
locationRefused: locationRefused,
saveFailed: saveFailed,
strap: strap,
); );
@override @override
@ -336,6 +372,7 @@ final class const RecordingState({
recording, recording,
locationRefused, locationRefused,
saveFailed, saveFailed,
strap,
]; ];
} }
@ -346,12 +383,20 @@ sealed class const RecordingEvent();
final class const RecordingEventOpened() extends RecordingEvent; final class const RecordingEventOpened() extends RecordingEvent;
/// The person starts recording [activity], pausing by itself where /// The person starts recording [activity], pausing by itself where
/// [autoPause] (D16). /// [autoPause] (D16), with their strap unless not [useStrap] (D27).
final class const RecordingEventStartRequested( final class const RecordingEventStartRequested(
final Activity activity, { final Activity activity, {
final bool autoPause = true, final bool autoPause = true,
final bool useStrap = true,
}) extends RecordingEvent; }) extends RecordingEvent;
/// The person chose [strap] to record with on this device.
final class const RecordingEventStrapChosen(final Strap strap)
extends RecordingEvent;
/// The person forgot their strap on this device.
final class const RecordingEventStrapForgotten() extends RecordingEvent;
final class const RecordingEventPauseRequested() extends RecordingEvent; final class const RecordingEventPauseRequested() extends RecordingEvent;
final class const RecordingEventResumeRequested() extends RecordingEvent; final class const RecordingEventResumeRequested() extends RecordingEvent;

View file

@ -0,0 +1,89 @@
import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
/// Looks for heart-rate straps nearby (ADR 0030, D27), once nearby devices
/// are allowed (asked first, as D19 asks when a thing is first needed), until
/// it is closed.
class StrapSearchCubit(final RecorderPlatform _platform)
extends Cubit<StrapSearchState> {
this : super(const StrapSearchState());
StreamSubscription<FoundStrap>? _scan;
Future<void> open() async {
final granted = await _platform.ask({RecorderPermission.bluetooth});
if (isClosed) {
return;
}
if (!granted.contains(RecorderPermission.bluetooth)) {
emit(const StrapSearchState(phase: StrapSearchPhase.refused));
return;
}
emit(const StrapSearchState(phase: StrapSearchPhase.searching));
_scan = _platform.scanStraps().listen(
_seen,
onError: (Object error) {
if (error is BluetoothOff && !isClosed) {
emit(const StrapSearchState(phase: StrapSearchPhase.bluetoothOff));
}
},
);
}
void _seen(FoundStrap strap) {
final before = [
for (final found in state.found)
if (found.address == strap.address) found,
].firstOrNull;
final seen = FoundStrap(
address: strap.address,
// Some straps send their name in one advertisement and not the next.
name: strap.name ?? before?.name,
rssi: strap.rssi,
);
emit(
StrapSearchState(
phase: state.phase,
found: [
for (final found in state.found)
if (found.address != strap.address) found,
seen,
]..sort((a, b) => b.rssi.compareTo(a.rssi)),
),
);
}
@override
Future<void> close() async {
await _scan?.cancel();
await super.close();
}
}
/// Where the search stands.
enum StrapSearchPhase() {
/// Asking for nearby devices.
asking,
/// Looking: what it found so far.
searching,
/// Nearby devices refused.
refused,
/// Bluetooth off.
bluetoothOff,
}
final class const StrapSearchState({
final StrapSearchPhase phase = StrapSearchPhase.asking,
/// The straps seen, the nearest first.
final List<FoundStrap> found = const [],
}) with Equatable {
@override
List<Object?> get props => [phase, found];
}

View file

@ -7,10 +7,12 @@ import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// Recording on Android, through the app's own `RecorderService` (Kotlin, /// Recording on Android, through the app's own `RecorderService` (Kotlin,
/// `android/app/src/main/kotlin/`, ADR 0030, D13): LocationManager's GPS and /// `android/app/src/main/kotlin/`, ADR 0030, D13): LocationManager's GPS and
/// the step detector in a foreground service, auto-pause decided there, the /// the step detector in a foreground service, auto-pause decided there, the
/// file appended to there. /// file appended to there, and the heart-rate strap found and read there
/// (D26).
class AndroidRecorderPlatform() implements RecorderPlatform { class AndroidRecorderPlatform() implements RecorderPlatform {
static const _channel = MethodChannel('app.halkyon.train/recorder'); static const _channel = MethodChannel('app.halkyon.train/recorder');
static const _lines = EventChannel('app.halkyon.train/recorder/lines'); static const _lines = EventChannel('app.halkyon.train/recorder/lines');
static const _straps = EventChannel('app.halkyon.train/recorder/straps');
@override @override
Future<Set<RecorderPermission>> ask(Set<RecorderPermission> wanted) async { Future<Set<RecorderPermission>> ask(Set<RecorderPermission> wanted) async {
@ -29,11 +31,13 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
required Activity activity, required Activity activity,
required AutoPauseBy? autoPause, required AutoPauseBy? autoPause,
required RecorderNotice notice, required RecorderNotice notice,
String? strap,
}) => _channel.invokeMethod<void>('start', { }) => _channel.invokeMethod<void>('start', {
'path': path, 'path': path,
'activity': activity.name, 'activity': activity.name,
'autoPause': autoPause?.name, 'autoPause': autoPause?.name,
'notice': noticeJson(notice), 'notice': noticeJson(notice),
'strap': strap,
}); });
@override @override
@ -55,6 +59,30 @@ class AndroidRecorderPlatform() implements RecorderPlatform {
@override @override
Stream<String> lines() => _lines.receiveBroadcastStream().cast<String>(); Stream<String> lines() => _lines.receiveBroadcastStream().cast<String>();
/// Each strap seen as `{address, name, rssi}`; the error `off` where
/// Bluetooth is.
@override
Stream<FoundStrap> scanStraps() => _straps
.receiveBroadcastStream()
.handleError(
(Object error) => throw BluetoothOff(),
test: (error) => error is PlatformException && error.code == 'off',
)
.map(
(event) => switch (event) {
{'address': final String address, 'rssi': final int rssi} =>
FoundStrap(
address: address,
name: switch (event['name']) {
final String name when name.isNotEmpty => name,
_ => null,
},
rssi: rssi,
),
_ => throw FormatException('A strap not read: $event'),
},
);
} }
/// [notice] as the service reads it, JSON: times in milliseconds, since 1970 /// [notice] as the service reads it, JSON: times in milliseconds, since 1970

View file

@ -6,6 +6,10 @@ import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// Whether [activity] is recorded with GPS: all but rowing indoors. /// Whether [activity] is recorded with GPS: all but rowing indoors.
bool usesGps(Activity activity) => activity != Activity.indoorRowing; bool usesGps(Activity activity) => activity != Activity.indoorRowing;
/// How old a heart rate may be and still show: a strap sends about once a
/// second.
const freshHeartRate = Duration(seconds: 5);
/// What the recording screen shows of [recording] at [now] (ADR 0030, D21), /// What the recording screen shows of [recording] at [now] (ADR 0030, D21),
/// worked out as a saved workout's totals are (D16, D20). /// worked out as a saved workout's totals are (D16, D20).
LiveRecording liveOf(Recording recording, DateTime now) { LiveRecording liveOf(Recording recording, DateTime now) {
@ -21,6 +25,8 @@ LiveRecording liveOf(Recording recording, DateTime now) {
_ => (null, null), _ => (null, null),
}; };
final lastFix = recording.fixes.lastOrNull; final lastFix = recording.fixes.lastOrNull;
final strap = recording.straps.lastOrNull?.state;
final rate = recording.heartRates.lastOrNull;
return LiveRecording( return LiveRecording(
activity: recording.activity, activity: recording.activity,
movingTime: movingTimeOf(recording, now), movingTime: movingTimeOf(recording, now),
@ -35,5 +41,12 @@ LiveRecording liveOf(Recording recording, DateTime now) {
gps && !recording.fixes.any((fix) => fix.accuracyCm <= maxAccuracyCm), gps && !recording.fixes.any((fix) => fix.accuracyCm <= maxAccuracyCm),
paused: paused, paused: paused,
autoPaused: paused && last!.auto, autoPaused: paused && last!.auto,
strap: strap,
bpm:
strap == StrapState.connected &&
rate != null &&
now.difference(rate.time) <= freshHeartRate
? rate.bpm
: null,
); );
} }

View file

@ -11,8 +11,26 @@ enum RecorderPermission() {
/// Physical activity, for the step detector's auto-pause on foot (D16). /// Physical activity, for the step detector's auto-pause on foot (D16).
activity, activity,
/// Nearby devices, for finding and connecting to a strap (D27; Android 12
/// on, location before).
bluetooth,
} }
/// A heart-rate strap seen nearby (D27): its address, its name where it
/// sends one, and how strong its signal is, in dBm.
final class const FoundStrap({
required final String address,
final String? name,
required final int rssi,
}) with Equatable {
@override
List<Object?> get props => [address, name, rssi];
}
/// Bluetooth is off, so nothing can be found.
final class BluetoothOff() implements Exception;
/// How a recording pauses by itself (D16), decided by the recorder. /// How a recording pauses by itself (D16), decided by the recorder.
enum AutoPauseBy() { enum AutoPauseBy() {
/// On foot: by steps, or by GPS speed where steps aren't allowed (D19). /// On foot: by steps, or by GPS speed where steps aren't allowed (D19).
@ -60,12 +78,14 @@ abstract interface class RecorderPlatform() {
Future<Set<RecorderPermission>> ask(Set<RecorderPermission> wanted); Future<Set<RecorderPermission>> ask(Set<RecorderPermission> wanted);
/// Starts recording [activity] into the file at [path], pausing by itself /// Starts recording [activity] into the file at [path], pausing by itself
/// by [autoPause] (null: never). /// by [autoPause] (null: never), with the heart-rate strap at [strap]
/// (null: none).
Future<void> start({ Future<void> start({
required String path, required String path,
required Activity activity, required Activity activity,
required AutoPauseBy? autoPause, required AutoPauseBy? autoPause,
required RecorderNotice notice, required RecorderNotice notice,
String? strap,
}); });
/// Pauses, or goes on, by hand. /// Pauses, or goes on, by hand.
@ -84,4 +104,8 @@ abstract interface class RecorderPlatform() {
/// Every line of the file written so far, then each new one as it is /// Every line of the file written so far, then each new one as it is
/// written, while a recording runs. /// written, while a recording runs.
Stream<String> lines(); Stream<String> lines();
/// The heart-rate straps nearby, each as it is seen, until the stream is
/// cancelled; [BluetoothOff] where it is off.
Stream<FoundStrap> scanStraps();
} }

Some files were not shown because too many files have changed in this diff Show more