feat(train): recording a workout on Android (ADR 0030, D13, D16, D19, D21)

RecorderService, a foreground service of the types location and health
started from the open app, takes fixes from LocationManager's GPS and
steps from the step detector, pauses by itself (AutoPause, tested on the
JVM) and appends each sample to the person's recording file, flushed
every five seconds. MainActivity asks for location, notifications and,
for auto-pause on foot, physical activity, never background location
(D19), and bridges the service to the app.

The app follows the file's lines with RecordingBloc and shows the moving
time, the distance, the pace or speed and GPS's state; the notification
shows the time and the distance with Pause or Resume. Stop is held down;
a stopped recording is saved as a workout and its series, or discarded
after asking, and one cut off or left unsaved is offered as the app
opens. Home has the Record button where the device records.

Three choices of the implementation's are recorded under D21 for review:
rowing waits for 5f, auto-pause on foot without steps goes by GPS speed
(below 2 km/h, on above 3 km/h), and Stop's hint shows above the button.

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 13:24:56 +01:00
commit 7df1cb1740
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
30 changed files with 3360 additions and 27 deletions

View file

@ -2,8 +2,9 @@
What holds for every app and package of this repository is in the [root AGENTS.md](../../AGENTS.md); it applies here in full, and Learn's patterns (`apps/learn/AGENTS.md`) wherever Train needs the same thing. What holds for every app and package of this repository is in the [root AGENTS.md](../../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), though no screen records or shows them yet (5b, 5c). 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), though no screen lists or shows them yet (5c). 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 with an empty home 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 with an empty home 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.
- **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

@ -47,3 +47,11 @@ kotlin {
flutter { flutter {
source = "../.." source = "../.."
} }
dependencies {
// NotificationCompat and ServiceCompat for the recording's foreground service,
// the version Learn's build uses.
implementation("androidx.core:core:1.18.0")
// The recorder's pure Kotlin parts (auto-pause) tested on the JVM.
testImplementation("junit:junit:4.13.2")
}

View file

@ -2,6 +2,20 @@
<!-- 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"/>
<!-- Recording a workout (ADR 0030, D13, D19): GPS while the app records in
the foreground, never in the background; the step detector for
auto-pause on foot; the recording's notification; the processor kept
awake with the screen off. -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_HEALTH"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-feature android:name="android.hardware.location.gps" android:required="false"/>
<uses-feature android:name="android.hardware.sensor.stepdetector" android:required="false"/>
<application <application
android:label="Halkyon Train" android:label="Halkyon Train"
android:name="${applicationName}" android:name="${applicationName}"
@ -40,6 +54,10 @@
<data android:scheme="app.halkyon.train" /> <data android:scheme="app.halkyon.train" />
</intent-filter> </intent-filter>
</activity> </activity>
<service
android:name=".RecorderService"
android:exported="false"
android:foregroundServiceType="location|health" />
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data <meta-data

View file

@ -0,0 +1,103 @@
package app.halkyon.train
// Auto-pause by each activity's best signal (ADR 0030, D16), decided in the
// service, which runs while the app may not: steps on foot, confirmed by GPS;
// GPS speed on a bike; GPS speed on foot where steps aren't allowed (D19).
// Times in milliseconds; each call returns true where it paused, false where
// it went on, null where nothing changed.
/** Decides when a recording pauses by itself, and when it goes on. */
class AutoPause(private val signal: Signal) {
enum class Signal {
/** On foot, by the step detector. */
STEPS,
/** On foot, by GPS speed: paused below 2 km/h, going on above 3 km/h. */
FOOT_SPEED,
/** By bike, by GPS speed: paused below 3 km/h, going on above 5 km/h. */
BIKE_SPEED,
}
/** Whether it paused by itself. */
var paused = false
private set
private var byHand = false
private var lastStep: Long? = null
private var slowSince: Long? = null
private var lastFixAt: Long? = null
private var lastFixFast = false
/** A step at [at]: two close together go on again (a lone one is a jostle). */
fun step(at: Long): Boolean? {
if (signal != Signal.STEPS || byHand) return null
val before = lastStep
lastStep = at
if (paused && before != null && at - before <= STEPS_TO_GO_ON_MS) {
paused = false
return false
}
return null
}
/** A fix at [at], moving at [metresPerSecond]. */
fun speed(at: Long, metresPerSecond: Double): Boolean? {
val kmh = metresPerSecond * 3.6
lastFixAt = at
if (signal == Signal.STEPS) {
// Faster than walking: moving, whatever the steps say (a phone in a
// bag counts fewer).
lastFixFast = kmh > 5
return null
}
if (byHand) return null
val (below, above) = if (signal == Signal.BIKE_SPEED) 3.0 to 5.0 else 2.0 to 3.0
if (paused) {
if (kmh > above) {
paused = false
slowSince = null
return false
}
return null
}
slowSince = if (kmh < below) slowSince ?: at else null
return null
}
/** Time passing, at [at]: where it pauses for having stood still long enough. */
fun tick(at: Long): Boolean? {
if (byHand || paused) return null
val still =
when (signal) {
Signal.STEPS -> {
// Starting counts as a step.
val last = lastStep ?: at.also { lastStep = it }
val fixFresh = lastFixAt?.let { at - it <= FRESH_FIX_MS } ?: false
at - last >= STILL_MS && !(lastFixFast && fixFresh)
}
else -> slowSince?.let { at - it >= STILL_MS } ?: false
}
if (still) {
paused = true
return true
}
return null
}
/** Paused, or gone on, by hand at [at]: left alone while paused, starting afresh after. */
fun byHand(paused: Boolean, at: Long = 0) {
byHand = paused
if (!paused) {
this.paused = false
lastStep = at
slowSince = null
}
}
private companion object {
const val STILL_MS = 5_000L
const val STEPS_TO_GO_ON_MS = 2_000L
const val FRESH_FIX_MS = 3_000L
}
}

View file

@ -1,12 +1,120 @@
package app.halkyon.train package app.halkyon.train
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() { class MainActivity : FlutterActivity() {
/** The app's question for permissions, answered once Android has asked. */
private var asking: Pair<List<String>, MethodChannel.Result>? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
val messenger = flutterEngine.dartExecutor.binaryMessenger
// Secrets halkyon_app shows kept out of screenshots and recents. // Secrets halkyon_app shows kept out of screenshots and recents.
ScreenGuard.attach(this, flutterEngine.dartExecutor.binaryMessenger) ScreenGuard.attach(this, messenger)
// The recording's side of the system (RecorderService).
MethodChannel(messenger, "app.halkyon.train/recorder").setMethodCallHandler { call, result ->
when (call.method) {
"ask" -> ask(call.arguments<List<String>>() ?: emptyList(), result)
"start" -> {
RecorderService.start(
this,
path = call.argument<String>("path")!!,
activity = call.argument<String>("activity")!!,
autoPause = call.argument<String>("autoPause"),
notice = call.argument<String>("notice")!!,
)
result.success(null)
}
"pause" -> result.success(RecorderService.pause().let { null })
"resume" -> result.success(RecorderService.resume().let { null })
"stop" -> result.success(RecorderService.stop().let { null })
"show" -> result.success(RecorderService.show(call.arguments as String).let { null })
"isRecording" -> result.success(RecorderService.isRecording())
else -> result.notImplemented()
}
}
EventChannel(messenger, "app.halkyon.train/recorder/lines").setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
// The service writes on this thread too, so no line comes between
// the file read back and the ones after.
RecorderService.replay(events::success)
RecorderService.lines = { line -> runOnUiThread { events.success(line) } }
}
override fun onCancel(arguments: Any?) {
RecorderService.lines = null
}
},
)
}
override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
RecorderService.lines = null
super.cleanUpFlutterEngine(flutterEngine)
}
/**
* Asks for the permissions [wanted] names (location, notifications,
* activity) that aren't granted yet (ADR 0030, D19), and answers which are
* granted afterwards.
*/
private fun ask(wanted: List<String>, result: MethodChannel.Result) {
val permissions = wanted.flatMap(::permissionsOf)
val missing = permissions.filter { checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED }
if (missing.isEmpty() || asking != null) {
result.success(grantedOf(wanted))
return
}
asking = wanted to result
requestPermissions(missing.toTypedArray(), RECORDER_REQUEST)
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray,
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode != RECORDER_REQUEST) return
asking?.let { (wanted, result) ->
asking = null
result.success(grantedOf(wanted))
}
}
private fun grantedOf(wanted: List<String>): Map<String, Boolean> =
wanted.associateWith { name ->
permissionsOf(name).all { checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }
}
/** Android's permissions behind each of the app's names; none where the system grants it by itself. */
private fun permissionsOf(name: String): List<String> =
when (name) {
// Android 12 on asks for coarse with fine, and lets the person pick.
"location" -> listOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)
"notifications" ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
listOf(Manifest.permission.POST_NOTIFICATIONS)
} else {
emptyList()
}
"activity" ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
listOf(Manifest.permission.ACTIVITY_RECOGNITION)
} else {
emptyList()
}
else -> emptyList()
}
private companion object {
const val RECORDER_REQUEST = 1
} }
} }

