Compare commits
20 commits
5d58071601
...
f2747b357b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2747b357b | ||
|
|
f2fd610b4f | ||
|
|
c8bfb7a759 | ||
|
|
21f56e3986 | ||
|
|
5a7f80049a | ||
|
|
41d0f1ff96 | ||
|
|
0750a47cef | ||
|
|
8f8b361915 | ||
|
|
246e47ee8f | ||
|
|
ad493a72aa | ||
|
|
8371d5e16c | ||
|
|
3ded6ee425 | ||
|
|
070a29298f | ||
|
|
dd39c85f94 | ||
|
|
1ca354af53 | ||
|
|
5bf90899bd | ||
|
|
984b1e058e | ||
|
|
14c3ac294e | ||
|
|
c13549338c | ||
|
|
e1d6372338 |
157 changed files with 16448 additions and 285 deletions
111
CHANGELOG.md
111
CHANGELOG.md
|
|
@ -1,5 +1,116 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.1.5 — 2026-09-23
|
||||||
|
|
||||||
|
- Completed the Sleep Tonight readiness delta and its non-destructive private
|
||||||
|
vc47 rollout. Pre-bed medication now creates or
|
||||||
|
reuses one durable pending 18:00-to-18:00 Sleep entry; Couché appends bedtime
|
||||||
|
to that same stable identity, while Réveil/Levé remain unavailable before
|
||||||
|
bedtime. Pending quick facts are publishable through a distinct marker and
|
||||||
|
are not day validation.
|
||||||
|
- Added structured per-intake medication quantity (`1..99`) in Android schema
|
||||||
|
v32 and desktop schema v35. The active `trainlog-sleep-diary` V2 companion
|
||||||
|
(`sleep-diary-v2.json`) carries quantity; frozen V1 remains readable and
|
||||||
|
interprets missing quantity as one. Per-unit dose/unit remain separate from
|
||||||
|
quantity, which is rendered in Android, Web and PDF as appropriate (for
|
||||||
|
example `75 mg ×2`).
|
||||||
|
- Kept sleep HR capture factual and sample-driven: pre-bed medication alone
|
||||||
|
creates no capture; the first real post-Couché sample opens/reuses the
|
||||||
|
entry-owned capture at bedtime; Réveil does not split it; and Levé closes it
|
||||||
|
exactly at final get-up. A no-sensor night fabricates neither capture nor
|
||||||
|
sample.
|
||||||
|
- Updated the Web Sleep agenda to render every returned night in the selected
|
||||||
|
period on one shared 18:00-to-18:00 axis. Row selection is navigation only
|
||||||
|
and shows the selected entry’s full-width HR detail. Relative measured HR
|
||||||
|
rises/falls use a trailing five-minute reference excluding the current
|
||||||
|
60-second window, thresholds of `max(10 BPM, 15%)`, and ignore gaps over
|
||||||
|
three seconds; labels remain descriptive and make no sleep-stage, medical,
|
||||||
|
or causal inference.
|
||||||
|
|
||||||
|
- Added the Cardio V1 persistence/synchronization foundation. Android schema
|
||||||
|
v27 owns durable timestamped heart-rate captures, samples and raw RR values;
|
||||||
|
desktop schema v30 imports the optional `trainlog-heart-rate` V1 companion
|
||||||
|
transactionally. Exact generation membership is retained until a correlated
|
||||||
|
consumed ACK, after which those stopped captures remain local history but are
|
||||||
|
excluded from ordinary re-publication. Desktop/Web remain sensor consumers
|
||||||
|
only; Bluetooth Classic Trainlog synchronization is unchanged.
|
||||||
|
- Added the Android Heart Rate Service diagnostic foundation. The independent
|
||||||
|
`0x2A37` parser covers 8/16-bit BPM, contact, energy and raw RR flags with
|
||||||
|
strict malformed-payload rejection. A settings diagnostic provides bounded
|
||||||
|
HRS-filtered and expanded BLE scans, GATT service discovery and notification
|
||||||
|
subscription without turning desktop/Web into sensor clients. Android 12+
|
||||||
|
scan/connect permissions and the Android 11-and-older scan-location boundary
|
||||||
|
are explicit. Real CYCPLUS H2 characterization then confirmed the standard
|
||||||
|
service/measurement path, Battery Service, ~1 Hz notifications and raw RR.
|
||||||
|
- Added persistent Android heart-rate acquisition and the global `♥ BPM`
|
||||||
|
indicator. A validated sensor is remembered, a dedicated connected-device
|
||||||
|
foreground service reconnects across app restarts and Bluetooth loss, stale
|
||||||
|
measurements are cleared instead of displayed indefinitely, and the heart
|
||||||
|
uses grey/orange/green for disconnected/reconnecting/fresh states. The real
|
||||||
|
CYCPLUS H2 was validated through force-stop/restart and Bluetooth off/on
|
||||||
|
recovery on the private 0.1.5 versionCode 40 installation.
|
||||||
|
- Added the training execution timeline used for cardio correlation. Android
|
||||||
|
schema v28 records explicit start/end actions for stable exercise occurrences
|
||||||
|
while preserving the session lifecycle `started_at`/`ended_at` as canonical
|
||||||
|
bounds. Only one exercise can be active; session finalization refuses an
|
||||||
|
unclosed occurrence. Live heart-rate samples snapshot the active `entry_id`,
|
||||||
|
rest intervals remain unowned, and the optional `trainlog-session-timeline`
|
||||||
|
V1 companion imports idempotently into desktop schema v31.
|
||||||
|
- Added Android one-tap Sleep capture on top of the existing Sleep Diary V1:
|
||||||
|
Couché, active medication buttons, point Réveil events and Levé each record
|
||||||
|
the current timestamp in one action. A guarded short-lived Annuler creates a
|
||||||
|
causal compensating revision, while Corriger la nuit retains the complete
|
||||||
|
editor. Heart-rate acquisition links directly to the stable sleep `entry_id`
|
||||||
|
and closes at Levé; no return-to-sleep or sleep-stage inference is added.
|
||||||
|
- Added the true `cardio` session type without widening frozen session
|
||||||
|
codecs. Android schema v29 and desktop schema v32 introduce canonical
|
||||||
|
`session_kind=training|max_test|cardio` while the historical
|
||||||
|
`session_type` compatibility column remains `training|max_test`.
|
||||||
|
Completed cardio sessions are excluded from `history-v4` and move through
|
||||||
|
the separate optional `trainlog-cardio-sessions` V1 generation companion;
|
||||||
|
exact replay is idempotent and identity collision with non-cardio history is
|
||||||
|
rejected.
|
||||||
|
|
||||||
|
- Added the first versioned Cardio calibration workflow. Android schema v30 creates one reserved system exercise outside the normal exercise catalogue, starts a real cardio session and HR capture, records only measured observed peak BPM, and requires at least three minutes of recovery. +1/+2/+3 minute recovery facts accept only real samples within a bounded ten-second window; missing signal therefore remains missing instead of being backfilled from a later reconnect. Completed immutable profiles synchronize Android→desktop through `trainlog-cardio-calibrations` V1 into desktop schema v33, where the importer proves peak and recovery points against the synchronized raw HR capture before accepting them.
|
||||||
|
|
||||||
|
|
||||||
|
- Added fresh-signal BPM-guided cardio phases. Android schema v31 stores
|
||||||
|
ordered phase runs against the active cardio occurrence, immutable resolved
|
||||||
|
BPM targets, optional exact calibration provenance, exit conditions and
|
||||||
|
instruction changes. The engine uses a 2 BPM hysteresis band and a 3-second
|
||||||
|
confirmation interval to avoid boundary oscillation; stale signal suspends
|
||||||
|
guidance immediately and never reuses an old BPM. Android exposes live
|
||||||
|
Accélère / Maintiens / Ralentis guidance during the active cardio exercise.
|
||||||
|
Completed runs synchronize once through `trainlog-cardio-guidance` V1 into
|
||||||
|
desktop schema v34. Desktop verifies cardio-session ownership, exercise
|
||||||
|
timeline, calibration provenance and exact raw HR evidence for instruction
|
||||||
|
events carrying BPM before accepting them.
|
||||||
|
|
||||||
|
- Fixed live Cardio guidance after changing an already-started manual draft
|
||||||
|
from Training to Cardio. Draft persistence now atomically reconciles the
|
||||||
|
same active heart-rate capture from `session` to `cardio` without replacing
|
||||||
|
its identity or losing existing BPM/RR samples and sequence. A transition
|
||||||
|
away from Cardio remains possible before Cardio-only facts exist, but is
|
||||||
|
rejected once durable guidance or calibration facts would make it
|
||||||
|
ambiguous. Repository coverage reproduces the real ordering through an
|
||||||
|
active exercise and guidance event. The private vc46 re-smoke retained the
|
||||||
|
original capture and more than 31 existing samples across the transition,
|
||||||
|
then displayed a measured 92 BPM and `ACCÉLÈRE`; the Sommeil and Calibration
|
||||||
|
cardio screens also rendered without creating persistent data.
|
||||||
|
|
||||||
|
- Added one end-to-end tomorrow-session readiness regression. A synchronized
|
||||||
|
Program session retains its stable session and Program identities through
|
||||||
|
edits, two ordered Program-derived occurrence intervals,
|
||||||
|
pre-exercise/inter-exercise/active heart-rate ownership, finalization,
|
||||||
|
full-generation publication, desktop import, replay and correlated ACK. Raw
|
||||||
|
observation timestamps, BPM, RR, nullable stable exercise ownership and
|
||||||
|
capture/session bounds are compared on both platforms without inferring
|
||||||
|
ownership from time overlap.
|
||||||
|
|
||||||
|
- Added factual synchronized heart-rate timelines to the local Web. One bounded C17 read model and loopback GET endpoint now project raw BPM/RR measurements with canonical workout exercise intervals or Sleep Diary/medication markers. Cardio history additionally presents resolved phase targets, instruction changes and calibration peak/recovery facts. History and Sleep reuse one responsive SVG component; full-sample min/max/mean statistics remain exact while only very large chart projections retain bounded bucket extrema. No sleep stage, HRV, readiness or medical inference is added.
|
||||||
|
|
||||||
|
Development opened after stable v0.1.4 release.
|
||||||
|
|
||||||
## 0.1.4 — 2026-09-22
|
## 0.1.4 — 2026-09-22
|
||||||
|
|
||||||
- Added the frozen Bluetooth Classic RFCOMM synchronization transport without
|
- Added the frozen Bluetooth Classic RFCOMM synchronization transport without
|
||||||
|
|
|
||||||
17
README.md
17
README.md
|
|
@ -24,7 +24,7 @@ it never copies SQLite database files between devices.
|
||||||
|---|---|
|
|---|---|
|
||||||
| Android | Capture and quickly correct sets, repetitions, loads, durations, and continuous activities; reorder active and completed-session occurrences; capture feedback, J+1 follow-ups, body measurements, and AI proposals; trigger sync; show quick summaries. |
|
| Android | Capture and quickly correct sets, repetitions, loads, durations, and continuous activities; reorder active and completed-session occurrences; capture feedback, J+1 follow-ups, body measurements, and AI proposals; trigger sync; show quick summaries. |
|
||||||
| Desktop TUI | Administer, inspect, maintain, import/export, correct canonical history, and provide technical tools. |
|
| Desktop TUI | Administer, inspect, maintain, import/export, correct canonical history, and provide technical tools. |
|
||||||
| Local Web (0.1.4) | Display Dashboard, Programmes, Sessions, Exercises and factual Analyse workspaces. Analyse includes the Sleep Diary editor, 18:00-to-18:00 agenda and local vector PDF export. |
|
| Local Web (0.1.5) | Display Dashboard, Programmes, Sessions, Exercises and factual Analyse workspaces. Analyse includes the Sleep Diary editor, multi-night 18:00-to-18:00 agenda, factual heart-rate timelines and local vector PDF export. |
|
||||||
|
|
||||||
No interface reconstructs business truth from SQLite tables. The local Web is
|
No interface reconstructs business truth from SQLite tables. The local Web is
|
||||||
a sibling adapter, not an extension of the TUI. On `main`, its loopback-only
|
a sibling adapter, not an extension of the TUI. On `main`, its loopback-only
|
||||||
|
|
@ -34,8 +34,13 @@ make the remaining placeholder routes functional.
|
||||||
The top-level `/programmes` route is an operational daily calendar for active
|
The top-level `/programmes` route is an operational daily calendar for active
|
||||||
Programs. Sessions → Programmes remains the technical administration/import,
|
Programs. Sessions → Programmes remains the technical administration/import,
|
||||||
list, detail, archive, and delete surface.
|
list, detail, archive, and delete surface.
|
||||||
Web Exercises administration is implemented. Version 0.1.4 also includes the
|
Web Exercises administration, the factual Dashboard/Analyse read model, and the
|
||||||
factual Dashboard/Analyse read model and the synchronized Sleep Diary workspace.
|
synchronized Sleep Diary workspace are the stable v0.1.4 baseline. Stable
|
||||||
|
0.1.5 adds persistent CYCPLUS acquisition, the global `♥ BPM` indicator,
|
||||||
|
training exercise timestamps, structured Android Sleep capture, dedicated
|
||||||
|
synchronized `cardio` sessions, versioned measured Cardio calibration,
|
||||||
|
fresh-signal BPM-guided phases and factual Web heart-rate timelines in
|
||||||
|
History/Sleep.
|
||||||
|
|
||||||
## Releases
|
## Releases
|
||||||
|
|
||||||
|
|
@ -52,8 +57,8 @@ Each published stable release provides:
|
||||||
- SHA-256 checksums.
|
- SHA-256 checksums.
|
||||||
|
|
||||||
Both mirrors publish the same Trainlog product version and release assets. The
|
Both mirrors publish the same Trainlog product version and release assets. The
|
||||||
latest stable version is **0.1.4** on Android and desktop; this is one shared
|
latest stable version is **0.1.5** on Android and desktop; this is one shared
|
||||||
Trainlog version, not separate interface versions. Version 0.1.3 is the
|
Trainlog version, not separate interface versions. Version 0.1.4 is the
|
||||||
previous stable release.
|
previous stable release.
|
||||||
|
|
||||||
Trainlog presents French by default, with English selectable
|
Trainlog presents French by default, with English selectable
|
||||||
|
|
@ -157,7 +162,7 @@ updates; generating a replacement key is not a normal release procedure.
|
||||||
|
|
||||||
### Linux runtime dependencies
|
### Linux runtime dependencies
|
||||||
|
|
||||||
The published v0.1.4 x86-64 desktop runtime is dynamically linked. Its tagged
|
The published v0.1.5 x86-64 desktop runtime is dynamically linked. Its tagged
|
||||||
source directly requires compatible versions of:
|
source directly requires compatible versions of:
|
||||||
|
|
||||||
- glibc and the GCC support runtime;
|
- glibc and the GCC support runtime;
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ val trainlogVersionCode =
|
||||||
providers.environmentVariable("TRAINLOG_ANDROID_VERSION_CODE").orNull?.let { raw ->
|
providers.environmentVariable("TRAINLOG_ANDROID_VERSION_CODE").orNull?.let { raw ->
|
||||||
raw.toIntOrNull()?.takeIf { it in 1..Int.MAX_VALUE }
|
raw.toIntOrNull()?.takeIf { it in 1..Int.MAX_VALUE }
|
||||||
?: throw GradleException("TRAINLOG_ANDROID_VERSION_CODE must be a positive integer")
|
?: throw GradleException("TRAINLOG_ANDROID_VERSION_CODE must be a positive integer")
|
||||||
} ?: 5
|
} ?: 6
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.labfytools.trainlog"
|
namespace = "com.labfytools.trainlog"
|
||||||
|
|
@ -55,7 +55,7 @@ android {
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
|
|
||||||
versionCode = trainlogVersionCode
|
versionCode = trainlogVersionCode
|
||||||
versionName = "0.1.4"
|
versionName = "0.1.5"
|
||||||
testInstrumentationRunner =
|
testInstrumentationRunner =
|
||||||
"androidx.test.runner.AndroidJUnitRunner"
|
"androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,14 @@
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.BLUETOOTH_ADMIN"
|
android:name="android.permission.BLUETOOTH_ADMIN"
|
||||||
android:maxSdkVersion="30" />
|
android:maxSdkVersion="30" />
|
||||||
|
<!-- BLE cardio discovery does not derive physical location. Android 11 and
|
||||||
|
lower still require fine location for direct BLE scans. -->
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.ACCESS_FINE_LOCATION"
|
||||||
|
android:maxSdkVersion="30" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.BLUETOOTH_SCAN"
|
||||||
|
android:usesPermissionFlags="neverForLocation" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
|
@ -42,5 +50,9 @@
|
||||||
android:name=".data.SyncBackgroundService"
|
android:name=".data.SyncBackgroundService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="connectedDevice|dataSync" />
|
android:foregroundServiceType="connectedDevice|dataSync" />
|
||||||
|
<service
|
||||||
|
android:name=".data.HeartRateSensorService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="connectedDevice" />
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,10 @@ import com.labfytools.trainlog.data.SyncCatalogInbox
|
||||||
import com.labfytools.trainlog.data.SyncRequestOutbox
|
import com.labfytools.trainlog.data.SyncRequestOutbox
|
||||||
import com.labfytools.trainlog.data.BackgroundSyncSettings
|
import com.labfytools.trainlog.data.BackgroundSyncSettings
|
||||||
import com.labfytools.trainlog.data.SyncBackgroundService
|
import com.labfytools.trainlog.data.SyncBackgroundService
|
||||||
|
import com.labfytools.trainlog.data.HeartRateSensorPreferences
|
||||||
|
import com.labfytools.trainlog.data.HeartRateSensorService
|
||||||
import com.labfytools.trainlog.data.hasBluetoothConnectPermission
|
import com.labfytools.trainlog.data.hasBluetoothConnectPermission
|
||||||
|
import com.labfytools.trainlog.data.hasHeartRateBleRuntimePermissions
|
||||||
import com.labfytools.trainlog.ui.TrainlogApp
|
import com.labfytools.trainlog.ui.TrainlogApp
|
||||||
import com.labfytools.trainlog.ui.TrainlogAppState
|
import com.labfytools.trainlog.ui.TrainlogAppState
|
||||||
import com.labfytools.trainlog.ui.LanguagePresentation
|
import com.labfytools.trainlog.ui.LanguagePresentation
|
||||||
|
|
@ -39,6 +42,12 @@ class MainActivity : ComponentActivity() {
|
||||||
) {
|
) {
|
||||||
SyncBackgroundService.start(applicationContext)
|
SyncBackgroundService.start(applicationContext)
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
HeartRateSensorPreferences(applicationContext).selected() != null &&
|
||||||
|
hasHeartRateBleRuntimePermissions(applicationContext)
|
||||||
|
) {
|
||||||
|
HeartRateSensorService.start(applicationContext)
|
||||||
|
}
|
||||||
|
|
||||||
val repository =
|
val repository =
|
||||||
TrainlogRepository(
|
TrainlogRepository(
|
||||||
|
|
|
||||||
|
|
@ -225,7 +225,7 @@ internal class AndroidBackupService(
|
||||||
manifest.getInt("version") == VERSION &&
|
manifest.getInt("version") == VERSION &&
|
||||||
manifest.getString("package") == context.packageName &&
|
manifest.getString("package") == context.packageName &&
|
||||||
manifest.getBoolean("plaintext") &&
|
manifest.getBoolean("plaintext") &&
|
||||||
manifest.getInt("schema") in 17..26
|
manifest.getInt("schema") in 17..32
|
||||||
) {
|
) {
|
||||||
"Unsupported backup."
|
"Unsupported backup."
|
||||||
}
|
}
|
||||||
|
|
@ -319,7 +319,7 @@ internal class AndroidBackupService(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() })
|
require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() })
|
||||||
require(db.version in 17..26) { "Unsupported Android schema ${db.version}." }
|
require(db.version in 17..32) { "Unsupported Android schema ${db.version}." }
|
||||||
return db.version
|
return db.version
|
||||||
} finally {
|
} finally {
|
||||||
db.close()
|
db.close()
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,17 @@ private const val MAX_BT_FILE_BYTES = 64L * 1024L * 1024L
|
||||||
private const val MAX_BT_FILES = 128
|
private const val MAX_BT_FILES = 128
|
||||||
private const val AUTO_REQUEST_COOLDOWN_MS = 5L * 60L * 1000L
|
private const val AUTO_REQUEST_COOLDOWN_MS = 5L * 60L * 1000L
|
||||||
|
|
||||||
|
internal val BLUETOOTH_ANDROID_PULL_COORDINATION =
|
||||||
|
listOf(
|
||||||
|
"android-peer-v1.json",
|
||||||
|
FULL_GENERATION_REQUEST_NAME,
|
||||||
|
LEGACY_SYNC_REQUEST_NAME,
|
||||||
|
"android-generation-v1.json",
|
||||||
|
"android-consumption-ack-v1.json",
|
||||||
|
"android-archive-acknowledgements-v1.json",
|
||||||
|
"android-generation-error-v1.json",
|
||||||
|
)
|
||||||
|
|
||||||
internal data class BondedBluetoothDesktop(
|
internal data class BondedBluetoothDesktop(
|
||||||
val name: String,
|
val name: String,
|
||||||
val address: String,
|
val address: String,
|
||||||
|
|
@ -248,14 +259,7 @@ private class BluetoothExchangeArchive(
|
||||||
val totals = longArrayOf(0L, 0L)
|
val totals = longArrayOf(0L, 0L)
|
||||||
try {
|
try {
|
||||||
ZipOutputStream(BufferedOutputStream(FileOutputStream(output))).use { zip ->
|
ZipOutputStream(BufferedOutputStream(FileOutputStream(output))).use { zip ->
|
||||||
listOf(
|
BLUETOOTH_ANDROID_PULL_COORDINATION.forEach { name ->
|
||||||
"android-peer-v1.json",
|
|
||||||
FULL_GENERATION_REQUEST_NAME,
|
|
||||||
LEGACY_SYNC_REQUEST_NAME,
|
|
||||||
"android-generation-v1.json",
|
|
||||||
"android-consumption-ack-v1.json",
|
|
||||||
"android-generation-error-v1.json",
|
|
||||||
).forEach { name ->
|
|
||||||
addFile(zip, File(exchangeDirectory, name), name, seen, totals)
|
addFile(zip, File(exchangeDirectory, name), name, seen, totals)
|
||||||
}
|
}
|
||||||
val reference = File(exchangeDirectory, "android-generation-v1.json")
|
val reference = File(exchangeDirectory, "android-generation-v1.json")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
|
import com.labfytools.trainlog.model.CardioGuidanceInstruction
|
||||||
|
import com.labfytools.trainlog.model.CardioGuidanceOutput
|
||||||
|
import com.labfytools.trainlog.model.CardioTargetDefinition
|
||||||
|
import com.labfytools.trainlog.model.CardioTargetSnapshot
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
internal object CardioTargetResolver {
|
||||||
|
fun resolve(definition: CardioTargetDefinition): CardioTargetSnapshot? =
|
||||||
|
when (definition) {
|
||||||
|
is CardioTargetDefinition.AbsoluteBpm -> {
|
||||||
|
if (!validBounds(definition.minimumBpm, definition.maximumBpm)) return null
|
||||||
|
CardioTargetSnapshot(
|
||||||
|
minimumBpm = definition.minimumBpm,
|
||||||
|
maximumBpm = definition.maximumBpm,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is CardioTargetDefinition.CalibrationPercent -> {
|
||||||
|
if (
|
||||||
|
definition.calibrationId.isBlank() ||
|
||||||
|
definition.observedPeakBpm !in 1..65535 ||
|
||||||
|
definition.minimumPercent !in 1..100 ||
|
||||||
|
definition.maximumPercent !in 1..100 ||
|
||||||
|
definition.minimumPercent > definition.maximumPercent
|
||||||
|
) return null
|
||||||
|
val minimum =
|
||||||
|
(definition.observedPeakBpm * definition.minimumPercent / 100.0)
|
||||||
|
.roundToInt()
|
||||||
|
val maximum =
|
||||||
|
(definition.observedPeakBpm * definition.maximumPercent / 100.0)
|
||||||
|
.roundToInt()
|
||||||
|
if (!validBounds(minimum, maximum)) return null
|
||||||
|
CardioTargetSnapshot(
|
||||||
|
minimumBpm = minimum,
|
||||||
|
maximumBpm = maximum,
|
||||||
|
calibrationId = definition.calibrationId,
|
||||||
|
calibrationObservedPeakBpm = definition.observedPeakBpm,
|
||||||
|
minimumPercent = definition.minimumPercent,
|
||||||
|
maximumPercent = definition.maximumPercent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validBounds(minimum: Int, maximum: Int): Boolean =
|
||||||
|
minimum in 1..65535 && maximum in 1..65535 && minimum <= maximum
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure state machine for BPM guidance.
|
||||||
|
*
|
||||||
|
* Hysteresis and confirmation time are UI/control stability parameters, not
|
||||||
|
* physiological zones. The engine never extrapolates a stale measurement.
|
||||||
|
*/
|
||||||
|
internal object CardioPhaseCompletion {
|
||||||
|
fun isComplete(
|
||||||
|
phase: com.labfytools.trainlog.model.CardioGuidedPhase,
|
||||||
|
elapsedSeconds: Int,
|
||||||
|
bpm: Int?,
|
||||||
|
fresh: Boolean,
|
||||||
|
): Boolean {
|
||||||
|
if (elapsedSeconds < 0) return false
|
||||||
|
return when (val exit = phase.exitCondition) {
|
||||||
|
is com.labfytools.trainlog.model.CardioPhaseExitCondition.FixedDuration ->
|
||||||
|
elapsedSeconds >= exit.seconds
|
||||||
|
|
||||||
|
com.labfytools.trainlog.model.CardioPhaseExitCondition.EnterTarget -> {
|
||||||
|
val target = phase.target ?: return false
|
||||||
|
fresh && bpm != null && bpm in target.minimumBpm..target.maximumBpm
|
||||||
|
}
|
||||||
|
|
||||||
|
is com.labfytools.trainlog.model.CardioPhaseExitCondition.RecoverBelow ->
|
||||||
|
fresh && bpm != null && bpm <= exit.bpm
|
||||||
|
|
||||||
|
is com.labfytools.trainlog.model.CardioPhaseExitCondition.DurationOrRecoverBelow ->
|
||||||
|
elapsedSeconds >= exit.maximumSeconds ||
|
||||||
|
(fresh && bpm != null && bpm <= exit.bpm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class CardioGuidanceEngine(
|
||||||
|
private val hysteresisBpm: Int = DEFAULT_HYSTERESIS_BPM,
|
||||||
|
private val confirmationMs: Long = DEFAULT_CONFIRMATION_MS,
|
||||||
|
initialInstruction: CardioGuidanceInstruction = CardioGuidanceInstruction.MAINTAIN,
|
||||||
|
) {
|
||||||
|
private var current =
|
||||||
|
initialInstruction.takeUnless { it == CardioGuidanceInstruction.SUSPENDED }
|
||||||
|
?: CardioGuidanceInstruction.MAINTAIN
|
||||||
|
private var candidate: CardioGuidanceInstruction? = null
|
||||||
|
private var candidateSinceMs: Long? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
require(hysteresisBpm in 0..20)
|
||||||
|
require(confirmationMs in 0L..30_000L)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
current = CardioGuidanceInstruction.MAINTAIN
|
||||||
|
candidate = null
|
||||||
|
candidateSinceMs = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun evaluate(
|
||||||
|
target: CardioTargetSnapshot?,
|
||||||
|
bpm: Int?,
|
||||||
|
fresh: Boolean,
|
||||||
|
nowMs: Long,
|
||||||
|
): CardioGuidanceOutput {
|
||||||
|
require(nowMs >= 0L)
|
||||||
|
if (!fresh || bpm == null || bpm !in 1..65535 || target == null) {
|
||||||
|
candidate = null
|
||||||
|
candidateSinceMs = null
|
||||||
|
return CardioGuidanceOutput(
|
||||||
|
instruction = CardioGuidanceInstruction.SUSPENDED,
|
||||||
|
bpm = null,
|
||||||
|
target = target,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
require(target.minimumBpm in 1..65535)
|
||||||
|
require(target.maximumBpm in target.minimumBpm..65535)
|
||||||
|
|
||||||
|
val desired = desiredInstruction(target, bpm)
|
||||||
|
if (desired == current) {
|
||||||
|
candidate = null
|
||||||
|
candidateSinceMs = null
|
||||||
|
return CardioGuidanceOutput(current, bpm, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate != desired) {
|
||||||
|
candidate = desired
|
||||||
|
candidateSinceMs = nowMs
|
||||||
|
}
|
||||||
|
val since = checkNotNull(candidateSinceMs)
|
||||||
|
if (nowMs - since >= confirmationMs) {
|
||||||
|
current = desired
|
||||||
|
candidate = null
|
||||||
|
candidateSinceMs = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return CardioGuidanceOutput(
|
||||||
|
instruction = current,
|
||||||
|
bpm = bpm,
|
||||||
|
target = target,
|
||||||
|
pendingInstruction = candidate,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun desiredInstruction(
|
||||||
|
target: CardioTargetSnapshot,
|
||||||
|
bpm: Int,
|
||||||
|
): CardioGuidanceInstruction {
|
||||||
|
// A target entered from either side immediately trends toward MAINTAIN.
|
||||||
|
if (bpm in target.minimumBpm..target.maximumBpm) {
|
||||||
|
return CardioGuidanceInstruction.MAINTAIN
|
||||||
|
}
|
||||||
|
|
||||||
|
return when (current) {
|
||||||
|
CardioGuidanceInstruction.ACCELERATE ->
|
||||||
|
if (bpm >= target.minimumBpm + hysteresisBpm) {
|
||||||
|
CardioGuidanceInstruction.MAINTAIN
|
||||||
|
} else {
|
||||||
|
CardioGuidanceInstruction.ACCELERATE
|
||||||
|
}
|
||||||
|
|
||||||
|
CardioGuidanceInstruction.SLOW_DOWN ->
|
||||||
|
if (bpm <= target.maximumBpm - hysteresisBpm) {
|
||||||
|
CardioGuidanceInstruction.MAINTAIN
|
||||||
|
} else {
|
||||||
|
CardioGuidanceInstruction.SLOW_DOWN
|
||||||
|
}
|
||||||
|
|
||||||
|
CardioGuidanceInstruction.MAINTAIN,
|
||||||
|
CardioGuidanceInstruction.SUSPENDED ->
|
||||||
|
when {
|
||||||
|
bpm < target.minimumBpm - hysteresisBpm ->
|
||||||
|
CardioGuidanceInstruction.ACCELERATE
|
||||||
|
bpm > target.maximumBpm + hysteresisBpm ->
|
||||||
|
CardioGuidanceInstruction.SLOW_DOWN
|
||||||
|
else -> CardioGuidanceInstruction.MAINTAIN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val DEFAULT_HYSTERESIS_BPM = 2
|
||||||
|
const val DEFAULT_CONFIRMATION_MS = 3_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
|
import com.labfytools.trainlog.model.CardioGuidanceInstruction
|
||||||
|
import com.labfytools.trainlog.model.CardioPhaseKind
|
||||||
|
import com.labfytools.trainlog.model.CardioTargetSnapshot
|
||||||
|
import java.util.concurrent.CopyOnWriteArraySet
|
||||||
|
|
||||||
|
internal data class CardioGuidanceLiveSnapshot(
|
||||||
|
val phaseId: String? = null,
|
||||||
|
val phaseKind: CardioPhaseKind? = null,
|
||||||
|
val instruction: CardioGuidanceInstruction = CardioGuidanceInstruction.SUSPENDED,
|
||||||
|
val bpm: Int? = null,
|
||||||
|
val target: CardioTargetSnapshot? = null,
|
||||||
|
val startedAt: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal object CardioGuidanceLiveState {
|
||||||
|
private val listeners =
|
||||||
|
CopyOnWriteArraySet<(CardioGuidanceLiveSnapshot) -> Unit>()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var snapshot = CardioGuidanceLiveSnapshot()
|
||||||
|
|
||||||
|
fun current(): CardioGuidanceLiveSnapshot = snapshot
|
||||||
|
|
||||||
|
fun publish(next: CardioGuidanceLiveSnapshot) {
|
||||||
|
snapshot = next
|
||||||
|
listeners.forEach { it(next) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
publish(CardioGuidanceLiveSnapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun subscribe(listener: (CardioGuidanceLiveSnapshot) -> Unit): AutoCloseable {
|
||||||
|
listeners += listener
|
||||||
|
listener(snapshot)
|
||||||
|
return AutoCloseable { listeners -= listener }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -195,7 +195,8 @@ internal class DriveDocumentTransport(private val context: Context, treeUri: Uri
|
||||||
localRoot.mkdirs()
|
localRoot.mkdirs()
|
||||||
val coordination = listOf(
|
val coordination = listOf(
|
||||||
"android-peer-v1.json", "android-generation-v1.json", "android-consumption-ack-v1.json",
|
"android-peer-v1.json", "android-generation-v1.json", "android-consumption-ack-v1.json",
|
||||||
"android-generation-error-v1.json", "request-v1.json",
|
"android-archive-acknowledgements-v1.json", "android-generation-error-v1.json",
|
||||||
|
"request-v1.json",
|
||||||
"desktop-archive-acknowledgements-v1.json", "desktop-consumption-ack-v1.json",
|
"desktop-archive-acknowledgements-v1.json", "desktop-consumption-ack-v1.json",
|
||||||
"desktop-generation-v1.json",
|
"desktop-generation-v1.json",
|
||||||
)
|
)
|
||||||
|
|
@ -261,6 +262,13 @@ internal class DriveDocumentTransport(private val context: Context, treeUri: Uri
|
||||||
upload("android-generation-v1.json", reference)
|
upload("android-generation-v1.json", reference)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun pushRecoveryAcknowledgements(localRoot: File) {
|
||||||
|
upload(
|
||||||
|
"android-archive-acknowledgements-v1.json",
|
||||||
|
File(localRoot, "android-archive-acknowledgements-v1.json"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun pushAcknowledgement(localRoot: File) {
|
fun pushAcknowledgement(localRoot: File) {
|
||||||
upload(
|
upload(
|
||||||
"android-consumption-ack-v1.json",
|
"android-consumption-ack-v1.json",
|
||||||
|
|
@ -318,6 +326,10 @@ class DriveSyncWorker(context: Context, parameters: WorkerParameters) :
|
||||||
local,
|
local,
|
||||||
Duration.ofMinutes(10),
|
Duration.ofMinutes(10),
|
||||||
afterPeerPublication = { transport.pushPeer(local) },
|
afterPeerPublication = { transport.pushPeer(local) },
|
||||||
|
afterRecoveryAcknowledgements = {
|
||||||
|
stage = "recovery-acknowledgement"
|
||||||
|
transport.pushRecoveryAcknowledgements(local)
|
||||||
|
},
|
||||||
afterPublication = {
|
afterPublication = {
|
||||||
stage = "generation-publication"
|
stage = "generation-publication"
|
||||||
transport.pushGeneration(local)
|
transport.pushGeneration(local)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,482 @@
|
||||||
|
/*
|
||||||
|
* Android-only BLE Heart Rate diagnostics.
|
||||||
|
*
|
||||||
|
* This owner is intentionally bounded and screen-scoped. Long-running capture
|
||||||
|
* belongs to the later foreground acquisition service, not this diagnostic.
|
||||||
|
*/
|
||||||
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
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.bluetooth.BluetoothStatusCodes
|
||||||
|
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.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.os.ParcelUuid
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
internal val HEART_RATE_SERVICE_UUID: UUID =
|
||||||
|
UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb")
|
||||||
|
internal val HEART_RATE_MEASUREMENT_UUID: UUID =
|
||||||
|
UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb")
|
||||||
|
internal val BATTERY_SERVICE_UUID: UUID =
|
||||||
|
UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")
|
||||||
|
internal val CLIENT_CHARACTERISTIC_CONFIG_UUID: UUID =
|
||||||
|
UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
||||||
|
|
||||||
|
internal enum class HeartRateDiagnosticPhase {
|
||||||
|
IDLE,
|
||||||
|
SCANNING,
|
||||||
|
CONNECTING,
|
||||||
|
DISCOVERING,
|
||||||
|
SUBSCRIBING,
|
||||||
|
RECEIVING,
|
||||||
|
DISCONNECTED,
|
||||||
|
ERROR,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class HeartRateDiagnosticDevice(
|
||||||
|
val address: String,
|
||||||
|
val name: String?,
|
||||||
|
val rssi: Int,
|
||||||
|
val advertisesHeartRate: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal data class HeartRateDiagnosticSnapshot(
|
||||||
|
val phase: HeartRateDiagnosticPhase = HeartRateDiagnosticPhase.IDLE,
|
||||||
|
val devices: List<HeartRateDiagnosticDevice> = emptyList(),
|
||||||
|
val selectedAddress: String? = null,
|
||||||
|
val serviceFound: Boolean = false,
|
||||||
|
val measurementFound: Boolean = false,
|
||||||
|
val batteryServiceFound: Boolean = false,
|
||||||
|
val notificationCount: Int = 0,
|
||||||
|
val latestMeasurement: ParsedHeartRateMeasurement? = null,
|
||||||
|
val diagnostic: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun heartRateBleRuntimePermissions(): Array<String> =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
|
||||||
|
} else {
|
||||||
|
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun hasHeartRateBleRuntimePermissions(context: Context): Boolean =
|
||||||
|
heartRateBleRuntimePermissions().all {
|
||||||
|
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class HeartRateBleDiagnostics(
|
||||||
|
context: Context,
|
||||||
|
private val onSnapshot: (HeartRateDiagnosticSnapshot) -> Unit,
|
||||||
|
) : AutoCloseable {
|
||||||
|
private val appContext = context.applicationContext
|
||||||
|
private val main = Handler(Looper.getMainLooper())
|
||||||
|
private val bluetoothManager =
|
||||||
|
appContext.getSystemService(BluetoothManager::class.java)
|
||||||
|
private val adapter get() = bluetoothManager?.adapter
|
||||||
|
private val devices = linkedMapOf<String, BluetoothDevice>()
|
||||||
|
private var snapshot = HeartRateDiagnosticSnapshot()
|
||||||
|
private var scannerCallback: ScanCallback? = null
|
||||||
|
private var scanStop: Runnable? = null
|
||||||
|
private var gatt: BluetoothGatt? = null
|
||||||
|
private var closed = false
|
||||||
|
|
||||||
|
private fun update(transform: (HeartRateDiagnosticSnapshot) -> HeartRateDiagnosticSnapshot) {
|
||||||
|
if (closed) return
|
||||||
|
if (Looper.myLooper() != Looper.getMainLooper()) {
|
||||||
|
main.post { update(transform) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
snapshot = transform(snapshot)
|
||||||
|
onSnapshot(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
fun startScan(includeAllBle: Boolean = false) {
|
||||||
|
if (!hasHeartRateBleRuntimePermissions(appContext)) {
|
||||||
|
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "permission") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val currentAdapter = adapter
|
||||||
|
if (currentAdapter == null) {
|
||||||
|
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "bluetooth_unavailable") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!currentAdapter.isEnabled) {
|
||||||
|
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "bluetooth_disabled") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val scanner = currentAdapter.bluetoothLeScanner
|
||||||
|
if (scanner == null) {
|
||||||
|
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "scanner_unavailable") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stopScan()
|
||||||
|
devices.clear()
|
||||||
|
update {
|
||||||
|
HeartRateDiagnosticSnapshot(
|
||||||
|
phase = HeartRateDiagnosticPhase.SCANNING,
|
||||||
|
diagnostic = if (includeAllBle) "scan_all" else "scan_heart_rate",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val callback =
|
||||||
|
object : ScanCallback() {
|
||||||
|
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||||
|
handleScanResult(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBatchScanResults(results: MutableList<ScanResult>) {
|
||||||
|
results.forEach(::handleScanResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onScanFailed(errorCode: Int) {
|
||||||
|
update {
|
||||||
|
it.copy(
|
||||||
|
phase = HeartRateDiagnosticPhase.ERROR,
|
||||||
|
diagnostic = "scan_failed:" + errorCode,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scannerCallback = callback
|
||||||
|
val filters =
|
||||||
|
if (includeAllBle) {
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
|
listOf(
|
||||||
|
ScanFilter.Builder()
|
||||||
|
.setServiceUuid(ParcelUuid(HEART_RATE_SERVICE_UUID))
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
scanner.startScan(
|
||||||
|
filters,
|
||||||
|
ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build(),
|
||||||
|
callback,
|
||||||
|
)
|
||||||
|
val stopper =
|
||||||
|
Runnable {
|
||||||
|
if (snapshot.phase == HeartRateDiagnosticPhase.SCANNING) {
|
||||||
|
stopScan()
|
||||||
|
update { it.copy(phase = HeartRateDiagnosticPhase.IDLE, diagnostic = "scan_complete") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scanStop = stopper
|
||||||
|
main.postDelayed(stopper, SCAN_DURATION_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
private fun handleScanResult(result: ScanResult) {
|
||||||
|
val record = result.scanRecord
|
||||||
|
val advertised =
|
||||||
|
record?.serviceUuids?.any { it.uuid == HEART_RATE_SERVICE_UUID } == true
|
||||||
|
val device = result.device
|
||||||
|
devices[device.address] = device
|
||||||
|
val row =
|
||||||
|
HeartRateDiagnosticDevice(
|
||||||
|
address = device.address,
|
||||||
|
name = record?.deviceName,
|
||||||
|
rssi = result.rssi,
|
||||||
|
advertisesHeartRate = advertised,
|
||||||
|
)
|
||||||
|
update { old ->
|
||||||
|
val rows =
|
||||||
|
(old.devices.filterNot { it.address == row.address } + row)
|
||||||
|
.sortedWith(
|
||||||
|
compareByDescending<HeartRateDiagnosticDevice> { it.advertisesHeartRate }
|
||||||
|
.thenByDescending { it.rssi },
|
||||||
|
)
|
||||||
|
old.copy(devices = rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
fun connect(address: String) {
|
||||||
|
if (!hasHeartRateBleRuntimePermissions(appContext)) {
|
||||||
|
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "permission") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val device =
|
||||||
|
devices[address]
|
||||||
|
?: runCatching { adapter?.getRemoteDevice(address) }.getOrNull()
|
||||||
|
?: run {
|
||||||
|
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "device_missing") }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stopScan()
|
||||||
|
closeGatt()
|
||||||
|
update {
|
||||||
|
it.copy(
|
||||||
|
phase = HeartRateDiagnosticPhase.CONNECTING,
|
||||||
|
selectedAddress = address,
|
||||||
|
serviceFound = false,
|
||||||
|
measurementFound = false,
|
||||||
|
batteryServiceFound = false,
|
||||||
|
notificationCount = 0,
|
||||||
|
latestMeasurement = null,
|
||||||
|
diagnostic = "connecting",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
runCatching {
|
||||||
|
device.connectGatt(
|
||||||
|
appContext,
|
||||||
|
false,
|
||||||
|
gattCallback,
|
||||||
|
BluetoothDevice.TRANSPORT_LE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onSuccess { gatt = it }
|
||||||
|
.onFailure { error ->
|
||||||
|
update {
|
||||||
|
it.copy(
|
||||||
|
phase = HeartRateDiagnosticPhase.ERROR,
|
||||||
|
diagnostic = "connect_failed:" + error.javaClass.simpleName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val gattCallback =
|
||||||
|
object : BluetoothGattCallback() {
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
override fun onConnectionStateChange(
|
||||||
|
callbackGatt: BluetoothGatt,
|
||||||
|
status: Int,
|
||||||
|
newState: Int,
|
||||||
|
) {
|
||||||
|
main.post {
|
||||||
|
if (closed || callbackGatt !== gatt) return@post
|
||||||
|
if (status == BluetoothGatt.GATT_SUCCESS &&
|
||||||
|
newState == BluetoothProfile.STATE_CONNECTED
|
||||||
|
) {
|
||||||
|
update {
|
||||||
|
it.copy(
|
||||||
|
phase = HeartRateDiagnosticPhase.DISCOVERING,
|
||||||
|
diagnostic = "connected",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!callbackGatt.discoverServices()) {
|
||||||
|
update {
|
||||||
|
it.copy(
|
||||||
|
phase = HeartRateDiagnosticPhase.ERROR,
|
||||||
|
diagnostic = "discover_start_failed",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
|
||||||
|
update {
|
||||||
|
it.copy(
|
||||||
|
phase = HeartRateDiagnosticPhase.DISCONNECTED,
|
||||||
|
diagnostic = "disconnected:" + status,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
closeGatt()
|
||||||
|
} else if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||||
|
update {
|
||||||
|
it.copy(
|
||||||
|
phase = HeartRateDiagnosticPhase.ERROR,
|
||||||
|
diagnostic = "gatt_status:" + status,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
closeGatt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
override fun onServicesDiscovered(callbackGatt: BluetoothGatt, status: Int) {
|
||||||
|
main.post {
|
||||||
|
if (closed || callbackGatt !== gatt) return@post
|
||||||
|
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||||
|
update {
|
||||||
|
it.copy(
|
||||||
|
phase = HeartRateDiagnosticPhase.ERROR,
|
||||||
|
diagnostic = "service_discovery:" + status,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return@post
|
||||||
|
}
|
||||||
|
val service = callbackGatt.getService(HEART_RATE_SERVICE_UUID)
|
||||||
|
val measurement = service?.getCharacteristic(HEART_RATE_MEASUREMENT_UUID)
|
||||||
|
val battery = callbackGatt.getService(BATTERY_SERVICE_UUID) != null
|
||||||
|
update {
|
||||||
|
it.copy(
|
||||||
|
serviceFound = service != null,
|
||||||
|
measurementFound = measurement != null,
|
||||||
|
batteryServiceFound = battery,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (measurement == null) {
|
||||||
|
update {
|
||||||
|
it.copy(
|
||||||
|
phase = HeartRateDiagnosticPhase.ERROR,
|
||||||
|
diagnostic = "heart_rate_measurement_missing",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return@post
|
||||||
|
}
|
||||||
|
subscribe(callbackGatt, measurement)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDescriptorWrite(
|
||||||
|
callbackGatt: BluetoothGatt,
|
||||||
|
descriptor: BluetoothGattDescriptor,
|
||||||
|
status: Int,
|
||||||
|
) {
|
||||||
|
if (descriptor.uuid != CLIENT_CHARACTERISTIC_CONFIG_UUID) return
|
||||||
|
update {
|
||||||
|
if (status == BluetoothGatt.GATT_SUCCESS) {
|
||||||
|
it.copy(
|
||||||
|
phase = HeartRateDiagnosticPhase.RECEIVING,
|
||||||
|
diagnostic = "notifications_enabled",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
it.copy(
|
||||||
|
phase = HeartRateDiagnosticPhase.ERROR,
|
||||||
|
diagnostic = "descriptor_write:" + status,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated("Deprecated by Android API 33 callback with explicit value")
|
||||||
|
override fun onCharacteristicChanged(
|
||||||
|
callbackGatt: BluetoothGatt,
|
||||||
|
characteristic: BluetoothGattCharacteristic,
|
||||||
|
) {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val value = characteristic.value ?: return
|
||||||
|
handleMeasurement(characteristic.uuid, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCharacteristicChanged(
|
||||||
|
callbackGatt: BluetoothGatt,
|
||||||
|
characteristic: BluetoothGattCharacteristic,
|
||||||
|
value: ByteArray,
|
||||||
|
) {
|
||||||
|
handleMeasurement(characteristic.uuid, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
private fun subscribe(
|
||||||
|
callbackGatt: BluetoothGatt,
|
||||||
|
measurement: BluetoothGattCharacteristic,
|
||||||
|
) {
|
||||||
|
if (!callbackGatt.setCharacteristicNotification(measurement, true)) {
|
||||||
|
update {
|
||||||
|
it.copy(
|
||||||
|
phase = HeartRateDiagnosticPhase.ERROR,
|
||||||
|
diagnostic = "notification_enable_failed",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val descriptor = measurement.getDescriptor(CLIENT_CHARACTERISTIC_CONFIG_UUID)
|
||||||
|
if (descriptor == null) {
|
||||||
|
update {
|
||||||
|
it.copy(
|
||||||
|
phase = HeartRateDiagnosticPhase.ERROR,
|
||||||
|
diagnostic = "ccc_descriptor_missing",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
update {
|
||||||
|
it.copy(
|
||||||
|
phase = HeartRateDiagnosticPhase.SUBSCRIBING,
|
||||||
|
diagnostic = "enabling_notifications",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val started =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
callbackGatt.writeDescriptor(
|
||||||
|
descriptor,
|
||||||
|
BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE,
|
||||||
|
) == BluetoothStatusCodes.SUCCESS
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
run {
|
||||||
|
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||||
|
callbackGatt.writeDescriptor(descriptor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!started) {
|
||||||
|
update {
|
||||||
|
it.copy(
|
||||||
|
phase = HeartRateDiagnosticPhase.ERROR,
|
||||||
|
diagnostic = "descriptor_write_start_failed",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleMeasurement(uuid: UUID, value: ByteArray) {
|
||||||
|
if (uuid != HEART_RATE_MEASUREMENT_UUID) return
|
||||||
|
when (val parsed = HeartRateMeasurementParser.parse(value)) {
|
||||||
|
is HeartRateMeasurementParseResult.Invalid ->
|
||||||
|
update {
|
||||||
|
it.copy(
|
||||||
|
phase = HeartRateDiagnosticPhase.ERROR,
|
||||||
|
diagnostic = "measurement:" + parsed.reason,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is HeartRateMeasurementParseResult.Parsed ->
|
||||||
|
update {
|
||||||
|
it.copy(
|
||||||
|
phase = HeartRateDiagnosticPhase.RECEIVING,
|
||||||
|
notificationCount = it.notificationCount + 1,
|
||||||
|
latestMeasurement = parsed.measurement,
|
||||||
|
diagnostic = "measurement",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
fun stopScan() {
|
||||||
|
val callback = scannerCallback ?: return
|
||||||
|
scanStop?.let(main::removeCallbacks)
|
||||||
|
scanStop = null
|
||||||
|
runCatching { adapter?.bluetoothLeScanner?.stopScan(callback) }
|
||||||
|
scannerCallback = null
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
private fun closeGatt() {
|
||||||
|
val current = gatt
|
||||||
|
gatt = null
|
||||||
|
runCatching { current?.disconnect() }
|
||||||
|
runCatching { current?.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
if (closed) return
|
||||||
|
stopScan()
|
||||||
|
closeGatt()
|
||||||
|
closed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val SCAN_DURATION_MS = 10_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
|
import java.util.concurrent.CopyOnWriteArraySet
|
||||||
|
|
||||||
|
internal enum class HeartRateLivePhase {
|
||||||
|
DISCONNECTED,
|
||||||
|
CONNECTING,
|
||||||
|
CONNECTED,
|
||||||
|
STALE,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class HeartRateLiveSnapshot(
|
||||||
|
val phase: HeartRateLivePhase = HeartRateLivePhase.DISCONNECTED,
|
||||||
|
val bpm: Int? = null,
|
||||||
|
val rrIntervals1024: List<Int> = emptyList(),
|
||||||
|
val sensorContactDetected: Boolean? = null,
|
||||||
|
val energyExpended: Int? = null,
|
||||||
|
val notificationCount: Long = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal object HeartRateLiveState {
|
||||||
|
private val listeners =
|
||||||
|
CopyOnWriteArraySet<(HeartRateLiveSnapshot) -> Unit>()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var snapshot = HeartRateLiveSnapshot()
|
||||||
|
|
||||||
|
fun current(): HeartRateLiveSnapshot = snapshot
|
||||||
|
|
||||||
|
fun publish(next: HeartRateLiveSnapshot) {
|
||||||
|
snapshot = next
|
||||||
|
listeners.forEach { it(next) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun subscribe(listener: (HeartRateLiveSnapshot) -> Unit): AutoCloseable {
|
||||||
|
listeners += listener
|
||||||
|
listener(snapshot)
|
||||||
|
return AutoCloseable { listeners -= listener }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnected() {
|
||||||
|
publish(HeartRateLiveSnapshot(phase = HeartRateLivePhase.DISCONNECTED))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun connecting() {
|
||||||
|
publish(
|
||||||
|
snapshot.copy(
|
||||||
|
phase = HeartRateLivePhase.CONNECTING,
|
||||||
|
bpm = null,
|
||||||
|
rrIntervals1024 = emptyList(),
|
||||||
|
sensorContactDetected = null,
|
||||||
|
energyExpended = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun measurement(measurement: ParsedHeartRateMeasurement) {
|
||||||
|
publish(
|
||||||
|
HeartRateLiveSnapshot(
|
||||||
|
phase = HeartRateLivePhase.CONNECTED,
|
||||||
|
bpm = measurement.bpm,
|
||||||
|
rrIntervals1024 = measurement.rrIntervals1024,
|
||||||
|
sensorContactDetected = measurement.sensorContactDetected,
|
||||||
|
energyExpended = measurement.energyExpended,
|
||||||
|
notificationCount = snapshot.notificationCount + 1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stale() {
|
||||||
|
if (snapshot.phase == HeartRateLivePhase.CONNECTED) {
|
||||||
|
publish(
|
||||||
|
snapshot.copy(
|
||||||
|
phase = HeartRateLivePhase.STALE,
|
||||||
|
bpm = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
/*
|
||||||
|
* Bluetooth Heart Rate Measurement parser.
|
||||||
|
*
|
||||||
|
* This file deliberately has no Android Bluetooth dependency so protocol
|
||||||
|
* decoding can be exhaustively unit-tested independently of GATT transport.
|
||||||
|
*/
|
||||||
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
|
internal data class ParsedHeartRateMeasurement(
|
||||||
|
val bpm: Int,
|
||||||
|
val sensorContactDetected: Boolean?,
|
||||||
|
val energyExpended: Int?,
|
||||||
|
/** Raw Bluetooth Heart Rate Service units: 1/1024 second. */
|
||||||
|
val rrIntervals1024: List<Int>,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal sealed interface HeartRateMeasurementParseResult {
|
||||||
|
data class Parsed(val measurement: ParsedHeartRateMeasurement) :
|
||||||
|
HeartRateMeasurementParseResult
|
||||||
|
data class Invalid(val reason: String) : HeartRateMeasurementParseResult
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object HeartRateMeasurementParser {
|
||||||
|
private const val FLAG_HEART_RATE_16_BIT = 1 shl 0
|
||||||
|
private const val FLAG_CONTACT_DETECTED = 1 shl 1
|
||||||
|
private const val FLAG_CONTACT_SUPPORTED = 1 shl 2
|
||||||
|
private const val FLAG_ENERGY_PRESENT = 1 shl 3
|
||||||
|
private const val FLAG_RR_PRESENT = 1 shl 4
|
||||||
|
private const val KNOWN_FLAGS =
|
||||||
|
FLAG_HEART_RATE_16_BIT or FLAG_CONTACT_DETECTED or FLAG_CONTACT_SUPPORTED or
|
||||||
|
FLAG_ENERGY_PRESENT or FLAG_RR_PRESENT
|
||||||
|
|
||||||
|
fun parse(payload: ByteArray): HeartRateMeasurementParseResult {
|
||||||
|
if (payload.isEmpty()) return HeartRateMeasurementParseResult.Invalid("missing flags")
|
||||||
|
val flags = payload[0].toInt() and 0xff
|
||||||
|
if ((flags and KNOWN_FLAGS.inv()) != 0) {
|
||||||
|
return HeartRateMeasurementParseResult.Invalid("reserved flags are set")
|
||||||
|
}
|
||||||
|
|
||||||
|
var offset = 1
|
||||||
|
fun uint8(): Int? =
|
||||||
|
payload.getOrNull(offset)?.let {
|
||||||
|
offset += 1
|
||||||
|
it.toInt() and 0xff
|
||||||
|
}
|
||||||
|
fun uint16(): Int? {
|
||||||
|
if (offset + 2 > payload.size) return null
|
||||||
|
val value =
|
||||||
|
(payload[offset].toInt() and 0xff) or
|
||||||
|
((payload[offset + 1].toInt() and 0xff) shl 8)
|
||||||
|
offset += 2
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
val bpm =
|
||||||
|
if ((flags and FLAG_HEART_RATE_16_BIT) != 0) {
|
||||||
|
uint16() ?: return HeartRateMeasurementParseResult.Invalid("truncated 16-bit BPM")
|
||||||
|
} else {
|
||||||
|
uint8() ?: return HeartRateMeasurementParseResult.Invalid("missing BPM")
|
||||||
|
}
|
||||||
|
|
||||||
|
val contact =
|
||||||
|
if ((flags and FLAG_CONTACT_SUPPORTED) == 0) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
(flags and FLAG_CONTACT_DETECTED) != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
val energy =
|
||||||
|
if ((flags and FLAG_ENERGY_PRESENT) != 0) {
|
||||||
|
uint16()
|
||||||
|
?: return HeartRateMeasurementParseResult.Invalid(
|
||||||
|
"truncated energy-expended value",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
val rr = mutableListOf<Int>()
|
||||||
|
if ((flags and FLAG_RR_PRESENT) != 0) {
|
||||||
|
if (offset == payload.size) {
|
||||||
|
return HeartRateMeasurementParseResult.Invalid("RR flag has no interval")
|
||||||
|
}
|
||||||
|
if ((payload.size - offset) % 2 != 0) {
|
||||||
|
return HeartRateMeasurementParseResult.Invalid("truncated RR interval")
|
||||||
|
}
|
||||||
|
while (offset < payload.size) {
|
||||||
|
rr += checkNotNull(uint16())
|
||||||
|
}
|
||||||
|
} else if (offset != payload.size) {
|
||||||
|
return HeartRateMeasurementParseResult.Invalid("unexpected trailing bytes")
|
||||||
|
}
|
||||||
|
|
||||||
|
return HeartRateMeasurementParseResult.Parsed(
|
||||||
|
ParsedHeartRateMeasurement(
|
||||||
|
bpm = bpm,
|
||||||
|
sensorContactDetected = contact,
|
||||||
|
energyExpended = energy,
|
||||||
|
rrIntervals1024 = rr,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
|
internal data class SelectedHeartRateSensor(
|
||||||
|
val address: String,
|
||||||
|
val name: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal class HeartRateSensorPreferences(context: Context) {
|
||||||
|
private val preferences =
|
||||||
|
context.applicationContext.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
fun selected(): SelectedHeartRateSensor? {
|
||||||
|
if (!preferences.getBoolean(ENABLED, false)) return null
|
||||||
|
val address = preferences.getString(ADDRESS, null)?.takeIf { it.isNotBlank() } ?: return null
|
||||||
|
return SelectedHeartRateSensor(
|
||||||
|
address = address,
|
||||||
|
name = preferences.getString(NAME, null)?.takeIf { it.isNotBlank() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun select(address: String, name: String?) {
|
||||||
|
require(address.isNotBlank())
|
||||||
|
preferences.edit()
|
||||||
|
.putBoolean(ENABLED, true)
|
||||||
|
.putString(ADDRESS, address)
|
||||||
|
.putString(NAME, name?.takeIf { it.isNotBlank() })
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
preferences.edit()
|
||||||
|
.putBoolean(ENABLED, false)
|
||||||
|
.remove(ADDRESS)
|
||||||
|
.remove(NAME)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val PREFERENCES = "trainlog-heart-rate-sensor"
|
||||||
|
private const val ENABLED = "enabled"
|
||||||
|
private const val ADDRESS = "address"
|
||||||
|
private const val NAME = "name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,561 @@
|
||||||
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.Service
|
||||||
|
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.bluetooth.BluetoothStatusCodes
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.os.Looper
|
||||||
|
import android.os.SystemClock
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.labfytools.trainlog.R
|
||||||
|
import com.labfytools.trainlog.model.CardioGuidanceInstruction
|
||||||
|
import java.time.Duration
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
|
class HeartRateSensorService : Service() {
|
||||||
|
private val main = Handler(Looper.getMainLooper())
|
||||||
|
private val preferences by lazy { HeartRateSensorPreferences(applicationContext) }
|
||||||
|
private val bluetoothManager by lazy {
|
||||||
|
applicationContext.getSystemService(BluetoothManager::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var selected: SelectedHeartRateSensor? = null
|
||||||
|
private var gatt: BluetoothGatt? = null
|
||||||
|
private var reconnectAttempt = 0
|
||||||
|
private var lastMeasurementElapsed = 0L
|
||||||
|
private var lastNotificationBpm: Int? = null
|
||||||
|
private var repository: TrainlogRepository? = null
|
||||||
|
private var guidancePhaseId: String? = null
|
||||||
|
private var guidanceEngine: CardioGuidanceEngine? = null
|
||||||
|
|
||||||
|
private fun repository(): TrainlogRepository =
|
||||||
|
repository ?: TrainlogRepository(applicationContext).also { repository = it }
|
||||||
|
|
||||||
|
private val reconnectRunnable = Runnable { connectSelected() }
|
||||||
|
private val connectionTimeoutRunnable =
|
||||||
|
Runnable {
|
||||||
|
if (HeartRateLiveState.current().phase == HeartRateLivePhase.CONNECTING) {
|
||||||
|
closeGatt()
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private val staleRunnable =
|
||||||
|
object : Runnable {
|
||||||
|
override fun run() {
|
||||||
|
val last = lastMeasurementElapsed
|
||||||
|
if (last > 0L) {
|
||||||
|
val age = SystemClock.elapsedRealtime() - last
|
||||||
|
if (
|
||||||
|
age >= STALE_AFTER_MS &&
|
||||||
|
HeartRateLiveState.current().phase == HeartRateLivePhase.CONNECTED
|
||||||
|
) {
|
||||||
|
HeartRateLiveState.stale()
|
||||||
|
suspendCardioGuidance()
|
||||||
|
updateForegroundNotification()
|
||||||
|
}
|
||||||
|
if (age >= STALE_RECONNECT_AFTER_MS && gatt != null) {
|
||||||
|
lastMeasurementElapsed = 0L
|
||||||
|
closeGatt()
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
main.postDelayed(this, STALE_CHECK_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
createChannel()
|
||||||
|
promoteForeground()
|
||||||
|
main.post(staleRunnable)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
if (intent?.action == ACTION_STOP) {
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
selected = preferences.selected()
|
||||||
|
if (selected == null || !hasHeartRateBleRuntimePermissions(this)) {
|
||||||
|
HeartRateLiveState.disconnected()
|
||||||
|
updateForegroundNotification()
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
if (bluetoothManager.adapter?.isEnabled != true) {
|
||||||
|
scheduleReconnect()
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
promoteForeground()
|
||||||
|
if (gatt == null) connectSelected()
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private fun connectSelected() {
|
||||||
|
main.removeCallbacks(reconnectRunnable)
|
||||||
|
main.removeCallbacks(connectionTimeoutRunnable)
|
||||||
|
|
||||||
|
val sensor = preferences.selected()
|
||||||
|
selected = sensor
|
||||||
|
val adapter = bluetoothManager.adapter
|
||||||
|
if (sensor == null || !hasHeartRateBleRuntimePermissions(this)) {
|
||||||
|
HeartRateLiveState.disconnected()
|
||||||
|
updateForegroundNotification()
|
||||||
|
stopSelf()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (adapter?.isEnabled != true) {
|
||||||
|
scheduleReconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
closeGatt()
|
||||||
|
lastMeasurementElapsed = 0L
|
||||||
|
HeartRateLiveState.connecting()
|
||||||
|
updateForegroundNotification()
|
||||||
|
|
||||||
|
val device =
|
||||||
|
runCatching { adapter.getRemoteDevice(sensor.address) }.getOrNull()
|
||||||
|
?: run {
|
||||||
|
HeartRateLiveState.disconnected()
|
||||||
|
scheduleReconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
device.connectGatt(
|
||||||
|
applicationContext,
|
||||||
|
false,
|
||||||
|
gattCallback,
|
||||||
|
BluetoothDevice.TRANSPORT_LE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onSuccess {
|
||||||
|
gatt = it
|
||||||
|
main.postDelayed(connectionTimeoutRunnable, CONNECTION_TIMEOUT_MS)
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val gattCallback =
|
||||||
|
object : BluetoothGattCallback() {
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
override fun onConnectionStateChange(
|
||||||
|
callbackGatt: BluetoothGatt,
|
||||||
|
status: Int,
|
||||||
|
newState: Int,
|
||||||
|
) {
|
||||||
|
main.post {
|
||||||
|
if (callbackGatt !== gatt) return@post
|
||||||
|
if (
|
||||||
|
status == BluetoothGatt.GATT_SUCCESS &&
|
||||||
|
newState == BluetoothProfile.STATE_CONNECTED
|
||||||
|
) {
|
||||||
|
reconnectAttempt = 0
|
||||||
|
main.removeCallbacks(connectionTimeoutRunnable)
|
||||||
|
HeartRateLiveState.connecting()
|
||||||
|
updateForegroundNotification()
|
||||||
|
if (!callbackGatt.discoverServices()) {
|
||||||
|
failConnection()
|
||||||
|
} else {
|
||||||
|
main.postDelayed(connectionTimeoutRunnable, CONNECTION_TIMEOUT_MS)
|
||||||
|
}
|
||||||
|
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
|
||||||
|
failConnection()
|
||||||
|
} else if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||||
|
failConnection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
override fun onServicesDiscovered(callbackGatt: BluetoothGatt, status: Int) {
|
||||||
|
main.post {
|
||||||
|
if (callbackGatt !== gatt) return@post
|
||||||
|
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||||
|
failConnection()
|
||||||
|
return@post
|
||||||
|
}
|
||||||
|
val measurement =
|
||||||
|
callbackGatt
|
||||||
|
.getService(HEART_RATE_SERVICE_UUID)
|
||||||
|
?.getCharacteristic(HEART_RATE_MEASUREMENT_UUID)
|
||||||
|
if (measurement == null) {
|
||||||
|
failConnection()
|
||||||
|
return@post
|
||||||
|
}
|
||||||
|
main.removeCallbacks(connectionTimeoutRunnable)
|
||||||
|
if (!subscribe(callbackGatt, measurement)) {
|
||||||
|
failConnection()
|
||||||
|
} else {
|
||||||
|
main.postDelayed(connectionTimeoutRunnable, CONNECTION_TIMEOUT_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDescriptorWrite(
|
||||||
|
callbackGatt: BluetoothGatt,
|
||||||
|
descriptor: BluetoothGattDescriptor,
|
||||||
|
status: Int,
|
||||||
|
) {
|
||||||
|
if (descriptor.uuid != CLIENT_CHARACTERISTIC_CONFIG_UUID) return
|
||||||
|
main.post {
|
||||||
|
if (callbackGatt !== gatt) return@post
|
||||||
|
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||||
|
failConnection()
|
||||||
|
} else {
|
||||||
|
main.removeCallbacks(connectionTimeoutRunnable)
|
||||||
|
HeartRateLiveState.connecting()
|
||||||
|
updateForegroundNotification()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated("Deprecated by Android API 33 callback with explicit value")
|
||||||
|
override fun onCharacteristicChanged(
|
||||||
|
callbackGatt: BluetoothGatt,
|
||||||
|
characteristic: BluetoothGattCharacteristic,
|
||||||
|
) {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val value = characteristic.value ?: return
|
||||||
|
onHeartRateNotification(callbackGatt, characteristic.uuid, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCharacteristicChanged(
|
||||||
|
callbackGatt: BluetoothGatt,
|
||||||
|
characteristic: BluetoothGattCharacteristic,
|
||||||
|
value: ByteArray,
|
||||||
|
) {
|
||||||
|
onHeartRateNotification(callbackGatt, characteristic.uuid, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
private fun subscribe(
|
||||||
|
callbackGatt: BluetoothGatt,
|
||||||
|
measurement: BluetoothGattCharacteristic,
|
||||||
|
): Boolean {
|
||||||
|
if (!callbackGatt.setCharacteristicNotification(measurement, true)) return false
|
||||||
|
val descriptor = measurement.getDescriptor(CLIENT_CHARACTERISTIC_CONFIG_UUID) ?: return false
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
callbackGatt.writeDescriptor(
|
||||||
|
descriptor,
|
||||||
|
BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE,
|
||||||
|
) == BluetoothStatusCodes.SUCCESS
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
run {
|
||||||
|
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||||
|
callbackGatt.writeDescriptor(descriptor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onHeartRateNotification(
|
||||||
|
callbackGatt: BluetoothGatt,
|
||||||
|
uuid: java.util.UUID,
|
||||||
|
value: ByteArray,
|
||||||
|
) {
|
||||||
|
if (callbackGatt !== gatt || uuid != HEART_RATE_MEASUREMENT_UUID) return
|
||||||
|
val parsed = HeartRateMeasurementParser.parse(value)
|
||||||
|
if (parsed !is HeartRateMeasurementParseResult.Parsed) return
|
||||||
|
lastMeasurementElapsed = SystemClock.elapsedRealtime()
|
||||||
|
HeartRateLiveState.measurement(parsed.measurement)
|
||||||
|
val observedAt = OffsetDateTime.now().toString()
|
||||||
|
val repository = repository()
|
||||||
|
val sessionRecorded =
|
||||||
|
repository.recordLiveHeartRateForActiveSession(
|
||||||
|
sensorName = selected?.name,
|
||||||
|
observedAt = observedAt,
|
||||||
|
measurement = parsed.measurement,
|
||||||
|
)
|
||||||
|
if (sessionRecorded != null) {
|
||||||
|
processCardioGuidance(repository, observedAt, parsed.measurement.bpm)
|
||||||
|
} else {
|
||||||
|
repository.recordLiveHeartRateForActiveSleep(
|
||||||
|
sensorName = selected?.name,
|
||||||
|
observedAt = observedAt,
|
||||||
|
measurement = parsed.measurement,
|
||||||
|
)
|
||||||
|
clearCardioGuidanceEngine()
|
||||||
|
}
|
||||||
|
if (lastNotificationBpm != parsed.measurement.bpm) {
|
||||||
|
lastNotificationBpm = parsed.measurement.bpm
|
||||||
|
updateForegroundNotification()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private fun processCardioGuidance(
|
||||||
|
repository: TrainlogRepository,
|
||||||
|
observedAt: String,
|
||||||
|
bpm: Int,
|
||||||
|
) {
|
||||||
|
val phase = repository.activeCardioGuidancePhase()
|
||||||
|
if (phase == null) {
|
||||||
|
clearCardioGuidanceEngine()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (guidancePhaseId != phase.phase.phaseId || guidanceEngine == null) {
|
||||||
|
guidancePhaseId = phase.phase.phaseId
|
||||||
|
guidanceEngine =
|
||||||
|
CardioGuidanceEngine(initialInstruction = phase.currentInstruction)
|
||||||
|
}
|
||||||
|
val engine = checkNotNull(guidanceEngine)
|
||||||
|
val output =
|
||||||
|
engine.evaluate(
|
||||||
|
target = phase.phase.target,
|
||||||
|
bpm = bpm,
|
||||||
|
fresh = true,
|
||||||
|
nowMs = SystemClock.elapsedRealtime(),
|
||||||
|
)
|
||||||
|
repository.recordCardioGuidanceOutput(
|
||||||
|
phase.phase.phaseId,
|
||||||
|
observedAt,
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
CardioGuidanceLiveState.publish(
|
||||||
|
CardioGuidanceLiveSnapshot(
|
||||||
|
phaseId = phase.phase.phaseId,
|
||||||
|
phaseKind = phase.phase.kind,
|
||||||
|
instruction = output.instruction,
|
||||||
|
bpm = output.bpm,
|
||||||
|
target = phase.phase.target,
|
||||||
|
startedAt = phase.startedAt,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
maybeFinishCardioGuidancePhase(repository, phase, observedAt, bpm, fresh = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun suspendCardioGuidance() {
|
||||||
|
val repository = repository ?: return
|
||||||
|
val phase = repository.activeCardioGuidancePhase() ?: run {
|
||||||
|
clearCardioGuidanceEngine()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (guidancePhaseId != phase.phase.phaseId || guidanceEngine == null) {
|
||||||
|
guidancePhaseId = phase.phase.phaseId
|
||||||
|
guidanceEngine =
|
||||||
|
CardioGuidanceEngine(initialInstruction = phase.currentInstruction)
|
||||||
|
}
|
||||||
|
val output =
|
||||||
|
checkNotNull(guidanceEngine).evaluate(
|
||||||
|
target = phase.phase.target,
|
||||||
|
bpm = null,
|
||||||
|
fresh = false,
|
||||||
|
nowMs = SystemClock.elapsedRealtime(),
|
||||||
|
)
|
||||||
|
val observedAt = OffsetDateTime.now().toString()
|
||||||
|
repository.recordCardioGuidanceOutput(
|
||||||
|
phase.phase.phaseId,
|
||||||
|
observedAt,
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
CardioGuidanceLiveState.publish(
|
||||||
|
CardioGuidanceLiveSnapshot(
|
||||||
|
phaseId = phase.phase.phaseId,
|
||||||
|
phaseKind = phase.phase.kind,
|
||||||
|
instruction = CardioGuidanceInstruction.SUSPENDED,
|
||||||
|
bpm = null,
|
||||||
|
target = phase.phase.target,
|
||||||
|
startedAt = phase.startedAt,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
maybeFinishCardioGuidancePhase(repository, phase, observedAt, null, fresh = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun maybeFinishCardioGuidancePhase(
|
||||||
|
repository: TrainlogRepository,
|
||||||
|
phase: com.labfytools.trainlog.model.CardioGuidancePhaseRecord,
|
||||||
|
observedAt: String,
|
||||||
|
bpm: Int?,
|
||||||
|
fresh: Boolean,
|
||||||
|
) {
|
||||||
|
val elapsedSeconds =
|
||||||
|
runCatching {
|
||||||
|
Duration.between(
|
||||||
|
OffsetDateTime.parse(phase.startedAt),
|
||||||
|
OffsetDateTime.parse(observedAt),
|
||||||
|
).seconds
|
||||||
|
}
|
||||||
|
.getOrNull()
|
||||||
|
?.coerceIn(0L, Int.MAX_VALUE.toLong())
|
||||||
|
?.toInt()
|
||||||
|
?: return
|
||||||
|
if (
|
||||||
|
CardioPhaseCompletion.isComplete(
|
||||||
|
phase.phase,
|
||||||
|
elapsedSeconds,
|
||||||
|
bpm,
|
||||||
|
fresh,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
repository.finishCardioGuidancePhase(phase.phase.phaseId, observedAt)
|
||||||
|
clearCardioGuidanceEngine()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearCardioGuidanceEngine() {
|
||||||
|
guidancePhaseId = null
|
||||||
|
guidanceEngine = null
|
||||||
|
CardioGuidanceLiveState.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun failConnection() {
|
||||||
|
main.removeCallbacks(connectionTimeoutRunnable)
|
||||||
|
closeGatt()
|
||||||
|
lastMeasurementElapsed = 0L
|
||||||
|
lastNotificationBpm = null
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleReconnect() {
|
||||||
|
if (preferences.selected() == null) {
|
||||||
|
HeartRateLiveState.disconnected()
|
||||||
|
updateForegroundNotification()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
HeartRateLiveState.connecting()
|
||||||
|
updateForegroundNotification()
|
||||||
|
reconnectAttempt = (reconnectAttempt + 1).coerceAtMost(6)
|
||||||
|
val delay =
|
||||||
|
(RECONNECT_BASE_MS * reconnectAttempt.toLong()).coerceAtMost(RECONNECT_MAX_MS)
|
||||||
|
main.removeCallbacks(reconnectRunnable)
|
||||||
|
main.postDelayed(reconnectRunnable, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
private fun closeGatt() {
|
||||||
|
val current = gatt
|
||||||
|
gatt = null
|
||||||
|
runCatching { current?.disconnect() }
|
||||||
|
runCatching { current?.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
main.removeCallbacks(reconnectRunnable)
|
||||||
|
main.removeCallbacks(connectionTimeoutRunnable)
|
||||||
|
main.removeCallbacks(staleRunnable)
|
||||||
|
closeGatt()
|
||||||
|
repository?.close()
|
||||||
|
repository = null
|
||||||
|
HeartRateLiveState.disconnected()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
private fun createChannel() {
|
||||||
|
getSystemService(NotificationManager::class.java)
|
||||||
|
.createNotificationChannel(
|
||||||
|
NotificationChannel(
|
||||||
|
CHANNEL,
|
||||||
|
getString(R.string.heart_rate_service_channel),
|
||||||
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun promoteForeground() {
|
||||||
|
val notification = buildNotification()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
startForeground(
|
||||||
|
NOTIFICATION_ID,
|
||||||
|
notification,
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateForegroundNotification() {
|
||||||
|
runCatching {
|
||||||
|
getSystemService(NotificationManager::class.java)
|
||||||
|
.notify(NOTIFICATION_ID, buildNotification())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildNotification(): android.app.Notification {
|
||||||
|
val live = HeartRateLiveState.current()
|
||||||
|
val text =
|
||||||
|
when (live.phase) {
|
||||||
|
HeartRateLivePhase.CONNECTED ->
|
||||||
|
live.bpm?.let { getString(R.string.heart_rate_service_bpm, it) }
|
||||||
|
?: getString(R.string.heart_rate_service_connecting)
|
||||||
|
HeartRateLivePhase.CONNECTING ->
|
||||||
|
getString(R.string.heart_rate_service_connecting)
|
||||||
|
HeartRateLivePhase.STALE ->
|
||||||
|
getString(R.string.heart_rate_service_reconnecting)
|
||||||
|
HeartRateLivePhase.DISCONNECTED ->
|
||||||
|
getString(R.string.heart_rate_service_disconnected)
|
||||||
|
}
|
||||||
|
return NotificationCompat.Builder(this, CHANNEL)
|
||||||
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
|
.setContentTitle(getString(R.string.heart_rate_service_title))
|
||||||
|
.setContentText(text)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val CHANNEL = "trainlog_heart_rate"
|
||||||
|
private const val NOTIFICATION_ID = 1402
|
||||||
|
private const val ACTION_START = "com.labfytools.trainlog.heart_rate.START"
|
||||||
|
private const val ACTION_STOP = "com.labfytools.trainlog.heart_rate.STOP"
|
||||||
|
private const val CONNECTION_TIMEOUT_MS = 12_000L
|
||||||
|
private const val STALE_AFTER_MS = 3_000L
|
||||||
|
private const val STALE_RECONNECT_AFTER_MS = 8_000L
|
||||||
|
private const val STALE_CHECK_MS = 1_000L
|
||||||
|
private const val RECONNECT_BASE_MS = 2_000L
|
||||||
|
private const val RECONNECT_MAX_MS = 15_000L
|
||||||
|
|
||||||
|
fun start(context: Context) {
|
||||||
|
if (
|
||||||
|
HeartRateSensorPreferences(context).selected() == null ||
|
||||||
|
!hasHeartRateBleRuntimePermissions(context)
|
||||||
|
) {
|
||||||
|
HeartRateLiveState.disconnected()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ContextCompat.startForegroundService(
|
||||||
|
context,
|
||||||
|
Intent(context, HeartRateSensorService::class.java).setAction(ACTION_START),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(context: Context) {
|
||||||
|
context.startService(
|
||||||
|
Intent(context, HeartRateSensorService::class.java).setAction(ACTION_STOP),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun forget(context: Context) {
|
||||||
|
HeartRateSensorPreferences(context).clear()
|
||||||
|
stop(context)
|
||||||
|
HeartRateLiveState.disconnected()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -54,6 +54,11 @@ internal class SyncGenerationCoordinator(
|
||||||
) {
|
) {
|
||||||
private val service = SyncGenerationService(repository)
|
private val service = SyncGenerationService(repository)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val ACK_RECOVERY_CAPABILITY = "generation-ack-recovery-v1"
|
||||||
|
const val MAX_COORDINATION_BYTES = 65_536
|
||||||
|
}
|
||||||
|
|
||||||
private fun phase(
|
private fun phase(
|
||||||
runId: String?,
|
runId: String?,
|
||||||
phase: String,
|
phase: String,
|
||||||
|
|
@ -196,6 +201,45 @@ internal class SyncGenerationCoordinator(
|
||||||
if (actual != expected) throw SyncGenerationException("invalid $label fields")
|
if (actual != expected) throw SyncGenerationException("invalid $label fields")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun requestSupports(request: JSONObject, capability: String): Boolean {
|
||||||
|
val capabilities = request.optJSONArray("capabilities") ?: return false
|
||||||
|
for (index in 0 until capabilities.length()) {
|
||||||
|
if (capabilities.optString(index) == capability) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recoveryAcknowledgements(
|
||||||
|
desktopPeerId: String,
|
||||||
|
androidPeerId: String,
|
||||||
|
): JSONArray =
|
||||||
|
repository.inSyncGenerationTransaction { db ->
|
||||||
|
val acknowledgements = JSONArray()
|
||||||
|
db.rawQuery(
|
||||||
|
"SELECT ack_json FROM sync_consumed_generations WHERE producer_peer_id=? " +
|
||||||
|
"AND consumer_peer_id=? AND result IN('consumed','rejected') " +
|
||||||
|
"ORDER BY consumed_at DESC,generation_id DESC LIMIT 32",
|
||||||
|
arrayOf(desktopPeerId, androidPeerId),
|
||||||
|
)
|
||||||
|
.use { cursor ->
|
||||||
|
while (cursor.moveToNext()) {
|
||||||
|
val acknowledgement = JSONObject(cursor.getString(0))
|
||||||
|
/* ACKs written before cross-runtime rejection diagnostics
|
||||||
|
* were normalized can hash '/' differently in Android
|
||||||
|
* and Python JSON canonicalizers. They remain immutable
|
||||||
|
* evidence, but must not block recovery of newer,
|
||||||
|
* compatible ACKs. Consumed ACKs are unaffected. */
|
||||||
|
if (
|
||||||
|
acknowledgement.optString("result") != "rejected" ||
|
||||||
|
!acknowledgement.optString("diagnostic").contains("/")
|
||||||
|
) {
|
||||||
|
acknowledgements.put(acknowledgement)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
acknowledgements
|
||||||
|
}
|
||||||
|
|
||||||
private fun reconcileArchiveAcknowledgements(
|
private fun reconcileArchiveAcknowledgements(
|
||||||
raw: ByteArray,
|
raw: ByteArray,
|
||||||
runId: String,
|
runId: String,
|
||||||
|
|
@ -299,6 +343,7 @@ internal class SyncGenerationCoordinator(
|
||||||
"mobile-history-v4",
|
"mobile-history-v4",
|
||||||
"execution-draft-v1",
|
"execution-draft-v1",
|
||||||
"generation-archive-v1",
|
"generation-archive-v1",
|
||||||
|
ACK_RECOVERY_CAPABILITY,
|
||||||
"session-preparations-v2",
|
"session-preparations-v2",
|
||||||
"programs-v1",
|
"programs-v1",
|
||||||
)
|
)
|
||||||
|
|
@ -359,6 +404,7 @@ internal class SyncGenerationCoordinator(
|
||||||
directory: File,
|
directory: File,
|
||||||
timeout: Duration = Duration.ofMinutes(5),
|
timeout: Duration = Duration.ofMinutes(5),
|
||||||
afterPeerPublication: (() -> Unit)? = null,
|
afterPeerPublication: (() -> Unit)? = null,
|
||||||
|
afterRecoveryAcknowledgements: (() -> Unit)? = null,
|
||||||
afterPublication: (() -> Unit)? = null,
|
afterPublication: (() -> Unit)? = null,
|
||||||
pollTransport: (() -> Unit)? = null,
|
pollTransport: (() -> Unit)? = null,
|
||||||
afterAcknowledgement: (() -> Unit)? = null,
|
afterAcknowledgement: (() -> Unit)? = null,
|
||||||
|
|
@ -433,6 +479,44 @@ internal class SyncGenerationCoordinator(
|
||||||
request.getString("desktop_peer_id"),
|
request.getString("desktop_peer_id"),
|
||||||
)
|
)
|
||||||
phase(runId, "archive_ack_observed", "desktop-archive-acknowledgements-v1.json")
|
phase(runId, "archive_ack_observed", "desktop-archive-acknowledgements-v1.json")
|
||||||
|
if (requestSupports(request, ACK_RECOVERY_CAPABILITY)) {
|
||||||
|
/* WHY: Android may commit consumption after the desktop
|
||||||
|
* producer has already published its immutable generation, then
|
||||||
|
* transport can fail before the ACK reaches that producer.
|
||||||
|
* CONTRACT: return only exact durable consumer ACK evidence,
|
||||||
|
* bounded to the newest 32 rows, before either side captures a
|
||||||
|
* new generation. INVARIANT: recovery never fabricates lineage
|
||||||
|
* and never rewrites Android consumption history. */
|
||||||
|
val recoveryPath =
|
||||||
|
File(directory, "android-archive-acknowledgements-v1.json")
|
||||||
|
val recoveryEnvelope =
|
||||||
|
JSONObject()
|
||||||
|
.put("format", "trainlog-sync-archive-acknowledgements")
|
||||||
|
.put("version", 1)
|
||||||
|
.put("run_id", runId)
|
||||||
|
.put("android_peer_id", peer)
|
||||||
|
.put("desktop_peer_id", request.getString("desktop_peer_id"))
|
||||||
|
.put(
|
||||||
|
"acknowledgements",
|
||||||
|
recoveryAcknowledgements(
|
||||||
|
request.getString("desktop_peer_id"),
|
||||||
|
peer,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toString()
|
||||||
|
if (recoveryEnvelope.toByteArray(Charsets.UTF_8).size > MAX_COORDINATION_BYTES)
|
||||||
|
throw SyncGenerationException(
|
||||||
|
"Android archive acknowledgement envelope exceeds bound"
|
||||||
|
)
|
||||||
|
publish(recoveryPath, recoveryEnvelope)
|
||||||
|
visibility.confirm(listOf(recoveryPath))
|
||||||
|
afterRecoveryAcknowledgements?.invoke()
|
||||||
|
phase(
|
||||||
|
runId,
|
||||||
|
"android_archive_ack_published",
|
||||||
|
recoveryPath.name,
|
||||||
|
)
|
||||||
|
}
|
||||||
/* WHY: transport can fail after immutable generation publication.
|
/* WHY: transport can fail after immutable generation publication.
|
||||||
* CONTRACT: an explicit retry for the same run republishes that
|
* CONTRACT: an explicit retry for the same run republishes that
|
||||||
* exact generation and resumes ACK handling; it never recaptures
|
* exact generation and resumes ACK handling; it never recaptures
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,8 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
||||||
linkedMapOf(
|
linkedMapOf(
|
||||||
"catalog" to Kind("trainlog-pc-catalog", 1, "catalog-v1.json"),
|
"catalog" to Kind("trainlog-pc-catalog", 1, "catalog-v1.json"),
|
||||||
"history" to Kind("trainlog-mobile-export", 4, "history-v4.json"),
|
"history" to Kind("trainlog-mobile-export", 4, "history-v4.json"),
|
||||||
|
"cardio-sessions" to
|
||||||
|
Kind("trainlog-cardio-sessions", 1, "cardio-sessions-v1.json", false),
|
||||||
"execution-drafts" to
|
"execution-drafts" to
|
||||||
Kind("trainlog-execution-drafts", 1, "execution-drafts-v1.json"),
|
Kind("trainlog-execution-drafts", 1, "execution-drafts-v1.json"),
|
||||||
"exercise-aliases" to
|
"exercise-aliases" to
|
||||||
|
|
@ -68,7 +70,14 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
||||||
Kind("trainlog-causal-deletions", 1, "causal-deletions-v1.json"),
|
Kind("trainlog-causal-deletions", 1, "causal-deletions-v1.json"),
|
||||||
"program-executions" to
|
"program-executions" to
|
||||||
Kind("trainlog-program-executions", 1, "program-executions-v1.json", false),
|
Kind("trainlog-program-executions", 1, "program-executions-v1.json", false),
|
||||||
"sleep-diary" to Kind("trainlog-sleep-diary", 1, "sleep-diary-v1.json", false),
|
"sleep-diary" to Kind("trainlog-sleep-diary", 2, "sleep-diary-v2.json", false),
|
||||||
|
"heart-rate" to Kind("trainlog-heart-rate", 1, "heart-rate-v1.json", false),
|
||||||
|
"cardio-calibrations" to
|
||||||
|
Kind("trainlog-cardio-calibrations", 1, "cardio-calibrations-v1.json", false),
|
||||||
|
"cardio-guidance" to
|
||||||
|
Kind("trainlog-cardio-guidance", 1, "cardio-guidance-v1.json", false),
|
||||||
|
"session-timeline" to
|
||||||
|
Kind("trainlog-session-timeline", 1, "session-timeline-v1.json", false),
|
||||||
)
|
)
|
||||||
private val SUPPORTED =
|
private val SUPPORTED =
|
||||||
(ANDROID_KINDS.values +
|
(ANDROID_KINDS.values +
|
||||||
|
|
@ -422,6 +431,48 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
||||||
arrayOf(generationId, entry.getString("entry_id"), entry.getString("revision_id")),
|
arrayOf(generationId, entry.getString("entry_id"), entry.getString("revision_id")),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
val heartRate = JSONObject(checkNotNull(artifacts["heart-rate"]))
|
||||||
|
val captures = heartRate.getJSONArray("captures")
|
||||||
|
for (index in 0 until captures.length()) {
|
||||||
|
db.execSQL(
|
||||||
|
"INSERT INTO heart_rate_generation_captures(generation_id,capture_id) VALUES(?,?)",
|
||||||
|
arrayOf(generationId, captures.getJSONObject(index).getString("capture_id")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val calibrations = JSONObject(checkNotNull(artifacts["cardio-calibrations"]))
|
||||||
|
val calibrationItems = calibrations.getJSONArray("calibrations")
|
||||||
|
for (index in 0 until calibrationItems.length()) {
|
||||||
|
db.execSQL(
|
||||||
|
"INSERT INTO cardio_calibration_generation(generation_id,calibration_id) VALUES(?,?)",
|
||||||
|
arrayOf(
|
||||||
|
generationId,
|
||||||
|
calibrationItems.getJSONObject(index).getString("calibration_id"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val guidance = JSONObject(checkNotNull(artifacts["cardio-guidance"]))
|
||||||
|
val guidanceRuns = guidance.getJSONArray("runs")
|
||||||
|
for (index in 0 until guidanceRuns.length()) {
|
||||||
|
db.execSQL(
|
||||||
|
"INSERT INTO cardio_guidance_generation(generation_id,run_id) VALUES(?,?)",
|
||||||
|
arrayOf(
|
||||||
|
generationId,
|
||||||
|
guidanceRuns.getJSONObject(index).getString("run_id"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val timeline = JSONObject(checkNotNull(artifacts["session-timeline"]))
|
||||||
|
val timelineSessions = timeline.getJSONArray("sessions")
|
||||||
|
for (index in 0 until timelineSessions.length()) {
|
||||||
|
db.execSQL(
|
||||||
|
"INSERT INTO session_timeline_generation_sessions(generation_id,session_id) " +
|
||||||
|
"VALUES(?,?)",
|
||||||
|
arrayOf(
|
||||||
|
generationId,
|
||||||
|
timelineSessions.getJSONObject(index).getString("session_id"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return CapturedSyncGeneration(generationId, runId, digest, stage)
|
return CapturedSyncGeneration(generationId, runId, digest, stage)
|
||||||
} catch (error: Exception) {
|
} catch (error: Exception) {
|
||||||
|
|
@ -1059,6 +1110,38 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
||||||
arrayOf(ack.getString("generation_id"), ack.getString("consumed_at"),
|
arrayOf(ack.getString("generation_id"), ack.getString("consumed_at"),
|
||||||
ack.getString("generation_id")),
|
ack.getString("generation_id")),
|
||||||
)
|
)
|
||||||
|
db.execSQL(
|
||||||
|
"UPDATE heart_rate_captures SET acknowledged_at=? WHERE " +
|
||||||
|
"acknowledged_at IS NULL AND EXISTS(" +
|
||||||
|
"SELECT 1 FROM heart_rate_generation_captures m " +
|
||||||
|
"WHERE m.generation_id=? AND " +
|
||||||
|
"m.capture_id=heart_rate_captures.capture_id)",
|
||||||
|
arrayOf(ack.getString("consumed_at"), ack.getString("generation_id")),
|
||||||
|
)
|
||||||
|
db.execSQL(
|
||||||
|
"UPDATE cardio_calibrations SET acknowledged_at=? WHERE " +
|
||||||
|
"acknowledged_at IS NULL AND EXISTS(" +
|
||||||
|
"SELECT 1 FROM cardio_calibration_generation m " +
|
||||||
|
"WHERE m.generation_id=? AND " +
|
||||||
|
"m.calibration_id=cardio_calibrations.calibration_id)",
|
||||||
|
arrayOf(ack.getString("consumed_at"), ack.getString("generation_id")),
|
||||||
|
)
|
||||||
|
db.execSQL(
|
||||||
|
"UPDATE cardio_guidance_runs SET acknowledged_at=? WHERE " +
|
||||||
|
"acknowledged_at IS NULL AND EXISTS(" +
|
||||||
|
"SELECT 1 FROM cardio_guidance_generation m " +
|
||||||
|
"WHERE m.generation_id=? AND " +
|
||||||
|
"m.run_id=cardio_guidance_runs.run_id)",
|
||||||
|
arrayOf(ack.getString("consumed_at"), ack.getString("generation_id")),
|
||||||
|
)
|
||||||
|
db.execSQL(
|
||||||
|
"UPDATE session_timeline_sessions SET acknowledged_at=? WHERE " +
|
||||||
|
"acknowledged_at IS NULL AND EXISTS(" +
|
||||||
|
"SELECT 1 FROM session_timeline_generation_sessions m " +
|
||||||
|
"WHERE m.generation_id=? AND " +
|
||||||
|
"m.session_id=session_timeline_sessions.session_id)",
|
||||||
|
arrayOf(ack.getString("consumed_at"), ack.getString("generation_id")),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
target
|
target
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,37 @@
|
||||||
|
package com.labfytools.trainlog.model
|
||||||
|
|
||||||
|
enum class CardioCalibrationPhase(val wireValue: String) {
|
||||||
|
WARMUP("warmup"),
|
||||||
|
PROGRESSIVE("progressive"),
|
||||||
|
HIGH_EFFORT("high_effort"),
|
||||||
|
RECOVERY("recovery"),
|
||||||
|
COMPLETED("completed"),
|
||||||
|
ABORTED("aborted");
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromWire(value: String): CardioCalibrationPhase =
|
||||||
|
entries.firstOrNull { it.wireValue == value }
|
||||||
|
?: error("Unknown cardio calibration phase: $value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CardioCalibrationRecoveryPoint(
|
||||||
|
val targetOffsetSeconds: Int,
|
||||||
|
val observedAt: String,
|
||||||
|
val bpm: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CardioCalibrationProfile(
|
||||||
|
val calibrationId: String,
|
||||||
|
val protocolVersion: Int,
|
||||||
|
val sessionId: String,
|
||||||
|
val entryId: String,
|
||||||
|
val phase: CardioCalibrationPhase,
|
||||||
|
val startedAt: String,
|
||||||
|
val phaseStartedAt: String,
|
||||||
|
val effortEndAt: String?,
|
||||||
|
val endedAt: String?,
|
||||||
|
val heartRateCaptureId: String?,
|
||||||
|
val observedPeakBpm: Int?,
|
||||||
|
val recovery: List<CardioCalibrationRecoveryPoint>,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
package com.labfytools.trainlog.model
|
||||||
|
|
||||||
|
enum class CardioPhaseKind(val wireValue: String) {
|
||||||
|
WARMUP("warmup"),
|
||||||
|
WORK("work"),
|
||||||
|
RECOVERY("recovery"),
|
||||||
|
COOLDOWN("cooldown"),
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface CardioTargetDefinition {
|
||||||
|
data class AbsoluteBpm(
|
||||||
|
val minimumBpm: Int,
|
||||||
|
val maximumBpm: Int,
|
||||||
|
) : CardioTargetDefinition
|
||||||
|
|
||||||
|
data class CalibrationPercent(
|
||||||
|
val calibrationId: String,
|
||||||
|
val observedPeakBpm: Int,
|
||||||
|
val minimumPercent: Int,
|
||||||
|
val maximumPercent: Int,
|
||||||
|
) : CardioTargetDefinition
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable target actually used by one phase.
|
||||||
|
*
|
||||||
|
* The calibration identity and percentages are provenance only. Runtime
|
||||||
|
* guidance consumes the resolved BPM bounds so later calibration changes
|
||||||
|
* cannot rewrite an already-started session.
|
||||||
|
*/
|
||||||
|
data class CardioTargetSnapshot(
|
||||||
|
val minimumBpm: Int,
|
||||||
|
val maximumBpm: Int,
|
||||||
|
val calibrationId: String? = null,
|
||||||
|
val calibrationObservedPeakBpm: Int? = null,
|
||||||
|
val minimumPercent: Int? = null,
|
||||||
|
val maximumPercent: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface CardioPhaseExitCondition {
|
||||||
|
data class FixedDuration(val seconds: Int) : CardioPhaseExitCondition
|
||||||
|
data object EnterTarget : CardioPhaseExitCondition
|
||||||
|
data class RecoverBelow(val bpm: Int) : CardioPhaseExitCondition
|
||||||
|
|
||||||
|
data class DurationOrRecoverBelow(
|
||||||
|
val maximumSeconds: Int,
|
||||||
|
val bpm: Int,
|
||||||
|
) : CardioPhaseExitCondition
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CardioGuidedPhase(
|
||||||
|
val phaseId: String,
|
||||||
|
val kind: CardioPhaseKind,
|
||||||
|
val target: CardioTargetSnapshot?,
|
||||||
|
val exitCondition: CardioPhaseExitCondition,
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class CardioGuidanceInstruction {
|
||||||
|
ACCELERATE,
|
||||||
|
MAINTAIN,
|
||||||
|
SLOW_DOWN,
|
||||||
|
SUSPENDED,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class CardioGuidanceOutput(
|
||||||
|
val instruction: CardioGuidanceInstruction,
|
||||||
|
val bpm: Int?,
|
||||||
|
val target: CardioTargetSnapshot?,
|
||||||
|
val pendingInstruction: CardioGuidanceInstruction? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CardioGuidancePhaseRecord(
|
||||||
|
val runId: String,
|
||||||
|
val sessionId: String,
|
||||||
|
val entryId: String,
|
||||||
|
val phase: CardioGuidedPhase,
|
||||||
|
val position: Int,
|
||||||
|
val startedAt: String,
|
||||||
|
val endedAt: String?,
|
||||||
|
val currentInstruction: CardioGuidanceInstruction,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CardioGuidanceEvent(
|
||||||
|
val sequence: Long,
|
||||||
|
val observedAt: String,
|
||||||
|
val instruction: CardioGuidanceInstruction,
|
||||||
|
val bpm: Int?,
|
||||||
|
val targetMinimumBpm: Int?,
|
||||||
|
val targetMaximumBpm: Int?,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CardioGuidanceRun(
|
||||||
|
val runId: String,
|
||||||
|
val sessionId: String,
|
||||||
|
val startedAt: String,
|
||||||
|
val endedAt: String?,
|
||||||
|
val phases: List<CardioGuidancePhaseRecord>,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
/*
|
||||||
|
* Android HeartRateModels model.
|
||||||
|
*
|
||||||
|
* Android owns sensor acquisition. Desktop/Web only consume synchronized
|
||||||
|
* measured facts and never connect to the heart-rate sensor.
|
||||||
|
*/
|
||||||
|
package com.labfytools.trainlog.model
|
||||||
|
|
||||||
|
enum class HeartRateContextKind(val wireValue: String) {
|
||||||
|
SESSION("session"),
|
||||||
|
CARDIO("cardio"),
|
||||||
|
SLEEP("sleep");
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromWire(value: String): HeartRateContextKind =
|
||||||
|
entries.firstOrNull { it.wireValue == value }
|
||||||
|
?: error("Unknown heart-rate context: $value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class HeartRateRrInterval(
|
||||||
|
val index: Int,
|
||||||
|
/** Raw Bluetooth Heart Rate Service unit: 1/1024 second. */
|
||||||
|
val value1024: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class HeartRateSample(
|
||||||
|
val sequence: Long,
|
||||||
|
val observedAt: String,
|
||||||
|
val bpm: Int,
|
||||||
|
val exerciseEntryId: String? = null,
|
||||||
|
val sensorContactDetected: Boolean? = null,
|
||||||
|
val energyExpended: Int? = null,
|
||||||
|
val rrIntervals: List<HeartRateRrInterval> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
data class HeartRateCapture(
|
||||||
|
val captureId: String,
|
||||||
|
val contextKind: HeartRateContextKind,
|
||||||
|
val contextId: String,
|
||||||
|
val startedAt: String,
|
||||||
|
val endedAt: String?,
|
||||||
|
val activeExerciseEntryId: String?,
|
||||||
|
val sensorName: String?,
|
||||||
|
val samples: List<HeartRateSample> = emptyList(),
|
||||||
|
)
|
||||||
|
|
@ -9,17 +9,14 @@ enum class SessionType(
|
||||||
val wireValue: String,
|
val wireValue: String,
|
||||||
) {
|
) {
|
||||||
TRAINING("training"),
|
TRAINING("training"),
|
||||||
MAX_TEST("max_test");
|
MAX_TEST("max_test"),
|
||||||
|
CARDIO("cardio");
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromWire(
|
fun fromWire(
|
||||||
value: String,
|
value: String,
|
||||||
): SessionType =
|
): SessionType =
|
||||||
if (value == "max_test") {
|
entries.firstOrNull { it.wireValue == value } ?: TRAINING
|
||||||
MAX_TEST
|
|
||||||
} else {
|
|
||||||
TRAINING
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -79,6 +76,24 @@ data class SessionDraft(
|
||||||
val sessionType: SessionType = SessionType.TRAINING,
|
val sessionType: SessionType = SessionType.TRAINING,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class SessionExerciseTiming(
|
||||||
|
val sessionId: String,
|
||||||
|
val entryId: String,
|
||||||
|
val exerciseId: String,
|
||||||
|
val startedAt: String,
|
||||||
|
val endedAt: String?,
|
||||||
|
) {
|
||||||
|
val active: Boolean get() = endedAt == null
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ActiveSessionTimelineContext(
|
||||||
|
val sessionId: String,
|
||||||
|
val startedAt: String,
|
||||||
|
val sessionType: SessionType,
|
||||||
|
val activeEntryId: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
data class SessionDraftForm(
|
data class SessionDraftForm(
|
||||||
val selectedExercise: ExerciseProfile? = null,
|
val selectedExercise: ExerciseProfile? = null,
|
||||||
/** Null means creation; otherwise replace this durable draft entry. */
|
/** Null means creation; otherwise replace this durable draft entry. */
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ data class SleepMedication(
|
||||||
data class MedicationIntake(
|
data class MedicationIntake(
|
||||||
val intakeId: String, val medicationId: String, val medicationName: String,
|
val intakeId: String, val medicationId: String, val medicationName: String,
|
||||||
val takenAt: String, val doseValue: Double?, val doseUnit: String?, val note: String,
|
val takenAt: String, val doseValue: Double?, val doseUnit: String?, val note: String,
|
||||||
val createdAt: String,
|
val createdAt: String, val quantity: Int = 1,
|
||||||
)
|
)
|
||||||
data class SleepDiaryEntry(
|
data class SleepDiaryEntry(
|
||||||
val entryId: String, val nightStartDate: String, val nightEndDate: String,
|
val entryId: String, val nightStartDate: String, val nightEndDate: String,
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ fun AndroidAppShell(
|
||||||
AppRoute.SessionEditor -> R.string.route_session_editor; AppRoute.SessionGenerator -> R.string.route_session_generator
|
AppRoute.SessionEditor -> R.string.route_session_editor; AppRoute.SessionGenerator -> R.string.route_session_generator
|
||||||
AppRoute.AiSessionDrafts -> R.string.route_ai_drafts; AppRoute.CompletedSessions -> R.string.route_completed_sessions
|
AppRoute.AiSessionDrafts -> R.string.route_ai_drafts; AppRoute.CompletedSessions -> R.string.route_completed_sessions
|
||||||
AppRoute.Programs -> R.string.route_programs; is AppRoute.ProgramDetail -> R.string.route_program_detail
|
AppRoute.Programs -> R.string.route_programs; is AppRoute.ProgramDetail -> R.string.route_program_detail
|
||||||
|
AppRoute.CardioCalibration -> R.string.route_cardio_calibration
|
||||||
is AppRoute.SessionDetail -> R.string.route_session_detail; is AppRoute.SessionCorrection -> R.string.route_session_correction
|
is AppRoute.SessionDetail -> R.string.route_session_detail; is AppRoute.SessionCorrection -> R.string.route_session_correction
|
||||||
AppRoute.Exercises -> R.string.nav_exercises; is AppRoute.ExerciseDetail -> R.string.route_exercise_detail
|
AppRoute.Exercises -> R.string.nav_exercises; is AppRoute.ExerciseDetail -> R.string.route_exercise_detail
|
||||||
is AppRoute.ExerciseEdit -> R.string.route_exercise_edit; is AppRoute.ExerciseCreate -> R.string.route_exercise_create
|
is AppRoute.ExerciseEdit -> R.string.route_exercise_edit; is AppRoute.ExerciseCreate -> R.string.route_exercise_create
|
||||||
|
|
@ -77,6 +78,7 @@ fun AndroidAppShell(
|
||||||
AppRoute.BodyMeasurements -> R.string.route_body_measurements; AppRoute.LatestMaxima -> R.string.route_latest_maxima
|
AppRoute.BodyMeasurements -> R.string.route_body_measurements; AppRoute.LatestMaxima -> R.string.route_latest_maxima
|
||||||
AppRoute.SleepDiary -> R.string.nav_sleep
|
AppRoute.SleepDiary -> R.string.nav_sleep
|
||||||
AppRoute.Sync -> R.string.nav_sync; AppRoute.Settings -> R.string.nav_settings
|
AppRoute.Sync -> R.string.nav_sync; AppRoute.Settings -> R.string.nav_settings
|
||||||
|
AppRoute.HeartRateDiagnostics -> R.string.route_heart_rate_diagnostics
|
||||||
})
|
})
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
@ -145,6 +147,7 @@ fun AndroidAppShell(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
HeartRateIndicator()
|
||||||
if (!root) {
|
if (!root) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { scope.launch { drawerState.open() } },
|
onClick = { scope.launch { drawerState.open() } },
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ sealed interface AppRoute {
|
||||||
data object AiSessionDrafts : AppRoute { override val section = AppSection.SESSIONS }
|
data object AiSessionDrafts : AppRoute { override val section = AppSection.SESSIONS }
|
||||||
data object CompletedSessions : AppRoute { override val section = AppSection.SESSIONS }
|
data object CompletedSessions : AppRoute { override val section = AppSection.SESSIONS }
|
||||||
data object Programs : AppRoute { override val section = AppSection.SESSIONS }
|
data object Programs : AppRoute { override val section = AppSection.SESSIONS }
|
||||||
|
data object CardioCalibration : AppRoute { override val section = AppSection.SESSIONS }
|
||||||
data class ProgramDetail(val programId: String) : AppRoute { override val section = AppSection.SESSIONS }
|
data class ProgramDetail(val programId: String) : AppRoute { override val section = AppSection.SESSIONS }
|
||||||
data class SessionDetail(val sessionId: String) : AppRoute { override val section = AppSection.SESSIONS }
|
data class SessionDetail(val sessionId: String) : AppRoute { override val section = AppSection.SESSIONS }
|
||||||
data class SessionCorrection(val sessionId: String) : AppRoute { override val section = AppSection.SESSIONS }
|
data class SessionCorrection(val sessionId: String) : AppRoute { override val section = AppSection.SESSIONS }
|
||||||
|
|
@ -39,6 +40,7 @@ sealed interface AppRoute {
|
||||||
data object LatestMaxima : AppRoute { override val section = AppSection.EXERCISES }
|
data object LatestMaxima : AppRoute { override val section = AppSection.EXERCISES }
|
||||||
data object Sync : AppRoute { override val section = AppSection.SYNC }
|
data object Sync : AppRoute { override val section = AppSection.SYNC }
|
||||||
data object Settings : AppRoute { override val section = AppSection.SETTINGS }
|
data object Settings : AppRoute { override val section = AppSection.SETTINGS }
|
||||||
|
data object HeartRateDiagnostics : AppRoute { override val section = AppSection.SETTINGS }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun AppSection.rootRoute(): AppRoute = when (this) {
|
fun AppSection.rootRoute(): AppRoute = when (this) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,172 @@
|
||||||
|
package com.labfytools.trainlog.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import com.labfytools.trainlog.R
|
||||||
|
import com.labfytools.trainlog.data.TrainlogRepository
|
||||||
|
import com.labfytools.trainlog.model.CardioCalibrationPhase
|
||||||
|
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CardioCalibrationScreen(
|
||||||
|
repository: TrainlogRepository,
|
||||||
|
onChanged: () -> Unit,
|
||||||
|
) {
|
||||||
|
val strings = localizedContext()
|
||||||
|
val colors = LocalTrainlogColors.current
|
||||||
|
var revision by remember { mutableIntStateOf(0) }
|
||||||
|
var message by remember { mutableStateOf<String?>(null) }
|
||||||
|
val active = remember(revision) { repository.activeCardioCalibration() }
|
||||||
|
val latest = remember(revision) {
|
||||||
|
repository.listCompletedCardioCalibrations(1).firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun apply(result: TrainlogRepository.CardioCalibrationResult) {
|
||||||
|
message =
|
||||||
|
when (result) {
|
||||||
|
is TrainlogRepository.CardioCalibrationResult.Applied -> {
|
||||||
|
revision++
|
||||||
|
onChanged()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
is TrainlogRepository.CardioCalibrationResult.Invalid -> result.message
|
||||||
|
TrainlogRepository.CardioCalibrationResult.Conflict ->
|
||||||
|
strings.getString(R.string.calibration_conflict)
|
||||||
|
is TrainlogRepository.CardioCalibrationResult.DatabaseError ->
|
||||||
|
result.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun phaseLabel(phase: CardioCalibrationPhase): String =
|
||||||
|
strings.getString(
|
||||||
|
when (phase) {
|
||||||
|
CardioCalibrationPhase.WARMUP -> R.string.calibration_phase_warmup
|
||||||
|
CardioCalibrationPhase.PROGRESSIVE -> R.string.calibration_phase_progressive
|
||||||
|
CardioCalibrationPhase.HIGH_EFFORT -> R.string.calibration_phase_high_effort
|
||||||
|
CardioCalibrationPhase.RECOVERY -> R.string.calibration_phase_recovery
|
||||||
|
CardioCalibrationPhase.COMPLETED -> R.string.calibration_phase_completed
|
||||||
|
CardioCalibrationPhase.ABORTED -> R.string.calibration_phase_aborted
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
TrainlogScreen(
|
||||||
|
strings.getString(R.string.route_cardio_calibration),
|
||||||
|
scrollKey = "cardio-calibration",
|
||||||
|
) {
|
||||||
|
TrainlogInfo(strings.getString(R.string.calibration_explanation), colors.muted)
|
||||||
|
|
||||||
|
if (active == null) {
|
||||||
|
TrainlogPrimaryAction(
|
||||||
|
strings.getString(R.string.calibration_start),
|
||||||
|
strings.getString(R.string.calibration_start_description),
|
||||||
|
) {
|
||||||
|
apply(repository.startCardioCalibration())
|
||||||
|
}
|
||||||
|
latest?.let { profile ->
|
||||||
|
TrainlogFrame(strings.getString(R.string.calibration_latest)) {
|
||||||
|
TrainlogInfo(
|
||||||
|
strings.getString(
|
||||||
|
R.string.calibration_peak_value,
|
||||||
|
profile.observedPeakBpm ?: 0,
|
||||||
|
),
|
||||||
|
colors.accent,
|
||||||
|
)
|
||||||
|
profile.recovery.forEach { point ->
|
||||||
|
TrainlogInfo(
|
||||||
|
strings.getString(
|
||||||
|
R.string.calibration_recovery_value,
|
||||||
|
point.targetOffsetSeconds / 60,
|
||||||
|
point.bpm,
|
||||||
|
),
|
||||||
|
colors.muted,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
TrainlogFrame(strings.getString(R.string.calibration_current)) {
|
||||||
|
TrainlogInfo(
|
||||||
|
strings.getString(
|
||||||
|
R.string.calibration_phase_value,
|
||||||
|
phaseLabel(active.phase),
|
||||||
|
),
|
||||||
|
colors.accent,
|
||||||
|
)
|
||||||
|
active.observedPeakBpm?.let { bpm ->
|
||||||
|
TrainlogInfo(
|
||||||
|
strings.getString(R.string.calibration_peak_value, bpm),
|
||||||
|
colors.warning,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
active.recovery.forEach { point ->
|
||||||
|
TrainlogInfo(
|
||||||
|
strings.getString(
|
||||||
|
R.string.calibration_recovery_value,
|
||||||
|
point.targetOffsetSeconds / 60,
|
||||||
|
point.bpm,
|
||||||
|
),
|
||||||
|
colors.muted,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when (active.phase) {
|
||||||
|
CardioCalibrationPhase.WARMUP -> {
|
||||||
|
TrainlogPrimaryAction(
|
||||||
|
strings.getString(R.string.calibration_to_progressive),
|
||||||
|
strings.getString(R.string.calibration_progressive_description),
|
||||||
|
) {
|
||||||
|
apply(repository.advanceCardioCalibrationPhase())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CardioCalibrationPhase.PROGRESSIVE -> {
|
||||||
|
TrainlogPrimaryAction(
|
||||||
|
strings.getString(R.string.calibration_to_high_effort),
|
||||||
|
strings.getString(R.string.calibration_high_effort_description),
|
||||||
|
) {
|
||||||
|
apply(repository.advanceCardioCalibrationPhase())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CardioCalibrationPhase.HIGH_EFFORT -> {
|
||||||
|
TrainlogPrimaryAction(
|
||||||
|
strings.getString(R.string.calibration_end_effort),
|
||||||
|
strings.getString(R.string.calibration_end_effort_description),
|
||||||
|
) {
|
||||||
|
apply(repository.markCardioCalibrationEffortEnd())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CardioCalibrationPhase.RECOVERY -> {
|
||||||
|
TrainlogPrimaryAction(
|
||||||
|
strings.getString(R.string.calibration_finish),
|
||||||
|
strings.getString(R.string.calibration_finish_description),
|
||||||
|
) {
|
||||||
|
apply(repository.finishCardioCalibration())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CardioCalibrationPhase.COMPLETED,
|
||||||
|
CardioCalibrationPhase.ABORTED -> Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
) {
|
||||||
|
TrainlogButton(
|
||||||
|
strings.getString(R.string.calibration_abort),
|
||||||
|
{ apply(repository.abortCardioCalibration()) },
|
||||||
|
style = TrainlogButtonStyle.DESTRUCTIVE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message?.let { TrainlogInfo(it, colors.error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,385 @@
|
||||||
|
package com.labfytools.trainlog.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.labfytools.trainlog.R
|
||||||
|
import com.labfytools.trainlog.data.CardioGuidanceLiveState
|
||||||
|
import com.labfytools.trainlog.data.CardioTargetResolver
|
||||||
|
import com.labfytools.trainlog.data.TrainlogRepository
|
||||||
|
import com.labfytools.trainlog.model.CardioGuidanceInstruction
|
||||||
|
import com.labfytools.trainlog.model.CardioGuidedPhase
|
||||||
|
import com.labfytools.trainlog.model.CardioPhaseExitCondition
|
||||||
|
import com.labfytools.trainlog.model.CardioPhaseKind
|
||||||
|
import com.labfytools.trainlog.model.CardioTargetDefinition
|
||||||
|
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun CardioGuidancePanel(
|
||||||
|
repository: TrainlogRepository,
|
||||||
|
entryId: String,
|
||||||
|
) {
|
||||||
|
val strings = localizedContext()
|
||||||
|
val colors = LocalTrainlogColors.current
|
||||||
|
var revision by remember(entryId) { mutableStateOf(0) }
|
||||||
|
val active =
|
||||||
|
remember(entryId, revision) {
|
||||||
|
repository.activeCardioGuidancePhase()?.takeIf { it.entryId == entryId }
|
||||||
|
}
|
||||||
|
val calibration =
|
||||||
|
remember(revision) {
|
||||||
|
repository.listCompletedCardioCalibrations(1).firstOrNull()
|
||||||
|
}
|
||||||
|
var live by remember { mutableStateOf(CardioGuidanceLiveState.current()) }
|
||||||
|
|
||||||
|
DisposableEffect(entryId) {
|
||||||
|
val subscription = CardioGuidanceLiveState.subscribe { live = it }
|
||||||
|
onDispose { subscription.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
TrainlogFrame(
|
||||||
|
title = strings.getString(R.string.cardio_guidance_title),
|
||||||
|
active = active != null,
|
||||||
|
) {
|
||||||
|
if (active != null) {
|
||||||
|
val phase = active.phase
|
||||||
|
val current = live.takeIf { it.phaseId == phase.phaseId }
|
||||||
|
val instruction = current?.instruction ?: active.currentInstruction
|
||||||
|
val bpm = current?.bpm
|
||||||
|
|
||||||
|
TrainlogInfo(
|
||||||
|
strings.getString(
|
||||||
|
R.string.cardio_guidance_phase_value,
|
||||||
|
phaseKindLabel(strings, phase.kind),
|
||||||
|
),
|
||||||
|
colors.muted,
|
||||||
|
)
|
||||||
|
phase.target?.let { target ->
|
||||||
|
TrainlogInfo(
|
||||||
|
strings.getString(
|
||||||
|
R.string.cardio_guidance_target_value,
|
||||||
|
target.minimumBpm,
|
||||||
|
target.maximumBpm,
|
||||||
|
),
|
||||||
|
colors.accent,
|
||||||
|
)
|
||||||
|
if (target.calibrationId != null) {
|
||||||
|
TrainlogInfo(
|
||||||
|
strings.getString(
|
||||||
|
R.string.cardio_guidance_calibration_value,
|
||||||
|
target.calibrationObservedPeakBpm ?: 0,
|
||||||
|
target.minimumPercent ?: 0,
|
||||||
|
target.maximumPercent ?: 0,
|
||||||
|
),
|
||||||
|
colors.muted,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TrainlogInfo(
|
||||||
|
guidanceInstructionLabel(strings, instruction),
|
||||||
|
when (instruction) {
|
||||||
|
CardioGuidanceInstruction.MAINTAIN -> colors.success
|
||||||
|
CardioGuidanceInstruction.ACCELERATE -> colors.accent
|
||||||
|
CardioGuidanceInstruction.SLOW_DOWN,
|
||||||
|
CardioGuidanceInstruction.SUSPENDED -> colors.warning
|
||||||
|
},
|
||||||
|
)
|
||||||
|
TrainlogInfo(
|
||||||
|
if (bpm == null) {
|
||||||
|
strings.getString(R.string.cardio_guidance_bpm_missing)
|
||||||
|
} else {
|
||||||
|
strings.getString(R.string.cardio_guidance_bpm_value, bpm)
|
||||||
|
},
|
||||||
|
if (bpm == null) colors.warning else colors.text,
|
||||||
|
)
|
||||||
|
TrainlogButton(
|
||||||
|
label = strings.getString(R.string.cardio_guidance_finish_phase),
|
||||||
|
onClick = {
|
||||||
|
if (
|
||||||
|
repository.finishCardioGuidancePhase(phase.phaseId) is
|
||||||
|
TrainlogRepository.CardioGuidanceMutationResult.Applied
|
||||||
|
) {
|
||||||
|
CardioGuidanceLiveState.clear()
|
||||||
|
revision++
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
style = TrainlogButtonStyle.SECONDARY,
|
||||||
|
)
|
||||||
|
return@TrainlogFrame
|
||||||
|
}
|
||||||
|
|
||||||
|
var kind by remember(entryId) { mutableStateOf(CardioPhaseKind.WARMUP) }
|
||||||
|
var targetMode by
|
||||||
|
remember(entryId, calibration?.calibrationId) {
|
||||||
|
mutableStateOf(if (calibration == null) "bpm" else "calibration")
|
||||||
|
}
|
||||||
|
var minimumBpm by remember(entryId) { mutableStateOf("") }
|
||||||
|
var maximumBpm by remember(entryId) { mutableStateOf("") }
|
||||||
|
var minimumPercent by remember(entryId) { mutableStateOf("") }
|
||||||
|
var maximumPercent by remember(entryId) { mutableStateOf("") }
|
||||||
|
var exitMode by remember(entryId) { mutableStateOf("duration") }
|
||||||
|
var durationSeconds by remember(entryId) { mutableStateOf("") }
|
||||||
|
var recoveryBpm by remember(entryId) { mutableStateOf("") }
|
||||||
|
var message by remember(entryId) { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
TrainlogChoiceChips(
|
||||||
|
choices =
|
||||||
|
listOf(
|
||||||
|
"warmup" to strings.getString(R.string.cardio_phase_warmup),
|
||||||
|
"work" to strings.getString(R.string.cardio_phase_work),
|
||||||
|
"recovery" to strings.getString(R.string.cardio_phase_recovery),
|
||||||
|
"cooldown" to strings.getString(R.string.cardio_phase_cooldown),
|
||||||
|
),
|
||||||
|
selectedId = kind.wireValue,
|
||||||
|
onSelected = { selected ->
|
||||||
|
kind = CardioPhaseKind.entries.first { it.wireValue == selected }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
TrainlogChoiceChips(
|
||||||
|
choices =
|
||||||
|
buildList {
|
||||||
|
add("bpm" to strings.getString(R.string.cardio_target_absolute))
|
||||||
|
if (calibration?.observedPeakBpm != null) {
|
||||||
|
add(
|
||||||
|
"calibration" to
|
||||||
|
strings.getString(R.string.cardio_target_calibration),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectedId = targetMode,
|
||||||
|
onSelected = { targetMode = it },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (targetMode == "calibration") {
|
||||||
|
calibration?.observedPeakBpm?.let { peak ->
|
||||||
|
TrainlogInfo(
|
||||||
|
strings.getString(R.string.cardio_guidance_latest_calibration, peak),
|
||||||
|
colors.muted,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
NumberPair(
|
||||||
|
leftLabel = strings.getString(R.string.cardio_target_min_percent),
|
||||||
|
left = minimumPercent,
|
||||||
|
onLeft = { minimumPercent = digits(it, 3) },
|
||||||
|
rightLabel = strings.getString(R.string.cardio_target_max_percent),
|
||||||
|
right = maximumPercent,
|
||||||
|
onRight = { maximumPercent = digits(it, 3) },
|
||||||
|
suffix = "%",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
NumberPair(
|
||||||
|
leftLabel = strings.getString(R.string.cardio_target_min_bpm),
|
||||||
|
left = minimumBpm,
|
||||||
|
onLeft = { minimumBpm = digits(it, 5) },
|
||||||
|
rightLabel = strings.getString(R.string.cardio_target_max_bpm),
|
||||||
|
right = maximumBpm,
|
||||||
|
onRight = { maximumBpm = digits(it, 5) },
|
||||||
|
suffix = "BPM",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
TrainlogChoiceChips(
|
||||||
|
choices =
|
||||||
|
listOf(
|
||||||
|
"duration" to strings.getString(R.string.cardio_exit_duration),
|
||||||
|
"target" to strings.getString(R.string.cardio_exit_target),
|
||||||
|
"recover" to strings.getString(R.string.cardio_exit_recover),
|
||||||
|
"bounded" to strings.getString(R.string.cardio_exit_bounded),
|
||||||
|
),
|
||||||
|
selectedId = exitMode,
|
||||||
|
onSelected = { exitMode = it },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (exitMode == "duration" || exitMode == "bounded") {
|
||||||
|
TrainlogInputField(
|
||||||
|
label = strings.getString(R.string.cardio_exit_seconds),
|
||||||
|
value = durationSeconds,
|
||||||
|
onValueChange = { durationSeconds = digits(it, 5) },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
suffix = "s",
|
||||||
|
compact = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (exitMode == "recover" || exitMode == "bounded") {
|
||||||
|
TrainlogInputField(
|
||||||
|
label = strings.getString(R.string.cardio_exit_recovery_bpm),
|
||||||
|
value = recoveryBpm,
|
||||||
|
onValueChange = { recoveryBpm = digits(it, 5) },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
suffix = "BPM",
|
||||||
|
compact = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val target =
|
||||||
|
if (targetMode == "calibration") {
|
||||||
|
val peak = calibration?.observedPeakBpm
|
||||||
|
val min = minimumPercent.toIntOrNull()
|
||||||
|
val max = maximumPercent.toIntOrNull()
|
||||||
|
if (calibration == null || peak == null || min == null || max == null) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
CardioTargetResolver.resolve(
|
||||||
|
CardioTargetDefinition.CalibrationPercent(
|
||||||
|
calibration.calibrationId,
|
||||||
|
peak,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val min = minimumBpm.toIntOrNull()
|
||||||
|
val max = maximumBpm.toIntOrNull()
|
||||||
|
if (min == null || max == null) null
|
||||||
|
else CardioTargetResolver.resolve(
|
||||||
|
CardioTargetDefinition.AbsoluteBpm(min, max),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
target?.let {
|
||||||
|
TrainlogInfo(
|
||||||
|
strings.getString(
|
||||||
|
R.string.cardio_guidance_resolved_target,
|
||||||
|
it.minimumBpm,
|
||||||
|
it.maximumBpm,
|
||||||
|
),
|
||||||
|
colors.accent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val exit =
|
||||||
|
when (exitMode) {
|
||||||
|
"duration" ->
|
||||||
|
durationSeconds.toIntOrNull()
|
||||||
|
?.takeIf { it in 1..86400 }
|
||||||
|
?.let(CardioPhaseExitCondition::FixedDuration)
|
||||||
|
"target" -> CardioPhaseExitCondition.EnterTarget
|
||||||
|
"recover" ->
|
||||||
|
recoveryBpm.toIntOrNull()
|
||||||
|
?.takeIf { it in 1..65535 }
|
||||||
|
?.let(CardioPhaseExitCondition::RecoverBelow)
|
||||||
|
"bounded" -> {
|
||||||
|
val seconds = durationSeconds.toIntOrNull()
|
||||||
|
val bpm = recoveryBpm.toIntOrNull()
|
||||||
|
if (seconds != null && bpm != null &&
|
||||||
|
seconds in 1..86400 && bpm in 1..65535
|
||||||
|
) {
|
||||||
|
CardioPhaseExitCondition.DurationOrRecoverBelow(seconds, bpm)
|
||||||
|
} else null
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
message?.let { TrainlogInfo(it, colors.warning) }
|
||||||
|
|
||||||
|
TrainlogButton(
|
||||||
|
label = strings.getString(R.string.cardio_guidance_start_phase),
|
||||||
|
onClick = {
|
||||||
|
val actualTarget = target
|
||||||
|
val actualExit = exit
|
||||||
|
if (actualTarget == null || actualExit == null) {
|
||||||
|
message =
|
||||||
|
strings.getString(R.string.cardio_guidance_invalid_configuration)
|
||||||
|
return@TrainlogButton
|
||||||
|
}
|
||||||
|
val result =
|
||||||
|
repository.startCardioGuidancePhase(
|
||||||
|
entryId,
|
||||||
|
CardioGuidedPhase(
|
||||||
|
phaseId = "cgp_" + UUID.randomUUID(),
|
||||||
|
kind = kind,
|
||||||
|
target = actualTarget,
|
||||||
|
exitCondition = actualExit,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (result is TrainlogRepository.CardioGuidanceMutationResult.Applied) {
|
||||||
|
message = null
|
||||||
|
revision++
|
||||||
|
} else {
|
||||||
|
message = strings.getString(R.string.cardio_guidance_start_failed)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = target != null && exit != null,
|
||||||
|
style = TrainlogButtonStyle.SUCCESS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NumberPair(
|
||||||
|
leftLabel: String,
|
||||||
|
left: String,
|
||||||
|
onLeft: (String) -> Unit,
|
||||||
|
rightLabel: String,
|
||||||
|
right: String,
|
||||||
|
onRight: (String) -> Unit,
|
||||||
|
suffix: String,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
TrainlogInputField(
|
||||||
|
label = leftLabel,
|
||||||
|
value = left,
|
||||||
|
onValueChange = onLeft,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
suffix = suffix,
|
||||||
|
compact = true,
|
||||||
|
)
|
||||||
|
TrainlogInputField(
|
||||||
|
label = rightLabel,
|
||||||
|
value = right,
|
||||||
|
onValueChange = onRight,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
suffix = suffix,
|
||||||
|
compact = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun phaseKindLabel(
|
||||||
|
strings: android.content.Context,
|
||||||
|
kind: CardioPhaseKind,
|
||||||
|
): String =
|
||||||
|
strings.getString(
|
||||||
|
when (kind) {
|
||||||
|
CardioPhaseKind.WARMUP -> R.string.cardio_phase_warmup
|
||||||
|
CardioPhaseKind.WORK -> R.string.cardio_phase_work
|
||||||
|
CardioPhaseKind.RECOVERY -> R.string.cardio_phase_recovery
|
||||||
|
CardioPhaseKind.COOLDOWN -> R.string.cardio_phase_cooldown
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun guidanceInstructionLabel(
|
||||||
|
strings: android.content.Context,
|
||||||
|
instruction: CardioGuidanceInstruction,
|
||||||
|
): String =
|
||||||
|
strings.getString(
|
||||||
|
when (instruction) {
|
||||||
|
CardioGuidanceInstruction.ACCELERATE -> R.string.cardio_guidance_accelerate
|
||||||
|
CardioGuidanceInstruction.MAINTAIN -> R.string.cardio_guidance_maintain
|
||||||
|
CardioGuidanceInstruction.SLOW_DOWN -> R.string.cardio_guidance_slow_down
|
||||||
|
CardioGuidanceInstruction.SUSPENDED -> R.string.cardio_guidance_suspended
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun digits(value: String, maximum: Int): String =
|
||||||
|
value.filter(Char::isDigit).take(maximum)
|
||||||
|
|
@ -0,0 +1,203 @@
|
||||||
|
package com.labfytools.trainlog.ui
|
||||||
|
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.labfytools.trainlog.R
|
||||||
|
import com.labfytools.trainlog.data.HeartRateBleDiagnostics
|
||||||
|
import com.labfytools.trainlog.data.HeartRateDiagnosticPhase
|
||||||
|
import com.labfytools.trainlog.data.HeartRateDiagnosticSnapshot
|
||||||
|
import com.labfytools.trainlog.data.HeartRateSensorPreferences
|
||||||
|
import com.labfytools.trainlog.data.HeartRateSensorService
|
||||||
|
import com.labfytools.trainlog.data.hasHeartRateBleRuntimePermissions
|
||||||
|
import com.labfytools.trainlog.data.heartRateBleRuntimePermissions
|
||||||
|
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HeartRateDiagnosticsScreen(onSelected: () -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val strings = localizedContext()
|
||||||
|
val colors = LocalTrainlogColors.current
|
||||||
|
var snapshot by remember { mutableStateOf(HeartRateDiagnosticSnapshot()) }
|
||||||
|
var pendingExpandedScan by remember { mutableStateOf(false) }
|
||||||
|
var permissionDenied by remember { mutableStateOf(false) }
|
||||||
|
val diagnostics =
|
||||||
|
remember(context) {
|
||||||
|
HeartRateBleDiagnostics(context) { next -> snapshot = next }
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(diagnostics) {
|
||||||
|
onDispose { diagnostics.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
val permissionLauncher =
|
||||||
|
rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestMultiplePermissions(),
|
||||||
|
) { results ->
|
||||||
|
if (heartRateBleRuntimePermissions().all { results[it] == true }) {
|
||||||
|
permissionDenied = false
|
||||||
|
diagnostics.startScan(pendingExpandedScan)
|
||||||
|
} else {
|
||||||
|
permissionDenied = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun scan(expanded: Boolean) {
|
||||||
|
pendingExpandedScan = expanded
|
||||||
|
if (hasHeartRateBleRuntimePermissions(context)) {
|
||||||
|
permissionDenied = false
|
||||||
|
diagnostics.startScan(expanded)
|
||||||
|
} else {
|
||||||
|
permissionLauncher.launch(heartRateBleRuntimePermissions())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun phaseLabel(): String =
|
||||||
|
strings.getString(
|
||||||
|
when (snapshot.phase) {
|
||||||
|
HeartRateDiagnosticPhase.IDLE -> R.string.hr_diag_idle
|
||||||
|
HeartRateDiagnosticPhase.SCANNING -> R.string.hr_diag_scanning
|
||||||
|
HeartRateDiagnosticPhase.CONNECTING -> R.string.hr_diag_connecting
|
||||||
|
HeartRateDiagnosticPhase.DISCOVERING -> R.string.hr_diag_discovering
|
||||||
|
HeartRateDiagnosticPhase.SUBSCRIBING -> R.string.hr_diag_subscribing
|
||||||
|
HeartRateDiagnosticPhase.RECEIVING -> R.string.hr_diag_receiving
|
||||||
|
HeartRateDiagnosticPhase.DISCONNECTED -> R.string.hr_diag_disconnected
|
||||||
|
HeartRateDiagnosticPhase.ERROR -> R.string.hr_diag_error
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
TrainlogScreen(strings.getString(R.string.route_heart_rate_diagnostics)) {
|
||||||
|
TrainlogFrame(strings.getString(R.string.hr_diag_status)) {
|
||||||
|
TrainlogInfo(
|
||||||
|
phaseLabel(),
|
||||||
|
when (snapshot.phase) {
|
||||||
|
HeartRateDiagnosticPhase.RECEIVING -> colors.success
|
||||||
|
HeartRateDiagnosticPhase.ERROR -> colors.error
|
||||||
|
HeartRateDiagnosticPhase.CONNECTING,
|
||||||
|
HeartRateDiagnosticPhase.DISCOVERING,
|
||||||
|
HeartRateDiagnosticPhase.SUBSCRIBING,
|
||||||
|
HeartRateDiagnosticPhase.SCANNING -> colors.warning
|
||||||
|
else -> colors.muted
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (permissionDenied) {
|
||||||
|
TrainlogInfo(strings.getString(R.string.hr_diag_permission_denied), colors.error)
|
||||||
|
}
|
||||||
|
snapshot.diagnostic?.let {
|
||||||
|
TrainlogInfo(strings.getString(R.string.hr_diag_technical, it), colors.muted)
|
||||||
|
}
|
||||||
|
TrainlogInfo(
|
||||||
|
strings.getString(
|
||||||
|
R.string.hr_diag_services,
|
||||||
|
if (snapshot.serviceFound) strings.getString(R.string.yes) else strings.getString(R.string.no),
|
||||||
|
if (snapshot.measurementFound) strings.getString(R.string.yes) else strings.getString(R.string.no),
|
||||||
|
if (snapshot.batteryServiceFound) strings.getString(R.string.yes) else strings.getString(R.string.no),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
snapshot.latestMeasurement?.let { measurement ->
|
||||||
|
TrainlogInfo(
|
||||||
|
strings.getString(R.string.hr_diag_bpm, measurement.bpm),
|
||||||
|
colors.success,
|
||||||
|
)
|
||||||
|
TrainlogInfo(
|
||||||
|
strings.getString(
|
||||||
|
R.string.hr_diag_measurement_meta,
|
||||||
|
measurement.rrIntervals1024.size,
|
||||||
|
measurement.sensorContactDetected?.let { if (it) strings.getString(R.string.yes) else strings.getString(R.string.no) } ?: "—",
|
||||||
|
measurement.energyExpended?.toString() ?: "—",
|
||||||
|
),
|
||||||
|
colors.muted,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TrainlogInfo(
|
||||||
|
strings.getString(R.string.hr_diag_notifications, snapshot.notificationCount),
|
||||||
|
colors.muted,
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
snapshot.phase == HeartRateDiagnosticPhase.RECEIVING &&
|
||||||
|
snapshot.selectedAddress != null
|
||||||
|
) {
|
||||||
|
TrainlogButton(
|
||||||
|
strings.getString(R.string.hr_diag_use_sensor),
|
||||||
|
{
|
||||||
|
val address = checkNotNull(snapshot.selectedAddress)
|
||||||
|
val name =
|
||||||
|
snapshot.devices
|
||||||
|
.firstOrNull { it.address == address }
|
||||||
|
?.name
|
||||||
|
diagnostics.close()
|
||||||
|
HeartRateSensorPreferences(context).select(address, name)
|
||||||
|
HeartRateSensorService.start(context)
|
||||||
|
onSelected()
|
||||||
|
},
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
style = TrainlogButtonStyle.SUCCESS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TrainlogFrame(strings.getString(R.string.hr_diag_scan_section)) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
TrainlogButton(
|
||||||
|
strings.getString(R.string.hr_diag_scan),
|
||||||
|
{ scan(false) },
|
||||||
|
Modifier.weight(1f),
|
||||||
|
enabled = snapshot.phase != HeartRateDiagnosticPhase.SCANNING,
|
||||||
|
)
|
||||||
|
TrainlogButton(
|
||||||
|
strings.getString(R.string.hr_diag_scan_all),
|
||||||
|
{ scan(true) },
|
||||||
|
Modifier.weight(1f),
|
||||||
|
enabled = snapshot.phase != HeartRateDiagnosticPhase.SCANNING,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TrainlogInfo(strings.getString(R.string.hr_diag_scan_description), colors.muted)
|
||||||
|
}
|
||||||
|
|
||||||
|
TrainlogFrame(
|
||||||
|
strings.getString(R.string.hr_diag_devices),
|
||||||
|
active = snapshot.devices.isNotEmpty(),
|
||||||
|
) {
|
||||||
|
if (snapshot.devices.isEmpty()) {
|
||||||
|
TrainlogInfo(strings.getString(R.string.hr_diag_no_devices), colors.muted)
|
||||||
|
}
|
||||||
|
snapshot.devices.forEach { device ->
|
||||||
|
val title =
|
||||||
|
device.name?.takeIf { it.isNotBlank() }
|
||||||
|
?: strings.getString(R.string.hr_diag_unnamed_device)
|
||||||
|
val advertised =
|
||||||
|
strings.getString(
|
||||||
|
if (device.advertisesHeartRate) {
|
||||||
|
R.string.hr_diag_hrs_advertised
|
||||||
|
} else {
|
||||||
|
R.string.hr_diag_hrs_not_advertised
|
||||||
|
},
|
||||||
|
)
|
||||||
|
TrainlogAction(
|
||||||
|
title,
|
||||||
|
strings.getString(
|
||||||
|
R.string.hr_diag_device_description,
|
||||||
|
device.address,
|
||||||
|
device.rssi,
|
||||||
|
advertised,
|
||||||
|
),
|
||||||
|
{ diagnostics.connect(device.address) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
package com.labfytools.trainlog.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.scale
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.labfytools.trainlog.R
|
||||||
|
import com.labfytools.trainlog.data.HeartRateLivePhase
|
||||||
|
import com.labfytools.trainlog.data.HeartRateLiveState
|
||||||
|
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun HeartRateIndicator() {
|
||||||
|
var live by remember { mutableStateOf(HeartRateLiveState.current()) }
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
val subscription = HeartRateLiveState.subscribe { live = it }
|
||||||
|
onDispose { subscription.close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
val colors = LocalTrainlogColors.current
|
||||||
|
val strings = localizedContext()
|
||||||
|
val bpm =
|
||||||
|
live.bpm?.takeIf {
|
||||||
|
live.phase == HeartRateLivePhase.CONNECTED
|
||||||
|
}
|
||||||
|
val color =
|
||||||
|
when (live.phase) {
|
||||||
|
HeartRateLivePhase.CONNECTED -> colors.success
|
||||||
|
HeartRateLivePhase.CONNECTING,
|
||||||
|
HeartRateLivePhase.STALE -> colors.warning
|
||||||
|
HeartRateLivePhase.DISCONNECTED -> colors.muted
|
||||||
|
}
|
||||||
|
|
||||||
|
val beatPeriodMs =
|
||||||
|
bpm?.takeIf { it > 0 }
|
||||||
|
?.let { (60_000 / it).coerceIn(300, 2_000) }
|
||||||
|
?: 1_000
|
||||||
|
val transition = rememberInfiniteTransition(label = "heart-rate-pulse")
|
||||||
|
val scale by
|
||||||
|
transition.animateFloat(
|
||||||
|
initialValue = 1f,
|
||||||
|
targetValue = if (bpm != null) 1.14f else 1f,
|
||||||
|
animationSpec =
|
||||||
|
infiniteRepeatable(
|
||||||
|
animation = tween(durationMillis = beatPeriodMs / 2),
|
||||||
|
repeatMode = RepeatMode.Reverse,
|
||||||
|
),
|
||||||
|
label = "heart-rate-scale",
|
||||||
|
)
|
||||||
|
|
||||||
|
val description =
|
||||||
|
when (live.phase) {
|
||||||
|
HeartRateLivePhase.CONNECTED ->
|
||||||
|
strings.getString(R.string.heart_rate_a11y_connected, bpm ?: 0)
|
||||||
|
HeartRateLivePhase.CONNECTING ->
|
||||||
|
strings.getString(R.string.heart_rate_a11y_connecting)
|
||||||
|
HeartRateLivePhase.STALE ->
|
||||||
|
strings.getString(R.string.heart_rate_a11y_reconnecting)
|
||||||
|
HeartRateLivePhase.DISCONNECTED ->
|
||||||
|
strings.getString(R.string.heart_rate_a11y_disconnected)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(horizontal = 4.dp)
|
||||||
|
.semantics { contentDescription = description },
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(5.dp),
|
||||||
|
) {
|
||||||
|
androidx.compose.material3.Text(
|
||||||
|
"♥",
|
||||||
|
color = color,
|
||||||
|
fontSize = 20.sp,
|
||||||
|
modifier = Modifier.scale(scale),
|
||||||
|
)
|
||||||
|
androidx.compose.material3.Text(
|
||||||
|
bpm?.let { "$it BPM" } ?: "-- BPM",
|
||||||
|
color = color,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
|
@ -39,6 +40,8 @@ import com.labfytools.trainlog.data.StartAiSessionDraftResult
|
||||||
import com.labfytools.trainlog.data.TrainlogRepository
|
import com.labfytools.trainlog.data.TrainlogRepository
|
||||||
import com.labfytools.trainlog.data.AndroidBackupService
|
import com.labfytools.trainlog.data.AndroidBackupService
|
||||||
import com.labfytools.trainlog.data.AndroidBackupResult
|
import com.labfytools.trainlog.data.AndroidBackupResult
|
||||||
|
import com.labfytools.trainlog.data.HeartRateSensorPreferences
|
||||||
|
import com.labfytools.trainlog.data.HeartRateSensorService
|
||||||
import com.labfytools.trainlog.data.directStoragePermissionIntent
|
import com.labfytools.trainlog.data.directStoragePermissionIntent
|
||||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||||
import com.labfytools.trainlog.model.SessionExerciseDraft
|
import com.labfytools.trainlog.model.SessionExerciseDraft
|
||||||
|
|
@ -55,6 +58,7 @@ fun SessionsHub(
|
||||||
onDrafts: () -> Unit,
|
onDrafts: () -> Unit,
|
||||||
onHistory: () -> Unit,
|
onHistory: () -> Unit,
|
||||||
onPrograms: () -> Unit = {},
|
onPrograms: () -> Unit = {},
|
||||||
|
onCardioCalibration: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val colors = LocalTrainlogColors.current
|
val colors = LocalTrainlogColors.current
|
||||||
val strings = localizedContext()
|
val strings = localizedContext()
|
||||||
|
|
@ -74,6 +78,13 @@ fun SessionsHub(
|
||||||
Modifier.weight(1f).fillMaxHeight().testTag("sessions-drafts-action"))
|
Modifier.weight(1f).fillMaxHeight().testTag("sessions-drafts-action"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
TrainlogButton(
|
||||||
|
strings.getString(R.string.route_cardio_calibration),
|
||||||
|
onCardioCalibration,
|
||||||
|
Modifier.fillMaxWidth().testTag("sessions-cardio-calibration-action"),
|
||||||
|
style = TrainlogButtonStyle.SUCCESS,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
TrainlogButton(strings.getString(R.string.route_completed_sessions), onHistory,
|
TrainlogButton(strings.getString(R.string.route_completed_sessions), onHistory,
|
||||||
Modifier.fillMaxWidth().testTag("sessions-history-action"))
|
Modifier.fillMaxWidth().testTag("sessions-history-action"))
|
||||||
Spacer(Modifier.height(16.dp).testTag("sessions-programs-spacing"))
|
Spacer(Modifier.height(16.dp).testTag("sessions-programs-spacing"))
|
||||||
|
|
@ -472,13 +483,22 @@ private fun catalogCountsText(context: android.content.Context, imported: Int, r
|
||||||
).joinToString(", ")
|
).joinToString(", ")
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(repository: TrainlogRepository, inbox: SyncCatalogInbox, onCatalogChanged: () -> Unit) {
|
fun SettingsScreen(
|
||||||
|
repository: TrainlogRepository,
|
||||||
|
inbox: SyncCatalogInbox,
|
||||||
|
onHeartRateDiagnostics: () -> Unit,
|
||||||
|
onCatalogChanged: () -> Unit,
|
||||||
|
) {
|
||||||
val colors = LocalTrainlogColors.current
|
val colors = LocalTrainlogColors.current
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val language = LocalLanguagePresentation.current
|
val language = LocalLanguagePresentation.current
|
||||||
val strings = localizedContext()
|
val strings = localizedContext()
|
||||||
var authorized by remember { mutableStateOf(inbox.hasFolderAccess()) }
|
var authorized by remember { mutableStateOf(inbox.hasFolderAccess()) }
|
||||||
var message by remember { mutableStateOf<String?>(null) }
|
var message by remember { mutableStateOf<String?>(null) }
|
||||||
|
val heartRatePreferences = remember(context) { HeartRateSensorPreferences(context) }
|
||||||
|
var heartRateRevision by remember { mutableIntStateOf(0) }
|
||||||
|
val selectedHeartRateSensor =
|
||||||
|
remember(heartRateRevision) { heartRatePreferences.selected() }
|
||||||
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||||
authorized = inbox.hasFolderAccess()
|
authorized = inbox.hasFolderAccess()
|
||||||
message = strings.getString(if (authorized) R.string.settings_access_granted else R.string.settings_access_required)
|
message = strings.getString(if (authorized) R.string.settings_access_granted else R.string.settings_access_required)
|
||||||
|
|
@ -533,6 +553,29 @@ fun SettingsScreen(repository: TrainlogRepository, inbox: SyncCatalogInbox, onCa
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
TrainlogFrame(strings.getString(R.string.settings_cardio_section)) {
|
||||||
|
if (selectedHeartRateSensor != null) {
|
||||||
|
TrainlogInfo(
|
||||||
|
strings.getString(R.string.settings_cardio_configured),
|
||||||
|
colors.success,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TrainlogAction(
|
||||||
|
strings.getString(R.string.settings_cardio_diagnostics),
|
||||||
|
strings.getString(R.string.settings_cardio_diagnostics_description),
|
||||||
|
onHeartRateDiagnostics,
|
||||||
|
)
|
||||||
|
if (selectedHeartRateSensor != null) {
|
||||||
|
TrainlogAction(
|
||||||
|
strings.getString(R.string.settings_cardio_forget),
|
||||||
|
strings.getString(R.string.settings_cardio_forget_description),
|
||||||
|
{
|
||||||
|
HeartRateSensorService.forget(context)
|
||||||
|
heartRateRevision++
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
TrainlogFrame(strings.getString(R.string.settings_exchange_folder)) {
|
TrainlogFrame(strings.getString(R.string.settings_exchange_folder)) {
|
||||||
TrainlogInfo(strings.getString(if (authorized) R.string.settings_folder_granted else R.string.settings_folder_required), if (authorized) colors.success else colors.warning)
|
TrainlogInfo(strings.getString(if (authorized) R.string.settings_folder_granted else R.string.settings_folder_required), if (authorized) colors.success else colors.warning)
|
||||||
TrainlogAction(strings.getString(if (authorized) R.string.settings_open_access else R.string.settings_authorize), strings.getString(R.string.settings_authorize_description), { launcher.launch(directStoragePermissionIntent(context)) })
|
TrainlogAction(strings.getString(if (authorized) R.string.settings_open_access else R.string.settings_authorize), strings.getString(R.string.settings_authorize_description), { launcher.launch(directStoragePermissionIntent(context)) })
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,10 @@ import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.foundation.text.BasicText
|
import androidx.compose.foundation.text.BasicText
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
|
@ -56,6 +59,7 @@ import com.labfytools.trainlog.data.FinalizeActiveDraftResult
|
||||||
import com.labfytools.trainlog.data.ManualPercentMaxResult
|
import com.labfytools.trainlog.data.ManualPercentMaxResult
|
||||||
import com.labfytools.trainlog.data.SaveFeedbackResult
|
import com.labfytools.trainlog.data.SaveFeedbackResult
|
||||||
import com.labfytools.trainlog.data.TrainlogRepository
|
import com.labfytools.trainlog.data.TrainlogRepository
|
||||||
|
import com.labfytools.trainlog.data.TrainlogRepository.SessionExerciseTimingResult
|
||||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||||
import com.labfytools.trainlog.model.ExerciseDataFields
|
import com.labfytools.trainlog.model.ExerciseDataFields
|
||||||
import com.labfytools.trainlog.model.ExerciseProfile
|
import com.labfytools.trainlog.model.ExerciseProfile
|
||||||
|
|
@ -152,6 +156,9 @@ fun SessionScreen(
|
||||||
var pendingDestructiveEdit by remember { mutableStateOf<Pair<Int, SessionExerciseDraft>?>(null) }
|
var pendingDestructiveEdit by remember { mutableStateOf<Pair<Int, SessionExerciseDraft>?>(null) }
|
||||||
var feedbackRevision by remember { mutableStateOf(0) }
|
var feedbackRevision by remember { mutableStateOf(0) }
|
||||||
var reorderedDuringGesture by remember { mutableStateOf(false) }
|
var reorderedDuringGesture by remember { mutableStateOf(false) }
|
||||||
|
var timelineRevision by remember { mutableStateOf(0) }
|
||||||
|
var pendingExerciseSwitch by remember { mutableStateOf<Pair<String, String>?>(null) }
|
||||||
|
var pendingFinalizeActiveEntryId by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
val persistDraft:
|
val persistDraft:
|
||||||
(ActiveSessionDraft, String?) -> Unit =
|
(ActiveSessionDraft, String?) -> Unit =
|
||||||
|
|
@ -191,6 +198,12 @@ fun SessionScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
val currentDraft = activeDraft!!
|
val currentDraft = activeDraft!!
|
||||||
|
val exerciseTimings =
|
||||||
|
remember(timelineRevision, currentDraft.exercises.map { it.entryId }) {
|
||||||
|
repository.listActiveSessionExerciseTimings()
|
||||||
|
}
|
||||||
|
val timingByEntry = exerciseTimings.associateBy { it.entryId }
|
||||||
|
val activeTiming = exerciseTimings.firstOrNull { it.active }
|
||||||
|
|
||||||
val lastWriteFailed =
|
val lastWriteFailed =
|
||||||
message?.startsWith(
|
message?.startsWith(
|
||||||
|
|
@ -243,6 +256,19 @@ fun SessionScreen(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
val cardio = currentDraft.sessionType == SessionType.CARDIO
|
||||||
|
TrainlogButton(
|
||||||
|
label = strings.getString(R.string.session_cardio),
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
style = if (cardio) TrainlogButtonStyle.SUCCESS else TrainlogButtonStyle.SECONDARY,
|
||||||
|
maxLines = 2,
|
||||||
|
onClick = {
|
||||||
|
persistDraft(
|
||||||
|
currentDraft.copy(sessionType = SessionType.CARDIO),
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -266,23 +292,19 @@ fun SessionScreen(
|
||||||
) {
|
) {
|
||||||
TrainlogInfo(
|
TrainlogInfo(
|
||||||
text =
|
text =
|
||||||
strings.getString(R.string.session_type_value,
|
strings.getString(
|
||||||
if (
|
R.string.session_type_value,
|
||||||
currentDraft.sessionType ==
|
when (currentDraft.sessionType) {
|
||||||
SessionType.MAX_TEST
|
SessionType.TRAINING -> strings.getString(R.string.session_training)
|
||||||
) {
|
SessionType.MAX_TEST -> strings.getString(R.string.session_max_test)
|
||||||
strings.getString(R.string.session_max_test)
|
SessionType.CARDIO -> strings.getString(R.string.session_cardio)
|
||||||
} else {
|
},
|
||||||
strings.getString(R.string.session_training)
|
),
|
||||||
}),
|
|
||||||
color =
|
color =
|
||||||
if (
|
when (currentDraft.sessionType) {
|
||||||
currentDraft.sessionType ==
|
SessionType.MAX_TEST -> colors.warning
|
||||||
SessionType.MAX_TEST
|
SessionType.CARDIO -> colors.success
|
||||||
) {
|
SessionType.TRAINING -> colors.accent
|
||||||
colors.warning
|
|
||||||
} else {
|
|
||||||
colors.accent
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -357,6 +379,98 @@ fun SessionScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val timing = timingByEntry[draft.entryId]
|
||||||
|
when {
|
||||||
|
timing == null -> {
|
||||||
|
TrainlogButton(
|
||||||
|
label = strings.getString(R.string.exercise_timeline_start),
|
||||||
|
onClick = {
|
||||||
|
when (
|
||||||
|
val result =
|
||||||
|
repository.startActiveSessionExercise(draft.entryId)
|
||||||
|
) {
|
||||||
|
is SessionExerciseTimingResult.Started -> {
|
||||||
|
timelineRevision++
|
||||||
|
message =
|
||||||
|
strings.getString(
|
||||||
|
R.string.exercise_timeline_started,
|
||||||
|
draft.exercise.name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is SessionExerciseTimingResult.ActiveConflict -> {
|
||||||
|
pendingExerciseSwitch =
|
||||||
|
result.activeEntryId to draft.entryId
|
||||||
|
}
|
||||||
|
is SessionExerciseTimingResult.Invalid ->
|
||||||
|
message = result.message
|
||||||
|
is SessionExerciseTimingResult.DatabaseError ->
|
||||||
|
message = result.message
|
||||||
|
is SessionExerciseTimingResult.Finished -> Unit
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.testTag("start-exercise-${draft.entryId}"),
|
||||||
|
style = TrainlogButtonStyle.SUCCESS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
timing.active -> {
|
||||||
|
TrainlogButton(
|
||||||
|
label = strings.getString(R.string.exercise_timeline_finish),
|
||||||
|
onClick = {
|
||||||
|
when (
|
||||||
|
val result =
|
||||||
|
repository.finishActiveSessionExercise(draft.entryId)
|
||||||
|
) {
|
||||||
|
is SessionExerciseTimingResult.Finished -> {
|
||||||
|
timelineRevision++
|
||||||
|
message =
|
||||||
|
strings.getString(
|
||||||
|
R.string.exercise_timeline_finished,
|
||||||
|
draft.exercise.name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is SessionExerciseTimingResult.Invalid ->
|
||||||
|
message = result.message
|
||||||
|
is SessionExerciseTimingResult.DatabaseError ->
|
||||||
|
message = result.message
|
||||||
|
is SessionExerciseTimingResult.Started,
|
||||||
|
is SessionExerciseTimingResult.ActiveConflict -> Unit
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.testTag("finish-exercise-${draft.entryId}"),
|
||||||
|
containerColor = colors.warning,
|
||||||
|
style = TrainlogButtonStyle.SECONDARY,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
TrainlogButton(
|
||||||
|
label = strings.getString(R.string.exercise_timeline_completed),
|
||||||
|
onClick = {},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.testTag("completed-exercise-${draft.entryId}"),
|
||||||
|
enabled = false,
|
||||||
|
style = TrainlogButtonStyle.SECONDARY,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
currentDraft.sessionType == SessionType.CARDIO &&
|
||||||
|
timing?.active == true
|
||||||
|
) {
|
||||||
|
CardioGuidancePanel(
|
||||||
|
repository = repository,
|
||||||
|
entryId = draft.entryId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
TrainlogAction(
|
TrainlogAction(
|
||||||
label = strings.getString(R.string.modify_name, draft.exercise.name),
|
label = strings.getString(R.string.modify_name, draft.exercise.name),
|
||||||
description = strings.getString(R.string.edit_exercise_description),
|
description = strings.getString(R.string.edit_exercise_description),
|
||||||
|
|
@ -518,22 +632,30 @@ fun SessionScreen(
|
||||||
Modifier.weight(1f).fillMaxHeight().testTag("active-session-create-exercise"),
|
Modifier.weight(1f).fillMaxHeight().testTag("active-session-create-exercise"),
|
||||||
containerColor = colors.surfaceAlt, maxLines = 2)
|
containerColor = colors.surfaceAlt, maxLines = 2)
|
||||||
TrainlogButton(strings.getString(R.string.session_save), onClick = {
|
TrainlogButton(strings.getString(R.string.session_save), onClick = {
|
||||||
when (
|
val activeEntryId = repository.activeSessionTimelineContext()?.activeEntryId
|
||||||
val result =
|
if (activeEntryId != null) {
|
||||||
repository.finalizeActiveSessionDraft()
|
pendingFinalizeActiveEntryId = activeEntryId
|
||||||
) {
|
} else {
|
||||||
is FinalizeActiveDraftResult.Saved -> {
|
when (
|
||||||
onSessionSaved()
|
val result =
|
||||||
onBack()
|
repository.finalizeActiveSessionDraft()
|
||||||
}
|
) {
|
||||||
|
is FinalizeActiveDraftResult.Saved -> {
|
||||||
|
onSessionSaved()
|
||||||
|
onBack()
|
||||||
|
}
|
||||||
|
|
||||||
is FinalizeActiveDraftResult.Invalid -> {
|
is FinalizeActiveDraftResult.Invalid -> {
|
||||||
message = localizedRepositoryMessage(strings, result.message)
|
message = localizedRepositoryMessage(strings, result.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
is FinalizeActiveDraftResult.DatabaseError -> {
|
is FinalizeActiveDraftResult.DatabaseError -> {
|
||||||
message =
|
message =
|
||||||
strings.getString(R.string.finalize_failed, localizedRepositoryMessage(strings, result.message))
|
strings.getString(
|
||||||
|
R.string.finalize_failed,
|
||||||
|
localizedRepositoryMessage(strings, result.message),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, modifier = Modifier.weight(1f).fillMaxHeight().testTag("active-session-save"),
|
}, modifier = Modifier.weight(1f).fillMaxHeight().testTag("active-session-save"),
|
||||||
|
|
@ -589,7 +711,19 @@ fun SessionScreen(
|
||||||
strings.getString(R.string.exercise_added) ||
|
strings.getString(R.string.exercise_added) ||
|
||||||
message ==
|
message ==
|
||||||
strings.getString(R.string.exercise_removed) ||
|
strings.getString(R.string.exercise_removed) ||
|
||||||
message == strings.getString(R.string.exercise_order_saved)
|
message == strings.getString(R.string.exercise_order_saved) ||
|
||||||
|
currentDraft.exercises.any { exercise ->
|
||||||
|
message ==
|
||||||
|
strings.getString(
|
||||||
|
R.string.exercise_timeline_started,
|
||||||
|
exercise.exercise.name,
|
||||||
|
) ||
|
||||||
|
message ==
|
||||||
|
strings.getString(
|
||||||
|
R.string.exercise_timeline_finished,
|
||||||
|
exercise.exercise.name,
|
||||||
|
)
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
colors.success
|
colors.success
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -640,6 +774,138 @@ fun SessionScreen(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pendingExerciseSwitch?.let { (activeEntryId, targetEntryId) ->
|
||||||
|
val activeName =
|
||||||
|
currentDraft.exercises.firstOrNull { it.entryId == activeEntryId }
|
||||||
|
?.exercise?.name
|
||||||
|
?: strings.getString(R.string.exercise_title)
|
||||||
|
val targetName =
|
||||||
|
currentDraft.exercises.firstOrNull { it.entryId == targetEntryId }
|
||||||
|
?.exercise?.name
|
||||||
|
?: strings.getString(R.string.exercise_title)
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { pendingExerciseSwitch = null },
|
||||||
|
title = { Text(strings.getString(R.string.exercise_timeline_switch_title)) },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
strings.getString(
|
||||||
|
R.string.exercise_timeline_switch_detail,
|
||||||
|
activeName,
|
||||||
|
targetName,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
when (
|
||||||
|
val finished =
|
||||||
|
repository.finishActiveSessionExercise(activeEntryId)
|
||||||
|
) {
|
||||||
|
is SessionExerciseTimingResult.Finished -> {
|
||||||
|
when (
|
||||||
|
val started =
|
||||||
|
repository.startActiveSessionExercise(targetEntryId)
|
||||||
|
) {
|
||||||
|
is SessionExerciseTimingResult.Started -> {
|
||||||
|
timelineRevision++
|
||||||
|
message =
|
||||||
|
strings.getString(
|
||||||
|
R.string.exercise_timeline_started,
|
||||||
|
targetName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is SessionExerciseTimingResult.Invalid ->
|
||||||
|
message = started.message
|
||||||
|
is SessionExerciseTimingResult.DatabaseError ->
|
||||||
|
message = started.message
|
||||||
|
is SessionExerciseTimingResult.ActiveConflict,
|
||||||
|
is SessionExerciseTimingResult.Finished -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is SessionExerciseTimingResult.Invalid ->
|
||||||
|
message = finished.message
|
||||||
|
is SessionExerciseTimingResult.DatabaseError ->
|
||||||
|
message = finished.message
|
||||||
|
is SessionExerciseTimingResult.Started,
|
||||||
|
is SessionExerciseTimingResult.ActiveConflict -> Unit
|
||||||
|
}
|
||||||
|
pendingExerciseSwitch = null
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(strings.getString(R.string.exercise_timeline_switch_confirm))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { pendingExerciseSwitch = null }) {
|
||||||
|
Text(strings.getString(R.string.dialog_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingFinalizeActiveEntryId?.let { activeEntryId ->
|
||||||
|
val activeName =
|
||||||
|
currentDraft.exercises.firstOrNull { it.entryId == activeEntryId }
|
||||||
|
?.exercise?.name
|
||||||
|
?: strings.getString(R.string.exercise_title)
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { pendingFinalizeActiveEntryId = null },
|
||||||
|
title = { Text(strings.getString(R.string.exercise_timeline_finalize_title)) },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
strings.getString(
|
||||||
|
R.string.exercise_timeline_finalize_detail,
|
||||||
|
activeName,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
when (
|
||||||
|
val finished =
|
||||||
|
repository.finishActiveSessionExercise(activeEntryId)
|
||||||
|
) {
|
||||||
|
is SessionExerciseTimingResult.Finished -> {
|
||||||
|
timelineRevision++
|
||||||
|
when (val result = repository.finalizeActiveSessionDraft()) {
|
||||||
|
is FinalizeActiveDraftResult.Saved -> {
|
||||||
|
pendingFinalizeActiveEntryId = null
|
||||||
|
onSessionSaved()
|
||||||
|
onBack()
|
||||||
|
}
|
||||||
|
is FinalizeActiveDraftResult.Invalid ->
|
||||||
|
message = localizedRepositoryMessage(strings, result.message)
|
||||||
|
is FinalizeActiveDraftResult.DatabaseError ->
|
||||||
|
message =
|
||||||
|
strings.getString(
|
||||||
|
R.string.finalize_failed,
|
||||||
|
localizedRepositoryMessage(strings, result.message),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is SessionExerciseTimingResult.Invalid ->
|
||||||
|
message = finished.message
|
||||||
|
is SessionExerciseTimingResult.DatabaseError ->
|
||||||
|
message = finished.message
|
||||||
|
is SessionExerciseTimingResult.Started,
|
||||||
|
is SessionExerciseTimingResult.ActiveConflict -> Unit
|
||||||
|
}
|
||||||
|
pendingFinalizeActiveEntryId = null
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(strings.getString(R.string.exercise_timeline_finalize_confirm))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { pendingFinalizeActiveEntryId = null }) {
|
||||||
|
Text(strings.getString(R.string.dialog_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -37,8 +38,271 @@ import java.time.LocalDate
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SleepDiaryScreen(repository: TrainlogRepository) {
|
fun SleepDiaryScreen(repository: TrainlogRepository) {
|
||||||
|
var correctionMode by remember { mutableStateOf(false) }
|
||||||
|
if (correctionMode) {
|
||||||
|
Column(Modifier.fillMaxSize()) {
|
||||||
|
Button(
|
||||||
|
onClick = { correctionMode = false },
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.sleep_return_night_mode))
|
||||||
|
}
|
||||||
|
SleepDiaryEditorScreen(repository)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
SleepNightCaptureScreen(repository) { correctionMode = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SleepNightCaptureScreen(
|
||||||
|
repository: TrainlogRepository,
|
||||||
|
onCorrection: () -> Unit,
|
||||||
|
) {
|
||||||
|
var revision by remember { mutableStateOf(0) }
|
||||||
|
var undoReceipt by remember {
|
||||||
|
mutableStateOf<TrainlogRepository.SleepQuickActionReceipt?>(null)
|
||||||
|
}
|
||||||
|
var message by remember { mutableStateOf<String?>(null) }
|
||||||
|
val strings = localizedContext()
|
||||||
|
val entries = remember(revision) { repository.listSleepDiary() }
|
||||||
|
val active =
|
||||||
|
entries.firstOrNull { entry ->
|
||||||
|
entry.events.any { it.type == SleepEventType.BED_TIME } &&
|
||||||
|
entry.events.none { it.type == SleepEventType.FINAL_GET_UP }
|
||||||
|
}
|
||||||
|
val pending =
|
||||||
|
entries.firstOrNull { entry ->
|
||||||
|
entry.events.none { it.type == SleepEventType.BED_TIME } &&
|
||||||
|
entry.events.none { it.type == SleepEventType.FINAL_GET_UP }
|
||||||
|
}
|
||||||
|
val visibleNight = active ?: pending
|
||||||
|
val medications =
|
||||||
|
remember(revision) {
|
||||||
|
repository.listSleepMedications(includeInactive = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(undoReceipt?.appliedRevisionId) {
|
||||||
|
if (undoReceipt != null) {
|
||||||
|
delay(10_000)
|
||||||
|
undoReceipt = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun apply(
|
||||||
|
successMessage: String,
|
||||||
|
result: TrainlogRepository.SleepQuickActionResult,
|
||||||
|
) {
|
||||||
|
when (result) {
|
||||||
|
is TrainlogRepository.SleepQuickActionResult.Applied -> {
|
||||||
|
undoReceipt = result.receipt
|
||||||
|
message = successMessage
|
||||||
|
revision++
|
||||||
|
}
|
||||||
|
TrainlogRepository.SleepQuickActionResult.NoActiveNight ->
|
||||||
|
message = strings.getString(R.string.sleep_quick_no_active)
|
||||||
|
TrainlogRepository.SleepQuickActionResult.AlreadyActive ->
|
||||||
|
message = strings.getString(R.string.sleep_quick_already_active)
|
||||||
|
TrainlogRepository.SleepQuickActionResult.InvalidMedication ->
|
||||||
|
message = strings.getString(R.string.sleep_quick_medication_unavailable)
|
||||||
|
TrainlogRepository.SleepQuickActionResult.Conflict ->
|
||||||
|
message = strings.getString(R.string.sleep_quick_conflict)
|
||||||
|
TrainlogRepository.SleepQuickActionResult.Error ->
|
||||||
|
message = strings.getString(R.string.sleep_quick_error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Text(stringResource(R.string.sleep_night_capture_title))
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
visibleNight?.let { night ->
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
R.string.sleep_night_in_progress,
|
||||||
|
night.nightStartDate,
|
||||||
|
night.nightEndDate,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} ?: Text(stringResource(R.string.sleep_no_night_active))
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
apply(
|
||||||
|
strings.getString(R.string.sleep_bed_recorded),
|
||||||
|
repository.quickSleepBedTime(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = active == null,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.sleep_quick_bed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (medications.isNotEmpty()) {
|
||||||
|
item { Text(stringResource(R.string.sleep_medications)) }
|
||||||
|
items(medications, key = { it.medicationId }) { medication ->
|
||||||
|
var quantity by remember(medication.medicationId) { mutableStateOf(1) }
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Button(onClick = { if (quantity > 1) quantity-- }) { Text("−") }
|
||||||
|
Text("×$quantity", modifier = Modifier.padding(top = 12.dp))
|
||||||
|
Button(onClick = { if (quantity < 99) quantity++ }) { Text("+") }
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val selectedQuantity = quantity
|
||||||
|
val result =
|
||||||
|
repository.quickSleepMedication(
|
||||||
|
medication.medicationId,
|
||||||
|
quantity = selectedQuantity,
|
||||||
|
)
|
||||||
|
apply(
|
||||||
|
strings.getString(
|
||||||
|
R.string.sleep_medication_recorded,
|
||||||
|
medication.name +
|
||||||
|
if (selectedQuantity > 1) " ×$selectedQuantity" else "",
|
||||||
|
),
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
if (result is TrainlogRepository.SleepQuickActionResult.Applied) {
|
||||||
|
quantity = 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
val dose =
|
||||||
|
medication.defaultDoseValue?.let { doseValue ->
|
||||||
|
" · " + doseValue.toString().trimEnd('0').trimEnd('.') +
|
||||||
|
" " + medication.defaultDoseUnit.orEmpty()
|
||||||
|
}.orEmpty()
|
||||||
|
Text("💊 " + medication.name + dose)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
apply(
|
||||||
|
strings.getString(R.string.sleep_wake_recorded),
|
||||||
|
repository.quickSleepWake(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = active != null,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.sleep_quick_wake))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
apply(
|
||||||
|
strings.getString(R.string.sleep_get_up_recorded),
|
||||||
|
repository.quickSleepFinalGetUp(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = active != null,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.sleep_quick_get_up))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message?.let { text ->
|
||||||
|
item {
|
||||||
|
Card {
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxWidth().padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(text)
|
||||||
|
undoReceipt?.let { receipt ->
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
when (repository.undoSleepQuickAction(receipt)) {
|
||||||
|
is TrainlogRepository.SleepQuickActionResult.Applied -> {
|
||||||
|
message = strings.getString(R.string.sleep_action_undone)
|
||||||
|
undoReceipt = null
|
||||||
|
revision++
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
message = strings.getString(R.string.sleep_undo_unavailable)
|
||||||
|
undoReceipt = null
|
||||||
|
revision++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.sleep_undo))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visibleNight?.let { night ->
|
||||||
|
item {
|
||||||
|
Card {
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxWidth().padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(5.dp),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.sleep_night_timeline))
|
||||||
|
night.events.sortedBy { it.startAt }.forEach { event ->
|
||||||
|
Text(eventLabelPlain(event.type, strings) + " · " + shortTime(event.startAt))
|
||||||
|
}
|
||||||
|
night.intakes.sortedBy { it.takenAt }.forEach { intake ->
|
||||||
|
Text(
|
||||||
|
"💊 " + intake.medicationName +
|
||||||
|
(if (intake.quantity > 1) " ×${intake.quantity}" else "") +
|
||||||
|
" · " + shortTime(intake.takenAt),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Button(
|
||||||
|
onClick = onCorrection,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.sleep_correct_night))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shortTime(value: String): String =
|
||||||
|
runCatching { OffsetDateTime.parse(value).toLocalTime().withSecond(0).withNano(0).toString() }
|
||||||
|
.getOrDefault(value)
|
||||||
|
|
||||||
|
private fun eventLabelPlain(type: SleepEventType, context: android.content.Context): String =
|
||||||
|
context.getString(
|
||||||
|
when (type) {
|
||||||
|
SleepEventType.BED_TIME -> R.string.sleep_bed_time
|
||||||
|
SleepEventType.FINAL_GET_UP -> R.string.sleep_final_get_up
|
||||||
|
SleepEventType.NIGHT_GET_UP -> R.string.sleep_night_get_up
|
||||||
|
SleepEventType.SLEEP -> R.string.sleep_sleep
|
||||||
|
SleepEventType.NAP -> R.string.sleep_nap
|
||||||
|
SleepEventType.LONG_AWAKE -> R.string.sleep_long_awake
|
||||||
|
SleepEventType.HALF_SLEEP -> R.string.sleep_half_sleep
|
||||||
|
SleepEventType.DAYTIME_SLEEPINESS -> R.string.sleep_sleepiness
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SleepDiaryEditorScreen(repository: TrainlogRepository) {
|
||||||
var revision by remember { mutableStateOf(0) }
|
var revision by remember { mutableStateOf(0) }
|
||||||
var selected by remember(revision) { mutableStateOf<SleepDiaryEntry?>(repository.listSleepDiary().firstOrNull()) }
|
var selected by remember(revision) { mutableStateOf<SleepDiaryEntry?>(repository.listSleepDiary().firstOrNull()) }
|
||||||
val today = LocalDate.now()
|
val today = LocalDate.now()
|
||||||
|
|
|
||||||
|
|
@ -103,8 +103,16 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
||||||
},
|
},
|
||||||
{ open(AppRoute.BodyMeasurements) }, { open(AppRoute.Sync) },
|
{ open(AppRoute.BodyMeasurements) }, { open(AppRoute.Sync) },
|
||||||
)
|
)
|
||||||
AppRoute.Sessions -> SessionsHub(activeDraft, pendingAiDraftCount, { open(AppRoute.SessionEditor) }, ::openManualSession,
|
AppRoute.Sessions -> SessionsHub(
|
||||||
{ open(AppRoute.AiSessionDrafts) }, { open(AppRoute.CompletedSessions) }, { open(AppRoute.Programs) })
|
activeDraft,
|
||||||
|
pendingAiDraftCount,
|
||||||
|
{ open(AppRoute.SessionEditor) },
|
||||||
|
::openManualSession,
|
||||||
|
{ open(AppRoute.AiSessionDrafts) },
|
||||||
|
{ open(AppRoute.CompletedSessions) },
|
||||||
|
{ open(AppRoute.Programs) },
|
||||||
|
{ open(AppRoute.CardioCalibration) },
|
||||||
|
)
|
||||||
AppRoute.SessionEditor -> SessionScreen(
|
AppRoute.SessionEditor -> SessionScreen(
|
||||||
repository,
|
repository,
|
||||||
catalogRevision,
|
catalogRevision,
|
||||||
|
|
@ -137,6 +145,10 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
||||||
)
|
)
|
||||||
AppRoute.CompletedSessions -> HistoryScreen(repository, { back() }) { open(AppRoute.SessionDetail(it)) }
|
AppRoute.CompletedSessions -> HistoryScreen(repository, { back() }) { open(AppRoute.SessionDetail(it)) }
|
||||||
AppRoute.Programs -> ProgramsScreen(repository) { open(AppRoute.ProgramDetail(it)) }
|
AppRoute.Programs -> ProgramsScreen(repository) { open(AppRoute.ProgramDetail(it)) }
|
||||||
|
AppRoute.CardioCalibration -> CardioCalibrationScreen(repository) {
|
||||||
|
draftRevision++
|
||||||
|
exportSnapshot()
|
||||||
|
}
|
||||||
is AppRoute.ProgramDetail -> ProgramDetailScreen(repository, route.programId) {
|
is AppRoute.ProgramDetail -> ProgramDetailScreen(repository, route.programId) {
|
||||||
draftRevision++
|
draftRevision++
|
||||||
open(AppRoute.SessionEditor)
|
open(AppRoute.SessionEditor)
|
||||||
|
|
@ -195,7 +207,8 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
||||||
AppRoute.SleepDiary -> SleepDiaryScreen(repository)
|
AppRoute.SleepDiary -> SleepDiaryScreen(repository)
|
||||||
AppRoute.LatestMaxima -> LatestMaximaScreen(repository)
|
AppRoute.LatestMaxima -> LatestMaximaScreen(repository)
|
||||||
AppRoute.Sync -> SyncScreen(repository, inbox, exporter, requestOutbox, { exportSnapshot(); catalogRevision++ }, { back() })
|
AppRoute.Sync -> SyncScreen(repository, inbox, exporter, requestOutbox, { exportSnapshot(); catalogRevision++ }, { back() })
|
||||||
AppRoute.Settings -> SettingsScreen(repository, inbox) { exportSnapshot(); catalogRevision++ }
|
AppRoute.Settings -> SettingsScreen(repository, inbox, { open(AppRoute.HeartRateDiagnostics) }) { exportSnapshot(); catalogRevision++ }
|
||||||
|
AppRoute.HeartRateDiagnostics -> HeartRateDiagnosticsScreen { back() }
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -480,4 +480,141 @@
|
||||||
<string name="sleep_unit">Unit</string>
|
<string name="sleep_unit">Unit</string>
|
||||||
<string name="sleep_deactivate">Deactivate</string>
|
<string name="sleep_deactivate">Deactivate</string>
|
||||||
<string name="sleep_edit">Edit</string>
|
<string name="sleep_edit">Edit</string>
|
||||||
|
<string name="yes">Yes</string>
|
||||||
|
<string name="no">No</string>
|
||||||
|
<string name="route_heart_rate_diagnostics">BLE heart-rate diagnostics</string>
|
||||||
|
<string name="settings_cardio_section">Cardio</string>
|
||||||
|
<string name="settings_cardio_diagnostics">Heart-rate sensor diagnostics</string>
|
||||||
|
<string name="settings_cardio_diagnostics_description">Find and verify a Bluetooth Heart Rate Service sensor.</string>
|
||||||
|
<string name="hr_diag_status">Sensor status</string>
|
||||||
|
<string name="hr_diag_idle">Ready</string>
|
||||||
|
<string name="hr_diag_scanning">Scanning…</string>
|
||||||
|
<string name="hr_diag_connecting">Connecting…</string>
|
||||||
|
<string name="hr_diag_discovering">Discovering services…</string>
|
||||||
|
<string name="hr_diag_subscribing">Enabling measurements…</string>
|
||||||
|
<string name="hr_diag_receiving">Measurements received</string>
|
||||||
|
<string name="hr_diag_disconnected">Disconnected</string>
|
||||||
|
<string name="hr_diag_error">BLE error</string>
|
||||||
|
<string name="hr_diag_permission_denied">Bluetooth permission denied.</string>
|
||||||
|
<string name="hr_diag_technical">Diagnostic: %1$s</string>
|
||||||
|
<string name="hr_diag_services">Heart Rate Service: %1$s · measurement: %2$s · battery: %3$s</string>
|
||||||
|
<string name="hr_diag_bpm">%1$d BPM</string>
|
||||||
|
<string name="hr_diag_measurement_meta">%1$d RR · contact %2$s · energy %3$s</string>
|
||||||
|
<string name="hr_diag_notifications">Notifications received: %1$d</string>
|
||||||
|
<string name="hr_diag_scan_section">BLE scan</string>
|
||||||
|
<string name="hr_diag_scan">Find heart-rate</string>
|
||||||
|
<string name="hr_diag_scan_all">Expanded scan</string>
|
||||||
|
<string name="hr_diag_scan_description">Heart-rate scan lasts at most 10 seconds. Expanded scan is for sensors that do not advertise their service in BLE advertisements.</string>
|
||||||
|
<string name="hr_diag_devices">BLE devices</string>
|
||||||
|
<string name="hr_diag_no_devices">No device found yet.</string>
|
||||||
|
<string name="hr_diag_unnamed_device">Unnamed device</string>
|
||||||
|
<string name="hr_diag_hrs_advertised">Heart Rate advertised</string>
|
||||||
|
<string name="hr_diag_hrs_not_advertised">Heart Rate not advertised</string>
|
||||||
|
<string name="hr_diag_device_description">%1$s · %2$d dBm · %3$s</string>
|
||||||
|
<string name="hr_diag_use_sensor">Use this sensor</string>
|
||||||
|
<string name="heart_rate_service_channel">Heart-rate sensor</string>
|
||||||
|
<string name="heart_rate_service_title">Trainlog · cardio</string>
|
||||||
|
<string name="heart_rate_service_connecting">Connecting to sensor…</string>
|
||||||
|
<string name="heart_rate_service_reconnecting">Waiting for heart-rate signal…</string>
|
||||||
|
<string name="heart_rate_service_disconnected">Heart-rate sensor disconnected</string>
|
||||||
|
<string name="heart_rate_service_bpm">%1$d BPM</string>
|
||||||
|
<string name="heart_rate_a11y_connected">Heart rate %1$d BPM, sensor connected</string>
|
||||||
|
<string name="heart_rate_a11y_connecting">Heart-rate sensor connecting</string>
|
||||||
|
<string name="heart_rate_a11y_reconnecting">Heart-rate signal temporarily lost, reconnecting</string>
|
||||||
|
<string name="heart_rate_a11y_disconnected">Heart-rate sensor disconnected</string>
|
||||||
|
<string name="settings_cardio_configured">Heart-rate sensor configured.</string>
|
||||||
|
<string name="settings_cardio_forget">Forget sensor</string>
|
||||||
|
<string name="settings_cardio_forget_description">Stop heart-rate connection and remove the remembered sensor.</string>
|
||||||
|
<string name="exercise_timeline_start">Start exercise</string>
|
||||||
|
<string name="exercise_timeline_finish">Finish exercise</string>
|
||||||
|
<string name="exercise_timeline_completed">Exercise completed</string>
|
||||||
|
<string name="exercise_timeline_started">%1$s · started</string>
|
||||||
|
<string name="exercise_timeline_finished">%1$s · finished</string>
|
||||||
|
<string name="exercise_timeline_switch_title">Switch exercise?</string>
|
||||||
|
<string name="exercise_timeline_switch_detail">%1$s is still active. Finish it now and then start %2$s?</string>
|
||||||
|
<string name="exercise_timeline_switch_confirm">Finish and start</string>
|
||||||
|
<string name="exercise_timeline_finalize_title">An exercise is still active</string>
|
||||||
|
<string name="exercise_timeline_finalize_detail">%1$s is still active. Finish it now and then save the session?</string>
|
||||||
|
<string name="exercise_timeline_finalize_confirm">Finish and save</string>
|
||||||
|
|
||||||
|
<string name="sleep_night_capture_title">Night capture</string>
|
||||||
|
<string name="sleep_no_night_active">No night in progress.</string>
|
||||||
|
<string name="sleep_night_in_progress">Night in progress · %1$s → %2$s</string>
|
||||||
|
<string name="sleep_quick_bed">🛏 BED</string>
|
||||||
|
<string name="sleep_quick_wake">👁 WAKE</string>
|
||||||
|
<string name="sleep_quick_get_up">☀ GET UP</string>
|
||||||
|
<string name="sleep_bed_recorded">Bed time recorded.</string>
|
||||||
|
<string name="sleep_wake_recorded">Wake recorded.</string>
|
||||||
|
<string name="sleep_get_up_recorded">Get-up recorded.</string>
|
||||||
|
<string name="sleep_medication_recorded">%1$s recorded.</string>
|
||||||
|
<string name="sleep_undo">UNDO</string>
|
||||||
|
<string name="sleep_action_undone">Last action undone.</string>
|
||||||
|
<string name="sleep_undo_unavailable">Undo unavailable: the night has already changed.</string>
|
||||||
|
<string name="sleep_correct_night">Correct the night</string>
|
||||||
|
<string name="sleep_return_night_mode">Back to night capture</string>
|
||||||
|
<string name="sleep_night_timeline">Night timeline</string>
|
||||||
|
|
||||||
|
<string name="sleep_quick_no_active">No night in progress.</string>
|
||||||
|
<string name="sleep_quick_already_active">A night is already in progress.</string>
|
||||||
|
<string name="sleep_quick_medication_unavailable">Medication unavailable.</string>
|
||||||
|
<string name="sleep_quick_conflict">The night changed; reload before trying again.</string>
|
||||||
|
<string name="sleep_quick_error">Unable to record the action.</string>
|
||||||
|
<string name="session_cardio">Cardio</string>
|
||||||
|
<string name="route_cardio_calibration">Cardio calibration</string>
|
||||||
|
<string name="calibration_explanation">This session measures your current heart-rate response. Effort increases progressively and you can stop immediately at any time. The result is stored as an observed heart-rate peak, not as a medical maximum heart rate.</string>
|
||||||
|
<string name="calibration_start">Start calibration</string>
|
||||||
|
<string name="calibration_start_description">Create a dedicated cardio session, start heart-rate capture and begin with warm-up.</string>
|
||||||
|
<string name="calibration_latest">Latest calibration</string>
|
||||||
|
<string name="calibration_current">Calibration in progress</string>
|
||||||
|
<string name="calibration_phase_value">Phase: %1$s</string>
|
||||||
|
<string name="calibration_phase_warmup">Warm-up</string>
|
||||||
|
<string name="calibration_phase_progressive">Progressive effort</string>
|
||||||
|
<string name="calibration_phase_high_effort">High effort</string>
|
||||||
|
<string name="calibration_phase_recovery">Recovery</string>
|
||||||
|
<string name="calibration_phase_completed">Completed</string>
|
||||||
|
<string name="calibration_phase_aborted">Stopped</string>
|
||||||
|
<string name="calibration_peak_value">Observed peak: %1$d BPM</string>
|
||||||
|
<string name="calibration_recovery_value">Recovery +%1$d min: %2$d BPM</string>
|
||||||
|
<string name="calibration_to_progressive">Start progressive effort</string>
|
||||||
|
<string name="calibration_progressive_description">Increase effort progressively without trying to beat a previous value.</string>
|
||||||
|
<string name="calibration_to_high_effort">Move to high effort</string>
|
||||||
|
<string name="calibration_high_effort_description">Continue only while you choose to tolerate the effort. The next button ends the effort phase immediately.</string>
|
||||||
|
<string name="calibration_end_effort">Stop effort</string>
|
||||||
|
<string name="calibration_end_effort_description">Mark the end of effort and begin measured recovery.</string>
|
||||||
|
<string name="calibration_finish">Finish calibration</string>
|
||||||
|
<string name="calibration_finish_description">Close recovery and save the calibration profile.</string>
|
||||||
|
<string name="calibration_abort">Stop calibration</string>
|
||||||
|
<string name="calibration_conflict">Another session or calibration is already active.</string>
|
||||||
|
<string name="cardio_guidance_title">Cardio guidance</string>
|
||||||
|
<string name="cardio_guidance_phase_value">Phase: %1$s</string>
|
||||||
|
<string name="cardio_guidance_target_value">Target: %1$d–%2$d BPM</string>
|
||||||
|
<string name="cardio_guidance_calibration_value">Calibration: observed peak %1$d BPM · %2$d–%3$d %%</string>
|
||||||
|
<string name="cardio_guidance_latest_calibration">Latest calibration: observed peak %1$d BPM</string>
|
||||||
|
<string name="cardio_guidance_resolved_target">Resolved target: %1$d–%2$d BPM</string>
|
||||||
|
<string name="cardio_guidance_accelerate">SPEED UP</string>
|
||||||
|
<string name="cardio_guidance_maintain">MAINTAIN</string>
|
||||||
|
<string name="cardio_guidance_slow_down">SLOW DOWN</string>
|
||||||
|
<string name="cardio_guidance_suspended">HEART-RATE SIGNAL UNAVAILABLE</string>
|
||||||
|
<string name="cardio_guidance_bpm_value">%1$d BPM</string>
|
||||||
|
<string name="cardio_guidance_bpm_missing">-- BPM</string>
|
||||||
|
<string name="cardio_guidance_start_phase">Start phase</string>
|
||||||
|
<string name="cardio_guidance_finish_phase">End phase</string>
|
||||||
|
<string name="cardio_guidance_invalid_configuration">Complete the target and exit condition.</string>
|
||||||
|
<string name="cardio_guidance_start_failed">Unable to start this phase.</string>
|
||||||
|
<string name="cardio_phase_warmup">Warm-up</string>
|
||||||
|
<string name="cardio_phase_work">Work</string>
|
||||||
|
<string name="cardio_phase_recovery">Recovery</string>
|
||||||
|
<string name="cardio_phase_cooldown">Cool-down</string>
|
||||||
|
<string name="cardio_target_absolute">BPM</string>
|
||||||
|
<string name="cardio_target_calibration">Calibration %%</string>
|
||||||
|
<string name="cardio_target_min_bpm">Min BPM</string>
|
||||||
|
<string name="cardio_target_max_bpm">Max BPM</string>
|
||||||
|
<string name="cardio_target_min_percent">Min %%</string>
|
||||||
|
<string name="cardio_target_max_percent">Max %%</string>
|
||||||
|
<string name="cardio_exit_duration">Duration</string>
|
||||||
|
<string name="cardio_exit_target">Enter target</string>
|
||||||
|
<string name="cardio_exit_recover">Below threshold</string>
|
||||||
|
<string name="cardio_exit_bounded">Duration or threshold</string>
|
||||||
|
<string name="cardio_exit_seconds">Max duration</string>
|
||||||
|
<string name="cardio_exit_recovery_bpm">Recovery threshold</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -490,4 +490,141 @@
|
||||||
<string name="sleep_unit">Unité</string>
|
<string name="sleep_unit">Unité</string>
|
||||||
<string name="sleep_deactivate">Désactiver</string>
|
<string name="sleep_deactivate">Désactiver</string>
|
||||||
<string name="sleep_edit">Modifier</string>
|
<string name="sleep_edit">Modifier</string>
|
||||||
|
<string name="yes">Oui</string>
|
||||||
|
<string name="no">Non</string>
|
||||||
|
<string name="route_heart_rate_diagnostics">Diagnostic cardio BLE</string>
|
||||||
|
<string name="settings_cardio_section">Cardio</string>
|
||||||
|
<string name="settings_cardio_diagnostics">Diagnostic du capteur cardio</string>
|
||||||
|
<string name="settings_cardio_diagnostics_description">Rechercher et vérifier un capteur Bluetooth Heart Rate Service.</string>
|
||||||
|
<string name="hr_diag_status">État du capteur</string>
|
||||||
|
<string name="hr_diag_idle">Prêt</string>
|
||||||
|
<string name="hr_diag_scanning">Recherche en cours…</string>
|
||||||
|
<string name="hr_diag_connecting">Connexion…</string>
|
||||||
|
<string name="hr_diag_discovering">Découverte des services…</string>
|
||||||
|
<string name="hr_diag_subscribing">Activation des mesures…</string>
|
||||||
|
<string name="hr_diag_receiving">Mesures reçues</string>
|
||||||
|
<string name="hr_diag_disconnected">Déconnecté</string>
|
||||||
|
<string name="hr_diag_error">Erreur BLE</string>
|
||||||
|
<string name="hr_diag_permission_denied">Autorisation Bluetooth refusée.</string>
|
||||||
|
<string name="hr_diag_technical">Diagnostic : %1$s</string>
|
||||||
|
<string name="hr_diag_services">Heart Rate Service : %1$s · mesure : %2$s · batterie : %3$s</string>
|
||||||
|
<string name="hr_diag_bpm">%1$d BPM</string>
|
||||||
|
<string name="hr_diag_measurement_meta">%1$d RR · contact %2$s · énergie %3$s</string>
|
||||||
|
<string name="hr_diag_notifications">Notifications reçues : %1$d</string>
|
||||||
|
<string name="hr_diag_scan_section">Recherche BLE</string>
|
||||||
|
<string name="hr_diag_scan">Rechercher cardio</string>
|
||||||
|
<string name="hr_diag_scan_all">Recherche élargie</string>
|
||||||
|
<string name="hr_diag_scan_description">La recherche cardio dure au maximum 10 secondes. La recherche élargie sert aux capteurs qui n’annoncent pas leur service dans la publicité BLE.</string>
|
||||||
|
<string name="hr_diag_devices">Périphériques BLE</string>
|
||||||
|
<string name="hr_diag_no_devices">Aucun périphérique trouvé pour l’instant.</string>
|
||||||
|
<string name="hr_diag_unnamed_device">Périphérique sans nom</string>
|
||||||
|
<string name="hr_diag_hrs_advertised">Heart Rate annoncé</string>
|
||||||
|
<string name="hr_diag_hrs_not_advertised">Heart Rate non annoncé</string>
|
||||||
|
<string name="hr_diag_device_description">%1$s · %2$d dBm · %3$s</string>
|
||||||
|
<string name="hr_diag_use_sensor">Utiliser ce capteur</string>
|
||||||
|
<string name="heart_rate_service_channel">Capteur cardio</string>
|
||||||
|
<string name="heart_rate_service_title">Trainlog · cardio</string>
|
||||||
|
<string name="heart_rate_service_connecting">Connexion au capteur…</string>
|
||||||
|
<string name="heart_rate_service_reconnecting">Signal cardio en attente…</string>
|
||||||
|
<string name="heart_rate_service_disconnected">Capteur cardio déconnecté</string>
|
||||||
|
<string name="heart_rate_service_bpm">%1$d BPM</string>
|
||||||
|
<string name="heart_rate_a11y_connected">Fréquence cardiaque %1$d BPM, capteur connecté</string>
|
||||||
|
<string name="heart_rate_a11y_connecting">Capteur cardio en connexion</string>
|
||||||
|
<string name="heart_rate_a11y_reconnecting">Signal cardio temporairement perdu, reconnexion en cours</string>
|
||||||
|
<string name="heart_rate_a11y_disconnected">Capteur cardio déconnecté</string>
|
||||||
|
<string name="settings_cardio_configured">Capteur cardio configuré.</string>
|
||||||
|
<string name="settings_cardio_forget">Oublier le capteur</string>
|
||||||
|
<string name="settings_cardio_forget_description">Arrêter la connexion cardio et supprimer le capteur mémorisé.</string>
|
||||||
|
<string name="exercise_timeline_start">Démarrer l’exercice</string>
|
||||||
|
<string name="exercise_timeline_finish">Terminer l’exercice</string>
|
||||||
|
<string name="exercise_timeline_completed">Exercice terminé</string>
|
||||||
|
<string name="exercise_timeline_started">%1$s · démarré</string>
|
||||||
|
<string name="exercise_timeline_finished">%1$s · terminé</string>
|
||||||
|
<string name="exercise_timeline_switch_title">Changer d’exercice ?</string>
|
||||||
|
<string name="exercise_timeline_switch_detail">%1$s est encore en cours. Le terminer maintenant puis démarrer %2$s ?</string>
|
||||||
|
<string name="exercise_timeline_switch_confirm">Terminer et démarrer</string>
|
||||||
|
<string name="exercise_timeline_finalize_title">Un exercice est encore en cours</string>
|
||||||
|
<string name="exercise_timeline_finalize_detail">%1$s est encore actif. Le terminer maintenant puis enregistrer la séance ?</string>
|
||||||
|
<string name="exercise_timeline_finalize_confirm">Terminer et enregistrer</string>
|
||||||
|
|
||||||
|
<string name="sleep_night_capture_title">Saisie nocturne</string>
|
||||||
|
<string name="sleep_no_night_active">Aucune nuit en cours.</string>
|
||||||
|
<string name="sleep_night_in_progress">Nuit en cours · %1$s → %2$s</string>
|
||||||
|
<string name="sleep_quick_bed">🛏 COUCHÉ</string>
|
||||||
|
<string name="sleep_quick_wake">👁 RÉVEIL</string>
|
||||||
|
<string name="sleep_quick_get_up">☀ LEVÉ</string>
|
||||||
|
<string name="sleep_bed_recorded">Couché enregistré.</string>
|
||||||
|
<string name="sleep_wake_recorded">Réveil enregistré.</string>
|
||||||
|
<string name="sleep_get_up_recorded">Levé enregistré.</string>
|
||||||
|
<string name="sleep_medication_recorded">%1$s enregistré.</string>
|
||||||
|
<string name="sleep_undo">ANNULER</string>
|
||||||
|
<string name="sleep_action_undone">Dernière action annulée.</string>
|
||||||
|
<string name="sleep_undo_unavailable">Annulation impossible : la nuit a déjà changé.</string>
|
||||||
|
<string name="sleep_correct_night">Corriger la nuit</string>
|
||||||
|
<string name="sleep_return_night_mode">Retour à la saisie nocturne</string>
|
||||||
|
<string name="sleep_night_timeline">Chronologie de la nuit</string>
|
||||||
|
|
||||||
|
<string name="sleep_quick_no_active">Aucune nuit en cours.</string>
|
||||||
|
<string name="sleep_quick_already_active">Une nuit est déjà en cours.</string>
|
||||||
|
<string name="sleep_quick_medication_unavailable">Médicament indisponible.</string>
|
||||||
|
<string name="sleep_quick_conflict">La nuit a changé ; rechargez avant de recommencer.</string>
|
||||||
|
<string name="sleep_quick_error">Enregistrement impossible.</string>
|
||||||
|
<string name="session_cardio">Cardio</string>
|
||||||
|
<string name="route_cardio_calibration">Calibration cardio</string>
|
||||||
|
<string name="calibration_explanation">Cette séance mesure votre réponse cardiaque actuelle. L’effort augmente progressivement et vous pouvez arrêter immédiatement à tout moment. Le résultat est enregistré comme un pic de fréquence cardiaque observé, pas comme une FC max médicale.</string>
|
||||||
|
<string name="calibration_start">Démarrer la calibration</string>
|
||||||
|
<string name="calibration_start_description">Créer une séance cardio dédiée, démarrer la capture cardiaque et commencer par l’échauffement.</string>
|
||||||
|
<string name="calibration_latest">Dernière calibration</string>
|
||||||
|
<string name="calibration_current">Calibration en cours</string>
|
||||||
|
<string name="calibration_phase_value">Phase : %1$s</string>
|
||||||
|
<string name="calibration_phase_warmup">Échauffement</string>
|
||||||
|
<string name="calibration_phase_progressive">Montée progressive</string>
|
||||||
|
<string name="calibration_phase_high_effort">Effort élevé</string>
|
||||||
|
<string name="calibration_phase_recovery">Récupération</string>
|
||||||
|
<string name="calibration_phase_completed">Terminée</string>
|
||||||
|
<string name="calibration_phase_aborted">Arrêtée</string>
|
||||||
|
<string name="calibration_peak_value">Pic observé : %1$d BPM</string>
|
||||||
|
<string name="calibration_recovery_value">Récupération +%1$d min : %2$d BPM</string>
|
||||||
|
<string name="calibration_to_progressive">Commencer la montée progressive</string>
|
||||||
|
<string name="calibration_progressive_description">Augmenter progressivement l’effort sans chercher à battre une ancienne valeur.</string>
|
||||||
|
<string name="calibration_to_high_effort">Passer à l’effort élevé</string>
|
||||||
|
<string name="calibration_high_effort_description">Continuer seulement tant que vous choisissez de tolérer l’effort. Le bouton suivant arrête immédiatement la phase d’effort.</string>
|
||||||
|
<string name="calibration_end_effort">Arrêter l’effort</string>
|
||||||
|
<string name="calibration_end_effort_description">Marquer la fin de l’effort et commencer la récupération mesurée.</string>
|
||||||
|
<string name="calibration_finish">Terminer la calibration</string>
|
||||||
|
<string name="calibration_finish_description">Fermer la récupération et enregistrer le profil de calibration.</string>
|
||||||
|
<string name="calibration_abort">Arrêter la calibration</string>
|
||||||
|
<string name="calibration_conflict">Une autre séance ou calibration est déjà en cours.</string>
|
||||||
|
<string name="cardio_guidance_title">Guidage cardio</string>
|
||||||
|
<string name="cardio_guidance_phase_value">Phase : %1$s</string>
|
||||||
|
<string name="cardio_guidance_target_value">Cible : %1$d–%2$d BPM</string>
|
||||||
|
<string name="cardio_guidance_calibration_value">Calibration : pic observé %1$d BPM · %2$d–%3$d %%</string>
|
||||||
|
<string name="cardio_guidance_latest_calibration">Dernière calibration : pic observé %1$d BPM</string>
|
||||||
|
<string name="cardio_guidance_resolved_target">Cible utilisée : %1$d–%2$d BPM</string>
|
||||||
|
<string name="cardio_guidance_accelerate">ACCÉLÈRE</string>
|
||||||
|
<string name="cardio_guidance_maintain">MAINTIENS</string>
|
||||||
|
<string name="cardio_guidance_slow_down">RALENTIS</string>
|
||||||
|
<string name="cardio_guidance_suspended">SIGNAL CARDIO INDISPONIBLE</string>
|
||||||
|
<string name="cardio_guidance_bpm_value">%1$d BPM</string>
|
||||||
|
<string name="cardio_guidance_bpm_missing">-- BPM</string>
|
||||||
|
<string name="cardio_guidance_start_phase">Démarrer la phase</string>
|
||||||
|
<string name="cardio_guidance_finish_phase">Terminer la phase</string>
|
||||||
|
<string name="cardio_guidance_invalid_configuration">Complète la cible et la condition de fin.</string>
|
||||||
|
<string name="cardio_guidance_start_failed">Impossible de démarrer cette phase.</string>
|
||||||
|
<string name="cardio_phase_warmup">Échauffement</string>
|
||||||
|
<string name="cardio_phase_work">Travail</string>
|
||||||
|
<string name="cardio_phase_recovery">Récupération</string>
|
||||||
|
<string name="cardio_phase_cooldown">Retour au calme</string>
|
||||||
|
<string name="cardio_target_absolute">BPM</string>
|
||||||
|
<string name="cardio_target_calibration">%% calibration</string>
|
||||||
|
<string name="cardio_target_min_bpm">BPM min</string>
|
||||||
|
<string name="cardio_target_max_bpm">BPM max</string>
|
||||||
|
<string name="cardio_target_min_percent">%% min</string>
|
||||||
|
<string name="cardio_target_max_percent">%% max</string>
|
||||||
|
<string name="cardio_exit_duration">Durée</string>
|
||||||
|
<string name="cardio_exit_target">Entrée dans la cible</string>
|
||||||
|
<string name="cardio_exit_recover">Sous un seuil</string>
|
||||||
|
<string name="cardio_exit_bounded">Durée ou seuil</string>
|
||||||
|
<string name="cardio_exit_seconds">Durée max</string>
|
||||||
|
<string name="cardio_exit_recovery_bpm">Seuil de récupération</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest {
|
||||||
|
|
||||||
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
||||||
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
assertEquals(26, scalar(db, "PRAGMA user_version"))
|
assertEquals(32, scalar(db, "PRAGMA user_version"))
|
||||||
for (table in listOf("session_exercises", "draft_session_exercises")) {
|
for (table in listOf("session_exercises", "draft_session_exercises")) {
|
||||||
assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')"))
|
assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')"))
|
||||||
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL"))
|
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL"))
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ class AndroidV11ExerciseAliasMigrationTest {
|
||||||
migrated.close()
|
migrated.close()
|
||||||
}
|
}
|
||||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
assertEquals(26, scalar(db, "PRAGMA user_version"))
|
assertEquals(32, scalar(db, "PRAGMA user_version"))
|
||||||
/* v13 intentionally adds the frozen machine identities and
|
/* v13 intentionally adds the frozen machine identities and
|
||||||
* exercise-owned metadata; all non-exercise facts stay exact. */
|
* exercise-owned metadata; all non-exercise facts stay exact. */
|
||||||
val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") }
|
val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") }
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest {
|
||||||
migrated.close()
|
migrated.close()
|
||||||
}
|
}
|
||||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
assertEquals(26, scalar(db, "PRAGMA user_version"))
|
assertEquals(32, scalar(db, "PRAGMA user_version"))
|
||||||
assertEquals(before, listOf(
|
assertEquals(before, listOf(
|
||||||
scalar(db, "SELECT COUNT(*) FROM sessions"),
|
scalar(db, "SELECT COUNT(*) FROM sessions"),
|
||||||
scalar(db, "SELECT COUNT(*) FROM performed_sets"),
|
scalar(db, "SELECT COUNT(*) FROM performed_sets"),
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ class AndroidV25ProgramExecutionMigrationTest {
|
||||||
reopened.close()
|
reopened.close()
|
||||||
}
|
}
|
||||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
assertEquals(26, scalar(db, "PRAGMA user_version"))
|
assertEquals(32, scalar(db, "PRAGMA user_version"))
|
||||||
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM sessions WHERE source_program_id IS NOT NULL"))
|
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM sessions WHERE source_program_id IS NOT NULL"))
|
||||||
assertEquals("ok", text(db, "PRAGMA integrity_check"))
|
assertEquals("ok", text(db, "PRAGMA integrity_check"))
|
||||||
db.rawQuery("PRAGMA foreign_key_check", null).use { cursor ->
|
db.rawQuery("PRAGMA foreign_key_check", null).use { cursor ->
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ class AndroidV26SleepMigrationTest {
|
||||||
context.getDatabasePath(name).path,
|
context.getDatabasePath(name).path,
|
||||||
null,
|
null,
|
||||||
SQLiteDatabase.OPEN_READONLY,
|
SQLiteDatabase.OPEN_READONLY,
|
||||||
).use { assertEquals(26, it.version) }
|
).use { assertEquals(32, it.version) }
|
||||||
} finally {
|
} finally {
|
||||||
repository.close()
|
repository.close()
|
||||||
context.deleteDatabase(name)
|
context.deleteDatabase(name)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.database.sqlite.SQLiteDatabase
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import java.util.UUID
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [35])
|
||||||
|
class AndroidV27HeartRateMigrationTest {
|
||||||
|
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun v26AddsEmptyHeartRateDomainAndReopens() {
|
||||||
|
val name = "heart-rate-v26-${UUID.randomUUID()}.db"
|
||||||
|
TrainlogRepository(context, name).also {
|
||||||
|
JSONObject(it.buildHeartRateV1Json())
|
||||||
|
it.close()
|
||||||
|
}
|
||||||
|
SQLiteDatabase.openDatabase(
|
||||||
|
context.getDatabasePath(name).path,
|
||||||
|
null,
|
||||||
|
SQLiteDatabase.OPEN_READWRITE,
|
||||||
|
).use { db ->
|
||||||
|
db.execSQL("DROP TABLE heart_rate_generation_captures")
|
||||||
|
db.execSQL("DROP TABLE heart_rate_rr_intervals")
|
||||||
|
db.execSQL("DROP TABLE heart_rate_samples")
|
||||||
|
db.execSQL("DROP TABLE heart_rate_captures")
|
||||||
|
db.version = 26
|
||||||
|
}
|
||||||
|
|
||||||
|
var repository = TrainlogRepository(context, name)
|
||||||
|
try {
|
||||||
|
assertEquals(0, JSONObject(repository.buildHeartRateV1Json())
|
||||||
|
.getJSONArray("captures").length())
|
||||||
|
repository.close()
|
||||||
|
repository = TrainlogRepository(context, name)
|
||||||
|
SQLiteDatabase.openDatabase(
|
||||||
|
context.getDatabasePath(name).path,
|
||||||
|
null,
|
||||||
|
SQLiteDatabase.OPEN_READONLY,
|
||||||
|
).use { assertEquals(32, it.version) }
|
||||||
|
} finally {
|
||||||
|
repository.close()
|
||||||
|
context.deleteDatabase(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.database.sqlite.SQLiteDatabase
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import com.labfytools.trainlog.model.NewExerciseProfile
|
||||||
|
import com.labfytools.trainlog.model.RecordingMode
|
||||||
|
import com.labfytools.trainlog.model.SessionExerciseDraft
|
||||||
|
import com.labfytools.trainlog.model.SessionSetDraft
|
||||||
|
import com.labfytools.trainlog.model.TrackingMode
|
||||||
|
import java.util.UUID
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [35])
|
||||||
|
class AndroidV28SessionTimelineMigrationTest {
|
||||||
|
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun v27SeedsOnlyAuthoritativeActiveSessionBoundary() {
|
||||||
|
val name = "timeline-v27-${UUID.randomUUID()}.db"
|
||||||
|
var repository = TrainlogRepository(context, name)
|
||||||
|
try {
|
||||||
|
assertEquals(
|
||||||
|
ActiveDraftMutationResult.Saved,
|
||||||
|
repository.startActiveSessionDraft(),
|
||||||
|
)
|
||||||
|
val exercise =
|
||||||
|
(repository.createExercise(
|
||||||
|
NewExerciseProfile(
|
||||||
|
"Timeline migration exercise",
|
||||||
|
RecordingMode.SETS,
|
||||||
|
TrackingMode.REPS,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
) as CreateExerciseResult.Created).exercise
|
||||||
|
val loaded =
|
||||||
|
repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
|
||||||
|
val sessionId = checkNotNull(loaded.draft.sessionId)
|
||||||
|
val startedAt = checkNotNull(loaded.draft.startedAt)
|
||||||
|
assertEquals(
|
||||||
|
ActiveDraftMutationResult.Saved,
|
||||||
|
repository.saveActiveSessionDraft(
|
||||||
|
loaded.draft.copy(
|
||||||
|
exercises =
|
||||||
|
listOf(
|
||||||
|
SessionExerciseDraft(
|
||||||
|
exercise = exercise,
|
||||||
|
sets = listOf(SessionSetDraft(reps = 1)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
repository.close()
|
||||||
|
|
||||||
|
SQLiteDatabase.openDatabase(
|
||||||
|
context.getDatabasePath(name).path,
|
||||||
|
null,
|
||||||
|
SQLiteDatabase.OPEN_READWRITE,
|
||||||
|
).use { db ->
|
||||||
|
db.execSQL("DROP TABLE session_timeline_generation_sessions")
|
||||||
|
db.execSQL("DROP TABLE session_timeline_exercises")
|
||||||
|
db.execSQL("DROP TABLE session_timeline_sessions")
|
||||||
|
db.version = 27
|
||||||
|
}
|
||||||
|
|
||||||
|
repository = TrainlogRepository(context, name)
|
||||||
|
assertNotNull(repository.activeSessionTimelineContext())
|
||||||
|
SQLiteDatabase.openDatabase(
|
||||||
|
context.getDatabasePath(name).path,
|
||||||
|
null,
|
||||||
|
SQLiteDatabase.OPEN_READONLY,
|
||||||
|
).use { db ->
|
||||||
|
assertEquals(32, db.version)
|
||||||
|
db.rawQuery(
|
||||||
|
"SELECT session_id,started_at,ended_at FROM session_timeline_sessions",
|
||||||
|
null,
|
||||||
|
).use { cursor ->
|
||||||
|
assertTrue(cursor.moveToFirst())
|
||||||
|
assertEquals(sessionId, cursor.getString(0))
|
||||||
|
assertEquals(startedAt, cursor.getString(1))
|
||||||
|
assertTrue(cursor.isNull(2))
|
||||||
|
}
|
||||||
|
db.rawQuery(
|
||||||
|
"SELECT COUNT(*) FROM session_timeline_exercises",
|
||||||
|
null,
|
||||||
|
).use { cursor ->
|
||||||
|
assertTrue(cursor.moveToFirst())
|
||||||
|
assertEquals(0, cursor.getInt(0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertNotNull(repository.activeSessionTimelineContext())
|
||||||
|
} finally {
|
||||||
|
repository.close()
|
||||||
|
context.deleteDatabase(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.database.sqlite.SQLiteDatabase
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||||
|
import com.labfytools.trainlog.model.NewExerciseProfile
|
||||||
|
import com.labfytools.trainlog.model.RecordingMode
|
||||||
|
import com.labfytools.trainlog.model.SessionDraft
|
||||||
|
import com.labfytools.trainlog.model.SessionExerciseDraft
|
||||||
|
import com.labfytools.trainlog.model.SessionSetDraft
|
||||||
|
import com.labfytools.trainlog.model.TrackingMode
|
||||||
|
import java.util.UUID
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [35])
|
||||||
|
class AndroidV29CardioSessionMigrationTest {
|
||||||
|
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun v28BackfillsCanonicalSessionKindFromFrozenLegacyType() {
|
||||||
|
val name = "cardio-v28-" + UUID.randomUUID() + ".db"
|
||||||
|
var repository = TrainlogRepository(context, name)
|
||||||
|
try {
|
||||||
|
val exercise =
|
||||||
|
(repository.createExercise(
|
||||||
|
NewExerciseProfile(
|
||||||
|
"Cardio migration exercise",
|
||||||
|
RecordingMode.SETS,
|
||||||
|
TrackingMode.REPS,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
) as CreateExerciseResult.Created).exercise
|
||||||
|
assertTrue(
|
||||||
|
repository.saveSession(
|
||||||
|
SessionDraft(
|
||||||
|
listOf(
|
||||||
|
SessionExerciseDraft(
|
||||||
|
exercise = exercise,
|
||||||
|
sets = listOf(SessionSetDraft(reps = 5)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
) is SaveSessionResult.Saved,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
ActiveDraftMutationResult.Saved,
|
||||||
|
repository.saveActiveSessionDraft(
|
||||||
|
ActiveSessionDraft(
|
||||||
|
exercises =
|
||||||
|
listOf(
|
||||||
|
SessionExerciseDraft(
|
||||||
|
exercise = exercise,
|
||||||
|
sets = listOf(SessionSetDraft(reps = 6)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
repository.close()
|
||||||
|
|
||||||
|
SQLiteDatabase.openDatabase(
|
||||||
|
context.getDatabasePath(name).path,
|
||||||
|
null,
|
||||||
|
SQLiteDatabase.OPEN_READWRITE,
|
||||||
|
).use { db ->
|
||||||
|
db.execSQL("UPDATE sessions SET session_kind='cardio'")
|
||||||
|
db.execSQL("UPDATE active_session_draft SET session_kind='cardio'")
|
||||||
|
db.version = 28
|
||||||
|
}
|
||||||
|
|
||||||
|
repository = TrainlogRepository(context, name)
|
||||||
|
assertEquals(1, repository.listSessions().size)
|
||||||
|
SQLiteDatabase.openDatabase(
|
||||||
|
context.getDatabasePath(name).path,
|
||||||
|
null,
|
||||||
|
SQLiteDatabase.OPEN_READONLY,
|
||||||
|
).use { db ->
|
||||||
|
assertEquals(32, db.version)
|
||||||
|
db.rawQuery(
|
||||||
|
"SELECT session_type,session_kind FROM sessions",
|
||||||
|
null,
|
||||||
|
).use { cursor ->
|
||||||
|
assertTrue(cursor.moveToFirst())
|
||||||
|
assertEquals("training", cursor.getString(0))
|
||||||
|
assertEquals("training", cursor.getString(1))
|
||||||
|
}
|
||||||
|
db.rawQuery(
|
||||||
|
"SELECT session_type,session_kind FROM active_session_draft WHERE id=1",
|
||||||
|
null,
|
||||||
|
).use { cursor ->
|
||||||
|
assertTrue(cursor.moveToFirst())
|
||||||
|
assertEquals("training", cursor.getString(0))
|
||||||
|
assertEquals("training", cursor.getString(1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
repository.close()
|
||||||
|
context.deleteDatabase(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.database.sqlite.SQLiteDatabase
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import java.util.UUID
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [35])
|
||||||
|
class AndroidV30CardioCalibrationMigrationTest {
|
||||||
|
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun v29AddsEmptyCalibrationDomainAndReservedExercise() {
|
||||||
|
val name = "calibration-v29-" + UUID.randomUUID() + ".db"
|
||||||
|
var repository = TrainlogRepository(context, name)
|
||||||
|
try {
|
||||||
|
repository.listExercises()
|
||||||
|
repository.close()
|
||||||
|
SQLiteDatabase.openDatabase(
|
||||||
|
context.getDatabasePath(name).path,
|
||||||
|
null,
|
||||||
|
SQLiteDatabase.OPEN_READWRITE,
|
||||||
|
).use { db ->
|
||||||
|
db.execSQL("DROP TABLE cardio_calibration_generation")
|
||||||
|
db.execSQL("DROP TABLE cardio_calibration_recovery")
|
||||||
|
db.execSQL("DROP TABLE cardio_calibrations")
|
||||||
|
db.version = 29
|
||||||
|
}
|
||||||
|
|
||||||
|
repository = TrainlogRepository(context, name)
|
||||||
|
assertTrue(repository.listExercises().none { it.name == "Calibration cardio" })
|
||||||
|
SQLiteDatabase.openDatabase(
|
||||||
|
context.getDatabasePath(name).path,
|
||||||
|
null,
|
||||||
|
SQLiteDatabase.OPEN_READONLY,
|
||||||
|
).use { db ->
|
||||||
|
assertEquals(32, db.version)
|
||||||
|
db.rawQuery(
|
||||||
|
"SELECT COUNT(*) FROM exercises WHERE " +
|
||||||
|
"exercise_id='ex_ca1b4a7e-1c2d-4f00-8a11-000000000001' " +
|
||||||
|
"AND name='Calibration cardio'",
|
||||||
|
null,
|
||||||
|
).use { cursor ->
|
||||||
|
assertTrue(cursor.moveToFirst())
|
||||||
|
assertEquals(1, cursor.getInt(0))
|
||||||
|
}
|
||||||
|
db.rawQuery(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN(" +
|
||||||
|
"'cardio_calibrations','cardio_calibration_recovery'," +
|
||||||
|
"'cardio_calibration_generation')",
|
||||||
|
null,
|
||||||
|
).use { cursor ->
|
||||||
|
assertTrue(cursor.moveToFirst())
|
||||||
|
assertEquals(3, cursor.getInt(0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
repository.close()
|
||||||
|
context.deleteDatabase(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.database.sqlite.SQLiteDatabase
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import java.util.UUID
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [35])
|
||||||
|
class AndroidV31CardioGuidanceMigrationTest {
|
||||||
|
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun v30AddsEmptyGuidanceDomainAndReopens() {
|
||||||
|
val name = "guidance-v30-" + UUID.randomUUID() + ".db"
|
||||||
|
var repository = TrainlogRepository(context, name)
|
||||||
|
try {
|
||||||
|
repository.listSessions()
|
||||||
|
repository.close()
|
||||||
|
SQLiteDatabase.openDatabase(
|
||||||
|
context.getDatabasePath(name).path,
|
||||||
|
null,
|
||||||
|
SQLiteDatabase.OPEN_READWRITE,
|
||||||
|
).use { db ->
|
||||||
|
db.execSQL("DROP TABLE cardio_guidance_generation")
|
||||||
|
db.execSQL("DROP TABLE cardio_guidance_events")
|
||||||
|
db.execSQL("DROP TABLE cardio_guidance_phases")
|
||||||
|
db.execSQL("DROP TABLE cardio_guidance_runs")
|
||||||
|
db.version = 30
|
||||||
|
}
|
||||||
|
|
||||||
|
repository = TrainlogRepository(context, name)
|
||||||
|
assertTrue(repository.activeCardioGuidancePhase() == null)
|
||||||
|
SQLiteDatabase.openDatabase(
|
||||||
|
context.getDatabasePath(name).path,
|
||||||
|
null,
|
||||||
|
SQLiteDatabase.OPEN_READONLY,
|
||||||
|
).use { db ->
|
||||||
|
assertEquals(32, db.version)
|
||||||
|
db.rawQuery(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN(" +
|
||||||
|
"'cardio_guidance_runs','cardio_guidance_phases'," +
|
||||||
|
"'cardio_guidance_events','cardio_guidance_generation')",
|
||||||
|
null,
|
||||||
|
).use { c ->
|
||||||
|
assertTrue(c.moveToFirst())
|
||||||
|
assertEquals(4, c.getInt(0))
|
||||||
|
}
|
||||||
|
db.rawQuery("SELECT COUNT(*) FROM cardio_guidance_runs", null).use { c ->
|
||||||
|
assertTrue(c.moveToFirst())
|
||||||
|
assertEquals(0, c.getInt(0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
repository.close()
|
||||||
|
context.deleteDatabase(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.database.sqlite.SQLiteDatabase
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import com.labfytools.trainlog.model.SleepMedication
|
||||||
|
import java.util.UUID
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [35])
|
||||||
|
class AndroidV32SleepQuantityMigrationTest {
|
||||||
|
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun v31IntakesHydrateToOneAndQuickPublicationStartsEmpty() {
|
||||||
|
val name = "sleep-quantity-v31-${UUID.randomUUID()}.db"
|
||||||
|
val initial = TrainlogRepository(context, name)
|
||||||
|
try {
|
||||||
|
val saved = initial.saveSleepMedication(
|
||||||
|
SleepMedication(
|
||||||
|
"", "", "2026-09-23T20:00:00+02:00", "2026-09-23T20:00:00+02:00",
|
||||||
|
"Migration medication", 75.0, "mg", "", "", true,
|
||||||
|
),
|
||||||
|
) as TrainlogRepository.SaveSleepDiaryResult.Saved
|
||||||
|
initial.quickSleepMedication(saved.entryId, "2026-09-23T21:00:00+02:00")
|
||||||
|
} finally {
|
||||||
|
initial.close()
|
||||||
|
}
|
||||||
|
val path = context.getDatabasePath(name).path
|
||||||
|
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READWRITE).use { db ->
|
||||||
|
db.execSQL("PRAGMA foreign_keys=OFF")
|
||||||
|
db.execSQL("DROP TABLE sleep_diary_quick_publication_state")
|
||||||
|
db.execSQL("ALTER TABLE sleep_medication_intakes RENAME TO sleep_medication_intakes_v32")
|
||||||
|
db.execSQL(
|
||||||
|
"""CREATE TABLE sleep_medication_intakes(
|
||||||
|
revision_id TEXT NOT NULL, intake_id TEXT NOT NULL,
|
||||||
|
medication_id TEXT NOT NULL, medication_name TEXT NOT NULL,
|
||||||
|
taken_at TEXT NOT NULL, dose_value REAL, dose_unit TEXT,
|
||||||
|
note TEXT, created_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY(revision_id,intake_id)
|
||||||
|
)""".trimIndent(),
|
||||||
|
)
|
||||||
|
db.execSQL(
|
||||||
|
"INSERT INTO sleep_medication_intakes SELECT revision_id,intake_id,medication_id," +
|
||||||
|
"medication_name,taken_at,dose_value,dose_unit,note,created_at " +
|
||||||
|
"FROM sleep_medication_intakes_v32",
|
||||||
|
)
|
||||||
|
db.execSQL("DROP TABLE sleep_medication_intakes_v32")
|
||||||
|
db.execSQL("PRAGMA user_version=31")
|
||||||
|
}
|
||||||
|
|
||||||
|
val migrated = TrainlogRepository(context, name)
|
||||||
|
migrated.listSleepDiary()
|
||||||
|
migrated.close()
|
||||||
|
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
|
assertEquals(32, scalar(db, "PRAGMA user_version"))
|
||||||
|
assertEquals(
|
||||||
|
1,
|
||||||
|
scalar(
|
||||||
|
db,
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('sleep_medication_intakes') " +
|
||||||
|
"WHERE name='quantity' AND \"notnull\"=1 AND dflt_value='1'",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertEquals(1, scalar(db, "SELECT quantity FROM sleep_medication_intakes"))
|
||||||
|
assertEquals(
|
||||||
|
1,
|
||||||
|
scalar(
|
||||||
|
db,
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' " +
|
||||||
|
"AND name='sleep_diary_quick_publication_state'",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertEquals("ok", text(db, "PRAGMA integrity_check"))
|
||||||
|
}
|
||||||
|
context.deleteDatabase(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scalar(db: SQLiteDatabase, sql: String): Int =
|
||||||
|
db.rawQuery(sql, null).use { cursor ->
|
||||||
|
assertTrue(cursor.moveToFirst())
|
||||||
|
cursor.getInt(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun text(db: SQLiteDatabase, sql: String): String =
|
||||||
|
db.rawQuery(sql, null).use { cursor ->
|
||||||
|
assertTrue(cursor.moveToFirst())
|
||||||
|
cursor.getString(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class BluetoothSyncTransportContractTest {
|
||||||
|
@Test
|
||||||
|
fun androidPullArchiveCarriesGenerationAckRecoveryEvidence() {
|
||||||
|
assertTrue(
|
||||||
|
BLUETOOTH_ANDROID_PULL_COORDINATION.contains(
|
||||||
|
"android-archive-acknowledgements-v1.json"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -328,7 +328,7 @@ class BodyZonesTest {
|
||||||
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
|
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
|
||||||
SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version", null).use { cursor ->
|
db.rawQuery("PRAGMA user_version", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst()); assertEquals(26, cursor.getInt(0))
|
assertTrue(cursor.moveToFirst()); assertEquals(32, cursor.getInt(0))
|
||||||
}
|
}
|
||||||
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
|
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ class BridgeBackupMigrationTest {
|
||||||
null,
|
null,
|
||||||
android.database.sqlite.SQLiteDatabase.OPEN_READONLY,
|
android.database.sqlite.SQLiteDatabase.OPEN_READONLY,
|
||||||
)
|
)
|
||||||
.use { assertEquals(26, it.version) }
|
.use { assertEquals(31, it.version) }
|
||||||
} finally {
|
} finally {
|
||||||
repository.close()
|
repository.close()
|
||||||
context.deleteDatabase(name)
|
context.deleteDatabase(name)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,187 @@
|
||||||
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import com.labfytools.trainlog.model.CardioCalibrationPhase
|
||||||
|
import com.labfytools.trainlog.model.SessionType
|
||||||
|
import java.util.UUID
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [35])
|
||||||
|
class CardioCalibrationRepositoryTest {
|
||||||
|
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||||
|
|
||||||
|
private fun measurement(bpm: Int) =
|
||||||
|
ParsedHeartRateMeasurement(
|
||||||
|
bpm = bpm,
|
||||||
|
sensorContactDetected = null,
|
||||||
|
energyExpended = null,
|
||||||
|
rrIntervals1024 = listOf(1024),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun calibrationTracksObservedPeakRecoveryAndFinalizesRealCardioSession() {
|
||||||
|
val name = "calibration-" + UUID.randomUUID() + ".db"
|
||||||
|
val repository = TrainlogRepository(context, name)
|
||||||
|
try {
|
||||||
|
val started =
|
||||||
|
repository.startCardioCalibration("2026-09-23T10:00:00+02:00")
|
||||||
|
as TrainlogRepository.CardioCalibrationResult.Applied
|
||||||
|
assertEquals(CardioCalibrationPhase.WARMUP, started.profile.phase)
|
||||||
|
|
||||||
|
repository.recordLiveHeartRateForActiveSession(
|
||||||
|
"CYCPLUS H2",
|
||||||
|
"2026-09-23T10:00:10+02:00",
|
||||||
|
measurement(80),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
CardioCalibrationPhase.PROGRESSIVE,
|
||||||
|
(repository.advanceCardioCalibrationPhase("2026-09-23T10:01:00+02:00")
|
||||||
|
as TrainlogRepository.CardioCalibrationResult.Applied).profile.phase,
|
||||||
|
)
|
||||||
|
repository.recordLiveHeartRateForActiveSession(
|
||||||
|
"CYCPLUS H2",
|
||||||
|
"2026-09-23T10:01:20+02:00",
|
||||||
|
measurement(112),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
CardioCalibrationPhase.HIGH_EFFORT,
|
||||||
|
(repository.advanceCardioCalibrationPhase("2026-09-23T10:02:00+02:00")
|
||||||
|
as TrainlogRepository.CardioCalibrationResult.Applied).profile.phase,
|
||||||
|
)
|
||||||
|
repository.recordLiveHeartRateForActiveSession(
|
||||||
|
"CYCPLUS H2",
|
||||||
|
"2026-09-23T10:02:30+02:00",
|
||||||
|
measurement(150),
|
||||||
|
)
|
||||||
|
repository.recordLiveHeartRateForActiveSession(
|
||||||
|
"CYCPLUS H2",
|
||||||
|
"2026-09-23T10:03:00+02:00",
|
||||||
|
measurement(160),
|
||||||
|
)
|
||||||
|
|
||||||
|
val recovery =
|
||||||
|
repository.markCardioCalibrationEffortEnd("2026-09-23T10:03:01+02:00")
|
||||||
|
as TrainlogRepository.CardioCalibrationResult.Applied
|
||||||
|
assertEquals(CardioCalibrationPhase.RECOVERY, recovery.profile.phase)
|
||||||
|
assertEquals(160, recovery.profile.observedPeakBpm)
|
||||||
|
assertTrue(recovery.profile.heartRateCaptureId != null)
|
||||||
|
|
||||||
|
repository.recordLiveHeartRateForActiveSession(
|
||||||
|
"CYCPLUS H2",
|
||||||
|
"2026-09-23T10:04:01+02:00",
|
||||||
|
measurement(140),
|
||||||
|
)
|
||||||
|
repository.recordLiveHeartRateForActiveSession(
|
||||||
|
"CYCPLUS H2",
|
||||||
|
"2026-09-23T10:05:02+02:00",
|
||||||
|
measurement(120),
|
||||||
|
)
|
||||||
|
repository.recordLiveHeartRateForActiveSession(
|
||||||
|
"CYCPLUS H2",
|
||||||
|
"2026-09-23T10:06:01+02:00",
|
||||||
|
measurement(100),
|
||||||
|
)
|
||||||
|
|
||||||
|
val completed =
|
||||||
|
repository.finishCardioCalibration("2026-09-23T10:06:10+02:00")
|
||||||
|
as TrainlogRepository.CardioCalibrationResult.Applied
|
||||||
|
assertEquals(CardioCalibrationPhase.COMPLETED, completed.profile.phase)
|
||||||
|
assertEquals(
|
||||||
|
listOf(60 to 140, 120 to 120, 180 to 100),
|
||||||
|
completed.profile.recovery.map { it.targetOffsetSeconds to it.bpm },
|
||||||
|
)
|
||||||
|
assertNull(repository.activeCardioCalibration())
|
||||||
|
assertNull(repository.activeHeartRateCapture())
|
||||||
|
|
||||||
|
val session = repository.listSessions().single()
|
||||||
|
assertEquals(SessionType.CARDIO, session.sessionType)
|
||||||
|
val detail = checkNotNull(repository.getSessionDetail(session.sessionId))
|
||||||
|
assertEquals(370, detail.exercises.single().continuousDurationSeconds)
|
||||||
|
assertEquals(1, repository.listCompletedCardioCalibrations().size)
|
||||||
|
} finally {
|
||||||
|
repository.close()
|
||||||
|
context.deleteDatabase(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun recoveryDoesNotInventLateReferencePointsAndCannotFinishTooEarly() {
|
||||||
|
val name = "calibration-recovery-" + UUID.randomUUID() + ".db"
|
||||||
|
val repository = TrainlogRepository(context, name)
|
||||||
|
try {
|
||||||
|
repository.startCardioCalibration("2026-09-23T12:00:00+02:00")
|
||||||
|
repository.recordLiveHeartRateForActiveSession(
|
||||||
|
"CYCPLUS H2",
|
||||||
|
"2026-09-23T12:00:10+02:00",
|
||||||
|
measurement(80),
|
||||||
|
)
|
||||||
|
repository.advanceCardioCalibrationPhase("2026-09-23T12:01:00+02:00")
|
||||||
|
repository.advanceCardioCalibrationPhase("2026-09-23T12:02:00+02:00")
|
||||||
|
repository.recordLiveHeartRateForActiveSession(
|
||||||
|
"CYCPLUS H2",
|
||||||
|
"2026-09-23T12:02:30+02:00",
|
||||||
|
measurement(155),
|
||||||
|
)
|
||||||
|
repository.markCardioCalibrationEffortEnd("2026-09-23T12:03:00+02:00")
|
||||||
|
|
||||||
|
repository.recordLiveHeartRateForActiveSession(
|
||||||
|
"CYCPLUS H2",
|
||||||
|
"2026-09-23T12:05:05+02:00",
|
||||||
|
measurement(118),
|
||||||
|
)
|
||||||
|
val duringRecovery = checkNotNull(repository.activeCardioCalibration())
|
||||||
|
assertEquals(
|
||||||
|
listOf(120 to 118),
|
||||||
|
duringRecovery.recovery.map { it.targetOffsetSeconds to it.bpm },
|
||||||
|
)
|
||||||
|
|
||||||
|
val tooEarly =
|
||||||
|
repository.finishCardioCalibration("2026-09-23T12:05:59+02:00")
|
||||||
|
assertTrue(tooEarly is TrainlogRepository.CardioCalibrationResult.Invalid)
|
||||||
|
assertTrue(repository.activeCardioCalibration() != null)
|
||||||
|
|
||||||
|
repository.recordLiveHeartRateForActiveSession(
|
||||||
|
"CYCPLUS H2",
|
||||||
|
"2026-09-23T12:06:03+02:00",
|
||||||
|
measurement(105),
|
||||||
|
)
|
||||||
|
val completed =
|
||||||
|
repository.finishCardioCalibration("2026-09-23T12:06:05+02:00")
|
||||||
|
as TrainlogRepository.CardioCalibrationResult.Applied
|
||||||
|
assertEquals(
|
||||||
|
listOf(120 to 118, 180 to 105),
|
||||||
|
completed.profile.recovery.map { it.targetOffsetSeconds to it.bpm },
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
repository.close()
|
||||||
|
context.deleteDatabase(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun abortBeforeEffortEndNeverCreatesCompletedReference() {
|
||||||
|
val name = "calibration-abort-" + UUID.randomUUID() + ".db"
|
||||||
|
val repository = TrainlogRepository(context, name)
|
||||||
|
try {
|
||||||
|
repository.startCardioCalibration("2026-09-23T11:00:00+02:00")
|
||||||
|
val aborted =
|
||||||
|
repository.abortCardioCalibration("2026-09-23T11:01:00+02:00")
|
||||||
|
as TrainlogRepository.CardioCalibrationResult.Applied
|
||||||
|
assertEquals(CardioCalibrationPhase.ABORTED, aborted.profile.phase)
|
||||||
|
assertNull(aborted.profile.effortEndAt)
|
||||||
|
assertTrue(repository.loadActiveSessionDraft() is ActiveDraftLoadResult.None)
|
||||||
|
assertTrue(repository.listCompletedCardioCalibrations().isEmpty())
|
||||||
|
} finally {
|
||||||
|
repository.close()
|
||||||
|
context.deleteDatabase(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,147 @@
|
||||||
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
|
import com.labfytools.trainlog.model.CardioGuidanceInstruction
|
||||||
|
import com.labfytools.trainlog.model.CardioTargetDefinition
|
||||||
|
import com.labfytools.trainlog.model.CardioTargetSnapshot
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class CardioGuidanceEngineTest {
|
||||||
|
private val target = CardioTargetSnapshot(120, 140)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun absoluteAndCalibrationTargetsResolveToImmutableBpmSnapshot() {
|
||||||
|
assertEquals(
|
||||||
|
CardioTargetSnapshot(120, 140),
|
||||||
|
CardioTargetResolver.resolve(CardioTargetDefinition.AbsoluteBpm(120, 140)),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
CardioTargetSnapshot(
|
||||||
|
minimumBpm = 100,
|
||||||
|
maximumBpm = 125,
|
||||||
|
calibrationId = "cal_11111111-1111-4111-8111-111111111111",
|
||||||
|
calibrationObservedPeakBpm = 167,
|
||||||
|
minimumPercent = 60,
|
||||||
|
maximumPercent = 75,
|
||||||
|
),
|
||||||
|
CardioTargetResolver.resolve(
|
||||||
|
CardioTargetDefinition.CalibrationPercent(
|
||||||
|
calibrationId = "cal_11111111-1111-4111-8111-111111111111",
|
||||||
|
observedPeakBpm = 167,
|
||||||
|
minimumPercent = 60,
|
||||||
|
maximumPercent = 75,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun invalidTargetDefinitionsAreRejectedInsteadOfClamped() {
|
||||||
|
assertNull(CardioTargetResolver.resolve(CardioTargetDefinition.AbsoluteBpm(140, 120)))
|
||||||
|
assertNull(
|
||||||
|
CardioTargetResolver.resolve(
|
||||||
|
CardioTargetDefinition.CalibrationPercent("cal_x", 0, 60, 70),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertNull(
|
||||||
|
CardioTargetResolver.resolve(
|
||||||
|
CardioTargetDefinition.CalibrationPercent("cal_x", 160, 80, 60),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun lowBpmMustPersistBeforeAccelerateInstructionChanges() {
|
||||||
|
val engine = CardioGuidanceEngine(hysteresisBpm = 2, confirmationMs = 3_000)
|
||||||
|
assertEquals(
|
||||||
|
CardioGuidanceInstruction.MAINTAIN,
|
||||||
|
engine.evaluate(target, 110, true, 1_000).instruction,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
CardioGuidanceInstruction.MAINTAIN,
|
||||||
|
engine.evaluate(target, 111, true, 3_999).instruction,
|
||||||
|
)
|
||||||
|
val changed = engine.evaluate(target, 111, true, 4_000)
|
||||||
|
assertEquals(CardioGuidanceInstruction.ACCELERATE, changed.instruction)
|
||||||
|
assertNull(changed.pendingInstruction)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun highBpmMustPersistBeforeSlowDownInstructionChanges() {
|
||||||
|
val engine = CardioGuidanceEngine(hysteresisBpm = 2, confirmationMs = 2_000)
|
||||||
|
engine.evaluate(target, 150, true, 0)
|
||||||
|
assertEquals(
|
||||||
|
CardioGuidanceInstruction.SLOW_DOWN,
|
||||||
|
engine.evaluate(target, 150, true, 2_000).instruction,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hysteresisPreventsBoundaryYoyo() {
|
||||||
|
val engine = CardioGuidanceEngine(hysteresisBpm = 2, confirmationMs = 0)
|
||||||
|
assertEquals(
|
||||||
|
CardioGuidanceInstruction.ACCELERATE,
|
||||||
|
engine.evaluate(target, 117, true, 0).instruction,
|
||||||
|
)
|
||||||
|
// 119 is still below target, but not enough to leave ACCELERATE.
|
||||||
|
assertEquals(
|
||||||
|
CardioGuidanceInstruction.ACCELERATE,
|
||||||
|
engine.evaluate(target, 119, true, 1).instruction,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
CardioGuidanceInstruction.MAINTAIN,
|
||||||
|
engine.evaluate(target, 122, true, 2).instruction,
|
||||||
|
)
|
||||||
|
// 141 is just above the zone and remains MAINTAIN within hysteresis.
|
||||||
|
assertEquals(
|
||||||
|
CardioGuidanceInstruction.MAINTAIN,
|
||||||
|
engine.evaluate(target, 141, true, 3).instruction,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
CardioGuidanceInstruction.SLOW_DOWN,
|
||||||
|
engine.evaluate(target, 143, true, 4).instruction,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isolatedOppositeSampleDoesNotReplaceCurrentInstruction() {
|
||||||
|
val engine = CardioGuidanceEngine(hysteresisBpm = 2, confirmationMs = 3_000)
|
||||||
|
engine.evaluate(target, 110, true, 0)
|
||||||
|
engine.evaluate(target, 110, true, 3_000)
|
||||||
|
assertEquals(
|
||||||
|
CardioGuidanceInstruction.ACCELERATE,
|
||||||
|
engine.evaluate(target, 150, true, 3_100).instruction,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
CardioGuidanceInstruction.ACCELERATE,
|
||||||
|
engine.evaluate(target, 112, true, 3_200).instruction,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun staleOrMissingSignalSuspendsImmediatelyAndNeverUsesOldBpm() {
|
||||||
|
val engine = CardioGuidanceEngine(hysteresisBpm = 2, confirmationMs = 0)
|
||||||
|
assertEquals(
|
||||||
|
CardioGuidanceInstruction.ACCELERATE,
|
||||||
|
engine.evaluate(target, 110, true, 0).instruction,
|
||||||
|
)
|
||||||
|
val stale = engine.evaluate(target, 110, false, 1_000)
|
||||||
|
assertEquals(CardioGuidanceInstruction.SUSPENDED, stale.instruction)
|
||||||
|
assertNull(stale.bpm)
|
||||||
|
|
||||||
|
// A fresh sample must re-establish a candidate from real current data.
|
||||||
|
val resumed = engine.evaluate(target, 130, true, 2_000)
|
||||||
|
assertTrue(resumed.instruction != CardioGuidanceInstruction.SUSPENDED)
|
||||||
|
assertEquals(130, resumed.bpm)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun noTargetSuspendsGuidance() {
|
||||||
|
val engine = CardioGuidanceEngine()
|
||||||
|
val output = engine.evaluate(null, 130, true, 0)
|
||||||
|
assertEquals(CardioGuidanceInstruction.SUSPENDED, output.instruction)
|
||||||
|
assertNull(output.bpm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,260 @@
|
||||||
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||||
|
import com.labfytools.trainlog.model.CardioGuidanceInstruction
|
||||||
|
import com.labfytools.trainlog.model.CardioGuidanceOutput
|
||||||
|
import com.labfytools.trainlog.model.CardioGuidedPhase
|
||||||
|
import com.labfytools.trainlog.model.CardioPhaseExitCondition
|
||||||
|
import com.labfytools.trainlog.model.CardioPhaseKind
|
||||||
|
import com.labfytools.trainlog.model.CardioTargetSnapshot
|
||||||
|
import com.labfytools.trainlog.model.NewExerciseProfile
|
||||||
|
import com.labfytools.trainlog.model.RecordingMode
|
||||||
|
import com.labfytools.trainlog.model.SessionExerciseDraft
|
||||||
|
import com.labfytools.trainlog.model.SessionExercisePlan
|
||||||
|
import com.labfytools.trainlog.model.SessionType
|
||||||
|
import com.labfytools.trainlog.model.TrackingMode
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [35])
|
||||||
|
class CardioGuidanceGenerationTest {
|
||||||
|
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||||
|
|
||||||
|
private fun measurement(bpm: Int) =
|
||||||
|
ParsedHeartRateMeasurement(
|
||||||
|
bpm = bpm,
|
||||||
|
sensorContactDetected = null,
|
||||||
|
energyExpended = null,
|
||||||
|
rrIntervals1024 = listOf(1024),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun completedGuidanceSynchronizesOnceAndAckStopsRepublication() {
|
||||||
|
val name = "guidance-generation-" + UUID.randomUUID() + ".db"
|
||||||
|
val repository = TrainlogRepository(context, name)
|
||||||
|
val root = Files.createTempDirectory("trainlog-guidance-generation-").toFile()
|
||||||
|
try {
|
||||||
|
val exercise =
|
||||||
|
(repository.createExercise(
|
||||||
|
NewExerciseProfile(
|
||||||
|
"Vélo guidé sync",
|
||||||
|
RecordingMode.CONTINUOUS,
|
||||||
|
TrackingMode.DURATION,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
) as CreateExerciseResult.Created).exercise
|
||||||
|
|
||||||
|
val entry =
|
||||||
|
SessionExerciseDraft(
|
||||||
|
exercise = exercise,
|
||||||
|
plan = SessionExercisePlan(sets = 0, durationSeconds = 30),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
ActiveDraftMutationResult.Saved,
|
||||||
|
repository.saveActiveSessionDraft(
|
||||||
|
ActiveSessionDraft(
|
||||||
|
startedAt = OffsetDateTime.now().minusMinutes(1).toString(),
|
||||||
|
sessionType = SessionType.CARDIO,
|
||||||
|
exercises = listOf(entry),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val active =
|
||||||
|
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
||||||
|
val base = OffsetDateTime.parse(checkNotNull(active.startedAt))
|
||||||
|
val exerciseStart = base.plusSeconds(1).toString()
|
||||||
|
assertTrue(
|
||||||
|
repository.startActiveSessionExercise(entry.entryId, exerciseStart) is
|
||||||
|
TrainlogRepository.SessionExerciseTimingResult.Started,
|
||||||
|
)
|
||||||
|
|
||||||
|
val target = CardioTargetSnapshot(120, 140)
|
||||||
|
val phase =
|
||||||
|
CardioGuidedPhase(
|
||||||
|
phaseId = "cgp_" + UUID.randomUUID(),
|
||||||
|
kind = CardioPhaseKind.WORK,
|
||||||
|
target = target,
|
||||||
|
exitCondition = CardioPhaseExitCondition.FixedDuration(30),
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
repository.startCardioGuidancePhase(
|
||||||
|
entry.entryId,
|
||||||
|
phase,
|
||||||
|
base.plusSeconds(2).toString(),
|
||||||
|
) is TrainlogRepository.CardioGuidanceMutationResult.Applied,
|
||||||
|
)
|
||||||
|
|
||||||
|
val lowAt = base.plusSeconds(3).toString()
|
||||||
|
repository.recordLiveHeartRateForActiveSession(
|
||||||
|
"Synthetic HR",
|
||||||
|
lowAt,
|
||||||
|
measurement(110),
|
||||||
|
)
|
||||||
|
repository.recordCardioGuidanceOutput(
|
||||||
|
phase.phaseId,
|
||||||
|
lowAt,
|
||||||
|
CardioGuidanceOutput(
|
||||||
|
CardioGuidanceInstruction.ACCELERATE,
|
||||||
|
110,
|
||||||
|
target,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val targetAt = base.plusSeconds(4).toString()
|
||||||
|
repository.recordLiveHeartRateForActiveSession(
|
||||||
|
"Synthetic HR",
|
||||||
|
targetAt,
|
||||||
|
measurement(130),
|
||||||
|
)
|
||||||
|
repository.recordCardioGuidanceOutput(
|
||||||
|
phase.phaseId,
|
||||||
|
targetAt,
|
||||||
|
CardioGuidanceOutput(
|
||||||
|
CardioGuidanceInstruction.MAINTAIN,
|
||||||
|
130,
|
||||||
|
target,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
repository.finishCardioGuidancePhase(
|
||||||
|
phase.phaseId,
|
||||||
|
base.plusSeconds(5).toString(),
|
||||||
|
)
|
||||||
|
repository.finishActiveSessionExercise(
|
||||||
|
entry.entryId,
|
||||||
|
base.plusSeconds(6).toString(),
|
||||||
|
)
|
||||||
|
val completedDraft =
|
||||||
|
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
||||||
|
assertEquals(
|
||||||
|
ActiveDraftMutationResult.Saved,
|
||||||
|
repository.saveActiveSessionDraft(
|
||||||
|
completedDraft.copy(
|
||||||
|
exercises =
|
||||||
|
completedDraft.exercises.map {
|
||||||
|
if (it.entryId == entry.entryId) {
|
||||||
|
it.copy(continuousDurationSeconds = 5)
|
||||||
|
} else {
|
||||||
|
it
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertTrue(repository.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
|
||||||
|
assertEquals(
|
||||||
|
1,
|
||||||
|
JSONObject(repository.buildCardioGuidanceV1Json())
|
||||||
|
.getJSONArray("runs")
|
||||||
|
.length(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val repositoryRoot =
|
||||||
|
generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))) {
|
||||||
|
it.parentFile
|
||||||
|
}
|
||||||
|
.first { java.io.File(it, "tools/sync_generation_exchange.py").isFile }
|
||||||
|
fun run(vararg command: String): String {
|
||||||
|
val process =
|
||||||
|
ProcessBuilder(*command)
|
||||||
|
.directory(repositoryRoot)
|
||||||
|
.redirectErrorStream(true)
|
||||||
|
.start()
|
||||||
|
val output = process.inputStream.bufferedReader().readText()
|
||||||
|
assertEquals(
|
||||||
|
"command failed: ${command.joinToString(" ")}\n$output",
|
||||||
|
0,
|
||||||
|
process.waitFor(),
|
||||||
|
)
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
val desktopDb = java.io.File(root, "desktop.sqlite")
|
||||||
|
run(
|
||||||
|
java.io.File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
|
||||||
|
desktopDb.absolutePath,
|
||||||
|
)
|
||||||
|
val desktopPeer =
|
||||||
|
Regex("PEER_ID=(peer_[^\\s]+)")
|
||||||
|
.find(
|
||||||
|
run(
|
||||||
|
"python3",
|
||||||
|
java.io.File(
|
||||||
|
repositoryRoot,
|
||||||
|
"tools/sync_generation_exchange.py",
|
||||||
|
).absolutePath,
|
||||||
|
"peer-id",
|
||||||
|
"--database",
|
||||||
|
desktopDb.absolutePath,
|
||||||
|
),
|
||||||
|
)!!
|
||||||
|
.groupValues[1]
|
||||||
|
|
||||||
|
val service = SyncGenerationService(repository)
|
||||||
|
val captured = service.capture(root, desktopPeer)
|
||||||
|
assertEquals(
|
||||||
|
1,
|
||||||
|
JSONObject(
|
||||||
|
java.io.File(
|
||||||
|
captured.stagingDirectory,
|
||||||
|
"cardio-guidance-v1.json",
|
||||||
|
).readText(),
|
||||||
|
)
|
||||||
|
.getJSONArray("runs")
|
||||||
|
.length(),
|
||||||
|
)
|
||||||
|
val published =
|
||||||
|
service.publish(captured, java.io.File(root, "objects"))
|
||||||
|
val ackFile = java.io.File(root, "ack.json")
|
||||||
|
run(
|
||||||
|
"python3",
|
||||||
|
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath,
|
||||||
|
"consume-desktop",
|
||||||
|
published.absolutePath,
|
||||||
|
"--database",
|
||||||
|
desktopDb.absolutePath,
|
||||||
|
"--ack-output",
|
||||||
|
ackFile.absolutePath,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"consumed",
|
||||||
|
JSONObject(ackFile.readText()).getString("result"),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"acknowledged",
|
||||||
|
service.acceptAcknowledgement(ackFile.readBytes()),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
0,
|
||||||
|
JSONObject(repository.buildCardioGuidanceV1Json())
|
||||||
|
.getJSONArray("runs")
|
||||||
|
.length(),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"1|1|2",
|
||||||
|
run(
|
||||||
|
"sqlite3",
|
||||||
|
desktopDb.absolutePath,
|
||||||
|
"SELECT (SELECT count(*) FROM cardio_guidance_runs) || '|' || " +
|
||||||
|
"(SELECT count(*) FROM cardio_guidance_phases) || '|' || " +
|
||||||
|
"(SELECT count(*) FROM cardio_guidance_events);",
|
||||||
|
)
|
||||||
|
.trim(),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
repository.close()
|
||||||
|
context.deleteDatabase(name)
|
||||||
|
root.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,428 @@
|
||||||
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.database.sqlite.SQLiteDatabase
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||||
|
import com.labfytools.trainlog.model.CardioGuidanceInstruction
|
||||||
|
import com.labfytools.trainlog.model.CardioGuidanceOutput
|
||||||
|
import com.labfytools.trainlog.model.CardioGuidedPhase
|
||||||
|
import com.labfytools.trainlog.model.CardioPhaseExitCondition
|
||||||
|
import com.labfytools.trainlog.model.CardioPhaseKind
|
||||||
|
import com.labfytools.trainlog.model.CardioTargetSnapshot
|
||||||
|
import com.labfytools.trainlog.model.HeartRateContextKind
|
||||||
|
import com.labfytools.trainlog.model.NewExerciseProfile
|
||||||
|
import com.labfytools.trainlog.model.RecordingMode
|
||||||
|
import com.labfytools.trainlog.model.SessionExerciseDraft
|
||||||
|
import com.labfytools.trainlog.model.SessionExercisePlan
|
||||||
|
import com.labfytools.trainlog.model.SessionType
|
||||||
|
import com.labfytools.trainlog.model.TrackingMode
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [35])
|
||||||
|
class CardioGuidanceRepositoryTest {
|
||||||
|
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||||
|
|
||||||
|
private data class ActiveCardio(
|
||||||
|
val repository: TrainlogRepository,
|
||||||
|
val databaseName: String,
|
||||||
|
val entryId: String,
|
||||||
|
val exerciseStartedAt: String,
|
||||||
|
) {
|
||||||
|
fun at(seconds: Long): String =
|
||||||
|
OffsetDateTime.parse(exerciseStartedAt).plusSeconds(seconds).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun activeCardio(): ActiveCardio {
|
||||||
|
val name = "guidance-" + UUID.randomUUID() + ".db"
|
||||||
|
val repository = TrainlogRepository(context, name)
|
||||||
|
val exercise =
|
||||||
|
(repository.createExercise(
|
||||||
|
NewExerciseProfile(
|
||||||
|
"Tapis guidé",
|
||||||
|
RecordingMode.CONTINUOUS,
|
||||||
|
TrackingMode.DURATION,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
) as CreateExerciseResult.Created).exercise
|
||||||
|
val entry =
|
||||||
|
SessionExerciseDraft(
|
||||||
|
exercise = exercise,
|
||||||
|
plan = SessionExercisePlan(sets = 0, durationSeconds = 600),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
ActiveDraftMutationResult.Saved,
|
||||||
|
repository.saveActiveSessionDraft(
|
||||||
|
ActiveSessionDraft(
|
||||||
|
startedAt = OffsetDateTime.now().minusMinutes(20).toString(),
|
||||||
|
sessionType = SessionType.CARDIO,
|
||||||
|
exercises = listOf(entry),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val loaded = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
|
||||||
|
val exerciseStartedAt =
|
||||||
|
OffsetDateTime.parse(checkNotNull(loaded.draft.startedAt))
|
||||||
|
.plusSeconds(10)
|
||||||
|
.toString()
|
||||||
|
val started =
|
||||||
|
repository.startActiveSessionExercise(
|
||||||
|
entry.entryId,
|
||||||
|
exerciseStartedAt,
|
||||||
|
)
|
||||||
|
assertTrue(started is TrainlogRepository.SessionExerciseTimingResult.Started)
|
||||||
|
return ActiveCardio(repository, name, entry.entryId, exerciseStartedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun trainingCaptureTransitionsToCardioWithoutStarvingGuidance() {
|
||||||
|
val name = "guidance-transition-" + UUID.randomUUID() + ".db"
|
||||||
|
val repository = TrainlogRepository(context, name)
|
||||||
|
try {
|
||||||
|
val exercise =
|
||||||
|
(repository.createExercise(
|
||||||
|
NewExerciseProfile(
|
||||||
|
"Vélo guidé après transition",
|
||||||
|
RecordingMode.CONTINUOUS,
|
||||||
|
TrackingMode.DURATION,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
) as CreateExerciseResult.Created).exercise
|
||||||
|
assertEquals(ActiveDraftMutationResult.Saved, repository.startActiveSessionDraft())
|
||||||
|
val training =
|
||||||
|
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
||||||
|
val base = OffsetDateTime.parse(checkNotNull(training.startedAt))
|
||||||
|
val firstMeasurement =
|
||||||
|
ParsedHeartRateMeasurement(
|
||||||
|
bpm = 101,
|
||||||
|
sensorContactDetected = true,
|
||||||
|
energyExpended = 12,
|
||||||
|
rrIntervals1024 = listOf(611),
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
repository.recordLiveHeartRateForActiveSession(
|
||||||
|
"Synthetic HR",
|
||||||
|
base.plusSeconds(1).toString(),
|
||||||
|
firstMeasurement,
|
||||||
|
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||||
|
)
|
||||||
|
val originalCapture = checkNotNull(repository.activeHeartRateCapture())
|
||||||
|
assertEquals(HeartRateContextKind.SESSION, originalCapture.contextKind)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
ActiveDraftMutationResult.Saved,
|
||||||
|
repository.saveActiveSessionDraft(training.copy(sessionType = SessionType.CARDIO)),
|
||||||
|
)
|
||||||
|
val transitionedCapture = checkNotNull(repository.activeHeartRateCapture())
|
||||||
|
assertEquals(originalCapture.captureId, transitionedCapture.captureId)
|
||||||
|
assertEquals(HeartRateContextKind.CARDIO, transitionedCapture.contextKind)
|
||||||
|
|
||||||
|
val entry =
|
||||||
|
SessionExerciseDraft(
|
||||||
|
exercise = exercise,
|
||||||
|
plan = SessionExercisePlan(sets = 0, durationSeconds = 60),
|
||||||
|
)
|
||||||
|
val cardio =
|
||||||
|
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
||||||
|
assertEquals(
|
||||||
|
ActiveDraftMutationResult.Saved,
|
||||||
|
repository.saveActiveSessionDraft(cardio.copy(exercises = listOf(entry))),
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
repository.startActiveSessionExercise(entry.entryId, base.plusSeconds(2).toString()) is
|
||||||
|
TrainlogRepository.SessionExerciseTimingResult.Started,
|
||||||
|
)
|
||||||
|
val target = CardioTargetSnapshot(100, 110)
|
||||||
|
val phase =
|
||||||
|
CardioGuidedPhase(
|
||||||
|
phaseId = "cgp_" + UUID.randomUUID(),
|
||||||
|
kind = CardioPhaseKind.WARMUP,
|
||||||
|
target = target,
|
||||||
|
exitCondition = CardioPhaseExitCondition.FixedDuration(60),
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
repository.startCardioGuidancePhase(
|
||||||
|
entry.entryId,
|
||||||
|
phase,
|
||||||
|
base.plusSeconds(3).toString(),
|
||||||
|
) is TrainlogRepository.CardioGuidanceMutationResult.Applied,
|
||||||
|
)
|
||||||
|
val secondAt = base.plusSeconds(4).toString()
|
||||||
|
assertTrue(
|
||||||
|
repository.recordLiveHeartRateForActiveSession(
|
||||||
|
"Synthetic HR",
|
||||||
|
secondAt,
|
||||||
|
firstMeasurement.copy(bpm = 99),
|
||||||
|
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
repository.recordCardioGuidanceOutput(
|
||||||
|
phase.phaseId,
|
||||||
|
secondAt,
|
||||||
|
CardioGuidanceOutput(CardioGuidanceInstruction.ACCELERATE, 99, target),
|
||||||
|
) is TrainlogRepository.CardioGuidanceMutationResult.Applied,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
listOf(CardioGuidanceInstruction.ACCELERATE),
|
||||||
|
repository.listCardioGuidanceEvents(phase.phaseId).map { it.instruction },
|
||||||
|
)
|
||||||
|
|
||||||
|
val lateReverse =
|
||||||
|
repository.saveActiveSessionDraft(
|
||||||
|
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded)
|
||||||
|
.draft
|
||||||
|
.copy(sessionType = SessionType.TRAINING),
|
||||||
|
)
|
||||||
|
assertTrue(lateReverse is ActiveDraftMutationResult.Error)
|
||||||
|
assertEquals(
|
||||||
|
SessionType.CARDIO,
|
||||||
|
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded)
|
||||||
|
.draft
|
||||||
|
.sessionType,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
HeartRateContextKind.CARDIO,
|
||||||
|
checkNotNull(repository.activeHeartRateCapture()).contextKind,
|
||||||
|
)
|
||||||
|
|
||||||
|
SQLiteDatabase.openDatabase(
|
||||||
|
context.getDatabasePath(name).path,
|
||||||
|
null,
|
||||||
|
SQLiteDatabase.OPEN_READONLY,
|
||||||
|
).use { db ->
|
||||||
|
db.rawQuery(
|
||||||
|
"SELECT sequence,bpm,exercise_entry_id FROM heart_rate_samples " +
|
||||||
|
"WHERE capture_id=? ORDER BY sequence",
|
||||||
|
arrayOf(originalCapture.captureId),
|
||||||
|
).use { cursor ->
|
||||||
|
assertTrue(cursor.moveToFirst())
|
||||||
|
assertEquals(0L, cursor.getLong(0))
|
||||||
|
assertEquals(101, cursor.getInt(1))
|
||||||
|
assertTrue(cursor.isNull(2))
|
||||||
|
assertTrue(cursor.moveToNext())
|
||||||
|
assertEquals(1L, cursor.getLong(0))
|
||||||
|
assertEquals(99, cursor.getInt(1))
|
||||||
|
assertEquals(entry.entryId, cursor.getString(2))
|
||||||
|
assertTrue(!cursor.moveToNext())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(ActiveDraftMutationResult.Saved, repository.discardActiveSessionDraft())
|
||||||
|
assertNull(repository.activeHeartRateCapture())
|
||||||
|
assertNull(repository.activeCardioGuidancePhase())
|
||||||
|
assertTrue(repository.listActiveSessionExerciseTimings().isEmpty())
|
||||||
|
} finally {
|
||||||
|
repository.close()
|
||||||
|
context.deleteDatabase(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cardioCaptureCanReturnToTrainingBeforeCardioOnlyFactsExist() {
|
||||||
|
val name = "guidance-early-reverse-" + UUID.randomUUID() + ".db"
|
||||||
|
val repository = TrainlogRepository(context, name)
|
||||||
|
try {
|
||||||
|
assertEquals(ActiveDraftMutationResult.Saved, repository.startActiveSessionDraft())
|
||||||
|
val training =
|
||||||
|
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
||||||
|
val observedAt =
|
||||||
|
OffsetDateTime.parse(checkNotNull(training.startedAt)).plusSeconds(1).toString()
|
||||||
|
assertTrue(
|
||||||
|
repository.recordLiveHeartRateForActiveSession(
|
||||||
|
"Synthetic HR",
|
||||||
|
observedAt,
|
||||||
|
ParsedHeartRateMeasurement(103, null, null, listOf(600)),
|
||||||
|
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||||
|
)
|
||||||
|
val captureId = checkNotNull(repository.activeHeartRateCapture()).captureId
|
||||||
|
assertEquals(
|
||||||
|
ActiveDraftMutationResult.Saved,
|
||||||
|
repository.saveActiveSessionDraft(training.copy(sessionType = SessionType.CARDIO)),
|
||||||
|
)
|
||||||
|
val cardio =
|
||||||
|
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
||||||
|
assertEquals(
|
||||||
|
ActiveDraftMutationResult.Saved,
|
||||||
|
repository.saveActiveSessionDraft(cardio.copy(sessionType = SessionType.TRAINING)),
|
||||||
|
)
|
||||||
|
val reversed = checkNotNull(repository.activeHeartRateCapture())
|
||||||
|
assertEquals(captureId, reversed.captureId)
|
||||||
|
assertEquals(HeartRateContextKind.SESSION, reversed.contextKind)
|
||||||
|
|
||||||
|
SQLiteDatabase.openDatabase(
|
||||||
|
context.getDatabasePath(name).path,
|
||||||
|
null,
|
||||||
|
SQLiteDatabase.OPEN_READONLY,
|
||||||
|
).use { db ->
|
||||||
|
db.rawQuery(
|
||||||
|
"SELECT COUNT(*),MIN(bpm) FROM heart_rate_samples WHERE capture_id=?",
|
||||||
|
arrayOf(captureId),
|
||||||
|
).use { cursor ->
|
||||||
|
assertTrue(cursor.moveToFirst())
|
||||||
|
assertEquals(1, cursor.getInt(0))
|
||||||
|
assertEquals(103, cursor.getInt(1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
repository.close()
|
||||||
|
context.deleteDatabase(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun phaseRequiresActiveCardioExerciseAndRecordsOnlyInstructionChanges() {
|
||||||
|
val active = activeCardio()
|
||||||
|
val repository = active.repository
|
||||||
|
try {
|
||||||
|
val target = CardioTargetSnapshot(120, 140)
|
||||||
|
val phase =
|
||||||
|
CardioGuidedPhase(
|
||||||
|
phaseId = "cgp_" + UUID.randomUUID(),
|
||||||
|
kind = CardioPhaseKind.WORK,
|
||||||
|
target = target,
|
||||||
|
exitCondition = CardioPhaseExitCondition.FixedDuration(180),
|
||||||
|
)
|
||||||
|
val started =
|
||||||
|
repository.startCardioGuidancePhase(
|
||||||
|
active.entryId,
|
||||||
|
phase,
|
||||||
|
active.at(10),
|
||||||
|
) as TrainlogRepository.CardioGuidanceMutationResult.Applied
|
||||||
|
assertEquals(CardioGuidanceInstruction.MAINTAIN, started.phase!!.currentInstruction)
|
||||||
|
|
||||||
|
repository.recordCardioGuidanceOutput(
|
||||||
|
phase.phaseId,
|
||||||
|
active.at(13),
|
||||||
|
CardioGuidanceOutput(
|
||||||
|
CardioGuidanceInstruction.ACCELERATE,
|
||||||
|
110,
|
||||||
|
target,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
repository.recordCardioGuidanceOutput(
|
||||||
|
phase.phaseId,
|
||||||
|
active.at(14),
|
||||||
|
CardioGuidanceOutput(
|
||||||
|
CardioGuidanceInstruction.ACCELERATE,
|
||||||
|
111,
|
||||||
|
target,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
repository.recordCardioGuidanceOutput(
|
||||||
|
phase.phaseId,
|
||||||
|
active.at(30),
|
||||||
|
CardioGuidanceOutput(
|
||||||
|
CardioGuidanceInstruction.MAINTAIN,
|
||||||
|
130,
|
||||||
|
target,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
listOf(
|
||||||
|
CardioGuidanceInstruction.ACCELERATE,
|
||||||
|
CardioGuidanceInstruction.MAINTAIN,
|
||||||
|
),
|
||||||
|
repository.listCardioGuidanceEvents(phase.phaseId).map { it.instruction },
|
||||||
|
)
|
||||||
|
|
||||||
|
repository.finishActiveSessionExercise(
|
||||||
|
active.entryId,
|
||||||
|
active.at(600),
|
||||||
|
)
|
||||||
|
assertNull(repository.activeCardioGuidancePhase())
|
||||||
|
val completedDraft =
|
||||||
|
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
||||||
|
assertEquals(
|
||||||
|
ActiveDraftMutationResult.Saved,
|
||||||
|
repository.saveActiveSessionDraft(
|
||||||
|
completedDraft.copy(
|
||||||
|
exercises =
|
||||||
|
completedDraft.exercises.map {
|
||||||
|
if (it.entryId == active.entryId) {
|
||||||
|
it.copy(continuousDurationSeconds = 600)
|
||||||
|
} else {
|
||||||
|
it
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val finalized = repository.finalizeActiveSessionDraft()
|
||||||
|
assertTrue(finalized is FinalizeActiveDraftResult.Saved)
|
||||||
|
} finally {
|
||||||
|
repository.close()
|
||||||
|
context.deleteDatabase(active.databaseName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun guidanceCannotStartBeforeExerciseTimingIsActive() {
|
||||||
|
val name = "guidance-inactive-" + UUID.randomUUID() + ".db"
|
||||||
|
val repository = TrainlogRepository(context, name)
|
||||||
|
try {
|
||||||
|
val exercise =
|
||||||
|
(repository.createExercise(
|
||||||
|
NewExerciseProfile(
|
||||||
|
"Vélo guidé",
|
||||||
|
RecordingMode.CONTINUOUS,
|
||||||
|
TrackingMode.DURATION,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
) as CreateExerciseResult.Created).exercise
|
||||||
|
repository.startActiveSessionDraft()
|
||||||
|
val loaded = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
|
||||||
|
val entry = SessionExerciseDraft(exercise = exercise, continuousDurationSeconds = 60)
|
||||||
|
repository.saveActiveSessionDraft(
|
||||||
|
loaded.draft.copy(sessionType = SessionType.CARDIO, exercises = listOf(entry)),
|
||||||
|
)
|
||||||
|
val result =
|
||||||
|
repository.startCardioGuidancePhase(
|
||||||
|
entry.entryId,
|
||||||
|
CardioGuidedPhase(
|
||||||
|
"cgp_" + UUID.randomUUID(),
|
||||||
|
CardioPhaseKind.WARMUP,
|
||||||
|
CardioTargetSnapshot(100, 115),
|
||||||
|
CardioPhaseExitCondition.FixedDuration(60),
|
||||||
|
),
|
||||||
|
"2026-09-23T13:00:00+02:00",
|
||||||
|
)
|
||||||
|
assertTrue(result is TrainlogRepository.CardioGuidanceMutationResult.Invalid)
|
||||||
|
} finally {
|
||||||
|
repository.close()
|
||||||
|
context.deleteDatabase(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun abandonedDraftDeletesOnlyUnfinishedGuidanceRun() {
|
||||||
|
val active = activeCardio()
|
||||||
|
val repository = active.repository
|
||||||
|
try {
|
||||||
|
repository.startCardioGuidancePhase(
|
||||||
|
active.entryId,
|
||||||
|
CardioGuidedPhase(
|
||||||
|
"cgp_" + UUID.randomUUID(),
|
||||||
|
CardioPhaseKind.WORK,
|
||||||
|
CardioTargetSnapshot(120, 140),
|
||||||
|
CardioPhaseExitCondition.FixedDuration(120),
|
||||||
|
),
|
||||||
|
active.at(10),
|
||||||
|
)
|
||||||
|
assertTrue(repository.activeCardioGuidancePhase() != null)
|
||||||
|
repository.discardActiveSessionDraft()
|
||||||
|
assertNull(repository.activeCardioGuidancePhase())
|
||||||
|
} finally {
|
||||||
|
repository.close()
|
||||||
|
context.deleteDatabase(active.databaseName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
|
import com.labfytools.trainlog.model.CardioGuidedPhase
|
||||||
|
import com.labfytools.trainlog.model.CardioPhaseExitCondition
|
||||||
|
import com.labfytools.trainlog.model.CardioPhaseKind
|
||||||
|
import com.labfytools.trainlog.model.CardioTargetSnapshot
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class CardioPhaseCompletionTest {
|
||||||
|
private val target = CardioTargetSnapshot(120, 140)
|
||||||
|
|
||||||
|
private fun phase(
|
||||||
|
exit: CardioPhaseExitCondition,
|
||||||
|
targetOverride: CardioTargetSnapshot? = target,
|
||||||
|
) = CardioGuidedPhase(
|
||||||
|
phaseId = "phase_test",
|
||||||
|
kind = CardioPhaseKind.WORK,
|
||||||
|
target = targetOverride,
|
||||||
|
exitCondition = exit,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fixedDurationUsesElapsedTimeOnly() {
|
||||||
|
val p = phase(CardioPhaseExitCondition.FixedDuration(180))
|
||||||
|
assertFalse(CardioPhaseCompletion.isComplete(p, 179, null, false))
|
||||||
|
assertTrue(CardioPhaseCompletion.isComplete(p, 180, null, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun enterTargetRequiresFreshMeasurementInsideSnapshot() {
|
||||||
|
val p = phase(CardioPhaseExitCondition.EnterTarget)
|
||||||
|
assertFalse(CardioPhaseCompletion.isComplete(p, 10, 130, false))
|
||||||
|
assertFalse(CardioPhaseCompletion.isComplete(p, 10, 119, true))
|
||||||
|
assertTrue(CardioPhaseCompletion.isComplete(p, 10, 120, true))
|
||||||
|
assertTrue(CardioPhaseCompletion.isComplete(p, 10, 140, true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun enterTargetWithoutTargetNeverCompletes() {
|
||||||
|
val p = phase(CardioPhaseExitCondition.EnterTarget, null)
|
||||||
|
assertFalse(CardioPhaseCompletion.isComplete(p, 100, 130, true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun recoveryThresholdRequiresFreshMeasurement() {
|
||||||
|
val p = phase(CardioPhaseExitCondition.RecoverBelow(110), null)
|
||||||
|
assertFalse(CardioPhaseCompletion.isComplete(p, 30, 105, false))
|
||||||
|
assertFalse(CardioPhaseCompletion.isComplete(p, 30, 111, true))
|
||||||
|
assertTrue(CardioPhaseCompletion.isComplete(p, 30, 110, true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun durationOrRecoveryCanFinishFromEitherRealCondition() {
|
||||||
|
val p = phase(CardioPhaseExitCondition.DurationOrRecoverBelow(300, 110), null)
|
||||||
|
assertFalse(CardioPhaseCompletion.isComplete(p, 299, 120, true))
|
||||||
|
assertTrue(CardioPhaseCompletion.isComplete(p, 100, 108, true))
|
||||||
|
assertTrue(CardioPhaseCompletion.isComplete(p, 300, null, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun negativeElapsedTimeCannotCompleteAnything() {
|
||||||
|
val p = phase(CardioPhaseExitCondition.FixedDuration(0))
|
||||||
|
assertFalse(CardioPhaseCompletion.isComplete(p, -1, 130, true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,194 @@
|
||||||
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||||
|
import com.labfytools.trainlog.model.NewExerciseProfile
|
||||||
|
import com.labfytools.trainlog.model.RecordingMode
|
||||||
|
import com.labfytools.trainlog.model.SessionExerciseDraft
|
||||||
|
import com.labfytools.trainlog.model.SessionSetDraft
|
||||||
|
import com.labfytools.trainlog.model.SessionType
|
||||||
|
import com.labfytools.trainlog.model.TrackingMode
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.util.UUID
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [35])
|
||||||
|
class CardioSessionSyncGenerationTest {
|
||||||
|
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dedicatedCardioCompanionImportsWithoutWideningFrozenHistory() {
|
||||||
|
val sourceName = "cardio-sync-" + UUID.randomUUID() + ".db"
|
||||||
|
val root = Files.createTempDirectory("trainlog-cardio-sync-").toFile()
|
||||||
|
val source = TrainlogRepository(context, sourceName)
|
||||||
|
try {
|
||||||
|
val exercise =
|
||||||
|
(source.createExercise(
|
||||||
|
NewExerciseProfile(
|
||||||
|
"Cardio sync exercise",
|
||||||
|
RecordingMode.SETS,
|
||||||
|
TrackingMode.REPS,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
) as CreateExerciseResult.Created).exercise
|
||||||
|
val entry =
|
||||||
|
SessionExerciseDraft(
|
||||||
|
exercise = exercise,
|
||||||
|
sets = listOf(SessionSetDraft(reps = 15)),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
ActiveDraftMutationResult.Saved,
|
||||||
|
source.saveActiveSessionDraft(
|
||||||
|
ActiveSessionDraft(
|
||||||
|
sessionType = SessionType.CARDIO,
|
||||||
|
exercises = listOf(entry),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
source.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved,
|
||||||
|
)
|
||||||
|
val sessionId = source.listSessions().single().sessionId
|
||||||
|
|
||||||
|
val repositoryRoot =
|
||||||
|
generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))) {
|
||||||
|
it.parentFile
|
||||||
|
}.first { java.io.File(it, "tools/sync_generation_exchange.py").isFile }
|
||||||
|
|
||||||
|
fun run(vararg command: String): String {
|
||||||
|
val process =
|
||||||
|
ProcessBuilder(*command)
|
||||||
|
.directory(repositoryRoot)
|
||||||
|
.redirectErrorStream(true)
|
||||||
|
.start()
|
||||||
|
val output = process.inputStream.bufferedReader().readText()
|
||||||
|
assertEquals(
|
||||||
|
"command failed: " + command.joinToString(" ") + "\n" + output,
|
||||||
|
0,
|
||||||
|
process.waitFor(),
|
||||||
|
)
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
val desktopDb = java.io.File(root, "desktop.sqlite")
|
||||||
|
run(
|
||||||
|
java.io.File(
|
||||||
|
repositoryRoot,
|
||||||
|
"build/tui/sync-generation-fixture",
|
||||||
|
).absolutePath,
|
||||||
|
desktopDb.absolutePath,
|
||||||
|
)
|
||||||
|
val desktopPeer =
|
||||||
|
Regex("PEER_ID=(peer_[^\\s]+)")
|
||||||
|
.find(
|
||||||
|
run(
|
||||||
|
"python3",
|
||||||
|
java.io.File(
|
||||||
|
repositoryRoot,
|
||||||
|
"tools/sync_generation_exchange.py",
|
||||||
|
).absolutePath,
|
||||||
|
"peer-id",
|
||||||
|
"--database",
|
||||||
|
desktopDb.absolutePath,
|
||||||
|
),
|
||||||
|
)!!
|
||||||
|
.groupValues[1]
|
||||||
|
|
||||||
|
val service = SyncGenerationService(source)
|
||||||
|
val captured = service.capture(root, desktopPeer)
|
||||||
|
val history =
|
||||||
|
JSONObject(
|
||||||
|
java.io.File(
|
||||||
|
captured.stagingDirectory,
|
||||||
|
"history-v4.json",
|
||||||
|
).readText(),
|
||||||
|
)
|
||||||
|
assertEquals(0, history.getJSONArray("sessions").length())
|
||||||
|
|
||||||
|
val cardio =
|
||||||
|
JSONObject(
|
||||||
|
java.io.File(
|
||||||
|
captured.stagingDirectory,
|
||||||
|
"cardio-sessions-v1.json",
|
||||||
|
).readText(),
|
||||||
|
)
|
||||||
|
assertEquals("trainlog-cardio-sessions", cardio.getString("format"))
|
||||||
|
assertEquals(1, cardio.getJSONArray("sessions").length())
|
||||||
|
assertEquals(
|
||||||
|
"cardio",
|
||||||
|
cardio
|
||||||
|
.getJSONArray("sessions")
|
||||||
|
.getJSONObject(0)
|
||||||
|
.getString("session_type"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val published = service.publish(captured, java.io.File(root, "objects"))
|
||||||
|
val ackFile = java.io.File(root, "ack.json")
|
||||||
|
run(
|
||||||
|
"python3",
|
||||||
|
java.io.File(
|
||||||
|
repositoryRoot,
|
||||||
|
"tools/sync_generation_exchange.py",
|
||||||
|
).absolutePath,
|
||||||
|
"consume-desktop",
|
||||||
|
published.absolutePath,
|
||||||
|
"--database",
|
||||||
|
desktopDb.absolutePath,
|
||||||
|
"--ack-output",
|
||||||
|
ackFile.absolutePath,
|
||||||
|
)
|
||||||
|
val ack = JSONObject(ackFile.readText())
|
||||||
|
assertEquals(
|
||||||
|
"desktop ACK diagnostic: " + ack.getString("diagnostic"),
|
||||||
|
"consumed",
|
||||||
|
ack.getString("result"),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"training|cardio|15",
|
||||||
|
run(
|
||||||
|
"sqlite3",
|
||||||
|
desktopDb.absolutePath,
|
||||||
|
"SELECT s.session_type || '|' || s.session_kind || '|' || ps.reps " +
|
||||||
|
"FROM sessions s JOIN session_exercises se ON se.session_row_id=s.id " +
|
||||||
|
"JOIN performed_sets ps ON ps.session_exercise_row_id=se.id " +
|
||||||
|
"WHERE s.session_id='$sessionId';",
|
||||||
|
).trim(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"acknowledged",
|
||||||
|
service.acceptAcknowledgement(ackFile.readBytes()),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
ack.toString(),
|
||||||
|
JSONObject(
|
||||||
|
run(
|
||||||
|
"python3",
|
||||||
|
java.io.File(
|
||||||
|
repositoryRoot,
|
||||||
|
"tools/sync_generation_exchange.py",
|
||||||
|
).absolutePath,
|
||||||
|
"consume-desktop",
|
||||||
|
published.absolutePath,
|
||||||
|
"--database",
|
||||||
|
desktopDb.absolutePath,
|
||||||
|
"--ack-output",
|
||||||
|
java.io.File(root, "replay-ack.json").absolutePath,
|
||||||
|
).let { java.io.File(root, "replay-ack.json").readText() },
|
||||||
|
).toString(),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
source.close()
|
||||||
|
context.deleteDatabase(sourceName)
|
||||||
|
root.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||||
|
import com.labfytools.trainlog.model.NewExerciseProfile
|
||||||
|
import com.labfytools.trainlog.model.RecordingMode
|
||||||
|
import com.labfytools.trainlog.model.SessionExerciseDraft
|
||||||
|
import com.labfytools.trainlog.model.SessionSetDraft
|
||||||
|
import com.labfytools.trainlog.model.SessionType
|
||||||
|
import com.labfytools.trainlog.model.TrackingMode
|
||||||
|
import java.util.UUID
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [35])
|
||||||
|
class CardioSessionTypeTest {
|
||||||
|
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cardioIsCanonicalLocallyAndExcludedFromFrozenMobileHistory() {
|
||||||
|
val name = "cardio-kind-" + UUID.randomUUID() + ".db"
|
||||||
|
val repository = TrainlogRepository(context, name)
|
||||||
|
try {
|
||||||
|
val exercise =
|
||||||
|
(repository.createExercise(
|
||||||
|
NewExerciseProfile(
|
||||||
|
"Cardio kind exercise",
|
||||||
|
RecordingMode.SETS,
|
||||||
|
TrackingMode.REPS,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
) as CreateExerciseResult.Created).exercise
|
||||||
|
val occurrence =
|
||||||
|
SessionExerciseDraft(
|
||||||
|
exercise = exercise,
|
||||||
|
sets = listOf(SessionSetDraft(reps = 1)),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
ActiveDraftMutationResult.Saved,
|
||||||
|
repository.saveActiveSessionDraft(
|
||||||
|
ActiveSessionDraft(
|
||||||
|
sessionType = SessionType.CARDIO,
|
||||||
|
exercises = listOf(occurrence),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val loaded =
|
||||||
|
repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
|
||||||
|
assertEquals(SessionType.CARDIO, loaded.draft.sessionType)
|
||||||
|
|
||||||
|
val db = context.openOrCreateDatabase(name, Context.MODE_PRIVATE, null)
|
||||||
|
db.rawQuery(
|
||||||
|
"SELECT session_type,session_kind FROM active_session_draft WHERE id=1",
|
||||||
|
null,
|
||||||
|
).use { cursor ->
|
||||||
|
assertTrue(cursor.moveToFirst())
|
||||||
|
assertEquals("training", cursor.getString(0))
|
||||||
|
assertEquals("cardio", cursor.getString(1))
|
||||||
|
}
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
repository.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
SessionType.CARDIO,
|
||||||
|
repository.listSessions().single().sessionType,
|
||||||
|
)
|
||||||
|
|
||||||
|
val frozen = JSONObject(repository.buildMobileExportV4Json())
|
||||||
|
assertEquals(0, frozen.getJSONArray("sessions").length())
|
||||||
|
|
||||||
|
val verify = context.openOrCreateDatabase(name, Context.MODE_PRIVATE, null)
|
||||||
|
verify.rawQuery(
|
||||||
|
"SELECT session_type,session_kind FROM sessions",
|
||||||
|
null,
|
||||||
|
).use { cursor ->
|
||||||
|
assertTrue(cursor.moveToFirst())
|
||||||
|
assertEquals("training", cursor.getString(0))
|
||||||
|
assertEquals("cardio", cursor.getString(1))
|
||||||
|
}
|
||||||
|
verify.close()
|
||||||
|
} finally {
|
||||||
|
repository.close()
|
||||||
|
context.deleteDatabase(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class HeartRateLiveStateTest {
|
||||||
|
@Test
|
||||||
|
fun liveStateDropsStaleBpmWithoutInventingMeasurement() {
|
||||||
|
HeartRateLiveState.disconnected()
|
||||||
|
val observed = mutableListOf<HeartRateLiveSnapshot>()
|
||||||
|
val subscription = HeartRateLiveState.subscribe { observed += it }
|
||||||
|
try {
|
||||||
|
HeartRateLiveState.connecting()
|
||||||
|
HeartRateLiveState.measurement(
|
||||||
|
ParsedHeartRateMeasurement(
|
||||||
|
bpm = 82,
|
||||||
|
sensorContactDetected = null,
|
||||||
|
energyExpended = null,
|
||||||
|
rrIntervals1024 = listOf(768),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertEquals(HeartRateLivePhase.CONNECTED, HeartRateLiveState.current().phase)
|
||||||
|
assertEquals(82, HeartRateLiveState.current().bpm)
|
||||||
|
assertEquals(listOf(768), HeartRateLiveState.current().rrIntervals1024)
|
||||||
|
assertEquals(1L, HeartRateLiveState.current().notificationCount)
|
||||||
|
|
||||||
|
HeartRateLiveState.stale()
|
||||||
|
assertEquals(HeartRateLivePhase.STALE, HeartRateLiveState.current().phase)
|
||||||
|
assertNull(HeartRateLiveState.current().bpm)
|
||||||
|
assertEquals(listOf(768), HeartRateLiveState.current().rrIntervals1024)
|
||||||
|
|
||||||
|
HeartRateLiveState.disconnected()
|
||||||
|
assertEquals(HeartRateLivePhase.DISCONNECTED, HeartRateLiveState.current().phase)
|
||||||
|
assertNull(HeartRateLiveState.current().bpm)
|
||||||
|
assertEquals(5, observed.size)
|
||||||
|
} finally {
|
||||||
|
subscription.close()
|
||||||
|
HeartRateLiveState.disconnected()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class HeartRateMeasurementParserTest {
|
||||||
|
private fun parsed(vararg bytes: Int): ParsedHeartRateMeasurement {
|
||||||
|
val result =
|
||||||
|
HeartRateMeasurementParser.parse(
|
||||||
|
ByteArray(bytes.size) { index -> bytes[index].toByte() },
|
||||||
|
)
|
||||||
|
return (result as HeartRateMeasurementParseResult.Parsed).measurement
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun invalid(vararg bytes: Int): HeartRateMeasurementParseResult.Invalid =
|
||||||
|
HeartRateMeasurementParser.parse(
|
||||||
|
ByteArray(bytes.size) { index -> bytes[index].toByte() },
|
||||||
|
) as HeartRateMeasurementParseResult.Invalid
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parsesEightBitBpm() {
|
||||||
|
val value = parsed(0x00, 72)
|
||||||
|
assertEquals(72, value.bpm)
|
||||||
|
assertNull(value.sensorContactDetected)
|
||||||
|
assertNull(value.energyExpended)
|
||||||
|
assertTrue(value.rrIntervals1024.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parsesSixteenBitBpmLittleEndian() {
|
||||||
|
assertEquals(300, parsed(0x01, 0x2c, 0x01).bpm)
|
||||||
|
assertEquals(65535, parsed(0x01, 0xff, 0xff).bpm)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun contactIsNullableAndOnlyMeaningfulWhenSupported() {
|
||||||
|
assertNull(parsed(0x02, 80).sensorContactDetected)
|
||||||
|
assertEquals(false, parsed(0x04, 80).sensorContactDetected)
|
||||||
|
assertEquals(true, parsed(0x06, 80).sensorContactDetected)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parsesEnergyExpended() {
|
||||||
|
val value = parsed(0x08, 90, 0x34, 0x12)
|
||||||
|
assertEquals(0x1234, value.energyExpended)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parsesOneAndMultipleRawRrIntervals() {
|
||||||
|
assertEquals(listOf(1024), parsed(0x10, 60, 0x00, 0x04).rrIntervals1024)
|
||||||
|
assertEquals(
|
||||||
|
listOf(1024, 1000, 65535),
|
||||||
|
parsed(0x10, 60, 0x00, 0x04, 0xe8, 0x03, 0xff, 0xff).rrIntervals1024,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parsesCombinedKnownFlagsInWireOrder() {
|
||||||
|
val value =
|
||||||
|
parsed(
|
||||||
|
0x1f,
|
||||||
|
0x96,
|
||||||
|
0x00,
|
||||||
|
0x07,
|
||||||
|
0x00,
|
||||||
|
0xf0,
|
||||||
|
0x03,
|
||||||
|
0x00,
|
||||||
|
0x04,
|
||||||
|
)
|
||||||
|
assertEquals(150, value.bpm)
|
||||||
|
assertEquals(true, value.sensorContactDetected)
|
||||||
|
assertEquals(7, value.energyExpended)
|
||||||
|
assertEquals(listOf(1008, 1024), value.rrIntervals1024)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectsTruncatedAndIncoherentPayloads() {
|
||||||
|
assertEquals("missing flags", invalid().reason)
|
||||||
|
assertEquals("truncated 16-bit BPM", invalid(0x01, 0x2c).reason)
|
||||||
|
assertEquals("truncated energy-expended value", invalid(0x08, 70, 0x01).reason)
|
||||||
|
assertEquals("RR flag has no interval", invalid(0x10, 70).reason)
|
||||||
|
assertEquals("truncated RR interval", invalid(0x10, 70, 0x01).reason)
|
||||||
|
assertEquals("unexpected trailing bytes", invalid(0x00, 70, 0x01).reason)
|
||||||
|
assertEquals("reserved flags are set", invalid(0x20, 70).reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun preservesBoundaryBpmValues() {
|
||||||
|
assertEquals(0, parsed(0x00, 0).bpm)
|
||||||
|
assertEquals(255, parsed(0x00, 255).bpm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import com.labfytools.trainlog.model.HeartRateContextKind
|
||||||
|
import com.labfytools.trainlog.model.HeartRateRrInterval
|
||||||
|
import com.labfytools.trainlog.model.SleepDiaryDraft
|
||||||
|
import java.util.UUID
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [35])
|
||||||
|
class HeartRateRepositoryTest {
|
||||||
|
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sleepCaptureStoresRawSamplesAndExportsOnlyAfterStop() {
|
||||||
|
val name = "heart-rate-${UUID.randomUUID()}.db"
|
||||||
|
val repository = TrainlogRepository(context, name)
|
||||||
|
try {
|
||||||
|
val created =
|
||||||
|
repository.saveSleepDiary(
|
||||||
|
SleepDiaryDraft(
|
||||||
|
nightStartDate = "2026-09-22",
|
||||||
|
nightEndDate = "2026-09-23",
|
||||||
|
createdAt = "2026-09-22T22:00:00+02:00",
|
||||||
|
updatedAt = "2026-09-22T22:00:00+02:00",
|
||||||
|
sleepQuality = null,
|
||||||
|
wakeQuality = null,
|
||||||
|
dayForm = null,
|
||||||
|
treatmentAndNotes = "",
|
||||||
|
events = emptyList(),
|
||||||
|
),
|
||||||
|
) as TrainlogRepository.SaveSleepDiaryResult.Saved
|
||||||
|
|
||||||
|
val started =
|
||||||
|
repository.startHeartRateCapture(
|
||||||
|
HeartRateContextKind.SLEEP,
|
||||||
|
created.entryId,
|
||||||
|
"2026-09-22T22:30:00+02:00",
|
||||||
|
"Synthetic HR sensor",
|
||||||
|
) as TrainlogRepository.StartHeartRateCaptureResult.Started
|
||||||
|
assertEquals(started.captureId, repository.activeHeartRateCapture()?.captureId)
|
||||||
|
|
||||||
|
val sample =
|
||||||
|
repository.appendHeartRateSample(
|
||||||
|
captureId = started.captureId,
|
||||||
|
observedAt = "2026-09-22T22:30:01+02:00",
|
||||||
|
bpm = 61,
|
||||||
|
sensorContactDetected = true,
|
||||||
|
energyExpended = 7,
|
||||||
|
rrIntervals = listOf(
|
||||||
|
HeartRateRrInterval(0, 1024),
|
||||||
|
HeartRateRrInterval(1, 1000),
|
||||||
|
),
|
||||||
|
) as TrainlogRepository.HeartRateMutationResult.Applied
|
||||||
|
assertEquals(0L, sample.sequence)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
0,
|
||||||
|
JSONObject(repository.buildHeartRateV1Json()).getJSONArray("captures").length(),
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
repository.stopHeartRateCapture(
|
||||||
|
started.captureId,
|
||||||
|
"2026-09-23T06:30:00+02:00",
|
||||||
|
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||||
|
)
|
||||||
|
assertNull(repository.activeHeartRateCapture())
|
||||||
|
|
||||||
|
val root = JSONObject(repository.buildHeartRateV1Json())
|
||||||
|
assertEquals("trainlog-heart-rate", root.getString("format"))
|
||||||
|
assertEquals(1, root.getInt("version"))
|
||||||
|
val capture = root.getJSONArray("captures").getJSONObject(0)
|
||||||
|
assertEquals(created.entryId, capture.getString("context_id"))
|
||||||
|
assertEquals("sleep", capture.getString("context_kind"))
|
||||||
|
assertEquals("Synthetic HR sensor", capture.getString("sensor_name"))
|
||||||
|
val stored = capture.getJSONArray("samples").getJSONObject(0)
|
||||||
|
assertEquals(61, stored.getInt("bpm"))
|
||||||
|
assertTrue(stored.getBoolean("sensor_contact_detected"))
|
||||||
|
assertEquals(7, stored.getInt("energy_expended"))
|
||||||
|
assertEquals(1024, stored.getJSONArray("rr_intervals_1024").getInt(0))
|
||||||
|
assertEquals(1000, stored.getJSONArray("rr_intervals_1024").getInt(1))
|
||||||
|
} finally {
|
||||||
|
repository.close()
|
||||||
|
context.deleteDatabase(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [35])
|
||||||
|
class HeartRateSensorPreferencesTest {
|
||||||
|
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun selectionIsDurableAndClearable() {
|
||||||
|
val preferences = HeartRateSensorPreferences(context)
|
||||||
|
preferences.clear()
|
||||||
|
assertNull(preferences.selected())
|
||||||
|
|
||||||
|
preferences.select("AA:BB:CC:DD:EE:FF", "Synthetic HR")
|
||||||
|
assertEquals(
|
||||||
|
SelectedHeartRateSensor("AA:BB:CC:DD:EE:FF", "Synthetic HR"),
|
||||||
|
HeartRateSensorPreferences(context).selected(),
|
||||||
|
)
|
||||||
|
|
||||||
|
preferences.clear()
|
||||||
|
assertNull(HeartRateSensorPreferences(context).selected())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,490 @@
|
||||||
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.database.sqlite.SQLiteDatabase
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import com.labfytools.trainlog.model.HeartRateContextKind
|
||||||
|
import com.labfytools.trainlog.model.SessionSetDraft
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [35])
|
||||||
|
class ProgramSessionCardioReadinessTest {
|
||||||
|
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun programSessionPreservesTimelineAndHeartRateOwnershipThroughDesktopReplay() {
|
||||||
|
val androidName = "program-cardio-readiness-${UUID.randomUUID()}.db"
|
||||||
|
val android = TrainlogRepository(context, androidName)
|
||||||
|
val root = Files.createTempDirectory("trainlog-program-cardio-readiness-").toFile()
|
||||||
|
val repositoryRoot =
|
||||||
|
generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))) {
|
||||||
|
it.parentFile
|
||||||
|
}.first { java.io.File(it, "tools/sync_generation_exchange.py").isFile }
|
||||||
|
val desktopDb = java.io.File(root, "desktop.sqlite")
|
||||||
|
val fixture = java.io.File(repositoryRoot, "build/tui/sync-generation-fixture")
|
||||||
|
val generationTool =
|
||||||
|
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath
|
||||||
|
|
||||||
|
fun run(vararg command: String): String {
|
||||||
|
val process =
|
||||||
|
ProcessBuilder(*command)
|
||||||
|
.directory(repositoryRoot)
|
||||||
|
.redirectErrorStream(true)
|
||||||
|
.start()
|
||||||
|
val output = process.inputStream.bufferedReader().readText()
|
||||||
|
assertEquals(
|
||||||
|
"command failed: ${command.joinToString(" ")}\n$output",
|
||||||
|
0,
|
||||||
|
process.waitFor(),
|
||||||
|
)
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
fun measurement(bpm: Int, rr: Int) =
|
||||||
|
ParsedHeartRateMeasurement(
|
||||||
|
bpm = bpm,
|
||||||
|
sensorContactDetected = true,
|
||||||
|
energyExpended = null,
|
||||||
|
rrIntervals1024 = listOf(rr),
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val programId = "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
||||||
|
val programSessionId = "pgs_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
|
||||||
|
val programExerciseAId = "ex_dddddddd-dddd-4ddd-8ddd-dddddddddddd"
|
||||||
|
val programExerciseBId = "ex_11111111-1111-4111-8111-111111111111"
|
||||||
|
val catalog =
|
||||||
|
JSONObject()
|
||||||
|
.put("format", "trainlog-pc-catalog")
|
||||||
|
.put("version", 1)
|
||||||
|
.put(
|
||||||
|
"exercises",
|
||||||
|
JSONArray()
|
||||||
|
.put(
|
||||||
|
JSONObject()
|
||||||
|
.put("exercise_id", programExerciseAId)
|
||||||
|
.put("name", "Program exercise fixture A")
|
||||||
|
.put("recording_mode", "sets")
|
||||||
|
.put("tracking_mode", "reps")
|
||||||
|
.put("data_fields", 0),
|
||||||
|
)
|
||||||
|
.put(
|
||||||
|
JSONObject()
|
||||||
|
.put("exercise_id", programExerciseBId)
|
||||||
|
.put("name", "Program exercise fixture B")
|
||||||
|
.put("recording_mode", "sets")
|
||||||
|
.put("tracking_mode", "reps")
|
||||||
|
.put("data_fields", 0),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toString()
|
||||||
|
assertTrue(android.applyPcCatalogJson(catalog) is PcCatalogImportResult.Applied)
|
||||||
|
val program =
|
||||||
|
JSONObject()
|
||||||
|
.put("program_id", programId)
|
||||||
|
.put("revision_id", "pgr_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
|
||||||
|
.put("title", "Tomorrow readiness")
|
||||||
|
.put("note", JSONObject.NULL)
|
||||||
|
.put("state", "active")
|
||||||
|
.put("start_date", "2026-09-24")
|
||||||
|
.put("end_date", JSONObject.NULL)
|
||||||
|
.put("created_at", "2026-09-23T16:00:00Z")
|
||||||
|
.put("updated_at", "2026-09-23T16:00:00Z")
|
||||||
|
.put("source_format", "trainlog-program")
|
||||||
|
.put("source_version", 1)
|
||||||
|
.put("source_payload_sha256", "a".repeat(64))
|
||||||
|
.put(
|
||||||
|
"sessions",
|
||||||
|
JSONArray().put(
|
||||||
|
JSONObject()
|
||||||
|
.put("program_session_id", programSessionId)
|
||||||
|
.put("title", "Session A")
|
||||||
|
.put("session_type", "training")
|
||||||
|
.put("planned_for", "2026-09-24")
|
||||||
|
.put("note", JSONObject.NULL)
|
||||||
|
.put(
|
||||||
|
"occurrences",
|
||||||
|
JSONArray()
|
||||||
|
.put(
|
||||||
|
JSONObject()
|
||||||
|
.put(
|
||||||
|
"entry_id",
|
||||||
|
"pge_cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
||||||
|
)
|
||||||
|
.put("exercise_id", programExerciseAId)
|
||||||
|
.put("equipment_id", JSONObject.NULL)
|
||||||
|
.put("load_mode", "external")
|
||||||
|
.put("rest_seconds", 90)
|
||||||
|
.put("target_sets", 1)
|
||||||
|
.put("target_reps", 8)
|
||||||
|
.put("target_duration_seconds", JSONObject.NULL)
|
||||||
|
.put("target_weight_kg", 40.0)
|
||||||
|
.put("notes", JSONObject.NULL),
|
||||||
|
)
|
||||||
|
.put(
|
||||||
|
JSONObject()
|
||||||
|
.put(
|
||||||
|
"entry_id",
|
||||||
|
"pge_22222222-2222-4222-8222-222222222222",
|
||||||
|
)
|
||||||
|
.put("exercise_id", programExerciseBId)
|
||||||
|
.put("equipment_id", JSONObject.NULL)
|
||||||
|
.put("load_mode", "external")
|
||||||
|
.put("rest_seconds", 60)
|
||||||
|
.put("target_sets", 1)
|
||||||
|
.put("target_reps", 10)
|
||||||
|
.put("target_duration_seconds", JSONObject.NULL)
|
||||||
|
.put("target_weight_kg", 25.0)
|
||||||
|
.put("notes", JSONObject.NULL),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val programs =
|
||||||
|
JSONObject()
|
||||||
|
.put("format", "trainlog-programs")
|
||||||
|
.put("version", 1)
|
||||||
|
.put("generated_at", "2026-09-23T16:01:00Z")
|
||||||
|
.put("programs", JSONArray().put(program))
|
||||||
|
.put("deletions", JSONArray())
|
||||||
|
.toString()
|
||||||
|
assertEquals(
|
||||||
|
ProgramsImportResult.Applied(1, 0, 0),
|
||||||
|
android.applyProgramsV1Json(programs),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
StartProgramSessionResult.Started,
|
||||||
|
android.startSyncedProgramSession(programId, programSessionId),
|
||||||
|
)
|
||||||
|
val opened = android.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
|
||||||
|
val sessionId = checkNotNull(opened.draft.sessionId)
|
||||||
|
val sessionStartedAt = checkNotNull(opened.draft.startedAt)
|
||||||
|
assertEquals(programId, opened.draft.sourceProgramId)
|
||||||
|
assertEquals(programSessionId, opened.draft.sourceProgramSessionId)
|
||||||
|
assertEquals(2, opened.draft.exercises.size)
|
||||||
|
val exerciseA = opened.draft.exercises[0]
|
||||||
|
val exerciseBEntry = opened.draft.exercises[1]
|
||||||
|
assertEquals(programExerciseAId, exerciseA.exercise.exerciseId)
|
||||||
|
assertEquals(programExerciseBId, exerciseBEntry.exercise.exerciseId)
|
||||||
|
assertTrue(exerciseA.entryId != exerciseBEntry.entryId)
|
||||||
|
assertEquals(
|
||||||
|
ActiveDraftMutationResult.Saved,
|
||||||
|
android.saveActiveSessionDraft(
|
||||||
|
opened.draft.copy(
|
||||||
|
exercises =
|
||||||
|
listOf(
|
||||||
|
exerciseA.copy(
|
||||||
|
sets = listOf(SessionSetDraft(reps = 8, weightKg = 40.0)),
|
||||||
|
),
|
||||||
|
exerciseBEntry.copy(
|
||||||
|
sets = listOf(SessionSetDraft(reps = 10, weightKg = 25.0)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val edited = android.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
|
||||||
|
assertEquals(sessionId, edited.draft.sessionId)
|
||||||
|
assertEquals(sessionStartedAt, edited.draft.startedAt)
|
||||||
|
assertEquals(programId, edited.draft.sourceProgramId)
|
||||||
|
assertEquals(programSessionId, edited.draft.sourceProgramSessionId)
|
||||||
|
|
||||||
|
val preExerciseAt = OffsetDateTime.now().toString()
|
||||||
|
assertTrue(
|
||||||
|
android.recordLiveHeartRateForActiveSession(
|
||||||
|
"Synthetic CYCPLUS",
|
||||||
|
preExerciseAt,
|
||||||
|
measurement(91, 675),
|
||||||
|
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||||
|
)
|
||||||
|
val capture = checkNotNull(android.activeHeartRateCapture())
|
||||||
|
assertEquals(HeartRateContextKind.SESSION, capture.contextKind)
|
||||||
|
assertEquals(sessionId, capture.contextId)
|
||||||
|
assertEquals(sessionStartedAt, capture.startedAt)
|
||||||
|
|
||||||
|
val exerciseAStartedAt = OffsetDateTime.now().toString()
|
||||||
|
assertTrue(
|
||||||
|
android.startActiveSessionExercise(exerciseA.entryId, exerciseAStartedAt) is
|
||||||
|
TrainlogRepository.SessionExerciseTimingResult.Started,
|
||||||
|
)
|
||||||
|
val exerciseASampleAt = OffsetDateTime.now().toString()
|
||||||
|
assertTrue(
|
||||||
|
android.recordLiveHeartRateForActiveSession(
|
||||||
|
"Synthetic CYCPLUS",
|
||||||
|
exerciseASampleAt,
|
||||||
|
measurement(112, 548),
|
||||||
|
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||||
|
)
|
||||||
|
val exerciseAEndedAt = OffsetDateTime.now().toString()
|
||||||
|
assertTrue(
|
||||||
|
android.finishActiveSessionExercise(exerciseA.entryId, exerciseAEndedAt) is
|
||||||
|
TrainlogRepository.SessionExerciseTimingResult.Finished,
|
||||||
|
)
|
||||||
|
|
||||||
|
val betweenExercisesAt = OffsetDateTime.now().toString()
|
||||||
|
assertTrue(
|
||||||
|
android.recordLiveHeartRateForActiveSession(
|
||||||
|
"Synthetic CYCPLUS",
|
||||||
|
betweenExercisesAt,
|
||||||
|
measurement(98, 620),
|
||||||
|
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||||
|
)
|
||||||
|
val exerciseBStartedAt = OffsetDateTime.now().toString()
|
||||||
|
assertTrue(
|
||||||
|
android.startActiveSessionExercise(
|
||||||
|
exerciseBEntry.entryId,
|
||||||
|
exerciseBStartedAt,
|
||||||
|
) is TrainlogRepository.SessionExerciseTimingResult.Started,
|
||||||
|
)
|
||||||
|
val exerciseBSampleAt = OffsetDateTime.now().toString()
|
||||||
|
assertTrue(
|
||||||
|
android.recordLiveHeartRateForActiveSession(
|
||||||
|
"Synthetic CYCPLUS",
|
||||||
|
exerciseBSampleAt,
|
||||||
|
measurement(119, 525),
|
||||||
|
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||||
|
)
|
||||||
|
val activeFinalize = android.finalizeActiveSessionDraft()
|
||||||
|
assertTrue(activeFinalize is FinalizeActiveDraftResult.Invalid)
|
||||||
|
assertTrue(
|
||||||
|
(activeFinalize as FinalizeActiveDraftResult.Invalid)
|
||||||
|
.message
|
||||||
|
.startsWith("ACTIVE_EXERCISE:"),
|
||||||
|
)
|
||||||
|
val exerciseBEndedAt = OffsetDateTime.now().toString()
|
||||||
|
assertTrue(
|
||||||
|
android.finishActiveSessionExercise(
|
||||||
|
exerciseBEntry.entryId,
|
||||||
|
exerciseBEndedAt,
|
||||||
|
) is TrainlogRepository.SessionExerciseTimingResult.Finished,
|
||||||
|
)
|
||||||
|
val finalized = android.finalizeActiveSessionDraft()
|
||||||
|
assertTrue(finalized is FinalizeActiveDraftResult.Saved)
|
||||||
|
assertEquals(sessionId, (finalized as FinalizeActiveDraftResult.Saved).sessionId)
|
||||||
|
assertNull(android.activeHeartRateCapture())
|
||||||
|
|
||||||
|
val timeline = JSONObject(android.buildSessionTimelineV1Json())
|
||||||
|
val timelineSession = timeline.getJSONArray("sessions").getJSONObject(0)
|
||||||
|
val sessionEndedAt = timelineSession.getString("ended_at")
|
||||||
|
assertEquals(sessionId, timelineSession.getString("session_id"))
|
||||||
|
assertEquals(sessionStartedAt, timelineSession.getString("started_at"))
|
||||||
|
assertTrue(OffsetDateTime.parse(sessionEndedAt) > OffsetDateTime.parse(sessionStartedAt))
|
||||||
|
val timelineExercises = timelineSession.getJSONArray("exercises")
|
||||||
|
assertEquals(2, timelineExercises.length())
|
||||||
|
assertEquals(exerciseA.entryId, timelineExercises.getJSONObject(0).getString("entry_id"))
|
||||||
|
assertEquals(exerciseAStartedAt, timelineExercises.getJSONObject(0).getString("started_at"))
|
||||||
|
assertEquals(exerciseAEndedAt, timelineExercises.getJSONObject(0).getString("ended_at"))
|
||||||
|
assertEquals(
|
||||||
|
exerciseBEntry.entryId,
|
||||||
|
timelineExercises.getJSONObject(1).getString("entry_id"),
|
||||||
|
)
|
||||||
|
assertEquals(exerciseBStartedAt, timelineExercises.getJSONObject(1).getString("started_at"))
|
||||||
|
assertEquals(exerciseBEndedAt, timelineExercises.getJSONObject(1).getString("ended_at"))
|
||||||
|
|
||||||
|
val heart = JSONObject(android.buildHeartRateV1Json())
|
||||||
|
val heartCapture = heart.getJSONArray("captures").getJSONObject(0)
|
||||||
|
assertEquals(capture.captureId, heartCapture.getString("capture_id"))
|
||||||
|
assertEquals("session", heartCapture.getString("context_kind"))
|
||||||
|
assertEquals(sessionId, heartCapture.getString("context_id"))
|
||||||
|
assertEquals(sessionStartedAt, heartCapture.getString("started_at"))
|
||||||
|
assertEquals(sessionEndedAt, heartCapture.getString("ended_at"))
|
||||||
|
val samples = heartCapture.getJSONArray("samples")
|
||||||
|
assertEquals(4, samples.length())
|
||||||
|
val expectedOwnership =
|
||||||
|
listOf(null, exerciseA.entryId, null, exerciseBEntry.entryId)
|
||||||
|
val expectedTimes =
|
||||||
|
listOf(preExerciseAt, exerciseASampleAt, betweenExercisesAt, exerciseBSampleAt)
|
||||||
|
val expectedRr = listOf(675, 548, 620, 525)
|
||||||
|
for (index in 0 until samples.length()) {
|
||||||
|
val sample = samples.getJSONObject(index)
|
||||||
|
assertEquals(index.toLong(), sample.getLong("sequence"))
|
||||||
|
assertEquals(expectedTimes[index], sample.getString("observed_at"))
|
||||||
|
if (expectedOwnership[index] == null) {
|
||||||
|
assertTrue(sample.isNull("exercise_entry_id"))
|
||||||
|
} else {
|
||||||
|
assertEquals(expectedOwnership[index], sample.getString("exercise_entry_id"))
|
||||||
|
}
|
||||||
|
assertEquals(expectedRr[index], sample.getJSONArray("rr_intervals_1024").getInt(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
SQLiteDatabase.openDatabase(
|
||||||
|
context.getDatabasePath(androidName).absolutePath,
|
||||||
|
null,
|
||||||
|
SQLiteDatabase.OPEN_READONLY,
|
||||||
|
).use { db ->
|
||||||
|
db.rawQuery(
|
||||||
|
"SELECT source_program_id,source_program_session_id FROM sessions " +
|
||||||
|
"WHERE session_id=?",
|
||||||
|
arrayOf(sessionId),
|
||||||
|
).use { cursor ->
|
||||||
|
assertTrue(cursor.moveToFirst())
|
||||||
|
assertEquals(programId, cursor.getString(0))
|
||||||
|
assertEquals(programSessionId, cursor.getString(1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run(fixture.absolutePath, desktopDb.absolutePath, "program-create")
|
||||||
|
val desktopPeer =
|
||||||
|
Regex("PEER_ID=(peer_[^\\s]+)")
|
||||||
|
.find(
|
||||||
|
run(
|
||||||
|
"python3",
|
||||||
|
generationTool,
|
||||||
|
"peer-id",
|
||||||
|
"--database",
|
||||||
|
desktopDb.absolutePath,
|
||||||
|
),
|
||||||
|
)!!
|
||||||
|
.groupValues[1]
|
||||||
|
val service = SyncGenerationService(android)
|
||||||
|
val captured = service.capture(root, desktopPeer)
|
||||||
|
val published = service.publish(captured, java.io.File(root, "objects"))
|
||||||
|
val publishedTimeline =
|
||||||
|
JSONObject(java.io.File(published, "session-timeline-v1.json").readText())
|
||||||
|
val publishedHeart =
|
||||||
|
JSONObject(java.io.File(published, "heart-rate-v1.json").readText())
|
||||||
|
assertEquals(sessionId, publishedTimeline.getJSONArray("sessions").getJSONObject(0).getString("session_id"))
|
||||||
|
assertEquals(capture.captureId, publishedHeart.getJSONArray("captures").getJSONObject(0).getString("capture_id"))
|
||||||
|
|
||||||
|
val firstAck = java.io.File(root, "desktop-ack-first.json")
|
||||||
|
run(
|
||||||
|
"python3",
|
||||||
|
generationTool,
|
||||||
|
"consume-desktop",
|
||||||
|
published.absolutePath,
|
||||||
|
"--database",
|
||||||
|
desktopDb.absolutePath,
|
||||||
|
"--ack-output",
|
||||||
|
firstAck.absolutePath,
|
||||||
|
)
|
||||||
|
val countsBeforeReplay =
|
||||||
|
run(
|
||||||
|
"sqlite3",
|
||||||
|
desktopDb.absolutePath,
|
||||||
|
"SELECT (SELECT count(*) FROM session_exercise_timeline WHERE session_id='$sessionId') || '|' || " +
|
||||||
|
"(SELECT count(*) FROM heart_rate_samples WHERE capture_id='${capture.captureId}') || '|' || " +
|
||||||
|
"(SELECT count(*) FROM heart_rate_rr_intervals WHERE capture_id='${capture.captureId}');",
|
||||||
|
).trim()
|
||||||
|
assertEquals("2|4|4", countsBeforeReplay)
|
||||||
|
|
||||||
|
val replayAck = java.io.File(root, "desktop-ack-replay.json")
|
||||||
|
run(
|
||||||
|
"python3",
|
||||||
|
generationTool,
|
||||||
|
"consume-desktop",
|
||||||
|
published.absolutePath,
|
||||||
|
"--database",
|
||||||
|
desktopDb.absolutePath,
|
||||||
|
"--ack-output",
|
||||||
|
replayAck.absolutePath,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
countsBeforeReplay,
|
||||||
|
run(
|
||||||
|
"sqlite3",
|
||||||
|
desktopDb.absolutePath,
|
||||||
|
"SELECT (SELECT count(*) FROM session_exercise_timeline WHERE session_id='$sessionId') || '|' || " +
|
||||||
|
"(SELECT count(*) FROM heart_rate_samples WHERE capture_id='${capture.captureId}') || '|' || " +
|
||||||
|
"(SELECT count(*) FROM heart_rate_rr_intervals WHERE capture_id='${capture.captureId}');",
|
||||||
|
).trim(),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"acknowledged",
|
||||||
|
service.acceptAcknowledgement(firstAck.readBytes()),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
0,
|
||||||
|
JSONObject(android.buildSessionTimelineV1Json()).getJSONArray("sessions").length(),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
0,
|
||||||
|
JSONObject(android.buildHeartRateV1Json()).getJSONArray("captures").length(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val desktopFacts =
|
||||||
|
JSONObject(
|
||||||
|
run(
|
||||||
|
"sqlite3",
|
||||||
|
desktopDb.absolutePath,
|
||||||
|
"SELECT json_object(" +
|
||||||
|
"'program_id',x.program_id,'program_session_id',x.program_session_id," +
|
||||||
|
"'started_at',s.started_at,'ended_at',s.ended_at) FROM sessions s " +
|
||||||
|
"JOIN program_session_executions x ON x.session_id=s.session_id " +
|
||||||
|
"WHERE s.session_id='$sessionId' AND x.state='completed';",
|
||||||
|
).trim(),
|
||||||
|
)
|
||||||
|
assertEquals(programId, desktopFacts.getString("program_id"))
|
||||||
|
assertEquals(programSessionId, desktopFacts.getString("program_session_id"))
|
||||||
|
assertEquals(sessionStartedAt, desktopFacts.getString("started_at"))
|
||||||
|
assertEquals(sessionEndedAt, desktopFacts.getString("ended_at"))
|
||||||
|
assertEquals(
|
||||||
|
listOf(
|
||||||
|
"$exerciseAStartedAt|$exerciseAEndedAt|${exerciseA.entryId}",
|
||||||
|
"$exerciseBStartedAt|$exerciseBEndedAt|${exerciseBEntry.entryId}",
|
||||||
|
),
|
||||||
|
run(
|
||||||
|
"sqlite3",
|
||||||
|
"-separator",
|
||||||
|
"|",
|
||||||
|
desktopDb.absolutePath,
|
||||||
|
"SELECT started_at,ended_at,entry_id FROM session_exercise_timeline " +
|
||||||
|
"WHERE session_id='$sessionId' ORDER BY started_at,entry_id;",
|
||||||
|
).lineSequence().filter { it.isNotBlank() }.toList(),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"session|$sessionId|$sessionStartedAt|$sessionEndedAt",
|
||||||
|
run(
|
||||||
|
"sqlite3",
|
||||||
|
"-separator",
|
||||||
|
"|",
|
||||||
|
desktopDb.absolutePath,
|
||||||
|
"SELECT context_kind,context_id,started_at,ended_at " +
|
||||||
|
"FROM heart_rate_captures WHERE capture_id='${capture.captureId}';",
|
||||||
|
).trim(),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
listOf(
|
||||||
|
"0|$preExerciseAt|",
|
||||||
|
"1|$exerciseASampleAt|${exerciseA.entryId}",
|
||||||
|
"2|$betweenExercisesAt|",
|
||||||
|
"3|$exerciseBSampleAt|${exerciseBEntry.entryId}",
|
||||||
|
),
|
||||||
|
run(
|
||||||
|
"sqlite3",
|
||||||
|
"-separator",
|
||||||
|
"|",
|
||||||
|
desktopDb.absolutePath,
|
||||||
|
"SELECT sequence,observed_at,COALESCE(exercise_entry_id,'') " +
|
||||||
|
"FROM heart_rate_samples " +
|
||||||
|
"WHERE capture_id='${capture.captureId}' ORDER BY sequence;",
|
||||||
|
).lineSequence().filter { it.isNotBlank() }.toList(),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
expectedRr,
|
||||||
|
run(
|
||||||
|
"sqlite3",
|
||||||
|
desktopDb.absolutePath,
|
||||||
|
"SELECT value_1024 FROM heart_rate_rr_intervals " +
|
||||||
|
"WHERE capture_id='${capture.captureId}' ORDER BY sample_sequence,rr_index;",
|
||||||
|
).lineSequence().filter { it.isNotBlank() }.map { it.toInt() }.toList(),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
android.close()
|
||||||
|
context.deleteDatabase(androidName)
|
||||||
|
root.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -360,7 +360,7 @@ class SessionPreparationExchangeTest {
|
||||||
repository = TrainlogRepository(context, name)
|
repository = TrainlogRepository(context, name)
|
||||||
assertEquals(1, repository.listPreparedSessions().size)
|
assertEquals(1, repository.listPreparedSessions().size)
|
||||||
repository.close()
|
repository.close()
|
||||||
assertEquals("26", scalarText(name, "PRAGMA user_version"))
|
assertEquals("32", scalarText(name, "PRAGMA user_version"))
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"0",
|
"0",
|
||||||
scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"),
|
scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,181 @@
|
||||||
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import com.labfytools.trainlog.model.NewExerciseProfile
|
||||||
|
import com.labfytools.trainlog.model.RecordingMode
|
||||||
|
import com.labfytools.trainlog.model.SessionExerciseDraft
|
||||||
|
import com.labfytools.trainlog.model.SessionSetDraft
|
||||||
|
import com.labfytools.trainlog.model.TrackingMode
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [35])
|
||||||
|
class SessionTimelineRepositoryTest {
|
||||||
|
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun startedOccurrenceCannotBeRemovedOrSilentlyClosedBySessionFinalization() {
|
||||||
|
val name = "timeline-guard-" + UUID.randomUUID() + ".db"
|
||||||
|
val repository = TrainlogRepository(context, name)
|
||||||
|
try {
|
||||||
|
val exercise =
|
||||||
|
(repository.createExercise(
|
||||||
|
NewExerciseProfile(
|
||||||
|
"Timeline guarded exercise",
|
||||||
|
RecordingMode.SETS,
|
||||||
|
TrackingMode.REPS,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
) as CreateExerciseResult.Created).exercise
|
||||||
|
assertEquals(
|
||||||
|
ActiveDraftMutationResult.Saved,
|
||||||
|
repository.startActiveSessionDraft(),
|
||||||
|
)
|
||||||
|
val opened =
|
||||||
|
repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
|
||||||
|
val occurrence =
|
||||||
|
SessionExerciseDraft(
|
||||||
|
exercise = exercise,
|
||||||
|
sets = listOf(SessionSetDraft(reps = 5)),
|
||||||
|
)
|
||||||
|
val activeDraft = opened.draft.copy(exercises = listOf(occurrence))
|
||||||
|
assertEquals(
|
||||||
|
ActiveDraftMutationResult.Saved,
|
||||||
|
repository.saveActiveSessionDraft(activeDraft),
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
repository.startActiveSessionExercise(occurrence.entryId) is
|
||||||
|
TrainlogRepository.SessionExerciseTimingResult.Started,
|
||||||
|
)
|
||||||
|
|
||||||
|
val removed = repository.saveActiveSessionDraft(activeDraft.copy(exercises = emptyList()))
|
||||||
|
assertTrue(removed is ActiveDraftMutationResult.Error)
|
||||||
|
val finalize = repository.finalizeActiveSessionDraft()
|
||||||
|
assertTrue(finalize is FinalizeActiveDraftResult.Invalid)
|
||||||
|
assertTrue(
|
||||||
|
(finalize as FinalizeActiveDraftResult.Invalid)
|
||||||
|
.message
|
||||||
|
.startsWith("ACTIVE_EXERCISE:"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
repository.finishActiveSessionExercise(occurrence.entryId) is
|
||||||
|
TrainlogRepository.SessionExerciseTimingResult.Finished,
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
repository.finalizeActiveSessionDraft() is
|
||||||
|
FinalizeActiveDraftResult.Saved,
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
repository.close()
|
||||||
|
context.deleteDatabase(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun trainingTimelineCorrelatesMeasuredSamplesWithoutInventingRestOwner() {
|
||||||
|
val name = "timeline-${UUID.randomUUID()}.db"
|
||||||
|
val repository = TrainlogRepository(context, name)
|
||||||
|
try {
|
||||||
|
fun exercise(label: String) =
|
||||||
|
(repository.createExercise(
|
||||||
|
NewExerciseProfile(label, RecordingMode.SETS, TrackingMode.REPS, 0),
|
||||||
|
) as CreateExerciseResult.Created).exercise
|
||||||
|
|
||||||
|
val first = exercise("Timeline A")
|
||||||
|
val second = exercise("Timeline B")
|
||||||
|
assertEquals(ActiveDraftMutationResult.Saved, repository.startActiveSessionDraft())
|
||||||
|
val opened = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
|
||||||
|
val firstEntry = SessionExerciseDraft(
|
||||||
|
exercise = first,
|
||||||
|
sets = listOf(SessionSetDraft(reps = 10)),
|
||||||
|
)
|
||||||
|
val secondEntry = SessionExerciseDraft(
|
||||||
|
exercise = second,
|
||||||
|
sets = listOf(SessionSetDraft(reps = 10)),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
ActiveDraftMutationResult.Saved,
|
||||||
|
repository.saveActiveSessionDraft(
|
||||||
|
opened.draft.copy(exercises = listOf(firstEntry, secondEntry)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val startA = OffsetDateTime.now().toString()
|
||||||
|
val startedA = repository.startActiveSessionExercise(firstEntry.entryId, startA)
|
||||||
|
assertTrue(startedA is TrainlogRepository.SessionExerciseTimingResult.Started)
|
||||||
|
val conflict = repository.startActiveSessionExercise(secondEntry.entryId)
|
||||||
|
assertEquals(
|
||||||
|
firstEntry.entryId,
|
||||||
|
(conflict as TrainlogRepository.SessionExerciseTimingResult.ActiveConflict).activeEntryId,
|
||||||
|
)
|
||||||
|
|
||||||
|
val measurement = ParsedHeartRateMeasurement(
|
||||||
|
bpm = 101,
|
||||||
|
sensorContactDetected = null,
|
||||||
|
energyExpended = null,
|
||||||
|
rrIntervals1024 = listOf(620),
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
repository.recordLiveHeartRateForActiveSession(
|
||||||
|
"Synthetic HR", OffsetDateTime.now().toString(), measurement,
|
||||||
|
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
repository.finishActiveSessionExercise(firstEntry.entryId)
|
||||||
|
is TrainlogRepository.SessionExerciseTimingResult.Finished,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
repository.recordLiveHeartRateForActiveSession(
|
||||||
|
"Synthetic HR", OffsetDateTime.now().toString(), measurement.copy(bpm = 88),
|
||||||
|
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
repository.startActiveSessionExercise(secondEntry.entryId)
|
||||||
|
is TrainlogRepository.SessionExerciseTimingResult.Started,
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
repository.recordLiveHeartRateForActiveSession(
|
||||||
|
"Synthetic HR", OffsetDateTime.now().toString(), measurement.copy(bpm = 109),
|
||||||
|
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
repository.finishActiveSessionExercise(secondEntry.entryId)
|
||||||
|
is TrainlogRepository.SessionExerciseTimingResult.Finished,
|
||||||
|
)
|
||||||
|
val finalized = repository.finalizeActiveSessionDraft()
|
||||||
|
assertTrue(finalized is FinalizeActiveDraftResult.Saved)
|
||||||
|
|
||||||
|
val timeline = JSONObject(repository.buildSessionTimelineV1Json())
|
||||||
|
val session = timeline.getJSONArray("sessions").getJSONObject(0)
|
||||||
|
assertEquals(2, session.getJSONArray("exercises").length())
|
||||||
|
assertEquals(firstEntry.entryId, session.getJSONArray("exercises").getJSONObject(0).getString("entry_id"))
|
||||||
|
assertEquals(secondEntry.entryId, session.getJSONArray("exercises").getJSONObject(1).getString("entry_id"))
|
||||||
|
|
||||||
|
val heart = JSONObject(repository.buildHeartRateV1Json())
|
||||||
|
val samples = heart.getJSONArray("captures").getJSONObject(0).getJSONArray("samples")
|
||||||
|
assertEquals(3, samples.length())
|
||||||
|
assertEquals(firstEntry.entryId, samples.getJSONObject(0).getString("exercise_entry_id"))
|
||||||
|
assertTrue(samples.getJSONObject(1).isNull("exercise_entry_id"))
|
||||||
|
assertEquals(secondEntry.entryId, samples.getJSONObject(2).getString("exercise_entry_id"))
|
||||||
|
assertEquals(101, samples.getJSONObject(0).getInt("bpm"))
|
||||||
|
assertEquals(88, samples.getJSONObject(1).getInt("bpm"))
|
||||||
|
assertEquals(109, samples.getJSONObject(2).getInt("bpm"))
|
||||||
|
assertNull(repository.activeHeartRateCapture())
|
||||||
|
} finally {
|
||||||
|
repository.close()
|
||||||
|
context.deleteDatabase(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,209 @@
|
||||||
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import com.labfytools.trainlog.model.NewExerciseProfile
|
||||||
|
import com.labfytools.trainlog.model.RecordingMode
|
||||||
|
import com.labfytools.trainlog.model.SessionExerciseDraft
|
||||||
|
import com.labfytools.trainlog.model.SessionSetDraft
|
||||||
|
import com.labfytools.trainlog.model.TrackingMode
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [35])
|
||||||
|
class SessionTimelineSyncGenerationTest {
|
||||||
|
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun timelineAndHeartRateArriveTogetherAndStopPublishingAfterAck() {
|
||||||
|
val sourceName = "timeline-sync-" + UUID.randomUUID() + ".db"
|
||||||
|
val root = Files.createTempDirectory("trainlog-timeline-sync-").toFile()
|
||||||
|
val source = TrainlogRepository(context, sourceName)
|
||||||
|
try {
|
||||||
|
fun exercise(label: String) =
|
||||||
|
(source.createExercise(
|
||||||
|
NewExerciseProfile(label, RecordingMode.SETS, TrackingMode.REPS, 0),
|
||||||
|
) as CreateExerciseResult.Created).exercise
|
||||||
|
val a = SessionExerciseDraft(
|
||||||
|
exercise = exercise("Sync timeline A"),
|
||||||
|
sets = listOf(SessionSetDraft(reps = 8)),
|
||||||
|
)
|
||||||
|
val b = SessionExerciseDraft(
|
||||||
|
exercise = exercise("Sync timeline B"),
|
||||||
|
sets = listOf(SessionSetDraft(reps = 9)),
|
||||||
|
)
|
||||||
|
assertEquals(ActiveDraftMutationResult.Saved, source.startActiveSessionDraft())
|
||||||
|
val opened = source.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
|
||||||
|
assertEquals(
|
||||||
|
ActiveDraftMutationResult.Saved,
|
||||||
|
source.saveActiveSessionDraft(opened.draft.copy(exercises = listOf(a, b))),
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
source.startActiveSessionExercise(a.entryId) is
|
||||||
|
TrainlogRepository.SessionExerciseTimingResult.Started,
|
||||||
|
)
|
||||||
|
val base = ParsedHeartRateMeasurement(96, null, null, listOf(640))
|
||||||
|
assertTrue(
|
||||||
|
source.recordLiveHeartRateForActiveSession(
|
||||||
|
"Synthetic HR",
|
||||||
|
OffsetDateTime.now().toString(),
|
||||||
|
base,
|
||||||
|
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
source.finishActiveSessionExercise(a.entryId) is
|
||||||
|
TrainlogRepository.SessionExerciseTimingResult.Finished,
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
source.recordLiveHeartRateForActiveSession(
|
||||||
|
"Synthetic HR",
|
||||||
|
OffsetDateTime.now().toString(),
|
||||||
|
base.copy(bpm = 84),
|
||||||
|
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
source.startActiveSessionExercise(b.entryId) is
|
||||||
|
TrainlogRepository.SessionExerciseTimingResult.Started,
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
source.recordLiveHeartRateForActiveSession(
|
||||||
|
"Synthetic HR",
|
||||||
|
OffsetDateTime.now().toString(),
|
||||||
|
base.copy(bpm = 105),
|
||||||
|
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
source.finishActiveSessionExercise(b.entryId) is
|
||||||
|
TrainlogRepository.SessionExerciseTimingResult.Finished,
|
||||||
|
)
|
||||||
|
assertTrue(source.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
|
||||||
|
|
||||||
|
val repositoryRoot =
|
||||||
|
generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))) {
|
||||||
|
it.parentFile
|
||||||
|
}.first { java.io.File(it, "tools/sync_generation_exchange.py").isFile }
|
||||||
|
fun run(vararg command: String): String {
|
||||||
|
val process =
|
||||||
|
ProcessBuilder(*command)
|
||||||
|
.directory(repositoryRoot)
|
||||||
|
.redirectErrorStream(true)
|
||||||
|
.start()
|
||||||
|
val output = process.inputStream.bufferedReader().readText()
|
||||||
|
assertEquals(
|
||||||
|
"command failed: " + command.joinToString(" ") + "\n" + output,
|
||||||
|
0,
|
||||||
|
process.waitFor(),
|
||||||
|
)
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
val desktopDb = java.io.File(root, "desktop.sqlite")
|
||||||
|
run(
|
||||||
|
java.io.File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
|
||||||
|
desktopDb.absolutePath,
|
||||||
|
)
|
||||||
|
val desktopPeer =
|
||||||
|
Regex("PEER_ID=(peer_[^\\s]+)")
|
||||||
|
.find(
|
||||||
|
run(
|
||||||
|
"python3",
|
||||||
|
java.io.File(
|
||||||
|
repositoryRoot,
|
||||||
|
"tools/sync_generation_exchange.py",
|
||||||
|
).absolutePath,
|
||||||
|
"peer-id",
|
||||||
|
"--database",
|
||||||
|
desktopDb.absolutePath,
|
||||||
|
),
|
||||||
|
)!!
|
||||||
|
.groupValues[1]
|
||||||
|
|
||||||
|
val service = SyncGenerationService(source)
|
||||||
|
val captured = service.capture(root, desktopPeer)
|
||||||
|
val timelineJson =
|
||||||
|
JSONObject(
|
||||||
|
java.io.File(
|
||||||
|
captured.stagingDirectory,
|
||||||
|
"session-timeline-v1.json",
|
||||||
|
).readText(),
|
||||||
|
)
|
||||||
|
assertEquals(1, timelineJson.getJSONArray("sessions").length())
|
||||||
|
assertEquals(
|
||||||
|
2,
|
||||||
|
timelineJson
|
||||||
|
.getJSONArray("sessions")
|
||||||
|
.getJSONObject(0)
|
||||||
|
.getJSONArray("exercises")
|
||||||
|
.length(),
|
||||||
|
)
|
||||||
|
val heartJson =
|
||||||
|
JSONObject(
|
||||||
|
java.io.File(captured.stagingDirectory, "heart-rate-v1.json").readText(),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
3,
|
||||||
|
heartJson
|
||||||
|
.getJSONArray("captures")
|
||||||
|
.getJSONObject(0)
|
||||||
|
.getJSONArray("samples")
|
||||||
|
.length(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val published = service.publish(captured, java.io.File(root, "objects"))
|
||||||
|
val ackFile = java.io.File(root, "ack.json")
|
||||||
|
run(
|
||||||
|
"python3",
|
||||||
|
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath,
|
||||||
|
"consume-desktop",
|
||||||
|
published.absolutePath,
|
||||||
|
"--database",
|
||||||
|
desktopDb.absolutePath,
|
||||||
|
"--ack-output",
|
||||||
|
ackFile.absolutePath,
|
||||||
|
)
|
||||||
|
val ack = JSONObject(ackFile.readText())
|
||||||
|
assertEquals(
|
||||||
|
"desktop ACK diagnostic: " + ack.getString("diagnostic"),
|
||||||
|
"consumed",
|
||||||
|
ack.getString("result"),
|
||||||
|
)
|
||||||
|
assertEquals("acknowledged", service.acceptAcknowledgement(ackFile.readBytes()))
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"2|3|3",
|
||||||
|
run(
|
||||||
|
"sqlite3",
|
||||||
|
desktopDb.absolutePath,
|
||||||
|
"SELECT (SELECT count(*) FROM session_exercise_timeline) || '|' || " +
|
||||||
|
"(SELECT count(*) FROM heart_rate_samples) || '|' || " +
|
||||||
|
"(SELECT count(*) FROM heart_rate_rr_intervals);",
|
||||||
|
).trim(),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
0,
|
||||||
|
JSONObject(source.buildSessionTimelineV1Json())
|
||||||
|
.getJSONArray("sessions")
|
||||||
|
.length(),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
0,
|
||||||
|
JSONObject(source.buildHeartRateV1Json())
|
||||||
|
.getJSONArray("captures")
|
||||||
|
.length(),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
source.close()
|
||||||
|
context.deleteDatabase(sourceName)
|
||||||
|
root.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,246 @@
|
||||||
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import com.labfytools.trainlog.model.SleepEventType
|
||||||
|
import com.labfytools.trainlog.model.SleepMedication
|
||||||
|
import java.util.UUID
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [35])
|
||||||
|
class SleepQuickCaptureTest {
|
||||||
|
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun pendingMedicationSurvivesReopenAndBedTimeKeepsEntryIdentity() {
|
||||||
|
val name = "sleep-reopen-" + UUID.randomUUID()
|
||||||
|
var repository = TrainlogRepository(context, name)
|
||||||
|
try {
|
||||||
|
val medication =
|
||||||
|
repository.saveSleepMedication(
|
||||||
|
SleepMedication(
|
||||||
|
medicationId = "",
|
||||||
|
revisionId = "",
|
||||||
|
createdAt = "2026-09-23T20:00:00+02:00",
|
||||||
|
updatedAt = "2026-09-23T20:00:00+02:00",
|
||||||
|
name = "Test medication",
|
||||||
|
defaultDoseValue = 5.0,
|
||||||
|
defaultDoseUnit = "mg",
|
||||||
|
form = "",
|
||||||
|
note = "",
|
||||||
|
active = true,
|
||||||
|
),
|
||||||
|
) as TrainlogRepository.SaveSleepDiaryResult.Saved
|
||||||
|
val pending =
|
||||||
|
repository.quickSleepMedication(
|
||||||
|
medication.entryId,
|
||||||
|
"2026-09-23T22:20:00+02:00",
|
||||||
|
quantity = 2,
|
||||||
|
) as TrainlogRepository.SleepQuickActionResult.Applied
|
||||||
|
|
||||||
|
repository.close()
|
||||||
|
repository = TrainlogRepository(context, name)
|
||||||
|
|
||||||
|
val bed =
|
||||||
|
repository.quickSleepBedTime("2026-09-23T22:30:00+02:00")
|
||||||
|
as TrainlogRepository.SleepQuickActionResult.Applied
|
||||||
|
assertEquals(pending.receipt.entryId, bed.receipt.entryId)
|
||||||
|
assertEquals(2, repository.listSleepDiary().single().intakes.single().quantity)
|
||||||
|
} finally {
|
||||||
|
repository.close()
|
||||||
|
context.deleteDatabase(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun oneTapNightRecordsMedicationWakesAndSleepHeartRate() {
|
||||||
|
val name = "sleep-quick-" + UUID.randomUUID()
|
||||||
|
val repository = TrainlogRepository(context, name)
|
||||||
|
try {
|
||||||
|
val medicationCreated =
|
||||||
|
repository.saveSleepMedication(
|
||||||
|
SleepMedication(
|
||||||
|
medicationId = "",
|
||||||
|
revisionId = "",
|
||||||
|
createdAt = "2026-09-23T20:00:00+02:00",
|
||||||
|
updatedAt = "2026-09-23T20:00:00+02:00",
|
||||||
|
name = "Test medication",
|
||||||
|
defaultDoseValue = 5.0,
|
||||||
|
defaultDoseUnit = "mg",
|
||||||
|
form = "",
|
||||||
|
note = "",
|
||||||
|
active = true,
|
||||||
|
),
|
||||||
|
) as TrainlogRepository.SaveSleepDiaryResult.Saved
|
||||||
|
val medication =
|
||||||
|
repository.listSleepMedications(false).single {
|
||||||
|
it.medicationId == medicationCreated.entryId
|
||||||
|
}
|
||||||
|
|
||||||
|
val preBed = repository.quickSleepMedication(
|
||||||
|
medication.medicationId,
|
||||||
|
"2026-09-23T22:20:00+02:00",
|
||||||
|
quantity = 2,
|
||||||
|
) as TrainlogRepository.SleepQuickActionResult.Applied
|
||||||
|
assertNull(repository.activeHeartRateCapture())
|
||||||
|
val pending = repository.listSleepDiary().single()
|
||||||
|
assertTrue(pending.events.none { it.type == SleepEventType.BED_TIME })
|
||||||
|
assertEquals(2, pending.intakes.single().quantity)
|
||||||
|
val preBedExport = JSONObject(repository.buildSleepDiaryV1Json())
|
||||||
|
val exportedPending = preBedExport.getJSONArray("entries").getJSONObject(0)
|
||||||
|
assertEquals(preBed.receipt.entryId, exportedPending.getString("entry_id"))
|
||||||
|
assertEquals(preBed.receipt.appliedRevisionId, exportedPending.getString("revision_id"))
|
||||||
|
assertEquals(0, exportedPending.getJSONArray("events").length())
|
||||||
|
assertEquals(1, exportedPending.getJSONArray("intakes").length())
|
||||||
|
|
||||||
|
val bed =
|
||||||
|
repository.quickSleepBedTime("2026-09-23T22:30:00+02:00")
|
||||||
|
as TrainlogRepository.SleepQuickActionResult.Applied
|
||||||
|
assertEquals(preBed.receipt.entryId, bed.receipt.entryId)
|
||||||
|
assertEquals("2026-09-23", repository.listSleepDiary().single().nightStartDate)
|
||||||
|
|
||||||
|
val measurement =
|
||||||
|
ParsedHeartRateMeasurement(
|
||||||
|
bpm = 62,
|
||||||
|
sensorContactDetected = null,
|
||||||
|
energyExpended = null,
|
||||||
|
rrIntervals1024 = listOf(1000),
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
repository.recordLiveHeartRateForActiveSleep(
|
||||||
|
"CYCPLUS H2",
|
||||||
|
"2026-09-23T22:30:01+02:00",
|
||||||
|
measurement,
|
||||||
|
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||||
|
)
|
||||||
|
val captureId = checkNotNull(repository.activeHeartRateCapture()).captureId
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
repository.quickSleepMedication(
|
||||||
|
medication.medicationId,
|
||||||
|
"2026-09-23T22:35:00+02:00",
|
||||||
|
) is TrainlogRepository.SleepQuickActionResult.Applied,
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
repository.quickSleepWake("2026-09-24T02:17:00+02:00")
|
||||||
|
is TrainlogRepository.SleepQuickActionResult.Applied,
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
repository.quickSleepWake("2026-09-24T04:42:00+02:00")
|
||||||
|
is TrainlogRepository.SleepQuickActionResult.Applied,
|
||||||
|
)
|
||||||
|
|
||||||
|
val beforeGetUp = repository.listSleepDiary().single()
|
||||||
|
assertEquals(bed.receipt.entryId, beforeGetUp.entryId)
|
||||||
|
assertEquals(
|
||||||
|
2,
|
||||||
|
beforeGetUp.events.count { it.type == SleepEventType.NIGHT_GET_UP },
|
||||||
|
)
|
||||||
|
assertEquals(2, beforeGetUp.intakes.size)
|
||||||
|
assertEquals(2, beforeGetUp.intakes.first().quantity)
|
||||||
|
assertEquals(5.0, checkNotNull(beforeGetUp.intakes.first().doseValue), 0.0)
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
repository.recordLiveHeartRateForActiveSleep(
|
||||||
|
"CYCPLUS H2",
|
||||||
|
"2026-09-24T04:43:00+02:00",
|
||||||
|
measurement.copy(bpm = 65),
|
||||||
|
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
repository.quickSleepFinalGetUp("2026-09-24T06:31:00+02:00")
|
||||||
|
is TrainlogRepository.SleepQuickActionResult.Applied,
|
||||||
|
)
|
||||||
|
assertNull(repository.activeHeartRateCapture())
|
||||||
|
assertNull(
|
||||||
|
repository.recordLiveHeartRateForActiveSleep(
|
||||||
|
"CYCPLUS H2",
|
||||||
|
"2026-09-24T06:32:00+02:00",
|
||||||
|
measurement.copy(bpm = 70),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val finalEntry = repository.listSleepDiary().single()
|
||||||
|
assertEquals(
|
||||||
|
1,
|
||||||
|
finalEntry.events.count { it.type == SleepEventType.FINAL_GET_UP },
|
||||||
|
)
|
||||||
|
|
||||||
|
val exported = JSONObject(repository.buildHeartRateV1Json())
|
||||||
|
val capture = exported.getJSONArray("captures").getJSONObject(0)
|
||||||
|
assertEquals(captureId, capture.getString("capture_id"))
|
||||||
|
assertEquals("sleep", capture.getString("context_kind"))
|
||||||
|
assertEquals(finalEntry.entryId, capture.getString("context_id"))
|
||||||
|
assertEquals(62, capture.getJSONArray("samples").getJSONObject(0).getInt("bpm"))
|
||||||
|
assertEquals("2026-09-23T22:30:00+02:00", capture.getString("started_at"))
|
||||||
|
assertEquals("2026-09-24T06:31:00+02:00", capture.getString("ended_at"))
|
||||||
|
assertEquals(2, capture.getJSONArray("samples").length())
|
||||||
|
} finally {
|
||||||
|
repository.close()
|
||||||
|
context.deleteDatabase(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun undoIsGuardedByTheExactAppliedRevision() {
|
||||||
|
val name = "sleep-undo-" + UUID.randomUUID()
|
||||||
|
val repository = TrainlogRepository(context, name)
|
||||||
|
try {
|
||||||
|
repository.quickSleepBedTime("2026-09-23T22:30:00+02:00")
|
||||||
|
val firstWake =
|
||||||
|
repository.quickSleepWake("2026-09-24T02:00:00+02:00")
|
||||||
|
as TrainlogRepository.SleepQuickActionResult.Applied
|
||||||
|
val secondWake =
|
||||||
|
repository.quickSleepWake("2026-09-24T03:00:00+02:00")
|
||||||
|
as TrainlogRepository.SleepQuickActionResult.Applied
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
repository.undoSleepQuickAction(firstWake.receipt)
|
||||||
|
is TrainlogRepository.SleepQuickActionResult.Conflict,
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
repository.undoSleepQuickAction(
|
||||||
|
secondWake.receipt,
|
||||||
|
"2026-09-24T03:00:05+02:00",
|
||||||
|
) is TrainlogRepository.SleepQuickActionResult.Applied,
|
||||||
|
)
|
||||||
|
|
||||||
|
val night = repository.listSleepDiary().single()
|
||||||
|
assertEquals(
|
||||||
|
listOf("2026-09-24T02:00:00+02:00"),
|
||||||
|
night.events
|
||||||
|
.filter { it.type == SleepEventType.NIGHT_GET_UP }
|
||||||
|
.map { it.startAt },
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
repository.close()
|
||||||
|
context.deleteDatabase(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun bedtimeAndFinalGetUpWithoutMeasurementFabricateNoCapture() {
|
||||||
|
val name = "sleep-no-sensor-" + UUID.randomUUID()
|
||||||
|
val repository = TrainlogRepository(context, name)
|
||||||
|
try {
|
||||||
|
repository.quickSleepBedTime("2026-09-23T22:30:00+02:00")
|
||||||
|
repository.quickSleepFinalGetUp("2026-09-24T06:31:00+02:00")
|
||||||
|
assertNull(repository.activeHeartRateCapture())
|
||||||
|
assertEquals(
|
||||||
|
0,
|
||||||
|
JSONObject(repository.buildHeartRateV1Json()).getJSONArray("captures").length(),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
repository.close()
|
||||||
|
context.deleteDatabase(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -296,7 +296,7 @@ class StatisticsRepositoryTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun session(db: SQLiteDatabase, id: String, timestamp: String, type: String = "training") {
|
private fun session(db: SQLiteDatabase, id: String, timestamp: String, type: String = "training") {
|
||||||
db.execSQL("INSERT INTO sessions(session_id,started_at,session_type) VALUES(?,?,?)", arrayOf(id, timestamp, type))
|
db.execSQL("INSERT INTO sessions(session_id,started_at,session_type,session_kind) VALUES(?,?,?,?)", arrayOf(id, timestamp, type, type))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun occurrence(
|
private fun occurrence(
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ class SyncDeploymentConversationTest {
|
||||||
val databaseName = "deployment-peer-${UUID.randomUUID()}.db"
|
val databaseName = "deployment-peer-${UUID.randomUUID()}.db"
|
||||||
var repository = TrainlogRepository(context, databaseName)
|
var repository = TrainlogRepository(context, databaseName)
|
||||||
var localWorker: Process? = null
|
var localWorker: Process? = null
|
||||||
|
var lostDesktopAckGeneration: String? = null
|
||||||
try {
|
try {
|
||||||
transport.mkdirs()
|
transport.mkdirs()
|
||||||
// A previous interrupted exchange leaves durable coordination
|
// A previous interrupted exchange leaves durable coordination
|
||||||
|
|
@ -180,6 +181,7 @@ class SyncDeploymentConversationTest {
|
||||||
"full_generation_trigger_published",
|
"full_generation_trigger_published",
|
||||||
"generation_request_observed",
|
"generation_request_observed",
|
||||||
"archive_ack_observed",
|
"archive_ack_observed",
|
||||||
|
"android_archive_ack_published",
|
||||||
"generation_capture_started",
|
"generation_capture_started",
|
||||||
"generation_captured",
|
"generation_captured",
|
||||||
"generation_objects_published",
|
"generation_objects_published",
|
||||||
|
|
@ -215,6 +217,71 @@ class SyncDeploymentConversationTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (suppliedTransport == null && it == 11) {
|
||||||
|
/* Reproduce the inverse deployed failure: Android has
|
||||||
|
* durably consumed the newest desktop generation, but its
|
||||||
|
* ACK was lost before the desktop producer committed it.
|
||||||
|
* The next conversation must recover that exact Android
|
||||||
|
* evidence before capturing another desktop generation. */
|
||||||
|
SQLiteDatabase.openDatabase(
|
||||||
|
desktop.absolutePath,
|
||||||
|
null,
|
||||||
|
SQLiteDatabase.OPEN_READWRITE,
|
||||||
|
).use { database ->
|
||||||
|
val generation =
|
||||||
|
database.rawQuery(
|
||||||
|
"SELECT generation_id FROM sync_generations " +
|
||||||
|
"WHERE status='acknowledged' ORDER BY generated_at DESC LIMIT 1",
|
||||||
|
null,
|
||||||
|
).use { cursor ->
|
||||||
|
assertTrue(cursor.moveToFirst())
|
||||||
|
cursor.getString(0)
|
||||||
|
}
|
||||||
|
database.delete(
|
||||||
|
"sync_acknowledgements",
|
||||||
|
"generation_id=?",
|
||||||
|
arrayOf(generation),
|
||||||
|
)
|
||||||
|
database.execSQL(
|
||||||
|
"UPDATE sync_generations SET status='waiting_acknowledgement'," +
|
||||||
|
"acknowledged_at=NULL WHERE generation_id=?",
|
||||||
|
arrayOf(generation),
|
||||||
|
)
|
||||||
|
database.rawQuery(
|
||||||
|
"SELECT status FROM sync_generations WHERE generation_id=?",
|
||||||
|
arrayOf(generation),
|
||||||
|
).use { cursor ->
|
||||||
|
assertTrue(cursor.moveToFirst())
|
||||||
|
assertEquals("waiting_acknowledgement", cursor.getString(0))
|
||||||
|
}
|
||||||
|
lostDesktopAckGeneration = generation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (suppliedTransport == null && it == 12) {
|
||||||
|
val recovered = checkNotNull(lostDesktopAckGeneration)
|
||||||
|
SQLiteDatabase.openDatabase(
|
||||||
|
desktop.absolutePath,
|
||||||
|
null,
|
||||||
|
SQLiteDatabase.OPEN_READONLY,
|
||||||
|
).use { database ->
|
||||||
|
database.rawQuery(
|
||||||
|
"SELECT status FROM sync_generations WHERE generation_id=?",
|
||||||
|
arrayOf(recovered),
|
||||||
|
).use { cursor ->
|
||||||
|
assertTrue(cursor.moveToFirst())
|
||||||
|
assertEquals("acknowledged", cursor.getString(0))
|
||||||
|
}
|
||||||
|
database.rawQuery(
|
||||||
|
"SELECT parent_generation_id,status FROM sync_generations " +
|
||||||
|
"ORDER BY generated_at DESC LIMIT 1",
|
||||||
|
null,
|
||||||
|
).use { cursor ->
|
||||||
|
assertTrue(cursor.moveToFirst())
|
||||||
|
assertEquals(recovered, cursor.getString(0))
|
||||||
|
assertEquals("acknowledged", cursor.getString(1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
localWorker = null
|
localWorker = null
|
||||||
}
|
}
|
||||||
repository.close()
|
repository.close()
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,14 @@ package com.labfytools.trainlog.data
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.database.sqlite.SQLiteDatabase
|
import android.database.sqlite.SQLiteDatabase
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import com.labfytools.trainlog.model.HeartRateContextKind
|
||||||
|
import com.labfytools.trainlog.model.HeartRateRrInterval
|
||||||
import com.labfytools.trainlog.model.NewExerciseProfile
|
import com.labfytools.trainlog.model.NewExerciseProfile
|
||||||
import com.labfytools.trainlog.model.RecordingMode
|
import com.labfytools.trainlog.model.RecordingMode
|
||||||
import com.labfytools.trainlog.model.SessionDraft
|
import com.labfytools.trainlog.model.SessionDraft
|
||||||
import com.labfytools.trainlog.model.SessionExerciseDraft
|
import com.labfytools.trainlog.model.SessionExerciseDraft
|
||||||
import com.labfytools.trainlog.model.SessionSetDraft
|
import com.labfytools.trainlog.model.SessionSetDraft
|
||||||
|
import com.labfytools.trainlog.model.SleepDiaryDraft
|
||||||
import com.labfytools.trainlog.model.TrackingMode
|
import com.labfytools.trainlog.model.TrackingMode
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
|
|
@ -506,7 +509,7 @@ class SyncGenerationServiceTest {
|
||||||
.use { db ->
|
.use { db ->
|
||||||
db.rawQuery("PRAGMA user_version", null).use {
|
db.rawQuery("PRAGMA user_version", null).use {
|
||||||
assertTrue(it.moveToFirst())
|
assertTrue(it.moveToFirst())
|
||||||
assertEquals(26, it.getInt(0))
|
assertEquals(32, it.getInt(0))
|
||||||
}
|
}
|
||||||
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
|
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
|
||||||
assertTrue(it.moveToFirst())
|
assertTrue(it.moveToFirst())
|
||||||
|
|
@ -855,7 +858,54 @@ class SyncGenerationServiceTest {
|
||||||
)
|
)
|
||||||
)!!
|
)!!
|
||||||
.groupValues[1]
|
.groupValues[1]
|
||||||
|
val sleepEntry =
|
||||||
|
source.saveSleepDiary(
|
||||||
|
SleepDiaryDraft(
|
||||||
|
nightStartDate = "2026-09-22",
|
||||||
|
nightEndDate = "2026-09-23",
|
||||||
|
createdAt = "2026-09-22T22:00:00+02:00",
|
||||||
|
updatedAt = "2026-09-22T22:00:00+02:00",
|
||||||
|
sleepQuality = null,
|
||||||
|
wakeQuality = null,
|
||||||
|
dayForm = null,
|
||||||
|
treatmentAndNotes = "",
|
||||||
|
events = emptyList(),
|
||||||
|
),
|
||||||
|
) as TrainlogRepository.SaveSleepDiaryResult.Saved
|
||||||
|
val heartCapture =
|
||||||
|
source.startHeartRateCapture(
|
||||||
|
HeartRateContextKind.SLEEP,
|
||||||
|
sleepEntry.entryId,
|
||||||
|
"2026-09-22T22:30:00+02:00",
|
||||||
|
"Synthetic HR",
|
||||||
|
) as TrainlogRepository.StartHeartRateCaptureResult.Started
|
||||||
|
assertTrue(
|
||||||
|
source.appendHeartRateSample(
|
||||||
|
heartCapture.captureId,
|
||||||
|
"2026-09-22T22:30:01+02:00",
|
||||||
|
61,
|
||||||
|
true,
|
||||||
|
7,
|
||||||
|
listOf(HeartRateRrInterval(0, 1024)),
|
||||||
|
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
source.stopHeartRateCapture(
|
||||||
|
heartCapture.captureId,
|
||||||
|
"2026-09-22T22:31:00+02:00",
|
||||||
|
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||||
|
)
|
||||||
|
|
||||||
val androidToDesktop = sourceService.capture(root, desktopPeer)
|
val androidToDesktop = sourceService.capture(root, desktopPeer)
|
||||||
|
assertEquals(
|
||||||
|
1,
|
||||||
|
JSONObject(
|
||||||
|
java.io.File(androidToDesktop.stagingDirectory, "heart-rate-v1.json")
|
||||||
|
.readText(),
|
||||||
|
)
|
||||||
|
.getJSONArray("captures")
|
||||||
|
.length(),
|
||||||
|
)
|
||||||
val androidPublished =
|
val androidPublished =
|
||||||
sourceService.publish(
|
sourceService.publish(
|
||||||
androidToDesktop,
|
androidToDesktop,
|
||||||
|
|
@ -872,10 +922,31 @@ class SyncGenerationServiceTest {
|
||||||
"--ack-output",
|
"--ack-output",
|
||||||
androidAckFile.absolutePath,
|
androidAckFile.absolutePath,
|
||||||
)
|
)
|
||||||
|
val androidAck = JSONObject(androidAckFile.readText())
|
||||||
|
assertEquals(
|
||||||
|
"desktop ACK diagnostic: ${androidAck.getString("diagnostic")}",
|
||||||
|
"consumed",
|
||||||
|
androidAck.getString("result"),
|
||||||
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"acknowledged",
|
"acknowledged",
|
||||||
sourceService.acceptAcknowledgement(androidAckFile.readBytes()),
|
sourceService.acceptAcknowledgement(androidAckFile.readBytes()),
|
||||||
)
|
)
|
||||||
|
assertEquals(
|
||||||
|
0,
|
||||||
|
JSONObject(source.buildHeartRateV1Json()).getJSONArray("captures").length(),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"1|1|1",
|
||||||
|
run(
|
||||||
|
"sqlite3",
|
||||||
|
desktopConsumerDb.absolutePath,
|
||||||
|
"SELECT (SELECT count(*) FROM heart_rate_captures) || '|' || " +
|
||||||
|
"(SELECT count(*) FROM heart_rate_samples) || '|' || " +
|
||||||
|
"(SELECT count(*) FROM heart_rate_rr_intervals);",
|
||||||
|
)
|
||||||
|
.trim(),
|
||||||
|
)
|
||||||
val androidSessionId = source.listSessions().single().sessionId
|
val androidSessionId = source.listSessions().single().sessionId
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"1",
|
"1",
|
||||||
|
|
|
||||||
|
|
@ -2260,7 +2260,7 @@ class TrainlogRepositoryDraftTest {
|
||||||
).use { db ->
|
).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
assertTrue(cursor.moveToFirst())
|
||||||
assertEquals(26, cursor.getInt(0))
|
assertEquals(32, cursor.getInt(0))
|
||||||
}
|
}
|
||||||
db.rawQuery(
|
db.rawQuery(
|
||||||
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
||||||
|
|
@ -2387,7 +2387,7 @@ class TrainlogRepositoryDraftTest {
|
||||||
).use { db ->
|
).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
assertTrue(cursor.moveToFirst())
|
||||||
assertEquals(26, cursor.getInt(0))
|
assertEquals(32, cursor.getInt(0))
|
||||||
}
|
}
|
||||||
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
|
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
assertTrue(cursor.moveToFirst())
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Android application
|
# Android application
|
||||||
|
|
||||||
## Sleep Diary V1
|
## Sleep Diary V1 and active V2 companion
|
||||||
|
|
||||||
Android schema v26 additively mirrors the Sleep Diary revision/event model for
|
Android schema v26 additively mirrors the Sleep Diary revision/event model for
|
||||||
local-first capture. The unreleased schema-v26 contract also includes the
|
local-first capture. The unreleased schema-v26 contract also includes the
|
||||||
|
|
@ -18,6 +18,30 @@ The screen resumes the most recent entry and distinguishes draft, ready,
|
||||||
synchronized and modified tips. “Validate day” marks the current revision for
|
synchronized and modified tips. “Validate day” marks the current revision for
|
||||||
the existing global synchronization action and never makes it immutable.
|
the existing global synchronization action and never makes it immutable.
|
||||||
|
|
||||||
|
Android schema v32 additively stores each immutable medication-intake
|
||||||
|
`quantity` as an integer in `1..99`, with `1` backfilled for historical rows.
|
||||||
|
It also adds a separate quick-publication marker for the exact current
|
||||||
|
revision. Quick publication makes a current fact eligible for generation; it
|
||||||
|
does not validate a day and does not change the existing validation/ACK
|
||||||
|
lifecycle.
|
||||||
|
|
||||||
|
The quick Sleep capture derives one night identity from the existing
|
||||||
|
18:00-to-18:00 date rule. A medication action can create that pending entry
|
||||||
|
before `BED_TIME`, later medication actions reuse it, and Couché appends
|
||||||
|
`BED_TIME` to the same stable `entry_id`. Réveil and Levé remain unavailable
|
||||||
|
until bedtime. The compact quantity control defaults to one and resets after a
|
||||||
|
successful intake; the per-unit dose and unit remain independent structured
|
||||||
|
facts. Pending entries persist through restart and can synchronize without
|
||||||
|
inventing bedtime.
|
||||||
|
|
||||||
|
Sleep-owned HR capture remains sample-driven. A pending medication alone opens
|
||||||
|
no capture. After Couché, the first fresh real measurement creates or resumes
|
||||||
|
one `context_kind=sleep` capture for that entry, with `started_at` equal to the
|
||||||
|
factual bedtime and each measurement retaining its observed timestamp, BPM and
|
||||||
|
raw RR values. Réveil neither stops nor splits it; Levé closes it at the exact
|
||||||
|
final-get-up timestamp. No sensor measurement means no fabricated capture or
|
||||||
|
sample.
|
||||||
|
|
||||||
## Background USB and private Drive synchronization
|
## Background USB and private Drive synchronization
|
||||||
|
|
||||||
SyncScreen and the explicitly enabled background service invoke one
|
SyncScreen and the explicitly enabled background service invoke one
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ The rollout packager now keeps the legacy request daemon and one-shot helper in
|
||||||
the same relocatable, hashed bundle as the Web generation coordinator, avoiding
|
the same relocatable, hashed bundle as the Web generation coordinator, avoiding
|
||||||
mixed-checkout user-service execution during the controlled rollout.
|
mixed-checkout user-service execution during the controlled rollout.
|
||||||
|
|
||||||
Snapshot date: **2026-09-22**.
|
Snapshot date: **2026-09-23**.
|
||||||
|
|
||||||
`TRAINLOG_CODE_READABILITY_V1` normalizes the recent generation-MTP,
|
`TRAINLOG_CODE_READABILITY_V1` normalizes the recent generation-MTP,
|
||||||
generation/ACK, Android-backup, Web Dashboard serialization, tests, doubles and
|
generation/ACK, Android-backup, Web Dashboard serialization, tests, doubles and
|
||||||
|
|
@ -112,11 +112,11 @@ parallel implementation of its rules.
|
||||||
| Boundary | Current state |
|
| Boundary | Current state |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
|
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
|
||||||
| Desktop SQLite | schema v29; Sleep Diary V1 is additive in v29 |
|
| Desktop SQLite | schema v35; v35 additively gives every Sleep medication intake a structured `quantity` (1..99), defaulting historical rows to 1 |
|
||||||
| Android SQLite | schema v26; Sleep Diary V1 is additive in v26 |
|
| Android SQLite | schema v32; v32 additively gives every Sleep medication intake a structured `quantity` (1..99), defaults historical rows to 1, and records a distinct quick-publication tip |
|
||||||
| Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport |
|
| Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport |
|
||||||
| Desktop terminal backend | Notcurses only |
|
| Desktop terminal backend | Notcurses only |
|
||||||
| Trainlog product version | `0.1.4` stable, synchronized across Android and desktop; latest stable release: `v0.1.4` |
|
| Trainlog product version | `0.1.5` stable, synchronized across Android and desktop; latest stable release: `v0.1.5` |
|
||||||
| Interface language | `TRAINLOG_I18N_V0_1_1=PASS`: French default; English selectable in Settings → Language on both surfaces |
|
| Interface language | `TRAINLOG_I18N_V0_1_1=PASS`: French default; English selectable in Settings → Language on both surfaces |
|
||||||
| AI history export | `TRAINLOG_AI_EXPORT_V1` active |
|
| AI history export | `TRAINLOG_AI_EXPORT_V1` active |
|
||||||
| AI session proposals | `TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING` |
|
| AI session proposals | `TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING` |
|
||||||
|
|
@ -134,7 +134,7 @@ parallel implementation of its rules.
|
||||||
| Web Dashboard tiles | `WEB_DASHBOARD_TILES_V1=PASS/FROZEN` |
|
| Web Dashboard tiles | `WEB_DASHBOARD_TILES_V1=PASS/FROZEN` |
|
||||||
| Web Dashboard visualizations | `WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` |
|
| Web Dashboard visualizations | `WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` |
|
||||||
| Web Dashboard V1 | `WEB_DASHBOARD_V1=PASS/FROZEN` |
|
| Web Dashboard V1 | `WEB_DASHBOARD_V1=PASS/FROZEN` |
|
||||||
| Sleep Diary V1 | `SLEEP_DIARY_V1=PASS`: timestamped causal capture, Android/Web editing, full-generation synchronization, exact 18:00-to-18:00 agenda projection, medication snapshots, and localized FR/EN vector PDF export |
|
| Sleep Diary V1/V2 | `SLEEP_DIARY_V1=PASS` remains frozen and readable. The active Sleep exchange requires `sleep-diary-v2` / `sleep-diary-v2.json`: structured per-intake quantity and quick-publishable current revisions supplement the existing causal diary model without changing V1. |
|
||||||
| Web Sessions V1 | `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN` (controlled desktop/Android deployment validated) |
|
| Web Sessions V1 | `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN` (controlled desktop/Android deployment validated) |
|
||||||
| Web Sessions deletion and Programs V1 | `TRAINLOG_WEB_SESSIONS_DELETE_AND_PROGRAMS_V1=PASS` (private grouped rollout validated) |
|
| Web Sessions deletion and Programs V1 | `TRAINLOG_WEB_SESSIONS_DELETE_AND_PROGRAMS_V1=PASS` (private grouped rollout validated) |
|
||||||
| Programs presentation, Android projection, and deletion | `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=PASS` (private coordinated deployment and restart/replay validated) |
|
| Programs presentation, Android projection, and deletion | `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=PASS` (private coordinated deployment and restart/replay validated) |
|
||||||
|
|
@ -147,7 +147,19 @@ parallel implementation of its rules.
|
||||||
| Private full-generation rollout | `TRAINLOG_SYNC_FINALIZATION_AND_ROLLOUT_V1=PASS` (authenticated Bluetooth primary transport with direct MTP recovery on one authorized phone) |
|
| Private full-generation rollout | `TRAINLOG_SYNC_FINALIZATION_AND_ROLLOUT_V1=PASS` (authenticated Bluetooth primary transport with direct MTP recovery on one authorized phone) |
|
||||||
| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` |
|
| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` |
|
||||||
| Web Exercises V1 | `TRAINLOG_WEB_EXERCISES_V1=PASS` |
|
| Web Exercises V1 | `TRAINLOG_WEB_EXERCISES_V1=PASS` |
|
||||||
| Current operational cursor | `TRAINLOG_CARDIO_V1_NEXT_CONTRACT`: heart-rate sensor ingestion starts in 0.1.5 and remains distinct from the frozen Bluetooth synchronization transport |
|
| Cardio persistence/sync | `TRAINLOG_CARDIO_PERSISTENCE_SYNC_V1=PASS`: Android v27 + desktop v30, strict optional `trainlog-heart-rate` V1 Android→desktop companion, generation relation and correlated ACK |
|
||||||
|
| Cardio BLE diagnostics | `TRAINLOG_CARDIO_BLE_DIAGNOSTICS_V1=PASS`: real CYCPLUS H2 validated on Android; `0x180D` + `0x2A37`, Battery Service, approximately 1 Hz notifications and one raw RR interval per observed notification; contact and energy fields absent in the observed payloads |
|
||||||
|
| Cardio persistent acquisition | `TRAINLOG_CARDIO_PERSISTENT_ACQUISITION_V1=PASS`: selected sensor persists, dedicated connected-device foreground service reconnects, stale BPM is cleared, and the global Android `♥ BPM` indicator is grey/orange/green by connection state; real CYCPLUS restart and Bluetooth-off/on recovery validated on private vc40 |
|
||||||
|
| Training timeline | `TRAINLOG_CARDIO_TRAINING_TIMELINE_V1=PASS`: real session bounds reuse canonical lifecycle timestamps; exercise start/end are explicit user actions; live BPM samples snapshot the active stable `entry_id`; session finalization refuses an unclosed exercise; timeline sync is Android→desktop idempotent |
|
||||||
|
| Sleep Android one-tap | `TRAINLOG_SLEEP_ANDROID_ONE_TAP_V1=PASS`: existing one-tap Couché / medication / Réveil / Levé, guarded 10 s undo, correction route, and sleep-entry-owned cardio capture remain intact. The validated V2 development delta additionally creates/reuses one 18:00-to-18:00 pending entry for pre-bed medication, reuses its stable `entry_id` at Couché, and exposes a compact quantity control. |
|
||||||
|
| Cardio session type | `TRAINLOG_CARDIO_SESSION_TYPE_V1=PASS`: Android v29 + desktop v32 use canonical `session_kind=cardio`; frozen mobile/history and draft formats keep legacy training/max_test; dedicated `trainlog-cardio-sessions` V1 syncs completed cardio sessions Android→desktop idempotently |
|
||||||
|
| Cardio calibration | `TRAINLOG_CARDIO_CALIBRATION_V1=PASS`: reserved system Calibration cardio exercise, real measured observed peak, ≥3 min recovery with bounded +1/+2/+3 min factual samples, immutable profile and Android→desktop calibration companion |
|
||||||
|
| Cardio BPM guidance | `TRAINLOG_CARDIO_BPM_GUIDANCE_V1=PASS`: absolute/calibration-derived immutable BPM targets, 2 BPM hysteresis, 3 s confirmation, stale-signal suspension, durable phase/condition history, Android live guidance UI and strict Android→desktop `trainlog-cardio-guidance` V1 sync |
|
||||||
|
| Cardio Web visualization | `TRAINLOG_CARDIO_WEB_VISUALIZATION_V1=PASS`: one bounded factual heart-rate timeline read model/HTTP endpoint powers Web History and Sleep with raw BPM/RR, real session/exercise/Sleep markers, guided targets/instructions and calibration facts; no sleep-stage or medical inference |
|
||||||
|
| Sleep tonight readiness delta | `TRAINLOG_SLEEP_TONIGHT_CLOSEOUT_V1=PASS`: Meson 111/111, import checks 6/6, Android `testDebugUnitTest` plus `assembleDebug`, Web 191/191 plus typecheck/build, exact V2 quantity replay, private vc47 schema-v32 migration, fresh CYCPLUS BPM and non-mutating Android/Web visual review. |
|
||||||
|
| 0.1.5 validation | `TRAINLOG_0_1_5_VALIDATION_V1=PASS`: native 111/111; Android unit-test task and APK build; Web 191/191 plus typecheck/build; JSON/import, C-format and diff checks pass. Private vc47 preserves real data through schema v32, renders pre-bed medication quantity controls, reconnects the CYCPLUS and presents the multi-night Web Sleep agenda. |
|
||||||
|
| Tomorrow session readiness | `TRAINLOG_TOMORROW_SESSION_CARDIO_READINESS_V1=PASS`: the private vc46 is clean on schema v31 with fresh CYCPLUS BPM and the next Program session is unambiguous; one combined regression proves two ordered occurrences loaded from the synchronized Program, Program provenance, factual timeline/HR ownership, finalization, full generation, desktop import, replay and correlated ACK |
|
||||||
|
| Current operational cursor | Stable v0.1.5 closes the cardio and Sleep Tonight cycle; subsequent development opens as 0.1.6 after the immutable release tag. |
|
||||||
| Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered |
|
| Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered |
|
||||||
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
|
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
|
||||||
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |
|
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |
|
||||||
|
|
@ -160,6 +172,14 @@ parallel implementation of its rules.
|
||||||
Desktop and Android schema numbers are independent. Neither changes the frozen
|
Desktop and Android schema numbers are independent. Neither changes the frozen
|
||||||
Trainlog JSON V1 contract.
|
Trainlog JSON V1 contract.
|
||||||
|
|
||||||
|
The active Sleep V2 delta keeps the Web agenda period-owned: every returned
|
||||||
|
night has one row on the shared 18:00-to-18:00 geometry, selecting a row is
|
||||||
|
presentation-only, and the selected stable Sleep entry drives the full-width
|
||||||
|
heart-rate detail. Its rise/fall overlays are descriptive relative measured
|
||||||
|
variation only (five-minute trailing reference excluding the current 60-second
|
||||||
|
window; threshold `max(10 BPM, 15%)`; gaps over three seconds excluded). They
|
||||||
|
are not sleep events, sleep stages, causes, readiness, or medical inference.
|
||||||
|
|
||||||
Language is device-local presentation state. Android keeps it in dedicated
|
Language is device-local presentation state. Android keeps it in dedicated
|
||||||
SharedPreferences; desktop keeps it in the XDG configuration file
|
SharedPreferences; desktop keeps it in the XDG configuration file
|
||||||
`trainlog/presentation.conf`. A successful selection updates the UI
|
`trainlog/presentation.conf`. A successful selection updates the UI
|
||||||
|
|
@ -405,10 +425,11 @@ The full Android lint comparison against an isolated clean `main` worktree is
|
||||||
unchanged at ten pre-existing errors: one `NewApi`, one
|
unchanged at ten pre-existing errors: one `NewApi`, one
|
||||||
`LocalContextConfigurationRead`, and eight bilingual `StringFormatMatches`
|
`LocalContextConfigurationRead`, and eight bilingual `StringFormatMatches`
|
||||||
diagnostics. This UI tranche adds no lint error and changes no lint policy.
|
diagnostics. This UI tranche adds no lint error and changes no lint policy.
|
||||||
The stable Android 0.1.4 source uses `versionName=0.1.4` and public
|
The stable Android v0.1.4 tag uses `versionName=0.1.4` and public versionCode 5.
|
||||||
versionCode 5. Private validation builds may still supply only a strictly
|
The v0.1.5 development source starts at `versionName=0.1.5` and versionCode 6.
|
||||||
increasing `versionCode` as a build-time override; that does not create a
|
Private validation builds may still supply only a strictly increasing
|
||||||
public release or change schemas and protocols.
|
`versionCode` as a build-time override; that does not create a public release
|
||||||
|
or change schemas and protocols.
|
||||||
|
|
||||||
The private Program execution rollout installed desktop schema v27 and the
|
The private Program execution rollout installed desktop schema v27 and the
|
||||||
signed nondebuggable Android versionCode 17/schema v25 update without
|
signed nondebuggable Android versionCode 17/schema v25 update without
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,91 @@
|
||||||
# Desktop database
|
# Desktop database
|
||||||
|
|
||||||
|
## Schema v35: Sleep Diary quantity V2
|
||||||
|
|
||||||
|
Schema v35 additively adds `sleep_medication_intakes.quantity`, a required
|
||||||
|
integer in `1..99` with default `1`. It is the count of the separately stored
|
||||||
|
per-unit dose, not a free-text convention and not a total-dose replacement.
|
||||||
|
Thus `75 mg` with quantity `2` retains `dose_value=75`, `dose_unit=mg`, and
|
||||||
|
`quantity=2`. Existing immutable intake rows read as one unit; the migration
|
||||||
|
does not alter their revision identity, timestamps, causal ancestry, events,
|
||||||
|
or medication snapshot.
|
||||||
|
|
||||||
|
Sleep Diary V1 remains a frozen historical input. Quantity is carried by the
|
||||||
|
separate V2 companion; desktop v35 validates the V2 bounded value while still
|
||||||
|
hydrating historical V1 input as quantity one.
|
||||||
|
|
||||||
|
## Schema v34: synchronized Cardio guidance V1
|
||||||
|
|
||||||
|
Schema v34 additively stores completed Android-owned guided-cardio runs. `cardio_guidance_runs` keeps the stable run/session identity and exact run bounds. `cardio_guidance_phases` stores the ordered `entry_id`-owned phase, its immutable resolved BPM target, optional exact calibration provenance, exit condition, phase bounds and final instruction. `cardio_guidance_events` stores only instruction changes, not another copy of the raw heart-rate curve.
|
||||||
|
|
||||||
|
The importer requires the referenced canonical session to be `cardio`, every phase `entry_id` to exist in that session, and every phase interval to remain inside the synchronized exercise timeline. Calibration-derived targets must reference an imported calibration with the same observed peak. Any guidance event carrying BPM must match an exact synchronized raw heart-rate sample at the same timestamp; suspended events intentionally carry no BPM.
|
||||||
|
|
||||||
|
No migration invents phases, instructions, BPM or calibration references.
|
||||||
|
|
||||||
|
## Schema v33: synchronized Cardio calibration V1
|
||||||
|
|
||||||
|
Schema v33 additively stores completed Android-owned Cardio calibration profiles. `cardio_calibrations` is keyed by stable `calibration_id` and retains protocol version, canonical cardio `session_id`, stable occurrence `entry_id`, exact calibration/effort/end timestamps, the synchronized heart-rate `capture_id`, observed peak BPM and import time.
|
||||||
|
|
||||||
|
`cardio_calibration_recovery` stores only factual recovery samples for the defined +60, +120 and +180 second references. The source curve remains Heart Rate V1; the calibration importer verifies that the observed peak and every recovery `(observed_at, bpm)` pair exist in that raw capture. It never derives, interpolates or rewrites HR samples.
|
||||||
|
|
||||||
|
Android owns the active calibration lifecycle. Desktop stores only completed profiles. A later calibration creates a new stable profile and never rewrites an older session or reference.
|
||||||
|
|
||||||
|
## Schema v32: canonical cardio session kind
|
||||||
|
|
||||||
|
Schema v32 adds the product-owned `sessions.session_kind` column with the
|
||||||
|
explicit domain `training | max_test | cardio`. The pre-existing
|
||||||
|
`sessions.session_type` column remains intentionally frozen to
|
||||||
|
`training | max_test` for compatibility with historical codecs and MAX
|
||||||
|
semantics. A cardio row therefore stores `session_type=training` only as a
|
||||||
|
legacy compatibility projection and `session_kind=cardio` as the canonical
|
||||||
|
product fact.
|
||||||
|
|
||||||
|
The v31→v32 migration introspects historical `sessions` layouts before
|
||||||
|
backfill. When the legacy `session_type` column exists it seeds
|
||||||
|
`session_kind` from it; very old historical fixtures that legitimately lack
|
||||||
|
that column keep the session kind produced by their earlier migration chain.
|
||||||
|
No existing session identity, occurrence, set, timeline, Program provenance or
|
||||||
|
heart-rate fact is rewritten.
|
||||||
|
|
||||||
|
Completed cardio sessions synchronize Android→desktop through the separate
|
||||||
|
optional `trainlog-cardio-sessions` V1 generation companion. The frozen
|
||||||
|
mobile/history V4 and execution-draft formats are not widened to a third
|
||||||
|
session type.
|
||||||
|
|
||||||
|
## Schema v31: synchronized session exercise timeline
|
||||||
|
|
||||||
|
Schema v31 additively owns Android-recorded execution timing for completed
|
||||||
|
session occurrences in `session_exercise_timeline`. Each row is keyed by the
|
||||||
|
stable `session_id + entry_id` pair and stores the matching catalogue
|
||||||
|
`exercise_id`, the real user-action `started_at` and `ended_at`, and import
|
||||||
|
time. The importer requires the existing canonical session and occurrence to
|
||||||
|
match; it never creates workout history or infers exercise timing from order,
|
||||||
|
sets, plans, or heart-rate samples.
|
||||||
|
|
||||||
|
The session bounds remain the existing canonical `sessions.started_at` and
|
||||||
|
`sessions.ended_at`. The separate `trainlog-session-timeline` V1 companion
|
||||||
|
must match those exact values before occurrence timing can be imported.
|
||||||
|
|
||||||
|
## Schema v30: synchronized Heart Rate V1
|
||||||
|
|
||||||
|
Schema v30 additively owns Android-recorded heart-rate captures after
|
||||||
|
full-generation synchronization. `heart_rate_captures` stores one immutable
|
||||||
|
stopped capture with its stable `hrc_` identity, owner kind (`session` or
|
||||||
|
`sleep`), stable owner identity, exact start/end timestamps, optional sensor
|
||||||
|
name, and import time.
|
||||||
|
|
||||||
|
`heart_rate_samples` stores the received timestamp, measured BPM, optional
|
||||||
|
exercise `entry_id` snapshot, optional sensor-contact state, and optional
|
||||||
|
cumulative energy value. `heart_rate_rr_intervals` preserves zero or more raw
|
||||||
|
Heart Rate Service RR values in their native 1/1024-second units. Desktop never
|
||||||
|
creates these measurements and never connects to the sensor.
|
||||||
|
|
||||||
|
The v29→v30 migration is additive and creates no cardio row. Heart-rate data
|
||||||
|
travels Android→desktop through the separate optional `trainlog-heart-rate`
|
||||||
|
V1 generation companion. Exact replay is idempotent; a stable capture/sample
|
||||||
|
identity with different measured content is rejected. Existing workout,
|
||||||
|
Sleep Diary, and frozen mobile formats are unchanged.
|
||||||
|
|
||||||
## Schema v29: Sleep Diary V1
|
## Schema v29: Sleep Diary V1
|
||||||
|
|
||||||
Schema v29 additively owns `sleep_diary_entries`, immutable
|
Schema v29 additively owns `sleep_diary_entries`, immutable
|
||||||
|
|
@ -9,14 +95,14 @@ one night and its associated day. Point events have an offset-bearing
|
||||||
their start. The 18:00-to-18:00 agenda is only a projection and is never stored
|
their start. The 18:00-to-18:00 agenda is only a projection and is never stored
|
||||||
as cells.
|
as cells.
|
||||||
|
|
||||||
The same unreleased migration owns `sleep_medications`, immutable
|
The same stable v0.1.4 migration owns `sleep_medications`, immutable
|
||||||
`sleep_medication_revisions`, and revision-owned `sleep_medication_intakes`.
|
`sleep_medication_revisions`, and revision-owned `sleep_medication_intakes`.
|
||||||
Catalog defaults assist capture but do not own historical facts: every intake
|
Catalog defaults assist capture but do not own historical facts: every intake
|
||||||
stores the displayed medication name and effective optional positive dose/unit
|
stores the displayed medication name and effective optional positive dose/unit
|
||||||
snapshot. Renaming or deactivating a catalog item therefore cannot rewrite a
|
snapshot. Renaming or deactivating a catalog item therefore cannot rewrite a
|
||||||
past entry. Development databases that had already opened the earlier v29
|
past entry. Development databases that had already opened the earlier v29
|
||||||
draft are completed idempotently on open; `user_version` remains 29 and no
|
draft are completed idempotently on open before the additive v30 migration;
|
||||||
existing row is rewritten.
|
no existing sleep row is rewritten.
|
||||||
|
|
||||||
Mutations advance `current_revision_id` only when the caller supplies the
|
Mutations advance `current_revision_id` only when the caller supplies the
|
||||||
current parent. Logical deletion advances the revision and retains the
|
current parent. Logical deletion advances the revision and retains the
|
||||||
|
|
|
||||||
412
docs/design/cardio_v1.md
Normal file
412
docs/design/cardio_v1.md
Normal file
|
|
@ -0,0 +1,412 @@
|
||||||
|
# Cardio V1
|
||||||
|
|
||||||
|
Status: `TRAINLOG_CARDIO_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`
|
||||||
|
|
||||||
|
This document is the implementation contract for Trainlog 0.1.5 cardio,
|
||||||
|
training-timeline correlation, and Android one-tap sleep capture. It does not
|
||||||
|
declare the implementation complete.
|
||||||
|
|
||||||
|
## Ownership and hard boundaries
|
||||||
|
|
||||||
|
Android is the only heart-rate sensor client. It owns BLE discovery,
|
||||||
|
connection, reconnection, Heart Rate Measurement parsing, local persistence,
|
||||||
|
capture lifecycle, and the live BPM presentation.
|
||||||
|
|
||||||
|
Desktop, TUI and Web never scan, pair with, or connect directly to a
|
||||||
|
heart-rate sensor. They consume only measurements already recorded by Android
|
||||||
|
and synchronized through Trainlog.
|
||||||
|
|
||||||
|
Heart-rate BLE/GATT and Trainlog Bluetooth synchronization are independent:
|
||||||
|
|
||||||
|
```text
|
||||||
|
heart-rate sensor -- BLE/GATT --> Android
|
||||||
|
Android <-------- Trainlog full-generation --------> desktop
|
||||||
|
over
|
||||||
|
Bluetooth Classic RFCOMM or MTP recovery
|
||||||
|
```
|
||||||
|
|
||||||
|
The cardio implementation must not reuse the frozen Bluetooth Classic service
|
||||||
|
UUID, framing, peer identity, connection state, or transport protocol.
|
||||||
|
|
||||||
|
## Standard sensor target
|
||||||
|
|
||||||
|
Cardio V1 targets the Bluetooth LE Heart Rate Service:
|
||||||
|
|
||||||
|
- Heart Rate Service: `0x180D`;
|
||||||
|
- Heart Rate Measurement: `0x2A37`.
|
||||||
|
|
||||||
|
The parser must support the standard 8-bit and 16-bit BPM forms, optional
|
||||||
|
sensor-contact state, optional cumulative energy expended, and zero or more RR
|
||||||
|
intervals. RR intervals are preserved as raw integer units of 1/1024 second.
|
||||||
|
|
||||||
|
Battery Service may be observed as optional device metadata but is not required
|
||||||
|
to retain a heart-rate sample.
|
||||||
|
|
||||||
|
Malformed notifications are rejected. Trainlog never manufactures missing BPM
|
||||||
|
or RR values.
|
||||||
|
|
||||||
|
## Global Android heart-rate indicator
|
||||||
|
|
||||||
|
The Android application shell exposes one compact indicator at the top right:
|
||||||
|
|
||||||
|
```text
|
||||||
|
♥ 82 BPM
|
||||||
|
```
|
||||||
|
|
||||||
|
Only the heart and BPM are shown. Sensor name, address, and technical state are
|
||||||
|
not shown in the global header.
|
||||||
|
|
||||||
|
Heart colour describes connection freshness only:
|
||||||
|
|
||||||
|
- grey: known sensor unavailable/disconnected;
|
||||||
|
- orange: connecting, reconnecting, or the last measurement is stale;
|
||||||
|
- green: connected and receiving fresh valid measurements.
|
||||||
|
|
||||||
|
Colour never indicates a medical zone or whether the BPM is high or low.
|
||||||
|
|
||||||
|
When no fresh measurement exists, the value is `-- BPM`. A stale previous
|
||||||
|
number must never remain visible as if it were current.
|
||||||
|
|
||||||
|
With a fresh BPM the heart uses a restrained pulse animation derived from
|
||||||
|
`60 / bpm`. This is only a visual representation of the current measured
|
||||||
|
rate; it does not claim knowledge of the exact instant of each contraction.
|
||||||
|
|
||||||
|
## Capture identities and contexts
|
||||||
|
|
||||||
|
One capture has a stable `hrc_<uuid-v4>` identity and exactly one semantic
|
||||||
|
context:
|
||||||
|
|
||||||
|
- `session`: a normal training or MAX session identified by stable
|
||||||
|
`se_<uuid-v4>`;
|
||||||
|
- `cardio`: a dedicated cardio session identified by stable
|
||||||
|
`se_<uuid-v4>`;
|
||||||
|
- `sleep`: one Sleep Diary entry identified by stable `sl_<uuid-v4>`.
|
||||||
|
|
||||||
|
Context is never inferred from a title, clock date, exercise name, or display
|
||||||
|
position.
|
||||||
|
|
||||||
|
A workout/cardio sample may additionally snapshot the stable active occurrence
|
||||||
|
`sxe_<uuid-v4>` at the instant the sample is received.
|
||||||
|
|
||||||
|
## Raw measurement contract
|
||||||
|
|
||||||
|
Each received measurement stores at minimum:
|
||||||
|
|
||||||
|
- `capture_id`;
|
||||||
|
- monotonically increasing `sequence` within that capture;
|
||||||
|
- offset-bearing `observed_at` reception timestamp;
|
||||||
|
- integer `bpm`.
|
||||||
|
|
||||||
|
When actually supplied by the sensor it may also store:
|
||||||
|
|
||||||
|
- sensor-contact state;
|
||||||
|
- cumulative energy-expended value;
|
||||||
|
- zero or more RR intervals in 1/1024-second units.
|
||||||
|
|
||||||
|
For workout/cardio captures it may store the active `entry_id` snapshot.
|
||||||
|
|
||||||
|
Raw storage preserves signal gaps. It does not interpolate, smooth, estimate,
|
||||||
|
diagnose, infer sleep stages, infer a return to sleep, or synthesize missing
|
||||||
|
measurements.
|
||||||
|
|
||||||
|
## Android durability
|
||||||
|
|
||||||
|
Android SQLite owns authoritative field capture. An active capture remains
|
||||||
|
durable across application navigation and ordinary process/lifecycle
|
||||||
|
transitions supported by Android. Long-running acquisition uses a dedicated
|
||||||
|
foreground service distinct from synchronization.
|
||||||
|
|
||||||
|
Cardio V1 permits one active heart-rate capture at a time.
|
||||||
|
|
||||||
|
Sensor disconnect does not delete or close a capture. Reconnection resumes
|
||||||
|
future measurements and leaves the missing interval as a real gap.
|
||||||
|
|
||||||
|
Explicit stop closes the capture with its actual `ended_at`.
|
||||||
|
|
||||||
|
## Synchronization contract
|
||||||
|
|
||||||
|
Recorded cardio is synchronized Android -> desktop as the separate optional
|
||||||
|
full-generation companion:
|
||||||
|
|
||||||
|
```text
|
||||||
|
logical_name = heart-rate
|
||||||
|
format = trainlog-heart-rate
|
||||||
|
version = 1
|
||||||
|
filename = heart-rate-v1.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Desktop persists and presents these measurements but is not a sensor producer.
|
||||||
|
|
||||||
|
Normal publication contains completed/stopped, not-yet-acknowledged captures.
|
||||||
|
An active capture is retained locally and is not emitted as a mutable
|
||||||
|
full-generation snapshot.
|
||||||
|
|
||||||
|
Identity is idempotent:
|
||||||
|
|
||||||
|
- capture key: `capture_id`;
|
||||||
|
- sample key: `capture_id + sequence`;
|
||||||
|
- RR key: `capture_id + sequence + rr_index`.
|
||||||
|
|
||||||
|
Exact replay is a no-op. Reusing an identity with different measured content
|
||||||
|
is rejected.
|
||||||
|
|
||||||
|
The exact captured `capture_id -> generation_id` relation is durable. Only a
|
||||||
|
correlated consumed ACK marks those captures acknowledged. A rejected
|
||||||
|
generation does not acknowledge them. Acknowledged captures remain in Android
|
||||||
|
history but are excluded from ordinary future publication.
|
||||||
|
|
||||||
|
The companion does not alter `TRAINLOG_FORMAT_V1`, mobile-export V3/V4,
|
||||||
|
Sleep Diary V1, generation/ACK framing, or Bluetooth Classic sync V1.
|
||||||
|
|
||||||
|
## Training timeline correlation
|
||||||
|
|
||||||
|
A training session must expose real time boundaries, never reconstructed
|
||||||
|
timings.
|
||||||
|
|
||||||
|
Session start/end use the existing stable session identity and canonical
|
||||||
|
`started_at` / `ended_at` lifecycle when those values are already the
|
||||||
|
authoritative timestamps.
|
||||||
|
|
||||||
|
Each session occurrence has an explicit execution lifecycle:
|
||||||
|
|
||||||
|
```text
|
||||||
|
not_started -> in_progress -> completed
|
||||||
|
```
|
||||||
|
|
||||||
|
Starting an exercise records its actual start timestamp against
|
||||||
|
`session_id + entry_id`. Ending it records its actual end timestamp.
|
||||||
|
|
||||||
|
Only one occurrence may be active at a time. Starting another occurrence while
|
||||||
|
one remains active requires an explicit resolution rather than silently
|
||||||
|
inventing an end.
|
||||||
|
|
||||||
|
Samples received while an occurrence is active snapshot that `entry_id`.
|
||||||
|
Samples between occurrences remain valid session samples with no fabricated
|
||||||
|
exercise ownership and represent rest/transition.
|
||||||
|
|
||||||
|
Ending a session while an occurrence is active requires an explicit UI action
|
||||||
|
to close the occurrence at the validated finalization instant.
|
||||||
|
|
||||||
|
The synchronized history must permit factual curve markers for:
|
||||||
|
|
||||||
|
- session start;
|
||||||
|
- exercise start;
|
||||||
|
- exercise end;
|
||||||
|
- session end.
|
||||||
|
|
||||||
|
Future set-level `set_start` / `set_end` events may extend this model, but
|
||||||
|
Cardio V1 must not infer them from reps, duration targets, or form edits.
|
||||||
|
|
||||||
|
## Dedicated cardio session type
|
||||||
|
|
||||||
|
Trainlog 0.1.5 adds a real third session type:
|
||||||
|
|
||||||
|
```text
|
||||||
|
training
|
||||||
|
max_test
|
||||||
|
cardio
|
||||||
|
```
|
||||||
|
|
||||||
|
Every existing boundary that assumes only `training|max_test` must be audited.
|
||||||
|
A frozen artifact may not be silently widened. If an existing wire codec
|
||||||
|
cannot safely express `cardio`, add a separately versioned compatible
|
||||||
|
extension while retaining legacy readers.
|
||||||
|
|
||||||
|
A cardio session consists of cardio exercises and optional phases. Initial
|
||||||
|
exercise families include treadmill, bicycle, elliptical, rower, stepper,
|
||||||
|
walking, running, and custom cardio.
|
||||||
|
|
||||||
|
A phase may express a type, target, duration, and exit condition. Required
|
||||||
|
phase concepts include warm-up, work, recovery, and cool-down.
|
||||||
|
|
||||||
|
The motor must be able to represent fixed duration, reaching a BPM zone,
|
||||||
|
recovering below a threshold, and bounded combinations of time and
|
||||||
|
heart-rate conditions.
|
||||||
|
|
||||||
|
Machine speed, resistance, incline, and distance may be recorded, but BPM is
|
||||||
|
the primary guidance signal.
|
||||||
|
|
||||||
|
## BPM-guidance motor
|
||||||
|
|
||||||
|
Cardio V1 guidance outputs are:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ACCÉLÈRE
|
||||||
|
MAINTIENS
|
||||||
|
RALENTIS
|
||||||
|
```
|
||||||
|
|
||||||
|
Guidance is based only on fresh measurements and the phase target.
|
||||||
|
|
||||||
|
Hysteresis and a minimum persistence interval prevent oscillation around a
|
||||||
|
boundary. One isolated sample must not cause rapid instruction changes.
|
||||||
|
|
||||||
|
When the signal becomes stale, guidance is suspended. The global heart becomes
|
||||||
|
orange and BPM becomes unavailable rather than continuing from an old value.
|
||||||
|
|
||||||
|
## Calibration cardio exercise
|
||||||
|
|
||||||
|
The first dedicated cardio exercise is `Calibration cardio`.
|
||||||
|
|
||||||
|
Its purpose is to establish a personal measured reference for later guided
|
||||||
|
exercise. Trainlog records an **observed peak BPM**, not an automatically
|
||||||
|
asserted physiological maximum.
|
||||||
|
|
||||||
|
The protocol is progressive:
|
||||||
|
|
||||||
|
```text
|
||||||
|
warm-up -> easy stage -> progressive stages -> user-chosen highest tolerated
|
||||||
|
effort for that day -> stop effort -> recovery observation
|
||||||
|
```
|
||||||
|
|
||||||
|
A manual stop is always immediately available. The motor never attempts to
|
||||||
|
beat the previous observed peak automatically.
|
||||||
|
|
||||||
|
Each calibration is immutable/versioned and retains at minimum:
|
||||||
|
|
||||||
|
- stable `calibration_id`;
|
||||||
|
- protocol version;
|
||||||
|
- performance timestamp;
|
||||||
|
- associated heart-rate capture;
|
||||||
|
- observed peak BPM;
|
||||||
|
- effort-end timestamp;
|
||||||
|
- recovery timeline.
|
||||||
|
|
||||||
|
Recovery projections may expose +1, +2 and +3 minute facts while the complete
|
||||||
|
raw curve remains authoritative.
|
||||||
|
|
||||||
|
A later calibration creates a new reference. It never rewrites an older
|
||||||
|
calibration or a prior guided session.
|
||||||
|
|
||||||
|
Every guided cardio session snapshots the exact `calibration_id` and target
|
||||||
|
thresholds used at that time.
|
||||||
|
|
||||||
|
Measured, user-configured, and calculated/estimated values remain explicitly
|
||||||
|
distinguishable.
|
||||||
|
|
||||||
|
## Android Sleep entry point
|
||||||
|
|
||||||
|
Android adds a first-class `Sommeil` menu entry backed by the existing Sleep
|
||||||
|
Diary domain. It must not create a parallel sleep database or incompatible
|
||||||
|
sleep truth.
|
||||||
|
|
||||||
|
Night-time interaction is deliberately minimal. The primary screen exposes:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[ COUCHÉ ]
|
||||||
|
|
||||||
|
[ médicament 1 ] [ médicament 2 ]
|
||||||
|
[ médicament 3 ] [ + ]
|
||||||
|
|
||||||
|
[ RÉVEIL ]
|
||||||
|
|
||||||
|
[ LEVÉ ]
|
||||||
|
```
|
||||||
|
|
||||||
|
Buttons are large, visually consistent with Trainlog, and usable while only
|
||||||
|
partly awake.
|
||||||
|
|
||||||
|
### Couché
|
||||||
|
|
||||||
|
One tap records the actual `bed_time` timestamp in the correct stable Sleep
|
||||||
|
Diary entry. If heart-rate acquisition is available, the nocturnal capture is
|
||||||
|
linked directly to that `sleep entry_id`.
|
||||||
|
|
||||||
|
### Médicaments
|
||||||
|
|
||||||
|
Active configured medications are one-tap actions. A tap stores the actual
|
||||||
|
intake timestamp plus stable medication identity and the effective
|
||||||
|
name/dose/unit snapshot. Full editing remains available later.
|
||||||
|
|
||||||
|
### Réveil
|
||||||
|
|
||||||
|
There is no `Rendormi` button.
|
||||||
|
|
||||||
|
One tap records one punctual night wake event. Nothing else is required before
|
||||||
|
the user tries to return to sleep.
|
||||||
|
|
||||||
|
Trainlog does not infer a certain return-to-sleep time from BPM or RR data.
|
||||||
|
An approximate interval may be completed later only if the user chooses to
|
||||||
|
provide it.
|
||||||
|
|
||||||
|
### Levé
|
||||||
|
|
||||||
|
One tap records the actual `final_get_up` timestamp, closes the night, and
|
||||||
|
stops the linked nocturnal heart-rate capture when one is active.
|
||||||
|
|
||||||
|
### Immediate undo and later correction
|
||||||
|
|
||||||
|
After Couché, medication, Réveil, or Levé, Android shows a short-lived action
|
||||||
|
such as:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Réveil enregistré à 02:17 [ ANNULER ]
|
||||||
|
```
|
||||||
|
|
||||||
|
Undo is scoped to that exact action and must not roll back unrelated newer
|
||||||
|
state.
|
||||||
|
|
||||||
|
`Corriger la nuit` provides later editing of timestamps, medication
|
||||||
|
intakes, accidental wake points, forgotten events, and existing subjective
|
||||||
|
Sleep Diary fields.
|
||||||
|
|
||||||
|
The night-time rule is: one tap, one timestamp, then no further required
|
||||||
|
interaction.
|
||||||
|
|
||||||
|
## Sleep + cardio factual timeline
|
||||||
|
|
||||||
|
A nocturnal capture is owned by stable `sleep entry_id`, not only a date.
|
||||||
|
|
||||||
|
After synchronization, Analyse may superpose factual events and measurements:
|
||||||
|
|
||||||
|
- Couché;
|
||||||
|
- medication intakes;
|
||||||
|
- wake points;
|
||||||
|
- Levé;
|
||||||
|
- BPM;
|
||||||
|
- RR when supplied;
|
||||||
|
- real sensor disconnect/reconnect gaps.
|
||||||
|
|
||||||
|
Trainlog does not diagnose sleep, invent sleep stages, or claim a certain
|
||||||
|
return-to-sleep instant from heart rate alone.
|
||||||
|
|
||||||
|
## Web/Analyse projection
|
||||||
|
|
||||||
|
Web and desktop consume synchronized facts only.
|
||||||
|
|
||||||
|
Training views may show the BPM curve, session bounds, exercise intervals,
|
||||||
|
rest/transition gaps, and factual min/max/mean projections.
|
||||||
|
|
||||||
|
Cardio views may additionally show phase targets, chronological guidance,
|
||||||
|
calibration identity, and measured response versus target.
|
||||||
|
|
||||||
|
Sleep views may overlay Sleep Diary events with BPM/RR measurements.
|
||||||
|
|
||||||
|
Graphs are projections. Persistent measurements, identities, and timestamps
|
||||||
|
remain the source of truth.
|
||||||
|
|
||||||
|
## Performance bounds
|
||||||
|
|
||||||
|
One night can contain tens of thousands of samples. Storage and exchange must
|
||||||
|
use bounded artifacts, indexed temporal queries, transactional import, and
|
||||||
|
avoid per-sample N+1 behavior on analysis paths.
|
||||||
|
|
||||||
|
Trainlog does not arbitrarily downsample the authoritative raw capture merely
|
||||||
|
for transport convenience. Presentation may derive bounded projections while
|
||||||
|
retaining the recorded source data.
|
||||||
|
|
||||||
|
## Implementation order
|
||||||
|
|
||||||
|
1. audit/freeze this contract;
|
||||||
|
2. Android + desktop persistence and strict heart-rate companion exchange;
|
||||||
|
3. standard Heart Rate Measurement parser and real-sensor diagnostics;
|
||||||
|
4. Android foreground acquisition service and global ♥ BPM indicator;
|
||||||
|
5. training start/end and exercise start/end timeline;
|
||||||
|
6. Android one-tap Sleep UI and sleep-linked capture;
|
||||||
|
7. true `cardio` session type;
|
||||||
|
8. versioned Calibration cardio exercise;
|
||||||
|
9. BPM-guided phase motor;
|
||||||
|
10. Web factual visualization;
|
||||||
|
11. complete regression, migration, hardware, replay, and restart validation.
|
||||||
|
|
||||||
|
No stage permits Web/TUI sensor connection.
|
||||||
|
|
@ -6,6 +6,7 @@ than later development. GitHub and Forgejo publication must preserve the
|
||||||
existing release, tag, status, and assets while keeping their note bodies equal
|
existing release, tag, status, and assets while keeping their note bodies equal
|
||||||
to the corresponding source file.
|
to the corresponding source file.
|
||||||
|
|
||||||
|
- [Trainlog v0.1.5](v0.1.5.md)
|
||||||
- [Trainlog v0.1.4](v0.1.4.md)
|
- [Trainlog v0.1.4](v0.1.4.md)
|
||||||
- [Trainlog v0.1.3](v0.1.3.md)
|
- [Trainlog v0.1.3](v0.1.3.md)
|
||||||
- [Trainlog v0.1.2](v0.1.2.md)
|
- [Trainlog v0.1.2](v0.1.2.md)
|
||||||
|
|
|
||||||
59
docs/releases/v0.1.5.md
Normal file
59
docs/releases/v0.1.5.md
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
Trainlog v0.1.5 completes the factual cardio capture cycle and makes the
|
||||||
|
Android-to-Web Sleep workflow ready for real nightly use without adding
|
||||||
|
medical, sleep-stage, or causal inference.
|
||||||
|
|
||||||
|
## Heart-rate capture and cardio sessions
|
||||||
|
|
||||||
|
- Android persistently acquires real CYCPLUS Heart Rate Service measurements,
|
||||||
|
retains factual observation timestamps, BPM and raw RR values, and exposes a
|
||||||
|
global fresh/stale/disconnected indicator.
|
||||||
|
- Training occurrences have explicit start/end timelines so synchronized
|
||||||
|
heart-rate samples retain their stable exercise ownership instead of
|
||||||
|
inferring it later from time overlap.
|
||||||
|
- Dedicated `cardio` sessions, measured calibration profiles and fresh-signal
|
||||||
|
BPM-guided phases use separate versioned companions and preserve the frozen
|
||||||
|
historical session formats.
|
||||||
|
- Guidance suspends on stale signal and records measured facts only; it does
|
||||||
|
not estimate fitness, recovery, readiness or medical state.
|
||||||
|
|
||||||
|
## Factual Web timelines
|
||||||
|
|
||||||
|
- History and Sleep display synchronized heart-rate samples through one
|
||||||
|
bounded native read model and responsive Web timeline.
|
||||||
|
- Full-source min/mean/max and RR counts remain exact while large visual
|
||||||
|
projections are bounded.
|
||||||
|
- Cardio timelines show factual occurrence bounds, resolved targets,
|
||||||
|
instruction changes and measured calibration points.
|
||||||
|
- Sleep highlights notable relative rises and falls with a deterministic local
|
||||||
|
median rule, explicitly without assigning a sleep stage or cause.
|
||||||
|
|
||||||
|
## Sleep capture and medication quantity
|
||||||
|
|
||||||
|
- Medication can be recorded before Couché. The first intake creates one
|
||||||
|
durable pending 18:00-to-18:00 night; Couché advances the same stable entry,
|
||||||
|
Réveil leaves heart-rate capture running, and Levé closes it at the factual
|
||||||
|
final-get-up timestamp.
|
||||||
|
- A missing sensor fabricates neither a capture nor a sample.
|
||||||
|
- Sleep Diary V2 carries structured per-intake quantity while published V1
|
||||||
|
remains strict and readable; historical intakes default to quantity one.
|
||||||
|
- The Web agenda renders every night in the selected period on one aligned
|
||||||
|
18:00-to-18:00 axis, and the selected night owns the full-width heart-rate
|
||||||
|
detail. PDF reports retain their multi-night behavior and display quantity
|
||||||
|
without replacing per-unit dose.
|
||||||
|
|
||||||
|
## Compatibility and validation
|
||||||
|
|
||||||
|
- Desktop schema v35 and Android schema v32 are additive migrations.
|
||||||
|
- `TRAINLOG_FORMAT_V1`, mobile export V3, stable identities, causal revision
|
||||||
|
lineage, generation/ACK semantics and Bluetooth/MTP transport ownership are
|
||||||
|
unchanged.
|
||||||
|
- Native tests, Android unit tests and APK assembly, Web tests/typecheck/build,
|
||||||
|
JSON/import validation, changed-C formatting and exact Sleep V2 replay all
|
||||||
|
pass at the release tag.
|
||||||
|
|
||||||
|
## Assets
|
||||||
|
|
||||||
|
- `trainlog-android-v0.1.5.apk`: signed public Android APK.
|
||||||
|
- `trainlog-tui-linux-x86_64-v0.1.5`: Linux x86-64 runtime bundle containing
|
||||||
|
the TUI, local Web adapter, synchronization helpers and required catalogs.
|
||||||
|
- `SHA256SUMS`: SHA-256 checksums for both artifacts.
|
||||||
|
|
@ -30,14 +30,14 @@ estimates.
|
||||||
|
|
||||||
## Current baseline
|
## Current baseline
|
||||||
|
|
||||||
Stable v0.1.4 is the release baseline for subsequent development.
|
Stable v0.1.5 is the release baseline for the next development cycle.
|
||||||
Desktop schema v29, Android schema v26, Notcurses, direct `Documents/Trainlog`
|
Desktop schema v35, Android schema v32, Notcurses, direct `Documents/Trainlog`
|
||||||
storage, mobile export V3, Training Knowledge V1, Body Zones V1, Training
|
storage, mobile export V3, Training Knowledge V1, Body Zones V1, Training
|
||||||
Feedback V1/V2, STATS V1, and Session Generator V1 are implemented. Session
|
Feedback V1/V2, STATS V1, and Session Generator V1 are implemented. Session
|
||||||
Generator V1 is hidden pending V2. AI session-draft exchange retains its
|
Generator V1 is hidden pending V2. AI session-draft exchange retains its
|
||||||
explicit manual validation gate. APP_SHELL_V1 remains useful historical design
|
explicit manual validation gate. APP_SHELL_V1 remains useful historical design
|
||||||
evidence, but its September 2026 observations must be revalidated against the
|
evidence, but its September 2026 observations must be revalidated against the
|
||||||
v0.1.4 baseline rather than treated as a current implementation plan.
|
v0.1.5 baseline rather than treated as a current implementation plan.
|
||||||
`TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`; its local-only
|
`TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`; its local-only
|
||||||
CLI/HTTP infrastructure, embedded frontend shell and the frozen Dashboard data
|
CLI/HTTP infrastructure, embedded frontend shell and the frozen Dashboard data
|
||||||
contract, frozen interactive grid, versioned layout persistence and factual
|
contract, frozen interactive grid, versioned layout persistence and factual
|
||||||
|
|
@ -46,8 +46,9 @@ visualizations and Sessions V1 are implemented. The top-level `/programmes`
|
||||||
route is an operational daily active-Program calendar, while
|
route is an operational daily active-Program calendar, while
|
||||||
Sessions → Programmes remains the technical administration/import, list,
|
Sessions → Programmes remains the technical administration/import, list,
|
||||||
detail, archive, and delete surface. Web Exercises V1, the factual
|
detail, archive, and delete surface. Web Exercises V1, the factual
|
||||||
Dashboard/Analyse read-model foundation, and Sleep Diary V1 are included in
|
Dashboard/Analyse read-model foundation, Sleep Diary V1/V2, synchronized
|
||||||
stable v0.1.4.
|
factual heart-rate timelines, and the complete Cardio V1
|
||||||
|
capture/calibration/guidance chain are included in stable v0.1.5.
|
||||||
The corrective prepared-item projection, bounded phase-owned MTP outbox,
|
The corrective prepared-item projection, bounded phase-owned MTP outbox,
|
||||||
Sessions presentation correction, local date preference and durable preparation
|
Sessions presentation correction, local date preference and durable preparation
|
||||||
withdrawal are implemented. The withdrawal uses the separate versioned
|
withdrawal are implemented. The withdrawal uses the separate versioned
|
||||||
|
|
@ -56,19 +57,37 @@ mobile snapshot or `TRAINLOG_FORMAT_V1` contracts.
|
||||||
|
|
||||||
## Current cursor
|
## Current cursor
|
||||||
|
|
||||||
Stable v0.1.4 closes the Analyse/Sleep Diary cycle and freezes the Bluetooth
|
Stable v0.1.5 closes Cardio V1 and the Sleep Tonight readiness cycle. Android
|
||||||
Classic synchronization transport independently from future sensor work.
|
alone discovers and reads the heart-rate sensor; desktop/Web consume only
|
||||||
|
synchronized factual measurements and never connect directly to the sensor.
|
||||||
The next development cycle is 0.1.5 cardio. Android will own heart-rate sensor
|
The heart-rate path remains independent from the frozen Bluetooth Trainlog
|
||||||
discovery/acquisition and timestamped local capture; synchronized desktop/Web
|
synchronization transport.
|
||||||
surfaces will consume recorded measurements and will not connect directly to
|
|
||||||
the sensor. Heart-rate ingestion must not reuse or overload the frozen Trainlog
|
|
||||||
Bluetooth synchronization transport contract.
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_CARDIO_V1_NEXT_CONTRACT
|
CURRENT_OPERATIONAL_CURSOR=Stable v0.1.5 published; open 0.1.6 development without reopening frozen 0.1.5 contracts.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Sleep Diary V1 stays frozen/readable while active capable peers require
|
||||||
|
`sleep-diary-v2`. The Web’s relative HR rise/fall overlays remain descriptive
|
||||||
|
measured-series presentation only, never inferred sleep states or causes.
|
||||||
|
|
||||||
|
The vc45 real-device smoke found that selecting Cardio after live capture had
|
||||||
|
already started on the default Training draft left the durable capture tagged
|
||||||
|
as `session`, starving Cardio guidance despite fresh global BPM. Repository
|
||||||
|
persistence now reconciles the same capture atomically while retaining its
|
||||||
|
identity, sample order and raw BPM/RR facts; automated coverage reproduces the
|
||||||
|
transition through an active exercise and guidance event. The vc46 re-smoke
|
||||||
|
then passed with the same capture, preserved pre-transition samples, measured
|
||||||
|
guidance, and non-mutating Sommeil/Calibration screen checks.
|
||||||
|
|
||||||
|
The private phone advanced non-destructively to Android schema v32 on vc47 with
|
||||||
|
integrity/FK checks clean and preserved business counts. The preceding
|
||||||
|
tomorrow-session readiness regression proves Program start and provenance
|
||||||
|
through two ordered occurrences originating in the synchronized Program
|
||||||
|
payload, four HR ownership positions, finalization, full generation, desktop
|
||||||
|
import, idempotent replay and correlated ACK. That readiness evidence is now
|
||||||
|
part of the stable v0.1.5 validation record rather than the active cursor.
|
||||||
|
|
||||||
The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this
|
The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this
|
||||||
cursor: complete predecessor state, imported built-in authorization,
|
cursor: complete predecessor state, imported built-in authorization,
|
||||||
cross-platform draft revision identity and resource-bound admission are closed.
|
cross-platform draft revision identity and resource-bound admission are closed.
|
||||||
|
|
|
||||||
|
|
@ -3,29 +3,37 @@
|
||||||
## Sleep Diary companion
|
## Sleep Diary companion
|
||||||
|
|
||||||
`trainlog-sleep-diary` version 1 is finalized in stable v0.1.4 with both the
|
`trainlog-sleep-diary` version 1 is finalized in stable v0.1.4 with both the
|
||||||
medication catalog and the entry-owned intake snapshots.
|
medication catalog and the entry-owned intake snapshots. It remains frozen and
|
||||||
|
readable historical input. The active Sleep exchange requires the separate
|
||||||
|
`sleep-diary-v2` capability and `sleep-diary-v2.json` with
|
||||||
|
`format=trainlog-sleep-diary`, `version=2`.
|
||||||
The artifact preserves catalog and diary causal parents, rejects concurrent
|
The artifact preserves catalog and diary causal parents, rejects concurrent
|
||||||
siblings and resurrection, and treats an exact revision replay as idempotent.
|
siblings and resurrection, and treats an exact revision replay as idempotent.
|
||||||
This completion happened before publication or freezing of the companion;
|
This completion happened before publication or freezing of the companion;
|
||||||
`TRAINLOG_FORMAT_V1` remains unchanged and frozen.
|
`TRAINLOG_FORMAT_V1` remains unchanged and frozen.
|
||||||
|
|
||||||
Every field edit commits locally as a new immutable diary revision but does
|
Every field edit commits locally as a new immutable diary revision. Explicit
|
||||||
not itself enter a generation. Explicit day validation marks only the guarded
|
day validation marks only the guarded current tip publishable and starts no
|
||||||
current tip publishable and starts no transport. Generation capture exports
|
transport. V2 also permits the current revision of a quick-captured pending
|
||||||
validated current tips, records each exact generation/revision relation, and
|
night to be marked quick-publishable: this is a distinct exact-revision marker,
|
||||||
only a correlated consumed ACK advances the acknowledged marker. A correction
|
not day validation, and it does not validate an incomplete night. Generation
|
||||||
after ACK therefore remains local and appears as modified until it is
|
capture exports either eligible current tip, records each exact
|
||||||
revalidated and acknowledged by a later generation. Imported tips are already
|
generation/revision relation, and only a correlated consumed ACK advances the
|
||||||
validated and acknowledged because their containing generation was durably
|
acknowledged marker. A correction after ACK therefore remains local and
|
||||||
consumed. Draft and synchronized state are not inferred from wall-clock time.
|
appears as modified until it is revalidated or quick-published again and
|
||||||
|
acknowledged by a later generation. Imported tips are already validated and
|
||||||
|
acknowledged because their containing generation was durably consumed. Draft
|
||||||
|
and synchronized state are not inferred from wall-clock time.
|
||||||
|
|
||||||
`trainlog-sleep-diary`, version 1, is emitted by full generations in stable
|
`trainlog-sleep-diary`, version 2, is the active full-generation companion.
|
||||||
v0.1.4. It is optional when consuming an older generation so a pre-domain
|
It carries bounded complete entry snapshots with
|
||||||
artifact remains replayable. It carries bounded complete entry snapshots with
|
|
||||||
stable identities, the current immutable revision and causal parent,
|
stable identities, the current immutable revision and causal parent,
|
||||||
offset-bearing timestamps, appreciations, notes and logical deletion. Exact
|
offset-bearing timestamps, appreciations, notes, logical deletion, and an
|
||||||
revision replay is unchanged; sibling revisions and resurrection after deletion
|
integer `quantity` in `1..99` on every medication intake. `dose_value` and
|
||||||
are rejected in the generation consumption transaction.
|
`dose_unit` remain the per-unit snapshot; quantity is never encoded in notes.
|
||||||
|
V1 input omits `quantity` and is normalized to one unit. Exact revision replay
|
||||||
|
is unchanged; sibling revisions and resurrection after deletion are rejected
|
||||||
|
in the generation consumption transaction.
|
||||||
|
|
||||||
This does not change `TRAINLOG_FORMAT_V1`, manifests, ACKs or causal
|
This does not change `TRAINLOG_FORMAT_V1`, manifests, ACKs or causal
|
||||||
ownership.
|
ownership.
|
||||||
|
|
@ -1060,7 +1068,90 @@ format. It lists bounded durable desktop AI proposals and relevant execution
|
||||||
drafts as distinct kinds. Completed sessions remain outside this projection,
|
drafts as distinct kinds. Completed sessions remain outside this projection,
|
||||||
and finalized or causally deleted execution drafts are excluded.
|
and finalized or causally deleted execution drafts are excluded.
|
||||||
|
|
||||||
## 19. Legacy active-protocol limitations
|
## 19. Heart-rate companion V1
|
||||||
|
|
||||||
|
`trainlog-heart-rate` V1 is an optional full-generation participant produced
|
||||||
|
only by Android. Its logical name is `heart-rate` and its immutable generation
|
||||||
|
file is `heart-rate-v1.json`. Desktop never produces sensor measurements and
|
||||||
|
never connects to the heart-rate device.
|
||||||
|
|
||||||
|
A normal Android generation includes only stopped captures that have not
|
||||||
|
already been acknowledged and are not already retained by an outstanding
|
||||||
|
captured/published/waiting-ACK generation. Capture, sample and RR identities
|
||||||
|
are respectively `capture_id`, `capture_id + sequence`, and
|
||||||
|
`capture_id + sequence + rr_index`.
|
||||||
|
|
||||||
|
Desktop schema v30 validates and imports the whole companion inside the
|
||||||
|
containing generation transaction. Exact replay is idempotent; reusing one
|
||||||
|
stable measurement identity with different content rejects the generation.
|
||||||
|
|
||||||
|
Android schema v27 records the exact `capture_id -> generation_id` relation
|
||||||
|
when the immutable generation is captured. Only a correlated `consumed` ACK
|
||||||
|
sets those captures' acknowledgement timestamps. Rejected generations do not
|
||||||
|
advance cardio acknowledgement. Acknowledged captures remain in Android
|
||||||
|
history but are omitted from normal later publication, preventing a full
|
||||||
|
night's samples from being retransmitted on every sync.
|
||||||
|
|
||||||
|
The companion changes neither the frozen Bluetooth Classic RFCOMM transport,
|
||||||
|
MTP recovery, manifest/ACK framing, Sleep Diary V1 nor mobile-export semantics.
|
||||||
|
Heart-rate BLE/GATT acquisition remains Android-only and is a separate
|
||||||
|
implementation layer.
|
||||||
|
|
||||||
|
## 20. Session timeline companion V1
|
||||||
|
|
||||||
|
`trainlog-session-timeline` V1 is an optional Android→desktop full-generation
|
||||||
|
participant. Android records the real execution interval of each stable
|
||||||
|
`entry_id`; session start/end reuse the same lifecycle identity and timestamps
|
||||||
|
that become canonical completed history.
|
||||||
|
|
||||||
|
Only completed timelines are published. The desktop v31 consumer first proves
|
||||||
|
that `session_id`, `started_at`, `ended_at`, `entry_id` and `exercise_id`
|
||||||
|
already match canonical history, then inserts the occurrence timing facts.
|
||||||
|
Exact replay is a no-op and conflicting reuse of an identity is rejected.
|
||||||
|
|
||||||
|
Heart-rate samples use the same active `entry_id` at acquisition time. Samples
|
||||||
|
between exercise end and the next exercise start retain no fabricated exercise
|
||||||
|
owner and therefore remain factual rest/transition data.
|
||||||
|
|
||||||
|
## 21. Cardio sessions companion V1
|
||||||
|
|
||||||
|
`trainlog-cardio-sessions` V1 is an optional Android→desktop
|
||||||
|
full-generation participant. It carries completed sessions whose canonical
|
||||||
|
Android `session_kind` is `cardio`. These sessions are deliberately absent
|
||||||
|
from the frozen `history-v4` artifact.
|
||||||
|
|
||||||
|
The companion reuses the same bounded exercise/occurrence/set/continuous fact
|
||||||
|
shape internally but owns its own envelope and requires every public
|
||||||
|
`session_type` to be exactly `cardio`. Desktop schema v32 imports those
|
||||||
|
facts transactionally, stores legacy `session_type=training` only for
|
||||||
|
compatibility, and stores canonical `session_kind=cardio`.
|
||||||
|
|
||||||
|
Exact replay is idempotent. Reusing a cardio `session_id` that already belongs
|
||||||
|
to non-cardio canonical history is rejected. The companion is consumed before
|
||||||
|
session-timeline and heart-rate participants in the same generation, so the
|
||||||
|
stable `session_id` and occurrence facts exist before their timing and sensor
|
||||||
|
facts are applied.
|
||||||
|
|
||||||
|
Programs, manual preparations, AI execution drafts, mobile V3/V4 and
|
||||||
|
`TRAINLOG_FORMAT_V1` are not widened by this companion.
|
||||||
|
|
||||||
|
## 22. Cardio calibration companion V1
|
||||||
|
|
||||||
|
`trainlog-cardio-calibrations` V1 is an optional Android→desktop full-generation participant containing only completed calibration profiles. It references an already synchronized cardio session, its reserved stable occurrence, and an immutable stopped Heart Rate V1 capture.
|
||||||
|
|
||||||
|
Before import, desktop proves that the referenced session/occurrence and HR capture already exist and match. The declared observed peak must equal a BPM actually present in that capture, and each recovery point must match an exact raw `(observed_at, bpm)` sample. Exact replay is idempotent; stable identity reuse with different metadata or recovery facts is rejected.
|
||||||
|
|
||||||
|
Android generation membership is retained until a correlated consumed ACK. Acknowledged profiles remain local history and are not ordinarily republished. No calibration artifact claims a physiological maximum: the persisted value is the highest BPM actually observed in that calibration capture before recovery.
|
||||||
|
|
||||||
|
## 23. Cardio guidance companion V1
|
||||||
|
|
||||||
|
`trainlog-cardio-guidance` V1 is an optional Android→desktop full-generation participant containing only completed guided-cardio runs. Each run owns ordered phases, the exact target snapshot used at execution time, its exit condition and only the chronological instruction changes (`accelerate`, `maintain`, `slow_down`, `suspended`). Raw BPM/RR remain exclusively in Heart Rate V1.
|
||||||
|
|
||||||
|
A calibration-derived target carries `calibration_id`, the observed peak snapshot and the percentages that resolved the BPM bounds. Desktop requires that reference to match an already imported completed calibration. Every non-suspended instruction event carrying BPM must match an exact raw heart-rate sample for the same cardio session and timestamp. Suspended events carry no BPM and therefore cannot silently reuse a stale measurement.
|
||||||
|
|
||||||
|
Exact replay is idempotent. Reusing a stable run/phase identity with different target, condition, interval or event history is rejected. Android records exact `run_id -> generation_id` membership; only a correlated consumed ACK marks the run acknowledged and excludes it from ordinary future publication.
|
||||||
|
|
||||||
|
## 24. Legacy active-protocol limitations
|
||||||
|
|
||||||
The Web end-to-end tranche adds an explicit opt-in application orchestrator.
|
The Web end-to-end tranche adds an explicit opt-in application orchestrator.
|
||||||
`POST /api/v1/sync` admits one correlated run and `GET /api/v1/sync/status` is
|
`POST /api/v1/sync` admits one correlated run and `GET /api/v1/sync/status` is
|
||||||
|
|
|
||||||
281
docs/tests.md
281
docs/tests.md
|
|
@ -1648,3 +1648,284 @@ checks passed, both outgoing desktop generations were acknowledged, and the
|
||||||
Android UI displayed completion. The private journal retains exact IDs and
|
Android UI displayed completion. The private journal retains exact IDs and
|
||||||
hashes. Drive and `connectedDebugAndroidTest` were not run; the personal phone
|
hashes. Drive and `connectedDebugAndroidTest` were not run; the personal phone
|
||||||
was used only for non-destructive install, backup and manual sync validation.
|
was used only for non-destructive install, backup and manual sync validation.
|
||||||
|
|
||||||
|
## Cardio persistence/synchronization V1 validation
|
||||||
|
|
||||||
|
`TRAINLOG_CARDIO_PERSISTENCE_SYNC_V1=PASS` is covered without requiring a
|
||||||
|
physical heart-rate sensor. Android migration coverage advances schema v26 to
|
||||||
|
v27 and verifies durable raw BPM/contact/energy/RR capture; desktop migration
|
||||||
|
coverage advances v29 to v30 and verifies the additive cardio tables/indexes.
|
||||||
|
|
||||||
|
`heart_rate_exchange` validates the strict `trainlog-heart-rate` V1 envelope,
|
||||||
|
cardio/session/sleep ownership, timestamp bounds, raw RR preservation, exact
|
||||||
|
idempotent replay, and rejection of identity reuse with different measured
|
||||||
|
content. The Android full-generation regression captures a stopped sleep-owned
|
||||||
|
measurement, publishes it, consumes it through the production desktop
|
||||||
|
generation tool, accepts the correlated ACK, verifies one desktop
|
||||||
|
capture/sample/RR row, and proves the acknowledged Android capture is no longer
|
||||||
|
emitted.
|
||||||
|
|
||||||
|
The 2026-09-23 closeout passes the complete native Meson inventory:
|
||||||
|
**102/102 passed, 0 failed**. The complete Android JVM inventory reports
|
||||||
|
**258 tests: 253 passed, five skipped, zero failures/errors**. The five skips
|
||||||
|
remain optional historical/external fixture gates and are not Cardio V1
|
||||||
|
failures.
|
||||||
|
|
||||||
|
## Cardio BLE diagnostics V1 validation
|
||||||
|
|
||||||
|
`TRAINLOG_CARDIO_BLE_DIAGNOSTICS_V1=SOFTWARE_PASS / HARDWARE_VALIDATION_PENDING`
|
||||||
|
keeps BLE protocol parsing separate from Android GATT transport. Unit coverage
|
||||||
|
exercises 8-bit and 16-bit BPM, nullable sensor-contact semantics, cumulative
|
||||||
|
energy, one/multiple raw RR intervals, combined flags, boundary BPM values,
|
||||||
|
reserved flags, missing fields, truncated RR/energy values and unexpected
|
||||||
|
trailing bytes.
|
||||||
|
|
||||||
|
The Android diagnostic uses a 10-second Heart Rate Service-filtered scan with
|
||||||
|
an explicit expanded BLE fallback, then discovers `0x180D`, locates `0x2A37`,
|
||||||
|
checks optional Battery Service and enables the CCC notification descriptor.
|
||||||
|
It is screen-scoped and does not implement the later persistent foreground
|
||||||
|
acquisition owner.
|
||||||
|
|
||||||
|
On 2026-09-23 the diagnostic APK was installed non-destructively on the
|
||||||
|
authorized SM_G990B as Trainlog 0.1.5 versionCode 38 using the same Android
|
||||||
|
debug signing certificate as the existing private installation. Application
|
||||||
|
startup completed without SQLite/crash errors and the diagnostic route opened.
|
||||||
|
The first real 10-second HRS-filtered scan completed cleanly before the armband
|
||||||
|
was positively identified. A subsequent expanded scan identified the real
|
||||||
|
CYCPLUS H2 advertising Heart Rate Service. Trainlog connected through GATT,
|
||||||
|
discovered `0x180D`, `0x2A37` and Battery Service, enabled notifications and
|
||||||
|
received live BPM successfully. The observed payloads carried one raw RR
|
||||||
|
interval, no sensor-contact field and no energy-expended field. A 12-second
|
||||||
|
measurement window advanced from 43 to 56 notifications (13 notifications),
|
||||||
|
confirming an approximately 1 Hz emission cadence. This closes the real-sensor
|
||||||
|
hardware characterization gate without recording device-specific MAC identity
|
||||||
|
in project documentation.
|
||||||
|
|
||||||
|
The complete Android JVM inventory after this tranche reports **266 tests:
|
||||||
|
261 passed, five skipped, zero failures/errors**, and `assembleDebug` succeeds.
|
||||||
|
|
||||||
|
## Cardio persistent acquisition V1 validation
|
||||||
|
|
||||||
|
`TRAINLOG_CARDIO_PERSISTENT_ACQUISITION_V1=PASS` adds a remembered Android
|
||||||
|
heart-rate sensor, a dedicated connected-device foreground service, process-wide
|
||||||
|
live state and the global app-bar `♥ BPM` indicator. A fresh measurement is
|
||||||
|
green; connection/reconnection or stale signal is orange; no configured/active
|
||||||
|
sensor is grey. A BPM older than three seconds is removed from presentation,
|
||||||
|
and a stale GATT stream is forcibly recycled after eight seconds.
|
||||||
|
|
||||||
|
Unit coverage proves durable sensor selection/clear and the live-state
|
||||||
|
transition from connecting to measured BPM/RR, stale `-- BPM`, and
|
||||||
|
disconnected. The complete Android JVM inventory after this tranche reports
|
||||||
|
**268 tests: 263 passed, five skipped, zero failures/errors**; `assembleDebug`
|
||||||
|
also passes.
|
||||||
|
|
||||||
|
Real-device validation used the authorized CYCPLUS H2 on the private Trainlog
|
||||||
|
0.1.5 versionCode 40 installation. With no selected sensor the global indicator
|
||||||
|
rendered `♥ -- BPM` and the disconnected accessibility state. Selecting the
|
||||||
|
validated sensor produced live `♥ 75 BPM`; a complete app force-stop/restart
|
||||||
|
reloaded the remembered sensor and returned automatically at `♥ 72 BPM`.
|
||||||
|
Disabling phone Bluetooth produced `♥ -- BPM` with the reconnecting state;
|
||||||
|
reenabling Bluetooth restored the same foreground service automatically and
|
||||||
|
returned to a fresh `♥ 79 BPM`. No uninstall or data clear occurred.
|
||||||
|
|
||||||
|
## Training timeline V1 validation
|
||||||
|
|
||||||
|
`TRAINLOG_CARDIO_TRAINING_TIMELINE_V1=PASS` covers Android schema v28 and
|
||||||
|
desktop schema v31. Repository tests prove explicit exercise start/end
|
||||||
|
timestamps, one-active-exercise enforcement, restart-safe active ownership,
|
||||||
|
finalization refusal while an exercise remains active, and heart-rate samples
|
||||||
|
tagged with the active stable `entry_id` while rest samples remain unowned.
|
||||||
|
|
||||||
|
The generation regression publishes a completed timeline through the production
|
||||||
|
full-generation path, imports it through `trainlog-session-timeline` V1,
|
||||||
|
accepts the correlated ACK, and verifies idempotent replay/conflict rejection.
|
||||||
|
|
||||||
|
The 2026-09-23 closeout passes the complete native Meson inventory:
|
||||||
|
**104/104 passed, 0 failed**. The complete Android JVM inventory reports
|
||||||
|
**272 tests: 267 passed, five skipped, zero failures/errors**; `assembleDebug`
|
||||||
|
also succeeds. The five skips remain optional historical/external fixture gates.
|
||||||
|
|
||||||
|
## Android one-tap Sleep V1 validation
|
||||||
|
|
||||||
|
`TRAINLOG_SLEEP_ANDROID_ONE_TAP_V1=SOFTWARE_PASS / DEVICE_VISUAL_PENDING`
|
||||||
|
reuses the existing Sleep Diary V1 revision model and schema. The quick-capture
|
||||||
|
API creates/advances normal immutable Sleep Diary revisions for Couché,
|
||||||
|
medication intake, point Réveil and Levé actions. Undo is accepted only when
|
||||||
|
the action's exact applied revision is still current; older receipts conflict,
|
||||||
|
and successful undo appends a compensating revision instead of rewriting the
|
||||||
|
history.
|
||||||
|
|
||||||
|
Targeted coverage executes Couché, a configured medication with dose snapshot,
|
||||||
|
two independent wake points, live sleep-owned BPM/RR capture, Levé and final
|
||||||
|
heart-rate export against the same stable sleep `entry_id`. A separate test
|
||||||
|
proves stale undo rejection and exact restoration of the latest action.
|
||||||
|
|
||||||
|
The complete Android JVM inventory before the final localization-only cleanup
|
||||||
|
reported **274 tests: 269 passed, five skipped, zero failures/errors** and
|
||||||
|
`assembleDebug` succeeded. After the localization cleanup the targeted
|
||||||
|
SleepQuickCapture test and `assembleDebug` passed again.
|
||||||
|
|
||||||
|
The private Android installation was advanced non-destructively to Trainlog
|
||||||
|
0.1.5 versionCode 41 with the same debug signing identity and no uninstall or
|
||||||
|
data clear. The device locked before the final visual Sleep-screen inspection,
|
||||||
|
so no lock bypass was attempted and no real Sleep business button was pressed;
|
||||||
|
that visual review remains explicitly pending.
|
||||||
|
|
||||||
|
## Cardio session type V1 validation
|
||||||
|
|
||||||
|
`TRAINLOG_CARDIO_SESSION_TYPE_V1=PASS` covers the new canonical session kind
|
||||||
|
without widening frozen historical codecs. Android schema v29 migration tests
|
||||||
|
prove that v28 training/max-test rows backfill from the legacy
|
||||||
|
`session_type`, including a structurally adversarial retained-column fixture.
|
||||||
|
A product-level Android regression creates, reloads and finalizes a real
|
||||||
|
`SessionType.CARDIO` draft, verifies legacy `session_type=training` plus
|
||||||
|
canonical `session_kind=cardio`, and proves the cardio session is absent from
|
||||||
|
`history-v4`.
|
||||||
|
|
||||||
|
The end-to-end generation regression proves the complementary path:
|
||||||
|
`history-v4` contains zero cardio sessions, `cardio-sessions-v1.json`
|
||||||
|
contains the completed cardio session, the production desktop generation
|
||||||
|
consumer imports it as legacy training/canonical cardio with its performed set,
|
||||||
|
the correlated ACK is accepted, and exact replay remains idempotent.
|
||||||
|
|
||||||
|
Desktop schema v32 migration coverage validates the additive canonical column,
|
||||||
|
historical-layout tolerance and foreign-key integrity.
|
||||||
|
`cardio_session_exchange` separately covers strict cardio-only validation,
|
||||||
|
exact replay and rejection of a stable identity colliding with non-cardio
|
||||||
|
history.
|
||||||
|
|
||||||
|
The 2026-09-23 closeout passes the complete native Meson inventory:
|
||||||
|
**106/106 passed, 0 failed**. The complete Android JVM inventory reports
|
||||||
|
**277 tests: 272 passed, five skipped, zero failures/errors**; `assembleDebug`
|
||||||
|
also succeeds. The five skips remain optional historical/external fixture gates.
|
||||||
|
|
||||||
|
## Cardio calibration V1 validation
|
||||||
|
|
||||||
|
`TRAINLOG_CARDIO_CALIBRATION_V1=PASS` covers the Android v29→v30 and desktop v32→v33 additive migrations, reserved system exercise ownership, real cardio session/timeline/HR capture integration, observed peak tracking, recovery, exact replay and generation synchronization.
|
||||||
|
|
||||||
|
The calibration system exercise keeps a stable `exercise_id` for occurrence foreign keys but is deliberately absent from the ordinary exercise catalogue, BODY ZONES, profile-state and PC-catalog companions. This preserves existing exercise-domain invariants while the dedicated cardio session/calibration companions own the system occurrence.
|
||||||
|
|
||||||
|
Recovery cannot complete before three minutes after effort end. The +60/+120/+180 second references accept only a real HR sample arriving from the target instant through ten seconds after it. A missing sensor interval remains missing rather than being populated by a later reconnect.
|
||||||
|
|
||||||
|
The desktop importer independently rejects a peak not supported by the raw HR capture and rejects a recovery point that is not an exact raw sample. The 2026-09-23 closeout passes the complete native inventory **108/108** and the complete Android JVM inventory **281 tests: 276 passed, five skipped, zero failures/errors**; `assembleDebug` also succeeds.
|
||||||
|
|
||||||
|
## Cardio BPM guidance V1 validation
|
||||||
|
|
||||||
|
`TRAINLOG_CARDIO_BPM_GUIDANCE_V1=PASS` covers Android schema v31, desktop
|
||||||
|
schema v34, pure guidance logic, persistent phase/run history, live CYCPLUS
|
||||||
|
integration, strict synchronization and the Android active-session UI.
|
||||||
|
|
||||||
|
The guidance engine resolves either absolute BPM targets or calibration-relative
|
||||||
|
percentages into an immutable BPM snapshot before execution. The default
|
||||||
|
control-stability parameters are a 2 BPM hysteresis band and a 3 second
|
||||||
|
confirmation interval; these are UI/control parameters, not physiological zone
|
||||||
|
claims. A stale or missing heart-rate signal suspends guidance immediately and
|
||||||
|
never reuses the last BPM.
|
||||||
|
|
||||||
|
Supported phase exit conditions are fixed duration, entry into the resolved BPM
|
||||||
|
target, recovery below a threshold, and duration-or-recovery. Only time-based
|
||||||
|
conditions can complete without a fresh BPM measurement.
|
||||||
|
|
||||||
|
Android records ordered phases against the active stable cardio `entry_id`,
|
||||||
|
persists target/calibration provenance and stores only instruction changes.
|
||||||
|
The full-generation regression proves a completed run with raw BPM evidence is
|
||||||
|
imported through `trainlog-cardio-guidance` V1, receives a correlated consumed
|
||||||
|
ACK, and is excluded from the next ordinary Android publication. Desktop
|
||||||
|
independently verifies canonical cardio session ownership, exercise timeline,
|
||||||
|
calibration provenance and exact raw BPM evidence for non-suspended events.
|
||||||
|
|
||||||
|
The 2026-09-23 closeout passes the complete native Meson inventory
|
||||||
|
**110/110**, and the complete Android JVM inventory reports **300 tests:
|
||||||
|
295 passed, five skipped, zero failures/errors**. `assembleDebug` succeeds.
|
||||||
|
|
||||||
|
## Cardio Web visualization V1 validation
|
||||||
|
|
||||||
|
`TRAINLOG_CARDIO_WEB_VISUALIZATION_V1=PASS` adds one bounded read-only C17
|
||||||
|
heart-rate timeline projection and one loopback GET endpoint keyed only by the
|
||||||
|
stable session/cardio `se_` or Sleep Diary `sl_` identity. The projection
|
||||||
|
returns synchronized raw BPM samples with native RR arrays, canonical
|
||||||
|
session/exercise or Sleep/medication markers, guided-cardio phase snapshots and
|
||||||
|
instruction changes, and factual calibration peak/recovery data when present.
|
||||||
|
It performs no sleep-stage, return-to-sleep, HRV, readiness or medical
|
||||||
|
inference.
|
||||||
|
|
||||||
|
Web History and the Sleep workspace reuse the same React timeline component.
|
||||||
|
Min/max/mean facts are calculated over every returned BPM sample. Only the SVG
|
||||||
|
projection is bounded for large captures by retaining bucket extrema; the
|
||||||
|
source samples and statistics are not downsampled. A large synchronized night
|
||||||
|
therefore remains exact at the data boundary without forcing a 100,000-point
|
||||||
|
SVG path.
|
||||||
|
|
||||||
|
The backend test covers cardio BPM/RR, exercise intervals, guidance, calibration,
|
||||||
|
Sleep events, medication points, empty contexts and invalid identities. The HTTP
|
||||||
|
test covers valid empty GET, bad context rejection and GET-only semantics. The
|
||||||
|
2026-09-23 closeout passes the complete native inventory **111/111** and the
|
||||||
|
complete Web inventory **29 files / 183 tests**; Web typecheck and production
|
||||||
|
build also succeed.
|
||||||
|
|
||||||
|
## Trainlog 0.1.5 complete validation
|
||||||
|
|
||||||
|
`TRAINLOG_0_1_5_VALIDATION_V1=SOFTWARE_PASS / DEVICE_VISUAL_PENDING` closes the
|
||||||
|
complete software regression after Cardio Web visualization. The native Meson
|
||||||
|
inventory passes **111/111**. Android reports **300 JVM tests: 295 passed, five
|
||||||
|
historical/external skips, zero failures/errors**, and `assembleDebug`
|
||||||
|
succeeds. Web passes **29 files / 183 tests**, TypeScript typecheck and the
|
||||||
|
production Vite build.
|
||||||
|
|
||||||
|
The authorized SM_G990B was backed up before the final private candidate. The
|
||||||
|
pre-update `trainlog-android.db` was copied while the app was force-stopped and
|
||||||
|
verified at 1,892,352 bytes. Private Trainlog 0.1.5 advanced in place from
|
||||||
|
versionCode 42 to versionCode 43 using the exact same Android debug signing
|
||||||
|
certificate; `adb install -r` succeeded without uninstall or data clear.
|
||||||
|
|
||||||
|
Before and after vc43, the Android store remained schema v31 with identical
|
||||||
|
high-level business counts: 12 completed sessions, 45 catalogue exercises and
|
||||||
|
2 Sleep Diary entries. The four cardio-guidance tables are present after the
|
||||||
|
update, `PRAGMA integrity_check` returns `ok`, and
|
||||||
|
`PRAGMA foreign_key_check` returns no row.
|
||||||
|
|
||||||
|
The locked-device background smoke test confirms both Trainlog foreground
|
||||||
|
services remain alive. `HeartRateSensorService` continued receiving the real
|
||||||
|
CYCPLUS H2 and exposed 94 then 95 BPM through its foreground notification while
|
||||||
|
the phone was dozing, proving the final candidate still acquires live heart
|
||||||
|
rate without requiring the activity to remain visible.
|
||||||
|
|
||||||
|
The phone was locked during the final UI probe, so the visual smoke test of
|
||||||
|
Sommeil, Calibration cardio and active guided-cardio presentation remains
|
||||||
|
`DEVICE_VISUAL_PENDING`. No stable `v0.1.5` tag or release is authorized by
|
||||||
|
this closeout.
|
||||||
|
|
||||||
|
## Sleep tonight readiness V2 validation
|
||||||
|
|
||||||
|
The validated development delta retains frozen Sleep Diary V1 input and tests
|
||||||
|
the active V2 companion separately. Android migration coverage advances schema
|
||||||
|
v31 to v32 and proves the additive `quantity` column defaults historical
|
||||||
|
intakes to `1`, while the separate quick-publication state does not mark a day
|
||||||
|
validated. Desktop migration coverage advances v34 to v35 with the same
|
||||||
|
bounded `1..99` quantity contract.
|
||||||
|
|
||||||
|
Focused Android repository/service coverage proves the full factual lifecycle:
|
||||||
|
pre-bed medication creates one durable pending 18:00-to-18:00 entry; additional
|
||||||
|
intakes reuse it; Couché adds `BED_TIME` to that same stable entry; and a
|
||||||
|
quantity of two remains distinct from the per-unit dose. It also proves
|
||||||
|
pre-bed medication opens no HR capture, the first real post-bedtime CYCPLUS
|
||||||
|
sample opens/reuses one sleep capture at factual bedtime, Réveil leaves it
|
||||||
|
open, Levé closes it at factual final get-up, and a later sample cannot append.
|
||||||
|
The no-sensor path proves Couché through Levé creates neither capture nor
|
||||||
|
sample.
|
||||||
|
|
||||||
|
Generation/import coverage proves a pending entry can synchronize without a
|
||||||
|
fabricated `BED_TIME`, the later bedtime revision advances the same identity,
|
||||||
|
quantity round-trips exactly, V1 intake input reads as quantity one, and replay
|
||||||
|
remains idempotent. The Web suite covers all period nights, row selection and
|
||||||
|
the selected full-width Sleep HR detail, shared 18:00-axis geometry, quantity
|
||||||
|
editing/presentation and PDF `×N` presentation. HR variation unit coverage
|
||||||
|
includes stable, rise, fall, insufficient-baseline, short-capture, gap and
|
||||||
|
boundary cases.
|
||||||
|
|
||||||
|
The reported validation passes the complete Meson inventory **111/111**, both
|
||||||
|
import-contract checks **6/6**, Android `testDebugUnitTest` and `assembleDebug`,
|
||||||
|
and the complete Web inventory **191/191** with typecheck and production build.
|
||||||
|
JSON validators, changed-file formatting, and `git diff --check` also pass.
|
||||||
|
This is software evidence only: it records no device rollout and authorizes no
|
||||||
|
stable release.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
project(
|
project(
|
||||||
'trainlog',
|
'trainlog',
|
||||||
'c',
|
'c',
|
||||||
version: '0.1.4',
|
version: '0.1.5',
|
||||||
meson_version: '>=1.3.0',
|
meson_version: '>=1.3.0',
|
||||||
default_options: [
|
default_options: [
|
||||||
'c_std=c17',
|
'c_std=c17',
|
||||||
|
|
|
||||||
175
tests/test_cardio_calibration_exchange.py
Normal file
175
tests/test_cardio_calibration_exchange.py
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from copy import deepcopy
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT / "tools"))
|
||||||
|
|
||||||
|
import cardio_calibration_exchange as calibration
|
||||||
|
from trainlog_sqlite import connect_database
|
||||||
|
|
||||||
|
|
||||||
|
CAL = "cal_11111111-1111-4111-8111-111111111111"
|
||||||
|
SESSION = "se_22222222-2222-4222-8222-222222222222"
|
||||||
|
ENTRY = "sxe_33333333-3333-4333-8333-333333333333"
|
||||||
|
CAPTURE = "hrc_44444444-4444-4444-8444-444444444444"
|
||||||
|
EXERCISE = "ex_ca1b4a7e-1c2d-4f00-8a11-000000000001"
|
||||||
|
|
||||||
|
|
||||||
|
def document() -> dict:
|
||||||
|
return {
|
||||||
|
"format": "trainlog-cardio-calibrations",
|
||||||
|
"version": 1,
|
||||||
|
"generated_at": "2026-09-23T10:07:00+02:00",
|
||||||
|
"calibrations": [
|
||||||
|
{
|
||||||
|
"calibration_id": CAL,
|
||||||
|
"protocol_version": 1,
|
||||||
|
"session_id": SESSION,
|
||||||
|
"entry_id": ENTRY,
|
||||||
|
"started_at": "2026-09-23T10:00:00+02:00",
|
||||||
|
"effort_end_at": "2026-09-23T10:03:01+02:00",
|
||||||
|
"ended_at": "2026-09-23T10:06:10+02:00",
|
||||||
|
"heart_rate_capture_id": CAPTURE,
|
||||||
|
"observed_peak_bpm": 160,
|
||||||
|
"recovery": [
|
||||||
|
{
|
||||||
|
"target_offset_seconds": 60,
|
||||||
|
"observed_at": "2026-09-23T10:04:01+02:00",
|
||||||
|
"bpm": 140,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target_offset_seconds": 120,
|
||||||
|
"observed_at": "2026-09-23T10:05:02+02:00",
|
||||||
|
"bpm": 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target_offset_seconds": 180,
|
||||||
|
"observed_at": "2026-09-23T10:06:01+02:00",
|
||||||
|
"bpm": 100,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CardioCalibrationExchangeTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.db_path = Path(self.temp.name) / "trainlog.sqlite"
|
||||||
|
fixture = ROOT / "build/tui/sync-generation-fixture"
|
||||||
|
subprocess.run([str(fixture), str(self.db_path)], check=True, capture_output=True)
|
||||||
|
self.db = connect_database(self.db_path)
|
||||||
|
self.db.execute(
|
||||||
|
"INSERT INTO exercises(exercise_id,name,normalized_name,recording_mode,tracking_mode,data_fields) "
|
||||||
|
"VALUES(?,?,?,?,?,?)",
|
||||||
|
(EXERCISE, "Calibration cardio", "calibration cardio", "continuous", "duration", 0),
|
||||||
|
)
|
||||||
|
exercise_row = self.db.execute(
|
||||||
|
"SELECT id FROM exercises WHERE exercise_id=?", (EXERCISE,)
|
||||||
|
).fetchone()[0]
|
||||||
|
self.db.execute(
|
||||||
|
"INSERT INTO sessions(session_id,started_at,ended_at,session_type,session_kind) "
|
||||||
|
"VALUES(?,?,?,?,?)",
|
||||||
|
(
|
||||||
|
SESSION,
|
||||||
|
"2026-09-23T10:00:00+02:00",
|
||||||
|
"2026-09-23T10:06:10+02:00",
|
||||||
|
"training",
|
||||||
|
"cardio",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
session_row = self.db.execute(
|
||||||
|
"SELECT id FROM sessions WHERE session_id=?", (SESSION,)
|
||||||
|
).fetchone()[0]
|
||||||
|
self.db.execute(
|
||||||
|
"INSERT INTO session_exercises("
|
||||||
|
"entry_id,session_row_id,exercise_row_id,recording_mode,tracking_mode,"
|
||||||
|
"data_fields,position,load_mode,rest_seconds) VALUES(?,?,?,?,?,?,?,?,?)",
|
||||||
|
(ENTRY, session_row, exercise_row, "continuous", "duration", 0, 0, "none", 0),
|
||||||
|
)
|
||||||
|
self.db.execute(
|
||||||
|
"INSERT INTO heart_rate_captures("
|
||||||
|
"capture_id,context_kind,context_id,started_at,ended_at,sensor_name,imported_at"
|
||||||
|
") VALUES(?,?,?,?,?,?,?)",
|
||||||
|
(
|
||||||
|
CAPTURE,
|
||||||
|
"cardio",
|
||||||
|
SESSION,
|
||||||
|
"2026-09-23T10:00:00+02:00",
|
||||||
|
"2026-09-23T10:06:10+02:00",
|
||||||
|
"CYCPLUS H2",
|
||||||
|
"2026-09-23T10:07:00+02:00",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
samples = [
|
||||||
|
("2026-09-23T10:00:10+02:00", 80),
|
||||||
|
("2026-09-23T10:01:20+02:00", 112),
|
||||||
|
("2026-09-23T10:02:30+02:00", 150),
|
||||||
|
("2026-09-23T10:03:00+02:00", 160),
|
||||||
|
("2026-09-23T10:04:01+02:00", 140),
|
||||||
|
("2026-09-23T10:05:02+02:00", 120),
|
||||||
|
("2026-09-23T10:06:01+02:00", 100),
|
||||||
|
]
|
||||||
|
self.db.executemany(
|
||||||
|
"INSERT INTO heart_rate_samples("
|
||||||
|
"capture_id,sequence,observed_at,bpm,exercise_entry_id,"
|
||||||
|
"sensor_contact_detected,energy_expended) VALUES(?,?,?,?,?,?,?)",
|
||||||
|
[
|
||||||
|
(CAPTURE, index, observed_at, bpm, ENTRY, None, None)
|
||||||
|
for index, (observed_at, bpm) in enumerate(samples)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.db.close()
|
||||||
|
self.temp.cleanup()
|
||||||
|
|
||||||
|
def test_import_and_exact_replay_are_factual_and_idempotent(self):
|
||||||
|
payload = calibration.validate(document())
|
||||||
|
self.assertEqual((1, 0), calibration.apply(self.db, payload))
|
||||||
|
self.assertEqual((0, 1), calibration.apply(self.db, payload))
|
||||||
|
self.assertEqual(
|
||||||
|
(1, SESSION, CAPTURE, 160),
|
||||||
|
tuple(
|
||||||
|
self.db.execute(
|
||||||
|
"SELECT protocol_version,session_id,heart_rate_capture_id,observed_peak_bpm "
|
||||||
|
"FROM cardio_calibrations WHERE calibration_id=?",
|
||||||
|
(CAL,),
|
||||||
|
).fetchone()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[(60, 140), (120, 120), (180, 100)],
|
||||||
|
[
|
||||||
|
tuple(row)
|
||||||
|
for row in self.db.execute(
|
||||||
|
"SELECT target_offset_seconds,bpm FROM cardio_calibration_recovery "
|
||||||
|
"WHERE calibration_id=? ORDER BY target_offset_seconds",
|
||||||
|
(CAL,),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rejects_peak_not_supported_by_raw_capture(self):
|
||||||
|
changed = deepcopy(document())
|
||||||
|
changed["calibrations"][0]["observed_peak_bpm"] = 159
|
||||||
|
with self.assertRaisesRegex(ValueError, "peak BPM"):
|
||||||
|
calibration.apply(self.db, calibration.validate(changed))
|
||||||
|
|
||||||
|
def test_rejects_recovery_point_not_present_in_raw_capture(self):
|
||||||
|
changed = deepcopy(document())
|
||||||
|
changed["calibrations"][0]["recovery"][0]["bpm"] = 139
|
||||||
|
with self.assertRaisesRegex(ValueError, "not a raw heart-rate sample"):
|
||||||
|
calibration.apply(self.db, calibration.validate(changed))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
236
tests/test_cardio_guidance_exchange.py
Normal file
236
tests/test_cardio_guidance_exchange.py
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import subprocess
|
||||||
|
import unittest
|
||||||
|
from copy import deepcopy
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT / "tools"))
|
||||||
|
|
||||||
|
import cardio_guidance_exchange as guidance
|
||||||
|
from trainlog_sqlite import connect_database
|
||||||
|
|
||||||
|
RUN = "cgr_11111111-1111-4111-8111-111111111111"
|
||||||
|
PHASE = "cgp_22222222-2222-4222-8222-222222222222"
|
||||||
|
SESSION = "se_33333333-3333-4333-8333-333333333333"
|
||||||
|
ENTRY = "sxe_44444444-4444-4444-8444-444444444444"
|
||||||
|
EXERCISE = "ex_55555555-5555-4555-8555-555555555555"
|
||||||
|
CAPTURE = "hrc_66666666-6666-4666-8666-666666666666"
|
||||||
|
CAL = "cal_77777777-7777-4777-8777-777777777777"
|
||||||
|
|
||||||
|
|
||||||
|
def document(calibrated: bool = False) -> dict:
|
||||||
|
target = {
|
||||||
|
"minimum_bpm": 120,
|
||||||
|
"maximum_bpm": 140,
|
||||||
|
"calibration_id": CAL if calibrated else None,
|
||||||
|
"calibration_observed_peak_bpm": 160 if calibrated else None,
|
||||||
|
"minimum_percent": 75 if calibrated else None,
|
||||||
|
"maximum_percent": 88 if calibrated else None,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"format": "trainlog-cardio-guidance",
|
||||||
|
"version": 1,
|
||||||
|
"generated_at": "2026-09-23T13:11:00+02:00",
|
||||||
|
"runs": [
|
||||||
|
{
|
||||||
|
"run_id": RUN,
|
||||||
|
"session_id": SESSION,
|
||||||
|
"started_at": "2026-09-23T13:00:20+02:00",
|
||||||
|
"ended_at": "2026-09-23T13:03:20+02:00",
|
||||||
|
"phases": [
|
||||||
|
{
|
||||||
|
"phase_id": PHASE,
|
||||||
|
"entry_id": ENTRY,
|
||||||
|
"position": 0,
|
||||||
|
"kind": "work",
|
||||||
|
"target": target,
|
||||||
|
"exit_condition": {
|
||||||
|
"kind": "fixed_duration",
|
||||||
|
"seconds": 180,
|
||||||
|
"bpm": None,
|
||||||
|
},
|
||||||
|
"started_at": "2026-09-23T13:00:20+02:00",
|
||||||
|
"ended_at": "2026-09-23T13:03:20+02:00",
|
||||||
|
"final_instruction": "maintain",
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"sequence": 0,
|
||||||
|
"observed_at": "2026-09-23T13:00:23+02:00",
|
||||||
|
"instruction": "accelerate",
|
||||||
|
"bpm": 110,
|
||||||
|
"target_minimum_bpm": 120,
|
||||||
|
"target_maximum_bpm": 140,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sequence": 1,
|
||||||
|
"observed_at": "2026-09-23T13:00:30+02:00",
|
||||||
|
"instruction": "suspended",
|
||||||
|
"bpm": None,
|
||||||
|
"target_minimum_bpm": 120,
|
||||||
|
"target_maximum_bpm": 140,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sequence": 2,
|
||||||
|
"observed_at": "2026-09-23T13:00:40+02:00",
|
||||||
|
"instruction": "maintain",
|
||||||
|
"bpm": 130,
|
||||||
|
"target_minimum_bpm": 120,
|
||||||
|
"target_maximum_bpm": 140,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CardioGuidanceExchangeTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.db_path = Path(self.temp.name) / "trainlog.sqlite"
|
||||||
|
fixture = ROOT / "build/tui/sync-generation-fixture"
|
||||||
|
subprocess.run([str(fixture), str(self.db_path)], check=True, capture_output=True)
|
||||||
|
self.db = connect_database(self.db_path)
|
||||||
|
self.db.execute(
|
||||||
|
"INSERT INTO exercises(exercise_id,name,normalized_name,recording_mode,tracking_mode,data_fields) "
|
||||||
|
"VALUES(?,?,?,?,?,?)",
|
||||||
|
(EXERCISE, "Tapis guidé", "tapis guide", "continuous", "duration", 0),
|
||||||
|
)
|
||||||
|
exercise_row = self.db.execute(
|
||||||
|
"SELECT id FROM exercises WHERE exercise_id=?", (EXERCISE,)
|
||||||
|
).fetchone()[0]
|
||||||
|
self.db.execute(
|
||||||
|
"INSERT INTO sessions(session_id,started_at,ended_at,session_type,session_kind) "
|
||||||
|
"VALUES(?,?,?,?,?)",
|
||||||
|
(
|
||||||
|
SESSION,
|
||||||
|
"2026-09-23T13:00:00+02:00",
|
||||||
|
"2026-09-23T13:10:10+02:00",
|
||||||
|
"training",
|
||||||
|
"cardio",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
session_row = self.db.execute(
|
||||||
|
"SELECT id FROM sessions WHERE session_id=?", (SESSION,)
|
||||||
|
).fetchone()[0]
|
||||||
|
self.db.execute(
|
||||||
|
"INSERT INTO session_exercises("
|
||||||
|
"entry_id,session_row_id,exercise_row_id,recording_mode,tracking_mode,"
|
||||||
|
"data_fields,position,load_mode,rest_seconds) VALUES(?,?,?,?,?,?,?,?,?)",
|
||||||
|
(ENTRY, session_row, exercise_row, "continuous", "duration", 0, 0, "none", 0),
|
||||||
|
)
|
||||||
|
self.db.execute(
|
||||||
|
"INSERT INTO session_exercise_timeline("
|
||||||
|
"session_id,entry_id,exercise_id,started_at,ended_at,imported_at"
|
||||||
|
") VALUES(?,?,?,?,?,?)",
|
||||||
|
(
|
||||||
|
SESSION,
|
||||||
|
ENTRY,
|
||||||
|
EXERCISE,
|
||||||
|
"2026-09-23T13:00:10+02:00",
|
||||||
|
"2026-09-23T13:10:10+02:00",
|
||||||
|
"2026-09-23T13:11:00+02:00",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.db.execute(
|
||||||
|
"INSERT INTO heart_rate_captures("
|
||||||
|
"capture_id,context_kind,context_id,started_at,ended_at,sensor_name,imported_at"
|
||||||
|
") VALUES(?,?,?,?,?,?,?)",
|
||||||
|
(
|
||||||
|
CAPTURE,
|
||||||
|
"cardio",
|
||||||
|
SESSION,
|
||||||
|
"2026-09-23T13:00:00+02:00",
|
||||||
|
"2026-09-23T13:10:10+02:00",
|
||||||
|
"CYCPLUS H2",
|
||||||
|
"2026-09-23T13:11:00+02:00",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.db.executemany(
|
||||||
|
"INSERT INTO heart_rate_samples("
|
||||||
|
"capture_id,sequence,observed_at,bpm,exercise_entry_id,"
|
||||||
|
"sensor_contact_detected,energy_expended) VALUES(?,?,?,?,?,?,?)",
|
||||||
|
[
|
||||||
|
(CAPTURE, 0, "2026-09-23T13:00:23+02:00", 110, ENTRY, None, None),
|
||||||
|
(CAPTURE, 1, "2026-09-23T13:00:40+02:00", 130, ENTRY, None, None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.db.execute(
|
||||||
|
"INSERT INTO cardio_calibrations("
|
||||||
|
"calibration_id,protocol_version,session_id,entry_id,started_at,effort_end_at,"
|
||||||
|
"ended_at,heart_rate_capture_id,observed_peak_bpm,imported_at"
|
||||||
|
") VALUES(?,?,?,?,?,?,?,?,?,?)",
|
||||||
|
(
|
||||||
|
CAL,
|
||||||
|
1,
|
||||||
|
SESSION,
|
||||||
|
ENTRY,
|
||||||
|
"2026-09-23T13:00:00+02:00",
|
||||||
|
"2026-09-23T13:02:00+02:00",
|
||||||
|
"2026-09-23T13:03:00+02:00",
|
||||||
|
CAPTURE,
|
||||||
|
160,
|
||||||
|
"2026-09-23T13:11:00+02:00",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.db.close()
|
||||||
|
self.temp.cleanup()
|
||||||
|
|
||||||
|
def test_import_and_exact_replay_preserve_snapshot_and_events(self):
|
||||||
|
payload = guidance.validate(document())
|
||||||
|
self.assertEqual((1, 0), guidance.apply(self.db, payload))
|
||||||
|
self.assertEqual((0, 1), guidance.apply(self.db, payload))
|
||||||
|
self.assertEqual(
|
||||||
|
(SESSION, "2026-09-23T13:00:20+02:00", "2026-09-23T13:03:20+02:00"),
|
||||||
|
tuple(
|
||||||
|
self.db.execute(
|
||||||
|
"SELECT session_id,started_at,ended_at FROM cardio_guidance_runs WHERE run_id=?",
|
||||||
|
(RUN,),
|
||||||
|
).fetchone()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[(0, "accelerate", 110), (1, "suspended", None), (2, "maintain", 130)],
|
||||||
|
[
|
||||||
|
tuple(row)
|
||||||
|
for row in self.db.execute(
|
||||||
|
"SELECT sequence,instruction,bpm FROM cardio_guidance_events "
|
||||||
|
"WHERE run_id=? AND phase_id=? ORDER BY sequence",
|
||||||
|
(RUN, PHASE),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_calibration_provenance_must_match_imported_profile(self):
|
||||||
|
payload = guidance.validate(document(calibrated=True))
|
||||||
|
self.assertEqual((1, 0), guidance.apply(self.db, payload))
|
||||||
|
changed = deepcopy(document(calibrated=True))
|
||||||
|
changed["runs"][0]["phases"][0]["target"]["calibration_observed_peak_bpm"] = 159
|
||||||
|
with self.assertRaisesRegex(ValueError, "calibration provenance"):
|
||||||
|
guidance.apply(self.db, guidance.validate(changed))
|
||||||
|
|
||||||
|
def test_guidance_bpm_must_exist_in_raw_capture(self):
|
||||||
|
changed = deepcopy(document())
|
||||||
|
changed["runs"][0]["phases"][0]["events"][0]["bpm"] = 109
|
||||||
|
with self.assertRaisesRegex(ValueError, "not a raw heart-rate sample"):
|
||||||
|
guidance.apply(self.db, guidance.validate(changed))
|
||||||
|
|
||||||
|
def test_identity_reuse_with_different_event_is_rejected(self):
|
||||||
|
payload = guidance.validate(document())
|
||||||
|
guidance.apply(self.db, payload)
|
||||||
|
changed = deepcopy(document())
|
||||||
|
changed["runs"][0]["phases"][0]["final_instruction"] = "slow_down"
|
||||||
|
with self.assertRaisesRegex(ValueError, "different content"):
|
||||||
|
guidance.apply(self.db, guidance.validate(changed))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
128
tests/test_cardio_session_exchange.py
Normal file
128
tests/test_cardio_session_exchange.py
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from copy import deepcopy
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT / "tools"))
|
||||||
|
|
||||||
|
import cardio_session_exchange as cardio
|
||||||
|
from trainlog_sqlite import connect_database
|
||||||
|
import import_equipment_definitions
|
||||||
|
|
||||||
|
|
||||||
|
SESSION = "se_11111111-1111-4111-8111-111111111111"
|
||||||
|
EXERCISE = "ex_22222222-2222-4222-8222-222222222222"
|
||||||
|
ENTRY = "sxe_33333333-3333-4333-8333-333333333333"
|
||||||
|
|
||||||
|
|
||||||
|
def document() -> dict:
|
||||||
|
return {
|
||||||
|
"format": "trainlog-cardio-sessions",
|
||||||
|
"version": 1,
|
||||||
|
"generated_at": "2026-09-23T10:00:00+02:00",
|
||||||
|
"exercises": [
|
||||||
|
{
|
||||||
|
"exercise_id": EXERCISE,
|
||||||
|
"name": "Cardio exchange exercise",
|
||||||
|
"recording_mode": "sets",
|
||||||
|
"tracking_mode": "reps",
|
||||||
|
"data_fields": 0,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sessions": [
|
||||||
|
{
|
||||||
|
"session_id": SESSION,
|
||||||
|
"started_at": "2026-09-23T09:00:00+02:00",
|
||||||
|
"ended_at": "2026-09-23T09:30:00+02:00",
|
||||||
|
"session_type": "cardio",
|
||||||
|
"note": None,
|
||||||
|
"exercises": [
|
||||||
|
{
|
||||||
|
"exercise_id": EXERCISE,
|
||||||
|
"name": "Cardio exchange exercise",
|
||||||
|
"recording_mode": "sets",
|
||||||
|
"tracking_mode": "reps",
|
||||||
|
"data_fields": 0,
|
||||||
|
"load_mode": "none",
|
||||||
|
"rest_seconds": 0,
|
||||||
|
"entry_id": ENTRY,
|
||||||
|
"position": 0,
|
||||||
|
"equipment_id": None,
|
||||||
|
"target": None,
|
||||||
|
"note": None,
|
||||||
|
"sets": [{"reps": 12}],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CardioSessionExchangeTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.db_path = Path(self.temp.name) / "trainlog.sqlite"
|
||||||
|
fixture = ROOT / "build/tui/sync-generation-fixture"
|
||||||
|
subprocess.run([str(fixture), str(self.db_path)], check=True, capture_output=True)
|
||||||
|
self.db = connect_database(self.db_path)
|
||||||
|
self.known_equipment = import_equipment_definitions.supplied_ids(
|
||||||
|
ROOT / "catalog/equipment-v1.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.db.close()
|
||||||
|
self.temp.cleanup()
|
||||||
|
|
||||||
|
def test_import_and_exact_replay_keep_legacy_type_frozen(self):
|
||||||
|
payload = cardio.validate(document(), self.known_equipment)
|
||||||
|
first = cardio.apply(self.db, payload, complete_causal_envelope=True)
|
||||||
|
second = cardio.apply(self.db, payload, complete_causal_envelope=True)
|
||||||
|
self.assertEqual(1, first["sessions_imported"])
|
||||||
|
self.assertEqual(0, second["sessions_imported"])
|
||||||
|
self.assertEqual(
|
||||||
|
("training", "cardio"),
|
||||||
|
tuple(
|
||||||
|
self.db.execute(
|
||||||
|
"SELECT session_type,session_kind FROM sessions WHERE session_id=?",
|
||||||
|
(SESSION,),
|
||||||
|
).fetchone()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
(ENTRY, 12),
|
||||||
|
tuple(
|
||||||
|
self.db.execute(
|
||||||
|
"SELECT se.entry_id,ps.reps FROM session_exercises se "
|
||||||
|
"JOIN performed_sets ps ON ps.session_exercise_row_id=se.id "
|
||||||
|
"JOIN sessions s ON s.id=se.session_row_id WHERE s.session_id=?",
|
||||||
|
(SESSION,),
|
||||||
|
).fetchone()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rejects_non_cardio_session_type(self):
|
||||||
|
changed = document()
|
||||||
|
changed["sessions"][0]["session_type"] = "training"
|
||||||
|
with self.assertRaisesRegex(ValueError, "must be cardio"):
|
||||||
|
cardio.validate(changed, self.known_equipment)
|
||||||
|
|
||||||
|
def test_rejects_identity_collision_with_non_cardio_history(self):
|
||||||
|
payload = cardio.validate(document(), self.known_equipment)
|
||||||
|
cardio.apply(self.db, payload, complete_causal_envelope=True)
|
||||||
|
self.db.execute(
|
||||||
|
"UPDATE sessions SET session_kind='training' WHERE session_id=?",
|
||||||
|
(SESSION,),
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(ValueError, "collides with non-cardio"):
|
||||||
|
cardio.apply(self.db, payload, complete_causal_envelope=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
126
tests/test_heart_rate_exchange.py
Normal file
126
tests/test_heart_rate_exchange.py
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Heart Rate V1 companion validation and idempotent desktop import tests."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT / "tools"))
|
||||||
|
import heart_rate_exchange as heart_rate
|
||||||
|
|
||||||
|
|
||||||
|
CAPTURE = "hrc_11111111-1111-4111-8111-111111111111"
|
||||||
|
SESSION = "se_22222222-2222-4222-8222-222222222222"
|
||||||
|
ENTRY = "sxe_33333333-3333-4333-8333-333333333333"
|
||||||
|
|
||||||
|
|
||||||
|
def schema(db: sqlite3.Connection) -> None:
|
||||||
|
db.executescript(
|
||||||
|
"""
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
CREATE TABLE heart_rate_captures(
|
||||||
|
capture_id TEXT PRIMARY KEY,context_kind TEXT,context_id TEXT,
|
||||||
|
started_at TEXT,ended_at TEXT,sensor_name TEXT,imported_at TEXT);
|
||||||
|
CREATE TABLE heart_rate_samples(
|
||||||
|
capture_id TEXT,sequence INTEGER,observed_at TEXT,bpm INTEGER,
|
||||||
|
exercise_entry_id TEXT,sensor_contact_detected INTEGER,
|
||||||
|
energy_expended INTEGER,PRIMARY KEY(capture_id,sequence),
|
||||||
|
FOREIGN KEY(capture_id) REFERENCES heart_rate_captures(capture_id));
|
||||||
|
CREATE TABLE heart_rate_rr_intervals(
|
||||||
|
capture_id TEXT,sample_sequence INTEGER,rr_index INTEGER,value_1024 INTEGER,
|
||||||
|
PRIMARY KEY(capture_id,sample_sequence,rr_index),
|
||||||
|
FOREIGN KEY(capture_id,sample_sequence)
|
||||||
|
REFERENCES heart_rate_samples(capture_id,sequence));
|
||||||
|
PRAGMA user_version=30;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def document() -> dict:
|
||||||
|
return {
|
||||||
|
"format": "trainlog-heart-rate",
|
||||||
|
"version": 1,
|
||||||
|
"generated_at": "2026-09-22T19:00:00+02:00",
|
||||||
|
"captures": [{
|
||||||
|
"capture_id": CAPTURE,
|
||||||
|
"context_kind": "session",
|
||||||
|
"context_id": SESSION,
|
||||||
|
"started_at": "2026-09-22T18:00:00+02:00",
|
||||||
|
"ended_at": "2026-09-22T18:30:00+02:00",
|
||||||
|
"sensor_name": "Synthetic HR",
|
||||||
|
"samples": [{
|
||||||
|
"sequence": 0,
|
||||||
|
"observed_at": "2026-09-22T18:01:00+02:00",
|
||||||
|
"bpm": 92,
|
||||||
|
"exercise_entry_id": ENTRY,
|
||||||
|
"sensor_contact_detected": True,
|
||||||
|
"energy_expended": 12,
|
||||||
|
"rr_intervals_1024": [700, 710],
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class HeartRateExchangeTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.db = sqlite3.connect(Path(self.temp.name) / "heart.sqlite")
|
||||||
|
schema(self.db)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.db.close()
|
||||||
|
self.temp.cleanup()
|
||||||
|
|
||||||
|
def test_import_exact_replay_and_raw_rr(self):
|
||||||
|
payload = heart_rate.validate(document())
|
||||||
|
self.assertEqual((1, 0), heart_rate.apply(self.db, payload))
|
||||||
|
self.assertEqual((0, 1), heart_rate.apply(self.db, payload))
|
||||||
|
self.assertEqual(
|
||||||
|
(92, ENTRY, 1, 12),
|
||||||
|
self.db.execute(
|
||||||
|
"SELECT bpm,exercise_entry_id,sensor_contact_detected,energy_expended "
|
||||||
|
"FROM heart_rate_samples"
|
||||||
|
).fetchone(),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[(700,), (710,)],
|
||||||
|
self.db.execute(
|
||||||
|
"SELECT value_1024 FROM heart_rate_rr_intervals ORDER BY rr_index"
|
||||||
|
).fetchall(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_same_identity_with_different_measurement_is_rejected(self):
|
||||||
|
payload = heart_rate.validate(document())
|
||||||
|
heart_rate.apply(self.db, payload)
|
||||||
|
changed = document()
|
||||||
|
changed["captures"][0]["samples"][0]["bpm"] = 93
|
||||||
|
with self.assertRaisesRegex(ValueError, "different content"):
|
||||||
|
heart_rate.apply(self.db, heart_rate.validate(changed))
|
||||||
|
|
||||||
|
def test_cardio_context_accepts_session_identity_and_exercise_marker(self):
|
||||||
|
cardio = document()
|
||||||
|
cardio["captures"][0]["context_kind"] = "cardio"
|
||||||
|
validated = heart_rate.validate(cardio)
|
||||||
|
self.assertEqual("cardio", validated["captures"][0]["context_kind"])
|
||||||
|
self.assertEqual(ENTRY, validated["captures"][0]["samples"][0]["exercise_entry_id"])
|
||||||
|
|
||||||
|
def test_rejects_sample_outside_capture_and_sleep_exercise_marker(self):
|
||||||
|
outside = document()
|
||||||
|
outside["captures"][0]["samples"][0]["observed_at"] = "2026-09-22T17:59:59+02:00"
|
||||||
|
with self.assertRaisesRegex(ValueError, "outside capture"):
|
||||||
|
heart_rate.validate(outside)
|
||||||
|
|
||||||
|
sleep = document()
|
||||||
|
capture = sleep["captures"][0]
|
||||||
|
capture["context_kind"] = "sleep"
|
||||||
|
capture["context_id"] = "sl_44444444-4444-4444-8444-444444444444"
|
||||||
|
with self.assertRaisesRegex(ValueError, "exercise context"):
|
||||||
|
heart_rate.validate(sleep)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
138
tests/test_session_timeline_exchange.py
Normal file
138
tests/test_session_timeline_exchange.py
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from copy import deepcopy
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT / "tools"))
|
||||||
|
|
||||||
|
import session_timeline_exchange as timeline
|
||||||
|
|
||||||
|
SESSION = "se_11111111-1111-4111-8111-111111111111"
|
||||||
|
ENTRY = "sxe_22222222-2222-4222-8222-222222222222"
|
||||||
|
EXERCISE = "ex_33333333-3333-4333-8333-333333333333"
|
||||||
|
|
||||||
|
|
||||||
|
def schema(db: sqlite3.Connection) -> None:
|
||||||
|
db.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE sessions(
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
session_id TEXT UNIQUE,
|
||||||
|
started_at TEXT,
|
||||||
|
ended_at TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE exercises(
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
exercise_id TEXT UNIQUE
|
||||||
|
);
|
||||||
|
CREATE TABLE session_exercises(
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
session_row_id INTEGER,
|
||||||
|
exercise_row_id INTEGER,
|
||||||
|
entry_id TEXT UNIQUE
|
||||||
|
);
|
||||||
|
CREATE TABLE session_exercise_timeline(
|
||||||
|
session_id TEXT,
|
||||||
|
entry_id TEXT,
|
||||||
|
exercise_id TEXT,
|
||||||
|
started_at TEXT,
|
||||||
|
ended_at TEXT,
|
||||||
|
imported_at TEXT,
|
||||||
|
PRIMARY KEY(session_id,entry_id)
|
||||||
|
);
|
||||||
|
PRAGMA user_version=31;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO sessions(id,session_id,started_at,ended_at) VALUES(1,?,?,?)",
|
||||||
|
(SESSION, "2026-09-23T08:00:00+02:00", "2026-09-23T09:00:00+02:00"),
|
||||||
|
)
|
||||||
|
db.execute("INSERT INTO exercises(id,exercise_id) VALUES(1,?)", (EXERCISE,))
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO session_exercises(id,session_row_id,exercise_row_id,entry_id) "
|
||||||
|
"VALUES(1,1,1,?)",
|
||||||
|
(ENTRY,),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def document() -> dict:
|
||||||
|
return {
|
||||||
|
"format": "trainlog-session-timeline",
|
||||||
|
"version": 1,
|
||||||
|
"generated_at": "2026-09-23T09:01:00+02:00",
|
||||||
|
"sessions": [
|
||||||
|
{
|
||||||
|
"session_id": SESSION,
|
||||||
|
"started_at": "2026-09-23T08:00:00+02:00",
|
||||||
|
"ended_at": "2026-09-23T09:00:00+02:00",
|
||||||
|
"exercises": [
|
||||||
|
{
|
||||||
|
"entry_id": ENTRY,
|
||||||
|
"exercise_id": EXERCISE,
|
||||||
|
"started_at": "2026-09-23T08:10:00+02:00",
|
||||||
|
"ended_at": "2026-09-23T08:20:00+02:00",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SessionTimelineExchangeTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.db = sqlite3.connect(Path(self.temp.name) / "timeline.sqlite")
|
||||||
|
schema(self.db)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.db.close()
|
||||||
|
self.temp.cleanup()
|
||||||
|
|
||||||
|
def test_import_and_exact_replay(self):
|
||||||
|
payload = timeline.validate(document())
|
||||||
|
self.assertEqual((1, 0), timeline.apply(self.db, payload))
|
||||||
|
self.assertEqual((0, 1), timeline.apply(self.db, payload))
|
||||||
|
self.assertEqual(
|
||||||
|
(
|
||||||
|
SESSION,
|
||||||
|
ENTRY,
|
||||||
|
EXERCISE,
|
||||||
|
"2026-09-23T08:10:00+02:00",
|
||||||
|
"2026-09-23T08:20:00+02:00",
|
||||||
|
),
|
||||||
|
self.db.execute(
|
||||||
|
"SELECT session_id,entry_id,exercise_id,started_at,ended_at "
|
||||||
|
"FROM session_exercise_timeline"
|
||||||
|
).fetchone(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rejects_identity_reuse_with_different_content(self):
|
||||||
|
payload = timeline.validate(document())
|
||||||
|
timeline.apply(self.db, payload)
|
||||||
|
changed = deepcopy(document())
|
||||||
|
changed["sessions"][0]["exercises"][0]["ended_at"] = "2026-09-23T08:21:00+02:00"
|
||||||
|
with self.assertRaisesRegex(ValueError, "different content"):
|
||||||
|
timeline.apply(self.db, timeline.validate(changed))
|
||||||
|
|
||||||
|
def test_rejects_occurrence_that_does_not_match_history(self):
|
||||||
|
payload = document()
|
||||||
|
payload["sessions"][0]["exercises"][0]["exercise_id"] = (
|
||||||
|
"ex_44444444-4444-4444-8444-444444444444"
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(ValueError, "does not match session history"):
|
||||||
|
timeline.apply(self.db, timeline.validate(payload))
|
||||||
|
|
||||||
|
def test_rejects_exercise_outside_session_bounds(self):
|
||||||
|
payload = document()
|
||||||
|
payload["sessions"][0]["exercises"][0]["started_at"] = "2026-09-23T07:59:59+02:00"
|
||||||
|
with self.assertRaisesRegex(ValueError, "outside session"):
|
||||||
|
timeline.validate(payload)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -35,7 +35,7 @@ class SleepExchangeTest(unittest.TestCase):
|
||||||
CREATE TABLE sleep_diary_events(revision_id TEXT,event_id TEXT,event_type TEXT,start_at TEXT,end_at TEXT,PRIMARY KEY(revision_id,event_id));
|
CREATE TABLE sleep_diary_events(revision_id TEXT,event_id TEXT,event_type TEXT,start_at TEXT,end_at TEXT,PRIMARY KEY(revision_id,event_id));
|
||||||
CREATE TABLE sleep_medications(medication_id TEXT PRIMARY KEY,created_at TEXT,updated_at TEXT,current_revision_id TEXT,deleted INTEGER);
|
CREATE TABLE sleep_medications(medication_id TEXT PRIMARY KEY,created_at TEXT,updated_at TEXT,current_revision_id TEXT,deleted INTEGER);
|
||||||
CREATE TABLE sleep_medication_revisions(revision_id TEXT PRIMARY KEY,medication_id TEXT,parent_revision_id TEXT,created_at TEXT,name TEXT,default_dose_value REAL,default_dose_unit TEXT,form TEXT,note TEXT,active INTEGER);
|
CREATE TABLE sleep_medication_revisions(revision_id TEXT PRIMARY KEY,medication_id TEXT,parent_revision_id TEXT,created_at TEXT,name TEXT,default_dose_value REAL,default_dose_unit TEXT,form TEXT,note TEXT,active INTEGER);
|
||||||
CREATE TABLE sleep_medication_intakes(revision_id TEXT,intake_id TEXT,medication_id TEXT,medication_name TEXT,taken_at TEXT,dose_value REAL,dose_unit TEXT,note TEXT,created_at TEXT,PRIMARY KEY(revision_id,intake_id));
|
CREATE TABLE sleep_medication_intakes(revision_id TEXT,intake_id TEXT,medication_id TEXT,medication_name TEXT,taken_at TEXT,dose_value REAL,dose_unit TEXT,quantity INTEGER NOT NULL DEFAULT 1 CHECK(quantity BETWEEN 1 AND 99),note TEXT,created_at TEXT,PRIMARY KEY(revision_id,intake_id));
|
||||||
CREATE TABLE sleep_diary_publication_state(entry_id TEXT PRIMARY KEY,validated_revision_id TEXT,validated_at TEXT,acknowledged_revision_id TEXT,acknowledged_at TEXT);
|
CREATE TABLE sleep_diary_publication_state(entry_id TEXT PRIMARY KEY,validated_revision_id TEXT,validated_at TEXT,acknowledged_revision_id TEXT,acknowledged_at TEXT);
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
|
@ -58,6 +58,60 @@ class SleepExchangeTest(unittest.TestCase):
|
||||||
exported = exchange.build(self.db)
|
exported = exchange.build(self.db)
|
||||||
self.assertTrue(exported["entries"][0]["deleted"])
|
self.assertTrue(exported["entries"][0]["deleted"])
|
||||||
|
|
||||||
|
def test_v1_hydrates_quantity_one_and_v2_preserves_quantity(self):
|
||||||
|
first = "slr_20000000-0000-4000-8000-000000000002"
|
||||||
|
legacy = self.document(entry(first))
|
||||||
|
self.assertEqual((1, 0), exchange.apply(self.db, legacy))
|
||||||
|
self.assertEqual(1, self.db.execute(
|
||||||
|
"SELECT quantity FROM sleep_medication_intakes"
|
||||||
|
).fetchone()[0])
|
||||||
|
|
||||||
|
second = "slr_40000000-0000-4000-8000-000000000004"
|
||||||
|
evolved = self.document(entry(second, first))
|
||||||
|
evolved["version"] = 2
|
||||||
|
evolved["entries"][0]["intakes"][0]["quantity"] = 2
|
||||||
|
self.assertEqual((1, 0), exchange.apply(self.db, evolved))
|
||||||
|
self.assertEqual(2, self.db.execute(
|
||||||
|
"SELECT quantity FROM sleep_medication_intakes WHERE revision_id=?",
|
||||||
|
(second,),
|
||||||
|
).fetchone()[0])
|
||||||
|
|
||||||
|
state_before_replay = {
|
||||||
|
"entry": self.db.execute(
|
||||||
|
"SELECT current_revision_id,deleted FROM sleep_diary_entries"
|
||||||
|
).fetchall(),
|
||||||
|
"revisions": self.db.execute(
|
||||||
|
"SELECT revision_id,parent_revision_id FROM sleep_diary_revisions "
|
||||||
|
"ORDER BY revision_id"
|
||||||
|
).fetchall(),
|
||||||
|
"intakes": self.db.execute(
|
||||||
|
"SELECT revision_id,intake_id,quantity FROM sleep_medication_intakes "
|
||||||
|
"ORDER BY revision_id,intake_id"
|
||||||
|
).fetchall(),
|
||||||
|
}
|
||||||
|
self.assertEqual((0, 1), exchange.apply(self.db, evolved))
|
||||||
|
self.assertEqual(state_before_replay, {
|
||||||
|
"entry": self.db.execute(
|
||||||
|
"SELECT current_revision_id,deleted FROM sleep_diary_entries"
|
||||||
|
).fetchall(),
|
||||||
|
"revisions": self.db.execute(
|
||||||
|
"SELECT revision_id,parent_revision_id FROM sleep_diary_revisions "
|
||||||
|
"ORDER BY revision_id"
|
||||||
|
).fetchall(),
|
||||||
|
"intakes": self.db.execute(
|
||||||
|
"SELECT revision_id,intake_id,quantity FROM sleep_medication_intakes "
|
||||||
|
"ORDER BY revision_id,intake_id"
|
||||||
|
).fetchall(),
|
||||||
|
})
|
||||||
|
self.assertEqual(2, self.db.execute(
|
||||||
|
"SELECT COUNT(*) FROM sleep_diary_revisions"
|
||||||
|
).fetchone()[0])
|
||||||
|
self.assertEqual((1, 2), self.db.execute(
|
||||||
|
"SELECT COUNT(*),MIN(quantity) FROM sleep_medication_intakes "
|
||||||
|
"WHERE revision_id=?",
|
||||||
|
(second,),
|
||||||
|
).fetchone())
|
||||||
|
|
||||||
def test_stale_ancestor_snapshot_does_not_regress_newer_tip(self):
|
def test_stale_ancestor_snapshot_does_not_regress_newer_tip(self):
|
||||||
first = "slr_20000000-0000-4000-8000-000000000002"
|
first = "slr_20000000-0000-4000-8000-000000000002"
|
||||||
second = "slr_40000000-0000-4000-8000-000000000004"
|
second = "slr_40000000-0000-4000-8000-000000000004"
|
||||||
|
|
|
||||||
|
|
@ -65,14 +65,20 @@ class DeploymentToolsTest(unittest.TestCase):
|
||||||
)
|
)
|
||||||
self.assertEqual(result.returncode, 0, result.stderr)
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
inventory = json.loads((output / "candidate-inventory.json").read_text())
|
inventory = json.loads((output / "candidate-inventory.json").read_text())
|
||||||
self.assertEqual(inventory["product_version"], "0.1.4")
|
self.assertEqual(inventory["product_version"], "0.1.5")
|
||||||
self.assertEqual(inventory["desktop_schema"], 29)
|
self.assertEqual(inventory["desktop_schema"], 35)
|
||||||
self.assertEqual(inventory["android_schema"], 26)
|
self.assertEqual(inventory["android_schema"], 32)
|
||||||
self.assertIn("session-preparations-v2", inventory["protocols"])
|
self.assertIn("session-preparations-v2", inventory["protocols"])
|
||||||
self.assertIn("programs-v1", inventory["protocols"])
|
self.assertIn("programs-v1", inventory["protocols"])
|
||||||
self.assertIn("program-executions-v1", inventory["protocols"])
|
self.assertIn("program-executions-v1", inventory["protocols"])
|
||||||
self.assertIn("sleep-diary-v1", inventory["protocols"])
|
self.assertIn("sleep-diary-v2", inventory["protocols"])
|
||||||
|
self.assertIn("cardio-sessions-v1", inventory["protocols"])
|
||||||
|
self.assertIn("cardio-calibrations-v1", inventory["protocols"])
|
||||||
|
self.assertIn("cardio-guidance-v1", inventory["protocols"])
|
||||||
|
self.assertIn("heart-rate-v1", inventory["protocols"])
|
||||||
|
self.assertIn("session-timeline-v1", inventory["protocols"])
|
||||||
self.assertIn("bluetooth-files-v1", inventory["protocols"])
|
self.assertIn("bluetooth-files-v1", inventory["protocols"])
|
||||||
|
self.assertIn("generation-ack-recovery-v1", inventory["protocols"])
|
||||||
self.assertTrue((output / "bin/trainlog").is_file())
|
self.assertTrue((output / "bin/trainlog").is_file())
|
||||||
self.assertTrue((output / "bin/trainlog-sync-once").is_file())
|
self.assertTrue((output / "bin/trainlog-sync-once").is_file())
|
||||||
self.assertTrue((output / "bin/trainlog-syncd").is_file())
|
self.assertTrue((output / "bin/trainlog-syncd").is_file())
|
||||||
|
|
@ -96,7 +102,7 @@ class DeploymentToolsTest(unittest.TestCase):
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
self.assertEqual(linked.returncode, 0, linked.stderr)
|
self.assertEqual(linked.returncode, 0, linked.stderr)
|
||||||
self.assertIn("trainlog 0.1.4", linked.stdout)
|
self.assertIn("trainlog 0.1.5", linked.stdout)
|
||||||
imported = subprocess.run(
|
imported = subprocess.run(
|
||||||
[
|
[
|
||||||
"python3",
|
"python3",
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,8 @@ class DriveTransportTest(unittest.TestCase):
|
||||||
source = Path(raw) / "source"
|
source = Path(raw) / "source"
|
||||||
source.mkdir()
|
source.mkdir()
|
||||||
self.generation(source)
|
self.generation(source)
|
||||||
|
recovery = source / "android-archive-acknowledgements-v1.json"
|
||||||
|
recovery.write_text('{"format":"trainlog-sync-archive-acknowledgements","version":1}')
|
||||||
remote = MemoryDrive()
|
remote = MemoryDrive()
|
||||||
drive.push(remote, source)
|
drive.push(remote, source)
|
||||||
destination = Path(raw) / "destination"
|
destination = Path(raw) / "destination"
|
||||||
|
|
@ -124,6 +126,10 @@ class DriveTransportTest(unittest.TestCase):
|
||||||
(destination / "desktop-objects/generations/gen_one/history.json").read_bytes(),
|
(destination / "desktop-objects/generations/gen_one/history.json").read_bytes(),
|
||||||
b"{}\n",
|
b"{}\n",
|
||||||
)
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
(destination / "android-archive-acknowledgements-v1.json").read_bytes(),
|
||||||
|
recovery.read_bytes(),
|
||||||
|
)
|
||||||
|
|
||||||
remote.downloads.clear()
|
remote.downloads.clear()
|
||||||
drive.pull(remote, destination)
|
drive.pull(remote, destination)
|
||||||
|
|
|
||||||
282
tools/cardio_calibration_exchange.py
Normal file
282
tools/cardio_calibration_exchange.py
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Validate/import completed Android cardio calibration profiles V1."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from trainlog_sqlite import connect_database
|
||||||
|
|
||||||
|
FORMAT = "trainlog-cardio-calibrations"
|
||||||
|
VERSION = 1
|
||||||
|
MAX_BYTES = 4 * 1024 * 1024
|
||||||
|
MAX_CALIBRATIONS = 128
|
||||||
|
UUID4 = r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"
|
||||||
|
CALIBRATION_ID = re.compile(rf"^cal_{UUID4}$")
|
||||||
|
SESSION_ID = re.compile(rf"^se_{UUID4}$")
|
||||||
|
ENTRY_ID = re.compile(rf"^sxe_{UUID4}$")
|
||||||
|
CAPTURE_ID = re.compile(rf"^hrc_{UUID4}$")
|
||||||
|
CALIBRATION_EXERCISE_ID = "ex_ca1b4a7e-1c2d-4f00-8a11-000000000001"
|
||||||
|
RECOVERY_OFFSETS = (60, 120, 180)
|
||||||
|
|
||||||
|
|
||||||
|
class CardioCalibrationError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message: str) -> None:
|
||||||
|
raise CardioCalibrationError(message)
|
||||||
|
|
||||||
|
|
||||||
|
def unique_object(pairs):
|
||||||
|
result = {}
|
||||||
|
for key, value in pairs:
|
||||||
|
if key in result:
|
||||||
|
fail("duplicate JSON field: " + key)
|
||||||
|
result[key] = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def timestamp(value: object) -> tuple[str, datetime]:
|
||||||
|
if not isinstance(value, str) or re.search(r"(?:Z|[+-][0-9]{2}:[0-9]{2})$", value) is None:
|
||||||
|
fail("invalid cardio calibration timestamp")
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
except ValueError as error:
|
||||||
|
raise CardioCalibrationError("invalid cardio calibration timestamp") from error
|
||||||
|
if parsed.utcoffset() is None:
|
||||||
|
fail("cardio calibration timestamp lacks offset")
|
||||||
|
return value, parsed
|
||||||
|
|
||||||
|
|
||||||
|
def validate(root: object) -> dict:
|
||||||
|
if not isinstance(root, dict) or set(root) != {
|
||||||
|
"format", "version", "generated_at", "calibrations"
|
||||||
|
}:
|
||||||
|
fail("invalid cardio calibration envelope")
|
||||||
|
if root["format"] != FORMAT or type(root["version"]) is not int or root["version"] != VERSION:
|
||||||
|
fail("unsupported cardio calibration format")
|
||||||
|
timestamp(root["generated_at"])
|
||||||
|
calibrations = root["calibrations"]
|
||||||
|
if not isinstance(calibrations, list) or len(calibrations) > MAX_CALIBRATIONS:
|
||||||
|
fail("cardio calibration bound exceeded")
|
||||||
|
seen = set()
|
||||||
|
for item in calibrations:
|
||||||
|
keys = {
|
||||||
|
"calibration_id", "protocol_version", "session_id", "entry_id",
|
||||||
|
"started_at", "effort_end_at", "ended_at", "heart_rate_capture_id",
|
||||||
|
"observed_peak_bpm", "recovery",
|
||||||
|
}
|
||||||
|
if not isinstance(item, dict) or set(item) != keys:
|
||||||
|
fail("invalid cardio calibration object")
|
||||||
|
calibration_id = item["calibration_id"]
|
||||||
|
if (
|
||||||
|
not isinstance(calibration_id, str)
|
||||||
|
or not CALIBRATION_ID.fullmatch(calibration_id)
|
||||||
|
or calibration_id in seen
|
||||||
|
):
|
||||||
|
fail("invalid or duplicate cardio calibration identity")
|
||||||
|
seen.add(calibration_id)
|
||||||
|
if type(item["protocol_version"]) is not int or item["protocol_version"] != 1:
|
||||||
|
fail("unsupported cardio calibration protocol")
|
||||||
|
if not isinstance(item["session_id"], str) or not SESSION_ID.fullmatch(item["session_id"]):
|
||||||
|
fail("invalid cardio calibration session identity")
|
||||||
|
if not isinstance(item["entry_id"], str) or not ENTRY_ID.fullmatch(item["entry_id"]):
|
||||||
|
fail("invalid cardio calibration entry identity")
|
||||||
|
if (
|
||||||
|
not isinstance(item["heart_rate_capture_id"], str)
|
||||||
|
or not CAPTURE_ID.fullmatch(item["heart_rate_capture_id"])
|
||||||
|
):
|
||||||
|
fail("invalid cardio calibration capture identity")
|
||||||
|
_, started = timestamp(item["started_at"])
|
||||||
|
_, effort_end = timestamp(item["effort_end_at"])
|
||||||
|
_, ended = timestamp(item["ended_at"])
|
||||||
|
if not started <= effort_end <= ended:
|
||||||
|
fail("invalid cardio calibration interval")
|
||||||
|
if type(item["observed_peak_bpm"]) is not int or not 0 <= item["observed_peak_bpm"] <= 65535:
|
||||||
|
fail("invalid observed peak BPM")
|
||||||
|
recovery = item["recovery"]
|
||||||
|
if not isinstance(recovery, list) or len(recovery) > 3:
|
||||||
|
fail("invalid cardio calibration recovery")
|
||||||
|
offsets = []
|
||||||
|
for point in recovery:
|
||||||
|
if not isinstance(point, dict) or set(point) != {
|
||||||
|
"target_offset_seconds", "observed_at", "bpm"
|
||||||
|
}:
|
||||||
|
fail("invalid recovery point")
|
||||||
|
offset = point["target_offset_seconds"]
|
||||||
|
if type(offset) is not int or offset not in RECOVERY_OFFSETS or offset in offsets:
|
||||||
|
fail("invalid recovery offset")
|
||||||
|
offsets.append(offset)
|
||||||
|
_, observed = timestamp(point["observed_at"])
|
||||||
|
if observed < effort_end + timedelta(seconds=offset) or observed > ended:
|
||||||
|
fail("recovery point outside factual interval")
|
||||||
|
if type(point["bpm"]) is not int or not 0 <= point["bpm"] <= 65535:
|
||||||
|
fail("invalid recovery BPM")
|
||||||
|
if offsets != sorted(offsets):
|
||||||
|
fail("recovery points must be ordered")
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def load(path: Path) -> dict:
|
||||||
|
if path.stat().st_size > MAX_BYTES:
|
||||||
|
fail("cardio calibration artifact exceeds 4 MiB")
|
||||||
|
try:
|
||||||
|
return validate(
|
||||||
|
json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_object)
|
||||||
|
)
|
||||||
|
except OSError as error:
|
||||||
|
raise CardioCalibrationError("cannot read cardio calibration artifact") from error
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_source_facts(db: sqlite3.Connection, item: dict) -> None:
|
||||||
|
session = db.execute(
|
||||||
|
"SELECT id,started_at,ended_at,session_kind FROM sessions WHERE session_id=?",
|
||||||
|
(item["session_id"],),
|
||||||
|
).fetchone()
|
||||||
|
if session is None or session[3] != "cardio":
|
||||||
|
fail("calibration session is missing or not cardio")
|
||||||
|
if session[1] != item["started_at"] or session[2] != item["ended_at"]:
|
||||||
|
fail("calibration session bounds differ from canonical history")
|
||||||
|
|
||||||
|
occurrence = db.execute(
|
||||||
|
"SELECT e.exercise_id FROM session_exercises se "
|
||||||
|
"JOIN exercises e ON e.id=se.exercise_row_id "
|
||||||
|
"WHERE se.session_row_id=? AND se.entry_id=?",
|
||||||
|
(session[0], item["entry_id"]),
|
||||||
|
).fetchone()
|
||||||
|
if occurrence is None or occurrence[0] != CALIBRATION_EXERCISE_ID:
|
||||||
|
fail("calibration occurrence does not match reserved exercise")
|
||||||
|
|
||||||
|
capture = db.execute(
|
||||||
|
"SELECT context_kind,context_id,started_at,ended_at FROM heart_rate_captures "
|
||||||
|
"WHERE capture_id=?",
|
||||||
|
(item["heart_rate_capture_id"],),
|
||||||
|
).fetchone()
|
||||||
|
if (
|
||||||
|
capture is None
|
||||||
|
or capture[0] != "cardio"
|
||||||
|
or capture[1] != item["session_id"]
|
||||||
|
or capture[2] != item["started_at"]
|
||||||
|
or capture[3] != item["ended_at"]
|
||||||
|
):
|
||||||
|
fail("calibration heart-rate capture does not match session")
|
||||||
|
|
||||||
|
_, effort_end = timestamp(item["effort_end_at"])
|
||||||
|
samples = db.execute(
|
||||||
|
"SELECT observed_at,bpm FROM heart_rate_samples WHERE capture_id=? ORDER BY sequence",
|
||||||
|
(item["heart_rate_capture_id"],),
|
||||||
|
).fetchall()
|
||||||
|
before_effort_end = [
|
||||||
|
bpm
|
||||||
|
for observed_at, bpm in samples
|
||||||
|
if timestamp(observed_at)[1] <= effort_end
|
||||||
|
]
|
||||||
|
if not before_effort_end or max(before_effort_end) != item["observed_peak_bpm"]:
|
||||||
|
fail("observed peak BPM does not match raw capture")
|
||||||
|
|
||||||
|
sample_pairs = {(observed_at, bpm) for observed_at, bpm in samples}
|
||||||
|
for point in item["recovery"]:
|
||||||
|
if (point["observed_at"], point["bpm"]) not in sample_pairs:
|
||||||
|
fail("recovery point is not a raw heart-rate sample")
|
||||||
|
|
||||||
|
|
||||||
|
def _same(db: sqlite3.Connection, item: dict) -> bool:
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT protocol_version,session_id,entry_id,started_at,effort_end_at,ended_at,"
|
||||||
|
"heart_rate_capture_id,observed_peak_bpm FROM cardio_calibrations "
|
||||||
|
"WHERE calibration_id=?",
|
||||||
|
(item["calibration_id"],),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return False
|
||||||
|
expected = (
|
||||||
|
item["protocol_version"],
|
||||||
|
item["session_id"],
|
||||||
|
item["entry_id"],
|
||||||
|
item["started_at"],
|
||||||
|
item["effort_end_at"],
|
||||||
|
item["ended_at"],
|
||||||
|
item["heart_rate_capture_id"],
|
||||||
|
item["observed_peak_bpm"],
|
||||||
|
)
|
||||||
|
if tuple(row) != expected:
|
||||||
|
fail("calibration identity reused with different metadata")
|
||||||
|
stored = [
|
||||||
|
tuple(row)
|
||||||
|
for row in db.execute(
|
||||||
|
"SELECT target_offset_seconds,observed_at,bpm FROM cardio_calibration_recovery "
|
||||||
|
"WHERE calibration_id=? ORDER BY target_offset_seconds",
|
||||||
|
(item["calibration_id"],),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
incoming = [
|
||||||
|
(p["target_offset_seconds"], p["observed_at"], p["bpm"])
|
||||||
|
for p in item["recovery"]
|
||||||
|
]
|
||||||
|
if stored != incoming:
|
||||||
|
fail("calibration identity reused with different recovery")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]:
|
||||||
|
applied = unchanged = 0
|
||||||
|
for item in root["calibrations"]:
|
||||||
|
_verify_source_facts(db, item)
|
||||||
|
if _same(db, item):
|
||||||
|
unchanged += 1
|
||||||
|
continue
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO cardio_calibrations("
|
||||||
|
"calibration_id,protocol_version,session_id,entry_id,started_at,effort_end_at,"
|
||||||
|
"ended_at,heart_rate_capture_id,observed_peak_bpm,imported_at"
|
||||||
|
") VALUES(?,?,?,?,?,?,?,?,?,?)",
|
||||||
|
(
|
||||||
|
item["calibration_id"],
|
||||||
|
item["protocol_version"],
|
||||||
|
item["session_id"],
|
||||||
|
item["entry_id"],
|
||||||
|
item["started_at"],
|
||||||
|
item["effort_end_at"],
|
||||||
|
item["ended_at"],
|
||||||
|
item["heart_rate_capture_id"],
|
||||||
|
item["observed_peak_bpm"],
|
||||||
|
root["generated_at"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
db.executemany(
|
||||||
|
"INSERT INTO cardio_calibration_recovery("
|
||||||
|
"calibration_id,target_offset_seconds,observed_at,bpm) VALUES(?,?,?,?)",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
item["calibration_id"],
|
||||||
|
point["target_offset_seconds"],
|
||||||
|
point["observed_at"],
|
||||||
|
point["bpm"],
|
||||||
|
)
|
||||||
|
for point in item["recovery"]
|
||||||
|
],
|
||||||
|
)
|
||||||
|
applied += 1
|
||||||
|
return applied, unchanged
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("input", type=Path)
|
||||||
|
parser.add_argument("--database", required=True, type=Path)
|
||||||
|
args = parser.parse_args()
|
||||||
|
root = load(args.input)
|
||||||
|
with connect_database(args.database) as db:
|
||||||
|
if db.execute("PRAGMA user_version").fetchone()[0] not in (33, 34, 35):
|
||||||
|
fail("desktop schema v33-v35 required")
|
||||||
|
db.execute("BEGIN IMMEDIATE")
|
||||||
|
apply(db, root)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
406
tools/cardio_guidance_exchange.py
Normal file
406
tools/cardio_guidance_exchange.py
Normal file
|
|
@ -0,0 +1,406 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Validate/import Android-owned guided-cardio control history."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from trainlog_sqlite import connect_database
|
||||||
|
|
||||||
|
FORMAT = "trainlog-cardio-guidance"
|
||||||
|
VERSION = 1
|
||||||
|
MAX_BYTES = 8 * 1024 * 1024
|
||||||
|
MAX_RUNS = 128
|
||||||
|
MAX_PHASES = 128
|
||||||
|
MAX_EVENTS = 4096
|
||||||
|
|
||||||
|
UUID4 = r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"
|
||||||
|
RUN_ID = re.compile(rf"^cgr_{UUID4}$")
|
||||||
|
PHASE_ID = re.compile(rf"^cgp_{UUID4}$")
|
||||||
|
SESSION_ID = re.compile(rf"^se_{UUID4}$")
|
||||||
|
ENTRY_ID = re.compile(rf"^sxe_{UUID4}$")
|
||||||
|
CALIBRATION_ID = re.compile(rf"^cal_{UUID4}$")
|
||||||
|
KINDS = {"warmup", "work", "recovery", "cooldown"}
|
||||||
|
INSTRUCTIONS = {"accelerate", "maintain", "slow_down", "suspended"}
|
||||||
|
EXIT_KINDS = {"fixed_duration", "enter_target", "recover_below", "duration_or_recover"}
|
||||||
|
|
||||||
|
|
||||||
|
class CardioGuidanceError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message: str) -> None:
|
||||||
|
raise CardioGuidanceError(message)
|
||||||
|
|
||||||
|
|
||||||
|
def unique_object(pairs):
|
||||||
|
result = {}
|
||||||
|
for key, value in pairs:
|
||||||
|
if key in result:
|
||||||
|
fail("duplicate cardio guidance JSON field: " + key)
|
||||||
|
result[key] = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def timestamp(value: object) -> tuple[str, datetime]:
|
||||||
|
if not isinstance(value, str) or re.search(r"(?:Z|[+-][0-9]{2}:[0-9]{2})$", value) is None:
|
||||||
|
fail("invalid cardio guidance timestamp")
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
except ValueError as error:
|
||||||
|
raise CardioGuidanceError("invalid cardio guidance timestamp") from error
|
||||||
|
if parsed.utcoffset() is None:
|
||||||
|
fail("cardio guidance timestamp lacks offset")
|
||||||
|
return value, parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _target(value: object) -> dict | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
keys = {
|
||||||
|
"minimum_bpm", "maximum_bpm", "calibration_id",
|
||||||
|
"calibration_observed_peak_bpm", "minimum_percent", "maximum_percent",
|
||||||
|
}
|
||||||
|
if not isinstance(value, dict) or set(value) != keys:
|
||||||
|
fail("invalid cardio guidance target")
|
||||||
|
minimum = value["minimum_bpm"]
|
||||||
|
maximum = value["maximum_bpm"]
|
||||||
|
if type(minimum) is not int or type(maximum) is not int or not 1 <= minimum <= maximum <= 65535:
|
||||||
|
fail("invalid cardio guidance BPM target")
|
||||||
|
provenance = (
|
||||||
|
value["calibration_id"],
|
||||||
|
value["calibration_observed_peak_bpm"],
|
||||||
|
value["minimum_percent"],
|
||||||
|
value["maximum_percent"],
|
||||||
|
)
|
||||||
|
if all(item is None for item in provenance):
|
||||||
|
return value
|
||||||
|
if any(item is None for item in provenance):
|
||||||
|
fail("incomplete cardio calibration target provenance")
|
||||||
|
calibration_id, peak, min_percent, max_percent = provenance
|
||||||
|
if not isinstance(calibration_id, str) or not CALIBRATION_ID.fullmatch(calibration_id):
|
||||||
|
fail("invalid cardio calibration target identity")
|
||||||
|
if (
|
||||||
|
type(peak) is not int or not 1 <= peak <= 65535
|
||||||
|
or type(min_percent) is not int or type(max_percent) is not int
|
||||||
|
or not 1 <= min_percent <= max_percent <= 100
|
||||||
|
):
|
||||||
|
fail("invalid cardio calibration target provenance")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _exit(value: object, target: dict | None) -> dict:
|
||||||
|
if not isinstance(value, dict) or set(value) != {"kind", "seconds", "bpm"}:
|
||||||
|
fail("invalid cardio phase exit condition")
|
||||||
|
kind = value["kind"]
|
||||||
|
seconds = value["seconds"]
|
||||||
|
bpm = value["bpm"]
|
||||||
|
if kind not in EXIT_KINDS:
|
||||||
|
fail("invalid cardio phase exit kind")
|
||||||
|
if kind == "fixed_duration":
|
||||||
|
if type(seconds) is not int or not 1 <= seconds <= 86400 or bpm is not None:
|
||||||
|
fail("invalid fixed cardio duration")
|
||||||
|
elif kind == "enter_target":
|
||||||
|
if target is None or seconds is not None or bpm is not None:
|
||||||
|
fail("invalid enter-target condition")
|
||||||
|
elif kind == "recover_below":
|
||||||
|
if seconds is not None or type(bpm) is not int or not 1 <= bpm <= 65535:
|
||||||
|
fail("invalid cardio recovery threshold")
|
||||||
|
else:
|
||||||
|
if (
|
||||||
|
type(seconds) is not int or not 1 <= seconds <= 86400
|
||||||
|
or type(bpm) is not int or not 1 <= bpm <= 65535
|
||||||
|
):
|
||||||
|
fail("invalid bounded cardio recovery")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def validate(root: object) -> dict:
|
||||||
|
if not isinstance(root, dict) or set(root) != {"format", "version", "generated_at", "runs"}:
|
||||||
|
fail("invalid cardio guidance envelope")
|
||||||
|
if root["format"] != FORMAT or type(root["version"]) is not int or root["version"] != VERSION:
|
||||||
|
fail("unsupported cardio guidance format")
|
||||||
|
timestamp(root["generated_at"])
|
||||||
|
runs = root["runs"]
|
||||||
|
if not isinstance(runs, list) or len(runs) > MAX_RUNS:
|
||||||
|
fail("cardio guidance run bound exceeded")
|
||||||
|
seen_runs = set()
|
||||||
|
seen_sessions = set()
|
||||||
|
seen_phases = set()
|
||||||
|
for run in runs:
|
||||||
|
if not isinstance(run, dict) or set(run) != {
|
||||||
|
"run_id", "session_id", "started_at", "ended_at", "phases"
|
||||||
|
}:
|
||||||
|
fail("invalid cardio guidance run")
|
||||||
|
if (
|
||||||
|
not isinstance(run["run_id"], str) or not RUN_ID.fullmatch(run["run_id"])
|
||||||
|
or run["run_id"] in seen_runs
|
||||||
|
):
|
||||||
|
fail("invalid or duplicate cardio guidance run identity")
|
||||||
|
seen_runs.add(run["run_id"])
|
||||||
|
if (
|
||||||
|
not isinstance(run["session_id"], str)
|
||||||
|
or not SESSION_ID.fullmatch(run["session_id"])
|
||||||
|
or run["session_id"] in seen_sessions
|
||||||
|
):
|
||||||
|
fail("invalid or duplicate cardio guidance session identity")
|
||||||
|
seen_sessions.add(run["session_id"])
|
||||||
|
_, run_start = timestamp(run["started_at"])
|
||||||
|
_, run_end = timestamp(run["ended_at"])
|
||||||
|
if run_end < run_start:
|
||||||
|
fail("invalid cardio guidance run interval")
|
||||||
|
phases = run["phases"]
|
||||||
|
if not isinstance(phases, list) or not phases or len(phases) > MAX_PHASES:
|
||||||
|
fail("invalid cardio guidance phases")
|
||||||
|
positions = []
|
||||||
|
for phase in phases:
|
||||||
|
keys = {
|
||||||
|
"phase_id", "entry_id", "position", "kind", "target",
|
||||||
|
"exit_condition", "started_at", "ended_at", "final_instruction", "events",
|
||||||
|
}
|
||||||
|
if not isinstance(phase, dict) or set(phase) != keys:
|
||||||
|
fail("invalid cardio guidance phase")
|
||||||
|
if (
|
||||||
|
not isinstance(phase["phase_id"], str) or not PHASE_ID.fullmatch(phase["phase_id"])
|
||||||
|
or phase["phase_id"] in seen_phases
|
||||||
|
):
|
||||||
|
fail("invalid or duplicate cardio guidance phase identity")
|
||||||
|
seen_phases.add(phase["phase_id"])
|
||||||
|
if not isinstance(phase["entry_id"], str) or not ENTRY_ID.fullmatch(phase["entry_id"]):
|
||||||
|
fail("invalid cardio guidance entry identity")
|
||||||
|
if type(phase["position"]) is not int or phase["position"] < 0 or phase["position"] in positions:
|
||||||
|
fail("invalid cardio guidance phase position")
|
||||||
|
positions.append(phase["position"])
|
||||||
|
if phase["kind"] not in KINDS:
|
||||||
|
fail("invalid cardio phase kind")
|
||||||
|
target = _target(phase["target"])
|
||||||
|
_exit(phase["exit_condition"], target)
|
||||||
|
_, phase_start = timestamp(phase["started_at"])
|
||||||
|
_, phase_end = timestamp(phase["ended_at"])
|
||||||
|
if not run_start <= phase_start <= phase_end <= run_end:
|
||||||
|
fail("cardio phase outside run")
|
||||||
|
if phase["final_instruction"] not in INSTRUCTIONS:
|
||||||
|
fail("invalid final cardio instruction")
|
||||||
|
events = phase["events"]
|
||||||
|
if not isinstance(events, list) or len(events) > MAX_EVENTS:
|
||||||
|
fail("cardio guidance event bound exceeded")
|
||||||
|
for expected, event in enumerate(events):
|
||||||
|
if not isinstance(event, dict) or set(event) != {
|
||||||
|
"sequence", "observed_at", "instruction", "bpm",
|
||||||
|
"target_minimum_bpm", "target_maximum_bpm",
|
||||||
|
}:
|
||||||
|
fail("invalid cardio guidance event")
|
||||||
|
if type(event["sequence"]) is not int or event["sequence"] != expected:
|
||||||
|
fail("invalid cardio guidance event sequence")
|
||||||
|
_, observed = timestamp(event["observed_at"])
|
||||||
|
if not phase_start <= observed <= phase_end:
|
||||||
|
fail("cardio guidance event outside phase")
|
||||||
|
instruction = event["instruction"]
|
||||||
|
if instruction not in INSTRUCTIONS:
|
||||||
|
fail("invalid cardio guidance instruction")
|
||||||
|
bpm = event["bpm"]
|
||||||
|
if instruction == "suspended":
|
||||||
|
if bpm is not None:
|
||||||
|
fail("suspended guidance cannot contain BPM")
|
||||||
|
elif type(bpm) is not int or not 1 <= bpm <= 65535:
|
||||||
|
fail("active guidance requires BPM")
|
||||||
|
target_min = event["target_minimum_bpm"]
|
||||||
|
target_max = event["target_maximum_bpm"]
|
||||||
|
if target is None:
|
||||||
|
if target_min is not None or target_max is not None:
|
||||||
|
fail("guidance event unexpectedly carries target")
|
||||||
|
elif target_min != target["minimum_bpm"] or target_max != target["maximum_bpm"]:
|
||||||
|
fail("guidance event target differs from phase snapshot")
|
||||||
|
if positions != sorted(positions):
|
||||||
|
fail("cardio guidance phases must be ordered")
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def load(path: Path) -> dict:
|
||||||
|
if path.stat().st_size > MAX_BYTES:
|
||||||
|
fail("cardio guidance artifact exceeds 8 MiB")
|
||||||
|
return validate(json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_object))
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_source_facts(db: sqlite3.Connection, run: dict) -> None:
|
||||||
|
session = db.execute(
|
||||||
|
"SELECT id,started_at,ended_at,session_kind FROM sessions WHERE session_id=?",
|
||||||
|
(run["session_id"],),
|
||||||
|
).fetchone()
|
||||||
|
if session is None or session[3] != "cardio":
|
||||||
|
fail("guided session is missing or not cardio")
|
||||||
|
_, session_start = timestamp(session[1])
|
||||||
|
_, session_end = timestamp(session[2])
|
||||||
|
_, run_start = timestamp(run["started_at"])
|
||||||
|
_, run_end = timestamp(run["ended_at"])
|
||||||
|
if not session_start <= run_start <= run_end <= session_end:
|
||||||
|
fail("guided run outside canonical cardio session")
|
||||||
|
|
||||||
|
for phase in run["phases"]:
|
||||||
|
occurrence = db.execute(
|
||||||
|
"SELECT 1 FROM session_exercises se WHERE se.session_row_id=? AND se.entry_id=?",
|
||||||
|
(session[0], phase["entry_id"]),
|
||||||
|
).fetchone()
|
||||||
|
if occurrence is None:
|
||||||
|
fail("guided phase occurrence is missing")
|
||||||
|
timing = db.execute(
|
||||||
|
"SELECT started_at,ended_at FROM session_exercise_timeline "
|
||||||
|
"WHERE session_id=? AND entry_id=?",
|
||||||
|
(run["session_id"], phase["entry_id"]),
|
||||||
|
).fetchone()
|
||||||
|
if timing is None:
|
||||||
|
fail("guided phase exercise timeline is missing")
|
||||||
|
_, exercise_start = timestamp(timing[0])
|
||||||
|
_, exercise_end = timestamp(timing[1])
|
||||||
|
_, phase_start = timestamp(phase["started_at"])
|
||||||
|
_, phase_end = timestamp(phase["ended_at"])
|
||||||
|
if not exercise_start <= phase_start <= phase_end <= exercise_end:
|
||||||
|
fail("guided phase outside exercise timeline")
|
||||||
|
|
||||||
|
target = phase["target"]
|
||||||
|
if target is not None and target["calibration_id"] is not None:
|
||||||
|
calibration = db.execute(
|
||||||
|
"SELECT observed_peak_bpm FROM cardio_calibrations WHERE calibration_id=?",
|
||||||
|
(target["calibration_id"],),
|
||||||
|
).fetchone()
|
||||||
|
if calibration is None or calibration[0] != target["calibration_observed_peak_bpm"]:
|
||||||
|
fail("guided phase calibration provenance is unavailable")
|
||||||
|
|
||||||
|
for event in phase["events"]:
|
||||||
|
if event["bpm"] is None:
|
||||||
|
continue
|
||||||
|
exact = db.execute(
|
||||||
|
"SELECT 1 FROM heart_rate_samples s JOIN heart_rate_captures c "
|
||||||
|
"ON c.capture_id=s.capture_id "
|
||||||
|
"WHERE c.context_kind='cardio' AND c.context_id=? "
|
||||||
|
"AND s.observed_at=? AND s.bpm=? LIMIT 1",
|
||||||
|
(run["session_id"], event["observed_at"], event["bpm"]),
|
||||||
|
).fetchone()
|
||||||
|
if exact is None:
|
||||||
|
fail("guided instruction BPM is not a raw heart-rate sample")
|
||||||
|
|
||||||
|
|
||||||
|
def _same(db: sqlite3.Connection, run: dict) -> bool:
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT session_id,started_at,ended_at FROM cardio_guidance_runs WHERE run_id=?",
|
||||||
|
(run["run_id"],),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return False
|
||||||
|
if tuple(row) != (run["session_id"], run["started_at"], run["ended_at"]):
|
||||||
|
fail("cardio guidance run identity reused with different metadata")
|
||||||
|
stored_phases = db.execute(
|
||||||
|
"SELECT phase_id,entry_id,position,kind,target_min_bpm,target_max_bpm,"
|
||||||
|
"calibration_id,calibration_observed_peak_bpm,minimum_percent,maximum_percent,"
|
||||||
|
"exit_kind,exit_seconds,exit_bpm,started_at,ended_at,final_instruction "
|
||||||
|
"FROM cardio_guidance_phases WHERE run_id=? ORDER BY position,phase_id",
|
||||||
|
(run["run_id"],),
|
||||||
|
).fetchall()
|
||||||
|
if len(stored_phases) != len(run["phases"]):
|
||||||
|
fail("cardio guidance run identity reused with different phases")
|
||||||
|
for stored, phase in zip(stored_phases, run["phases"]):
|
||||||
|
target = phase["target"]
|
||||||
|
exit_condition = phase["exit_condition"]
|
||||||
|
expected = (
|
||||||
|
phase["phase_id"], phase["entry_id"], phase["position"], phase["kind"],
|
||||||
|
None if target is None else target["minimum_bpm"],
|
||||||
|
None if target is None else target["maximum_bpm"],
|
||||||
|
None if target is None else target["calibration_id"],
|
||||||
|
None if target is None else target["calibration_observed_peak_bpm"],
|
||||||
|
None if target is None else target["minimum_percent"],
|
||||||
|
None if target is None else target["maximum_percent"],
|
||||||
|
exit_condition["kind"], exit_condition["seconds"], exit_condition["bpm"],
|
||||||
|
phase["started_at"], phase["ended_at"], phase["final_instruction"],
|
||||||
|
)
|
||||||
|
if tuple(stored) != expected:
|
||||||
|
fail("cardio guidance phase identity reused with different content")
|
||||||
|
stored_events = [
|
||||||
|
tuple(row)
|
||||||
|
for row in db.execute(
|
||||||
|
"SELECT sequence,observed_at,instruction,bpm,target_min_bpm,target_max_bpm "
|
||||||
|
"FROM cardio_guidance_events WHERE run_id=? AND phase_id=? ORDER BY sequence",
|
||||||
|
(run["run_id"], phase["phase_id"]),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
incoming = [
|
||||||
|
(
|
||||||
|
event["sequence"], event["observed_at"], event["instruction"], event["bpm"],
|
||||||
|
event["target_minimum_bpm"], event["target_maximum_bpm"],
|
||||||
|
)
|
||||||
|
for event in phase["events"]
|
||||||
|
]
|
||||||
|
if stored_events != incoming:
|
||||||
|
fail("cardio guidance phase identity reused with different events")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]:
|
||||||
|
applied = unchanged = 0
|
||||||
|
for run in root["runs"]:
|
||||||
|
_verify_source_facts(db, run)
|
||||||
|
if _same(db, run):
|
||||||
|
unchanged += 1
|
||||||
|
continue
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO cardio_guidance_runs(run_id,session_id,started_at,ended_at,imported_at) "
|
||||||
|
"VALUES(?,?,?,?,?)",
|
||||||
|
(run["run_id"], run["session_id"], run["started_at"], run["ended_at"], root["generated_at"]),
|
||||||
|
)
|
||||||
|
for phase in run["phases"]:
|
||||||
|
target = phase["target"]
|
||||||
|
exit_condition = phase["exit_condition"]
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO cardio_guidance_phases("
|
||||||
|
"run_id,phase_id,entry_id,position,kind,target_min_bpm,target_max_bpm,"
|
||||||
|
"calibration_id,calibration_observed_peak_bpm,minimum_percent,maximum_percent,"
|
||||||
|
"exit_kind,exit_seconds,exit_bpm,started_at,ended_at,final_instruction"
|
||||||
|
") VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
|
(
|
||||||
|
run["run_id"], phase["phase_id"], phase["entry_id"], phase["position"],
|
||||||
|
phase["kind"],
|
||||||
|
None if target is None else target["minimum_bpm"],
|
||||||
|
None if target is None else target["maximum_bpm"],
|
||||||
|
None if target is None else target["calibration_id"],
|
||||||
|
None if target is None else target["calibration_observed_peak_bpm"],
|
||||||
|
None if target is None else target["minimum_percent"],
|
||||||
|
None if target is None else target["maximum_percent"],
|
||||||
|
exit_condition["kind"], exit_condition["seconds"], exit_condition["bpm"],
|
||||||
|
phase["started_at"], phase["ended_at"], phase["final_instruction"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
db.executemany(
|
||||||
|
"INSERT INTO cardio_guidance_events("
|
||||||
|
"run_id,phase_id,sequence,observed_at,instruction,bpm,target_min_bpm,target_max_bpm"
|
||||||
|
") VALUES(?,?,?,?,?,?,?,?)",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
run["run_id"], phase["phase_id"], event["sequence"], event["observed_at"],
|
||||||
|
event["instruction"], event["bpm"], event["target_minimum_bpm"],
|
||||||
|
event["target_maximum_bpm"],
|
||||||
|
)
|
||||||
|
for event in phase["events"]
|
||||||
|
],
|
||||||
|
)
|
||||||
|
applied += 1
|
||||||
|
return applied, unchanged
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("input", type=Path)
|
||||||
|
parser.add_argument("--database", required=True, type=Path)
|
||||||
|
args = parser.parse_args()
|
||||||
|
root = load(args.input)
|
||||||
|
with connect_database(args.database) as db:
|
||||||
|
if db.execute("PRAGMA user_version").fetchone()[0] not in (34, 35):
|
||||||
|
fail("desktop schema v34-v35 required")
|
||||||
|
db.execute("BEGIN IMMEDIATE")
|
||||||
|
apply(db, root)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
126
tools/cardio_session_exchange.py
Normal file
126
tools/cardio_session_exchange.py
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Validate and import Android-owned dedicated cardio sessions V1."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import import_mobile_export
|
||||||
|
|
||||||
|
|
||||||
|
FORMAT = "trainlog-cardio-sessions"
|
||||||
|
VERSION = 1
|
||||||
|
MAX_BYTES = 64 * 1024 * 1024
|
||||||
|
ROOT_KEYS = {"format", "version", "generated_at", "exercises", "sessions"}
|
||||||
|
|
||||||
|
|
||||||
|
class CardioSessionError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message: str) -> None:
|
||||||
|
raise CardioSessionError(message)
|
||||||
|
|
||||||
|
|
||||||
|
def unique_object(pairs):
|
||||||
|
result = {}
|
||||||
|
for key, value in pairs:
|
||||||
|
if key in result:
|
||||||
|
fail("duplicate JSON field: " + key)
|
||||||
|
result[key] = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def load(path: Path) -> dict:
|
||||||
|
if path.stat().st_size > MAX_BYTES:
|
||||||
|
fail("cardio session artifact exceeds 64 MiB")
|
||||||
|
try:
|
||||||
|
root = json.loads(
|
||||||
|
path.read_text(encoding="utf-8"),
|
||||||
|
object_pairs_hook=unique_object,
|
||||||
|
)
|
||||||
|
except (OSError, UnicodeError, json.JSONDecodeError) as error:
|
||||||
|
raise CardioSessionError("invalid cardio session JSON") from error
|
||||||
|
if not isinstance(root, dict) or set(root) != ROOT_KEYS:
|
||||||
|
fail("invalid cardio session envelope")
|
||||||
|
if root["format"] != FORMAT or type(root["version"]) is not int or root["version"] != VERSION:
|
||||||
|
fail("unsupported cardio session format")
|
||||||
|
if not isinstance(root["exercises"], list) or not isinstance(root["sessions"], list):
|
||||||
|
fail("invalid cardio session arrays")
|
||||||
|
for index, session in enumerate(root["sessions"]):
|
||||||
|
if not isinstance(session, dict) or session.get("session_type") != "cardio":
|
||||||
|
fail(f"sessions[{index}].session_type must be cardio")
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def mobile_projection(root: dict) -> dict:
|
||||||
|
sessions = []
|
||||||
|
for source in root["sessions"]:
|
||||||
|
session = dict(source)
|
||||||
|
session["session_type"] = "training"
|
||||||
|
sessions.append(session)
|
||||||
|
return {
|
||||||
|
"format": "trainlog-mobile-export",
|
||||||
|
"version": 4,
|
||||||
|
"generated_at": root["generated_at"],
|
||||||
|
"exercises": root["exercises"],
|
||||||
|
"sessions": sessions,
|
||||||
|
"body_observations": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate(root: dict, known_equipment_ids: set[str]) -> dict:
|
||||||
|
if not isinstance(root, dict) or set(root) != ROOT_KEYS:
|
||||||
|
fail("invalid cardio session envelope")
|
||||||
|
if root.get("format") != FORMAT or type(root.get("version")) is not int or root["version"] != VERSION:
|
||||||
|
fail("unsupported cardio session format")
|
||||||
|
if not isinstance(root.get("sessions"), list) or not isinstance(root.get("exercises"), list):
|
||||||
|
fail("invalid cardio session arrays")
|
||||||
|
for index, session in enumerate(root["sessions"]):
|
||||||
|
if not isinstance(session, dict) or session.get("session_type") != "cardio":
|
||||||
|
fail(f"sessions[{index}].session_type must be cardio")
|
||||||
|
projection = mobile_projection(root)
|
||||||
|
try:
|
||||||
|
import_mobile_export.validate_payload(projection, known_equipment_ids)
|
||||||
|
except Exception as error:
|
||||||
|
raise CardioSessionError(str(error)) from error
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def apply(
|
||||||
|
connection,
|
||||||
|
root: dict,
|
||||||
|
*,
|
||||||
|
complete_causal_envelope: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
session_ids = [session["session_id"] for session in root["sessions"]]
|
||||||
|
for session_id in session_ids:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT session_kind FROM sessions WHERE session_id=?",
|
||||||
|
(session_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is not None and row[0] != "cardio":
|
||||||
|
fail("cardio session identity collides with non-cardio history")
|
||||||
|
|
||||||
|
projection = mobile_projection(root)
|
||||||
|
try:
|
||||||
|
report = import_mobile_export.apply_payload(
|
||||||
|
connection,
|
||||||
|
projection,
|
||||||
|
complete_causal_envelope=complete_causal_envelope,
|
||||||
|
)
|
||||||
|
except Exception as error:
|
||||||
|
raise CardioSessionError(str(error)) from error
|
||||||
|
|
||||||
|
for session_id in session_ids:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT session_kind FROM sessions WHERE session_id=?",
|
||||||
|
(session_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is not None:
|
||||||
|
if row[0] not in ("training", "cardio"):
|
||||||
|
fail("cardio session identity has incompatible stored kind")
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE sessions SET session_kind='cardio' WHERE session_id=?",
|
||||||
|
(session_id,),
|
||||||
|
)
|
||||||
|
return report
|
||||||
|
|
@ -38,8 +38,8 @@ def digest(value) -> str:
|
||||||
|
|
||||||
def require_schema(db: sqlite3.Connection) -> None:
|
def require_schema(db: sqlite3.Connection) -> None:
|
||||||
version = db.execute("PRAGMA user_version").fetchone()[0]
|
version = db.execute("PRAGMA user_version").fetchone()[0]
|
||||||
if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
|
if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35):
|
||||||
raise CausalError(f"desktop schema v20-v29 required, found v{version}")
|
raise CausalError(f"desktop schema v20-v35 required, found v{version}")
|
||||||
|
|
||||||
|
|
||||||
def target_snapshot(db: sqlite3.Connection, kind: str, target: str):
|
def target_snapshot(db: sqlite3.Connection, kind: str, target: str):
|
||||||
|
|
|
||||||
|
|
@ -182,8 +182,8 @@ def causal_payload(draft):
|
||||||
|
|
||||||
def require_schema(connection):
|
def require_schema(connection):
|
||||||
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||||
if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
|
if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35):
|
||||||
raise LifecycleError(f"desktop schema v19-v29 required, found v{version}")
|
raise LifecycleError(f"desktop schema v19-v35 required, found v{version}")
|
||||||
|
|
||||||
|
|
||||||
def import_document(connection, document, own_transaction=True):
|
def import_document(connection, document, own_transaction=True):
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ from pathlib import Path
|
||||||
from trainlog_sqlite import configure_connection
|
from trainlog_sqlite import configure_connection
|
||||||
|
|
||||||
|
|
||||||
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29)
|
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35)
|
||||||
MEASUREMENT_FIELDS = (
|
MEASUREMENT_FIELDS = (
|
||||||
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
|
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
|
||||||
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm",
|
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm",
|
||||||
|
|
@ -125,7 +125,7 @@ def build_export(connection, generated_at):
|
||||||
"""Build the raw export using only explicitly ordered SELECT statements."""
|
"""Build the raw export using only explicitly ordered SELECT statements."""
|
||||||
schema_version = connection.execute("PRAGMA user_version").fetchone()[0]
|
schema_version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||||
if schema_version not in SUPPORTED_SCHEMA_VERSIONS:
|
if schema_version not in SUPPORTED_SCHEMA_VERSIONS:
|
||||||
raise ValueError("schema desktop v15-v29 requis")
|
raise ValueError("schema desktop v15-v35 requis")
|
||||||
|
|
||||||
root = {
|
root = {
|
||||||
"format": "TRAINLOG_AI_EXPORT",
|
"format": "TRAINLOG_AI_EXPORT",
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,9 @@ ID_PREFIX = "aid_"
|
||||||
def ensure_publication_state(connection):
|
def ensure_publication_state(connection):
|
||||||
"""Install the additive v18 cursor for direct syncd/tool invocations."""
|
"""Install the additive v18 cursor for direct syncd/tool invocations."""
|
||||||
owned_transaction = not connection.in_transaction
|
owned_transaction = not connection.in_transaction
|
||||||
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29)
|
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35)
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||||
raise ValueError("schema desktop v18-v29 requis")
|
raise ValueError("schema desktop v18-v35 requis")
|
||||||
columns = {row[1] for row in connection.execute(
|
columns = {row[1] for row in connection.execute(
|
||||||
"PRAGMA table_info(ai_session_drafts)")}
|
"PRAGMA table_info(ai_session_drafts)")}
|
||||||
if "published_at" not in columns:
|
if "published_at" not in columns:
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,9 @@ def main():
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
connection = connect_database(args.database)
|
connection = connect_database(args.database)
|
||||||
try:
|
try:
|
||||||
supported_versions = tuple(range(8, 30))
|
supported_versions = tuple(range(8, 36))
|
||||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
|
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
|
||||||
raise ValueError("schema desktop v8 à v29 requis")
|
raise ValueError("schema desktop v8 à v35 requis")
|
||||||
known_equipment = load_supplied_equipment_ids(args.catalog)
|
known_equipment = load_supplied_equipment_ids(args.catalog)
|
||||||
known_equipment.update(row[0] for row in connection.execute(
|
known_equipment.update(row[0] for row in connection.execute(
|
||||||
"SELECT equipment_id FROM custom_equipment"))
|
"SELECT equipment_id FROM custom_equipment"))
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,9 @@ def main(complete_causal_envelope=False):
|
||||||
connection = connect_database(args.database)
|
connection = connect_database(args.database)
|
||||||
connection.row_factory = sqlite3.Row
|
connection.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
supported_versions = tuple(range(8, 30))
|
supported_versions = tuple(range(8, 36))
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||||
raise ValueError("schema desktop v8 à v29 requis")
|
raise ValueError("schema desktop v8 à v35 requis")
|
||||||
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
|
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
|
||||||
raise ValueError("causal equipment protection requires the staged artifact")
|
raise ValueError("causal equipment protection requires the staged artifact")
|
||||||
equipment = [dict(row) for row in connection.execute(
|
equipment = [dict(row) for row in connection.execute(
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@ def main():
|
||||||
con = connect_database(args.database)
|
con = connect_database(args.database)
|
||||||
try:
|
try:
|
||||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (
|
if con.execute("PRAGMA user_version").fetchone()[0] not in (
|
||||||
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
|
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35):
|
||||||
raise ValueError("schema desktop v12-v29 requis")
|
raise ValueError("schema desktop v12-v35 requis")
|
||||||
rows = con.execute(
|
rows = con.execute(
|
||||||
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
|
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
|
||||||
"ORDER BY source_exercise_id COLLATE BINARY"
|
"ORDER BY source_exercise_id COLLATE BINARY"
|
||||||
|
|
|
||||||
|
|
@ -41,9 +41,9 @@ def main(complete_causal_envelope=False):
|
||||||
connection = connect_database(args.database)
|
connection = connect_database(args.database)
|
||||||
connection.row_factory = sqlite3.Row
|
connection.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
supported_versions = tuple(range(11, 30))
|
supported_versions = tuple(range(11, 36))
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||||
raise ValueError("schema desktop v11-v16 requis")
|
raise ValueError("schema desktop v11-v35 requis")
|
||||||
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone():
|
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone():
|
||||||
raise ValueError("causal BODY ZONES protection requires the staged artifact")
|
raise ValueError("causal BODY ZONES protection requires the staged artifact")
|
||||||
exercises = []
|
exercises = []
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ def main():
|
||||||
con.row_factory = sqlite3.Row
|
con.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
version = con.execute("PRAGMA user_version").fetchone()[0]
|
version = con.execute("PRAGMA user_version").fetchone()[0]
|
||||||
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
|
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35):
|
||||||
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
|
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
|
||||||
rows = con.execute("""
|
rows = con.execute("""
|
||||||
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,
|
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ def main():
|
||||||
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
|
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
|
||||||
# catalogue artifact is unchanged, but it must read the current
|
# catalogue artifact is unchanged, but it must read the current
|
||||||
# canonical desktop schema rather than accept a stale pre-v8 database.
|
# canonical desktop schema rather than accept a stale pre-v8 database.
|
||||||
if version not in range(8, 30):
|
if version not in range(8, 36):
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
"PC_CATALOG_EXPORT=FAIL "
|
"PC_CATALOG_EXPORT=FAIL "
|
||||||
f"schema={version}"
|
f"schema={version}"
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue