Compare commits

..

No commits in common. "f2747b357bb0281d316fb4378797a7deff08bae9" and "5d58071601d5613f236c414cbe36cf1390ec2d90" have entirely different histories.

157 changed files with 285 additions and 16448 deletions

View file

@ -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

View file

@ -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;

View file

@ -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"
}

View file

@ -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>

View file

@ -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(

View file

@ -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()

View file

@ -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")

View file

@ -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
}
}

View file

@ -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 }
}
}

View file

@ -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)

View file

@ -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
}
}

View file

@ -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,
),
)
}
}
}

View file

@ -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,
),
)
}
}

View file

@ -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"
}
}

View file

@ -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()
}
}
}

View file

@ -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

View file

@ -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
}

View file

@ -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>,
)

View file

@ -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>,
)

View file

@ -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(),
)

View file

@ -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. */

View file

@ -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,

View file

@ -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() } },

View file

@ -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) {

View file

@ -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) }
}
}

View file

@ -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)

View file

@ -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) },
)
}
}
}
}

View file

@ -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,
)
}
}

View file

@ -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)) })

View file

@ -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))
}
},
)
}
}
}

View file

@ -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()

View file

@ -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++ }
}}
}

View file

@ -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>

View file

@ -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>

View file

@ -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"))

View file

@ -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") }

View file

@ -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"),

View file

@ -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 ->

View file

@ -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)

View file

@ -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)
}
}
}

View file

@ -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)
}
}
}

View file

@ -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)
}
}
}

View file

@ -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)
}
}
}

View file

@ -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)
}
}
}

View file

@ -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)
}
}

View file

@ -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"
)
)
}
}

View file

@ -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))

View file

@ -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)

View file

@ -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)
}
}
}

View file

@ -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)
}
}

View file

@ -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()
}
}
}

View file

@ -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)
}
}
}

View file

@ -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))
}
}

View file

@ -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()
}
}
}

View file

@ -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)
}
}
}

View file

@ -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()
}
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}
}

View file

@ -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())
}
}

View file

@ -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()
}
}
}

View file

@ -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"),

View file

@ -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)
}
}
}

View file

@ -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()
}
}
}

View file

@ -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)
}
}
}

View file

@ -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(

View file

@ -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()

View file

@ -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",

View file

@ -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())

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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)

View file

@ -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.

View file

@ -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.

View file

@ -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

View file

@ -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.

View file

@ -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',

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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"

View file

@ -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",

View file

@ -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)

View file

@ -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()

View file

@ -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()

View file

@ -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

View file

@ -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):

View file

@ -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):

View file

@ -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",

View file

@ -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:

View file

@ -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"))

View file

@ -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(

View file

@ -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"

View file

@ -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 = []

View file

@ -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,

View file

@ -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