View file

@ -0,0 +1,333 @@
package app.halkyon.train
import android.Manifest
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.PowerManager
import android.os.SystemClock
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import java.io.File
import java.io.Writer
import kotlin.math.roundToLong
import org.json.JSONObject
// 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
// Android's LocationManager (no Play services) and steps from the step
// detector, pauses by itself, and appends each sample as a line to the
// 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
// well while it listens; the app works out the totals and tells the service
// what its notification shows (a notice, as JSON, in the app's language).
/** Records a workout into its file, in the foreground. */
class RecorderService : Service(), LocationListener, SensorEventListener {
companion object {
private const val ACTION_START = "app.halkyon.train.recorder.START"
private const val ACTION_PAUSE = "app.halkyon.train.recorder.PAUSE"
private const val ACTION_RESUME = "app.halkyon.train.recorder.RESUME"
private const val EXTRA_PATH = "path"
private const val EXTRA_ACTIVITY = "activity"
private const val EXTRA_AUTO_PAUSE = "autoPause"
private const val EXTRA_NOTICE = "notice"
private const val CHANNEL = "recording"
private const val NOTIFICATION_ID = 5301
private const val FLUSH_EVERY_MS = 5_000L
/** Hands each line written to the app; null while no app listens. */
@Volatile var lines: ((String) -> Unit)? = null
/** The service while it records. */
@Volatile private var running: RecorderService? = null
/** Whether a recording runs. */
fun isRecording(): Boolean = running != null
/**
* Starts recording [activity] into the file at [path], pausing by itself
* by [autoPause] (null: never), its notification as [notice] says.
*/
fun start(context: Context, path: String, activity: String, autoPause: String?, notice: String) {
ContextCompat.startForegroundService(
context,
Intent(context, RecorderService::class.java)
.setAction(ACTION_START)
.putExtra(EXTRA_PATH, path)
.putExtra(EXTRA_ACTIVITY, activity)
.putExtra(EXTRA_AUTO_PAUSE, autoPause)
.putExtra(EXTRA_NOTICE, notice),
)
}
/** Pauses, or goes on, by hand. */
fun pause() = running?.byHand(paused = true)
fun resume() = running?.byHand(paused = false)
/** Ends the recording: its last line written, the file closed, the service gone. */
fun stop() = running?.finish()
/** Shows [notice] (JSON) in the recording's notification. */
fun show(notice: String) = running?.present(JSONObject(notice))
/** Every line of the file so far, then each new one as it is written. */
fun replay(to: (String) -> Unit) {
running?.let { service ->
service.writer?.flush()
File(service.path).forEachLine(action = to)
}
}
}
private lateinit var path: String
private var writer: Writer? = null
private var autoPause: AutoPause? = null
private var pausedByHand = false
private var notice: JSONObject? = null
private var wakeLock: PowerManager.WakeLock? = null
private val handler = Handler(Looper.getMainLooper())
private var lastFlush = 0L
private val ticker =
object : Runnable {
override fun run() {
val now = System.currentTimeMillis()
autoPause?.tick(now)?.let { paused -> mark(paused, auto = true, at = now) }
if (now - lastFlush >= FLUSH_EVERY_MS) {
writer?.flush()
lastFlush = now
}
handler.postDelayed(this, 1_000)
}
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START -> if (running == null) begin(intent)
ACTION_PAUSE -> byHand(paused = true)
ACTION_RESUME -> byHand(paused = false)
}
return START_NOT_STICKY
}
override fun onDestroy() {
if (running === this) finish()
super.onDestroy()
}
@SuppressLint("MissingPermission") // Each listener is asked for only where its permission is granted.
private fun begin(intent: Intent) {
path = intent.getStringExtra(EXTRA_PATH) ?: return stopSelf()
val activity = intent.getStringExtra(EXTRA_ACTIVITY) ?: return stopSelf()
val located = granted(Manifest.permission.ACCESS_FINE_LOCATION)
val counted =
Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || granted(Manifest.permission.ACTIVITY_RECOGNITION)
notice = intent.getStringExtra(EXTRA_NOTICE)?.let(::JSONObject)
channel(notice?.optString("channelName"))
// In the foreground first: Android allows a few seconds for it.
ServiceCompat.startForeground(this, NOTIFICATION_ID, notification(), types(located, counted))
running = this
val now = System.currentTimeMillis()
writer = File(path).apply { parentFile?.mkdirs() }.bufferedWriter().also {
it.append("halkyon-train-recording 1 $activity $now\n")
}
autoPause =
when (intent.getStringExtra(EXTRA_AUTO_PAUSE)) {
// Steps where they may be counted, GPS speed where not (D19).
"steps" -> AutoPause(if (counted) AutoPause.Signal.STEPS else AutoPause.Signal.FOOT_SPEED)
"bike" -> AutoPause(AutoPause.Signal.BIKE_SPEED)
else -> null
}
if (located) {
getSystemService(LocationManager::class.java)
.requestLocationUpdates(LocationManager.GPS_PROVIDER, 1_000L, 0f, this, Looper.getMainLooper())
}
if (counted && autoPause != null) {
getSystemService(SensorManager::class.java).let { sensors ->
sensors.getDefaultSensor(Sensor.TYPE_STEP_DETECTOR)?.let {
sensors.registerListener(this, it, SensorManager.SENSOR_DELAY_NORMAL)
}
}
}
// The ticker and the file need the processor with the screen off, as a
// rower without GPS gives nothing else to wake it.
wakeLock =
getSystemService(PowerManager::class.java)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "HalkyonTrain:recording")
.apply { acquire(12 * 60 * 60 * 1_000L) }
lastFlush = now
handler.postDelayed(ticker, 1_000)
}
/** The foreground service's types: location with GPS, health with the step detector (Android 14 on asks). */
private fun types(located: Boolean, counted: Boolean): Int {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return 0
var types = 0
if (located) types = types or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
if (counted && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
types = types or ServiceInfo.FOREGROUND_SERVICE_TYPE_HEALTH
}
return types
}
override fun onLocationChanged(location: Location) {
val at = wallTime(location.elapsedRealtimeNanos)
val altitude = if (location.hasAltitude()) (location.altitude * 100).roundToLong().toString() else "-"
val accuracy = if (location.hasAccuracy()) (location.accuracy * 100).roundToLong() else 99_999L
write(
"p $at ${(location.latitude * 1e7).roundToLong()} ${(location.longitude * 1e7).roundToLong()} " +
"$accuracy $altitude",
)
if (location.hasSpeed() && !pausedByHand) {
autoPause?.speed(at, location.speed.toDouble())?.let { paused -> mark(paused, auto = true, at = at) }
}
}
override fun onSensorChanged(event: SensorEvent) {
if (event.sensor.type != Sensor.TYPE_STEP_DETECTOR) return
val at = wallTime(event.timestamp)
write("s $at")
autoPause?.step(at)?.let { paused -> mark(paused, auto = true, at = at) }
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
/** Paused, or gone on, by hand. */
private fun byHand(paused: Boolean) {
if (running !== this) return
val pausedNow = pausedByHand || autoPause?.paused == true
if (paused == pausedNow) return
val now = System.currentTimeMillis()
pausedByHand = paused
autoPause?.byHand(paused, now)
mark(paused, auto = false, at = now)
}
/** Writes a pause or its end, and shows it at once, the app there or not. */
private fun mark(paused: Boolean, auto: Boolean, at: Long) {
write("${if (paused) "pause" else "resume"} $at ${if (auto) "auto" else "manual"}")
writer?.flush()
repost()
}
private fun write(line: String) {
val writer = writer ?: return
writer.append(line).append('\n')
lines?.invoke(line)
}
/** Ends the recording, once. */
private fun finish() {
if (running !== this) return
running = null
handler.removeCallbacks(ticker)
getSystemService(LocationManager::class.java).removeUpdates(this)
getSystemService(SensorManager::class.java).unregisterListener(this)
write("end ${System.currentTimeMillis()}")
writer?.close()
writer = null
wakeLock?.takeIf { it.isHeld }?.release()
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf()
}
private fun present(json: JSONObject) {
notice = json
repost()
}
private fun repost() {
NotificationManagerCompat.from(this).let {
if (it.areNotificationsEnabled()) {
try {
it.notify(NOTIFICATION_ID, notification())
} catch (_: SecurityException) {
// Notifications not allowed: the recording runs on, unseen (D19).
}
}
}
}
private fun notification(): android.app.Notification {
val notice = notice
val paused = pausedByHand || autoPause?.paused == true
val builder =
NotificationCompat.Builder(this, CHANNEL)
.setSmallIcon(R.drawable.ic_stat_recording)
.setContentTitle(notice?.optString("title") ?: "Halkyon Train")
.setOngoing(true)
.setSilent(true)
.setOnlyAlertOnce(true)
.setCategory(NotificationCompat.CATEGORY_WORKOUT)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.setContentIntent(openApp())
notice?.optString("text")?.let(builder::setContentText)
// The moving time counts on by itself while it moves, the app there or not.
if (!paused && notice != null && notice.has("movingMs")) {
builder
.setUsesChronometer(true)
.setWhen(notice.getLong("shownAt") - notice.getLong("movingMs"))
.setShowWhen(true)
} else {
builder.setShowWhen(false)
}
if (paused) {
builder.addAction(0, notice?.optString("resume") ?: "Resume", button(ACTION_RESUME))
} else {
builder.addAction(0, notice?.optString("pause") ?: "Pause", button(ACTION_PAUSE))
}
return builder.build()
}
private fun button(action: String): PendingIntent =
PendingIntent.getService(
this,
action.hashCode(),
Intent(this, RecorderService::class.java).setAction(action),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
private fun openApp(): PendingIntent? =
packageManager.getLaunchIntentForPackage(packageName)?.let {
PendingIntent.getActivity(this, 0, it, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
}
/** The recording's channel, silent, named in the app's language. */
private fun channel(name: String?) {
getSystemService(NotificationManager::class.java)
.createNotificationChannel(
NotificationChannel(CHANNEL, name ?: "Recording", NotificationManager.IMPORTANCE_LOW)
.apply { setShowBadge(false) },
)
}
private fun granted(permission: String): Boolean =
ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
/** The wall-clock time, in UTC milliseconds, of an instant on the elapsed-realtime clock. */
private fun wallTime(elapsedNanos: Long): Long =
System.currentTimeMillis() - (SystemClock.elapsedRealtimeNanos() - elapsedNanos) / 1_000_000
}

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The recording's status bar icon: Lucide's bike, Train's own (ADR 0031),
white on nothing, as Android tints it. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#00000000"
android:pathData="M18.5,14A3.5,3.5 0,1 1,18.5 21A3.5,3.5 0,1 1,18.5 14ZM5.5,14A3.5,3.5 0,1 1,5.5 21A3.5,3.5 0,1 1,5.5 14ZM15,4A1,1 0,1 1,15 6A1,1 0,1 1,15 4ZM12,17.5V14l-3,-3 4,-3 2,3h2"
android:strokeWidth="2"
android:strokeColor="#FFFFFFFF"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
</vector>

View file

@ -0,0 +1,87 @@
package app.halkyon.train
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
// Auto-pause by each activity's best signal (ADR 0030, D16): steps on foot,
// GPS speed on a bike, and GPS speed on foot where steps aren't allowed (D19).
class AutoPauseTest {
/** Feeds [steps] (seconds) to a pause on foot, ticking each second up to [until], and returns its changes by second. */
private fun onFoot(steps: List<Int>, until: Int, fixes: Map<Int, Double> = emptyMap()): Map<Int, Boolean> {
val pause = AutoPause(AutoPause.Signal.STEPS)
val changes = mutableMapOf<Int, Boolean>()
for (second in 0..until) {
val at = second * 1000L
fixes[second]?.let { kmh -> pause.speed(at, kmh / 3.6)?.let { changes[second] = it } }
if (second in steps) pause.step(at)?.let { changes[second] = it }
pause.tick(at)?.let { changes[second] = it }
}
return changes
}
private fun byBike(kmh: List<Double>, signal: AutoPause.Signal = AutoPause.Signal.BIKE_SPEED): Map<Int, Boolean> {
val pause = AutoPause(signal)
val changes = mutableMapOf<Int, Boolean>()
kmh.forEachIndexed { second, speed ->
pause.speed(second * 1000L, speed / 3.6)?.let { changes[second] = it }
pause.tick(second * 1000L)?.let { changes[second] = it }
}
return changes
}
@Test
fun `on foot it pauses five seconds after the last step and goes on with two steps close together`() {
val changes = onFoot(steps = listOf(0, 1, 2, 3, 12, 20, 21, 22), until = 25)
// Paused at 8 (no step since 3); a lone step at 12 is a jostle, not a start.
assertEquals(mapOf(8 to true, 21 to false), changes)
}
@Test
fun `on foot GPS moving faster than walking keeps it going without steps`() {
val changes = onFoot(
steps = listOf(0, 1),
until = 12,
fixes = (0..12).associateWith { 12.0 },
)
assertEquals(emptyMap<Int, Boolean>(), changes)
}
@Test
fun `by bike it pauses below 3 kmh for five seconds and goes on above 5 kmh`() {
val changes = byBike(listOf(20.0, 18.0, 2.0, 1.0, 0.0, 0.0, 0.0, 0.0, 4.0, 4.5, 6.0, 15.0))
assertEquals(mapOf(7 to true, 10 to false), changes)
}
@Test
fun `by bike slowing down a moment pauses nothing`() {
val changes = byBike(listOf(20.0, 2.0, 2.0, 2.0, 8.0, 2.0, 2.0, 2.0, 2.0, 9.0))
assertEquals(emptyMap<Int, Boolean>(), changes)
}
@Test
fun `on foot without steps it pauses below 2 kmh and goes on above 3 kmh`() {
val changes = byBike(listOf(6.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 2.5, 3.5), AutoPause.Signal.FOOT_SPEED)
assertEquals(mapOf(6 to true, 8 to false), changes)
}
@Test
fun `a pause by hand is left alone, and going on by hand starts afresh`() {
val pause = AutoPause(AutoPause.Signal.STEPS)
pause.step(0)
pause.byHand(paused = true)
assertNull(pause.tick(10_000))
assertNull(pause.step(11_000))
assertNull(pause.step(11_500))
pause.byHand(paused = false, at = 12_000)
assertNull(pause.tick(16_000))
assertEquals(true, pause.tick(17_000))
}
}

View file

@ -12,5 +12,45 @@
"accountTitle": "Konto", "accountTitle": "Konto",
"accountDeleteMessage": "Dein Konto wird auf dem Server und auf all deinen Geräten gelöscht. Dies kann nicht rückgängig gemacht werden.", "accountDeleteMessage": "Dein Konto wird auf dem Server und auf all deinen Geräten gelöscht. Dies kann nicht rückgängig gemacht werden.",
"aboutTitle": "Über" "aboutTitle": "Über",
"homeRecordHint": "Zeichne mit dem Knopf unten ein Training auf. Die Liste deiner Trainings kommt mit der nächsten Version von Halkyon Train.",
"recordAction": "Aufzeichnen",
"recordBack": "Zurück zur Aufzeichnung",
"recordSheetTitle": "Training aufzeichnen",
"activityRunning": "Laufen",
"activityWalking": "Gehen",
"activityHiking": "Wandern",
"activityCycling": "Radfahren",
"activityIndoorRowing": "Rudern drinnen",
"recordAutoPause": "Von selbst pausieren",
"recordAutoPauseHint": "Wenn du stehst, und weiter, sobald du dich wieder bewegst",
"recordStart": "Start",
"recordLocationRefused": "Train braucht deinen Standort, um das aufzuzeichnen. Du kannst ihn in den Einstellungen des Systems erlauben.",
"recordingWaitingForGps": "Warte auf GPS",
"recordingWaitingForGpsAccuracy": "Warte auf GPS · ±{metres} m",
"recordingGpsAccuracy": "GPS ±{metres} m",
"recordingMovingTime": "Zeit in Bewegung",
"recordingDistance": "Strecke",
"recordingPace": "Tempo",
"recordingSpeed": "Geschwindigkeit",
"recordingDistanceValue": "{km} km",
"recordingPaceValue": "{pace} /km",
"recordingSpeedValue": "{kmh} km/h",
"recordingPausedAuto": "Pausiert: keine Bewegung",
"recordingPausedByHand": "Pausiert",
"recordingPause": "Pause",
"recordingResume": "Weiter",
"recordingStop": "Stopp",
"recordingHoldToStop": "Halte Stopp gedrückt, um das Training zu beenden",
"recordingStoppedTitle": "Dieses Training speichern?",
"recordingSave": "Speichern",
"recordingDiscard": "Verwerfen",
"recordingDiscardTitle": "Dieses Training verwerfen?",
"recordingDiscardMessage": "Seine Zeit und Strecke werden von diesem Gerät gelöscht. Dies kann nicht rückgängig gemacht werden.",
"recordingSaveFailed": "Es kann auf diesem Gerät nicht gespeichert werden, da es die Schlüssel deines Kontos nicht hat. Es bleibt hier, bis es gespeichert werden kann.",
"recordingCutOffTitle": "Eine Aufzeichnung wurde abgebrochen",
"recordingCutOffMessage": "Eine Aufzeichnung von {start} wurde abgebrochen. Bis {end} speichern oder verwerfen?",
"recordingNotificationChannel": "Aufzeichnung",
"recordingNotificationPaused": "{activity} · pausiert"
} }

