Compare commits
No commits in common. "f2747b357bb0281d316fb4378797a7deff08bae9" and "5d58071601d5613f236c414cbe36cf1390ec2d90" have entirely different histories.
f2747b357b
...
5d58071601
157 changed files with 285 additions and 16448 deletions
111
CHANGELOG.md
111
CHANGELOG.md
|
|
@ -1,116 +1,5 @@
|
|||
# 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
|
||||
|
||||
- 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. |
|
||||
| Desktop TUI | Administer, inspect, maintain, import/export, correct canonical history, and provide technical tools. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
|
||||
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
|
||||
|
|
@ -34,13 +34,8 @@ make the remaining placeholder routes functional.
|
|||
The top-level `/programmes` route is an operational daily calendar for active
|
||||
Programs. Sessions → Programmes remains the technical administration/import,
|
||||
list, detail, archive, and delete surface.
|
||||
Web Exercises administration, the factual Dashboard/Analyse read model, and the
|
||||
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.
|
||||
Web Exercises administration is implemented. Version 0.1.4 also includes the
|
||||
factual Dashboard/Analyse read model and the synchronized Sleep Diary workspace.
|
||||
|
||||
## Releases
|
||||
|
||||
|
|
@ -57,8 +52,8 @@ Each published stable release provides:
|
|||
- SHA-256 checksums.
|
||||
|
||||
Both mirrors publish the same Trainlog product version and release assets. The
|
||||
latest stable version is **0.1.5** on Android and desktop; this is one shared
|
||||
Trainlog version, not separate interface versions. Version 0.1.4 is the
|
||||
latest stable version is **0.1.4** on Android and desktop; this is one shared
|
||||
Trainlog version, not separate interface versions. Version 0.1.3 is the
|
||||
previous stable release.
|
||||
|
||||
Trainlog presents French by default, with English selectable
|
||||
|
|
@ -162,7 +157,7 @@ updates; generating a replacement key is not a normal release procedure.
|
|||
|
||||
### Linux runtime dependencies
|
||||
|
||||
The published v0.1.5 x86-64 desktop runtime is dynamically linked. Its tagged
|
||||
The published v0.1.4 x86-64 desktop runtime is dynamically linked. Its tagged
|
||||
source directly requires compatible versions of:
|
||||
|
||||
- glibc and the GCC support runtime;
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ val trainlogVersionCode =
|
|||
providers.environmentVariable("TRAINLOG_ANDROID_VERSION_CODE").orNull?.let { raw ->
|
||||
raw.toIntOrNull()?.takeIf { it in 1..Int.MAX_VALUE }
|
||||
?: throw GradleException("TRAINLOG_ANDROID_VERSION_CODE must be a positive integer")
|
||||
} ?: 6
|
||||
} ?: 5
|
||||
|
||||
android {
|
||||
namespace = "com.labfytools.trainlog"
|
||||
|
|
@ -55,7 +55,7 @@ android {
|
|||
targetSdk = 36
|
||||
|
||||
versionCode = trainlogVersionCode
|
||||
versionName = "0.1.5"
|
||||
versionName = "0.1.4"
|
||||
testInstrumentationRunner =
|
||||
"androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,14 +12,6 @@
|
|||
<uses-permission
|
||||
android:name="android.permission.BLUETOOTH_ADMIN"
|
||||
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.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
|
|
@ -50,9 +42,5 @@
|
|||
android:name=".data.SyncBackgroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="connectedDevice|dataSync" />
|
||||
<service
|
||||
android:name=".data.HeartRateSensorService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="connectedDevice" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -16,10 +16,7 @@ import com.labfytools.trainlog.data.SyncCatalogInbox
|
|||
import com.labfytools.trainlog.data.SyncRequestOutbox
|
||||
import com.labfytools.trainlog.data.BackgroundSyncSettings
|
||||
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.hasHeartRateBleRuntimePermissions
|
||||
import com.labfytools.trainlog.ui.TrainlogApp
|
||||
import com.labfytools.trainlog.ui.TrainlogAppState
|
||||
import com.labfytools.trainlog.ui.LanguagePresentation
|
||||
|
|
@ -42,12 +39,6 @@ class MainActivity : ComponentActivity() {
|
|||
) {
|
||||
SyncBackgroundService.start(applicationContext)
|
||||
}
|
||||
if (
|
||||
HeartRateSensorPreferences(applicationContext).selected() != null &&
|
||||
hasHeartRateBleRuntimePermissions(applicationContext)
|
||||
) {
|
||||
HeartRateSensorService.start(applicationContext)
|
||||
}
|
||||
|
||||
val repository =
|
||||
TrainlogRepository(
|
||||
|
|
|
|||
|
|
@ -225,7 +225,7 @@ internal class AndroidBackupService(
|
|||
manifest.getInt("version") == VERSION &&
|
||||
manifest.getString("package") == context.packageName &&
|
||||
manifest.getBoolean("plaintext") &&
|
||||
manifest.getInt("schema") in 17..32
|
||||
manifest.getInt("schema") in 17..26
|
||||
) {
|
||||
"Unsupported backup."
|
||||
}
|
||||
|
|
@ -319,7 +319,7 @@ internal class AndroidBackupService(
|
|||
}
|
||||
)
|
||||
require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() })
|
||||
require(db.version in 17..32) { "Unsupported Android schema ${db.version}." }
|
||||
require(db.version in 17..26) { "Unsupported Android schema ${db.version}." }
|
||||
return db.version
|
||||
} finally {
|
||||
db.close()
|
||||
|
|
|
|||
|
|
@ -41,17 +41,6 @@ private const val MAX_BT_FILE_BYTES = 64L * 1024L * 1024L
|
|||
private const val MAX_BT_FILES = 128
|
||||
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(
|
||||
val name: String,
|
||||
val address: String,
|
||||
|
|
@ -259,7 +248,14 @@ private class BluetoothExchangeArchive(
|
|||
val totals = longArrayOf(0L, 0L)
|
||||
try {
|
||||
ZipOutputStream(BufferedOutputStream(FileOutputStream(output))).use { zip ->
|
||||
BLUETOOTH_ANDROID_PULL_COORDINATION.forEach { name ->
|
||||
listOf(
|
||||
"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)
|
||||
}
|
||||
val reference = File(exchangeDirectory, "android-generation-v1.json")
|
||||
|
|
|
|||
|
|
@ -1,190 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
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,8 +195,7 @@ internal class DriveDocumentTransport(private val context: Context, treeUri: Uri
|
|||
localRoot.mkdirs()
|
||||
val coordination = listOf(
|
||||
"android-peer-v1.json", "android-generation-v1.json", "android-consumption-ack-v1.json",
|
||||
"android-archive-acknowledgements-v1.json", "android-generation-error-v1.json",
|
||||
"request-v1.json",
|
||||
"android-generation-error-v1.json", "request-v1.json",
|
||||
"desktop-archive-acknowledgements-v1.json", "desktop-consumption-ack-v1.json",
|
||||
"desktop-generation-v1.json",
|
||||
)
|
||||
|
|
@ -262,13 +261,6 @@ internal class DriveDocumentTransport(private val context: Context, treeUri: Uri
|
|||
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) {
|
||||
upload(
|
||||
"android-consumption-ack-v1.json",
|
||||
|
|
@ -326,10 +318,6 @@ class DriveSyncWorker(context: Context, parameters: WorkerParameters) :
|
|||
local,
|
||||
Duration.ofMinutes(10),
|
||||
afterPeerPublication = { transport.pushPeer(local) },
|
||||
afterRecoveryAcknowledgements = {
|
||||
stage = "recovery-acknowledgement"
|
||||
transport.pushRecoveryAcknowledgements(local)
|
||||
},
|
||||
afterPublication = {
|
||||
stage = "generation-publication"
|
||||
transport.pushGeneration(local)
|
||||
|
|
|
|||
|
|
@ -1,482 +0,0 @@
|
|||
/*
|
||||
* 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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
/*
|
||||
* 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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
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"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,561 +0,0 @@
|
|||
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,11 +54,6 @@ internal class SyncGenerationCoordinator(
|
|||
) {
|
||||
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(
|
||||
runId: String?,
|
||||
phase: String,
|
||||
|
|
@ -201,45 +196,6 @@ internal class SyncGenerationCoordinator(
|
|||
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(
|
||||
raw: ByteArray,
|
||||
runId: String,
|
||||
|
|
@ -343,7 +299,6 @@ internal class SyncGenerationCoordinator(
|
|||
"mobile-history-v4",
|
||||
"execution-draft-v1",
|
||||
"generation-archive-v1",
|
||||
ACK_RECOVERY_CAPABILITY,
|
||||
"session-preparations-v2",
|
||||
"programs-v1",
|
||||
)
|
||||
|
|
@ -404,7 +359,6 @@ internal class SyncGenerationCoordinator(
|
|||
directory: File,
|
||||
timeout: Duration = Duration.ofMinutes(5),
|
||||
afterPeerPublication: (() -> Unit)? = null,
|
||||
afterRecoveryAcknowledgements: (() -> Unit)? = null,
|
||||
afterPublication: (() -> Unit)? = null,
|
||||
pollTransport: (() -> Unit)? = null,
|
||||
afterAcknowledgement: (() -> Unit)? = null,
|
||||
|
|
@ -479,44 +433,6 @@ internal class SyncGenerationCoordinator(
|
|||
request.getString("desktop_peer_id"),
|
||||
)
|
||||
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.
|
||||
* CONTRACT: an explicit retry for the same run republishes that
|
||||
* exact generation and resumes ACK handling; it never recaptures
|
||||
|
|
|
|||
|
|
@ -52,8 +52,6 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
|||
linkedMapOf(
|
||||
"catalog" to Kind("trainlog-pc-catalog", 1, "catalog-v1.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
|
||||
Kind("trainlog-execution-drafts", 1, "execution-drafts-v1.json"),
|
||||
"exercise-aliases" to
|
||||
|
|
@ -70,14 +68,7 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
|||
Kind("trainlog-causal-deletions", 1, "causal-deletions-v1.json"),
|
||||
"program-executions" to
|
||||
Kind("trainlog-program-executions", 1, "program-executions-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),
|
||||
"sleep-diary" to Kind("trainlog-sleep-diary", 1, "sleep-diary-v1.json", false),
|
||||
)
|
||||
private val SUPPORTED =
|
||||
(ANDROID_KINDS.values +
|
||||
|
|
@ -431,48 +422,6 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
|||
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)
|
||||
} catch (error: Exception) {
|
||||
|
|
@ -1110,38 +1059,6 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
|||
arrayOf(ack.getString("generation_id"), ack.getString("consumed_at"),
|
||||
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
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,37 +0,0 @@
|
|||
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>,
|
||||
)
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
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>,
|
||||
)
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
/*
|
||||
* 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,14 +9,17 @@ enum class SessionType(
|
|||
val wireValue: String,
|
||||
) {
|
||||
TRAINING("training"),
|
||||
MAX_TEST("max_test"),
|
||||
CARDIO("cardio");
|
||||
MAX_TEST("max_test");
|
||||
|
||||
companion object {
|
||||
fun fromWire(
|
||||
value: String,
|
||||
): SessionType =
|
||||
entries.firstOrNull { it.wireValue == value } ?: TRAINING
|
||||
if (value == "max_test") {
|
||||
MAX_TEST
|
||||
} else {
|
||||
TRAINING
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -76,24 +79,6 @@ data class SessionDraft(
|
|||
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(
|
||||
val selectedExercise: ExerciseProfile? = null,
|
||||
/** Null means creation; otherwise replace this durable draft entry. */
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ data class SleepMedication(
|
|||
data class MedicationIntake(
|
||||
val intakeId: String, val medicationId: String, val medicationName: String,
|
||||
val takenAt: String, val doseValue: Double?, val doseUnit: String?, val note: String,
|
||||
val createdAt: String, val quantity: Int = 1,
|
||||
val createdAt: String,
|
||||
)
|
||||
data class SleepDiaryEntry(
|
||||
val entryId: String, val nightStartDate: String, val nightEndDate: String,
|
||||
|
|
|
|||
|
|
@ -69,7 +69,6 @@ fun AndroidAppShell(
|
|||
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.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
|
||||
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
|
||||
|
|
@ -78,7 +77,6 @@ fun AndroidAppShell(
|
|||
AppRoute.BodyMeasurements -> R.string.route_body_measurements; AppRoute.LatestMaxima -> R.string.route_latest_maxima
|
||||
AppRoute.SleepDiary -> R.string.nav_sleep
|
||||
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 scope = rememberCoroutineScope()
|
||||
|
|
@ -147,7 +145,6 @@ fun AndroidAppShell(
|
|||
}
|
||||
},
|
||||
actions = {
|
||||
HeartRateIndicator()
|
||||
if (!root) {
|
||||
IconButton(
|
||||
onClick = { scope.launch { drawerState.open() } },
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ sealed interface AppRoute {
|
|||
data object AiSessionDrafts : 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 CardioCalibration : 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 SessionCorrection(val sessionId: String) : AppRoute { override val section = AppSection.SESSIONS }
|
||||
|
|
@ -40,7 +39,6 @@ sealed interface AppRoute {
|
|||
data object LatestMaxima : AppRoute { override val section = AppSection.EXERCISES }
|
||||
data object Sync : AppRoute { override val section = AppSection.SYNC }
|
||||
data object Settings : AppRoute { override val section = AppSection.SETTINGS }
|
||||
data object HeartRateDiagnostics : AppRoute { override val section = AppSection.SETTINGS }
|
||||
}
|
||||
|
||||
fun AppSection.rootRoute(): AppRoute = when (this) {
|
||||
|
|
|
|||
|
|
@ -1,172 +0,0 @@
|
|||
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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,385 +0,0 @@
|
|||
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)
|
||||
|
|
@ -1,203 +0,0 @@
|
|||
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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
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,7 +9,6 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
|||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
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
|
||||
|
|
@ -40,8 +39,6 @@ import com.labfytools.trainlog.data.StartAiSessionDraftResult
|
|||
import com.labfytools.trainlog.data.TrainlogRepository
|
||||
import com.labfytools.trainlog.data.AndroidBackupService
|
||||
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.model.ActiveSessionDraft
|
||||
import com.labfytools.trainlog.model.SessionExerciseDraft
|
||||
|
|
@ -58,7 +55,6 @@ fun SessionsHub(
|
|||
onDrafts: () -> Unit,
|
||||
onHistory: () -> Unit,
|
||||
onPrograms: () -> Unit = {},
|
||||
onCardioCalibration: () -> Unit = {},
|
||||
) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
val strings = localizedContext()
|
||||
|
|
@ -78,13 +74,6 @@ fun SessionsHub(
|
|||
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,
|
||||
Modifier.fillMaxWidth().testTag("sessions-history-action"))
|
||||
Spacer(Modifier.height(16.dp).testTag("sessions-programs-spacing"))
|
||||
|
|
@ -483,22 +472,13 @@ private fun catalogCountsText(context: android.content.Context, imported: Int, r
|
|||
).joinToString(", ")
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
repository: TrainlogRepository,
|
||||
inbox: SyncCatalogInbox,
|
||||
onHeartRateDiagnostics: () -> Unit,
|
||||
onCatalogChanged: () -> Unit,
|
||||
) {
|
||||
fun SettingsScreen(repository: TrainlogRepository, inbox: SyncCatalogInbox, onCatalogChanged: () -> Unit) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
val context = LocalContext.current
|
||||
val language = LocalLanguagePresentation.current
|
||||
val strings = localizedContext()
|
||||
var authorized by remember { mutableStateOf(inbox.hasFolderAccess()) }
|
||||
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()) {
|
||||
authorized = inbox.hasFolderAccess()
|
||||
message = strings.getString(if (authorized) R.string.settings_access_granted else R.string.settings_access_required)
|
||||
|
|
@ -553,29 +533,6 @@ fun SettingsScreen(
|
|||
)
|
||||
}
|
||||
}
|
||||
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)) {
|
||||
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)) })
|
||||
|
|
|
|||
|
|
@ -29,10 +29,7 @@ import androidx.compose.foundation.rememberScrollState
|
|||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
|
|
@ -59,7 +56,6 @@ import com.labfytools.trainlog.data.FinalizeActiveDraftResult
|
|||
import com.labfytools.trainlog.data.ManualPercentMaxResult
|
||||
import com.labfytools.trainlog.data.SaveFeedbackResult
|
||||
import com.labfytools.trainlog.data.TrainlogRepository
|
||||
import com.labfytools.trainlog.data.TrainlogRepository.SessionExerciseTimingResult
|
||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||
import com.labfytools.trainlog.model.ExerciseDataFields
|
||||
import com.labfytools.trainlog.model.ExerciseProfile
|
||||
|
|
@ -156,9 +152,6 @@ fun SessionScreen(
|
|||
var pendingDestructiveEdit by remember { mutableStateOf<Pair<Int, SessionExerciseDraft>?>(null) }
|
||||
var feedbackRevision by remember { mutableStateOf(0) }
|
||||
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:
|
||||
(ActiveSessionDraft, String?) -> Unit =
|
||||
|
|
@ -198,12 +191,6 @@ fun SessionScreen(
|
|||
}
|
||||
|
||||
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 =
|
||||
message?.startsWith(
|
||||
|
|
@ -256,19 +243,6 @@ 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,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -292,19 +266,23 @@ fun SessionScreen(
|
|||
) {
|
||||
TrainlogInfo(
|
||||
text =
|
||||
strings.getString(
|
||||
R.string.session_type_value,
|
||||
when (currentDraft.sessionType) {
|
||||
SessionType.TRAINING -> strings.getString(R.string.session_training)
|
||||
SessionType.MAX_TEST -> strings.getString(R.string.session_max_test)
|
||||
SessionType.CARDIO -> strings.getString(R.string.session_cardio)
|
||||
},
|
||||
),
|
||||
strings.getString(R.string.session_type_value,
|
||||
if (
|
||||
currentDraft.sessionType ==
|
||||
SessionType.MAX_TEST
|
||||
) {
|
||||
strings.getString(R.string.session_max_test)
|
||||
} else {
|
||||
strings.getString(R.string.session_training)
|
||||
}),
|
||||
color =
|
||||
when (currentDraft.sessionType) {
|
||||
SessionType.MAX_TEST -> colors.warning
|
||||
SessionType.CARDIO -> colors.success
|
||||
SessionType.TRAINING -> colors.accent
|
||||
if (
|
||||
currentDraft.sessionType ==
|
||||
SessionType.MAX_TEST
|
||||
) {
|
||||
colors.warning
|
||||
} else {
|
||||
colors.accent
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -379,98 +357,6 @@ 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(
|
||||
label = strings.getString(R.string.modify_name, draft.exercise.name),
|
||||
description = strings.getString(R.string.edit_exercise_description),
|
||||
|
|
@ -632,30 +518,22 @@ fun SessionScreen(
|
|||
Modifier.weight(1f).fillMaxHeight().testTag("active-session-create-exercise"),
|
||||
containerColor = colors.surfaceAlt, maxLines = 2)
|
||||
TrainlogButton(strings.getString(R.string.session_save), onClick = {
|
||||
val activeEntryId = repository.activeSessionTimelineContext()?.activeEntryId
|
||||
if (activeEntryId != null) {
|
||||
pendingFinalizeActiveEntryId = activeEntryId
|
||||
} else {
|
||||
when (
|
||||
val result =
|
||||
repository.finalizeActiveSessionDraft()
|
||||
) {
|
||||
is FinalizeActiveDraftResult.Saved -> {
|
||||
onSessionSaved()
|
||||
onBack()
|
||||
}
|
||||
when (
|
||||
val result =
|
||||
repository.finalizeActiveSessionDraft()
|
||||
) {
|
||||
is FinalizeActiveDraftResult.Saved -> {
|
||||
onSessionSaved()
|
||||
onBack()
|
||||
}
|
||||
|
||||
is FinalizeActiveDraftResult.Invalid -> {
|
||||
message = localizedRepositoryMessage(strings, result.message)
|
||||
}
|
||||
is FinalizeActiveDraftResult.Invalid -> {
|
||||
message = localizedRepositoryMessage(strings, result.message)
|
||||
}
|
||||
|
||||
is FinalizeActiveDraftResult.DatabaseError -> {
|
||||
message =
|
||||
strings.getString(
|
||||
R.string.finalize_failed,
|
||||
localizedRepositoryMessage(strings, result.message),
|
||||
)
|
||||
}
|
||||
is FinalizeActiveDraftResult.DatabaseError -> {
|
||||
message =
|
||||
strings.getString(R.string.finalize_failed, localizedRepositoryMessage(strings, result.message))
|
||||
}
|
||||
}
|
||||
}, modifier = Modifier.weight(1f).fillMaxHeight().testTag("active-session-save"),
|
||||
|
|
@ -711,19 +589,7 @@ fun SessionScreen(
|
|||
strings.getString(R.string.exercise_added) ||
|
||||
message ==
|
||||
strings.getString(R.string.exercise_removed) ||
|
||||
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,
|
||||
)
|
||||
}
|
||||
message == strings.getString(R.string.exercise_order_saved)
|
||||
) {
|
||||
colors.success
|
||||
} else {
|
||||
|
|
@ -774,138 +640,6 @@ 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,7 +17,6 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import kotlinx.coroutines.delay
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -38,271 +37,8 @@ import java.time.LocalDate
|
|||
import java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
|
||||
|
||||
@Composable
|
||||
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 selected by remember(revision) { mutableStateOf<SleepDiaryEntry?>(repository.listSleepDiary().firstOrNull()) }
|
||||
val today = LocalDate.now()
|
||||
|
|
|
|||
|
|
@ -103,16 +103,8 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
|||
},
|
||||
{ open(AppRoute.BodyMeasurements) }, { open(AppRoute.Sync) },
|
||||
)
|
||||
AppRoute.Sessions -> SessionsHub(
|
||||
activeDraft,
|
||||
pendingAiDraftCount,
|
||||
{ open(AppRoute.SessionEditor) },
|
||||
::openManualSession,
|
||||
{ open(AppRoute.AiSessionDrafts) },
|
||||
{ open(AppRoute.CompletedSessions) },
|
||||
{ open(AppRoute.Programs) },
|
||||
{ open(AppRoute.CardioCalibration) },
|
||||
)
|
||||
AppRoute.Sessions -> SessionsHub(activeDraft, pendingAiDraftCount, { open(AppRoute.SessionEditor) }, ::openManualSession,
|
||||
{ open(AppRoute.AiSessionDrafts) }, { open(AppRoute.CompletedSessions) }, { open(AppRoute.Programs) })
|
||||
AppRoute.SessionEditor -> SessionScreen(
|
||||
repository,
|
||||
catalogRevision,
|
||||
|
|
@ -145,10 +137,6 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
|||
)
|
||||
AppRoute.CompletedSessions -> HistoryScreen(repository, { back() }) { open(AppRoute.SessionDetail(it)) }
|
||||
AppRoute.Programs -> ProgramsScreen(repository) { open(AppRoute.ProgramDetail(it)) }
|
||||
AppRoute.CardioCalibration -> CardioCalibrationScreen(repository) {
|
||||
draftRevision++
|
||||
exportSnapshot()
|
||||
}
|
||||
is AppRoute.ProgramDetail -> ProgramDetailScreen(repository, route.programId) {
|
||||
draftRevision++
|
||||
open(AppRoute.SessionEditor)
|
||||
|
|
@ -207,8 +195,7 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
|||
AppRoute.SleepDiary -> SleepDiaryScreen(repository)
|
||||
AppRoute.LatestMaxima -> LatestMaximaScreen(repository)
|
||||
AppRoute.Sync -> SyncScreen(repository, inbox, exporter, requestOutbox, { exportSnapshot(); catalogRevision++ }, { back() })
|
||||
AppRoute.Settings -> SettingsScreen(repository, inbox, { open(AppRoute.HeartRateDiagnostics) }) { exportSnapshot(); catalogRevision++ }
|
||||
AppRoute.HeartRateDiagnostics -> HeartRateDiagnosticsScreen { back() }
|
||||
AppRoute.Settings -> SettingsScreen(repository, inbox) { exportSnapshot(); catalogRevision++ }
|
||||
}}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -480,141 +480,4 @@
|
|||
<string name="sleep_unit">Unit</string>
|
||||
<string name="sleep_deactivate">Deactivate</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>
|
||||
|
|
|
|||
|
|
@ -490,141 +490,4 @@
|
|||
<string name="sleep_unit">Unité</string>
|
||||
<string name="sleep_deactivate">Désactiver</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>
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest {
|
|||
|
||||
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
||||
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(32, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(26, scalar(db, "PRAGMA user_version"))
|
||||
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(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()
|
||||
}
|
||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(32, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(26, scalar(db, "PRAGMA user_version"))
|
||||
/* v13 intentionally adds the frozen machine identities and
|
||||
* 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") }
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest {
|
|||
migrated.close()
|
||||
}
|
||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(32, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(26, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(before, listOf(
|
||||
scalar(db, "SELECT COUNT(*) FROM sessions"),
|
||||
scalar(db, "SELECT COUNT(*) FROM performed_sets"),
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ class AndroidV25ProgramExecutionMigrationTest {
|
|||
reopened.close()
|
||||
}
|
||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(32, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(26, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM sessions WHERE source_program_id IS NOT NULL"))
|
||||
assertEquals("ok", text(db, "PRAGMA integrity_check"))
|
||||
db.rawQuery("PRAGMA foreign_key_check", null).use { cursor ->
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ class AndroidV26SleepMigrationTest {
|
|||
context.getDatabasePath(name).path,
|
||||
null,
|
||||
SQLiteDatabase.OPEN_READONLY,
|
||||
).use { assertEquals(32, it.version) }
|
||||
).use { assertEquals(26, it.version) }
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
|
|
|
|||
|
|
@ -1,54 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
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.OPEN_READONLY).use { db ->
|
||||
db.rawQuery("PRAGMA user_version", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(32, cursor.getInt(0))
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(26, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ class BridgeBackupMigrationTest {
|
|||
null,
|
||||
android.database.sqlite.SQLiteDatabase.OPEN_READONLY,
|
||||
)
|
||||
.use { assertEquals(31, it.version) }
|
||||
.use { assertEquals(26, it.version) }
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
|
|
|
|||
|
|
@ -1,187 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,147 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,260 +0,0 @@
|
|||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,428 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
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))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,194 +0,0 @@
|
|||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
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())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,490 +0,0 @@
|
|||
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)
|
||||
assertEquals(1, repository.listPreparedSessions().size)
|
||||
repository.close()
|
||||
assertEquals("32", scalarText(name, "PRAGMA user_version"))
|
||||
assertEquals("26", scalarText(name, "PRAGMA user_version"))
|
||||
assertEquals(
|
||||
"0",
|
||||
scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"),
|
||||
|
|
|
|||
|
|
@ -1,181 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,209 +0,0 @@
|
|||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,246 +0,0 @@
|
|||
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") {
|
||||
db.execSQL("INSERT INTO sessions(session_id,started_at,session_type,session_kind) VALUES(?,?,?,?)", arrayOf(id, timestamp, type, type))
|
||||
db.execSQL("INSERT INTO sessions(session_id,started_at,session_type) VALUES(?,?,?)", arrayOf(id, timestamp, type))
|
||||
}
|
||||
|
||||
private fun occurrence(
|
||||
|
|
|
|||
|
|
@ -56,7 +56,6 @@ class SyncDeploymentConversationTest {
|
|||
val databaseName = "deployment-peer-${UUID.randomUUID()}.db"
|
||||
var repository = TrainlogRepository(context, databaseName)
|
||||
var localWorker: Process? = null
|
||||
var lostDesktopAckGeneration: String? = null
|
||||
try {
|
||||
transport.mkdirs()
|
||||
// A previous interrupted exchange leaves durable coordination
|
||||
|
|
@ -181,7 +180,6 @@ class SyncDeploymentConversationTest {
|
|||
"full_generation_trigger_published",
|
||||
"generation_request_observed",
|
||||
"archive_ack_observed",
|
||||
"android_archive_ack_published",
|
||||
"generation_capture_started",
|
||||
"generation_captured",
|
||||
"generation_objects_published",
|
||||
|
|
@ -217,71 +215,6 @@ 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
|
||||
}
|
||||
repository.close()
|
||||
|
|
|
|||
|
|
@ -3,14 +3,11 @@ 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.HeartRateRrInterval
|
||||
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.SleepDiaryDraft
|
||||
import com.labfytools.trainlog.model.TrackingMode
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
|
|
@ -509,7 +506,7 @@ class SyncGenerationServiceTest {
|
|||
.use { db ->
|
||||
db.rawQuery("PRAGMA user_version", null).use {
|
||||
assertTrue(it.moveToFirst())
|
||||
assertEquals(32, it.getInt(0))
|
||||
assertEquals(26, it.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
|
||||
assertTrue(it.moveToFirst())
|
||||
|
|
@ -858,54 +855,7 @@ class SyncGenerationServiceTest {
|
|||
)
|
||||
)!!
|
||||
.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)
|
||||
assertEquals(
|
||||
1,
|
||||
JSONObject(
|
||||
java.io.File(androidToDesktop.stagingDirectory, "heart-rate-v1.json")
|
||||
.readText(),
|
||||
)
|
||||
.getJSONArray("captures")
|
||||
.length(),
|
||||
)
|
||||
val androidPublished =
|
||||
sourceService.publish(
|
||||
androidToDesktop,
|
||||
|
|
@ -922,31 +872,10 @@ class SyncGenerationServiceTest {
|
|||
"--ack-output",
|
||||
androidAckFile.absolutePath,
|
||||
)
|
||||
val androidAck = JSONObject(androidAckFile.readText())
|
||||
assertEquals(
|
||||
"desktop ACK diagnostic: ${androidAck.getString("diagnostic")}",
|
||||
"consumed",
|
||||
androidAck.getString("result"),
|
||||
)
|
||||
assertEquals(
|
||||
"acknowledged",
|
||||
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
|
||||
assertEquals(
|
||||
"1",
|
||||
|
|
|
|||
|
|
@ -2260,7 +2260,7 @@ class TrainlogRepositoryDraftTest {
|
|||
).use { db ->
|
||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(32, cursor.getInt(0))
|
||||
assertEquals(26, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery(
|
||||
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
||||
|
|
@ -2387,7 +2387,7 @@ class TrainlogRepositoryDraftTest {
|
|||
).use { db ->
|
||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(32, cursor.getInt(0))
|
||||
assertEquals(26, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Android application
|
||||
|
||||
## Sleep Diary V1 and active V2 companion
|
||||
## Sleep Diary V1
|
||||
|
||||
Android schema v26 additively mirrors the Sleep Diary revision/event model for
|
||||
local-first capture. The unreleased schema-v26 contract also includes the
|
||||
|
|
@ -18,30 +18,6 @@ The screen resumes the most recent entry and distinguishes draft, ready,
|
|||
synchronized and modified tips. “Validate day” marks the current revision for
|
||||
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
|
||||
|
||||
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
|
||||
mixed-checkout user-service execution during the controlled rollout.
|
||||
|
||||
Snapshot date: **2026-09-23**.
|
||||
Snapshot date: **2026-09-22**.
|
||||
|
||||
`TRAINLOG_CODE_READABILITY_V1` normalizes the recent generation-MTP,
|
||||
generation/ACK, Android-backup, Web Dashboard serialization, tests, doubles and
|
||||
|
|
@ -112,11 +112,11 @@ parallel implementation of its rules.
|
|||
| Boundary | Current state |
|
||||
|---|---|
|
||||
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
|
||||
| Desktop SQLite | schema v35; v35 additively gives every Sleep medication intake a structured `quantity` (1..99), defaulting historical rows to 1 |
|
||||
| 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 |
|
||||
| Desktop SQLite | schema v29; Sleep Diary V1 is additive in v29 |
|
||||
| Android SQLite | schema v26; Sleep Diary V1 is additive in v26 |
|
||||
| Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport |
|
||||
| Desktop terminal backend | Notcurses only |
|
||||
| Trainlog product version | `0.1.5` stable, synchronized across Android and desktop; latest stable release: `v0.1.5` |
|
||||
| Trainlog product version | `0.1.4` stable, synchronized across Android and desktop; latest stable release: `v0.1.4` |
|
||||
| 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 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 visualizations | `WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` |
|
||||
| Web Dashboard V1 | `WEB_DASHBOARD_V1=PASS/FROZEN` |
|
||||
| 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. |
|
||||
| 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 |
|
||||
| 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) |
|
||||
| Programs presentation, Android projection, and deletion | `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=PASS` (private coordinated deployment and restart/replay validated) |
|
||||
|
|
@ -147,19 +147,7 @@ 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) |
|
||||
| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` |
|
||||
| Web Exercises V1 | `TRAINLOG_WEB_EXERCISES_V1=PASS` |
|
||||
| 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. |
|
||||
| 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 |
|
||||
| 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` |
|
||||
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |
|
||||
|
|
@ -172,14 +160,6 @@ parallel implementation of its rules.
|
|||
Desktop and Android schema numbers are independent. Neither changes the frozen
|
||||
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
|
||||
SharedPreferences; desktop keeps it in the XDG configuration file
|
||||
`trainlog/presentation.conf`. A successful selection updates the UI
|
||||
|
|
@ -425,11 +405,10 @@ The full Android lint comparison against an isolated clean `main` worktree is
|
|||
unchanged at ten pre-existing errors: one `NewApi`, one
|
||||
`LocalContextConfigurationRead`, and eight bilingual `StringFormatMatches`
|
||||
diagnostics. This UI tranche adds no lint error and changes no lint policy.
|
||||
The stable Android v0.1.4 tag uses `versionName=0.1.4` and public versionCode 5.
|
||||
The v0.1.5 development source starts at `versionName=0.1.5` and versionCode 6.
|
||||
Private validation builds may still supply only a strictly increasing
|
||||
`versionCode` as a build-time override; that does not create a public release
|
||||
or change schemas and protocols.
|
||||
The stable Android 0.1.4 source uses `versionName=0.1.4` and public
|
||||
versionCode 5. Private validation builds may still supply only a strictly
|
||||
increasing `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
|
||||
signed nondebuggable Android versionCode 17/schema v25 update without
|
||||
|
|
|
|||
|
|
@ -1,91 +1,5 @@
|
|||
# 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 additively owns `sleep_diary_entries`, immutable
|
||||
|
|
@ -95,14 +9,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
|
||||
as cells.
|
||||
|
||||
The same stable v0.1.4 migration owns `sleep_medications`, immutable
|
||||
The same unreleased migration owns `sleep_medications`, immutable
|
||||
`sleep_medication_revisions`, and revision-owned `sleep_medication_intakes`.
|
||||
Catalog defaults assist capture but do not own historical facts: every intake
|
||||
stores the displayed medication name and effective optional positive dose/unit
|
||||
snapshot. Renaming or deactivating a catalog item therefore cannot rewrite a
|
||||
past entry. Development databases that had already opened the earlier v29
|
||||
draft are completed idempotently on open before the additive v30 migration;
|
||||
no existing sleep row is rewritten.
|
||||
draft are completed idempotently on open; `user_version` remains 29 and no
|
||||
existing row is rewritten.
|
||||
|
||||
Mutations advance `current_revision_id` only when the caller supplies the
|
||||
current parent. Logical deletion advances the revision and retains the
|
||||
|
|
|
|||
|
|
@ -1,412 +0,0 @@
|
|||
# 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,7 +6,6 @@ than later development. GitHub and Forgejo publication must preserve the
|
|||
existing release, tag, status, and assets while keeping their note bodies equal
|
||||
to the corresponding source file.
|
||||
|
||||
- [Trainlog v0.1.5](v0.1.5.md)
|
||||
- [Trainlog v0.1.4](v0.1.4.md)
|
||||
- [Trainlog v0.1.3](v0.1.3.md)
|
||||
- [Trainlog v0.1.2](v0.1.2.md)
|
||||
|
|
|
|||
|
|
@ -1,59 +0,0 @@
|
|||
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
|
||||
|
||||
Stable v0.1.5 is the release baseline for the next development cycle.
|
||||
Desktop schema v35, Android schema v32, Notcurses, direct `Documents/Trainlog`
|
||||
Stable v0.1.4 is the release baseline for subsequent development.
|
||||
Desktop schema v29, Android schema v26, Notcurses, direct `Documents/Trainlog`
|
||||
storage, mobile export V3, Training Knowledge V1, Body Zones V1, Training
|
||||
Feedback V1/V2, STATS V1, and Session Generator V1 are implemented. Session
|
||||
Generator V1 is hidden pending V2. AI session-draft exchange retains its
|
||||
explicit manual validation gate. APP_SHELL_V1 remains useful historical design
|
||||
evidence, but its September 2026 observations must be revalidated against the
|
||||
v0.1.5 baseline rather than treated as a current implementation plan.
|
||||
v0.1.4 baseline rather than treated as a current implementation plan.
|
||||
`TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`; its local-only
|
||||
CLI/HTTP infrastructure, embedded frontend shell and the frozen Dashboard data
|
||||
contract, frozen interactive grid, versioned layout persistence and factual
|
||||
|
|
@ -46,9 +46,8 @@ visualizations and Sessions V1 are implemented. The top-level `/programmes`
|
|||
route is an operational daily active-Program calendar, while
|
||||
Sessions → Programmes remains the technical administration/import, list,
|
||||
detail, archive, and delete surface. Web Exercises V1, the factual
|
||||
Dashboard/Analyse read-model foundation, Sleep Diary V1/V2, synchronized
|
||||
factual heart-rate timelines, and the complete Cardio V1
|
||||
capture/calibration/guidance chain are included in stable v0.1.5.
|
||||
Dashboard/Analyse read-model foundation, and Sleep Diary V1 are included in
|
||||
stable v0.1.4.
|
||||
The corrective prepared-item projection, bounded phase-owned MTP outbox,
|
||||
Sessions presentation correction, local date preference and durable preparation
|
||||
withdrawal are implemented. The withdrawal uses the separate versioned
|
||||
|
|
@ -57,37 +56,19 @@ mobile snapshot or `TRAINLOG_FORMAT_V1` contracts.
|
|||
|
||||
## Current cursor
|
||||
|
||||
Stable v0.1.5 closes Cardio V1 and the Sleep Tonight readiness cycle. Android
|
||||
alone discovers and reads the heart-rate sensor; desktop/Web consume only
|
||||
synchronized factual measurements and never connect directly to the sensor.
|
||||
The heart-rate path remains independent from the frozen Bluetooth Trainlog
|
||||
synchronization transport.
|
||||
Stable v0.1.4 closes the Analyse/Sleep Diary cycle and freezes the Bluetooth
|
||||
Classic synchronization transport independently from future sensor work.
|
||||
|
||||
The next development cycle is 0.1.5 cardio. Android will own heart-rate sensor
|
||||
discovery/acquisition and timestamped local capture; synchronized desktop/Web
|
||||
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
|
||||
CURRENT_OPERATIONAL_CURSOR=Stable v0.1.5 published; open 0.1.6 development without reopening frozen 0.1.5 contracts.
|
||||
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_CARDIO_V1_NEXT_CONTRACT
|
||||
```
|
||||
|
||||
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
|
||||
cursor: complete predecessor state, imported built-in authorization,
|
||||
cross-platform draft revision identity and resource-bound admission are closed.
|
||||
|
|
|
|||
|
|
@ -3,37 +3,29 @@
|
|||
## Sleep Diary companion
|
||||
|
||||
`trainlog-sleep-diary` version 1 is finalized in stable v0.1.4 with both the
|
||||
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`.
|
||||
medication catalog and the entry-owned intake snapshots.
|
||||
The artifact preserves catalog and diary causal parents, rejects concurrent
|
||||
siblings and resurrection, and treats an exact revision replay as idempotent.
|
||||
This completion happened before publication or freezing of the companion;
|
||||
`TRAINLOG_FORMAT_V1` remains unchanged and frozen.
|
||||
|
||||
Every field edit commits locally as a new immutable diary revision. Explicit
|
||||
day validation marks only the guarded current tip publishable and starts no
|
||||
transport. V2 also permits the current revision of a quick-captured pending
|
||||
night to be marked quick-publishable: this is a distinct exact-revision marker,
|
||||
not day validation, and it does not validate an incomplete night. Generation
|
||||
capture exports either eligible current tip, records each exact
|
||||
generation/revision relation, and only a correlated consumed ACK advances the
|
||||
acknowledged marker. A correction after ACK therefore remains local and
|
||||
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.
|
||||
Every field edit commits locally as a new immutable diary revision but does
|
||||
not itself enter a generation. Explicit day validation marks only the guarded
|
||||
current tip publishable and starts no transport. Generation capture exports
|
||||
validated current tips, records each exact generation/revision relation, and
|
||||
only a correlated consumed ACK advances the acknowledged marker. A correction
|
||||
after ACK therefore remains local and appears as modified until it is
|
||||
revalidated 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 2, is the active full-generation companion.
|
||||
It carries bounded complete entry snapshots with
|
||||
`trainlog-sleep-diary`, version 1, is emitted by full generations in stable
|
||||
v0.1.4. It is optional when consuming an older generation so a pre-domain
|
||||
artifact remains replayable. It carries bounded complete entry snapshots with
|
||||
stable identities, the current immutable revision and causal parent,
|
||||
offset-bearing timestamps, appreciations, notes, logical deletion, and an
|
||||
integer `quantity` in `1..99` on every medication intake. `dose_value` and
|
||||
`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.
|
||||
offset-bearing timestamps, appreciations, notes and logical deletion. 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
|
||||
ownership.
|
||||
|
|
@ -1068,90 +1060,7 @@ format. It lists bounded durable desktop AI proposals and relevant execution
|
|||
drafts as distinct kinds. Completed sessions remain outside this projection,
|
||||
and finalized or causally deleted execution drafts are excluded.
|
||||
|
||||
## 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
|
||||
## 19. Legacy active-protocol limitations
|
||||
|
||||
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
|
||||
|
|
|
|||
281
docs/tests.md
281
docs/tests.md
|
|
@ -1648,284 +1648,3 @@ checks passed, both outgoing desktop generations were acknowledged, and the
|
|||
Android UI displayed completion. The private journal retains exact IDs and
|
||||
hashes. Drive and `connectedDebugAndroidTest` were not run; the personal phone
|
||||
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(
|
||||
'trainlog',
|
||||
'c',
|
||||
version: '0.1.5',
|
||||
version: '0.1.4',
|
||||
meson_version: '>=1.3.0',
|
||||
default_options: [
|
||||
'c_std=c17',
|
||||
|
|
|
|||
|
|
@ -1,175 +0,0 @@
|
|||
#!/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()
|
||||
|
|
@ -1,236 +0,0 @@
|
|||
#!/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()
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
#!/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()
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
#!/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()
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
#!/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_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_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_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_diary_publication_state(entry_id TEXT PRIMARY KEY,validated_revision_id TEXT,validated_at TEXT,acknowledged_revision_id TEXT,acknowledged_at TEXT);
|
||||
""")
|
||||
|
||||
|
|
@ -58,60 +58,6 @@ class SleepExchangeTest(unittest.TestCase):
|
|||
exported = exchange.build(self.db)
|
||||
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):
|
||||
first = "slr_20000000-0000-4000-8000-000000000002"
|
||||
second = "slr_40000000-0000-4000-8000-000000000004"
|
||||
|
|
|
|||
|
|
@ -65,20 +65,14 @@ class DeploymentToolsTest(unittest.TestCase):
|
|||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
inventory = json.loads((output / "candidate-inventory.json").read_text())
|
||||
self.assertEqual(inventory["product_version"], "0.1.5")
|
||||
self.assertEqual(inventory["desktop_schema"], 35)
|
||||
self.assertEqual(inventory["android_schema"], 32)
|
||||
self.assertEqual(inventory["product_version"], "0.1.4")
|
||||
self.assertEqual(inventory["desktop_schema"], 29)
|
||||
self.assertEqual(inventory["android_schema"], 26)
|
||||
self.assertIn("session-preparations-v2", inventory["protocols"])
|
||||
self.assertIn("programs-v1", inventory["protocols"])
|
||||
self.assertIn("program-executions-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("sleep-diary-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-sync-once").is_file())
|
||||
self.assertTrue((output / "bin/trainlog-syncd").is_file())
|
||||
|
|
@ -102,7 +96,7 @@ class DeploymentToolsTest(unittest.TestCase):
|
|||
text=True,
|
||||
)
|
||||
self.assertEqual(linked.returncode, 0, linked.stderr)
|
||||
self.assertIn("trainlog 0.1.5", linked.stdout)
|
||||
self.assertIn("trainlog 0.1.4", linked.stdout)
|
||||
imported = subprocess.run(
|
||||
[
|
||||
"python3",
|
||||
|
|
|
|||
|
|
@ -116,8 +116,6 @@ class DriveTransportTest(unittest.TestCase):
|
|||
source = Path(raw) / "source"
|
||||
source.mkdir()
|
||||
self.generation(source)
|
||||
recovery = source / "android-archive-acknowledgements-v1.json"
|
||||
recovery.write_text('{"format":"trainlog-sync-archive-acknowledgements","version":1}')
|
||||
remote = MemoryDrive()
|
||||
drive.push(remote, source)
|
||||
destination = Path(raw) / "destination"
|
||||
|
|
@ -126,10 +124,6 @@ class DriveTransportTest(unittest.TestCase):
|
|||
(destination / "desktop-objects/generations/gen_one/history.json").read_bytes(),
|
||||
b"{}\n",
|
||||
)
|
||||
self.assertEqual(
|
||||
(destination / "android-archive-acknowledgements-v1.json").read_bytes(),
|
||||
recovery.read_bytes(),
|
||||
)
|
||||
|
||||
remote.downloads.clear()
|
||||
drive.pull(remote, destination)
|
||||
|
|
|
|||
|
|
@ -1,282 +0,0 @@
|
|||
#!/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()
|
||||
|
|
@ -1,406 +0,0 @@
|
|||
#!/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()
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
#!/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:
|
||||
version = db.execute("PRAGMA user_version").fetchone()[0]
|
||||
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-v35 required, found v{version}")
|
||||
if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
|
||||
raise CausalError(f"desktop schema v20-v29 required, found v{version}")
|
||||
|
||||
|
||||
def target_snapshot(db: sqlite3.Connection, kind: str, target: str):
|
||||
|
|
|
|||
|
|
@ -182,8 +182,8 @@ def causal_payload(draft):
|
|||
|
||||
def require_schema(connection):
|
||||
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||
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-v35 required, found v{version}")
|
||||
if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
|
||||
raise LifecycleError(f"desktop schema v19-v29 required, found v{version}")
|
||||
|
||||
|
||||
def import_document(connection, document, own_transaction=True):
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from pathlib import Path
|
|||
from trainlog_sqlite import configure_connection
|
||||
|
||||
|
||||
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35)
|
||||
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29)
|
||||
MEASUREMENT_FIELDS = (
|
||||
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_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."""
|
||||
schema_version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||
if schema_version not in SUPPORTED_SCHEMA_VERSIONS:
|
||||
raise ValueError("schema desktop v15-v35 requis")
|
||||
raise ValueError("schema desktop v15-v29 requis")
|
||||
|
||||
root = {
|
||||
"format": "TRAINLOG_AI_EXPORT",
|
||||
|
|
|
|||
|
|
@ -20,9 +20,9 @@ ID_PREFIX = "aid_"
|
|||
def ensure_publication_state(connection):
|
||||
"""Install the additive v18 cursor for direct syncd/tool invocations."""
|
||||
owned_transaction = not connection.in_transaction
|
||||
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35)
|
||||
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v18-v35 requis")
|
||||
raise ValueError("schema desktop v18-v29 requis")
|
||||
columns = {row[1] for row in connection.execute(
|
||||
"PRAGMA table_info(ai_session_drafts)")}
|
||||
if "published_at" not in columns:
|
||||
|
|
|
|||
|
|
@ -28,9 +28,9 @@ def main():
|
|||
args = parser.parse_args()
|
||||
connection = connect_database(args.database)
|
||||
try:
|
||||
supported_versions = tuple(range(8, 36))
|
||||
supported_versions = tuple(range(8, 30))
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v8 à v35 requis")
|
||||
raise ValueError("schema desktop v8 à v29 requis")
|
||||
known_equipment = load_supplied_equipment_ids(args.catalog)
|
||||
known_equipment.update(row[0] for row in connection.execute(
|
||||
"SELECT equipment_id FROM custom_equipment"))
|
||||
|
|
|
|||
|
|
@ -24,9 +24,9 @@ def main(complete_causal_envelope=False):
|
|||
connection = connect_database(args.database)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
supported_versions = tuple(range(8, 36))
|
||||
supported_versions = tuple(range(8, 30))
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v8 à v35 requis")
|
||||
raise ValueError("schema desktop v8 à v29 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():
|
||||
raise ValueError("causal equipment protection requires the staged artifact")
|
||||
equipment = [dict(row) for row in connection.execute(
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ def main():
|
|||
con = connect_database(args.database)
|
||||
try:
|
||||
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, 30, 31, 32, 33, 34, 35):
|
||||
raise ValueError("schema desktop v12-v35 requis")
|
||||
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
|
||||
raise ValueError("schema desktop v12-v29 requis")
|
||||
rows = con.execute(
|
||||
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
|
||||
"ORDER BY source_exercise_id COLLATE BINARY"
|
||||
|
|
|
|||
|
|
@ -41,9 +41,9 @@ def main(complete_causal_envelope=False):
|
|||
connection = connect_database(args.database)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
supported_versions = tuple(range(11, 36))
|
||||
supported_versions = tuple(range(11, 30))
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v11-v35 requis")
|
||||
raise ValueError("schema desktop v11-v16 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():
|
||||
raise ValueError("causal BODY ZONES protection requires the staged artifact")
|
||||
exercises = []
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ def main():
|
|||
con.row_factory = sqlite3.Row
|
||||
try:
|
||||
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, 30, 31, 32, 33, 34, 35):
|
||||
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
|
||||
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
|
||||
rows = con.execute("""
|
||||
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
|
||||
# catalogue artifact is unchanged, but it must read the current
|
||||
# canonical desktop schema rather than accept a stale pre-v8 database.
|
||||
if version not in range(8, 36):
|
||||
if version not in range(8, 30):
|
||||
raise SystemExit(
|
||||
"PC_CATALOG_EXPORT=FAIL "
|
||||
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