View file

@ -14,5 +14,56 @@
"accountTitle": "Account", "accountTitle": "Account",
"accountDeleteMessage": "Your account is deleted on the server and on all your devices. This can't be undone.", "accountDeleteMessage": "Your account is deleted on the server and on all your devices. This can't be undone.",
"aboutTitle": "About" "aboutTitle": "About",
"homeRecordHint": "Record a workout with the button below. The list of your workouts comes with the next version of Halkyon Train.",
"recordAction": "Record",
"recordBack": "Back to the recording",
"recordSheetTitle": "Record a workout",
"activityRunning": "Running",
"activityWalking": "Walking",
"activityHiking": "Hiking",
"activityCycling": "Cycling",
"activityIndoorRowing": "Rowing indoors",
"recordAutoPause": "Pause by itself",
"@recordAutoPause": {"description": "The switch for auto-pause, as the recording starts."},
"recordAutoPauseHint": "When you stand still, and going on as you move again",
"recordStart": "Start",
"recordLocationRefused": "Train needs your location to record this. You can allow it in the system's settings.",
"recordingWaitingForGps": "Waiting for GPS",
"recordingWaitingForGpsAccuracy": "Waiting for GPS · ±{metres} m",
"@recordingWaitingForGpsAccuracy": {"description": "Before a fix precise enough: how far off the last one may be.", "placeholders": {"metres": {"type": "int"}}},
"recordingGpsAccuracy": "GPS ±{metres} m",
"@recordingGpsAccuracy": {"description": "How far off the last fix may be.", "placeholders": {"metres": {"type": "int"}}},
"recordingMovingTime": "moving time",
"@recordingMovingTime": {"description": "Under the time, lower case: the time moved, pauses left out."},
"recordingDistance": "distance",
"recordingPace": "pace",
"recordingSpeed": "speed",
"recordingDistanceValue": "{km} km",
"@recordingDistanceValue": {"placeholders": {"km": {"type": "String", "example": "5.12"}}},
"recordingPaceValue": "{pace} /km",
"@recordingPaceValue": {"description": "Minutes and seconds a kilometre takes.", "placeholders": {"pace": {"type": "String", "example": "6:18"}}},
"recordingSpeedValue": "{kmh} km/h",
"@recordingSpeedValue": {"placeholders": {"kmh": {"type": "String", "example": "24.3"}}},
"recordingPausedAuto": "Paused: not moving",
"@recordingPausedAuto": {"description": "Train paused the recording by itself (auto-pause)."},
"recordingPausedByHand": "Paused",
"recordingPause": "Pause",
"recordingResume": "Resume",
"recordingStop": "Stop",
"recordingHoldToStop": "Hold Stop to end the workout",
"recordingStoppedTitle": "Save this workout?",
"recordingSave": "Save",
"recordingDiscard": "Discard",
"recordingDiscardTitle": "Discard this workout?",
"recordingDiscardMessage": "Its time and track are deleted from this device. This can't be undone.",
"recordingSaveFailed": "It can't be saved on this device, which doesn't hold your account's keys. It stays here until it can be.",
"recordingCutOffTitle": "A recording was cut off",
"recordingCutOffMessage": "A recording from {start} was cut off. Save it up to {end}, or discard it?",
"@recordingCutOffMessage": {"description": "A recording found unfinished as the app opened: when it started and its last sample.", "placeholders": {"start": {"type": "String", "example": "07:30"}, "end": {"type": "String", "example": "08:02"}}},
"recordingNotificationChannel": "Recording",
"@recordingNotificationChannel": {"description": "The name of the recording's notification channel, in the system's settings."},
"recordingNotificationPaused": "{activity} · paused",
"@recordingNotificationPaused": {"description": "The notification's title while paused.", "placeholders": {"activity": {"type": "String", "example": "Running"}}}
} }

View file

@ -147,6 +147,240 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'About'** /// **'About'**
String get aboutTitle; String get aboutTitle;
/// No description provided for @homeRecordHint.
///
/// In en, this message translates to:
/// **'Record a workout with the button below. The list of your workouts comes with the next version of Halkyon Train.'**
String get homeRecordHint;
/// No description provided for @recordAction.
///
/// In en, this message translates to:
/// **'Record'**
String get recordAction;
/// No description provided for @recordBack.
///
/// In en, this message translates to:
/// **'Back to the recording'**
String get recordBack;
/// No description provided for @recordSheetTitle.
///
/// In en, this message translates to:
/// **'Record a workout'**
String get recordSheetTitle;
/// No description provided for @activityRunning.
///
/// In en, this message translates to:
/// **'Running'**
String get activityRunning;
/// No description provided for @activityWalking.
///
/// In en, this message translates to:
/// **'Walking'**
String get activityWalking;
/// No description provided for @activityHiking.
///
/// In en, this message translates to:
/// **'Hiking'**
String get activityHiking;
/// No description provided for @activityCycling.
///
/// In en, this message translates to:
/// **'Cycling'**
String get activityCycling;
/// No description provided for @activityIndoorRowing.
///
/// In en, this message translates to:
/// **'Rowing indoors'**
String get activityIndoorRowing;
/// The switch for auto-pause, as the recording starts.
///
/// In en, this message translates to:
/// **'Pause by itself'**
String get recordAutoPause;
/// No description provided for @recordAutoPauseHint.
///
/// In en, this message translates to:
/// **'When you stand still, and going on as you move again'**
String get recordAutoPauseHint;
/// No description provided for @recordStart.
///
/// In en, this message translates to:
/// **'Start'**
String get recordStart;
/// No description provided for @recordLocationRefused.
///
/// In en, this message translates to:
/// **'Train needs your location to record this. You can allow it in the system\'s settings.'**
String get recordLocationRefused;
/// No description provided for @recordingWaitingForGps.
///
/// In en, this message translates to:
/// **'Waiting for GPS'**
String get recordingWaitingForGps;
/// Before a fix precise enough: how far off the last one may be.
///
/// In en, this message translates to:
/// **'Waiting for GPS · ±{metres} m'**
String recordingWaitingForGpsAccuracy(int metres);
/// How far off the last fix may be.
///
/// In en, this message translates to:
/// **'GPS ±{metres} m'**
String recordingGpsAccuracy(int metres);
/// Under the time, lower case: the time moved, pauses left out.
///
/// In en, this message translates to:
/// **'moving time'**
String get recordingMovingTime;
/// No description provided for @recordingDistance.
///
/// In en, this message translates to:
/// **'distance'**
String get recordingDistance;
/// No description provided for @recordingPace.
///
/// In en, this message translates to:
/// **'pace'**
String get recordingPace;
/// No description provided for @recordingSpeed.
///
/// In en, this message translates to:
/// **'speed'**
String get recordingSpeed;
/// No description provided for @recordingDistanceValue.
///
/// In en, this message translates to:
/// **'{km} km'**
String recordingDistanceValue(String km);
/// Minutes and seconds a kilometre takes.
///
/// In en, this message translates to:
/// **'{pace} /km'**
String recordingPaceValue(String pace);
/// No description provided for @recordingSpeedValue.
///
/// In en, this message translates to:
/// **'{kmh} km/h'**
String recordingSpeedValue(String kmh);
/// Train paused the recording by itself (auto-pause).
///
/// In en, this message translates to:
/// **'Paused: not moving'**
String get recordingPausedAuto;
/// No description provided for @recordingPausedByHand.
///
/// In en, this message translates to:
/// **'Paused'**
String get recordingPausedByHand;
/// No description provided for @recordingPause.
///
/// In en, this message translates to:
/// **'Pause'**
String get recordingPause;
/// No description provided for @recordingResume.
///
/// In en, this message translates to:
/// **'Resume'**
String get recordingResume;
/// No description provided for @recordingStop.
///
/// In en, this message translates to:
/// **'Stop'**
String get recordingStop;
/// No description provided for @recordingHoldToStop.
///
/// In en, this message translates to:
/// **'Hold Stop to end the workout'**
String get recordingHoldToStop;
/// No description provided for @recordingStoppedTitle.
///
/// In en, this message translates to:
/// **'Save this workout?'**
String get recordingStoppedTitle;
/// No description provided for @recordingSave.
///
/// In en, this message translates to:
/// **'Save'**
String get recordingSave;
/// No description provided for @recordingDiscard.
///
/// In en, this message translates to:
/// **'Discard'**
String get recordingDiscard;
/// No description provided for @recordingDiscardTitle.
///
/// In en, this message translates to:
/// **'Discard this workout?'**
String get recordingDiscardTitle;
/// No description provided for @recordingDiscardMessage.
///
/// In en, this message translates to:
/// **'Its time and track are deleted from this device. This can\'t be undone.'**
String get recordingDiscardMessage;
/// No description provided for @recordingSaveFailed.
///
/// In en, this message translates to:
/// **'It can\'t be saved on this device, which doesn\'t hold your account\'s keys. It stays here until it can be.'**
String get recordingSaveFailed;
/// No description provided for @recordingCutOffTitle.
///
/// In en, this message translates to:
/// **'A recording was cut off'**
String get recordingCutOffTitle;
/// A recording found unfinished as the app opened: when it started and its last sample.
///
/// In en, this message translates to:
/// **'A recording from {start} was cut off. Save it up to {end}, or discard it?'**
String recordingCutOffMessage(String start, String end);
/// The name of the recording's notification channel, in the system's settings.
///
/// In en, this message translates to:
/// **'Recording'**
String get recordingNotificationChannel;
/// The notification's title while paused.
///
/// In en, this message translates to:
/// **'{activity} · paused'**
String recordingNotificationPaused(String activity);
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -35,4 +35,141 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get aboutTitle => 'Über'; String get aboutTitle => 'Über';
@override
String get homeRecordHint =>
'Zeichne mit dem Knopf unten ein Training auf. Die Liste deiner Trainings kommt mit der nächsten Version von Halkyon Train.';
@override
String get recordAction => 'Aufzeichnen';
@override
String get recordBack => 'Zurück zur Aufzeichnung';
@override
String get recordSheetTitle => 'Training aufzeichnen';
@override
String get activityRunning => 'Laufen';
@override
String get activityWalking => 'Gehen';
@override
String get activityHiking => 'Wandern';
@override
String get activityCycling => 'Radfahren';
@override
String get activityIndoorRowing => 'Rudern drinnen';
@override
String get recordAutoPause => 'Von selbst pausieren';
@override
String get recordAutoPauseHint =>
'Wenn du stehst, und weiter, sobald du dich wieder bewegst';
@override
String get recordStart => 'Start';
@override
String get recordLocationRefused =>
'Train braucht deinen Standort, um das aufzuzeichnen. Du kannst ihn in den Einstellungen des Systems erlauben.';
@override
String get recordingWaitingForGps => 'Warte auf GPS';
@override
String recordingWaitingForGpsAccuracy(int metres) {
return 'Warte auf GPS · ±$metres m';
}
@override
String recordingGpsAccuracy(int metres) {
return 'GPS ±$metres m';
}
@override
String get recordingMovingTime => 'Zeit in Bewegung';
@override
String get recordingDistance => 'Strecke';
@override
String get recordingPace => 'Tempo';
@override
String get recordingSpeed => 'Geschwindigkeit';
@override
String recordingDistanceValue(String km) {
return '$km km';
}
@override
String recordingPaceValue(String pace) {
return '$pace /km';
}
@override
String recordingSpeedValue(String kmh) {
return '$kmh km/h';
}
@override
String get recordingPausedAuto => 'Pausiert: keine Bewegung';
@override
String get recordingPausedByHand => 'Pausiert';
@override
String get recordingPause => 'Pause';
@override
String get recordingResume => 'Weiter';
@override
String get recordingStop => 'Stopp';
@override
String get recordingHoldToStop =>
'Halte Stopp gedrückt, um das Training zu beenden';
@override
String get recordingStoppedTitle => 'Dieses Training speichern?';
@override
String get recordingSave => 'Speichern';
@override
String get recordingDiscard => 'Verwerfen';
@override
String get recordingDiscardTitle => 'Dieses Training verwerfen?';
@override
String get recordingDiscardMessage =>
'Seine Zeit und Strecke werden von diesem Gerät gelöscht. Dies kann nicht rückgängig gemacht werden.';
@override
String get recordingSaveFailed =>
'Es kann auf diesem Gerät nicht gespeichert werden, da es die Schlüssel deines Kontos nicht hat. Es bleibt hier, bis es gespeichert werden kann.';
@override
String get recordingCutOffTitle => 'Eine Aufzeichnung wurde abgebrochen';
@override
String recordingCutOffMessage(String start, String end) {
return 'Eine Aufzeichnung von $start wurde abgebrochen. Bis $end speichern oder verwerfen?';
}
@override
String get recordingNotificationChannel => 'Aufzeichnung';
@override
String recordingNotificationPaused(String activity) {
return '$activity · pausiert';
}
} }

View file

@ -35,6 +35,142 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get aboutTitle => 'About'; String get aboutTitle => 'About';
@override
String get homeRecordHint =>
'Record a workout with the button below. The list of your workouts comes with the next version of Halkyon Train.';
@override
String get recordAction => 'Record';
@override
String get recordBack => 'Back to the recording';
@override
String get recordSheetTitle => 'Record a workout';
@override
String get activityRunning => 'Running';
@override
String get activityWalking => 'Walking';
@override
String get activityHiking => 'Hiking';
@override
String get activityCycling => 'Cycling';
@override
String get activityIndoorRowing => 'Rowing indoors';
@override
String get recordAutoPause => 'Pause by itself';
@override
String get recordAutoPauseHint =>
'When you stand still, and going on as you move again';
@override
String get recordStart => 'Start';
@override
String get recordLocationRefused =>
'Train needs your location to record this. You can allow it in the system\'s settings.';
@override
String get recordingWaitingForGps => 'Waiting for GPS';
@override
String recordingWaitingForGpsAccuracy(int metres) {
return 'Waiting for GPS · ±$metres m';
}
@override
String recordingGpsAccuracy(int metres) {
return 'GPS ±$metres m';
}
@override
String get recordingMovingTime => 'moving time';
@override
String get recordingDistance => 'distance';
@override
String get recordingPace => 'pace';
@override
String get recordingSpeed => 'speed';
@override
String recordingDistanceValue(String km) {
return '$km km';
}
@override
String recordingPaceValue(String pace) {
return '$pace /km';
}
@override
String recordingSpeedValue(String kmh) {
return '$kmh km/h';
}
@override
String get recordingPausedAuto => 'Paused: not moving';
@override
String get recordingPausedByHand => 'Paused';
@override
String get recordingPause => 'Pause';
@override
String get recordingResume => 'Resume';
@override
String get recordingStop => 'Stop';
@override
String get recordingHoldToStop => 'Hold Stop to end the workout';
@override
String get recordingStoppedTitle => 'Save this workout?';
@override
String get recordingSave => 'Save';
@override
String get recordingDiscard => 'Discard';
@override
String get recordingDiscardTitle => 'Discard this workout?';
@override
String get recordingDiscardMessage =>
'Its time and track are deleted from this device. This can\'t be undone.';
@override
String get recordingSaveFailed =>
'It can\'t be saved on this device, which doesn\'t hold your account\'s keys. It stays here until it can be.';
@override
String get recordingCutOffTitle => 'A recording was cut off';
@override
String recordingCutOffMessage(String start, String end) {
return 'A recording from $start was cut off. Save it up to $end, or discard it?';
}
@override
String get recordingNotificationChannel => 'Recording';
@override
String recordingNotificationPaused(String activity) {
return '$activity · paused';
}
} }
/// 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

@ -4,6 +4,8 @@ import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart'; import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
import 'package:halkyon_train_flutter/src/app/train_frame.dart'; import 'package:halkyon_train_flutter/src/app/train_frame.dart';
import 'package:halkyon_train_flutter/src/app/train_languages.dart'; import 'package:halkyon_train_flutter/src/app/train_languages.dart';
import 'package:halkyon_train_flutter/src/feature/recording/bloc/recording_bloc.dart';
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_texts.dart';
import 'package:halkyon_train_flutter/src/home/home_screen.dart'; import 'package:halkyon_train_flutter/src/home/home_screen.dart';
import 'package:halkyon_ui/halkyon_ui.dart'; import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
@ -70,9 +72,36 @@ class const TrainPersonApp({
child: BlocProvider( child: BlocProvider(
create: (context) => create: (context) =>
OperatingCubit(AppScope.of(context).instanceOperators), OperatingCubit(AppScope.of(context).instanceOperators),
child: child, child: _Recording(child: child!),
), ),
), ),
home: const HomeScreen(), home: const HomeScreen(),
); );
} }
/// The person's recording (ADR 0030, D21) above [child], where the device
/// records: a recording still running, cut off or stopped is looked for as
/// it opens.
class const _Recording({required final Widget child}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final train = PersonScope.of<TrainPerson>(context);
final recording = train.recording;
if (recording == null) {
return child;
}
return BlocProvider(
create: (context) => RecordingBloc(
recording.platform,
train.workouts,
recording.file,
notice: (live) => noticeOf(
AppLocalizations.of(context),
Localizations.localeOf(context).toString(),
live,
),
)..add(const RecordingEventOpened()),
child: child,
);
}
}

View file

@ -8,6 +8,8 @@ import 'package:halkyon_train_client/halkyon_train_client.dart'
show Client, UuidValue; show Client, UuidValue;
import 'package:halkyon_train_flutter/src/core/privacy/sync_content_key_gateway.dart'; import 'package:halkyon_train_flutter/src/core/privacy/sync_content_key_gateway.dart';
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart'; import 'package:halkyon_train_flutter/src/core/privacy/train_keys.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/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';
@ -17,16 +19,25 @@ 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
/// the person's recording file in their folder.
typedef RecordingDevice = ({RecorderPlatform platform, File file});
/// 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
/// step with their other devices (ADR 0030, D7 and D14). halkyon_app's /// step with their other devices (ADR 0030, D7 and D14), and where the device
/// `PersonScope` opens and disposes them with the person. /// records. halkyon_app's `PersonScope` opens and disposes them with the
/// person.
class TrainPerson( class TrainPerson(
/// Whose they are. /// Whose they are.
final SignedIn person, { final SignedIn person, {
/// Their workouts, sealed and synced. /// Their workouts, sealed and synced.
required final WorkoutRepository workouts, required final WorkoutRepository workouts,
/// Where this device records; null where it doesn't (iOS until 5d, the
/// desktop, which shows and plans, D17).
final RecordingDevice? recording,
final Future<void> Function()? _onDispose, final Future<void> Function()? _onDispose,
}) implements Disposable { }) implements Disposable {
/// Closes what was opened for the person. /// Closes what was opened for the person.
@ -264,6 +275,12 @@ Future<TrainPerson> _openPersonData(
return TrainPerson( return TrainPerson(
person, person,
workouts: workouts, workouts: workouts,
recording: Platform.isAndroid
? (
platform: AndroidRecorderPlatform(),
file: File(p.join(folder.path, 'recording.txt')),
)
: null,
onDispose: () async { onDispose: () async {
try { try {
await workouts.dispose(); await workouts.dispose();

View file

@ -0,0 +1,372 @@
import 'dart:async';
import 'dart:developer';
import 'dart:io';
import 'package:clock/clock.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/live_recording.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/recording_file.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/workout/data/workout_repository.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// Recording a workout (ADR 0030, D21): asks the permissions as it starts
/// (D19), starts the platform's recorder into the person's recording file
/// (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
/// app was gone, the service not), one cut off, or one stopped and not saved.
///
/// The recorder decides the pauses (D16); this works out what the screen and
/// the notification show from the same lines the file holds.
class RecordingBloc(
final RecorderPlatform _platform,
final WorkoutRepository _workouts,
/// The person's recording file, in their folder.
final File _file, {
/// What the notification shows of [LiveRecording], in the app's language.
required final RecorderNotice Function(LiveRecording live) notice,
/// A second passing, while it records; every second by default.
Stream<void>? ticks,
}) extends Bloc<RecordingEvent, RecordingState> {
this : super(const RecordingState()) {
on<RecordingEventOpened>(_onOpened);
on<RecordingEventStartRequested>(_onStart);
on<RecordingEventPauseRequested>((event, emit) => _platform.pause());
on<RecordingEventResumeRequested>((event, emit) => _platform.resume());
on<RecordingEventStopRequested>(_onStop);
on<RecordingEventSaveRequested>(_onSave);
on<RecordingEventDiscardRequested>(_onDiscard);
on<RecordingEventLineWritten>((event, emit) {
_lines.writeln(event.line);
// A pause, its end or the end show at once; samples with the next
// second.
if (!_sample.hasMatch(event.line)) {
_refresh(emit);
}
});
on<RecordingEventTicked>((event, emit) => _refresh(emit));
}
final RecorderNotice Function(LiveRecording live) _notice = notice;
final Stream<void> _ticks =
ticks ?? Stream<void>.periodic(const Duration(seconds: 1));
static final _sample = RegExp('^[phs] ');
/// The lines the recorder wrote so far.
final _lines = StringBuffer();
StreamSubscription<String>? _following;
StreamSubscription<void>? _ticking;
/// What the notification shows now, so it is told only of changes.
(String, String, bool)? _shown;
Future<void> _onOpened(
RecordingEventOpened event,
Emitter<RecordingState> emit,
) async {
final lastActivity = await _lastActivity();
if (await _platform.isRecording()) {
_follow();
emit(
RecordingState(
phase: RecordingPhase.recording,
lastActivity: lastActivity,
),
);
return;
}
final kept = _kept();
emit(
RecordingState(
phase: switch (kept) {
null => RecordingPhase.idle,
Recording(finished: true) => RecordingPhase.stopped,
_ => RecordingPhase.cutOff,
},
lastActivity: lastActivity,
recording: kept,
),
);
}
Future<void> _onStart(
RecordingEventStartRequested event,
Emitter<RecordingState> emit,
) async {
// Asked anew: a refusal before says nothing of this one.
emit(state.copyWith(locationRefused: false));
final activity = event.activity;
final gps = usesGps(activity);
final autoPause = !event.autoPause
? null
: switch (activity) {
Activity.cycling => AutoPauseBy.bike,
Activity.running ||
Activity.walking ||
Activity.hiking => AutoPauseBy.steps,
Activity.indoorRowing => null,
};
final granted = await _platform.ask({
if (gps) RecorderPermission.location,
RecorderPermission.notifications,
if (autoPause == AutoPauseBy.steps) RecorderPermission.activity,
});
if (gps && !granted.contains(RecorderPermission.location)) {
emit(state.copyWith(locationRefused: true));
return;
}
final now = clock.now().toUtc();
final notice = _notice(
liveOf(Recording(activity: activity, startedAt: now), now),
);
await _platform.start(
path: _file.path,
activity: activity,
autoPause: autoPause,
notice: notice,
);
_shown = _keyOf(notice);
_follow();
emit(
RecordingState(
phase: RecordingPhase.recording,
lastActivity: activity,
live: liveOf(Recording(activity: activity, startedAt: now), now),
),
);
}
Future<void> _onStop(
RecordingEventStopRequested event,
Emitter<RecordingState> emit,
) async {
await _platform.stop();
_unfollow();
final recording = _kept();
emit(
RecordingState(
phase: recording == null ? RecordingPhase.idle : RecordingPhase.stopped,
lastActivity: state.lastActivity,
recording: recording,
),
);
}
Future<void> _onSave(
RecordingEventSaveRequested event,
Emitter<RecordingState> emit,
) async {
final recording = state.recording;
if (recording == null) {
return;
}
final (content, series) = recordedWorkout(recording);
try {
await _workouts.save(content, series);
} on Object catch (error) {
// The file stays, to be saved once it can be.
log('A recording not saved: ${error.runtimeType}', name: 'Recording');
emit(state.copyWith(saveFailed: true));
return;
}
await _forget();
emit(
RecordingState(
phase: RecordingPhase.idle,
lastActivity: recording.activity,
),
);
}
Future<void> _onDiscard(
RecordingEventDiscardRequested event,
Emitter<RecordingState> emit,
) async {
await _forget();
emit(
RecordingState(
phase: RecordingPhase.idle,
lastActivity: state.lastActivity,
),
);
}
/// Follows the recorder's lines, from the file's first.
void _follow() {
_lines.clear();
_following = _platform.lines().listen(
(line) => add(RecordingEventLineWritten(line)),
);
_ticking = _ticks.listen((_) => add(const RecordingEventTicked()));
}
/// Stops following the recorder; nothing waits for that to be done, as
/// nothing more is read from it.
void _unfollow() {
unawaited(_following?.cancel());
unawaited(_ticking?.cancel());
_following = null;
_ticking = null;
_shown = null;
}
/// Works out what shows from the lines so far, and tells the notification
/// where that changed.
void _refresh(Emitter<RecordingState> emit) {
if (state.phase != RecordingPhase.recording) {
return;
}
final recording = readRecording(_lines.toString());
if (recording == null) {
return;
}
final live = liveOf(recording, clock.now().toUtc());
emit(state.copyWith(live: live));
final notice = _notice(live);
final key = _keyOf(notice);
if (key != _shown) {
_shown = key;
unawaited(_platform.show(notice));
}
}
/// What of a notice is worth telling: the time counts on by itself.
static (String, String, bool) _keyOf(RecorderNotice notice) =>
(notice.title, notice.text, notice.moving == null);
/// The recording the file holds, if it holds one; a file it can't read is
/// thrown away.
Recording? _kept() {
if (!_file.existsSync()) {
return null;
}
final recording = readRecording(_file.readAsStringSync());
if (recording == null) {
_file.deleteSync();
}
return recording;
}
Future<void> _forget() async {
_unfollow();
if (_file.existsSync()) {
_file.deleteSync();
}
}
/// The activity of the workout last recorded, to be chosen again.
Future<Activity> _lastActivity() async {
final latest = [
for (final workout in await _workouts.all()) ?workout.content,
]..sort((a, b) => a.startedAt.compareTo(b.startedAt));
return latest.lastOrNull?.activity ?? Activity.running;
}
@override
Future<void> close() async {
_unfollow();
await super.close();
}
}
/// Where a recording stands.
enum RecordingPhase() {
/// Looking for one, as the screen opens.
loading,
/// None: one may start.
idle,
/// One runs.
recording,
/// Stopped by the person, to be saved or discarded.
stopped,
/// Found cut off as the app opened (D18), to be saved up to its last
/// sample or discarded.
cutOff,
}
/// What the recording screen shows.
final class const RecordingState({
final RecordingPhase phase = RecordingPhase.loading,
/// The activity to choose first: the one last recorded.
final Activity lastActivity = Activity.running,
/// What shows while it records.
final LiveRecording? live,
/// The recording stopped or cut off.
final Recording? recording,
/// Whether location was refused as a GPS activity was to start.
final bool locationRefused = false,
/// Whether saving failed (as on a device without the account's keys).
final bool saveFailed = false,
}) with Equatable {
RecordingState copyWith({
LiveRecording? live,
bool? locationRefused,
bool? saveFailed,
}) => RecordingState(
phase: phase,
lastActivity: lastActivity,
live: live ?? this.live,
recording: recording,
locationRefused: locationRefused ?? this.locationRefused,
saveFailed: saveFailed ?? this.saveFailed,
);
@override
List<Object?> get props => [
phase,
lastActivity,
live,
recording,
locationRefused,
saveFailed,
];
}
/// What happens to a recording.
sealed class const RecordingEvent();
/// The screen opened: it looks for a recording.
final class const RecordingEventOpened() extends RecordingEvent;
/// The person starts recording [activity], pausing by itself where
/// [autoPause] (D16).
final class const RecordingEventStartRequested(
final Activity activity, {
final bool autoPause = true,
}) extends RecordingEvent;
final class const RecordingEventPauseRequested() extends RecordingEvent;
final class const RecordingEventResumeRequested() extends RecordingEvent;
final class const RecordingEventStopRequested() extends RecordingEvent;
/// The recording stopped or cut off is saved as a workout.
final class const RecordingEventSaveRequested() extends RecordingEvent;
/// The recording stopped or cut off is thrown away.
final class const RecordingEventDiscardRequested() extends RecordingEvent;
/// The recorder wrote a line; the bloc's own, as it follows the recorder.
final class const RecordingEventLineWritten(final String line)
extends RecordingEvent;
/// A second passed; the bloc's own, as it records.
final class const RecordingEventTicked() extends RecordingEvent;

View file

@ -0,0 +1,70 @@
import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// Recording on Android, through the app's own `RecorderService` (Kotlin,
/// `android/app/src/main/kotlin/`, ADR 0030, D13): LocationManager's GPS and
/// the step detector in a foreground service, auto-pause decided there, the
/// file appended to there.
class AndroidRecorderPlatform() implements RecorderPlatform {
static const _channel = MethodChannel('app.halkyon.train/recorder');
static const _lines = EventChannel('app.halkyon.train/recorder/lines');
@override
Future<Set<RecorderPermission>> ask(Set<RecorderPermission> wanted) async {
final granted = await _channel.invokeMapMethod<String, bool>('ask', [
for (final permission in wanted) permission.name,
]);
return {
for (final permission in wanted)
if (granted?[permission.name] ?? false) permission,
};
}
@override
Future<void> start({
required String path,
required Activity activity,
required AutoPauseBy? autoPause,
required RecorderNotice notice,
}) => _channel.invokeMethod<void>('start', {
'path': path,
'activity': activity.name,
'autoPause': autoPause?.name,
'notice': noticeJson(notice),
});
@override
Future<void> pause() => _channel.invokeMethod<void>('pause');
@override
Future<void> resume() => _channel.invokeMethod<void>('resume');
@override
Future<void> stop() => _channel.invokeMethod<void>('stop');
@override
Future<void> show(RecorderNotice notice) =>
_channel.invokeMethod<void>('show', noticeJson(notice));
@override
Future<bool> isRecording() async =>
await _channel.invokeMethod<bool>('isRecording') ?? false;
@override
Stream<String> lines() => _lines.receiveBroadcastStream().cast<String>();
}
/// [notice] as the service reads it, JSON: times in milliseconds, since 1970
/// for a moment.
String noticeJson(RecorderNotice notice) => jsonEncode({
'title': notice.title,
'text': notice.text,
if (notice.moving case final moving?) 'movingMs': moving.inMilliseconds,
'shownAt': notice.shownAt.millisecondsSinceEpoch,
'pause': notice.pause,
'resume': notice.resume,
'channelName': notice.channelName,
});

View file

@ -0,0 +1,39 @@
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.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/workout/model/workout.dart';
/// Whether [activity] is recorded with GPS: all but rowing indoors.
bool usesGps(Activity activity) => activity != Activity.indoorRowing;
/// What the recording screen shows of [recording] at [now] (ADR 0030, D21),
/// worked out as a saved workout's totals are (D16, D20).
LiveRecording liveOf(Recording recording, DateTime now) {
final gps = usesGps(recording.activity);
final last = recording.pauses.lastOrNull;
final paused = last?.paused ?? false;
final recent = paused ? null : recentOf(recording, now);
final (pace, kmh) = switch (recent) {
(final metres, final time) when metres >= 10 && time.inSeconds >= 10 => (
Duration(milliseconds: (time.inMilliseconds * 1000 / metres).round()),
metres / time.inMilliseconds * 3600,
),
_ => (null, null),
};
final lastFix = recording.fixes.lastOrNull;
return LiveRecording(
activity: recording.activity,
movingTime: movingTimeOf(recording, now),
distanceMetres: gps ? distanceOf(recording) ?? 0 : null,
pace: pace,
kmh: kmh,
accuracyMetres: switch (lastFix) {
final fix? => (fix.accuracyCm / 100).round(),
null => null,
},
waitingForGps:
gps && !recording.fixes.any((fix) => fix.accuracyCm <= maxAccuracyCm),
paused: paused,
autoPaused: paused && last!.auto,
);
}

View file

@ -19,8 +19,8 @@ const maxAccuracyCm = 2000;
activity: recording.activity, activity: recording.activity,
startedAt: recording.startedAt, startedAt: recording.startedAt,
endedAt: endedAt, endedAt: endedAt,
distanceMetres: _distance(recording), distanceMetres: distanceOf(recording),
movingTime: _movingTime(recording, endedAt), movingTime: movingTimeOf(recording, endedAt),
), ),
_series(recording), _series(recording),
); );
@ -28,7 +28,12 @@ const maxAccuracyCm = 2000;
/// The track's length in metres, null without fixes: from fix to fix while /// The track's length in metres, null without fixes: from fix to fix while
/// it moves, the imprecise ones left out, starting afresh after each pause. /// it moves, the imprecise ones left out, starting afresh after each pause.
int? _distance(Recording recording) { int? distanceOf(Recording recording) => switch (_metres(recording)) {
final metres? => metres.round(),
null => null,
};
double? _metres(Recording recording) {
if (recording.fixes.isEmpty) { if (recording.fixes.isEmpty) {
return null; return null;
} }
@ -51,12 +56,38 @@ int? _distance(Recording recording) {
} }
last = fix; last = fix;
} }
return metres.round(); return metres;
}
/// How far [recording] went over its last [window] up to [now], and how
/// long that took from its first fix there to its last: the pace now.
(double metres, Duration time)? recentOf(
Recording recording,
DateTime now, {
Duration window = const Duration(seconds: 30),
}) {
final from = now.subtract(window);
final fixes = [
for (final fix in recording.fixes)
if (!fix.time.isBefore(from) && fix.accuracyCm <= maxAccuracyCm) fix,
];
if (fixes.length < 2) {
return null;
}
final metres = _metres(
Recording(
activity: recording.activity,
startedAt: recording.startedAt,
fixes: fixes,
pauses: recording.pauses,
),
)!;
return (metres, fixes.last.time.difference(fixes.first.time));
} }
/// How long it moved: from start to [endedAt], each pause's time left out, one /// How long it moved: from start to [endedAt], each pause's time left out, one
/// still open at the end running to it. /// still open at the end running to it.
Duration _movingTime(Recording recording, DateTime endedAt) { Duration movingTimeOf(Recording recording, DateTime endedAt) {
var moving = endedAt.difference(recording.startedAt); var moving = endedAt.difference(recording.startedAt);
DateTime? pausedAt; DateTime? pausedAt;
for (final mark in recording.pauses) { for (final mark in recording.pauses) {

View file

@ -0,0 +1,87 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
/// What a recording may ask the system for (ADR 0030, D19).
enum RecorderPermission() {
/// Precise location, for GPS.
location,
/// Showing the recording's notification (Android 13 on).
notifications,
/// Physical activity, for the step detector's auto-pause on foot (D16).
activity,
}
/// How a recording pauses by itself (D16), decided by the recorder.
enum AutoPauseBy() {
/// On foot: by steps, or by GPS speed where steps aren't allowed (D19).
steps,
/// By bike: by GPS speed.
bike,
}
/// What the recording's notification shows (D21), in the app's language.
final class const RecorderNotice({
/// The activity, and whether it is paused: "Running", "Running · paused".
required final String title,
/// The distance so far, as "5.12 km"; empty without GPS.
required final String text,
/// How long it moved when the notice was made ([shownAt]), from which the
/// notification counts on by itself; null while paused.
required final Duration? moving,
required final DateTime shownAt,
/// The buttons' labels, and the notifications channel's name.
required final String pause,
required final String resume,
required final String channelName,
}) with Equatable {
@override
List<Object?> get props => [
title,
text,
moving,
shownAt,
pause,
resume,
channelName,
];
}
/// The system's side of recording (D13): a service that records into a file
/// (D18) while the app may be gone, and its notification.
abstract interface class RecorderPlatform() {
/// Asks for the permissions [wanted] not granted yet, and answers those
/// granted now.
Future<Set<RecorderPermission>> ask(Set<RecorderPermission> wanted);
/// Starts recording [activity] into the file at [path], pausing by itself
/// by [autoPause] (null: never).
Future<void> start({
required String path,
required Activity activity,
required AutoPauseBy? autoPause,
required RecorderNotice notice,
});
/// Pauses, or goes on, by hand.
Future<void> pause();
Future<void> resume();
/// Ends the recording: its file is finished and closed when this completes.
Future<void> stop();
/// Shows [notice] in the recording's notification.
Future<void> show(RecorderNotice notice);
/// Whether a recording runs, as after the app was gone and came back.
Future<bool> isRecording();
/// Every line of the file written so far, then each new one as it is
/// written, while a recording runs.
Stream<String> lines();
}

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