Compare commits

...

103 commits
v0.1.2 ... main

Author SHA1 Message Date
fy59
9a8708c63b release: prepare v0.1.6 2026-09-25 04:57:10 +02:00
fy59
bb20aa44ef docs: redesign README with Catppuccin identity 2026-09-25 04:43:41 +02:00
fy59
45bb44e45d fix(cardio): truncate recovered session capture at factual end 2026-09-24 18:36:02 +02:00
fy59
bd795495df fix(sync): recover divergent sleep revision identities 2026-09-24 14:31:53 +02:00
fy59
a652644e24 fix(android): recover continuous session finalization 2026-09-24 13:29:30 +02:00
fy59
8646422bef fix(web): anchor sleep rows to factual timezone 2026-09-24 08:42:31 +02:00
fy59
77b5bbca85 fix(web): center sleep hours and refine date controls 2026-09-24 08:35:13 +02:00
fy59
ffcdc3efcf fix(web): align sleep axis and range PDF export 2026-09-24 08:18:53 +02:00
fy59
23717ba6da fix(sleep): record wake taps as timed intervals 2026-09-24 07:54:29 +02:00
fy59
de4b77d59b fix(web): synchronize sleep selection and PDF projection 2026-09-24 07:16:47 +02:00
fy59
4cb911d5c4 docs: record sleep persistence and agenda evidence 2026-09-24 06:54:11 +02:00
fy59
a60e1aa056 fix(web): reuse sleep projection in agenda timeline 2026-09-24 06:52:10 +02:00
fy59
696dac82a3 test(android): prove sleep intake lifecycle retention 2026-09-24 06:52:03 +02:00
fy59
fe79c44e51 docs(web): record sleep overlay private deployment 2026-09-24 06:31:34 +02:00
fy59
2ce458d286 feat(web): enrich sleep heart-rate timeline overlays 2026-09-24 06:29:57 +02:00
fy59
3a51211f01 fix(sync): restore automatic sleep bluetooth synchronization 2026-09-24 06:00:29 +02:00
fy59
2755d29f2d docs(web): record post-review validation and private deployment 2026-09-24 05:10:33 +02:00
fy59
713238fd0e refactor(web): finish post-review layout and visual corrections 2026-09-24 05:09:54 +02:00
fy59
d7ca6a1924 fix(sync): recover sleep sync and canonical web mutations 2026-09-24 05:05:38 +02:00
fy59
e7fbd6ff86 refactor(web): refine dashboard analysis and settings UI 2026-09-23 22:14:25 +02:00
fy59
7adeb59b06 feat(web): add factual equipment merge and analysis aggregates 2026-09-23 22:14:25 +02:00
fy59
f20e5c4d39 chore: open 0.1.6 development 2026-09-23 21:20:38 +02:00
fy59
f2747b357b release: prepare v0.1.5 2026-09-23 21:10:11 +02:00
fy59
f2fd610b4f feat(sleep): complete pre-bed capture and multi-night cardio review 2026-09-23 20:48:06 +02:00
fy59
c8bfb7a759 test(cardio): prove multi-occurrence program heart-rate ownership 2026-09-23 19:02:59 +02:00
fy59
21f56e3986 test(cardio): prove program session heart-rate timeline readiness 2026-09-23 18:54:29 +02:00
fy59
5a7f80049a fix(cardio): keep live capture coherent across session type changes 2026-09-23 18:14:45 +02:00
fy59
41d0f1ff96 fix(sync): carry ack recovery over transports 2026-09-23 16:40:20 +02:00
fy59
0750a47cef fix(sync): recover lost generation acknowledgements 2026-09-23 16:19:17 +02:00
fy59
8f8b361915 docs(release): record 0.1.5 validation 2026-09-23 15:58:09 +02:00
fy59
246e47ee8f feat(web): visualize synchronized heart rate 2026-09-23 15:24:16 +02:00
fy59
ad493a72aa feat(cardio): guide phases from measured heart rate 2026-09-23 14:42:59 +02:00
fy59
8371d5e16c feat(cardio): add measured calibration profile 2026-09-23 13:20:00 +02:00
fy59
3ded6ee425 feat(cardio): add cardio session type 2026-09-23 12:34:01 +02:00
fy59
070a29298f feat(android): add one-tap sleep capture 2026-09-23 11:47:32 +02:00
fy59
dd39c85f94 feat(training): timestamp exercise execution 2026-09-23 11:32:15 +02:00
fy59
1ca354af53 feat(android): add persistent heart rate capture 2026-09-23 10:33:02 +02:00
fy59
5bf90899bd test(cardio): validate CYCPLUS H2 BLE profile 2026-09-23 10:13:26 +02:00
fy59
984b1e058e feat(android): add heart rate BLE diagnostics 2026-09-23 10:07:24 +02:00
fy59
14c3ac294e feat(cardio): add heart rate persistence and sync 2026-09-23 09:40:05 +02:00
fy59
c13549338c docs(cardio): freeze v1 implementation contract 2026-09-23 09:26:14 +02:00
fy59
e1d6372338 chore: open 0.1.5 development 2026-09-22 18:45:49 +02:00
fy59
5d58071601 release: prepare v0.1.4 2026-09-22 18:28:12 +02:00
fy59
b4fd753ac5 feat(web): chart body measurement trends 2026-09-22 18:07:12 +02:00
fy59
8a334613f8 fix(web): refresh next session after sync 2026-09-22 17:54:05 +02:00
fy59
26d24d2ed7 docs(sync): freeze bluetooth transport v1 2026-09-22 16:53:25 +02:00
fy59
1d8450ad1c fix(android): repair bluetooth bond on startup 2026-09-22 16:25:40 +02:00
fy59
d5b4584ae2 feat(sync): add automatic bluetooth transport 2026-09-22 16:02:46 +02:00
fy59
b580b63886 fix(sync): serialize auto mtp probe 2026-09-22 14:10:47 +02:00
fy59
066a3a44a7 fix(sync): arbitrate shared mtp transport 2026-09-22 14:06:29 +02:00
fy59
87931ca1ec fix(sync): serialize daemon mtp discovery 2026-09-22 14:00:58 +02:00
fy59
c11ea07a19 fix(web): refresh stale sync csrf token 2026-09-22 13:57:09 +02:00
fy59
3347de3dbb fix(sync): reject boolean worker timeout 2026-09-22 13:34:39 +02:00
fy59
0a35ddacbf fix(sync): carry sleep revision ancestry 2026-09-22 13:34:28 +02:00
fy59
a88e85c235 fix(sync): ignore terminal legacy generation capacity 2026-09-22 13:15:49 +02:00
fy59
0f7d793960 fix(sync): ignore stale sleep snapshot ancestors 2026-09-22 13:00:23 +02:00
fy59
dd6d2a180d fix(programs): recover historical program provenance 2026-09-21 22:27:56 +02:00
fy59
891c09179e fix(programs): reflect completed sessions in progress 2026-09-21 21:36:25 +02:00
fy59
e6e5e76f68 fix(web): align sleep agenda and isolate active nights 2026-09-21 17:34:14 +02:00
fy59
9652b35189 fix(report): localize sleep diary pdf 2026-09-21 17:09:24 +02:00
fy59
a04e41d252 test(web): parameterize deployed sleep diary review 2026-09-21 16:21:41 +02:00
fy59
20fe562cd8 fix(web): refresh sleep agenda from persisted revisions 2026-09-21 16:19:39 +02:00
fy59
a98a4440ed docs(review): record sleep diary private deployment 2026-09-21 15:41:53 +02:00
fy59
915728cec4 fix(web): stabilize and refine sleep diary capture 2026-09-21 15:38:51 +02:00
fy59
47ee128807 fix(android): complete active equipment workflow 2026-09-21 15:15:16 +02:00
fy59
e320767516 fix(android): create equipment from active editor 2026-09-21 14:25:19 +02:00
fy59
c5e62c2e5e fix(android): render active exercise editor 2026-09-21 13:35:05 +02:00
fy59
e2284be1e3 fix(android): start mixed session preparations 2026-09-21 13:34:43 +02:00
fy59
f48be35f3b fix(web): expose sleep diary capture workflow 2026-09-21 08:03:46 +02:00
fy59
0cf53228da feat(sleep): add daily draft publication lifecycle 2026-09-21 07:30:55 +02:00
fy59
afd1fcc92f feat(sleep): add structured medication intakes 2026-09-21 07:01:20 +02:00
fy59
53f15e1354 fix(packaging): declare sleep diary schemas 2026-09-21 06:25:05 +02:00
fy59
1096b13939 docs: document sleep diary v1 2026-09-21 06:24:17 +02:00
fy59
bf27e05e7a feat(android): add sleep diary capture 2026-09-21 06:24:13 +02:00
fy59
36a7e651ac feat(web): add sleep diary workspace and pdf 2026-09-21 06:24:10 +02:00
fy59
30df3030e6 feat(sync): synchronize sleep diary 2026-09-21 06:24:05 +02:00
fy59
2565d0eafa feat(core): add sleep diary domain 2026-09-21 06:23:58 +02:00
fy59
6948e0bbd5 refactor(web): refine analysis visual presentation 2026-09-20 21:06:10 +02:00
fy59
c31c394e4f refactor(web): improve dashboard summaries 2026-09-20 20:38:39 +02:00
fy59
109eb6fcd4 docs: document 0.1.4 analysis foundation 2026-09-20 20:35:16 +02:00
fy59
2bd68100e9 feat(web): implement analysis read models and workspace 2026-09-20 20:35:16 +02:00
fy59
451d0a36a0 chore: open 0.1.4 development 2026-09-20 20:19:54 +02:00
fy59
bedf392542 release: prepare v0.1.3 2026-09-20 20:17:13 +02:00
fy59
0262433615 docs(sync): document decoupled generation admission 2026-09-20 19:22:19 +02:00
fy59
ab3986200e feat(sync): separate full-generation Android trigger 2026-09-20 19:22:19 +02:00
fy59
eb6d70fb62 docs(sync): document Android conversation ownership 2026-09-20 17:54:47 +02:00
fy59
38d304d014 fix(android): prioritize explicit sync conversations 2026-09-20 17:54:43 +02:00
fy59
ca2ea9379b docs(sync): document MTP visibility guarantees 2026-09-20 15:30:06 +02:00
fy59
0e13c3947a fix(android): keep body metrics two-column on normal phones 2026-09-20 15:30:06 +02:00
fy59
60b08f225a fix(sync): throttle MTP polling and preserve timeout cause 2026-09-20 15:30:06 +02:00
fy59
a2eaf224e8 fix(sync): make Android generation publication visible to MTP 2026-09-20 15:30:00 +02:00
fy59
ba5021b436 fix(sync): retain strict V3 fallback publication 2026-09-20 14:40:59 +02:00
fy59
99e428c558 docs(sync): reconcile rollout readiness 2026-09-20 14:37:07 +02:00
fy59
2a68e5a505 docs(sync): document full-generation daily routing 2026-09-20 14:35:42 +02:00
fy59
1e9c8aa741 fix(sync): route Android requests through full generations 2026-09-20 14:35:42 +02:00
fy59
f27ef5af5f refactor(android): compact drafts and body measurement screens 2026-09-20 14:26:34 +02:00
fy59
53baa7ad24 chore(android): keep private candidates on 0.1.3 2026-09-20 13:29:58 +02:00
fy59
e41949557b docs(ui): document unified capture interface 2026-09-20 13:23:08 +02:00
fy59
d56bd3b54f feat(web): refine program progress presentation 2026-09-20 13:23:08 +02:00
fy59
cb4010dbd5 feat(android): unify capture-first mobile interface 2026-09-20 13:23:08 +02:00
fy59
88e0eb0fb7 feat(web): add exercise catalog administration 2026-09-20 12:11:45 +02:00
fy59
0a9639bb94 docs: define 0.1.3 development roadmap 2026-09-20 08:55:08 +02:00
fy59
5701e2a541 chore: start 0.1.3 development 2026-09-20 08:27:56 +02:00
299 changed files with 49403 additions and 2384 deletions

View file

@ -1,5 +1,422 @@
# Changelog
## 0.1.6 — 2026-09-25
- Corrected delayed session finalization so a heart-rate capture cannot extend
beyond the explicit factual session end. Android now transactionally removes
strict post-end BPM/RR tails while retaining an exact-cutoff sample. For an
already acknowledged capture, the separately versioned
`trainlog-heart-rate-corrections` V1 companion carries an immutable
`capture_id|cutoff` correction through the existing causal ledger without
widening frozen causal-deletions V1. Old Heart Rate V1 generation replay
applies that cutoff before identity comparison and cannot resurrect removed
measurements. A guarded dry-run recovery tool reports before/after counts and
statistics without rewriting published manifests or ACKs.
- Completed the paired-device recovery for session
`se_fa845379-794d-4bb6-8cd3-48187ebbd134`: Android and desktop now retain
3,429 BPM samples and 3,429 RR values through the inclusive 11:16 cutoff,
and two successive full MTP generation exchanges completed with durable
consumed ACKs. Replaying the original 4,919-sample artifact cannot restore
the removed tail. Generation parent selection now excludes archived or
legacy acknowledgement rows lacking durable ACK evidence, while consumers
accept the producer's declared parent when it is one of multiple retained
current tips. Android Sleep V2 persistence also names medication-intake
columns explicitly, preserving `quantity`, note, and creation time across
the additive v33 layout.
- Recovered a real divergent Sleep revision identity without choosing an
arbitrary winner. The unpublished desktop branch was re-identified with its
exact content, the immutable Android revision retained its published ID and
parent, and a fresh successor preserved the non-conflicting qualities,
events and medication occurrences. The original immutable generation now
has a `consumed` / `sqlite-commit-full` ACK and the repaired 24 September
session is available through Desktop and Web with its timeline, feedback,
cardio and Program provenance.
- Added Desktop schema v36 and Android schema v33 append-only guards for Sleep
revision payloads. Both importers now reject a known historical revision ID
whose parent or canonical content differs, even when that revision is below
the current tip. A dedicated transactional recovery utility and regressions
cover exact replay, parent/content collisions, medication quantities,
rollback, immutable generation retry, and long UUID/parent chains.
- Fixed Android finalization of Program-derived sessions containing a completed
target-only continuous occurrence. The repository now materializes the
performed duration from the persisted exercise timeline, including preserved
drafts closed by an older build, while keeping planned set counts independent
from valid actual sets, repetitions and loads. A strictly identity-bound
private recovery build may supply the factual session end; strict late
heart-rate BPM/RR tails are now removed transactionally at that boundary.
- Hardened desktop Sleep generation consumption when an identical durable
revision has no local publication row. Content comparison now reads the
durable revision directly, acknowledges an exact match, and reports a real
revision-identity conflict instead of an empty rejection diagnostic when the
same ID carries different content.
Development opened after the stable v0.1.5 release. The Web refinement tranche
adds persistent French/English settings, denser shared surfaces, ISO-week
Dashboard activity, factual exercise-count overview charts, measured-timeline
muscle-group drill-down, normalized measurement summaries, one shared refined
BODY ZONES silhouette, and a compact Program header. A new Equipment workspace
lists factual references and transactionally absorbs a custom duplicate into a
canonical identity while retaining history/MAX context and recording a causal
deletion marker; immutable embedded drafts cause an explicit conflict.
The post-review correction keeps acknowledged-generation capacity recoverable
for legacy Android rows without ACK-ledger evidence, proves two successive
Sleep-only generation/ACK exchanges, and never evicts an unacknowledged
generation. Web can now causally remove only an orphaned zero-occurrence active
draft while retaining protection for real active drafts. Mutation UUIDs use
`crypto.getRandomValues()` on loopback HTTP origins. Sleep timeline geometry,
Dashboard sizing, Program progress contrast, chart proportions, duration
coverage messaging, and the right-aligned Settings/Sync action cluster are
also refined.
- Restored automatic Sleep-only Bluetooth synchronization after a real-night
trace exposed several restart and first-import gaps. The user-service
installer now removes only its obsolete `syncd` ExecStart override and
installs the RFCOMM agent;
Android resumes the same unsuperseded immutable generation across a new
conversation run and accepts its exact generation/digest ACK even though
the immutable ACK retains the original run identity. Ledger-proven
superseded branches remain retained without exhausting admission. Desktop
progress validation accepts the existing ACK-recovery phase, and a fresh
desktop can seed a non-root Sleep V2 current snapshot when its validated
ancestry is present. Regression
coverage carries medication quantity, Couché/Réveil/Levé and Sleep BPM/RR
through import, ACK, replay and a second Sleep-only generation with no
Android workout session.
- Enriched the selected Sleep night's heart-rate chart with declared sleep
ranges, explicit awakening ranges and point events, medication markers that
preserve unit dose and quantity, exact start/end bounds, and every
intervening hourly tick. When no declared sleep interval exists, the Web
presentation alone shows the conservative bedtime +45 minutes to final
get-up -10 minutes estimate, labels it as estimated/non-medical, and never
writes it to Sleep data. Nights without synchronized HR retain these factual
and estimated timeline overlays without inventing BPM samples. The private
`trainlog.perf` deployment and read-only Firefox smoke validate the latest
real HR night and the latest real night containing medications from code
commit `2ce458d`.
- Reused that same read-only fallback projection in the multi-night Sleep
agenda, with a visibly estimated hatched interval and no Sleep write. The
agenda now derives an exact 18:00-to-18:00 hourly grid, presents at least
two-hour labels on ordinary desktop widths, expands to every hour when wide,
and reduces to six-hour anchors on narrow screens. A read-only real-device
investigation of the 2026-09-23 night found no medication intake in any
retained local revision and established that later synchronization only
published the already-empty current revision. Android regression coverage
now proves two pre-bed intakes, including quantity two, through repeated
repository reopen, bedtime, wake, final get-up, generation, desktop ACK,
replay, stale-ancestor input and a subsequent revision. The private service
serves Web code commit `a60e1aa`; a non-mutating Firefox smoke on the real
night confirms the identical 22:50–04:04 agenda/detail projection and the
responsive 13/25/5-label axis densities.
- Unified the Web Sleep agenda, selected-night HR detail, and vector PDF on one
temporal projection. Every structured long-awake interval remains a distinct
band, row selection updates the detail dates and content without persistence,
and preview/download export exactly the active night with observations
recomputed for that subset. Sleep duration now consistently sums factual
sleep intervals or, only when none exist, uses the displayed Couché +45 min
to Levé -10 min estimate minus structured awakenings; the estimate is never
written to SQLite.
- Changed future Android one-tap Réveil capture from punctual `night_get_up`
facts to structured `long_awake` windows under the user-approved 30-minute
default. A repeated tap inside the window extends its end to 30 minutes after
the latest tap, while Levé clamps it to the factual final-get-up timestamp.
The policy is revisioned, undoable, and independent of BPM/RR inference.
- Centered the Sleep agenda's hour labels inside a shared 24-column visual grid
and added an inclusive night-start date range for PDF preview and export.
The duplicate terminal `18` is removed; detail/HR night selection remains
independent from the report range, whose date fields now use a grouped
Trainlog form treatment.
- Fixed a real Sleep agenda offset bug when revision metadata used UTC `Z` but
factual events used the user's local offset: the row now anchors its complete
18:00 axis to one factual night offset. Agenda night headings use French
`DD/MM/YYYY` rather than persistence-oriented ISO dates in the French UI.
## 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
changing full-generation business semantics. The paired deployment selects
Bluetooth before MTP, authenticates the configured bonded Android peer with
the persisted Trainlog peer identity, rate-limits reconnect noise, and keeps
MTP as the wired recovery transport. This transport is for Trainlog data
synchronization only; heart-rate sensor ingestion remains future 0.1.5 work.
- Refreshed the Web next-session projection after synchronization and added
factual body-measurement trend charts through the existing bounded analysis
read model without introducing health interpretation or estimated metrics.
- Fixed Program progress for sessions started from a delivered manual
preparation. The preparation companion now carries its stable Program and
Program-session provenance through Android start, finalization, restart and
the existing execution companion; legacy stored deliveries are safely
enriched only when every pre-existing field and execution identity matches.
Android and Web consequently present the completed session and progress from
the same persisted identity without fabricating history.
Historical acknowledged deliveries now complete that recovery on the
original session even when the preparation contained planned continuous
activities, and current sleep snapshots can initialize a fresh peer from a
non-root revision without discarding causal identity.
- Aligned the Sleep Diary Agenda ticks, interval bands and point markers on one
exact 18:00-to-18:00 timestamp projection. Selecting another night now clears
the previous entry immediately, adopts only an exact-date result and rejects
stale out-of-order reads; an absent night remains unpersisted and empty until
the first real edit. The editor presents local completion as Draft,
Day validated or Modified without exposing synchronization transport state.
- Fully localized the French Sleep Diary PDF without changing its canonical
data source or event semantics. Column headings, timeline labels, legend,
factual summary, draft warning, dates and plurals are now French, accented
text uses the PDF WinAnsi encoding, and durations use human-readable units.
Long awakenings remain proportional intervals between independent sleep
ranges, while medication intake snapshots retain localized dose and unit.
The English report keeps an independent English vocabulary.
- Made the Sleep Diary Agenda a live projection of the latest locally
persisted entry revision. Event and intake mutations now re-read and adopt
the canonical Core snapshot, serialized autosaves carry the returned
revision forward, and stale asynchronous reads cannot regress the visible
state. The Agenda shows factual bedtime, final get-up, declared-sleep and
time-in-bed durations, long awakenings, naps, sleepiness and grouped
medication markers without requiring reload or publication. Medication
catalog entries and selectors now include their usual localized dose and
unit, keep same-name/different-dose records distinct, and prefill editable
intake doses. Schemas, domain semantics, synchronization and PDF generation
are unchanged.
- Fixed the Sleep Diary Web workspace becoming entirely blank after adding an
event or medication intake on the loopback-routed HTTP origin. Sleep event
and intake UUIDv4 identities now use secure random bytes without depending
on the secure-context-only `crypto.randomUUID()` API, and a section-local
error boundary preserves the Trainlog shell if an unrelated render failure
occurs. The capture workspace now reuses Trainlog cards, controls, status,
primary/secondary/danger actions and responsive spacing for a structured
night header, appreciations, event timeline, medication catalog and intake
editor. Domain data, schemas, revisions, synchronization and PDF semantics
are unchanged.
- Fixed equipment creation from the Android active-session exercise editor.
The selector now exposes the complete catalogue in a bounded scrollable
list, reuses the catalogue creation route, returns to the same stable
occurrence with all draft fields intact, and selects the explicitly created
equipment. Target-only prepared occurrences can save that equipment in
place without fabricating performed sets or appending a duplicate occurrence.
- Fixed the Android active-session exercise editor for target-only continuous
preparation entries. Opening an occurrence now renders its prefilled form,
preserves its independent plan while editing performed values, updates only
the selected stable occurrence, and reports persistence failures instead of
displaying a false editor-opened confirmation.
- Fixed Android activation of mixed session preparations containing continuous
duration targets and set-based exercises. Continuous target metadata now
remains planning-only, SQL `NULL` set counts round-trip correctly, and no
performed continuous activity or set is created before user capture.
- Added Sleep Diary V1 across desktop schema v29, Android schema v26, Analyse,
full-generation synchronization and a deterministic local vector PDF agenda.
Entries retain causal revisions, absolute timestamped events, the five-value
appreciation scale and user-authored notes; no medical interpretation or
heart-rate data is introduced. Sleep Diary also includes a revisioned local
medication catalog and timestamped intake snapshots with optional positive
dose/unit, synchronized and rendered factually in the agenda and PDF without
efficacy, interaction, causality, dosage, or clinical advice.
Progressive daily capture now persists every edit locally while keeping
draft, ready, synchronized, and post-sync modified states distinct. Explicit
validation publishes only the guarded current revision through the existing
full-generation/ACK path; validation never makes a day immutable.
Development opened after stable v0.1.3 release.
- Implemented the loopback-only Web Analyse V1 workspace and shared bounded
Core read model. The 7/30/90-day and all-history filters, factual activity,
active-Program progress, compatible exercise metrics, explicit persisted
MAX results, primary BODY ZONE exposures, and fourteen body measurements are
calculated by the C17 backend. Dashboard summaries consume the same model
while preserving all existing tile identities and layout behavior. Deep
links, safe query fallbacks, null/empty states, French/English presentation,
keyboard focus, and responsive charts are covered without estimating 1RM,
readiness, fatigue, recovery, body fat, or performance.
- Refined the Analyse V1 and Dashboard presentation without changing their
read models or API contracts. Period controls now share the section toolbar;
durations use the shared human-readable formatter; incompatible exercise
metrics remain hidden; activity, exercise, and measurement charts expose
chronological dates, units, and factual tooltips; BODY ZONE silhouettes and
exposure comparisons are more legible; and active-Program summaries use a
compact structured progression layout. Persisted Dashboard tile identities
and layout behavior remain unchanged.
## 0.1.3 — 2026-09-20
- Decoupled the Android full-generation request from the strict legacy V3
compatibility signal. One explicit intent now publishes the dedicated
`trainlog-sync-full-generation-request-v1.json` channel first; the legacy
request is published with the same identity only after a fresh V3 snapshot
succeeds. The desktop daemon prioritizes this channel and durably
deduplicates a bounded set of request identities across both channels.
Development opened after the stable v0.1.2 release.
- Made explicit Android synchronization intent authoritative over automatic
background resumption. A process-wide conversation arbiter now yields an
older background wait before the foreground publishes its fresh request
UUID, and `NEW_EXPLICIT` ignores the request/run that existed before the
click. Background USB and Drive workers classify new requests, new
correlated remote evidence, legitimate handoff, and stale resumable state;
terminal runs are no longer retried every five seconds without new proof.
Durable generations are retained across yield and handoff, and desktop
request-ID deduplication remains strict. No schema, protocol, causal rule or
exchange format changed.
- Hardened direct-MTP full-generation conversations after a private Android
run was frozen before generation capture. Android now emits bounded
run/phase metadata, refreshes the user-enabled background listener before a
foreground request, and confirms MediaProvider/MTP visibility for immutable
generation objects before publishing their reference. Desktop MTP pulls are
throttled to one complete refresh every three seconds, keep a separate
30-second operation budget, and report expiration of the conversation as a
correlated protocol timeout instead of a misleading final one-second
transport timeout. Explicit requests continue to receive fresh UUIDs.
Mensurations now retains two compact columns at the deployed phone's 312 dp
content width, with a narrow/large-font fallback. Schemas, causal semantics,
generation formats, ACK formats and standalone V3 strictness are unchanged.
- Routed Android foreground and daemon-triggered daily synchronization through
the existing full-generation orchestrator whenever its trusted local
configuration is present. The standalone mobile V3 importer remains strict
and still rejects causally protected live replay; complete generations apply
tombstones before filtering dominated snapshot facts. No schema, exchange
format, causal rule, or synchronization protocol changed. Android backup
manifests now obtain `product_version` from installed package metadata
instead of retaining the historical `0.1.2` literal.
- Unified the Android capture interface around compact dark cards, action
tiles, restrained-radius button variants, wrapping selection grids, and
two-column performed-set entry. Home now focuses on session capture, recent
BODY ZONES exposure, synchronization, and measurements; mobile Statistics
navigation was removed without changing stored data or exchange contracts.
Programs now show compact session metadata, completed progress, and explicit
start/resume/completed states using the existing schema-v25 durable Program
execution provenance. Web Program administration gained adaptive cards, a
visible progress bar, and green completed-session treatment while retaining
its revision-guarded trash confirmation and causal deletion behavior.
Android full lint remains at the exact ten-error `main` baseline (one
`NewApi`, one `LocalContextConfigurationRead`, and eight bilingual
`StringFormatMatches` diagnostics); this lot adds no lint error and disables
no rule. Private daily candidates retain development `versionName=0.1.3` and
may override only an increasing `versionCode` at build time.
- Added the loopback-only `/exercices` desktop catalogue administration
surface with bounded search/profile/BODY ZONES filtering, detail and
read-only equipment presentation, Core-owned creation and editing, complete
optimistic concurrency, and protected causal retirement. Profile changes
preserve stable exercise IDs and all historical occurrence snapshots.
Mutations remain desktop-local until the existing USB-priority, Drive
mirror/fallback synchronization publishes the existing catalogue companions;
no Android code, schema, exchange format, or automatic synchronization was
added.
## 0.1.2 — 2026-09-20
- Added USB-priority full-generation synchronization with a separately

564
README.md
View file

@ -1,370 +1,124 @@
# Trainlog
<p align="center">
<img src="docs/assets/trainlog-banner.svg" alt="Trainlog — local-first training data system" width="100%">
</p>
Synchronization uses one generation/ACK engine over USB/MTP first and an
optional private Google Drive mirror/fallback. Drive contains only versioned
Trainlog artifacts under its separate Sync namespace; desktop and Android
SQLite databases always remain local. Android can process PC USB requests from
an explicitly enabled, visibly notified background service, while Drive checks
follow Android's bounded background scheduling.
<p align="center">
<img src="docs/assets/trainlog-badges.svg" alt="Trainlog badges: local-first, C17, Android, local Web, GPLv3" width="720">
</p>
Trainlog is a local-first workout and body-data system. Trainlog Core owns
business truth and canonical desktop persistence. Its native Android app is the
field companion, its C17/Notcurses desktop TUI is the administration and
technical surface, and its local browser Web sibling provides the implemented
Dashboard, Sessions, and Programmes surfaces while reserving Analyse and
Exercises for separately contracted work.
Trainlog is a **local-first system for recording, synchronizing, and examining
training and body data**. Its Android companion handles capture in the field;
the C17 desktop core keeps the canonical long-term history; a keyboard-first
Notcurses TUI and a loopback-only Web application provide complementary ways
to inspect and manage it.
Android and desktop each own a local SQLite database. The desktop database is
the canonical long-term history. Trainlog synchronizes versioned JSON artifacts
over direct MTP; it never copies SQLite database files between devices.
The system is built around explicit facts, stable identities, versioned JSON
exchanges, and user-controlled storage. Plans never become performed work until
they are actually recorded, continuous activities are not disguised as sets,
and estimates are kept distinct from measurements.
## Product split
> **Release line:** The latest stable release is
> [`v0.1.6`](https://github.com/labfytools/trainlog/releases/tag/v0.1.6).
| Surface | Primary responsibility |
|---|---|
| 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.2) | Display the Dashboard, the operational top-level Programmes daily active-program calendar, and the implemented Sessions inventory, deletion, manual preparation, Android-delivery, and Programs administration workflow through typed Trainlog Core/API boundaries. Programs has responsive detail, terminal logical deletion, and a privately deployed read-only Android projection. Analyse and Exercises remain placeholders. |
## ✦ Showcase
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
CLI/HTTP adapter, embedded frontend, read-only Dashboard API, factual tiles,
visualizations, and private layout persistence are implemented. This does not
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.
<p align="center">
<img src="docs/reviews/evidence/sleep-diary-alignment-firefox.png" alt="Trainlog Web Analyse workspace showing the Sleep Diary agenda and factual heart-rate timeline" width="100%">
</p>
## Releases
<p align="center"><sub>Web Analyse in Trainlog 0.1.6 — synchronized Sleep facts and measured heart-rate data on one timeline.</sub></p>
When a Trainlog release is published, its prebuilt assets are available from
both official mirrors:
The repository currently tracks real-browser evidence for the Web interface.
Its Android overview asset is a clearly labelled static design study with
fictional data, not a runtime screenshot; no current TUI screenshot is tracked.
Those assets are therefore not presented here as product captures.
- [GitHub Releases](https://github.com/labfytools/trainlog/releases)
- [Forgejo Releases](https://git.labfytools.com/fy59/trainlog/releases)
## ✦ Features
Each published stable release provides:
| Area | Available in the current development tree |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Training capture** | Metadata-driven sets, repetitions, durations and continuous activities; heterogeneous actual sets; durable active draft; exercise timelines; explicit measured MAX results. |
| **Planning** | Manual preparations, imported Programs, Android delivery, execution provenance and strict separation between targets and facts. |
| **Context** | Exercise and equipment catalogues, BODY ZONES, body measurements, immediate feedback, wording revisions and J+1 follow-ups. |
| **Cardio** | Android BLE heart-rate acquisition, factual BPM/RR history, cardio sessions, measured calibration and fresh-signal phase guidance. |
| **Sleep** | One-tap Android capture, causal Sleep Diary revisions, medication occurrences, synchronized factual timelines and local vector PDF export. |
| **Desktop** | C17 Core, canonical SQLite history, keyboard-first Notcurses administration, correction, statistics, graphs and synchronization diagnostics. |
| **Local Web** | Loopback-only Dashboard, Analyse, Programmes, Sessions, Exercises, Equipment, Settings and synchronization surfaces backed by typed Core services. |
| **Synchronization** | One generation/ACK business engine over authenticated Bluetooth Classic RFCOMM, direct USB/MTP recovery and a separately configured private Drive workflow; SQLite files are never exchanged. |
- a signed Android APK;
- a Linux x86-64 desktop runtime bundle;
- SHA-256 checksums.
### Boundaries and work in progress
Both mirrors publish the same Trainlog product version and release assets. The
latest stable version is **0.1.2** on Android and desktop; this is one shared
Trainlog version, not separate interface versions. Version 0.1.1 is the
previous stable release.
- `TRAINLOG_FORMAT_V1` remains frozen. The active mobile snapshot is V3; V1
and V2 remain readable, while V4 codecs are staged but not selected by the
active transport.
- Full-generation synchronization is deployed only behind a trusted local
opt-in; the default automatic exchange remains V3.
- AI history export is active. AI session proposals still have a pending real
Drive plus Android-triggered bidirectional smoke test.
- Session Generator V1 is implemented but hidden while the V2 planning model
is developed.
- Web Analyse is implemented; its recorded visual-review gate remains open.
Trainlog presents French by default, with English selectable
from **Settings → Language** on Android and the desktop TUI. The selection is
local to that installation and updates the visible interface immediately; it
does not translate user exercise/catalogue names or change training data,
stable IDs, databases, schemas, exchange artifacts, synchronization protocols,
AI, MAX, or feedback. Synchronization status is rendered locally from typed
status and counters; raw protocol and operational summaries are not translated
or injected into the other surface.
The canonical status and limitations live in
[`docs/current_state.md`](docs/current_state.md); future work belongs in the
[`roadmap`](docs/roadmap.md).
See the [documentation](docs/README.md) and [changelog](CHANGELOG.md) for more
details.
## Installation
### Download a prebuilt release
For a published release, download Trainlog from either official mirror:
- [GitHub](https://github.com/labfytools/trainlog/releases)
- [Forgejo](https://git.labfytools.com/fy59/trainlog/releases)
Both mirrors contain the same Android APK, Linux x86-64 runtime bundle, and SHA-256
checksums for each stable Trainlog release. Verify the downloaded binaries
against `SHA256SUMS` before installing them.
The Linux artifact is an architecture-specific tar archive whose published
filename intentionally remains `trainlog-tui-linux-x86_64-v<version>`. It
contains the TUI/Web executable, synchronization daemon and helpers, catalogs,
and a hashed inventory from the same release commit. Its native executables are
dynamically linked, so the host must provide compatible runtime libraries
listed under [Dependencies](#dependencies).
```bash
mkdir trainlog-v<version>
tar -xf trainlog-tui-linux-x86_64-v<version> -C trainlog-v<version>
./trainlog-v<version>/bin/trainlog
```
The extracted bundle can remain in a user-managed installation directory.
Linking its launchers from `bin/` into `~/.local/bin` is optional.
For Android, download the release APK, allow installation from the browser or
file manager when Android requests it, and install the package. Android 8.0
(API 26) or later is required. Grant Trainlog all-files access only when direct
`Documents/Trainlog` synchronization is needed. Desktop synchronization also
requires the Linux side and its MTP dependencies to be installed and configured.
Android and the TUI in each stable release share the same Trainlog product
version on both mirrors. Database schemas and JSON protocol versions are
independent.
### Daily synchronization on an opted-in installation
After explicitly enabling Android background synchronization once, connect and
unlock the paired phone and select **Synchronize** in the local Web Dashboard.
USB/MTP is preferred when the Trainlog peer is usable; the separately selected
private Drive folder is updated as a mirror and is the fallback transport. Wait
until Web reports the correlated generation and acknowledgements. A failure
remains durable and should be diagnosed before retrying; do not delete
generation, ACK, database, or backup files. Full-generation mode and Drive
remain trusted per-installation configuration.
### Build from source
Desktop:
```bash
meson setup build
meson compile -C build
meson test -C build --print-errorlogs
```
Android:
```bash
cd android
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew test
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew assembleRelease
```
`assembleRelease` produces a distributable stable APK only when release signing
is configured by the release operator. The repository does not contain signing
credentials and does not silently generate them.
Release hosts provide `TRAINLOG_RELEASE_STORE_FILE`,
`TRAINLOG_RELEASE_STORE_PASSWORD`, `TRAINLOG_RELEASE_KEY_ALIAS`, and
`TRAINLOG_RELEASE_KEY_PASSWORD`, or a private
`~/.config/trainlog/release-signing.properties` file containing the equivalent
`storeFile`, `storePassword`, `keyAlias`, and `keyPassword` keys. Environment
variables take precedence. Keystores and local credential files are ignored by
Git; neither belongs in source control, build logs, or release notes.
The Android release key is the permanent update identity for the application.
The release operator must preserve an encrypted external backup of both the
keystore and its credentials. Losing either prevents future compatible Android
updates; generating a replacement key is not a normal release procedure.
## Dependencies
### Linux runtime dependencies
The published v0.1.2 x86-64 desktop runtime is dynamically linked. Its tagged
source directly requires compatible versions of:
- glibc and the GCC support runtime;
- SQLite 3;
- libuuid;
- utf8proc;
- libudev;
- libmtp;
- Notcurses Core;
- the standard math library.
Version 0.1.2 additionally links GNU libmicrohttpd and yyjson for the local Web
adapter and Dashboard layout configuration. Those are not retroactive
requirements of the pre-Web v0.1.1 tagged source.
Distribution packages may pull additional transitive libraries, including
libusb, ncursesw, unistring, gpm, libgcrypt, libgpg-error, and libdeflate.
Package names and ABI versions vary by distribution; inspect the release's
`ldd` evidence and install packages from the target distribution rather than
assuming one universal package command.
### Optional runtime and integration dependencies
- Android is optional when using the desktop TUI alone. USB/MTP synchronization
requires the Android companion, libudev, libmtp, and an unlocked connected
device.
- `trainlog-syncd` installation expects a systemd user session. Manual TUI
synchronization does not require the user service.
- `rclone` is required for desktop Google Drive synchronization and automatic
AI Drive workflows. Core capture, local history, analytics, and direct MTP
synchronization continue to work without `rclone`.
### Build dependencies
Desktop builds require:
- Meson and Ninja;
- a C17 compiler toolchain;
- `pkg-config`;
- Notcurses Core development headers;
- SQLite, libuuid, utf8proc, libudev, libmtp, GNU libmicrohttpd, and yyjson
development headers for the current development source;
- Python 3 for generated sources, validators, import/export helpers, and tests.
The embedded Web frontend additionally uses Node and npm at build time only.
Prepare its exact locked dependencies explicitly, then enable the release
frontend build:
```bash
cd web
npm ci
npm run typecheck
npm test
cd ..
meson setup build -Dweb=enabled
meson compile -C build
```
Meson never runs `npm install` or downloads packages. Run `npm ci` explicitly
for the exact lockfile before configuring a Web-enabled build. The default `web=auto`
embeds the frontend when Node, npm, and the prepared `web/node_modules` are
available; otherwise it preserves a desktop-only build and `trainlog -w`
reports that frontend support is absent. Release builds must use
`-Dweb=enabled`. `-Dweb=disabled` is the explicit desktop-only choice. Node,
npm, `node_modules`, and `web/dist` are not runtime dependencies.
For an existing build directory, change the option with `meson configure
build -Dweb=enabled` (or the required value) instead of running `meson setup`
as though the directory were new.
### Optional systemd user service for local Web development
The versioned `systemd/trainlog-web.service` unit can keep `trainlog -w`
available on `127.0.0.1:8080` during an interactive user session. Without
systemd user lingering, enabling it does not guarantee startup before login.
This is a development convenience, not a requirement of the final Trainlog
distribution.
It intentionally runs the canonical user entry point
`~/.local/bin/trainlog`; in a source checkout that entry point may be a symlink
to the current `build/tui/trainlog` binary, but the symlink must resolve to an
executable before the service is started.
The unit prevents privilege acquisition, gives the process a private temporary
directory, makes system paths read-only, and applies kernel/control-group and
setuid restrictions that are compatible with the local HTTP server. It does
not protect or hide the home directory because SQLite and Trainlog
configuration must remain writable under `~/.local/share/trainlog` and
`~/.config/trainlog`. Failed starts use a ten-second restart delay and are
limited to three attempts per minute, preventing a persistent port-8080
conflict from producing an uncontrolled loop.
Install the development unit as a symlink so repository updates are picked up
after `daemon-reload`:
```bash
mkdir -p ~/.config/systemd/user
ln -s "$(pwd)/systemd/trainlog-web.service" ~/.config/systemd/user/trainlog-web.service
systemctl --user daemon-reload
systemctl --user enable --now trainlog-web.service
```
Useful lifecycle and diagnostic commands are:
```bash
systemctl --user status trainlog-web
systemctl --user restart trainlog-web
systemctl --user stop trainlog-web
systemctl --user start trainlog-web
journalctl --user -u trainlog-web
```
An operator may separately configure a local nginx proxy such as
`http://trainlog.perf`. That hostname and proxy are machine-local conveniences,
not a public Trainlog domain or a runtime dependency, and Trainlog does not
configure them automatically.
Uninstalling the integration disables the unit before removing only its user
configuration symlink:
```bash
systemctl --user disable --now trainlog-web.service
rm ~/.config/systemd/user/trainlog-web.service
systemctl --user daemon-reload
```
Android builds require JDK 17, an Android SDK supporting the configured API
levels, and the Gradle wrapper committed in this repository. People installing
the APK do not need Java, Gradle, or the Android SDK.
## Documentation
| Topic | Document | Purpose |
|---|---|---|
| Documentation map | [docs/README.md](docs/README.md) | Canonical index and ownership map. |
| Current state | [docs/current_state.md](docs/current_state.md) | Current implemented schemas, capabilities, validation, and limitations. |
| Architecture | [docs/architecture.md](docs/architecture.md) | Component, storage, process, and ownership boundaries. |
| Android | [docs/android.md](docs/android.md) | Field-companion behavior and local persistence. |
| Desktop TUI | [docs/tui.md](docs/tui.md) | Notcurses workflows, correction, and analytics. |
| Database | [docs/database.md](docs/database.md) | Desktop persistence schema and migration contracts. |
| Exercise model | [docs/exercise_data_model.md](docs/exercise_data_model.md) | Exercise, occurrence, load, and MAX semantics. |
| Synchronization | [docs/sync_exchange.md](docs/sync_exchange.md) | Active MTP flow and companion artifacts. |
| Exchange formats | [docs/exchange_format.md](docs/exchange_format.md) | Frozen and separately versioned JSON contracts. |
| Training feedback | [docs/training_feedback.md](docs/training_feedback.md) | Immediate feedback, revisions, and J+1 follow-ups. |
| Tests | [docs/tests.md](docs/tests.md) | Durable validation commands and coverage strategy. |
| Roadmap | [docs/roadmap.md](docs/roadmap.md) | Current cursor and future work only. |
| Development contract | [AGENTS.md](AGENTS.md) | Repository invariants and contribution rules. |
| Change history | [CHANGELOG.md](CHANGELOG.md) | Chronological implementation history. |
## Architecture overview
## ✦ Architecture
```text
Android local SQLite
|
| versioned JSON snapshots and requests
v
Documents/Trainlog on Android storage
|
| direct MTP / libmtp
v
trainlog_sync_run() <---- trainlog-syncd or desktop TUI
|
+---- import Android snapshot into desktop SQLite
+---- publish desktop catalog companions to Android
+---- write a synchronization receipt
desktop SQLite = canonical long-term history
┌──────────────────────────────┐
│ Android field companion │
│ private SQLite capture store │
└──────────────┬───────────────┘
│ versioned JSON generations + acknowledgements
│ Bluetooth RFCOMM primary · direct USB/MTP recovery
▼
┌──────────────────────────────┐ optional, separately configured
│ Synchronization orchestration│◄───── private Drive workflow
└──────────────┬───────────────┘
│ typed imports, commands and read models
▼
┌──────────────────────────────┐
│ Trainlog Core · C17 │
│ desktop SQLite = canonical │
│ long-term history │
└──────────────┬───────────────┘
│
┌──────┴──────────┐
▼ ▼
┌──────────────┐ ┌──────────────────────┐
│ Notcurses TUI│ │ 127.0.0.1 Web adapter│──► browser
└──────────────┘ └──────────────────────┘
```
The optional desktop AI flow exports read-only history and exchanges session
proposals through Google Drive via the external `rclone` process. Android does
not contain cloud credentials and does not run `rclone`.
The TUI, Web application, and Android client are sibling adapters with
different responsibilities. Business rules, calculations, persistence, and
typed read models belong to Core; presentation code does not reconstruct them
from raw database tables. See the full
[`architecture contract`](docs/architecture.md).
## Current status
## ✦ Your data, your system
As of 2026-09-18:
- Trainlog runs on the user's own Android and Linux systems; no hosted Trainlog
account or proprietary cloud service is required for core capture, history,
analytics, or direct synchronization.
- Android and desktop each keep a local SQLite database; the desktop database
is the canonical long-term history.
- Devices exchange bounded, versioned JSON artifacts—not database files.
- The local Web server binds to `127.0.0.1` by default and never silently falls
back to a LAN address or another port.
- Direct Android storage uses `Documents/Trainlog`; desktop USB access uses
`libudev` and `libmtp` without requiring a GVFS/FUSE mount.
- The optional Drive path is operator-configured through external `rclone`.
Android holds no cloud credentials.
- `TRAINLOG_FORMAT_V1=PASS/FROZEN`;
- desktop SQLite schema v28 and Android SQLite schema v25;
- Notcurses is the only active desktop terminal backend;
- direct storage is `/storage/emulated/0/Documents/Trainlog` under Android's
all-files access setting;
- mobile export V3 is active; V1/V2 remain readable legacy inputs; explicit V4
history and execution-draft codecs are staged but not selected by transport;
- `STATS_V1=IMPLEMENTED`, `TRAINING_KNOWLEDGE_V1=PASS`, and
`SESSION_GENERATOR_V1=PASS` (the generator is hidden pending V2);
- `APP_SHELL_V1=IMPLEMENTED_AWAITING_VISUAL_REVIEW_2`;
- `TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING` pending a real Drive plus
Android-triggered bidirectional smoke test.
- `WEB_FRONTEND_SHELL_V1=PASS/FROZEN` and
`WEB_DASHBOARD_V1=PASS/FROZEN` and `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN`;
`/programmes` is an operational active-Program calendar, while
Sessions → Programmes remains the administration surface; Analyse and
Exercises remain placeholders;
- `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=PASS`: the coordinated
private desktop/Android deployment, real Firefox presentation, correlated
`programs-v1` deletion ACK, restart/replay, and non-resurrection checks pass;
- `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`;
- `TRAINLOG_SYNC_GENERATION_ACK_V1=PASS/FROZEN` through explicit staged
services; automatic synchronization still selects V3. The trusted local
opt-in path is deployed on the paired private installation and has completed
real direct-MTP restart/replay validation; it is not a public rollout.
## ✦ Build and run
The latest executable result belongs in
[current state](docs/current_state.md), not in multiple README narratives.
### Desktop and TUI
## Desktop development quick start
Requirements include Meson, Ninja, SQLite3, utf8proc, libuuid, libudev,
libmtp, and Notcurses.
The desktop build requires Meson, Ninja, a C17 toolchain, `pkg-config`, Python
3, and development files for SQLite, libuuid, utf8proc, libudev, libmtp,
Notcurses Core, GNU libmicrohttpd, and yyjson.
```bash
meson setup build
@ -373,13 +127,36 @@ meson test -C build --print-errorlogs
./build/tui/trainlog
```
The database path is `$XDG_DATA_HOME/trainlog/trainlog.db`, or
The desktop database is stored at
`$XDG_DATA_HOME/trainlog/trainlog.db`, or
`~/.local/share/trainlog/trainlog.db` when `XDG_DATA_HOME` is unset.
## Android development quick start
### Embedded local Web application
Use Java 17 and provide the Android SDK through `ANDROID_HOME` or the untracked
`android/local.properties` file.
Node and npm are build-time dependencies only. Meson does not download npm
packages.
```bash
cd web
npm ci
npm run typecheck
npm test
cd ..
meson setup build-web -Dweb=enabled
meson compile -C build-web
./build-web/tui/trainlog --web
```
The Web application listens on `127.0.0.1:8080` by default. Use
`--port <port>` for an explicit alternative. For an existing build directory,
use `meson configure build -Dweb=enabled` instead of running `meson setup`
again.
### Android
Android builds use the committed Gradle wrapper, JDK 17, and an Android SDK for
the configured API levels (`minSdk 26`, `targetSdk 36`, `compileSdk 37`).
```bash
cd android
@ -387,38 +164,81 @@ JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew test
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew assembleDebug
```
Instrumented tests belong on an emulator. Do not run
`connectedDebugAndroidTest` on the primary personal phone.
Instrumented tests belong on an emulator; the primary personal phone is
reserved for manual, non-destructive installation and synchronization checks.
Release APK signing is intentionally operator-local and is not configured with
credentials from this repository.
## Synchronization quick start
<details>
<summary>Optional user-session services</summary>
After a desktop build, the repository provides an installer for the private
automatic synchronization services:
```bash
meson compile -C build
bash tools/install_syncd_user.sh
systemctl --user is-active trainlog-syncd.service
```
On Android, grant all-files access for the fixed `Documents/Trainlog`
directory, then use **Synchronisation → Synchroniser maintenant**. Hardware MTP
validation requires an unlocked connected device and remains an explicit manual
check.
This modifies the current user's `~/.local/bin` and systemd user configuration.
Bluetooth generation transport starts only when its separate Trainlog
configuration explicitly enables it. The loopback Web development unit in
`systemd/trainlog-web.service` is another optional integration; neither service
is required for manual TUI use.
## Development principles
</details>
- Preserve frozen, versioned compatibility boundaries.
- Use stable IDs; display names are not identities.
- Keep performed work separate from plans and continuous activity separate
from fake sets.
- Keep Android capture, desktop analytics, persistence, transport, and
rendering responsibilities distinct.
- Prefer explicit failure to silent data loss or corruption.
- Treat migrations, idempotence, tests, and documentation as part of feature
completion.
- Keep scientific catalogs separate from runtime user data and preserve stated
uncertainty.
Prebuilt stable APKs and Linux x86-64 runtime bundles are published on both
official mirrors: [Forgejo Releases](https://git.labfytools.com/fy59/trainlog/releases)
and [GitHub Releases](https://github.com/labfytools/trainlog/releases). Verify
downloaded artifacts against the accompanying `SHA256SUMS`.
## License and project notes
## ✦ Repository layout
See [LICENSE](LICENSE). Forgejo is the primary repository; GitHub is a mirror.
Repository layout, detailed contracts, and historical review records are linked
from [docs/README.md](docs/README.md).
```text
trainlog/
├── android/ Kotlin + Jetpack Compose field companion
├── tui/ C17 Core, Notcurses TUI, Web adapter and native tests
├── web/ React + TypeScript + Vite frontend
├── catalog/ versioned scientific and product knowledge
├── format/ JSON schemas and frozen format material
├── contracts/ machine-readable presentation contracts
├── tools/ validators, import/export and sync utilities
├── tests/ cross-surface and protocol regression tests
├── systemd/ optional user-session integration
└── docs/ canonical documentation and review evidence
```
## ✦ Documentation
| Start here | Scope |
| ------------------------------------------------------------ | ------------------------------------------------------------------------------- |
| [`docs/current_state.md`](docs/current_state.md) | Implemented capabilities, versions, validation evidence and active limitations. |
| [`docs/architecture.md`](docs/architecture.md) | Component, storage, process, transport and ownership boundaries. |
| [`docs/roadmap.md`](docs/roadmap.md) | Current cursor and future work. |
| [`docs/android.md`](docs/android.md) | Android capture, persistence and synchronization behavior. |
| [`docs/tui.md`](docs/tui.md) | Notcurses workflows, correction, analytics and technical tools. |
| [`docs/sync_exchange.md`](docs/sync_exchange.md) | Active synchronization flow, replay and acknowledgement semantics. |
| [`docs/exchange_format.md`](docs/exchange_format.md) | Frozen and separately versioned JSON contracts. |
| [`docs/exercise_data_model.md`](docs/exercise_data_model.md) | Exercise, occurrence, planning, load and MAX semantics. |
| [`docs/database.md`](docs/database.md) | Desktop SQLite schema and migration contracts. |
| [`docs/tests.md`](docs/tests.md) | Canonical validation commands and coverage strategy. |
The complete ownership map is in [`docs/README.md`](docs/README.md), with
chronological detail in the [`CHANGELOG`](CHANGELOG.md).
## ✦ Project status
| Boundary | Current state |
| ------------------- | --------------------------------------------------------------------------------------------------------- |
| Product | `0.1.6` stable |
| Desktop persistence | SQLite schema v36 |
| Android persistence | SQLite schema v33 |
| Mobile exchange | V3 active; V1/V2 readable; V4 staged only |
| Desktop terminal | C17 + Notcurses, active backend |
| Local Web | Dashboard and operational Analyse, Programmes, Sessions, Exercises, Equipment, Settings and sync surfaces |
| Interface language | French by default; English selectable on Android and desktop |
| Compatibility | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
Forgejo is the primary repository; GitHub is its release mirror. Trainlog is
licensed under the [GNU General Public License v3](LICENSE).

View file

@ -14,7 +14,7 @@ ANDROID_BODY_SHELL=IMPLEMENTED
LOCAL_PERSISTENCE=IMPLEMENTED
DIRECT_STORAGE=IMPLEMENTED
MTP_SYNC=IMPLEMENTED
ANDROID_SCHEMA=17
ANDROID_SCHEMA=33
```
## Visual contract

View file

@ -1,3 +1,4 @@
import java.time.OffsetDateTime
import java.util.Properties
plugins {
@ -43,7 +44,38 @@ 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")
} ?: 3
} ?: 7
/*
* WHY: a user may need to finish one preserved draft after a completion bug
* has been repaired, while its factual end time predates the repair build.
* CONTRACT: both values are private-build inputs, must be supplied together,
* and are embedded only after strict identity/timestamp validation.
* INVARIANT: ordinary builds use empty values and every non-matching future
* session continues to finalize at the live clock time.
*/
val recoverySessionId =
providers.environmentVariable("TRAINLOG_ANDROID_RECOVERY_SESSION_ID").orNull.orEmpty()
val recoveryEndedAt =
providers.environmentVariable("TRAINLOG_ANDROID_RECOVERY_ENDED_AT").orNull.orEmpty()
if (recoverySessionId.isEmpty() != recoveryEndedAt.isEmpty()) {
throw GradleException(
"TRAINLOG_ANDROID_RECOVERY_SESSION_ID and " +
"TRAINLOG_ANDROID_RECOVERY_ENDED_AT must be supplied together",
)
}
if (recoverySessionId.isNotEmpty()) {
val sessionPattern =
Regex("^se_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
if (!sessionPattern.matches(recoverySessionId)) {
throw GradleException("TRAINLOG_ANDROID_RECOVERY_SESSION_ID is invalid")
}
try {
OffsetDateTime.parse(recoveryEndedAt)
} catch (_: Exception) {
throw GradleException("TRAINLOG_ANDROID_RECOVERY_ENDED_AT is invalid")
}
}
android {
namespace = "com.labfytools.trainlog"
@ -55,7 +87,9 @@ android {
targetSdk = 36
versionCode = trainlogVersionCode
versionName = "0.1.2"
versionName = "0.1.6"
buildConfigField("String", "ACTIVE_SESSION_RECOVERY_ID", "\"$recoverySessionId\"")
buildConfigField("String", "ACTIVE_SESSION_RECOVERY_ENDED_AT", "\"$recoveryEndedAt\"")
testInstrumentationRunner =
"androidx.test.runner.AndroidJUnitRunner"
}
@ -86,6 +120,7 @@ android {
buildFeatures {
compose = true
buildConfig = true
}
sourceSets {

View file

@ -60,6 +60,7 @@ class DraftUiTestActivity : ComponentActivity() {
sessionVisible = false
},
onCreateExercise = {},
onCreateEquipment = { _ -> },
/* CONTRACT: this host never writes shared export data. */
onSessionSaved = {},
)
@ -82,8 +83,6 @@ class DraftUiTestActivity : ComponentActivity() {
?: (loaded as?
ActiveDraftLoadResult.Loaded)
?.warning,
latestSession = null,
latestMaximum = null,
bodyZoneOverview = repository.getBodyZoneHomeOverview(),
onSession = {
when (
@ -102,9 +101,7 @@ class DraftUiTestActivity : ComponentActivity() {
}
},
onOpenExercises = {},
onOpenStatistics = {},
onBody = {},
onOpenLatestSession = {},
onSync = {},
)
}

View file

@ -6,8 +6,24 @@
user-visible directory, Documents/Trainlog, without a SAF provider. -->
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission
android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<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" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<application
android:allowBackup="false"
@ -33,6 +49,10 @@
<service
android:name=".data.SyncBackgroundService"
android:exported="false"
android:foregroundServiceType="dataSync" />
android:foregroundServiceType="connectedDevice|dataSync" />
<service
android:name=".data.HeartRateSensorService"
android:exported="false"
android:foregroundServiceType="connectedDevice" />
</application>
</manifest>

View file

@ -14,6 +14,12 @@ import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.data.SyncExporter
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
@ -30,6 +36,19 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
languageSettings = LanguageSettingsOwner(applicationContext)
if (
BackgroundSyncSettings(applicationContext).enabled &&
hasBluetoothConnectPermission(applicationContext)
) {
SyncBackgroundService.start(applicationContext)
}
if (
HeartRateSensorPreferences(applicationContext).selected() != null &&
hasHeartRateBleRuntimePermissions(applicationContext)
) {
HeartRateSensorService.start(applicationContext)
}
val repository =
TrainlogRepository(
applicationContext

View file

@ -0,0 +1,235 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import java.io.File
import java.io.FileOutputStream
import java.security.MessageDigest
import java.util.concurrent.TimeUnit
import org.json.JSONObject
internal const val AI_EXPORT_FILENAME = "trainlog_ai_export_v1.json"
internal sealed interface AiExportDriveResult {
data object NotConfigured : AiExportDriveResult
data object Missing : AiExportDriveResult
data object Unchanged : AiExportDriveResult
data class Published(val sha256: String) : AiExportDriveResult
data class Error(val message: String) : AiExportDriveResult
}
internal class AiExportDriveSettings(private val context: Context) {
private val preferences =
context.applicationContext.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
val treeUri: Uri?
get() = preferences.getString(TREE, null)?.let(Uri::parse)
val connected: Boolean
get() = treeUri != null
fun connect(uri: Uri) {
context.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
)
preferences.edit().putString(TREE, uri.toString()).apply()
schedule(context)
}
fun disconnect() {
treeUri?.let { uri ->
runCatching {
context.contentResolver.releasePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
)
}
}
preferences.edit()
.remove(TREE)
.remove(LAST_SHA256)
.remove(LAST_SIZE)
.remove(LAST_MODIFIED)
.apply()
WorkManager.getInstance(context).cancelUniqueWork(PERIODIC_WORK)
WorkManager.getInstance(context).cancelUniqueWork(IMMEDIATE_WORK)
}
fun alreadyPublished(source: File, sha256: String): Boolean =
preferences.getString(LAST_SHA256, null) == sha256 &&
preferences.getLong(LAST_SIZE, -1L) == source.length() &&
preferences.getLong(LAST_MODIFIED, -1L) == source.lastModified()
fun needsPublication(source: File): Boolean =
connected &&
source.isFile &&
(
preferences.getString(LAST_SHA256, null) == null ||
preferences.getLong(LAST_SIZE, -1L) != source.length() ||
preferences.getLong(LAST_MODIFIED, -1L) != source.lastModified()
)
fun recordPublished(source: File, sha256: String) {
preferences.edit()
.putString(LAST_SHA256, sha256)
.putLong(LAST_SIZE, source.length())
.putLong(LAST_MODIFIED, source.lastModified())
.apply()
}
companion object {
private const val PREFERENCES = "trainlog-ai-export-drive"
private const val TREE = "tree_uri"
private const val LAST_SHA256 = "last_sha256"
private const val LAST_SIZE = "last_size"
private const val LAST_MODIFIED = "last_modified"
private const val PERIODIC_WORK = "trainlog-ai-export-drive-periodic-v1"
private const val IMMEDIATE_WORK = "trainlog-ai-export-drive-now-v1"
fun sourceFile(): File = File(canonicalExchangeDirectory(), AI_EXPORT_FILENAME)
fun schedule(context: Context) {
val constraints =
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val periodic =
PeriodicWorkRequestBuilder<AiExportDriveWorker>(15, TimeUnit.MINUTES)
.setConstraints(constraints)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
PERIODIC_WORK,
ExistingPeriodicWorkPolicy.UPDATE,
periodic,
)
enqueueImmediate(context)
}
fun enqueueImmediate(context: Context) {
val settings = AiExportDriveSettings(context)
val source = sourceFile()
if (!settings.needsPublication(source)) return
val request =
OneTimeWorkRequestBuilder<AiExportDriveWorker>()
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
IMMEDIATE_WORK,
ExistingWorkPolicy.KEEP,
request,
)
}
}
}
internal class AiExportDrivePublisher(private val context: Context) {
private val settings = AiExportDriveSettings(context)
private fun sha256(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().use { input ->
val buffer = ByteArray(64 * 1024)
while (true) {
val count = input.read(buffer)
if (count < 0) break
digest.update(buffer, 0, count)
}
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
private fun validateSource(source: File) {
require(source.isFile && !java.nio.file.Files.isSymbolicLink(source.toPath())) {
"Export IA local indisponible."
}
require(source.length() in 1..64L * 1024L * 1024L) {
"Export IA local invalide."
}
val root =
source.inputStream().bufferedReader(Charsets.UTF_8).use { reader ->
JSONObject(reader.readText())
}
require(
root.optString("format") == "TRAINLOG_AI_EXPORT" &&
root.optInt("version", -1) == 1
) { "Format d’export IA invalide." }
}
fun publishPending(): AiExportDriveResult {
val uri = settings.treeUri ?: return AiExportDriveResult.NotConfigured
val source = AiExportDriveSettings.sourceFile()
if (!source.isFile) return AiExportDriveResult.Missing
return try {
validateSource(source)
val sourceSha = sha256(source)
if (settings.alreadyPublished(source, sourceSha)) {
return AiExportDriveResult.Unchanged
}
val root =
DocumentFile.fromTreeUri(context, uri)
?: return AiExportDriveResult.Error("Dossier Drive IA indisponible.")
require(root.isDirectory) { "Dossier Drive IA invalide." }
val destination =
root.findFile(AI_EXPORT_FILENAME)
?: root.createFile("application/json", AI_EXPORT_FILENAME)
?: throw IllegalStateException("Impossible de créer l’export IA sur Drive.")
require(destination.isFile) { "Destination Drive IA invalide." }
context.contentResolver.openOutputStream(destination.uri, "wt")?.use { output ->
source.inputStream().use { input -> input.copyTo(output, 64 * 1024) }
output.flush()
} ?: throw IllegalStateException("Impossible d’écrire l’export IA sur Drive.")
val verification =
File.createTempFile("trainlog-ai-drive-verify-", ".json", context.cacheDir)
try {
context.contentResolver.openInputStream(destination.uri)?.use { input ->
FileOutputStream(verification).use { output ->
input.copyTo(output, 64 * 1024)
output.fd.sync()
}
} ?: throw IllegalStateException("Impossible de relire l’export IA Drive.")
require(
verification.length() == source.length() &&
sha256(verification) == sourceSha
) { "Vérification de l’export IA Drive échouée." }
} finally {
verification.delete()
}
settings.recordPublished(source, sourceSha)
AiExportDriveResult.Published(sourceSha)
} catch (error: Exception) {
AiExportDriveResult.Error(error.message ?: error.javaClass.simpleName)
}
}
}
class AiExportDriveWorker(
context: Context,
parameters: WorkerParameters,
) : CoroutineWorker(context, parameters) {
override suspend fun doWork(): Result =
when (AiExportDrivePublisher(applicationContext).publishPending()) {
AiExportDriveResult.NotConfigured,
AiExportDriveResult.Missing,
AiExportDriveResult.Unchanged,
is AiExportDriveResult.Published -> Result.success()
is AiExportDriveResult.Error -> Result.retry()
}
}

View file

@ -26,7 +26,13 @@ internal data class VerifiedAndroidBackup(
)
/** Complete same-installation backup container; plaintext is explicit by design. */
internal class AndroidBackupService(private val context: Context) {
internal class AndroidBackupService(
private val context: Context,
private val productVersion: String =
checkNotNull(
context.packageManager.getPackageInfo(context.packageName, 0).versionName,
),
) {
companion object {
const val FORMAT = "trainlog-android-backup"
const val VERSION = 1
@ -140,7 +146,7 @@ internal class AndroidBackupService(private val context: Context) {
.put("version", VERSION)
.put("package", context.packageName)
.put("schema", schema)
.put("product_version", "0.1.2")
.put("product_version", productVersion)
.put("plaintext", true)
.put("created_at", OffsetDateTime.now().toString())
.put(
@ -219,7 +225,7 @@ internal class AndroidBackupService(private val context: Context) {
manifest.getInt("version") == VERSION &&
manifest.getString("package") == context.packageName &&
manifest.getBoolean("plaintext") &&
manifest.getInt("schema") in 17..25
manifest.getInt("schema") in 17..33
) {
"Unsupported backup."
}
@ -313,7 +319,7 @@ internal class AndroidBackupService(private val context: Context) {
}
)
require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() })
require(db.version in 17..25) { "Unsupported Android schema ${db.version}." }
require(db.version in 17..33) { "Unsupported Android schema ${db.version}." }
return db.version
} finally {
db.close()

View file

@ -0,0 +1,562 @@
package com.labfytools.trainlog.data
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothClass
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothSocket
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.core.content.ContextCompat
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.nio.file.AtomicMoveNotSupportedException
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.security.MessageDigest
import java.time.Duration
import java.util.UUID
import java.util.concurrent.atomic.AtomicBoolean
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
import java.util.zip.ZipOutputStream
import org.json.JSONObject
internal const val TRAINLOG_BLUETOOTH_SERVICE_UUID =
"f0d1c0de-7a11-4f62-9b7c-545241494e4c"
internal const val TRAINLOG_BLUETOOTH_FILE_PROTOCOL = "trainlog-bt-files-v1"
private const val BLUETOOTH_FRAME_FORMAT = "trainlog-bt-frame"
private const val BLUETOOTH_FRAME_VERSION = 1
private const val MAX_BT_HEADER_BYTES = 64 * 1024
private const val MAX_BT_PAYLOAD_BYTES = 256L * 1024L * 1024L
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,
)
internal class BluetoothSyncSettings(context: Context) {
private val preferences =
context.applicationContext.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
val desktopAddress: String?
get() = preferences.getString(DESKTOP_ADDRESS, null)
val configured: Boolean
get() = desktopAddress != null
fun selectDesktop(address: String) {
require(BLUETOOTH_ADDRESS.matches(address.uppercase())) {
"Adresse Bluetooth invalide."
}
preferences.edit().putString(DESKTOP_ADDRESS, address.uppercase()).apply()
}
fun disconnect() {
preferences.edit().remove(DESKTOP_ADDRESS).apply()
}
fun shouldPublishAutomaticRequest(nowMillis: Long): Boolean {
val previous = preferences.getLong(LAST_AUTO_REQUEST, 0L)
return previous <= 0L || nowMillis - previous >= AUTO_REQUEST_COOLDOWN_MS
}
fun recordAutomaticRequest(nowMillis: Long) {
preferences.edit().putLong(LAST_AUTO_REQUEST, nowMillis).apply()
}
companion object {
private const val PREFERENCES = "trainlog-bluetooth-sync"
private const val DESKTOP_ADDRESS = "desktop_address"
private const val LAST_AUTO_REQUEST = "last_auto_request_ms"
private val BLUETOOTH_ADDRESS =
Regex("^[0-9A-F]{2}(:[0-9A-F]{2}){5}$")
}
}
internal fun hasBluetoothConnectPermission(context: Context): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.S ||
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) ==
PackageManager.PERMISSION_GRANTED
/**
* Decide whether a newly connected RFCOMM peer may publish an automatic request.
*
* WHY: Sleep-only changes have no workout event to wake the desktop path, so
* Bluetooth arrival itself must remain the trigger. CONTRACT: user opt-in and
* the existing cooldown are both mandatory. INVARIANT: reconnection never
* bypasses the bounded request cadence or requires an explicit UI action.
*/
internal fun shouldPublishBluetoothArrivalRequest(
autoSyncEnabled: Boolean,
cooldownReady: Boolean,
): Boolean = autoSyncEnabled && cooldownReady
@SuppressLint("MissingPermission")
internal fun bondedBluetoothDesktops(context: Context): List<BondedBluetoothDesktop> {
if (!hasBluetoothConnectPermission(context)) return emptyList()
val adapter =
context.getSystemService(BluetoothManager::class.java)?.adapter
?: return emptyList()
return adapter.bondedDevices
.asSequence()
.filter { device ->
device.bluetoothClass?.majorDeviceClass == BluetoothClass.Device.Major.COMPUTER
}
.map { device ->
BondedBluetoothDesktop(
name = device.name?.takeIf(String::isNotBlank) ?: device.address,
address = device.address.uppercase(),
)
}
.sortedWith(compareBy<BondedBluetoothDesktop> { it.name }.thenBy { it.address })
.toList()
}
private fun sha256(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
FileInputStream(file).use { input ->
val buffer = ByteArray(64 * 1024)
while (true) {
val count = input.read(buffer)
if (count < 0) break
digest.update(buffer, 0, count)
}
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
private data class BluetoothFrame(
val header: JSONObject,
val payload: File?,
) : AutoCloseable {
override fun close() {
payload?.delete()
}
}
private class BluetoothFrameCodec(private val cacheDirectory: File) {
fun read(input: DataInputStream): BluetoothFrame {
val headerSize = input.readInt()
if (headerSize !in 1..MAX_BT_HEADER_BYTES)
throw IllegalStateException("En-tête Bluetooth invalide.")
val rawHeader = ByteArray(headerSize)
input.readFully(rawHeader)
val header = JSONObject(rawHeader.toString(Charsets.UTF_8))
require(
header.optString("format") == BLUETOOTH_FRAME_FORMAT &&
header.optInt("version", -1) == BLUETOOTH_FRAME_VERSION
) { "Trame Bluetooth non supportée." }
val payloadSize =
if (header.has("payload_size")) header.getLong("payload_size") else 0L
require(payloadSize in 0..MAX_BT_PAYLOAD_BYTES) {
"Charge utile Bluetooth invalide."
}
if (payloadSize == 0L) {
require(!header.has("payload_sha256") || header.optString("payload_sha256").isEmpty())
return BluetoothFrame(header, null)
}
val expected = header.getString("payload_sha256")
require(expected.matches(Regex("^[0-9a-f]{64}$"))) {
"Empreinte Bluetooth invalide."
}
val temporary =
File.createTempFile("trainlog-bt-frame-", ".bin", cacheDirectory)
try {
val digest = MessageDigest.getInstance("SHA-256")
FileOutputStream(temporary).use { output ->
var remaining = payloadSize
val buffer = ByteArray(64 * 1024)
while (remaining > 0L) {
val wanted = minOf(buffer.size.toLong(), remaining).toInt()
val count = input.read(buffer, 0, wanted)
if (count < 0) throw IllegalStateException("Connexion Bluetooth interrompue.")
output.write(buffer, 0, count)
digest.update(buffer, 0, count)
remaining -= count.toLong()
}
output.fd.sync()
}
val actual = digest.digest().joinToString("") { "%02x".format(it) }
require(actual == expected) { "Empreinte Bluetooth incorrecte." }
return BluetoothFrame(header, temporary)
} catch (error: Exception) {
temporary.delete()
throw error
}
}
fun write(output: DataOutputStream, header: JSONObject, payload: File? = null) {
val payloadSize = payload?.length() ?: 0L
require(payloadSize in 0..MAX_BT_PAYLOAD_BYTES)
val value =
JSONObject(header.toString())
.put("format", BLUETOOTH_FRAME_FORMAT)
.put("version", BLUETOOTH_FRAME_VERSION)
.put("payload_size", payloadSize)
if (payload != null) {
require(payload.isFile)
value.put("payload_sha256", sha256(payload))
}
val raw = value.toString().toByteArray(Charsets.UTF_8)
require(raw.size in 1..MAX_BT_HEADER_BYTES)
output.writeInt(raw.size)
output.write(raw)
if (payload != null) {
FileInputStream(payload).use { input -> input.copyTo(output, 64 * 1024) }
}
output.flush()
}
}
private class BluetoothExchangeArchive(
private val context: Context,
) {
private val exchangeDirectory = canonicalExchangeDirectory()
private fun safeRelative(relative: String): List<String> {
require(relative.isNotBlank() && '\\' !in relative)
val parts = relative.split('/')
require(parts.size in 1..6 && parts.all { it.isNotBlank() && it != "." && it != ".." })
return parts
}
private fun addFile(
archive: ZipOutputStream,
file: File,
relative: String,
seen: MutableSet<String>,
totals: LongArray,
) {
if (!file.isFile || Files.isSymbolicLink(file.toPath())) return
safeRelative(relative)
require(seen.add(relative)) { "Chemin Bluetooth dupliqué." }
val size = file.length()
require(size in 0..MAX_BT_FILE_BYTES) { "Fichier Bluetooth trop volumineux." }
totals[0] += 1
totals[1] = Math.addExact(totals[1], size)
require(totals[0] <= MAX_BT_FILES && totals[1] <= MAX_BT_PAYLOAD_BYTES) {
"Archive Bluetooth trop volumineuse."
}
archive.putNextEntry(ZipEntry(relative).also { it.size = size })
FileInputStream(file).use { input -> input.copyTo(archive, 64 * 1024) }
archive.closeEntry()
}
fun buildAndroidPullArchive(): File {
require(exchangeDirectory.isDirectory) { "Dossier d'échange Trainlog indisponible." }
val output = File.createTempFile("trainlog-bt-pull-", ".zip", context.cacheDir)
val seen = linkedSetOf<String>()
val totals = longArrayOf(0L, 0L)
try {
ZipOutputStream(BufferedOutputStream(FileOutputStream(output))).use { zip ->
BLUETOOTH_ANDROID_PULL_COORDINATION.forEach { name ->
addFile(zip, File(exchangeDirectory, name), name, seen, totals)
}
val reference = File(exchangeDirectory, "android-generation-v1.json")
if (reference.isFile && reference.length() <= MAX_BT_HEADER_BYTES) {
val value = JSONObject(reference.readText())
val relative = value.optString("relative_path")
val generationId = value.optString("generation_id")
val expected = "android-objects/generations/$generationId"
require(relative == expected) { "Référence de génération Android invalide." }
val directory = File(exchangeDirectory, relative)
require(directory.isDirectory && !Files.isSymbolicLink(directory.toPath()))
directory.listFiles().orEmpty().sortedBy(File::getName).forEach { file ->
addFile(zip, file, "$relative/${file.name}", seen, totals)
}
}
}
require(output.length() <= MAX_BT_PAYLOAD_BYTES)
return output
} catch (error: Exception) {
output.delete()
throw error
}
}
fun applyDesktopPushArchive(archiveFile: File) {
require(archiveFile.isFile && archiveFile.length() in 1..MAX_BT_PAYLOAD_BYTES)
if (!exchangeDirectory.exists()) require(exchangeDirectory.mkdirs())
require(exchangeDirectory.isDirectory)
val root = exchangeDirectory.canonicalFile
val seen = linkedSetOf<String>()
val published = mutableListOf<File>()
var count = 0L
var total = 0L
ZipFile(archiveFile).use { archive ->
val entries = archive.entries()
while (entries.hasMoreElements()) {
val entry = entries.nextElement()
if (entry.isDirectory) continue
val parts = safeRelative(entry.name)
require(seen.add(entry.name)) { "Chemin Bluetooth dupliqué." }
require(entry.size in 0..MAX_BT_FILE_BYTES) { "Fichier Bluetooth trop volumineux." }
count += 1
total = Math.addExact(total, entry.size)
require(count <= MAX_BT_FILES && total <= MAX_BT_PAYLOAD_BYTES)
val destination =
parts.fold(root) { parent, name -> File(parent, name) }
val parent = requireNotNull(destination.parentFile)
require(parent.mkdirs() || parent.isDirectory)
require(parent.canonicalFile.toPath().startsWith(root.toPath())) {
"Chemin Bluetooth hors du dossier Trainlog."
}
val temporary =
File(parent, ".${destination.name}.bt-${UUID.randomUUID()}.tmp")
try {
archive.getInputStream(entry).use { input ->
FileOutputStream(temporary).use { output ->
var copied = 0L
val buffer = ByteArray(64 * 1024)
while (true) {
val read = input.read(buffer)
if (read < 0) break
copied += read.toLong()
require(copied <= entry.size)
output.write(buffer, 0, read)
}
require(copied == entry.size)
output.fd.sync()
}
}
try {
Files.move(
temporary.toPath(),
destination.toPath(),
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING,
)
} catch (_: AtomicMoveNotSupportedException) {
Files.move(
temporary.toPath(),
destination.toPath(),
StandardCopyOption.REPLACE_EXISTING,
)
}
published += destination
} catch (error: Exception) {
temporary.delete()
throw error
}
}
}
AndroidMtpPublicationVisibility(context).confirm(published)
if (AI_EXPORT_FILENAME in seen) {
AiExportDriveSettings.enqueueImmediate(context)
}
}
}
internal class BluetoothSyncClient(
private val context: Context,
private val repository: TrainlogRepository,
) {
companion object {
private const val TAG = "TrainlogBluetoothSync"
}
@Volatile private var activeSocket: BluetoothSocket? = null
private val settings = BluetoothSyncSettings(context)
private val codec = BluetoothFrameCodec(context.cacheDir)
private val archive = BluetoothExchangeArchive(context)
@SuppressLint("MissingPermission")
fun run(stopped: AtomicBoolean) {
while (!stopped.get()) {
if (!settings.configured || !hasBluetoothConnectPermission(context)) {
sleep(stopped, Duration.ofSeconds(10))
continue
}
val adapter =
context.getSystemService(BluetoothManager::class.java)?.adapter
if (adapter == null || !adapter.isEnabled) {
sleep(stopped, Duration.ofSeconds(10))
continue
}
val address = settings.desktopAddress ?: continue
val device =
runCatching { adapter.getRemoteDevice(address) }
.onFailure { Log.w(TAG, "Configured Bluetooth desktop is invalid", it) }
.getOrNull()
if (device == null || !ensureBond(device, stopped)) {
sleep(stopped, Duration.ofSeconds(5))
continue
}
val socket =
runCatching {
device.createRfcommSocketToServiceRecord(
UUID.fromString(TRAINLOG_BLUETOOTH_SERVICE_UUID)
)
}
.onFailure { Log.w(TAG, "Unable to create RFCOMM socket", it) }
.getOrNull()
if (socket == null) {
sleep(stopped, Duration.ofSeconds(10))
continue
}
activeSocket = socket
try {
socket.connect()
Log.i(TAG, "RFCOMM connected to selected Trainlog desktop")
serve(socket, stopped)
} catch (error: Exception) {
Log.w(TAG, "RFCOMM connection ended: ${error.message}", error)
} finally {
activeSocket = null
runCatching { socket.close() }
}
if (!stopped.get()) sleep(stopped, Duration.ofSeconds(5))
}
}
fun shutdown() {
runCatching { activeSocket?.close() }
activeSocket = null
}
@SuppressLint("MissingPermission")
private fun ensureBond(device: BluetoothDevice, stopped: AtomicBoolean): Boolean {
if (device.bondState == BluetoothDevice.BOND_BONDED) return true
if (device.bondState == BluetoothDevice.BOND_NONE) {
Log.i(TAG, "Bluetooth bond missing; requesting one-time repair")
if (!runCatching { device.createBond() }.getOrDefault(false)) {
Log.w(TAG, "Bluetooth bond request was rejected by Android")
return false
}
}
val deadline = System.nanoTime() + Duration.ofSeconds(30).toNanos()
while (!stopped.get() && System.nanoTime() < deadline) {
when (device.bondState) {
BluetoothDevice.BOND_BONDED -> {
Log.i(TAG, "Bluetooth bond ready")
return true
}
BluetoothDevice.BOND_NONE -> {
Log.w(TAG, "Bluetooth bond repair failed")
return false
}
}
sleep(stopped, Duration.ofMillis(250))
}
return device.bondState == BluetoothDevice.BOND_BONDED
}
private fun sleep(stopped: AtomicBoolean, duration: Duration) {
val deadline = System.nanoTime() + duration.toNanos()
while (!stopped.get() && System.nanoTime() < deadline) {
try {
Thread.sleep(250)
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
return
}
}
}
private fun serve(socket: BluetoothSocket, stopped: AtomicBoolean) {
val input = DataInputStream(BufferedInputStream(socket.inputStream))
val output = DataOutputStream(BufferedOutputStream(socket.outputStream))
val peer = SyncGenerationService(repository).peerId()
codec.write(
output,
JSONObject()
.put("type", "hello")
.put("protocol", TRAINLOG_BLUETOOTH_FILE_PROTOCOL)
.put("peer_id", peer),
)
codec.read(input).use { response ->
require(
response.payload == null &&
response.header.optString("type") == "hello_ack" &&
response.header.optString("protocol") == TRAINLOG_BLUETOOTH_FILE_PROTOCOL &&
response.header.optString("peer_id") == peer
) { "Accusé Bluetooth Trainlog invalide." }
}
publishArrivalRequest()
while (!stopped.get()) {
codec.read(input).use { frame ->
val type = frame.header.optString("type")
val requestId = frame.header.optString("request_id")
require(requestId.startsWith("bt_")) { "Requête Bluetooth non corrélée." }
when (type) {
"pull" -> {
require(frame.payload == null)
val payload = archive.buildAndroidPullArchive()
try {
codec.write(
output,
JSONObject()
.put("type", "result")
.put("request_id", requestId)
.put("result", "ok"),
payload,
)
} finally {
payload.delete()
}
}
"push" -> {
val payload = requireNotNull(frame.payload) {
"Archive Bluetooth desktop absente."
}
archive.applyDesktopPushArchive(payload)
codec.write(
output,
JSONObject()
.put("type", "result")
.put("request_id", requestId)
.put("result", "ok"),
)
}
else -> throw IllegalStateException("Opération Bluetooth non supportée.")
}
}
}
}
private fun publishArrivalRequest() {
val now = System.currentTimeMillis()
if (
!shouldPublishBluetoothArrivalRequest(
BackgroundSyncSettings(context).enabled,
settings.shouldPublishAutomaticRequest(now),
)
) {
return
}
val directory = canonicalExchangeDirectory()
if (!directory.isDirectory) return
val coordinator =
SyncGenerationCoordinator(repository, AndroidMtpPublicationVisibility(context))
coordinator.publishPeer(directory)
when (SyncRequestOutbox(context).requestFullGeneration()) {
is SyncRequestResult.Requested,
is SyncRequestResult.Completed -> settings.recordAutomaticRequest(now)
else -> Unit
}
}
}

View file

@ -0,0 +1,190 @@
package com.labfytools.trainlog.data
import com.labfytools.trainlog.model.CardioGuidanceInstruction
import com.labfytools.trainlog.model.CardioGuidanceOutput
import com.labfytools.trainlog.model.CardioTargetDefinition
import com.labfytools.trainlog.model.CardioTargetSnapshot
import kotlin.math.roundToInt
internal object CardioTargetResolver {
fun resolve(definition: CardioTargetDefinition): CardioTargetSnapshot? =
when (definition) {
is CardioTargetDefinition.AbsoluteBpm -> {
if (!validBounds(definition.minimumBpm, definition.maximumBpm)) return null
CardioTargetSnapshot(
minimumBpm = definition.minimumBpm,
maximumBpm = definition.maximumBpm,
)
}
is CardioTargetDefinition.CalibrationPercent -> {
if (
definition.calibrationId.isBlank() ||
definition.observedPeakBpm !in 1..65535 ||
definition.minimumPercent !in 1..100 ||
definition.maximumPercent !in 1..100 ||
definition.minimumPercent > definition.maximumPercent
) return null
val minimum =
(definition.observedPeakBpm * definition.minimumPercent / 100.0)
.roundToInt()
val maximum =
(definition.observedPeakBpm * definition.maximumPercent / 100.0)
.roundToInt()
if (!validBounds(minimum, maximum)) return null
CardioTargetSnapshot(
minimumBpm = minimum,
maximumBpm = maximum,
calibrationId = definition.calibrationId,
calibrationObservedPeakBpm = definition.observedPeakBpm,
minimumPercent = definition.minimumPercent,
maximumPercent = definition.maximumPercent,
)
}
}
private fun validBounds(minimum: Int, maximum: Int): Boolean =
minimum in 1..65535 && maximum in 1..65535 && minimum <= maximum
}
/**
* Pure state machine for BPM guidance.
*
* Hysteresis and confirmation time are UI/control stability parameters, not
* physiological zones. The engine never extrapolates a stale measurement.
*/
internal object CardioPhaseCompletion {
fun isComplete(
phase: com.labfytools.trainlog.model.CardioGuidedPhase,
elapsedSeconds: Int,
bpm: Int?,
fresh: Boolean,
): Boolean {
if (elapsedSeconds < 0) return false
return when (val exit = phase.exitCondition) {
is com.labfytools.trainlog.model.CardioPhaseExitCondition.FixedDuration ->
elapsedSeconds >= exit.seconds
com.labfytools.trainlog.model.CardioPhaseExitCondition.EnterTarget -> {
val target = phase.target ?: return false
fresh && bpm != null && bpm in target.minimumBpm..target.maximumBpm
}
is com.labfytools.trainlog.model.CardioPhaseExitCondition.RecoverBelow ->
fresh && bpm != null && bpm <= exit.bpm
is com.labfytools.trainlog.model.CardioPhaseExitCondition.DurationOrRecoverBelow ->
elapsedSeconds >= exit.maximumSeconds ||
(fresh && bpm != null && bpm <= exit.bpm)
}
}
}
internal class CardioGuidanceEngine(
private val hysteresisBpm: Int = DEFAULT_HYSTERESIS_BPM,
private val confirmationMs: Long = DEFAULT_CONFIRMATION_MS,
initialInstruction: CardioGuidanceInstruction = CardioGuidanceInstruction.MAINTAIN,
) {
private var current =
initialInstruction.takeUnless { it == CardioGuidanceInstruction.SUSPENDED }
?: CardioGuidanceInstruction.MAINTAIN
private var candidate: CardioGuidanceInstruction? = null
private var candidateSinceMs: Long? = null
init {
require(hysteresisBpm in 0..20)
require(confirmationMs in 0L..30_000L)
}
fun reset() {
current = CardioGuidanceInstruction.MAINTAIN
candidate = null
candidateSinceMs = null
}
fun evaluate(
target: CardioTargetSnapshot?,
bpm: Int?,
fresh: Boolean,
nowMs: Long,
): CardioGuidanceOutput {
require(nowMs >= 0L)
if (!fresh || bpm == null || bpm !in 1..65535 || target == null) {
candidate = null
candidateSinceMs = null
return CardioGuidanceOutput(
instruction = CardioGuidanceInstruction.SUSPENDED,
bpm = null,
target = target,
)
}
require(target.minimumBpm in 1..65535)
require(target.maximumBpm in target.minimumBpm..65535)
val desired = desiredInstruction(target, bpm)
if (desired == current) {
candidate = null
candidateSinceMs = null
return CardioGuidanceOutput(current, bpm, target)
}
if (candidate != desired) {
candidate = desired
candidateSinceMs = nowMs
}
val since = checkNotNull(candidateSinceMs)
if (nowMs - since >= confirmationMs) {
current = desired
candidate = null
candidateSinceMs = null
}
return CardioGuidanceOutput(
instruction = current,
bpm = bpm,
target = target,
pendingInstruction = candidate,
)
}
private fun desiredInstruction(
target: CardioTargetSnapshot,
bpm: Int,
): CardioGuidanceInstruction {
// A target entered from either side immediately trends toward MAINTAIN.
if (bpm in target.minimumBpm..target.maximumBpm) {
return CardioGuidanceInstruction.MAINTAIN
}
return when (current) {
CardioGuidanceInstruction.ACCELERATE ->
if (bpm >= target.minimumBpm + hysteresisBpm) {
CardioGuidanceInstruction.MAINTAIN
} else {
CardioGuidanceInstruction.ACCELERATE
}
CardioGuidanceInstruction.SLOW_DOWN ->
if (bpm <= target.maximumBpm - hysteresisBpm) {
CardioGuidanceInstruction.MAINTAIN
} else {
CardioGuidanceInstruction.SLOW_DOWN
}
CardioGuidanceInstruction.MAINTAIN,
CardioGuidanceInstruction.SUSPENDED ->
when {
bpm < target.minimumBpm - hysteresisBpm ->
CardioGuidanceInstruction.ACCELERATE
bpm > target.maximumBpm + hysteresisBpm ->
CardioGuidanceInstruction.SLOW_DOWN
else -> CardioGuidanceInstruction.MAINTAIN
}
}
}
companion object {
const val DEFAULT_HYSTERESIS_BPM = 2
const val DEFAULT_CONFIRMATION_MS = 3_000L
}
}

View file

@ -0,0 +1,40 @@
package com.labfytools.trainlog.data
import com.labfytools.trainlog.model.CardioGuidanceInstruction
import com.labfytools.trainlog.model.CardioPhaseKind
import com.labfytools.trainlog.model.CardioTargetSnapshot
import java.util.concurrent.CopyOnWriteArraySet
internal data class CardioGuidanceLiveSnapshot(
val phaseId: String? = null,
val phaseKind: CardioPhaseKind? = null,
val instruction: CardioGuidanceInstruction = CardioGuidanceInstruction.SUSPENDED,
val bpm: Int? = null,
val target: CardioTargetSnapshot? = null,
val startedAt: String? = null,
)
internal object CardioGuidanceLiveState {
private val listeners =
CopyOnWriteArraySet<(CardioGuidanceLiveSnapshot) -> Unit>()
@Volatile
private var snapshot = CardioGuidanceLiveSnapshot()
fun current(): CardioGuidanceLiveSnapshot = snapshot
fun publish(next: CardioGuidanceLiveSnapshot) {
snapshot = next
listeners.forEach { it(next) }
}
fun clear() {
publish(CardioGuidanceLiveSnapshot())
}
fun subscribe(listener: (CardioGuidanceLiveSnapshot) -> Unit): AutoCloseable {
listeners += listener
listener(snapshot)
return AutoCloseable { listeners -= listener }
}
}

View file

@ -195,7 +195,8 @@ 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-generation-error-v1.json", "request-v1.json",
"android-archive-acknowledgements-v1.json", "android-generation-error-v1.json",
"request-v1.json",
"desktop-archive-acknowledgements-v1.json", "desktop-consumption-ack-v1.json",
"desktop-generation-v1.json",
)
@ -261,6 +262,13 @@ 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",
@ -291,10 +299,15 @@ class DriveSyncWorker(context: Context, parameters: WorkerParameters) :
transport.pull(local)
stage = "coordinate"
val coordinator = SyncGenerationCoordinator(repository)
val terminalLedger = BackgroundSyncRunLedger(applicationContext)
coordinator.publishPeer(local)
stage = "peer-publication"
transport.pushPeer(local)
if (!coordinator.hasActionableRequest(local)) {
val actionability = coordinator.classifyBackgroundRequest(local, terminalLedger.terminal())
if (
actionability == BackgroundRequestActionability.NOT_ACTIONABLE ||
actionability == BackgroundRequestActionability.RESUMABLE_BUT_STALE
) {
if (coordinator.hasReplayableAcknowledgement(local)) {
stage = "acknowledgement-replay"
transport.pushAcknowledgement(local)
@ -313,6 +326,10 @@ 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)
@ -322,8 +339,31 @@ class DriveSyncWorker(context: Context, parameters: WorkerParameters) :
stage = "acknowledgement"
transport.pushAcknowledgement(local)
},
intent = SyncConversationIntent.RESUME_BACKGROUND,
)
if (result is ForegroundGenerationResult.Completed) Result.success() else Result.retry()
when (result) {
is ForegroundGenerationResult.Completed -> {
terminalLedger.recordTerminal(
result.runId,
coordinator.remoteEvidence(local, result.runId),
)
Result.success()
}
ForegroundGenerationResult.Busy,
is ForegroundGenerationResult.Cancelled -> Result.success()
is ForegroundGenerationResult.Superseded -> {
result.runId?.let {
terminalLedger.recordTerminal(it, coordinator.remoteEvidence(local, it))
}
Result.success()
}
is ForegroundGenerationResult.Failed -> {
result.runId?.let {
terminalLedger.recordTerminal(it, coordinator.remoteEvidence(local, it))
}
Result.retry()
}
}
} catch (error: Exception) {
/* SECURITY: stage and exception class are fixed application
* metadata. Provider messages may contain private URIs and are

View file

@ -39,6 +39,7 @@ internal fun canonicalExchangeDirectory(): File =
internal fun exchangeArtifactIdentity(displayName: String): String = when (displayName) {
"trainlog-exercise-body-zones-v1.json" -> "BODY_ZONES"
"trainlog-sync-request-v1.json" -> "SYNC_REQUEST"
"trainlog-sync-full-generation-request-v1.json" -> "FULL_GENERATION_SYNC_REQUEST"
"trainlog-mobile-export-v3.json" -> "MOBILE_EXPORT"
"trainlog-mobile-equipment-definitions-v1.json" -> "EQUIPMENT_DEFINITIONS"
"trainlog-equipment-associations-v2.json" -> "EQUIPMENT_ASSOCIATIONS"

View file

@ -0,0 +1,482 @@
/*
* Android-only BLE Heart Rate diagnostics.
*
* This owner is intentionally bounded and screen-scoped. Long-running capture
* belongs to the later foreground acquisition service, not this diagnostic.
*/
package com.labfytools.trainlog.data
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.bluetooth.BluetoothStatusCodes
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.ParcelUuid
import androidx.core.content.ContextCompat
import java.util.UUID
internal val HEART_RATE_SERVICE_UUID: UUID =
UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb")
internal val HEART_RATE_MEASUREMENT_UUID: UUID =
UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb")
internal val BATTERY_SERVICE_UUID: UUID =
UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")
internal val CLIENT_CHARACTERISTIC_CONFIG_UUID: UUID =
UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
internal enum class HeartRateDiagnosticPhase {
IDLE,
SCANNING,
CONNECTING,
DISCOVERING,
SUBSCRIBING,
RECEIVING,
DISCONNECTED,
ERROR,
}
internal data class HeartRateDiagnosticDevice(
val address: String,
val name: String?,
val rssi: Int,
val advertisesHeartRate: Boolean,
)
internal data class HeartRateDiagnosticSnapshot(
val phase: HeartRateDiagnosticPhase = HeartRateDiagnosticPhase.IDLE,
val devices: List<HeartRateDiagnosticDevice> = emptyList(),
val selectedAddress: String? = null,
val serviceFound: Boolean = false,
val measurementFound: Boolean = false,
val batteryServiceFound: Boolean = false,
val notificationCount: Int = 0,
val latestMeasurement: ParsedHeartRateMeasurement? = null,
val diagnostic: String? = null,
)
internal fun heartRateBleRuntimePermissions(): Array<String> =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
} else {
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
}
internal fun hasHeartRateBleRuntimePermissions(context: Context): Boolean =
heartRateBleRuntimePermissions().all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
internal class HeartRateBleDiagnostics(
context: Context,
private val onSnapshot: (HeartRateDiagnosticSnapshot) -> Unit,
) : AutoCloseable {
private val appContext = context.applicationContext
private val main = Handler(Looper.getMainLooper())
private val bluetoothManager =
appContext.getSystemService(BluetoothManager::class.java)
private val adapter get() = bluetoothManager?.adapter
private val devices = linkedMapOf<String, BluetoothDevice>()
private var snapshot = HeartRateDiagnosticSnapshot()
private var scannerCallback: ScanCallback? = null
private var scanStop: Runnable? = null
private var gatt: BluetoothGatt? = null
private var closed = false
private fun update(transform: (HeartRateDiagnosticSnapshot) -> HeartRateDiagnosticSnapshot) {
if (closed) return
if (Looper.myLooper() != Looper.getMainLooper()) {
main.post { update(transform) }
return
}
snapshot = transform(snapshot)
onSnapshot(snapshot)
}
@SuppressLint("MissingPermission")
fun startScan(includeAllBle: Boolean = false) {
if (!hasHeartRateBleRuntimePermissions(appContext)) {
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "permission") }
return
}
val currentAdapter = adapter
if (currentAdapter == null) {
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "bluetooth_unavailable") }
return
}
if (!currentAdapter.isEnabled) {
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "bluetooth_disabled") }
return
}
val scanner = currentAdapter.bluetoothLeScanner
if (scanner == null) {
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "scanner_unavailable") }
return
}
stopScan()
devices.clear()
update {
HeartRateDiagnosticSnapshot(
phase = HeartRateDiagnosticPhase.SCANNING,
diagnostic = if (includeAllBle) "scan_all" else "scan_heart_rate",
)
}
val callback =
object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
handleScanResult(result)
}
override fun onBatchScanResults(results: MutableList<ScanResult>) {
results.forEach(::handleScanResult)
}
override fun onScanFailed(errorCode: Int) {
update {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "scan_failed:" + errorCode,
)
}
}
}
scannerCallback = callback
val filters =
if (includeAllBle) {
emptyList()
} else {
listOf(
ScanFilter.Builder()
.setServiceUuid(ParcelUuid(HEART_RATE_SERVICE_UUID))
.build(),
)
}
scanner.startScan(
filters,
ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build(),
callback,
)
val stopper =
Runnable {
if (snapshot.phase == HeartRateDiagnosticPhase.SCANNING) {
stopScan()
update { it.copy(phase = HeartRateDiagnosticPhase.IDLE, diagnostic = "scan_complete") }
}
}
scanStop = stopper
main.postDelayed(stopper, SCAN_DURATION_MS)
}
@SuppressLint("MissingPermission")
private fun handleScanResult(result: ScanResult) {
val record = result.scanRecord
val advertised =
record?.serviceUuids?.any { it.uuid == HEART_RATE_SERVICE_UUID } == true
val device = result.device
devices[device.address] = device
val row =
HeartRateDiagnosticDevice(
address = device.address,
name = record?.deviceName,
rssi = result.rssi,
advertisesHeartRate = advertised,
)
update { old ->
val rows =
(old.devices.filterNot { it.address == row.address } + row)
.sortedWith(
compareByDescending<HeartRateDiagnosticDevice> { it.advertisesHeartRate }
.thenByDescending { it.rssi },
)
old.copy(devices = rows)
}
}
@SuppressLint("MissingPermission")
fun connect(address: String) {
if (!hasHeartRateBleRuntimePermissions(appContext)) {
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "permission") }
return
}
val device =
devices[address]
?: runCatching { adapter?.getRemoteDevice(address) }.getOrNull()
?: run {
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "device_missing") }
return
}
stopScan()
closeGatt()
update {
it.copy(
phase = HeartRateDiagnosticPhase.CONNECTING,
selectedAddress = address,
serviceFound = false,
measurementFound = false,
batteryServiceFound = false,
notificationCount = 0,
latestMeasurement = null,
diagnostic = "connecting",
)
}
@Suppress("DEPRECATION")
runCatching {
device.connectGatt(
appContext,
false,
gattCallback,
BluetoothDevice.TRANSPORT_LE,
)
}
.onSuccess { gatt = it }
.onFailure { error ->
update {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "connect_failed:" + error.javaClass.simpleName,
)
}
}
}
private val gattCallback =
object : BluetoothGattCallback() {
@SuppressLint("MissingPermission")
override fun onConnectionStateChange(
callbackGatt: BluetoothGatt,
status: Int,
newState: Int,
) {
main.post {
if (closed || callbackGatt !== gatt) return@post
if (status == BluetoothGatt.GATT_SUCCESS &&
newState == BluetoothProfile.STATE_CONNECTED
) {
update {
it.copy(
phase = HeartRateDiagnosticPhase.DISCOVERING,
diagnostic = "connected",
)
}
if (!callbackGatt.discoverServices()) {
update {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "discover_start_failed",
)
}
}
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
update {
it.copy(
phase = HeartRateDiagnosticPhase.DISCONNECTED,
diagnostic = "disconnected:" + status,
)
}
closeGatt()
} else if (status != BluetoothGatt.GATT_SUCCESS) {
update {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "gatt_status:" + status,
)
}
closeGatt()
}
}
}
@SuppressLint("MissingPermission")
override fun onServicesDiscovered(callbackGatt: BluetoothGatt, status: Int) {
main.post {
if (closed || callbackGatt !== gatt) return@post
if (status != BluetoothGatt.GATT_SUCCESS) {
update {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "service_discovery:" + status,
)
}
return@post
}
val service = callbackGatt.getService(HEART_RATE_SERVICE_UUID)
val measurement = service?.getCharacteristic(HEART_RATE_MEASUREMENT_UUID)
val battery = callbackGatt.getService(BATTERY_SERVICE_UUID) != null
update {
it.copy(
serviceFound = service != null,
measurementFound = measurement != null,
batteryServiceFound = battery,
)
}
if (measurement == null) {
update {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "heart_rate_measurement_missing",
)
}
return@post
}
subscribe(callbackGatt, measurement)
}
}
override fun onDescriptorWrite(
callbackGatt: BluetoothGatt,
descriptor: BluetoothGattDescriptor,
status: Int,
) {
if (descriptor.uuid != CLIENT_CHARACTERISTIC_CONFIG_UUID) return
update {
if (status == BluetoothGatt.GATT_SUCCESS) {
it.copy(
phase = HeartRateDiagnosticPhase.RECEIVING,
diagnostic = "notifications_enabled",
)
} else {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "descriptor_write:" + status,
)
}
}
}
@Deprecated("Deprecated by Android API 33 callback with explicit value")
override fun onCharacteristicChanged(
callbackGatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
) {
@Suppress("DEPRECATION")
val value = characteristic.value ?: return
handleMeasurement(characteristic.uuid, value)
}
override fun onCharacteristicChanged(
callbackGatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray,
) {
handleMeasurement(characteristic.uuid, value)
}
}
@SuppressLint("MissingPermission")
private fun subscribe(
callbackGatt: BluetoothGatt,
measurement: BluetoothGattCharacteristic,
) {
if (!callbackGatt.setCharacteristicNotification(measurement, true)) {
update {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "notification_enable_failed",
)
}
return
}
val descriptor = measurement.getDescriptor(CLIENT_CHARACTERISTIC_CONFIG_UUID)
if (descriptor == null) {
update {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "ccc_descriptor_missing",
)
}
return
}
update {
it.copy(
phase = HeartRateDiagnosticPhase.SUBSCRIBING,
diagnostic = "enabling_notifications",
)
}
val started =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
callbackGatt.writeDescriptor(
descriptor,
BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE,
) == BluetoothStatusCodes.SUCCESS
} else {
@Suppress("DEPRECATION")
run {
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
callbackGatt.writeDescriptor(descriptor)
}
}
if (!started) {
update {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "descriptor_write_start_failed",
)
}
}
}
private fun handleMeasurement(uuid: UUID, value: ByteArray) {
if (uuid != HEART_RATE_MEASUREMENT_UUID) return
when (val parsed = HeartRateMeasurementParser.parse(value)) {
is HeartRateMeasurementParseResult.Invalid ->
update {
it.copy(
phase = HeartRateDiagnosticPhase.ERROR,
diagnostic = "measurement:" + parsed.reason,
)
}
is HeartRateMeasurementParseResult.Parsed ->
update {
it.copy(
phase = HeartRateDiagnosticPhase.RECEIVING,
notificationCount = it.notificationCount + 1,
latestMeasurement = parsed.measurement,
diagnostic = "measurement",
)
}
}
}
@SuppressLint("MissingPermission")
fun stopScan() {
val callback = scannerCallback ?: return
scanStop?.let(main::removeCallbacks)
scanStop = null
runCatching { adapter?.bluetoothLeScanner?.stopScan(callback) }
scannerCallback = null
}
@SuppressLint("MissingPermission")
private fun closeGatt() {
val current = gatt
gatt = null
runCatching { current?.disconnect() }
runCatching { current?.close() }
}
override fun close() {
if (closed) return
stopScan()
closeGatt()
closed = true
}
private companion object {
const val SCAN_DURATION_MS = 10_000L
}
}

View file

@ -0,0 +1,80 @@
package com.labfytools.trainlog.data
import java.util.concurrent.CopyOnWriteArraySet
internal enum class HeartRateLivePhase {
DISCONNECTED,
CONNECTING,
CONNECTED,
STALE,
}
internal data class HeartRateLiveSnapshot(
val phase: HeartRateLivePhase = HeartRateLivePhase.DISCONNECTED,
val bpm: Int? = null,
val rrIntervals1024: List<Int> = emptyList(),
val sensorContactDetected: Boolean? = null,
val energyExpended: Int? = null,
val notificationCount: Long = 0,
)
internal object HeartRateLiveState {
private val listeners =
CopyOnWriteArraySet<(HeartRateLiveSnapshot) -> Unit>()
@Volatile
private var snapshot = HeartRateLiveSnapshot()
fun current(): HeartRateLiveSnapshot = snapshot
fun publish(next: HeartRateLiveSnapshot) {
snapshot = next
listeners.forEach { it(next) }
}
fun subscribe(listener: (HeartRateLiveSnapshot) -> Unit): AutoCloseable {
listeners += listener
listener(snapshot)
return AutoCloseable { listeners -= listener }
}
fun disconnected() {
publish(HeartRateLiveSnapshot(phase = HeartRateLivePhase.DISCONNECTED))
}
fun connecting() {
publish(
snapshot.copy(
phase = HeartRateLivePhase.CONNECTING,
bpm = null,
rrIntervals1024 = emptyList(),
sensorContactDetected = null,
energyExpended = null,
),
)
}
fun measurement(measurement: ParsedHeartRateMeasurement) {
publish(
HeartRateLiveSnapshot(
phase = HeartRateLivePhase.CONNECTED,
bpm = measurement.bpm,
rrIntervals1024 = measurement.rrIntervals1024,
sensorContactDetected = measurement.sensorContactDetected,
energyExpended = measurement.energyExpended,
notificationCount = snapshot.notificationCount + 1,
),
)
}
fun stale() {
if (snapshot.phase == HeartRateLivePhase.CONNECTED) {
publish(
snapshot.copy(
phase = HeartRateLivePhase.STALE,
bpm = null,
),
)
}
}
}

View file

@ -0,0 +1,103 @@
/*
* Bluetooth Heart Rate Measurement parser.
*
* This file deliberately has no Android Bluetooth dependency so protocol
* decoding can be exhaustively unit-tested independently of GATT transport.
*/
package com.labfytools.trainlog.data
internal data class ParsedHeartRateMeasurement(
val bpm: Int,
val sensorContactDetected: Boolean?,
val energyExpended: Int?,
/** Raw Bluetooth Heart Rate Service units: 1/1024 second. */
val rrIntervals1024: List<Int>,
)
internal sealed interface HeartRateMeasurementParseResult {
data class Parsed(val measurement: ParsedHeartRateMeasurement) :
HeartRateMeasurementParseResult
data class Invalid(val reason: String) : HeartRateMeasurementParseResult
}
internal object HeartRateMeasurementParser {
private const val FLAG_HEART_RATE_16_BIT = 1 shl 0
private const val FLAG_CONTACT_DETECTED = 1 shl 1
private const val FLAG_CONTACT_SUPPORTED = 1 shl 2
private const val FLAG_ENERGY_PRESENT = 1 shl 3
private const val FLAG_RR_PRESENT = 1 shl 4
private const val KNOWN_FLAGS =
FLAG_HEART_RATE_16_BIT or FLAG_CONTACT_DETECTED or FLAG_CONTACT_SUPPORTED or
FLAG_ENERGY_PRESENT or FLAG_RR_PRESENT
fun parse(payload: ByteArray): HeartRateMeasurementParseResult {
if (payload.isEmpty()) return HeartRateMeasurementParseResult.Invalid("missing flags")
val flags = payload[0].toInt() and 0xff
if ((flags and KNOWN_FLAGS.inv()) != 0) {
return HeartRateMeasurementParseResult.Invalid("reserved flags are set")
}
var offset = 1
fun uint8(): Int? =
payload.getOrNull(offset)?.let {
offset += 1
it.toInt() and 0xff
}
fun uint16(): Int? {
if (offset + 2 > payload.size) return null
val value =
(payload[offset].toInt() and 0xff) or
((payload[offset + 1].toInt() and 0xff) shl 8)
offset += 2
return value
}
val bpm =
if ((flags and FLAG_HEART_RATE_16_BIT) != 0) {
uint16() ?: return HeartRateMeasurementParseResult.Invalid("truncated 16-bit BPM")
} else {
uint8() ?: return HeartRateMeasurementParseResult.Invalid("missing BPM")
}
val contact =
if ((flags and FLAG_CONTACT_SUPPORTED) == 0) {
null
} else {
(flags and FLAG_CONTACT_DETECTED) != 0
}
val energy =
if ((flags and FLAG_ENERGY_PRESENT) != 0) {
uint16()
?: return HeartRateMeasurementParseResult.Invalid(
"truncated energy-expended value",
)
} else {
null
}
val rr = mutableListOf<Int>()
if ((flags and FLAG_RR_PRESENT) != 0) {
if (offset == payload.size) {
return HeartRateMeasurementParseResult.Invalid("RR flag has no interval")
}
if ((payload.size - offset) % 2 != 0) {
return HeartRateMeasurementParseResult.Invalid("truncated RR interval")
}
while (offset < payload.size) {
rr += checkNotNull(uint16())
}
} else if (offset != payload.size) {
return HeartRateMeasurementParseResult.Invalid("unexpected trailing bytes")
}
return HeartRateMeasurementParseResult.Parsed(
ParsedHeartRateMeasurement(
bpm = bpm,
sensorContactDetected = contact,
energyExpended = energy,
rrIntervals1024 = rr,
),
)
}
}

View file

@ -0,0 +1,46 @@
package com.labfytools.trainlog.data
import android.content.Context
internal data class SelectedHeartRateSensor(
val address: String,
val name: String?,
)
internal class HeartRateSensorPreferences(context: Context) {
private val preferences =
context.applicationContext.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
fun selected(): SelectedHeartRateSensor? {
if (!preferences.getBoolean(ENABLED, false)) return null
val address = preferences.getString(ADDRESS, null)?.takeIf { it.isNotBlank() } ?: return null
return SelectedHeartRateSensor(
address = address,
name = preferences.getString(NAME, null)?.takeIf { it.isNotBlank() },
)
}
fun select(address: String, name: String?) {
require(address.isNotBlank())
preferences.edit()
.putBoolean(ENABLED, true)
.putString(ADDRESS, address)
.putString(NAME, name?.takeIf { it.isNotBlank() })
.apply()
}
fun clear() {
preferences.edit()
.putBoolean(ENABLED, false)
.remove(ADDRESS)
.remove(NAME)
.apply()
}
companion object {
private const val PREFERENCES = "trainlog-heart-rate-sensor"
private const val ENABLED = "enabled"
private const val ADDRESS = "address"
private const val NAME = "name"
}
}

View file

@ -0,0 +1,561 @@
package com.labfytools.trainlog.data
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.bluetooth.BluetoothStatusCodes
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.SystemClock
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import com.labfytools.trainlog.R
import com.labfytools.trainlog.model.CardioGuidanceInstruction
import java.time.Duration
import java.time.OffsetDateTime
class HeartRateSensorService : Service() {
private val main = Handler(Looper.getMainLooper())
private val preferences by lazy { HeartRateSensorPreferences(applicationContext) }
private val bluetoothManager by lazy {
applicationContext.getSystemService(BluetoothManager::class.java)
}
private var selected: SelectedHeartRateSensor? = null
private var gatt: BluetoothGatt? = null
private var reconnectAttempt = 0
private var lastMeasurementElapsed = 0L
private var lastNotificationBpm: Int? = null
private var repository: TrainlogRepository? = null
private var guidancePhaseId: String? = null
private var guidanceEngine: CardioGuidanceEngine? = null
private fun repository(): TrainlogRepository =
repository ?: TrainlogRepository(applicationContext).also { repository = it }
private val reconnectRunnable = Runnable { connectSelected() }
private val connectionTimeoutRunnable =
Runnable {
if (HeartRateLiveState.current().phase == HeartRateLivePhase.CONNECTING) {
closeGatt()
scheduleReconnect()
}
}
private val staleRunnable =
object : Runnable {
override fun run() {
val last = lastMeasurementElapsed
if (last > 0L) {
val age = SystemClock.elapsedRealtime() - last
if (
age >= STALE_AFTER_MS &&
HeartRateLiveState.current().phase == HeartRateLivePhase.CONNECTED
) {
HeartRateLiveState.stale()
suspendCardioGuidance()
updateForegroundNotification()
}
if (age >= STALE_RECONNECT_AFTER_MS && gatt != null) {
lastMeasurementElapsed = 0L
closeGatt()
scheduleReconnect()
}
}
main.postDelayed(this, STALE_CHECK_MS)
}
}
override fun onCreate() {
super.onCreate()
createChannel()
promoteForeground()
main.post(staleRunnable)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) {
stopSelf()
return START_NOT_STICKY
}
selected = preferences.selected()
if (selected == null || !hasHeartRateBleRuntimePermissions(this)) {
HeartRateLiveState.disconnected()
updateForegroundNotification()
stopSelf()
return START_NOT_STICKY
}
if (bluetoothManager.adapter?.isEnabled != true) {
scheduleReconnect()
return START_STICKY
}
promoteForeground()
if (gatt == null) connectSelected()
return START_STICKY
}
@SuppressLint("MissingPermission")
@Suppress("DEPRECATION")
private fun connectSelected() {
main.removeCallbacks(reconnectRunnable)
main.removeCallbacks(connectionTimeoutRunnable)
val sensor = preferences.selected()
selected = sensor
val adapter = bluetoothManager.adapter
if (sensor == null || !hasHeartRateBleRuntimePermissions(this)) {
HeartRateLiveState.disconnected()
updateForegroundNotification()
stopSelf()
return
}
if (adapter?.isEnabled != true) {
scheduleReconnect()
return
}
closeGatt()
lastMeasurementElapsed = 0L
HeartRateLiveState.connecting()
updateForegroundNotification()
val device =
runCatching { adapter.getRemoteDevice(sensor.address) }.getOrNull()
?: run {
HeartRateLiveState.disconnected()
scheduleReconnect()
return
}
runCatching {
device.connectGatt(
applicationContext,
false,
gattCallback,
BluetoothDevice.TRANSPORT_LE,
)
}
.onSuccess {
gatt = it
main.postDelayed(connectionTimeoutRunnable, CONNECTION_TIMEOUT_MS)
}
.onFailure {
scheduleReconnect()
}
}
private val gattCallback =
object : BluetoothGattCallback() {
@SuppressLint("MissingPermission")
override fun onConnectionStateChange(
callbackGatt: BluetoothGatt,
status: Int,
newState: Int,
) {
main.post {
if (callbackGatt !== gatt) return@post
if (
status == BluetoothGatt.GATT_SUCCESS &&
newState == BluetoothProfile.STATE_CONNECTED
) {
reconnectAttempt = 0
main.removeCallbacks(connectionTimeoutRunnable)
HeartRateLiveState.connecting()
updateForegroundNotification()
if (!callbackGatt.discoverServices()) {
failConnection()
} else {
main.postDelayed(connectionTimeoutRunnable, CONNECTION_TIMEOUT_MS)
}
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
failConnection()
} else if (status != BluetoothGatt.GATT_SUCCESS) {
failConnection()
}
}
}
@SuppressLint("MissingPermission")
override fun onServicesDiscovered(callbackGatt: BluetoothGatt, status: Int) {
main.post {
if (callbackGatt !== gatt) return@post
if (status != BluetoothGatt.GATT_SUCCESS) {
failConnection()
return@post
}
val measurement =
callbackGatt
.getService(HEART_RATE_SERVICE_UUID)
?.getCharacteristic(HEART_RATE_MEASUREMENT_UUID)
if (measurement == null) {
failConnection()
return@post
}
main.removeCallbacks(connectionTimeoutRunnable)
if (!subscribe(callbackGatt, measurement)) {
failConnection()
} else {
main.postDelayed(connectionTimeoutRunnable, CONNECTION_TIMEOUT_MS)
}
}
}
override fun onDescriptorWrite(
callbackGatt: BluetoothGatt,
descriptor: BluetoothGattDescriptor,
status: Int,
) {
if (descriptor.uuid != CLIENT_CHARACTERISTIC_CONFIG_UUID) return
main.post {
if (callbackGatt !== gatt) return@post
if (status != BluetoothGatt.GATT_SUCCESS) {
failConnection()
} else {
main.removeCallbacks(connectionTimeoutRunnable)
HeartRateLiveState.connecting()
updateForegroundNotification()
}
}
}
@Deprecated("Deprecated by Android API 33 callback with explicit value")
override fun onCharacteristicChanged(
callbackGatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
) {
@Suppress("DEPRECATION")
val value = characteristic.value ?: return
onHeartRateNotification(callbackGatt, characteristic.uuid, value)
}
override fun onCharacteristicChanged(
callbackGatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray,
) {
onHeartRateNotification(callbackGatt, characteristic.uuid, value)
}
}
@SuppressLint("MissingPermission")
private fun subscribe(
callbackGatt: BluetoothGatt,
measurement: BluetoothGattCharacteristic,
): Boolean {
if (!callbackGatt.setCharacteristicNotification(measurement, true)) return false
val descriptor = measurement.getDescriptor(CLIENT_CHARACTERISTIC_CONFIG_UUID) ?: return false
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
callbackGatt.writeDescriptor(
descriptor,
BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE,
) == BluetoothStatusCodes.SUCCESS
} else {
@Suppress("DEPRECATION")
run {
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
callbackGatt.writeDescriptor(descriptor)
}
}
}
private fun onHeartRateNotification(
callbackGatt: BluetoothGatt,
uuid: java.util.UUID,
value: ByteArray,
) {
if (callbackGatt !== gatt || uuid != HEART_RATE_MEASUREMENT_UUID) return
val parsed = HeartRateMeasurementParser.parse(value)
if (parsed !is HeartRateMeasurementParseResult.Parsed) return
lastMeasurementElapsed = SystemClock.elapsedRealtime()
HeartRateLiveState.measurement(parsed.measurement)
val observedAt = OffsetDateTime.now().toString()
val repository = repository()
val sessionRecorded =
repository.recordLiveHeartRateForActiveSession(
sensorName = selected?.name,
observedAt = observedAt,
measurement = parsed.measurement,
)
if (sessionRecorded != null) {
processCardioGuidance(repository, observedAt, parsed.measurement.bpm)
} else {
repository.recordLiveHeartRateForActiveSleep(
sensorName = selected?.name,
observedAt = observedAt,
measurement = parsed.measurement,
)
clearCardioGuidanceEngine()
}
if (lastNotificationBpm != parsed.measurement.bpm) {
lastNotificationBpm = parsed.measurement.bpm
updateForegroundNotification()
}
}
private fun processCardioGuidance(
repository: TrainlogRepository,
observedAt: String,
bpm: Int,
) {
val phase = repository.activeCardioGuidancePhase()
if (phase == null) {
clearCardioGuidanceEngine()
return
}
if (guidancePhaseId != phase.phase.phaseId || guidanceEngine == null) {
guidancePhaseId = phase.phase.phaseId
guidanceEngine =
CardioGuidanceEngine(initialInstruction = phase.currentInstruction)
}
val engine = checkNotNull(guidanceEngine)
val output =
engine.evaluate(
target = phase.phase.target,
bpm = bpm,
fresh = true,
nowMs = SystemClock.elapsedRealtime(),
)
repository.recordCardioGuidanceOutput(
phase.phase.phaseId,
observedAt,
output,
)
CardioGuidanceLiveState.publish(
CardioGuidanceLiveSnapshot(
phaseId = phase.phase.phaseId,
phaseKind = phase.phase.kind,
instruction = output.instruction,
bpm = output.bpm,
target = phase.phase.target,
startedAt = phase.startedAt,
),
)
maybeFinishCardioGuidancePhase(repository, phase, observedAt, bpm, fresh = true)
}
private fun suspendCardioGuidance() {
val repository = repository ?: return
val phase = repository.activeCardioGuidancePhase() ?: run {
clearCardioGuidanceEngine()
return
}
if (guidancePhaseId != phase.phase.phaseId || guidanceEngine == null) {
guidancePhaseId = phase.phase.phaseId
guidanceEngine =
CardioGuidanceEngine(initialInstruction = phase.currentInstruction)
}
val output =
checkNotNull(guidanceEngine).evaluate(
target = phase.phase.target,
bpm = null,
fresh = false,
nowMs = SystemClock.elapsedRealtime(),
)
val observedAt = OffsetDateTime.now().toString()
repository.recordCardioGuidanceOutput(
phase.phase.phaseId,
observedAt,
output,
)
CardioGuidanceLiveState.publish(
CardioGuidanceLiveSnapshot(
phaseId = phase.phase.phaseId,
phaseKind = phase.phase.kind,
instruction = CardioGuidanceInstruction.SUSPENDED,
bpm = null,
target = phase.phase.target,
startedAt = phase.startedAt,
),
)
maybeFinishCardioGuidancePhase(repository, phase, observedAt, null, fresh = false)
}
private fun maybeFinishCardioGuidancePhase(
repository: TrainlogRepository,
phase: com.labfytools.trainlog.model.CardioGuidancePhaseRecord,
observedAt: String,
bpm: Int?,
fresh: Boolean,
) {
val elapsedSeconds =
runCatching {
Duration.between(
OffsetDateTime.parse(phase.startedAt),
OffsetDateTime.parse(observedAt),
).seconds
}
.getOrNull()
?.coerceIn(0L, Int.MAX_VALUE.toLong())
?.toInt()
?: return
if (
CardioPhaseCompletion.isComplete(
phase.phase,
elapsedSeconds,
bpm,
fresh,
)
) {
repository.finishCardioGuidancePhase(phase.phase.phaseId, observedAt)
clearCardioGuidanceEngine()
}
}
private fun clearCardioGuidanceEngine() {
guidancePhaseId = null
guidanceEngine = null
CardioGuidanceLiveState.clear()
}
private fun failConnection() {
main.removeCallbacks(connectionTimeoutRunnable)
closeGatt()
lastMeasurementElapsed = 0L
lastNotificationBpm = null
scheduleReconnect()
}
private fun scheduleReconnect() {
if (preferences.selected() == null) {
HeartRateLiveState.disconnected()
updateForegroundNotification()
return
}
HeartRateLiveState.connecting()
updateForegroundNotification()
reconnectAttempt = (reconnectAttempt + 1).coerceAtMost(6)
val delay =
(RECONNECT_BASE_MS * reconnectAttempt.toLong()).coerceAtMost(RECONNECT_MAX_MS)
main.removeCallbacks(reconnectRunnable)
main.postDelayed(reconnectRunnable, delay)
}
@SuppressLint("MissingPermission")
private fun closeGatt() {
val current = gatt
gatt = null
runCatching { current?.disconnect() }
runCatching { current?.close() }
}
override fun onDestroy() {
main.removeCallbacks(reconnectRunnable)
main.removeCallbacks(connectionTimeoutRunnable)
main.removeCallbacks(staleRunnable)
closeGatt()
repository?.close()
repository = null
HeartRateLiveState.disconnected()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
private fun createChannel() {
getSystemService(NotificationManager::class.java)
.createNotificationChannel(
NotificationChannel(
CHANNEL,
getString(R.string.heart_rate_service_channel),
NotificationManager.IMPORTANCE_LOW,
),
)
}
private fun promoteForeground() {
val notification = buildNotification()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE,
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
private fun updateForegroundNotification() {
runCatching {
getSystemService(NotificationManager::class.java)
.notify(NOTIFICATION_ID, buildNotification())
}
}
private fun buildNotification(): android.app.Notification {
val live = HeartRateLiveState.current()
val text =
when (live.phase) {
HeartRateLivePhase.CONNECTED ->
live.bpm?.let { getString(R.string.heart_rate_service_bpm, it) }
?: getString(R.string.heart_rate_service_connecting)
HeartRateLivePhase.CONNECTING ->
getString(R.string.heart_rate_service_connecting)
HeartRateLivePhase.STALE ->
getString(R.string.heart_rate_service_reconnecting)
HeartRateLivePhase.DISCONNECTED ->
getString(R.string.heart_rate_service_disconnected)
}
return NotificationCompat.Builder(this, CHANNEL)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(getString(R.string.heart_rate_service_title))
.setContentText(text)
.setOngoing(true)
.setOnlyAlertOnce(true)
.build()
}
companion object {
private const val CHANNEL = "trainlog_heart_rate"
private const val NOTIFICATION_ID = 1402
private const val ACTION_START = "com.labfytools.trainlog.heart_rate.START"
private const val ACTION_STOP = "com.labfytools.trainlog.heart_rate.STOP"
private const val CONNECTION_TIMEOUT_MS = 12_000L
private const val STALE_AFTER_MS = 3_000L
private const val STALE_RECONNECT_AFTER_MS = 8_000L
private const val STALE_CHECK_MS = 1_000L
private const val RECONNECT_BASE_MS = 2_000L
private const val RECONNECT_MAX_MS = 15_000L
fun start(context: Context) {
if (
HeartRateSensorPreferences(context).selected() == null ||
!hasHeartRateBleRuntimePermissions(context)
) {
HeartRateLiveState.disconnected()
return
}
ContextCompat.startForegroundService(
context,
Intent(context, HeartRateSensorService::class.java).setAction(ACTION_START),
)
}
fun stop(context: Context) {
context.startService(
Intent(context, HeartRateSensorService::class.java).setAction(ACTION_STOP),
)
}
fun forget(context: Context) {
HeartRateSensorPreferences(context).clear()
stop(context)
HeartRateLiveState.disconnected()
}
}
}

View file

@ -0,0 +1,40 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.media.MediaScannerConnection
import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/** Confirms that durable external-storage files have reached Android's MTP index. */
internal fun interface MtpPublicationVisibility {
fun confirm(files: List<File>)
}
internal object ImmediateMtpPublicationVisibility : MtpPublicationVisibility {
override fun confirm(files: List<File>) = Unit
}
internal class AndroidMtpPublicationVisibility(context: Context) : MtpPublicationVisibility {
private val applicationContext = context.applicationContext
override fun confirm(files: List<File>) {
val paths = files.asSequence().filter(File::isFile).map { it.absolutePath }.distinct().toList()
if (paths.isEmpty()) return
val completed = CountDownLatch(paths.size)
/* WHY: direct File writes are durable before MediaProvider necessarily
* advertises them through Samsung's MTP database. CONTRACT: every
* immutable artifact is scanned before its mutable reference, and a
* publication is not reported complete until every callback arrives.
* INVARIANT: scanning changes transport visibility only; canonical
* names, bytes, generation identities and commit markers are intact. */
MediaScannerConnection.scanFile(
applicationContext,
paths.toTypedArray(),
Array(paths.size) { "application/json" },
) { _, _ -> completed.countDown() }
if (!completed.await(15, TimeUnit.SECONDS)) {
throw SyncGenerationException("timeout confirming MTP publication visibility")
}
}
}

View file

@ -0,0 +1,44 @@
package com.labfytools.trainlog.data
import android.content.Context
internal enum class BackgroundRequestActionability {
NEW_REQUEST,
NEW_REMOTE_EVIDENCE,
ACTIVE_HANDOFF,
RESUMABLE_BUT_STALE,
NOT_ACTIONABLE,
}
internal data class BackgroundTerminalRun(
val runId: String,
val remoteEvidence: String,
)
/** One-entry durable suppression ledger for the current background request. */
internal class BackgroundSyncRunLedger(context: Context) {
private val preferences =
context.applicationContext.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
fun terminal(): BackgroundTerminalRun? {
val runId = preferences.getString(RUN_ID, null) ?: return null
return BackgroundTerminalRun(runId, preferences.getString(REMOTE_EVIDENCE, "").orEmpty())
}
fun recordTerminal(runId: String, remoteEvidence: String) {
/* CONTRACT: only the request currently present on the transport needs
* suppression. A replacement request has a distinct run_id and is
* immediately actionable, so this ledger remains permanently bounded
* to one record and is not a second synchronization database. */
preferences.edit()
.putString(RUN_ID, runId)
.putString(REMOTE_EVIDENCE, remoteEvidence)
.apply()
}
companion object {
private const val PREFERENCES = "trainlog-background-sync-terminal"
private const val RUN_ID = "run_id"
private const val REMOTE_EVIDENCE = "remote_evidence"
}
}

View file

@ -32,37 +32,29 @@ internal class BackgroundSyncSettings(context: Context) {
}
/**
* User-enabled foreground listener for PC-initiated USB generation requests.
* User-enabled foreground listener for automatic Bluetooth and MTP synchronization.
*
* WHY: a Web request must be consumed while Compose is not visible, but modern
* Android does not permit an invisible permanent background loop.
* CONTRACT: the service shows a mandatory notification, invokes the same
* generation coordinator as SyncScreen, and never starts an exercise.
* INVARIANT: filesystem visibility only triggers protocol validation; stable
* WHY: the phone must reconnect to the paired desktop while Compose is not
* visible, but modern Android does not permit an invisible permanent loop.
* CONTRACT: the service shows a mandatory notification, owns the RFCOMM client,
* invokes the same generation coordinator as SyncScreen, and never starts an
* exercise. MTP remains a filesystem fallback.
* INVARIANT: transport visibility only triggers protocol validation; stable
* run/generation/ACK identities remain the sole business authority.
*/
class SyncBackgroundService : Service() {
private val stopped = AtomicBoolean(false)
private val launched = AtomicBoolean(false)
private val executor = Executors.newSingleThreadExecutor()
private val executor = Executors.newFixedThreadPool(2)
private var repository: TrainlogRepository? = null
private var bluetoothClient: BluetoothSyncClient? = null
override fun onCreate() {
super.onCreate()
createChannel()
val notification = NotificationCompat.Builder(this, CHANNEL)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(getString(R.string.background_sync_notification_title))
.setContentText(getString(R.string.background_sync_notification_text))
.setOngoing(true)
.setOnlyAlertOnce(true)
.build()
if (Build.VERSION.SDK_INT >= 29) {
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
startForeground(NOTIFICATION_ID, notification)
}
promoteForeground()
repository = TrainlogRepository(applicationContext)
bluetoothClient = BluetoothSyncClient(applicationContext, requireNotNull(repository))
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@ -70,9 +62,17 @@ class SyncBackgroundService : Service() {
stopSelf()
return START_NOT_STICKY
}
promoteForeground()
if (!launched.compareAndSet(false, true)) return START_NOT_STICKY
executor.execute {
val coordinator = SyncGenerationCoordinator(requireNotNull(repository))
bluetoothClient?.run(stopped)
}
executor.execute {
val coordinator = SyncGenerationCoordinator(
requireNotNull(repository),
AndroidMtpPublicationVisibility(applicationContext),
)
val terminalLedger = BackgroundSyncRunLedger(applicationContext)
while (!stopped.get() && BackgroundSyncSettings(this).enabled) {
val directory = canonicalExchangeDirectory()
/* WHY: waiting inside run() owns the process-wide conversation
@ -81,9 +81,38 @@ class SyncBackgroundService : Service() {
* the coordinator still validates all protocol evidence.
* INVARIANT: no request means no business work and no lock. */
coordinator.publishPeer(directory)
if (coordinator.hasActionableRequest(directory)) {
coordinator.run(directory, Duration.ofMinutes(5))
val actionability =
coordinator.classifyBackgroundRequest(directory, terminalLedger.terminal())
if (
actionability == BackgroundRequestActionability.NEW_REQUEST ||
actionability == BackgroundRequestActionability.NEW_REMOTE_EVIDENCE ||
actionability == BackgroundRequestActionability.ACTIVE_HANDOFF
) {
when (
val result = coordinator.run(
directory,
Duration.ofMinutes(5),
intent = SyncConversationIntent.RESUME_BACKGROUND,
)
) {
is ForegroundGenerationResult.Completed ->
terminalLedger.recordTerminal(
result.runId,
coordinator.remoteEvidence(directory, result.runId),
)
is ForegroundGenerationResult.Failed ->
result.runId?.let {
terminalLedger.recordTerminal(it, coordinator.remoteEvidence(directory, it))
}
is ForegroundGenerationResult.Superseded ->
result.runId?.let {
terminalLedger.recordTerminal(it, coordinator.remoteEvidence(directory, it))
}
ForegroundGenerationResult.Busy,
is ForegroundGenerationResult.Cancelled -> Unit
}
}
AiExportDriveSettings.enqueueImmediate(applicationContext)
if (!stopped.get()) runCatching { Thread.sleep(5_000) }
}
stopSelf()
@ -93,6 +122,8 @@ class SyncBackgroundService : Service() {
override fun onDestroy() {
stopped.set(true)
bluetoothClient?.shutdown()
bluetoothClient = null
executor.shutdownNow()
repository?.close()
repository = null
@ -109,6 +140,28 @@ class SyncBackgroundService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
private fun promoteForeground() {
val notification =
NotificationCompat.Builder(this, CHANNEL)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(getString(R.string.background_sync_notification_title))
.setContentText(getString(R.string.background_sync_notification_text))
.setOngoing(true)
.setOnlyAlertOnce(true)
.build()
if (Build.VERSION.SDK_INT >= 29) {
val serviceType =
if (hasBluetoothConnectPermission(this)) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
} else {
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
}
startForeground(NOTIFICATION_ID, notification, serviceType)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
private fun createChannel() {
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(

View file

@ -0,0 +1,116 @@
package com.labfytools.trainlog.data
import java.time.Duration
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
internal enum class SyncConversationOwner {
FOREGROUND_EXPLICIT,
BACKGROUND_AUTOMATIC,
}
internal class SyncConversationYieldedException : Exception("conversation superseded by explicit sync")
/** Process-wide ownership authority for Android generation conversations. */
internal class SyncConversationArbiter {
private val lock = ReentrantLock()
private val ownershipChanged = lock.newCondition()
private var active: Lease? = null
private var pendingExplicit = 0
/**
* Announces user intent before the foreground service is refreshed.
*
* WHY: starting the service first allowed it to acquire the old binary
* lock and silently absorb the click. CONTRACT: an announcement prevents
* new background ownership and cooperatively supersedes an existing
* background lease. INVARIANT: ownership is still exclusive until that
* lease observes yield and releases in its finally block.
*/
fun announceExplicit(): ExplicitClaim = lock.withLock {
pendingExplicit += 1
active?.takeIf { it.owner == SyncConversationOwner.BACKGROUND_AUTOMATIC }
?.requestYield()
ownershipChanged.signalAll()
ExplicitClaim(this)
}
fun tryAcquireBackground(): Lease? = lock.withLock {
if (active != null || pendingExplicit > 0) return null
Lease(this, SyncConversationOwner.BACKGROUND_AUTOMATIC).also { active = it }
}
private fun acquireExplicit(claim: ExplicitClaim, timeout: Duration): Lease? {
return lock.withLock {
check(claim.announced)
var remaining = timeout.toNanos()
while (active != null) {
if (remaining <= 0L) {
releaseAnnouncement(claim)
return@withLock null
}
remaining = ownershipChanged.awaitNanos(remaining)
}
releaseAnnouncement(claim)
Lease(this, SyncConversationOwner.FOREGROUND_EXPLICIT).also { active = it }
}
}
private fun releaseAnnouncement(claim: ExplicitClaim) {
if (!claim.announced) return
claim.announced = false
pendingExplicit -= 1
check(pendingExplicit >= 0)
ownershipChanged.signalAll()
}
private fun release(lease: Lease) = lock.withLock {
if (active === lease) {
active = null
ownershipChanged.signalAll()
}
}
internal class ExplicitClaim internal constructor(private val arbiter: SyncConversationArbiter) :
AutoCloseable {
internal var announced = true
private var acquired = false
fun acquire(timeout: Duration): Lease? {
check(!acquired)
val lease = arbiter.acquireExplicit(this, timeout)
acquired = lease != null
return lease
}
override fun close() = arbiter.lock.withLock {
if (!acquired) arbiter.releaseAnnouncement(this)
}
}
internal class Lease internal constructor(
private val arbiter: SyncConversationArbiter,
val owner: SyncConversationOwner,
) : AutoCloseable {
@Volatile private var yieldRequested = false
@Volatile private var closed = false
internal fun requestYield() {
yieldRequested = true
}
fun throwIfYieldRequested() {
if (yieldRequested) throw SyncConversationYieldedException()
}
override fun close() {
if (closed) return
closed = true
arbiter.release(this)
}
}
companion object {
val process = SyncConversationArbiter()
}
}

View file

@ -1,5 +1,6 @@
package com.labfytools.trainlog.data
import android.util.Log
import java.io.File
import java.io.FileOutputStream
import java.nio.file.AtomicMoveNotSupportedException
@ -12,33 +13,72 @@ import org.json.JSONObject
internal sealed interface ForegroundGenerationResult {
data class Completed(val runId: String) : ForegroundGenerationResult
data class Error(val message: String) : ForegroundGenerationResult
data object Busy : ForegroundGenerationResult
data class Superseded(val runId: String?) : ForegroundGenerationResult
data class Cancelled(val runId: String?) : ForegroundGenerationResult
data class Failed(val message: String, val runId: String?) : ForegroundGenerationResult
}
internal enum class SyncConversationIntent {
NEW_EXPLICIT,
RESUME_BACKGROUND,
}
internal fun interface SyncGenerationTrace {
fun record(runId: String?, phase: String, artifact: String?, generationId: String?, result: String?)
}
internal object AndroidSyncGenerationTrace : SyncGenerationTrace {
override fun record(
runId: String?, phase: String, artifact: String?, generationId: String?, result: String?
) {
Log.i(
"TrainlogSyncGeneration",
listOfNotNull(
"run_id=${runId ?: "pending"}",
"phase=$phase",
artifact?.let { "artifact=$it" },
generationId?.let { "generation_id=$it" },
result?.let { "result=$it" },
).joinToString(" "),
)
}
}
internal fun acknowledgementMatchesCapturedGeneration(
acknowledgement: JSONObject,
captured: CapturedSyncGeneration,
): Boolean =
acknowledgement.optString("generation_id") == captured.generationId &&
acknowledgement.optString("manifest_sha256") == captured.manifestSha256
/** Owns the bounded, user-initiated Android side of a generation conversation. */
internal class SyncGenerationCoordinator(private val repository: TrainlogRepository) {
internal class SyncGenerationCoordinator(
private val repository: TrainlogRepository,
private val visibility: MtpPublicationVisibility = ImmediateMtpPublicationVisibility,
private val trace: SyncGenerationTrace = AndroidSyncGenerationTrace,
private val arbiter: SyncConversationArbiter = SyncConversationArbiter.process,
) {
private val service = SyncGenerationService(repository)
companion object {
/* WHY: UI and background triggers share one repository protocol and
* must never create concurrent runs. CONTRACT: this process-wide lock
* guards only one bounded conversation; SQLite remains the durable
* replay authority after process restart. */
private val conversationLock = java.util.concurrent.locks.ReentrantLock()
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,
artifact: String? = null,
generationId: String? = null,
result: String? = null,
) = trace.record(runId, phase, artifact, generationId, result)
internal fun resumableGeneration(runId: String, consumerPeerId: String? = null): CapturedSyncGeneration? =
repository.inSyncGenerationTransaction { db ->
db.rawQuery(
"SELECT generation_id,manifest_sha256,staging_path FROM sync_generations " +
"WHERE run_id=? AND (status IN('captured','published','waiting_acknowledgement') " +
"OR (status='acknowledged' AND NOT EXISTS(" +
"SELECT 1 FROM sync_consumed_generations c WHERE c.run_id=sync_generations.run_id " +
"AND c.result IN('consumed','rejected')))) " +
(if (consumerPeerId == null) "" else "AND consumer_peer_id=? ") +
"LIMIT 2",
if (consumerPeerId == null) arrayOf(runId) else arrayOf(runId, consumerPeerId),
fun query(sql: String, arguments: Array<String>): CapturedSyncGeneration? = db.rawQuery(
sql,
arguments,
).use { cursor ->
if (!cursor.moveToFirst()) null
else {
@ -54,12 +94,35 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
captured
}
}
query(
"SELECT generation_id,manifest_sha256,staging_path FROM sync_generations " +
"WHERE run_id=? AND (status IN('captured','published','waiting_acknowledgement') " +
"OR (status='acknowledged' AND NOT EXISTS(" +
"SELECT 1 FROM sync_consumed_generations c WHERE c.run_id=sync_generations.run_id " +
"AND c.result IN('consumed','rejected')))) " +
(if (consumerPeerId == null) "" else "AND consumer_peer_id=? ") +
"LIMIT 2",
if (consumerPeerId == null) arrayOf(runId) else arrayOf(runId, consumerPeerId),
) ?: if (consumerPeerId == null) null else query(
/* WHY: an RFCOMM reset creates a new conversation run while
* the immutable generation still awaits its correlated ACK.
* CONTRACT: resume only the unsuperseded generation for the
* same peer; the reference adopts the new run while manifest
* and generation identity remain unchanged. */
"SELECT g.generation_id,g.manifest_sha256,g.staging_path FROM sync_generations g " +
"WHERE g.consumer_peer_id=? AND g.status IN('captured','published','waiting_acknowledgement') " +
"AND NOT EXISTS(SELECT 1 FROM sync_generations sibling JOIN sync_acknowledgements a " +
"ON a.generation_id=sibling.generation_id WHERE sibling.parent_generation_id=g.parent_generation_id " +
"AND sibling.generation_id<>g.generation_id AND sibling.status='acknowledged') " +
"ORDER BY g.generated_at DESC LIMIT 2",
arrayOf(consumerPeerId),
)
}
internal fun hasActionableRequest(directory: File): Boolean {
private fun request(directory: File): JSONObject? {
val path = File(directory, "request-v1.json")
if (!path.isFile || path.length() !in 1..64 * 1024) return false
val request = runCatching { JSONObject(path.readText()) }.getOrNull() ?: return false
if (!path.isFile || path.length() !in 1..64 * 1024) return null
val request = runCatching { JSONObject(path.readText()) }.getOrNull() ?: return null
val runId = request.optString("run_id")
val desktopPeerId = request.optString("desktop_peer_id")
if (
@ -68,16 +131,64 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
!runId.matches(Regex("^sy_[0-9a-f-]{36}$")) ||
!desktopPeerId.matches(Regex("^peer_[0-9a-f-]{36}$")) ||
request.optString("android_peer_id") != service.peerId()
) return false
if (resumableGeneration(runId, desktopPeerId) != null) return true
return !repository.inSyncGenerationTransaction { db ->
) return null
return request
}
private fun hasGeneration(runId: String): Boolean =
repository.inSyncGenerationTransaction { db ->
db.rawQuery(
"SELECT 1 FROM sync_generations WHERE run_id=? LIMIT 1",
arrayOf(runId),
).use { it.moveToFirst() }
}
internal fun remoteEvidence(directory: File, runId: String): String {
val evidence =
listOf(
Triple("desktop-consumption-ack-v1.json", "trainlog-sync-ack", 1),
Triple("desktop-generation-v1.json", "trainlog-sync-generation-reference", 1),
)
.mapNotNull { (name, format, version) ->
val path = File(directory, name)
if (!path.isFile || path.length() !in 1..64 * 1024) return@mapNotNull null
val raw = runCatching { path.readBytes() }.getOrNull() ?: return@mapNotNull null
val value = runCatching { JSONObject(raw.toString(Charsets.UTF_8)) }.getOrNull()
?: return@mapNotNull null
if (
value.optString("format") != format ||
value.optInt("version", -1) != version ||
value.optString("run_id") != runId
) return@mapNotNull null
"$name:${java.security.MessageDigest.getInstance("SHA-256").digest(raw)
.joinToString("") { "%02x".format(it) }}"
}
return evidence.sorted().joinToString("|")
}
internal fun classifyBackgroundRequest(
directory: File,
terminal: BackgroundTerminalRun?,
): BackgroundRequestActionability {
val request = request(directory) ?: return BackgroundRequestActionability.NOT_ACTIONABLE
val runId = request.getString("run_id")
val desktopPeerId = request.getString("desktop_peer_id")
val resumable = resumableGeneration(runId, desktopPeerId) != null
if (terminal?.runId == runId) {
val currentEvidence = remoteEvidence(directory, runId)
return if (currentEvidence.isNotEmpty() && currentEvidence != terminal.remoteEvidence)
BackgroundRequestActionability.NEW_REMOTE_EVIDENCE
else if (resumable) BackgroundRequestActionability.RESUMABLE_BUT_STALE
else BackgroundRequestActionability.NOT_ACTIONABLE
}
if (!hasGeneration(runId)) return BackgroundRequestActionability.NEW_REQUEST
if (resumable) return BackgroundRequestActionability.ACTIVE_HANDOFF
return BackgroundRequestActionability.NOT_ACTIONABLE
}
internal fun currentRequestRunId(directory: File): String? =
request(directory)?.optString("run_id")
internal fun hasReplayableAcknowledgement(directory: File): Boolean {
val request =
runCatching { JSONObject(File(directory, "request-v1.json").readText()) }.getOrNull()
@ -114,6 +225,45 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
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,
@ -217,6 +367,7 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
"mobile-history-v4",
"execution-draft-v1",
"generation-archive-v1",
ACK_RECOVERY_CAPABILITY,
"session-preparations-v2",
"programs-v1",
)
@ -229,9 +380,12 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
* an identical canonical descriptor is already a complete durable
* publication. INVARIANT: changed or unreadable bytes still use the
* crash-safe replacement path above. */
if (runCatching { path.isFile && path.readText() == descriptor }.getOrDefault(false))
if (runCatching { path.isFile && path.readText() == descriptor }.getOrDefault(false)) {
visibility.confirm(listOf(path))
return peer
}
publish(path, descriptor)
visibility.confirm(listOf(path))
return peer
}
@ -250,8 +404,10 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
deadline: Long,
predicate: (JSONObject) -> Boolean,
pump: (() -> Unit)? = null,
lease: SyncConversationArbiter.Lease,
): ByteArray {
while (true) {
lease.throwIfYieldRequested()
if (path.isFile && path.length() <= 64 * 1024) {
try {
val raw = path.readBytes()
@ -272,36 +428,61 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
directory: File,
timeout: Duration = Duration.ofMinutes(5),
afterPeerPublication: (() -> Unit)? = null,
afterRecoveryAcknowledgements: (() -> Unit)? = null,
afterPublication: (() -> Unit)? = null,
pollTransport: (() -> Unit)? = null,
afterAcknowledgement: (() -> Unit)? = null,
intent: SyncConversationIntent = SyncConversationIntent.RESUME_BACKGROUND,
explicitClaim: SyncConversationArbiter.ExplicitClaim? = null,
): ForegroundGenerationResult {
if (!conversationLock.tryLock()) {
return ForegroundGenerationResult.Error("Synchronization already in progress.")
val claim =
if (intent == SyncConversationIntent.NEW_EXPLICIT)
explicitClaim ?: arbiter.announceExplicit()
else null
val lease = try {
claim?.acquire(timeout) ?: if (claim == null) arbiter.tryAcquireBackground() else null
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
claim?.close()
return ForegroundGenerationResult.Cancelled(null)
}
if (lease == null) {
claim?.close()
return ForegroundGenerationResult.Busy
}
var currentRunId: String? = null
return try {
val peer = publishPeer(directory)
phase(null, "peer_published", "android-peer-v1.json")
val baselineRunId = request(directory)?.optString("run_id")
/* WHY: trainlog-syncd is intentionally driven by the established
* Android request artifact, not by polling generation internals.
* CONTRACT: each explicit foreground attempt publishes the peer
* identity before its caller emits one fresh request signal.
* INVARIANT: a failed attempt's request, run, generations, and
* ACK evidence remain immutable; retry creates new correlation. */
afterPeerPublication?.invoke()
afterPeerPublication?.let {
it.invoke()
phase(
null,
"full_generation_trigger_published",
FULL_GENERATION_REQUEST_NAME,
)
}
val deadline = System.nanoTime() + timeout.toNanos()
val requestRaw =
awaitCorrelated(File(directory, "request-v1.json"), deadline, { candidate ->
val runId = candidate.optString("run_id")
runId.startsWith("sy_") &&
(resumableGeneration(runId) != null ||
!repository.inSyncGenerationTransaction { db ->
db.rawQuery(
"SELECT 1 FROM sync_generations WHERE run_id=? LIMIT 1",
arrayOf(runId),
).use { it.moveToFirst() }
})
}, pollTransport)
runId.startsWith("sy_") && when (intent) {
SyncConversationIntent.NEW_EXPLICIT -> runId != baselineRunId
SyncConversationIntent.RESUME_BACKGROUND ->
resumableGeneration(runId) != null || !hasGeneration(runId)
}
}, pollTransport, lease)
val request = JSONObject(requestRaw.toString(Charsets.UTF_8))
val runId = request.getString("run_id")
currentRunId = runId
phase(runId, "generation_request_observed", "request-v1.json")
if (request.getString("android_peer_id") != peer)
throw SyncGenerationException("request targets another Android peer")
val archiveAckRaw =
@ -313,6 +494,7 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
it.optString("android_peer_id") == peer
},
pollTransport,
lease,
)
reconcileArchiveAcknowledgements(
archiveAckRaw,
@ -320,6 +502,45 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
peer,
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
@ -327,6 +548,8 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
* INVARIANT: a completed inbound ledger makes the run ineligible;
* an acknowledged outbound generation remains resumable only to
* finish its missing inbound half. */
phase(runId, "generation_capture_started")
lease.throwIfYieldRequested()
val captured =
resumableGeneration(
request.getString("run_id"),
@ -336,9 +559,22 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
request.getString("desktop_peer_id"),
request.getString("run_id"),
)
lease.throwIfYieldRequested()
phase(runId, "generation_captured", generationId = captured.generationId)
val published = service.publish(captured, File(directory, "android-objects"))
visibility.confirm(
published.walkTopDown().filter(File::isFile).sortedBy { it.name == "manifest.json" }.toList()
)
lease.throwIfYieldRequested()
phase(
runId,
"generation_objects_published",
published.relativeTo(directory).path,
captured.generationId,
)
val generationReference = File(directory, "android-generation-v1.json")
publish(
File(directory, "android-generation-v1.json"),
generationReference,
JSONObject()
.put("format", "trainlog-sync-generation-reference")
.put("version", 1)
@ -348,21 +584,39 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
.put("relative_path", published.relativeTo(directory).path)
.toString(),
)
visibility.confirm(listOf(generationReference))
phase(
runId,
"generation_reference_published",
generationReference.name,
captured.generationId,
)
afterPublication?.invoke()
lease.throwIfYieldRequested()
// CONTRACT: durable objects from earlier conversations remain in
// the exchange directory. Only this run's exact generation ACK
// can advance its lineage; stale bytes are retained and ignored.
// the exchange directory. A cross-run retry preserves the
// immutable manifest's original run_id, so correlate its ACK by
// generation identity and manifest digest. acceptAcknowledgement
// then revalidates the complete durable ACK against SQLite.
// INVARIANT: unrelated or same-ID/different-bytes ACKs remain
// ignored; an exact replay is deliberately accepted.
val desktopAck =
awaitCorrelated(File(directory, "desktop-consumption-ack-v1.json"), deadline, {
it.optString("run_id") == captured.runId &&
it.optString("generation_id") == captured.generationId
}, pollTransport)
acknowledgementMatchesCapturedGeneration(it, captured)
}, pollTransport, lease)
service.acceptAcknowledgement(desktopAck)
phase(runId, "desktop_ack_observed", "desktop-consumption-ack-v1.json", captured.generationId)
val referenceRaw =
awaitCorrelated(File(directory, "desktop-generation-v1.json"), deadline, {
it.optString("run_id") == captured.runId
}, pollTransport)
}, pollTransport, lease)
val reference = JSONObject(referenceRaw.toString(Charsets.UTF_8))
phase(
runId,
"desktop_generation_observed",
"desktop-generation-v1.json",
reference.optString("generation_id"),
)
/* WHY: non-atomic transports can expose a newly downloaded
* reference locally before the corresponding immutable objects
* have reached this process. CONTRACT: correlation validates the
@ -373,10 +627,37 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
pollTransport?.invoke()
val acknowledgement =
service.consume(File(directory, reference.getString("relative_path")))
publish(File(directory, "android-consumption-ack-v1.json"), acknowledgement)
phase(
runId,
"desktop_generation_consumed",
generationId = reference.optString("generation_id"),
)
val acknowledgementPath = File(directory, "android-consumption-ack-v1.json")
publish(acknowledgementPath, acknowledgement)
visibility.confirm(listOf(acknowledgementPath))
phase(
runId,
"android_ack_published",
acknowledgementPath.name,
reference.optString("generation_id"),
)
afterAcknowledgement?.invoke()
phase(runId, "completed", result = "completed")
ForegroundGenerationResult.Completed(captured.runId)
} catch (_: SyncConversationYieldedException) {
phase(currentRunId, "superseded", result = "foreground_priority")
ForegroundGenerationResult.Superseded(currentRunId)
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
phase(currentRunId, "cancelled", result = "interrupted")
ForegroundGenerationResult.Cancelled(currentRunId)
} catch (error: Exception) {
Log.e(
"TrainlogSyncGeneration",
"run_id=${currentRunId ?: "pending"} phase=error " +
"type=${error.javaClass.name} message=${error.message}",
error,
)
if (error.message == "generation retention capacity exhausted") {
try {
val request = JSONObject(File(directory, "request-v1.json").readText())
@ -391,14 +672,26 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
.put("action", "Archive acknowledged generations, then retry explicitly.")
.toString(),
)
visibility.confirm(listOf(File(directory, "android-generation-error-v1.json")))
} catch (_: Exception) {
// Preserve the original local failure when error reporting
// cannot itself be durably published.
}
}
ForegroundGenerationResult.Error(error.message ?: "generation synchronization failed")
phase(
runCatching {
JSONObject(File(directory, "request-v1.json").readText()).optString("run_id")
}.getOrNull(),
"error",
result = error.javaClass.simpleName,
)
ForegroundGenerationResult.Failed(
error.message ?: "generation synchronization failed",
currentRunId,
)
} finally {
conversationLock.unlock()
lease.close()
claim?.close()
}
}
}

View file

@ -52,6 +52,8 @@ 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
@ -68,6 +70,17 @@ 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),
"heart-rate-corrections" to
Kind("trainlog-heart-rate-corrections", 1,
"heart-rate-corrections-v1.json", false),
"cardio-calibrations" to
Kind("trainlog-cardio-calibrations", 1, "cardio-calibrations-v1.json", false),
"cardio-guidance" to
Kind("trainlog-cardio-guidance", 1, "cardio-guidance-v1.json", false),
"session-timeline" to
Kind("trainlog-session-timeline", 1, "session-timeline-v1.json", false),
)
private val SUPPORTED =
(ANDROID_KINDS.values +
@ -284,7 +297,26 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
repository.inSyncGenerationTransaction { db ->
val retained =
db.rawQuery(
"SELECT COUNT(*) FROM sync_generations g WHERE consumer_peer_id=? AND status IN('captured','published','waiting_acknowledgement','acknowledged') AND NOT EXISTS(SELECT 1 FROM sync_generation_archives a WHERE a.generation_id=g.generation_id)",
/* WHY: early deployed generation builds could advance a
* producer row to acknowledged without retaining the ACK
* ledger row. Such terminal rows cannot be archived
* safely because Trainlog must not fabricate evidence,
* but they must not exhaust the active admission window
* forever. A failed conversation can also leave an
* unacknowledged branch whose sibling later receives a
* durable ACK; that superseded branch remains retained
* for audit but can never be the causal tip. INVARIANT:
* only a ledger-backed acknowledged sibling proves that
* an unacknowledged branch no longer consumes admission. */
"SELECT COUNT(*) FROM sync_generations g WHERE consumer_peer_id=? AND " +
"((status IN('captured','published','waiting_acknowledgement') AND NOT EXISTS(" +
"SELECT 1 FROM sync_generations sibling JOIN sync_acknowledgements sibling_ack " +
"ON sibling_ack.generation_id=sibling.generation_id WHERE " +
"sibling.parent_generation_id=g.parent_generation_id AND " +
"sibling.generation_id<>g.generation_id AND sibling.status='acknowledged')) OR " +
"(status='acknowledged' AND EXISTS(SELECT 1 FROM sync_acknowledgements k " +
"WHERE k.generation_id=g.generation_id))) AND NOT EXISTS(" +
"SELECT 1 FROM sync_generation_archives a WHERE a.generation_id=g.generation_id)",
arrayOf(consumerPeerId),
)
.use {
@ -330,10 +362,35 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
)
}
val generatedAt = OffsetDateTime.now().toString()
/* WHY: a fully acknowledged fork can remain in the immutable
* ledger after its payload has been durably archived.
* CONTRACT: archived evidence stays queryable but no longer
* participates in the active producer-lineage tip selection.
* INVARIANT: only unarchived rows backed by a full durable ACK can
* become the parent; two such active tips still reject capture. */
val parent =
repository.inSyncGenerationTransaction { db ->
db.rawQuery(
"SELECT g.generation_id FROM sync_generations g WHERE g.producer_peer_id=? AND g.consumer_peer_id=? AND g.status='acknowledged' AND NOT EXISTS(SELECT 1 FROM sync_generations c WHERE c.parent_generation_id=g.generation_id AND c.status='acknowledged') LIMIT 2",
"SELECT g.generation_id FROM sync_generations g WHERE " +
"g.producer_peer_id=? AND g.consumer_peer_id=? AND " +
"g.status='acknowledged' AND EXISTS(SELECT 1 FROM " +
"sync_acknowledgements k WHERE " +
"k.generation_id=g.generation_id AND " +
"k.result='consumed' AND " +
"k.durability='sqlite-commit-full') AND " +
"NOT EXISTS(SELECT 1 FROM " +
"sync_generation_archives a WHERE " +
"a.generation_id=g.generation_id) AND NOT EXISTS(" +
"SELECT 1 FROM sync_generations c WHERE " +
"c.parent_generation_id=g.generation_id AND " +
"c.status='acknowledged' AND EXISTS(SELECT 1 FROM " +
"sync_acknowledgements ck WHERE " +
"ck.generation_id=c.generation_id AND " +
"ck.result='consumed' AND " +
"ck.durability='sqlite-commit-full') AND " +
"NOT EXISTS(SELECT 1 FROM " +
"sync_generation_archives ca WHERE " +
"ca.generation_id=c.generation_id)) LIMIT 2",
arrayOf(producer, consumerPeerId),
)
.use {
@ -411,6 +468,76 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
arrayOf(operationId, generationId, if (existed) 0 else 1),
)
}
val heartRateCorrections =
JSONObject(checkNotNull(artifacts["heart-rate-corrections"]))
.getJSONArray("corrections")
for (index in 0 until heartRateCorrections.length()) {
val operationId =
heartRateCorrections.getJSONObject(index).getString("operation_id")
val existed =
db.rawQuery(
"SELECT 1 FROM sync_causal_publications " +
"WHERE operation_id=? LIMIT 1",
arrayOf(operationId),
)
.use { it.moveToFirst() }
db.execSQL(
"INSERT INTO sync_causal_publications VALUES(?,?,?)",
arrayOf(operationId, generationId, if (existed) 0 else 1),
)
}
val sleep = JSONObject(checkNotNull(artifacts["sleep-diary"]))
val sleepEntries = sleep.getJSONArray("entries")
for (index in 0 until sleepEntries.length()) {
val entry = sleepEntries.getJSONObject(index)
db.execSQL(
"INSERT INTO sleep_diary_generation_entries(generation_id,entry_id,revision_id) " +
"VALUES(?,?,?)",
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) {
@ -664,6 +791,7 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
is ProgramsImportResult.Applied,
is CausalDeleteResult.Applied,
is CausalDeleteResult.Unchanged -> null
is TrainlogRepository.SleepDiaryImportResult.Applied -> null
else -> result.toString()
}
@ -771,26 +899,38 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
}
}
val producerPeer = manifest.getJSONObject("producer").getString("peer_id")
val expectedParent =
/* WHY: the producer can archive one transient fork while this
* consumer retains both immutable consumed audit rows.
* CONTRACT: a new generation extends any current consumed tip;
* a null, old, or unrelated parent remains invalid.
* INVARIANT: fork history is never rewritten to select a tip. */
val tipPredicate =
"g.producer_peer_id=? AND g.result='consumed' AND " +
"NOT EXISTS(SELECT 1 FROM sync_consumed_generations c " +
"WHERE c.parent_generation_id=g.generation_id AND " +
"c.result='consumed')"
val hasCurrentTip =
db.rawQuery(
"SELECT g.generation_id FROM sync_consumed_generations g WHERE g.producer_peer_id=? AND g.result='consumed' AND NOT EXISTS(SELECT 1 FROM sync_consumed_generations c WHERE c.parent_generation_id=g.generation_id AND c.result='consumed') LIMIT 2",
"SELECT 1 FROM sync_consumed_generations g WHERE " +
tipPredicate + " LIMIT 1",
arrayOf(producerPeer),
)
.use {
if (!it.moveToFirst()) null
else {
val value = it.getString(0)
if (it.moveToNext())
throw SyncGenerationException(
"consumed generation lineage has multiple tips"
)
value
}
}
.use { it.moveToFirst() }
val suppliedParent =
if (manifest.isNull("parent_generation_id")) null
else manifest.getString("parent_generation_id")
if (suppliedParent != expectedParent)
val suppliedParentIsTip =
suppliedParent != null &&
db.rawQuery(
"SELECT 1 FROM sync_consumed_generations g WHERE " +
"g.generation_id=? AND " + tipPredicate + " LIMIT 1",
arrayOf(suppliedParent, producerPeer),
)
.use { it.moveToFirst() }
if (
(!hasCurrentTip && suppliedParent != null) ||
(hasCurrentTip && !suppliedParentIsTip)
)
throw SyncGenerationException("generation lineage is stale or unrelated")
val a = frozen.artifacts
fun apply(name: String, result: () -> Any) {
@ -840,6 +980,10 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
apply("execution-drafts") {
repository.applyExecutionDraftExportV1Json(checkNotNull(a["execution-drafts"]))
}
if ("sleep-diary" in a)
apply("sleep-diary") {
repository.applySleepDiaryV1Json(checkNotNull(a["sleep-diary"]))
}
if ("ai-proposals" in a)
apply("ai-proposals") {
repository.applyAiSessionDraftsJson(checkNotNull(a["ai-proposals"]))
@ -1033,6 +1177,49 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
ack.getString("generation_id"),
),
)
if (target == "acknowledged") {
db.execSQL(
"UPDATE sleep_diary_publication_state SET acknowledged_revision_id=(SELECT " +
"m.revision_id FROM sleep_diary_generation_entries m WHERE " +
"m.generation_id=? AND m.entry_id=sleep_diary_publication_state.entry_id)," +
"acknowledged_at=? WHERE EXISTS(SELECT 1 FROM sleep_diary_generation_entries m " +
"WHERE m.generation_id=? AND m.entry_id=sleep_diary_publication_state.entry_id)",
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

@ -13,6 +13,23 @@ import java.util.UUID
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
internal const val FULL_GENERATION_REQUEST_NAME =
"trainlog-sync-full-generation-request-v1.json"
internal const val LEGACY_SYNC_REQUEST_NAME = "trainlog-sync-request-v1.json"
internal data class SyncRequestIntent(
val requestId: String,
val requestedAt: String,
) {
fun payload(): String =
JSONObject()
.put("format", "trainlog-sync-request")
.put("version", 1)
.put("request_id", requestId)
.put("requested_at", requestedAt)
.toString()
}
sealed interface SyncRequestResult {
/** The full-generation coordinator completed both durable peer ACKs. */
data class Completed(
@ -33,13 +50,16 @@ sealed interface SyncRequestResult {
class SyncRequestOutbox private constructor(
private val publisher: DirectExchangePublisher,
private val visibility: MtpPublicationVisibility,
) {
constructor(context: Context) : this(
DirectExchangePublisher { directExchangeDirectory(context) },
AndroidMtpPublicationVisibility(context),
)
internal constructor(directoryProvider: () -> DirectExchangeDirectoryAccess?) : this(
DirectExchangePublisher(directoryProvider),
ImmediateMtpPublicationVisibility,
)
suspend fun requestSync(): SyncRequestResult = withContext(Dispatchers.IO) {
@ -49,6 +69,16 @@ class SyncRequestOutbox private constructor(
}
}
internal fun requestFullGeneration(): SyncRequestResult {
return when (val opened = publisher.snapshot()) {
is DirectExchangeSnapshotResult.Ready -> {
val intent = newIntent()
publishFullGeneration(opened.snapshot, intent)
}
is DirectExchangeSnapshotResult.Error -> SyncRequestResult.Error(opened.message)
}
}
internal fun requestSync(snapshot: DirectExchangeSnapshot): SyncRequestResult {
if (
Build.VERSION.SDK_INT <
@ -57,37 +87,42 @@ class SyncRequestOutbox private constructor(
return SyncRequestResult.Unsupported
}
val requestId =
"sr_" +
UUID.randomUUID()
.toString()
val intent = newIntent()
return publishLegacy(snapshot, intent)
}
val payload =
JSONObject()
.put(
"format",
"trainlog-sync-request",
)
.put(
"version",
1,
)
.put(
"request_id",
requestId,
)
.put(
"requested_at",
OffsetDateTime.now()
.toString(),
)
.toString()
internal fun newIntent(): SyncRequestIntent =
SyncRequestIntent(
requestId = "sr_${UUID.randomUUID()}",
requestedAt = OffsetDateTime.now().toString(),
)
val displayName =
"trainlog-sync-request-v1.json"
val error = snapshot.writeJson(displayName, payload)
internal fun publishFullGeneration(
snapshot: DirectExchangeSnapshot,
intent: SyncRequestIntent,
): SyncRequestResult = publish(snapshot, FULL_GENERATION_REQUEST_NAME, intent)
internal fun publishLegacy(
snapshot: DirectExchangeSnapshot,
intent: SyncRequestIntent,
): SyncRequestResult = publish(snapshot, LEGACY_SYNC_REQUEST_NAME, intent)
private fun publish(
snapshot: DirectExchangeSnapshot,
displayName: String,
intent: SyncRequestIntent,
): SyncRequestResult {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
return SyncRequestResult.Unsupported
}
val error = snapshot.writeJson(displayName, intent.payload())
return if (error == null) {
SyncRequestResult.Requested(requestId)
try {
visibility.confirm(listOf(java.io.File(canonicalExchangeDirectory(), displayName)))
SyncRequestResult.Requested(intent.requestId)
} catch (error: Exception) {
SyncRequestResult.Error(error.message ?: "Publication MTP impossible.")
}
} else {
SyncRequestResult.Error(error)
}

View file

@ -0,0 +1,37 @@
package com.labfytools.trainlog.model
enum class CardioCalibrationPhase(val wireValue: String) {
WARMUP("warmup"),
PROGRESSIVE("progressive"),
HIGH_EFFORT("high_effort"),
RECOVERY("recovery"),
COMPLETED("completed"),
ABORTED("aborted");
companion object {
fun fromWire(value: String): CardioCalibrationPhase =
entries.firstOrNull { it.wireValue == value }
?: error("Unknown cardio calibration phase: $value")
}
}
data class CardioCalibrationRecoveryPoint(
val targetOffsetSeconds: Int,
val observedAt: String,
val bpm: Int,
)
data class CardioCalibrationProfile(
val calibrationId: String,
val protocolVersion: Int,
val sessionId: String,
val entryId: String,
val phase: CardioCalibrationPhase,
val startedAt: String,
val phaseStartedAt: String,
val effortEndAt: String?,
val endedAt: String?,
val heartRateCaptureId: String?,
val observedPeakBpm: Int?,
val recovery: List<CardioCalibrationRecoveryPoint>,
)

View file

@ -0,0 +1,98 @@
package com.labfytools.trainlog.model
enum class CardioPhaseKind(val wireValue: String) {
WARMUP("warmup"),
WORK("work"),
RECOVERY("recovery"),
COOLDOWN("cooldown"),
}
sealed interface CardioTargetDefinition {
data class AbsoluteBpm(
val minimumBpm: Int,
val maximumBpm: Int,
) : CardioTargetDefinition
data class CalibrationPercent(
val calibrationId: String,
val observedPeakBpm: Int,
val minimumPercent: Int,
val maximumPercent: Int,
) : CardioTargetDefinition
}
/**
* Immutable target actually used by one phase.
*
* The calibration identity and percentages are provenance only. Runtime
* guidance consumes the resolved BPM bounds so later calibration changes
* cannot rewrite an already-started session.
*/
data class CardioTargetSnapshot(
val minimumBpm: Int,
val maximumBpm: Int,
val calibrationId: String? = null,
val calibrationObservedPeakBpm: Int? = null,
val minimumPercent: Int? = null,
val maximumPercent: Int? = null,
)
sealed interface CardioPhaseExitCondition {
data class FixedDuration(val seconds: Int) : CardioPhaseExitCondition
data object EnterTarget : CardioPhaseExitCondition
data class RecoverBelow(val bpm: Int) : CardioPhaseExitCondition
data class DurationOrRecoverBelow(
val maximumSeconds: Int,
val bpm: Int,
) : CardioPhaseExitCondition
}
data class CardioGuidedPhase(
val phaseId: String,
val kind: CardioPhaseKind,
val target: CardioTargetSnapshot?,
val exitCondition: CardioPhaseExitCondition,
)
enum class CardioGuidanceInstruction {
ACCELERATE,
MAINTAIN,
SLOW_DOWN,
SUSPENDED,
}
data class CardioGuidanceOutput(
val instruction: CardioGuidanceInstruction,
val bpm: Int?,
val target: CardioTargetSnapshot?,
val pendingInstruction: CardioGuidanceInstruction? = null,
)
data class CardioGuidancePhaseRecord(
val runId: String,
val sessionId: String,
val entryId: String,
val phase: CardioGuidedPhase,
val position: Int,
val startedAt: String,
val endedAt: String?,
val currentInstruction: CardioGuidanceInstruction,
)
data class CardioGuidanceEvent(
val sequence: Long,
val observedAt: String,
val instruction: CardioGuidanceInstruction,
val bpm: Int?,
val targetMinimumBpm: Int?,
val targetMaximumBpm: Int?,
)
data class CardioGuidanceRun(
val runId: String,
val sessionId: String,
val startedAt: String,
val endedAt: String?,
val phases: List<CardioGuidancePhaseRecord>,
)

View file

@ -0,0 +1,46 @@
/*
* Android HeartRateModels model.
*
* Android owns sensor acquisition. Desktop/Web only consume synchronized
* measured facts and never connect to the heart-rate sensor.
*/
package com.labfytools.trainlog.model
enum class HeartRateContextKind(val wireValue: String) {
SESSION("session"),
CARDIO("cardio"),
SLEEP("sleep");
companion object {
fun fromWire(value: String): HeartRateContextKind =
entries.firstOrNull { it.wireValue == value }
?: error("Unknown heart-rate context: $value")
}
}
data class HeartRateRrInterval(
val index: Int,
/** Raw Bluetooth Heart Rate Service unit: 1/1024 second. */
val value1024: Int,
)
data class HeartRateSample(
val sequence: Long,
val observedAt: String,
val bpm: Int,
val exerciseEntryId: String? = null,
val sensorContactDetected: Boolean? = null,
val energyExpended: Int? = null,
val rrIntervals: List<HeartRateRrInterval> = emptyList(),
)
data class HeartRateCapture(
val captureId: String,
val contextKind: HeartRateContextKind,
val contextId: String,
val startedAt: String,
val endedAt: String?,
val activeExerciseEntryId: String?,
val sensorName: String?,
val samples: List<HeartRateSample> = emptyList(),
)

View file

@ -9,17 +9,14 @@ enum class SessionType(
val wireValue: String,
) {
TRAINING("training"),
MAX_TEST("max_test");
MAX_TEST("max_test"),
CARDIO("cardio");
companion object {
fun fromWire(
value: String,
): SessionType =
if (value == "max_test") {
MAX_TEST
} else {
TRAINING
}
entries.firstOrNull { it.wireValue == value } ?: TRAINING
}
}
@ -79,6 +76,24 @@ 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

@ -0,0 +1,34 @@
package com.labfytools.trainlog.model
enum class SleepQuality(val wireValue: String) { TB("TB"), B("B"), MOY("Moy"), M("M"), TM("TM") }
enum class SleepEventType(val wireValue: String) {
BED_TIME("bed_time"), FINAL_GET_UP("final_get_up"), NIGHT_GET_UP("night_get_up"),
SLEEP("sleep"), NAP("nap"), LONG_AWAKE("long_awake"), HALF_SLEEP("half_sleep"),
DAYTIME_SLEEPINESS("daytime_sleepiness"),
}
enum class SleepPublicationStatus { DRAFT, READY, SYNCHRONIZED, MODIFIED }
data class SleepDiaryEvent(val eventId: String, val type: SleepEventType, val startAt: String, val endAt: String?)
data class SleepMedication(
val medicationId: String, val revisionId: String, val createdAt: String, val updatedAt: String,
val name: String, val defaultDoseValue: Double?, val defaultDoseUnit: String?,
val form: String, val note: String, val active: Boolean,
)
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,
)
data class SleepDiaryEntry(
val entryId: String, val nightStartDate: String, val nightEndDate: String,
val createdAt: String, val updatedAt: String, val revisionId: String,
val sleepQuality: SleepQuality?, val wakeQuality: SleepQuality?, val dayForm: SleepQuality?,
val treatmentAndNotes: String, val events: List<SleepDiaryEvent>, val intakes: List<MedicationIntake>,
val publicationStatus: SleepPublicationStatus,
)
data class SleepDiaryDraft(
val entryId: String? = null, val expectedRevision: String? = null,
val nightStartDate: String, val nightEndDate: String, val createdAt: String,
val updatedAt: String, val sleepQuality: SleepQuality?, val wakeQuality: SleepQuality?,
val dayForm: SleepQuality?, val treatmentAndNotes: String, val events: List<SleepDiaryEvent>,
val intakes: List<MedicationIntake> = emptyList(),
)

View file

@ -19,6 +19,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
@ -32,6 +33,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.shape.RoundedCornerShape
import com.labfytools.trainlog.R
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import kotlinx.coroutines.launch
@ -41,7 +43,7 @@ private val drawerIcons = mapOf(
AppSection.SESSIONS to R.drawable.ic_sessions,
AppSection.EXERCISES to R.drawable.ic_exercises,
AppSection.EQUIPMENT to R.drawable.ic_equipment,
AppSection.STATISTICS to R.drawable.ic_statistics,
AppSection.SLEEP to R.drawable.ic_statistics,
AppSection.SYNC to R.drawable.ic_sync,
AppSection.SETTINGS to R.drawable.ic_settings,
)
@ -59,21 +61,24 @@ fun AndroidAppShell(
fun sectionLabel(section: AppSection): String = strings.getString(when (section) {
AppSection.HOME -> R.string.nav_home; AppSection.SESSIONS -> R.string.nav_sessions
AppSection.EXERCISES -> R.string.nav_exercises; AppSection.EQUIPMENT -> R.string.nav_equipment
AppSection.STATISTICS -> R.string.nav_statistics; AppSection.SYNC -> R.string.nav_sync
AppSection.SETTINGS -> R.string.nav_settings
AppSection.SLEEP -> R.string.nav_sleep
AppSection.SYNC -> R.string.nav_sync; AppSection.SETTINGS -> R.string.nav_settings
})
fun title(): String = strings.getString(when (route) {
AppRoute.Home -> R.string.nav_home; AppRoute.Sessions -> R.string.nav_sessions
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
AppRoute.Equipment -> R.string.nav_equipment; is AppRoute.EquipmentDetail -> R.string.route_equipment_detail
is AppRoute.EquipmentCreate -> R.string.route_equipment_create; AppRoute.Statistics -> R.string.nav_statistics
is AppRoute.EquipmentCreate -> R.string.route_equipment_create
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()
@ -107,7 +112,16 @@ fun AndroidAppShell(
onOpenSection(section)
scope.launch { drawerState.close() }
},
modifier = Modifier.padding(horizontal = 12.dp),
modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp),
shape = RoundedCornerShape(10.dp),
colors = NavigationDrawerItemDefaults.colors(
selectedContainerColor = colors.surfaceAlt,
selectedIconColor = colors.accent,
selectedTextColor = colors.text,
unselectedContainerColor = colors.mantle,
unselectedIconColor = colors.muted,
unselectedTextColor = colors.muted,
),
)
}
}
@ -133,6 +147,7 @@ fun AndroidAppShell(
}
},
actions = {
HeartRateIndicator()
if (!root) {
IconButton(
onClick = { scope.launch { drawerState.open() } },

View file

@ -12,7 +12,7 @@ import androidx.lifecycle.ViewModel
enum class AppSection {
HOME, SESSIONS, EXERCISES,
EQUIPMENT, STATISTICS, SYNC, SETTINGS,
EQUIPMENT, SLEEP, SYNC, SETTINGS,
}
sealed interface AppRoute {
@ -24,6 +24,7 @@ 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 }
@ -34,11 +35,12 @@ sealed interface AppRoute {
data object Equipment : AppRoute { override val section = AppSection.EQUIPMENT }
data class EquipmentDetail(val equipmentId: String) : AppRoute { override val section = AppSection.EQUIPMENT }
data class EquipmentCreate(val caller: AppRoute) : AppRoute { override val section = caller.section }
data object Statistics : AppRoute { override val section = AppSection.STATISTICS }
data object BodyMeasurements : AppRoute { override val section = AppSection.STATISTICS }
data object LatestMaxima : AppRoute { override val section = AppSection.STATISTICS }
data object BodyMeasurements : AppRoute { override val section = AppSection.HOME }
data object SleepDiary : AppRoute { override val section = AppSection.SLEEP }
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) {
@ -46,7 +48,7 @@ fun AppSection.rootRoute(): AppRoute = when (this) {
AppSection.SESSIONS -> AppRoute.Sessions
AppSection.EXERCISES -> AppRoute.Exercises
AppSection.EQUIPMENT -> AppRoute.Equipment
AppSection.STATISTICS -> AppRoute.Statistics
AppSection.SLEEP -> AppRoute.SleepDiary
AppSection.SYNC -> AppRoute.Sync
AppSection.SETTINGS -> AppRoute.Settings
}
@ -171,5 +173,11 @@ class TrainlogAppState : ViewModel() {
val equipment = EquipmentScreenState()
val exercise = ExerciseScreenState()
val body = BodyScreenState()
/* WHY: the durable form intentionally does not serialize transient edit
* position, but a catalogue detour must still return to the same row.
* CONTRACT: this stable occurrence ID spans only that caller-aware detour;
* all actual form fields remain repository-owned and durable.
* INVARIANT: null means a new occurrence, never an inferred row. */
var pendingSessionEquipmentEntryId: String? = null
val navigationController = AppNavigationController(navigation, generator, equipment, exercise, body)
}

View file

@ -8,6 +8,13 @@ package com.labfytools.trainlog.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
@ -89,148 +96,74 @@ fun BodyScreen(
TrainlogFrame(
title = strings.getString(R.string.general)
) {
BodyMetricField(
label = strings.getString(R.string.weight),
unit = "kg",
value = bodyWeight,
onValueChange = {
bodyWeight = it
message = null
},
)
BodyMetricField(
label = strings.getString(R.string.neck),
unit = "cm",
value = neck,
onValueChange = {
neck = it
message = null
},
)
BodyMetricField(
label = strings.getString(R.string.shoulders),
unit = "cm",
value = shoulders,
onValueChange = {
shoulders = it
message = null
},
)
BodyMetricField(
label = strings.getString(R.string.chest),
unit = "cm",
value = chest,
onValueChange = {
chest = it
message = null
},
)
BodyMetricField(
label = strings.getString(R.string.waist),
unit = "cm",
value = waist,
onValueChange = {
waist = it
message = null
},
)
BodyMetricField(
label = strings.getString(R.string.hips),
unit = "cm",
value = hips,
onValueChange = {
hips = it
message = null
},
BodyMetricGrid(
listOf(
BodyMetricItem(strings.getString(R.string.weight), "kg", bodyWeight) {
bodyWeight = it
message = null
},
BodyMetricItem(strings.getString(R.string.neck), "cm", neck) {
neck = it
message = null
},
BodyMetricItem(strings.getString(R.string.shoulders), "cm", shoulders) {
shoulders = it
message = null
},
BodyMetricItem(strings.getString(R.string.chest), "cm", chest) {
chest = it
message = null
},
BodyMetricItem(strings.getString(R.string.waist), "cm", waist) {
waist = it
message = null
},
BodyMetricItem(strings.getString(R.string.hips), "cm", hips) {
hips = it
message = null
},
),
)
}
TrainlogFrame(
title = strings.getString(R.string.limbs)
) {
BodyMetricField(
label = strings.getString(R.string.left_arm),
unit = "cm",
value = leftArm,
onValueChange = {
leftArm = it
message = null
},
)
BodyMetricField(
label = strings.getString(R.string.right_arm),
unit = "cm",
value = rightArm,
onValueChange = {
rightArm = it
message = null
},
)
BodyMetricField(
label = strings.getString(R.string.left_forearm),
unit = "cm",
value = leftForearm,
onValueChange = {
leftForearm = it
message = null
},
)
BodyMetricField(
label = strings.getString(R.string.right_forearm),
unit = "cm",
value = rightForearm,
onValueChange = {
rightForearm = it
message = null
},
)
BodyMetricField(
label = strings.getString(R.string.left_thigh),
unit = "cm",
value = leftThigh,
onValueChange = {
leftThigh = it
message = null
},
)
BodyMetricField(
label = strings.getString(R.string.right_thigh),
unit = "cm",
value = rightThigh,
onValueChange = {
rightThigh = it
message = null
},
)
BodyMetricField(
label = strings.getString(R.string.left_calf),
unit = "cm",
value = leftCalf,
onValueChange = {
leftCalf = it
message = null
},
)
BodyMetricField(
label = strings.getString(R.string.right_calf),
unit = "cm",
value = rightCalf,
onValueChange = {
rightCalf = it
message = null
},
BodyMetricGrid(
listOf(
BodyMetricItem(strings.getString(R.string.left_arm), "cm", leftArm) {
leftArm = it
message = null
},
BodyMetricItem(strings.getString(R.string.right_arm), "cm", rightArm) {
rightArm = it
message = null
},
BodyMetricItem(strings.getString(R.string.left_forearm), "cm", leftForearm) {
leftForearm = it
message = null
},
BodyMetricItem(strings.getString(R.string.right_forearm), "cm", rightForearm) {
rightForearm = it
message = null
},
BodyMetricItem(strings.getString(R.string.left_thigh), "cm", leftThigh) {
leftThigh = it
message = null
},
BodyMetricItem(strings.getString(R.string.right_thigh), "cm", rightThigh) {
rightThigh = it
message = null
},
BodyMetricItem(strings.getString(R.string.left_calf), "cm", leftCalf) {
leftCalf = it
message = null
},
BodyMetricItem(strings.getString(R.string.right_calf), "cm", rightCalf) {
rightCalf = it
message = null
},
),
)
}
@ -426,12 +359,14 @@ private fun BodyMetricField(
unit: String,
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
TrainlogInputField(
label = "$label ($unit)",
label = label,
value = value,
onValueChange =
onValueChange,
modifier = modifier,
keyboardOptions =
KeyboardOptions(
keyboardType =
@ -439,9 +374,51 @@ private fun BodyMetricField(
imeAction =
ImeAction.Next,
),
suffix = unit,
compact = true,
)
}
internal data class BodyMetricItem(
val label: String,
val unit: String,
val value: String,
val onValueChange: (String) -> Unit,
)
/** Two columns retain scanability; very narrow accessibility layouts stack. */
internal fun bodyMetricColumnCount(availableWidthDp: Float, fontScale: Float): Int {
val twoCompactFieldsDp = 2 * 140f + 10f
return if (availableWidthDp >= twoCompactFieldsDp && fontScale < 1.5f) 2 else 1
}
@Composable
internal fun ColumnScope.BodyMetricGrid(items: List<BodyMetricItem>) {
BoxWithConstraints(Modifier.fillMaxWidth()) {
/* WHY: this 360 dp device has 312 dp after screen padding; the old
* 330 dp threshold therefore stacked fields that need only 140 dp.
* CONTRACT: two compact fields plus their 10 dp gutter are preferred
* unless width or accessibility font scaling makes them unreadable. */
val columns = bodyMetricColumnCount(maxWidth.value, LocalDensity.current.fontScale)
Column(verticalArrangement = Arrangement.spacedBy(0.dp)) {
items.chunked(columns).forEach { rowItems ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
rowItems.forEach { item ->
BodyMetricField(
item.label,
item.unit,
item.value,
item.onValueChange,
Modifier.weight(1f),
)
}
repeat(columns - rowItems.size) { androidx.compose.foundation.layout.Spacer(Modifier.weight(1f)) }
}
}
}
}
}
private sealed interface MetricParse {
data object Empty :
MetricParse

View file

@ -0,0 +1,172 @@
package com.labfytools.trainlog.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import com.labfytools.trainlog.R
import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.model.CardioCalibrationPhase
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
@Composable
fun CardioCalibrationScreen(
repository: TrainlogRepository,
onChanged: () -> Unit,
) {
val strings = localizedContext()
val colors = LocalTrainlogColors.current
var revision by remember { mutableIntStateOf(0) }
var message by remember { mutableStateOf<String?>(null) }
val active = remember(revision) { repository.activeCardioCalibration() }
val latest = remember(revision) {
repository.listCompletedCardioCalibrations(1).firstOrNull()
}
fun apply(result: TrainlogRepository.CardioCalibrationResult) {
message =
when (result) {
is TrainlogRepository.CardioCalibrationResult.Applied -> {
revision++
onChanged()
null
}
is TrainlogRepository.CardioCalibrationResult.Invalid -> result.message
TrainlogRepository.CardioCalibrationResult.Conflict ->
strings.getString(R.string.calibration_conflict)
is TrainlogRepository.CardioCalibrationResult.DatabaseError ->
result.message
}
}
fun phaseLabel(phase: CardioCalibrationPhase): String =
strings.getString(
when (phase) {
CardioCalibrationPhase.WARMUP -> R.string.calibration_phase_warmup
CardioCalibrationPhase.PROGRESSIVE -> R.string.calibration_phase_progressive
CardioCalibrationPhase.HIGH_EFFORT -> R.string.calibration_phase_high_effort
CardioCalibrationPhase.RECOVERY -> R.string.calibration_phase_recovery
CardioCalibrationPhase.COMPLETED -> R.string.calibration_phase_completed
CardioCalibrationPhase.ABORTED -> R.string.calibration_phase_aborted
},
)
TrainlogScreen(
strings.getString(R.string.route_cardio_calibration),
scrollKey = "cardio-calibration",
) {
TrainlogInfo(strings.getString(R.string.calibration_explanation), colors.muted)
if (active == null) {
TrainlogPrimaryAction(
strings.getString(R.string.calibration_start),
strings.getString(R.string.calibration_start_description),
) {
apply(repository.startCardioCalibration())
}
latest?.let { profile ->
TrainlogFrame(strings.getString(R.string.calibration_latest)) {
TrainlogInfo(
strings.getString(
R.string.calibration_peak_value,
profile.observedPeakBpm ?: 0,
),
colors.accent,
)
profile.recovery.forEach { point ->
TrainlogInfo(
strings.getString(
R.string.calibration_recovery_value,
point.targetOffsetSeconds / 60,
point.bpm,
),
colors.muted,
)
}
}
}
} else {
TrainlogFrame(strings.getString(R.string.calibration_current)) {
TrainlogInfo(
strings.getString(
R.string.calibration_phase_value,
phaseLabel(active.phase),
),
colors.accent,
)
active.observedPeakBpm?.let { bpm ->
TrainlogInfo(
strings.getString(R.string.calibration_peak_value, bpm),
colors.warning,
)
}
active.recovery.forEach { point ->
TrainlogInfo(
strings.getString(
R.string.calibration_recovery_value,
point.targetOffsetSeconds / 60,
point.bpm,
),
colors.muted,
)
}
}
when (active.phase) {
CardioCalibrationPhase.WARMUP -> {
TrainlogPrimaryAction(
strings.getString(R.string.calibration_to_progressive),
strings.getString(R.string.calibration_progressive_description),
) {
apply(repository.advanceCardioCalibrationPhase())
}
}
CardioCalibrationPhase.PROGRESSIVE -> {
TrainlogPrimaryAction(
strings.getString(R.string.calibration_to_high_effort),
strings.getString(R.string.calibration_high_effort_description),
) {
apply(repository.advanceCardioCalibrationPhase())
}
}
CardioCalibrationPhase.HIGH_EFFORT -> {
TrainlogPrimaryAction(
strings.getString(R.string.calibration_end_effort),
strings.getString(R.string.calibration_end_effort_description),
) {
apply(repository.markCardioCalibrationEffortEnd())
}
}
CardioCalibrationPhase.RECOVERY -> {
TrainlogPrimaryAction(
strings.getString(R.string.calibration_finish),
strings.getString(R.string.calibration_finish_description),
) {
apply(repository.finishCardioCalibration())
}
}
CardioCalibrationPhase.COMPLETED,
CardioCalibrationPhase.ABORTED -> Unit
}
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TrainlogButton(
strings.getString(R.string.calibration_abort),
{ apply(repository.abortCardioCalibration()) },
style = TrainlogButtonStyle.DESTRUCTIVE,
)
}
}
message?.let { TrainlogInfo(it, colors.error) }
}
}

View file

@ -0,0 +1,385 @@
package com.labfytools.trainlog.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.labfytools.trainlog.R
import com.labfytools.trainlog.data.CardioGuidanceLiveState
import com.labfytools.trainlog.data.CardioTargetResolver
import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.model.CardioGuidanceInstruction
import com.labfytools.trainlog.model.CardioGuidedPhase
import com.labfytools.trainlog.model.CardioPhaseExitCondition
import com.labfytools.trainlog.model.CardioPhaseKind
import com.labfytools.trainlog.model.CardioTargetDefinition
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import java.util.UUID
@Composable
internal fun CardioGuidancePanel(
repository: TrainlogRepository,
entryId: String,
) {
val strings = localizedContext()
val colors = LocalTrainlogColors.current
var revision by remember(entryId) { mutableStateOf(0) }
val active =
remember(entryId, revision) {
repository.activeCardioGuidancePhase()?.takeIf { it.entryId == entryId }
}
val calibration =
remember(revision) {
repository.listCompletedCardioCalibrations(1).firstOrNull()
}
var live by remember { mutableStateOf(CardioGuidanceLiveState.current()) }
DisposableEffect(entryId) {
val subscription = CardioGuidanceLiveState.subscribe { live = it }
onDispose { subscription.close() }
}
TrainlogFrame(
title = strings.getString(R.string.cardio_guidance_title),
active = active != null,
) {
if (active != null) {
val phase = active.phase
val current = live.takeIf { it.phaseId == phase.phaseId }
val instruction = current?.instruction ?: active.currentInstruction
val bpm = current?.bpm
TrainlogInfo(
strings.getString(
R.string.cardio_guidance_phase_value,
phaseKindLabel(strings, phase.kind),
),
colors.muted,
)
phase.target?.let { target ->
TrainlogInfo(
strings.getString(
R.string.cardio_guidance_target_value,
target.minimumBpm,
target.maximumBpm,
),
colors.accent,
)
if (target.calibrationId != null) {
TrainlogInfo(
strings.getString(
R.string.cardio_guidance_calibration_value,
target.calibrationObservedPeakBpm ?: 0,
target.minimumPercent ?: 0,
target.maximumPercent ?: 0,
),
colors.muted,
)
}
}
TrainlogInfo(
guidanceInstructionLabel(strings, instruction),
when (instruction) {
CardioGuidanceInstruction.MAINTAIN -> colors.success
CardioGuidanceInstruction.ACCELERATE -> colors.accent
CardioGuidanceInstruction.SLOW_DOWN,
CardioGuidanceInstruction.SUSPENDED -> colors.warning
},
)
TrainlogInfo(
if (bpm == null) {
strings.getString(R.string.cardio_guidance_bpm_missing)
} else {
strings.getString(R.string.cardio_guidance_bpm_value, bpm)
},
if (bpm == null) colors.warning else colors.text,
)
TrainlogButton(
label = strings.getString(R.string.cardio_guidance_finish_phase),
onClick = {
if (
repository.finishCardioGuidancePhase(phase.phaseId) is
TrainlogRepository.CardioGuidanceMutationResult.Applied
) {
CardioGuidanceLiveState.clear()
revision++
}
},
modifier = Modifier.fillMaxWidth(),
style = TrainlogButtonStyle.SECONDARY,
)
return@TrainlogFrame
}
var kind by remember(entryId) { mutableStateOf(CardioPhaseKind.WARMUP) }
var targetMode by
remember(entryId, calibration?.calibrationId) {
mutableStateOf(if (calibration == null) "bpm" else "calibration")
}
var minimumBpm by remember(entryId) { mutableStateOf("") }
var maximumBpm by remember(entryId) { mutableStateOf("") }
var minimumPercent by remember(entryId) { mutableStateOf("") }
var maximumPercent by remember(entryId) { mutableStateOf("") }
var exitMode by remember(entryId) { mutableStateOf("duration") }
var durationSeconds by remember(entryId) { mutableStateOf("") }
var recoveryBpm by remember(entryId) { mutableStateOf("") }
var message by remember(entryId) { mutableStateOf<String?>(null) }
TrainlogChoiceChips(
choices =
listOf(
"warmup" to strings.getString(R.string.cardio_phase_warmup),
"work" to strings.getString(R.string.cardio_phase_work),
"recovery" to strings.getString(R.string.cardio_phase_recovery),
"cooldown" to strings.getString(R.string.cardio_phase_cooldown),
),
selectedId = kind.wireValue,
onSelected = { selected ->
kind = CardioPhaseKind.entries.first { it.wireValue == selected }
},
)
TrainlogChoiceChips(
choices =
buildList {
add("bpm" to strings.getString(R.string.cardio_target_absolute))
if (calibration?.observedPeakBpm != null) {
add(
"calibration" to
strings.getString(R.string.cardio_target_calibration),
)
}
},
selectedId = targetMode,
onSelected = { targetMode = it },
)
if (targetMode == "calibration") {
calibration?.observedPeakBpm?.let { peak ->
TrainlogInfo(
strings.getString(R.string.cardio_guidance_latest_calibration, peak),
colors.muted,
)
}
NumberPair(
leftLabel = strings.getString(R.string.cardio_target_min_percent),
left = minimumPercent,
onLeft = { minimumPercent = digits(it, 3) },
rightLabel = strings.getString(R.string.cardio_target_max_percent),
right = maximumPercent,
onRight = { maximumPercent = digits(it, 3) },
suffix = "%",
)
} else {
NumberPair(
leftLabel = strings.getString(R.string.cardio_target_min_bpm),
left = minimumBpm,
onLeft = { minimumBpm = digits(it, 5) },
rightLabel = strings.getString(R.string.cardio_target_max_bpm),
right = maximumBpm,
onRight = { maximumBpm = digits(it, 5) },
suffix = "BPM",
)
}
TrainlogChoiceChips(
choices =
listOf(
"duration" to strings.getString(R.string.cardio_exit_duration),
"target" to strings.getString(R.string.cardio_exit_target),
"recover" to strings.getString(R.string.cardio_exit_recover),
"bounded" to strings.getString(R.string.cardio_exit_bounded),
),
selectedId = exitMode,
onSelected = { exitMode = it },
)
if (exitMode == "duration" || exitMode == "bounded") {
TrainlogInputField(
label = strings.getString(R.string.cardio_exit_seconds),
value = durationSeconds,
onValueChange = { durationSeconds = digits(it, 5) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
suffix = "s",
compact = true,
)
}
if (exitMode == "recover" || exitMode == "bounded") {
TrainlogInputField(
label = strings.getString(R.string.cardio_exit_recovery_bpm),
value = recoveryBpm,
onValueChange = { recoveryBpm = digits(it, 5) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
suffix = "BPM",
compact = true,
)
}
val target =
if (targetMode == "calibration") {
val peak = calibration?.observedPeakBpm
val min = minimumPercent.toIntOrNull()
val max = maximumPercent.toIntOrNull()
if (calibration == null || peak == null || min == null || max == null) {
null
} else {
CardioTargetResolver.resolve(
CardioTargetDefinition.CalibrationPercent(
calibration.calibrationId,
peak,
min,
max,
),
)
}
} else {
val min = minimumBpm.toIntOrNull()
val max = maximumBpm.toIntOrNull()
if (min == null || max == null) null
else CardioTargetResolver.resolve(
CardioTargetDefinition.AbsoluteBpm(min, max),
)
}
target?.let {
TrainlogInfo(
strings.getString(
R.string.cardio_guidance_resolved_target,
it.minimumBpm,
it.maximumBpm,
),
colors.accent,
)
}
val exit =
when (exitMode) {
"duration" ->
durationSeconds.toIntOrNull()
?.takeIf { it in 1..86400 }
?.let(CardioPhaseExitCondition::FixedDuration)
"target" -> CardioPhaseExitCondition.EnterTarget
"recover" ->
recoveryBpm.toIntOrNull()
?.takeIf { it in 1..65535 }
?.let(CardioPhaseExitCondition::RecoverBelow)
"bounded" -> {
val seconds = durationSeconds.toIntOrNull()
val bpm = recoveryBpm.toIntOrNull()
if (seconds != null && bpm != null &&
seconds in 1..86400 && bpm in 1..65535
) {
CardioPhaseExitCondition.DurationOrRecoverBelow(seconds, bpm)
} else null
}
else -> null
}
message?.let { TrainlogInfo(it, colors.warning) }
TrainlogButton(
label = strings.getString(R.string.cardio_guidance_start_phase),
onClick = {
val actualTarget = target
val actualExit = exit
if (actualTarget == null || actualExit == null) {
message =
strings.getString(R.string.cardio_guidance_invalid_configuration)
return@TrainlogButton
}
val result =
repository.startCardioGuidancePhase(
entryId,
CardioGuidedPhase(
phaseId = "cgp_" + UUID.randomUUID(),
kind = kind,
target = actualTarget,
exitCondition = actualExit,
),
)
if (result is TrainlogRepository.CardioGuidanceMutationResult.Applied) {
message = null
revision++
} else {
message = strings.getString(R.string.cardio_guidance_start_failed)
}
},
modifier = Modifier.fillMaxWidth(),
enabled = target != null && exit != null,
style = TrainlogButtonStyle.SUCCESS,
)
}
}
@Composable
private fun NumberPair(
leftLabel: String,
left: String,
onLeft: (String) -> Unit,
rightLabel: String,
right: String,
onRight: (String) -> Unit,
suffix: String,
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TrainlogInputField(
label = leftLabel,
value = left,
onValueChange = onLeft,
modifier = Modifier.weight(1f),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
suffix = suffix,
compact = true,
)
TrainlogInputField(
label = rightLabel,
value = right,
onValueChange = onRight,
modifier = Modifier.weight(1f),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
suffix = suffix,
compact = true,
)
}
}
private fun phaseKindLabel(
strings: android.content.Context,
kind: CardioPhaseKind,
): String =
strings.getString(
when (kind) {
CardioPhaseKind.WARMUP -> R.string.cardio_phase_warmup
CardioPhaseKind.WORK -> R.string.cardio_phase_work
CardioPhaseKind.RECOVERY -> R.string.cardio_phase_recovery
CardioPhaseKind.COOLDOWN -> R.string.cardio_phase_cooldown
},
)
private fun guidanceInstructionLabel(
strings: android.content.Context,
instruction: CardioGuidanceInstruction,
): String =
strings.getString(
when (instruction) {
CardioGuidanceInstruction.ACCELERATE -> R.string.cardio_guidance_accelerate
CardioGuidanceInstruction.MAINTAIN -> R.string.cardio_guidance_maintain
CardioGuidanceInstruction.SLOW_DOWN -> R.string.cardio_guidance_slow_down
CardioGuidanceInstruction.SUSPENDED -> R.string.cardio_guidance_suspended
},
)
private fun digits(value: String, maximum: Int): String =
value.filter(Char::isDigit).take(maximum)

View file

@ -8,9 +8,12 @@ package com.labfytools.trainlog.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
@ -852,7 +855,11 @@ private fun TrainlogChoiceGroup(
),
)
content()
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) { content() }
}
}
@ -869,15 +876,15 @@ private fun TrainlogChoice(
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 2.dp)
.widthIn(min = 132.dp)
.heightIn(min = 48.dp)
.background(
if (selected) {
colors.surfaceAlt
} else {
colors.surface
}
},
androidx.compose.material3.MaterialTheme.shapes.medium,
)
.semantics { this.selected = selected }
.clickable(

View file

@ -0,0 +1,203 @@
package com.labfytools.trainlog.ui
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.labfytools.trainlog.R
import com.labfytools.trainlog.data.HeartRateBleDiagnostics
import com.labfytools.trainlog.data.HeartRateDiagnosticPhase
import com.labfytools.trainlog.data.HeartRateDiagnosticSnapshot
import com.labfytools.trainlog.data.HeartRateSensorPreferences
import com.labfytools.trainlog.data.HeartRateSensorService
import com.labfytools.trainlog.data.hasHeartRateBleRuntimePermissions
import com.labfytools.trainlog.data.heartRateBleRuntimePermissions
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
@Composable
fun HeartRateDiagnosticsScreen(onSelected: () -> Unit) {
val context = LocalContext.current
val strings = localizedContext()
val colors = LocalTrainlogColors.current
var snapshot by remember { mutableStateOf(HeartRateDiagnosticSnapshot()) }
var pendingExpandedScan by remember { mutableStateOf(false) }
var permissionDenied by remember { mutableStateOf(false) }
val diagnostics =
remember(context) {
HeartRateBleDiagnostics(context) { next -> snapshot = next }
}
DisposableEffect(diagnostics) {
onDispose { diagnostics.close() }
}
val permissionLauncher =
rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions(),
) { results ->
if (heartRateBleRuntimePermissions().all { results[it] == true }) {
permissionDenied = false
diagnostics.startScan(pendingExpandedScan)
} else {
permissionDenied = true
}
}
fun scan(expanded: Boolean) {
pendingExpandedScan = expanded
if (hasHeartRateBleRuntimePermissions(context)) {
permissionDenied = false
diagnostics.startScan(expanded)
} else {
permissionLauncher.launch(heartRateBleRuntimePermissions())
}
}
fun phaseLabel(): String =
strings.getString(
when (snapshot.phase) {
HeartRateDiagnosticPhase.IDLE -> R.string.hr_diag_idle
HeartRateDiagnosticPhase.SCANNING -> R.string.hr_diag_scanning
HeartRateDiagnosticPhase.CONNECTING -> R.string.hr_diag_connecting
HeartRateDiagnosticPhase.DISCOVERING -> R.string.hr_diag_discovering
HeartRateDiagnosticPhase.SUBSCRIBING -> R.string.hr_diag_subscribing
HeartRateDiagnosticPhase.RECEIVING -> R.string.hr_diag_receiving
HeartRateDiagnosticPhase.DISCONNECTED -> R.string.hr_diag_disconnected
HeartRateDiagnosticPhase.ERROR -> R.string.hr_diag_error
},
)
TrainlogScreen(strings.getString(R.string.route_heart_rate_diagnostics)) {
TrainlogFrame(strings.getString(R.string.hr_diag_status)) {
TrainlogInfo(
phaseLabel(),
when (snapshot.phase) {
HeartRateDiagnosticPhase.RECEIVING -> colors.success
HeartRateDiagnosticPhase.ERROR -> colors.error
HeartRateDiagnosticPhase.CONNECTING,
HeartRateDiagnosticPhase.DISCOVERING,
HeartRateDiagnosticPhase.SUBSCRIBING,
HeartRateDiagnosticPhase.SCANNING -> colors.warning
else -> colors.muted
},
)
if (permissionDenied) {
TrainlogInfo(strings.getString(R.string.hr_diag_permission_denied), colors.error)
}
snapshot.diagnostic?.let {
TrainlogInfo(strings.getString(R.string.hr_diag_technical, it), colors.muted)
}
TrainlogInfo(
strings.getString(
R.string.hr_diag_services,
if (snapshot.serviceFound) strings.getString(R.string.yes) else strings.getString(R.string.no),
if (snapshot.measurementFound) strings.getString(R.string.yes) else strings.getString(R.string.no),
if (snapshot.batteryServiceFound) strings.getString(R.string.yes) else strings.getString(R.string.no),
),
)
snapshot.latestMeasurement?.let { measurement ->
TrainlogInfo(
strings.getString(R.string.hr_diag_bpm, measurement.bpm),
colors.success,
)
TrainlogInfo(
strings.getString(
R.string.hr_diag_measurement_meta,
measurement.rrIntervals1024.size,
measurement.sensorContactDetected?.let { if (it) strings.getString(R.string.yes) else strings.getString(R.string.no) } ?: "—",
measurement.energyExpended?.toString() ?: "—",
),
colors.muted,
)
}
TrainlogInfo(
strings.getString(R.string.hr_diag_notifications, snapshot.notificationCount),
colors.muted,
)
if (
snapshot.phase == HeartRateDiagnosticPhase.RECEIVING &&
snapshot.selectedAddress != null
) {
TrainlogButton(
strings.getString(R.string.hr_diag_use_sensor),
{
val address = checkNotNull(snapshot.selectedAddress)
val name =
snapshot.devices
.firstOrNull { it.address == address }
?.name
diagnostics.close()
HeartRateSensorPreferences(context).select(address, name)
HeartRateSensorService.start(context)
onSelected()
},
Modifier.fillMaxWidth(),
style = TrainlogButtonStyle.SUCCESS,
)
}
}
TrainlogFrame(strings.getString(R.string.hr_diag_scan_section)) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TrainlogButton(
strings.getString(R.string.hr_diag_scan),
{ scan(false) },
Modifier.weight(1f),
enabled = snapshot.phase != HeartRateDiagnosticPhase.SCANNING,
)
TrainlogButton(
strings.getString(R.string.hr_diag_scan_all),
{ scan(true) },
Modifier.weight(1f),
enabled = snapshot.phase != HeartRateDiagnosticPhase.SCANNING,
)
}
TrainlogInfo(strings.getString(R.string.hr_diag_scan_description), colors.muted)
}
TrainlogFrame(
strings.getString(R.string.hr_diag_devices),
active = snapshot.devices.isNotEmpty(),
) {
if (snapshot.devices.isEmpty()) {
TrainlogInfo(strings.getString(R.string.hr_diag_no_devices), colors.muted)
}
snapshot.devices.forEach { device ->
val title =
device.name?.takeIf { it.isNotBlank() }
?: strings.getString(R.string.hr_diag_unnamed_device)
val advertised =
strings.getString(
if (device.advertisesHeartRate) {
R.string.hr_diag_hrs_advertised
} else {
R.string.hr_diag_hrs_not_advertised
},
)
TrainlogAction(
title,
strings.getString(
R.string.hr_diag_device_description,
device.address,
device.rssi,
advertised,
),
{ diagnostics.connect(device.address) },
)
}
}
}
}

View file

@ -0,0 +1,98 @@
package com.labfytools.trainlog.ui
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.labfytools.trainlog.R
import com.labfytools.trainlog.data.HeartRateLivePhase
import com.labfytools.trainlog.data.HeartRateLiveState
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
@Composable
internal fun HeartRateIndicator() {
var live by remember { mutableStateOf(HeartRateLiveState.current()) }
DisposableEffect(Unit) {
val subscription = HeartRateLiveState.subscribe { live = it }
onDispose { subscription.close() }
}
val colors = LocalTrainlogColors.current
val strings = localizedContext()
val bpm =
live.bpm?.takeIf {
live.phase == HeartRateLivePhase.CONNECTED
}
val color =
when (live.phase) {
HeartRateLivePhase.CONNECTED -> colors.success
HeartRateLivePhase.CONNECTING,
HeartRateLivePhase.STALE -> colors.warning
HeartRateLivePhase.DISCONNECTED -> colors.muted
}
val beatPeriodMs =
bpm?.takeIf { it > 0 }
?.let { (60_000 / it).coerceIn(300, 2_000) }
?: 1_000
val transition = rememberInfiniteTransition(label = "heart-rate-pulse")
val scale by
transition.animateFloat(
initialValue = 1f,
targetValue = if (bpm != null) 1.14f else 1f,
animationSpec =
infiniteRepeatable(
animation = tween(durationMillis = beatPeriodMs / 2),
repeatMode = RepeatMode.Reverse,
),
label = "heart-rate-scale",
)
val description =
when (live.phase) {
HeartRateLivePhase.CONNECTED ->
strings.getString(R.string.heart_rate_a11y_connected, bpm ?: 0)
HeartRateLivePhase.CONNECTING ->
strings.getString(R.string.heart_rate_a11y_connecting)
HeartRateLivePhase.STALE ->
strings.getString(R.string.heart_rate_a11y_reconnecting)
HeartRateLivePhase.DISCONNECTED ->
strings.getString(R.string.heart_rate_a11y_disconnected)
}
Row(
modifier =
Modifier
.padding(horizontal = 4.dp)
.semantics { contentDescription = description },
horizontalArrangement = Arrangement.spacedBy(5.dp),
) {
androidx.compose.material3.Text(
"♥",
color = color,
fontSize = 20.sp,
modifier = Modifier.scale(scale),
)
androidx.compose.material3.Text(
bpm?.let { "$it BPM" } ?: "-- BPM",
color = color,
fontSize = 14.sp,
)
}
}

View file

@ -12,6 +12,7 @@ import com.labfytools.trainlog.model.SessionType
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import com.labfytools.trainlog.R
import java.time.OffsetDateTime
import java.time.LocalDate
import java.time.format.DateTimeFormatter
@Composable
@ -133,7 +134,8 @@ internal fun formatStartedAt(
internal fun formatDate(value: String): String {
val locale = presentationLocale()
val pattern = uiString(R.string.date_pattern)
return runCatching {
OffsetDateTime.parse(value).format(DateTimeFormatter.ofPattern(pattern, locale))
}.getOrElse { value.take(10) }
val formatter = DateTimeFormatter.ofPattern(pattern, locale)
return runCatching { LocalDate.parse(value.take(10)).format(formatter) }
.recoverCatching { OffsetDateTime.parse(value).format(formatter) }
.getOrElse { value.take(10) }
}

View file

@ -20,7 +20,6 @@ import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
@ -52,8 +51,6 @@ import com.labfytools.trainlog.data.BodyZoneHomeOverview
import com.labfytools.trainlog.data.BodyZoneHomeState
import com.labfytools.trainlog.data.BodyZoneHomeStatus
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.LatestExerciseMax
import com.labfytools.trainlog.model.SessionSummary
import com.labfytools.trainlog.model.SessionType
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import com.labfytools.trainlog.ui.theme.TrainlogColors
@ -64,19 +61,14 @@ import com.labfytools.trainlog.R
fun HomeScreen(
activeDraft: ActiveSessionDraft?,
draftError: String?,
latestSession: SessionSummary?,
latestMaximum: LatestExerciseMax?,
bodyZoneOverview: BodyZoneHomeOverview,
onSession: () -> Unit,
onOpenExercises: (String) -> Unit,
onOpenStatistics: () -> Unit,
onBody: () -> Unit,
onOpenLatestSession: (String) -> Unit,
onSync: () -> Unit,
) {
val colors = LocalTrainlogColors.current
val strings = localizedContext()
val locale = presentationLocale()
TrainlogScreen(strings.getString(R.string.nav_home), scrollKey = "home") {
draftError?.let { TrainlogInfo(it, colors.error) }
TrainlogFrame(strings.getString(R.string.home_session)) {
@ -87,17 +79,7 @@ fun HomeScreen(
TrainlogPrimaryAction(strings.getString(R.string.new_manual_session), strings.getString(R.string.new_manual_description), onSession)
}
}
BodyZoneHomeSection(bodyZoneOverview, onOpenExercises, onOpenStatistics)
latestSession?.let { session ->
TrainlogFrame(strings.getString(R.string.latest_session)) {
TrainlogAction(formatStartedAt(session.startedAt), sessionTypeLabel(session.sessionType) + " · " +
strings.resources.getQuantityString(R.plurals.exercise_count, session.exerciseCount, session.exerciseCount), { onOpenLatestSession(session.sessionId) })
}
}
latestMaximum?.let { maximum ->
val weight = "%.2f".format(locale, maximum.maxWeightKg).trimEnd('0').trimEnd(',', '.')
TrainlogInfo(strings.getString(R.string.latest_max_value, maximum.exerciseName, weight, formatDate(maximum.startedAt)), colors.warning)
}
BodyZoneHomeSection(bodyZoneOverview, onOpenExercises)
TrainlogFrame(strings.getString(R.string.quick_access), active = false) {
Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min).testTag("home-quick-actions-row"),
horizontalArrangement = Arrangement.spacedBy(10.dp)) {
@ -119,7 +101,6 @@ fun HomeScreen(
internal fun BodyZoneHomeSection(
overview: BodyZoneHomeOverview,
onOpenExercises: (String) -> Unit,
onOpenStatistics: () -> Unit,
) {
val colors = LocalTrainlogColors.current
val strings = localizedContext()
@ -159,7 +140,9 @@ internal fun BodyZoneHomeSection(
}
selected?.let { zone ->
Column(
Modifier.fillMaxWidth().padding(top = 8.dp).background(colors.surface, RoundedCornerShape(8.dp)).padding(12.dp),
Modifier.fillMaxWidth().padding(top = 8.dp)
.background(colors.surface, androidx.compose.material3.MaterialTheme.shapes.medium)
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
BasicText(localizedBodyZoneName(strings, zone.zoneId, zone.displayName), style = TrainlogTypography.section.copy(color = colors.text))
@ -179,7 +162,6 @@ internal fun BodyZoneHomeSection(
)
if (zone.availableExerciseCount == 0) TrainlogInfo(BodyZoneHomeState.UNSUPPORTED.label, colors.muted)
TrainlogAction(strings.getString(R.string.view_exercises), "", { onOpenExercises(zone.zoneId) })
TrainlogAction(strings.getString(R.string.view_statistics), "", onOpenStatistics)
}
}
}

View file

@ -8,6 +8,17 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import com.labfytools.trainlog.R
import com.labfytools.trainlog.data.StartProgramSessionResult
import com.labfytools.trainlog.data.TrainlogRepository
@ -18,13 +29,34 @@ import com.labfytools.trainlog.model.ProgramSessionExecutionState
fun ProgramsScreen(repository: TrainlogRepository, onOpen: (String) -> Unit) {
val strings = localizedContext()
val programs = repository.listSyncedPrograms()
val colors = LocalTrainlogColors.current
TrainlogScreen(strings.getString(R.string.route_programs)) {
if (programs.isEmpty()) TrainlogInfo(strings.getString(R.string.programs_none))
programs.forEach { program ->
TrainlogFrame(program.title, active = program.state == "active") {
val detail = remember(program.programId) { repository.getSyncedProgram(program.programId) }
val completed = detail?.sessions?.count {
it.executionState == ProgramSessionExecutionState.COMPLETED
} ?: 0
Column(
Modifier.fillMaxWidth().padding(bottom = 10.dp)
.background(colors.surface, MaterialTheme.shapes.medium).padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
program.title,
modifier = Modifier.weight(1f).padding(end = 8.dp),
color = colors.text,
fontWeight = FontWeight.Bold,
)
ProgramStateBadge(
if (program.state == "active") strings.getString(R.string.program_active)
else strings.getString(R.string.program_archived),
program.state == "active",
)
}
TrainlogInfo(
listOfNotNull(
strings.getString(if (program.state == "active") R.string.program_active else R.string.program_archived),
program.startDate?.let { strings.getString(R.string.program_start_date, formatDate(it)) },
program.endDate?.let { strings.getString(R.string.program_end_date, formatDate(it)) },
strings.resources.getQuantityString(
@ -34,6 +66,7 @@ fun ProgramsScreen(repository: TrainlogRepository, onOpen: (String) -> Unit) {
),
).joinToString(" · "),
)
TrainlogInfo("$completed / ${program.sessionCount} · ${strings.getString(R.string.program_session_completed)}", if (completed > 0) colors.success else colors.muted)
TrainlogButton(
strings.getString(R.string.program_open),
{ onOpen(program.programId) },
@ -84,22 +117,41 @@ fun ProgramDetailScreen(
program.startDate?.let { TrainlogInfo(strings.getString(R.string.program_start_date, formatDate(it))) }
program.endDate?.let { TrainlogInfo(strings.getString(R.string.program_end_date, formatDate(it))) }
program.note?.let { TrainlogInfo(it) }
val completed = program.sessions.count { it.executionState == ProgramSessionExecutionState.COMPLETED }
TrainlogInfo("$completed / ${program.sessions.size} · ${strings.getString(R.string.program_session_completed)}", if (completed > 0) LocalTrainlogColors.current.success else LocalTrainlogColors.current.muted)
}
message?.let { TrainlogInfo(it) }
program.sessions.forEachIndexed { index, session ->
var expanded by remember(session.programSessionId) { mutableStateOf(false) }
val completed = session.executionState == ProgramSessionExecutionState.COMPLETED
TrainlogFrame("${index + 1}. ${session.title}", active = completed) {
Column(
Modifier.fillMaxWidth().padding(bottom = 10.dp)
.background(
if (completed) LocalTrainlogColors.current.success.copy(alpha = .12f)
else LocalTrainlogColors.current.surface,
MaterialTheme.shapes.medium,
).padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
val stateLabel = when (session.executionState) {
ProgramSessionExecutionState.TODO -> R.string.program_session_todo
ProgramSessionExecutionState.IN_PROGRESS -> R.string.program_session_in_progress
ProgramSessionExecutionState.COMPLETED -> R.string.program_session_completed
}
TrainlogInfo(strings.getString(stateLabel))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
"${index + 1}. ${session.title}",
modifier = Modifier.weight(1f).padding(end = 8.dp),
color = LocalTrainlogColors.current.text,
fontWeight = FontWeight.Bold,
)
ProgramStateBadge(strings.getString(stateLabel), completed)
}
TrainlogInfo(
listOfNotNull(
strings.getString(if (session.sessionType == "max_test") R.string.session_max_test else R.string.session_training),
session.plannedFor?.let { strings.getString(R.string.planned_for, formatDate(it)) },
strings.resources.getQuantityString(R.plurals.exercise_count, session.occurrences.size, session.occurrences.size),
).joinToString(" · "),
)
session.note?.let { TrainlogInfo(it) }
@ -141,3 +193,18 @@ fun ProgramDetailScreen(
}
}
}
/** Presentation-only state badge; execution truth remains repository-owned. */
@Composable
private fun ProgramStateBadge(label: String, positive: Boolean) {
val colors = LocalTrainlogColors.current
Text(
label,
modifier = Modifier.background(
(if (positive) colors.success else colors.surfaceAlt).copy(alpha = .22f),
MaterialTheme.shapes.small,
).padding(horizontal = 8.dp, vertical = 4.dp),
color = if (positive) colors.success else colors.muted,
style = MaterialTheme.typography.labelMedium,
)
}

View file

@ -9,17 +9,23 @@ 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
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.LocalContext
@ -34,8 +40,12 @@ 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
import com.labfytools.trainlog.ui.theme.TrainlogTypography
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import com.labfytools.trainlog.R
@ -48,6 +58,7 @@ fun SessionsHub(
onDrafts: () -> Unit,
onHistory: () -> Unit,
onPrograms: () -> Unit = {},
onCardioCalibration: () -> Unit = {},
) {
val colors = LocalTrainlogColors.current
val strings = localizedContext()
@ -67,6 +78,13 @@ 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"))
@ -93,21 +111,14 @@ fun AiSessionDraftsScreen(
message?.let { TrainlogInfo(it) }
if (drafts.isEmpty() && preparations.isEmpty()) TrainlogInfo(strings.getString(R.string.draft_none))
preparations.forEach { preparation ->
TrainlogFrame(preparation.title, active = true) {
TrainlogInfo(listOfNotNull(
strings.getString(R.string.manual_preparation),
preparation.plannedFor?.let { strings.getString(R.string.planned_for, formatDate(it)) },
strings.resources.getQuantityString(R.plurals.exercise_count, preparation.entries.size, preparation.entries.size),
).joinToString(" · "))
preparation.notes?.let { TrainlogInfo(it) }
preparation.entries.forEach { entry ->
val plan = checkNotNull(entry.plan)
val metric = plan.reps?.let { strings.resources.getQuantityString(R.plurals.repetition_count, it, it) }
?: plan.durationSeconds?.let { strings.resources.getQuantityString(R.plurals.seconds_count, it, it) }.orEmpty()
val weight = plan.weightKg?.let { " · $it kg" }.orEmpty()
TrainlogInfo(strings.getString(R.string.plan_draft_summary, entry.exercise.name, plan.sets, metric, weight, plan.restSeconds))
}
TrainlogPrimaryAction(strings.getString(R.string.start), strings.getString(R.string.start_preparation_description)) {
DraftSummaryCard(
title = preparation.title,
provenance = strings.getString(R.string.manual_preparation),
plannedFor = preparation.plannedFor,
notes = preparation.notes,
entries = preparation.entries,
startDescription = strings.getString(R.string.start_preparation_description),
onStart = {
when (val result = repository.startPreparedSession(preparation.deliveryId)) {
StartAiSessionDraftResult.Started -> { onPendingChanged(); onStarted() }
StartAiSessionDraftResult.ExistingActiveDraft ->
@ -115,24 +126,18 @@ fun AiSessionDraftsScreen(
StartAiSessionDraftResult.NotPending -> { message = strings.getString(R.string.draft_missing); revision++; onPendingChanged() }
is StartAiSessionDraftResult.Error -> message = localizedRepositoryMessage(strings, result.message)
}
}
}
},
)
}
drafts.forEach { draft ->
TrainlogFrame(draft.title ?: strings.getString(R.string.session_proposal), active = true) {
TrainlogInfo(listOfNotNull(
draft.plannedFor?.let { strings.getString(R.string.planned_for, formatDate(it)) },
strings.resources.getQuantityString(R.plurals.exercise_count, draft.entries.size, draft.entries.size),
).joinToString(" · "))
draft.notes?.let { TrainlogInfo(it) }
draft.entries.forEach { entry ->
val plan = checkNotNull(entry.plan)
val metric = plan.reps?.let { strings.resources.getQuantityString(R.plurals.repetition_count, it, it) }
?: plan.durationSeconds?.let { strings.resources.getQuantityString(R.plurals.seconds_count, it, it) }.orEmpty()
val weight = plan.weightKg?.let { " · $it kg" }.orEmpty()
TrainlogInfo(strings.getString(R.string.plan_draft_summary, entry.exercise.name, plan.sets, metric, weight, plan.restSeconds))
}
TrainlogPrimaryAction(strings.getString(R.string.start), strings.getString(R.string.start_proposal_description)) {
DraftSummaryCard(
title = draft.title ?: strings.getString(R.string.session_proposal),
provenance = strings.getString(R.string.session_proposal),
plannedFor = draft.plannedFor,
notes = draft.notes,
entries = draft.entries,
startDescription = strings.getString(R.string.start_proposal_description),
onStart = {
when (val result = repository.startAiSessionDraft(draft.draftId)) {
StartAiSessionDraftResult.Started -> {
onPendingChanged()
@ -147,15 +152,24 @@ fun AiSessionDraftsScreen(
}
is StartAiSessionDraftResult.Error -> message = localizedRepositoryMessage(strings, result.message)
}
}
TrainlogDeleteButton(
contentDescription = localizedContext().getString(R.string.a11y_delete_draft),
modifier = Modifier.testTag("delete-ai-draft-${draft.draftId}"),
onClick = {
pendingDeletion = draft.draftId to (draft.title ?: strings.getString(R.string.session_proposal))
},
)
}
},
footer = {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TrainlogDeleteButton(
contentDescription = localizedContext().getString(R.string.a11y_delete_draft),
modifier = Modifier.testTag("delete-ai-draft-${draft.draftId}"),
fullWidth = false,
onClick = {
pendingDeletion = draft.draftId to
(draft.title ?: strings.getString(R.string.session_proposal))
},
)
}
},
)
}
}
pendingDeletion?.let { (draftId, title) ->
@ -181,6 +195,138 @@ fun AiSessionDraftsScreen(
}
}
/** Compact presentation of an inert preparation; starting remains caller-owned. */
@Composable
private fun DraftSummaryCard(
title: String,
provenance: String,
plannedFor: String?,
notes: String?,
entries: List<SessionExerciseDraft>,
startDescription: String,
onStart: () -> Unit,
footer: (@Composable () -> Unit)? = null,
) {
val colors = LocalTrainlogColors.current
val strings = localizedContext()
Column(Modifier.fillMaxWidth().padding(bottom = 12.dp)) {
TrainlogCompactCard {
androidx.compose.material3.Text(
title,
color = colors.text,
style = TrainlogTypography.section,
fontWeight = FontWeight.Bold,
)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
TrainlogMetaBadge(provenance, Modifier.weight(1f))
TrainlogMetaBadge(
strings.resources.getQuantityString(
R.plurals.exercise_count,
entries.size,
entries.size,
),
)
}
plannedFor?.let {
androidx.compose.material3.Text(
strings.getString(R.string.planned_for, formatDate(it)),
color = colors.muted,
style = TrainlogTypography.small,
)
}
notes?.takeIf { it.isNotBlank() }?.let {
androidx.compose.material3.Text(
it,
color = colors.muted,
style = TrainlogTypography.small,
maxLines = 2,
)
}
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
entries.forEach { DraftExerciseRow(it) }
}
Row(
Modifier.fillMaxWidth().padding(top = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TrainlogButton(
strings.getString(R.string.start),
onStart,
Modifier.widthIn(min = 128.dp),
style = TrainlogButtonStyle.SUCCESS,
)
androidx.compose.material3.Text(
startDescription,
modifier = Modifier.weight(1f),
color = colors.muted,
style = TrainlogTypography.small,
maxLines = 2,
)
}
footer?.invoke()
}
}
}
@Composable
private fun DraftExerciseRow(entry: SessionExerciseDraft) {
val colors = LocalTrainlogColors.current
val plan = checkNotNull(entry.plan)
Column(
Modifier.fillMaxWidth()
.background(colors.background, androidx.compose.material3.MaterialTheme.shapes.small)
.padding(horizontal = 10.dp, vertical = 8.dp),
) {
androidx.compose.material3.Text(
entry.exercise.name,
color = colors.text,
style = TrainlogTypography.small,
fontWeight = FontWeight.SemiBold,
)
androidx.compose.material3.Text(
draftPlanSummary(plan.sets, plan.reps, plan.durationSeconds, plan.weightKg, plan.restSeconds),
color = colors.muted,
style = TrainlogTypography.small,
)
}
}
@Composable
private fun draftPlanSummary(
sets: Int,
reps: Int?,
durationSeconds: Int?,
weightKg: Double?,
restSeconds: Int,
): String {
val strings = localizedContext()
val metric = when {
reps != null -> "$sets × $reps"
durationSeconds != null && sets > 0 -> "$sets × ${humanDuration(durationSeconds)}"
durationSeconds != null -> humanDuration(durationSeconds)
else -> "—"
}
return buildList {
add(metric)
weightKg?.let { add("${it.toString().trimEnd('0').trimEnd('.')} kg") }
if (restSeconds > 0) add(strings.getString(R.string.draft_rest_short, restSeconds))
}.joinToString(" · ")
}
@Composable
private fun humanDuration(seconds: Int): String {
val strings = localizedContext()
return if (seconds % 60 == 0) {
val minutes = seconds / 60
strings.resources.getQuantityString(R.plurals.duration_minutes_value, minutes, minutes)
} else {
strings.resources.getQuantityString(R.plurals.duration_seconds_value, seconds, seconds)
}
}
@Composable
fun LatestMaximaScreen(repository: TrainlogRepository) {
val colors = LocalTrainlogColors.current
@ -188,13 +334,29 @@ fun LatestMaximaScreen(repository: TrainlogRepository) {
val locale = presentationLocale()
val strings = localizedContext()
TrainlogScreen(strings.getString(R.string.route_latest_maxima)) {
TrainlogFrame(strings.getString(R.string.max_capacities), active = maxima.isNotEmpty()) {
if (maxima.isEmpty()) TrainlogInfo(strings.getString(R.string.max_none))
maxima.forEach { max ->
val weight = "%.2f".format(locale, max.maxWeightKg).trimEnd('0').trimEnd(',', '.')
TrainlogInfo("${max.exerciseName} · $weight kg", colors.warning)
TrainlogInfo("${formatDate(max.startedAt)} · " + strings.getString(R.string.equipment_value,
max.equipmentDisplayName ?: strings.getString(R.string.value_none)), colors.muted)
if (maxima.isEmpty()) TrainlogInfo(strings.getString(R.string.max_none))
maxima.forEach { max ->
val weight = "%.2f".format(locale, max.maxWeightKg).trimEnd('0').trimEnd(',', '.')
androidx.compose.foundation.layout.Column(
Modifier.fillMaxWidth().padding(bottom = 8.dp)
.background(colors.surface, androidx.compose.material3.MaterialTheme.shapes.medium)
.padding(12.dp),
) {
androidx.compose.material3.Text(
max.exerciseName,
color = colors.text,
style = com.labfytools.trainlog.ui.theme.TrainlogTypography.section,
)
androidx.compose.foundation.layout.Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
androidx.compose.material3.Text("$weight kg", color = colors.warning)
androidx.compose.material3.Text(formatDate(max.startedAt), color = colors.muted)
}
max.equipmentDisplayName?.let {
TrainlogInfo(strings.getString(R.string.equipment_value, it), colors.muted)
}
}
}
}
@ -272,17 +434,29 @@ fun EquipmentDetailScreen(repository: TrainlogRepository, equipmentId: String, o
}
@Composable
fun EquipmentCreateScreen(repository: TrainlogRepository, state: EquipmentScreenState, onCreated: () -> Unit) {
fun EquipmentCreateScreen(
repository: TrainlogRepository,
state: EquipmentScreenState,
onCreated: (EquipmentCatalogEntry) -> Unit,
) {
val colors = LocalTrainlogColors.current
val strings = localizedContext()
TrainlogScreen(strings.getString(R.string.create_equipment), scrollKey = "equipment-create") {
TrainlogFrame(strings.getString(R.string.personal_equipment)) {
TrainlogInputField(strings.getString(R.string.name), state.customName, { state.customName = it; state.message = null })
TrainlogInputField(
strings.getString(R.string.name),
state.customName,
{ state.customName = it; state.message = null },
testTag = "equipment-create-name",
)
TrainlogPrimaryAction(strings.getString(R.string.create), strings.getString(R.string.create_machine_description)) {
when (val result = repository.createCustomEquipment(state.customName)) {
is CreateEquipmentResult.Created -> {
state.selectedEquipmentId = result.equipment.equipmentId
state.customName = ""; state.message = null; state.revision++; onCreated()
state.customName = ""
state.message = null
state.revision++
onCreated(result.equipment)
}
CreateEquipmentResult.Invalid -> state.message = strings.getString(R.string.equipment_name_invalid)
CreateEquipmentResult.Conflict -> state.message = strings.getString(R.string.equipment_name_exists)
@ -309,13 +483,22 @@ private fun catalogCountsText(context: android.content.Context, imported: Int, r
).joinToString(", ")
@Composable
fun SettingsScreen(repository: TrainlogRepository, inbox: SyncCatalogInbox, onCatalogChanged: () -> Unit) {
fun SettingsScreen(
repository: TrainlogRepository,
inbox: SyncCatalogInbox,
onHeartRateDiagnostics: () -> Unit,
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)
@ -370,6 +553,29 @@ fun SettingsScreen(repository: TrainlogRepository, inbox: SyncCatalogInbox, onCa
)
}
}
TrainlogFrame(strings.getString(R.string.settings_cardio_section)) {
if (selectedHeartRateSensor != null) {
TrainlogInfo(
strings.getString(R.string.settings_cardio_configured),
colors.success,
)
}
TrainlogAction(
strings.getString(R.string.settings_cardio_diagnostics),
strings.getString(R.string.settings_cardio_diagnostics_description),
onHeartRateDiagnostics,
)
if (selectedHeartRateSensor != null) {
TrainlogAction(
strings.getString(R.string.settings_cardio_forget),
strings.getString(R.string.settings_cardio_forget_description),
{
HeartRateSensorService.forget(context)
heartRateRevision++
},
)
}
}
TrainlogFrame(strings.getString(R.string.settings_exchange_folder)) {
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

@ -25,9 +25,16 @@ import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
@ -45,14 +52,16 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.labfytools.trainlog.BuildConfig
import com.labfytools.trainlog.R
import com.labfytools.trainlog.data.ActiveDraftLoadResult
import com.labfytools.trainlog.data.ActiveDraftMutationResult
import com.labfytools.trainlog.data.CreateEquipmentResult
import com.labfytools.trainlog.data.EquipmentLoadSemantics
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
@ -68,7 +77,18 @@ import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import com.labfytools.trainlog.ui.theme.TrainlogTypography
import java.text.Normalizer
import java.util.Locale
import com.labfytools.trainlog.R
/**
* CONTRACT: a private recovery build may override finalization time for one
* exact stable lifecycle identity only. Empty build inputs and every other
* draft retain the normal repository clock default.
*/
private fun recoveryEndedAt(draft: ActiveSessionDraft): String? =
BuildConfig.ACTIVE_SESSION_RECOVERY_ENDED_AT.takeIf {
BuildConfig.ACTIVE_SESSION_RECOVERY_ID.isNotEmpty() &&
BuildConfig.ACTIVE_SESSION_RECOVERY_ID == draft.sessionId &&
it.isNotEmpty()
}
@Composable
fun SessionScreen(
@ -76,7 +96,10 @@ fun SessionScreen(
catalogRevision: Int,
onBack: () -> Unit,
onCreateExercise: () -> Unit,
onCreateEquipment: (editingEntryId: String?) -> Unit,
onSessionSaved: () -> Unit,
restoreEditingEntryId: String? = null,
onEditingContextRestored: () -> Unit = {},
) {
val colors =
LocalTrainlogColors.current
@ -96,13 +119,27 @@ fun SessionScreen(
var activeDraft by
remember(catalogRevision) {
val loadedDraft = (initialLoad as? ActiveDraftLoadResult.Loaded)?.draft
val restoredIndex = restoreEditingEntryId?.let { entryId ->
loadedDraft?.exercises?.indexOfFirst { it.entryId == entryId }
?.takeIf { it >= 0 }
}
mutableStateOf(
(initialLoad as?
ActiveDraftLoadResult.Loaded)
?.draft
if (restoredIndex == null) loadedDraft else loadedDraft?.let { draft ->
draft.copy(
form = draft.form.copy(
editingExerciseIndex = restoredIndex,
editingEntryId = restoreEditingEntryId,
),
)
}
)
}
LaunchedEffect(restoreEditingEntryId) {
if (restoreEditingEntryId != null) onEditingContextRestored()
}
var message by
remember(catalogRevision) {
mutableStateOf<
@ -132,6 +169,9 @@ 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 =
@ -171,6 +211,12 @@ 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(
@ -196,69 +242,47 @@ fun SessionScreen(
TrainlogFrame(
title = strings.getString(R.string.session_type)
) {
TrainlogAction(
label =
if (
currentDraft.sessionType ==
SessionType.TRAINING
) {
strings.getString(R.string.training_selected)
} else {
strings.getString(R.string.training_unselected)
},
description =
strings.getString(R.string.normal_session_description),
accent =
if (
currentDraft.sessionType ==
SessionType.TRAINING
) {
colors.success
} else {
colors.muted
},
onClick = {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
val training = currentDraft.sessionType == SessionType.TRAINING
TrainlogButton(
label = strings.getString(R.string.training),
modifier = Modifier.weight(1f),
style = if (training) TrainlogButtonStyle.SUCCESS else TrainlogButtonStyle.SECONDARY,
maxLines = 2,
onClick = {
persistDraft(
currentDraft.copy(
sessionType =
SessionType.TRAINING
),
currentDraft.copy(sessionType = SessionType.TRAINING),
null,
)
},
)
TrainlogAction(
label =
if (
currentDraft.sessionType ==
SessionType.MAX_TEST
) {
strings.getString(R.string.max_test_selected)
} else {
strings.getString(R.string.max_test_unselected)
},
description =
strings.getString(R.string.max_session_description),
accent =
if (
currentDraft.sessionType ==
SessionType.MAX_TEST
) {
colors.warning
} else {
colors.muted
},
onClick = {
)
val maxTest = currentDraft.sessionType == SessionType.MAX_TEST
TrainlogButton(
label = strings.getString(R.string.session_max_test),
modifier = Modifier.weight(1f),
style = if (maxTest) TrainlogButtonStyle.SUCCESS else TrainlogButtonStyle.SECONDARY,
maxLines = 2,
onClick = {
persistDraft(
currentDraft.copy(
sessionType =
SessionType.MAX_TEST
),
currentDraft.copy(sessionType = SessionType.MAX_TEST),
null,
)
},
)
},
)
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,
)
},
)
}
}
ExercisePicker(
@ -281,23 +305,19 @@ fun SessionScreen(
) {
TrainlogInfo(
text =
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)
}),
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)
},
),
color =
if (
currentDraft.sessionType ==
SessionType.MAX_TEST
) {
colors.warning
} else {
colors.accent
when (currentDraft.sessionType) {
SessionType.MAX_TEST -> colors.warning
SessionType.CARDIO -> colors.success
SessionType.TRAINING -> colors.accent
},
)
@ -372,18 +392,110 @@ 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),
accent = colors.accent,
modifier = Modifier.testTag("edit-draft-exercise-${draft.entryId}"),
onClick = {
persistDraft(
currentDraft.copy(
form = formForExistingExercise(draft, index),
),
null,
strings.getString(R.string.editor_opened, draft.exercise.name),
)
message = strings.getString(R.string.editor_opened, draft.exercise.name)
},
)
if (currentDraft.form.editingEntryId == draft.entryId) {
@ -467,8 +579,11 @@ fun SessionScreen(
sessionType = currentDraft.sessionType,
initialForm =
currentDraft.form,
initialPlan = currentDraft.form.editingExerciseIndex?.let {
currentDraft.exercises.getOrNull(it)?.plan
initialOccurrence = currentDraft.form.editingExerciseIndex?.let {
currentDraft.exercises.getOrNull(it)
},
onCreateEquipment = {
onCreateEquipment(currentDraft.form.editingEntryId)
},
onFormChanged = {
form ->
@ -530,22 +645,32 @@ fun SessionScreen(
Modifier.weight(1f).fillMaxHeight().testTag("active-session-create-exercise"),
containerColor = colors.surfaceAlt, maxLines = 2)
TrainlogButton(strings.getString(R.string.session_save), onClick = {
when (
val result =
repository.finalizeActiveSessionDraft()
) {
is FinalizeActiveDraftResult.Saved -> {
onSessionSaved()
onBack()
}
val activeEntryId = repository.activeSessionTimelineContext()?.activeEntryId
if (activeEntryId != null) {
pendingFinalizeActiveEntryId = activeEntryId
} else {
when (
val result =
repository.finalizeActiveSessionDraft(
recoveryEndedAt(currentDraft),
)
) {
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"),
@ -601,12 +726,24 @@ fun SessionScreen(
strings.getString(R.string.exercise_added) ||
message ==
strings.getString(R.string.exercise_removed) ||
message == strings.getString(R.string.exercise_order_saved)
message == strings.getString(R.string.exercise_order_saved) ||
currentDraft.exercises.any { exercise ->
message ==
strings.getString(
R.string.exercise_timeline_started,
exercise.exercise.name,
) ||
message ==
strings.getString(
R.string.exercise_timeline_finished,
exercise.exercise.name,
)
}
) {
colors.success
} else {
colors.error
},
}
)
}
}
@ -652,6 +789,142 @@ 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(
recoveryEndedAt(currentDraft),
)
) {
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))
}
},
)
}
}
}
@ -894,7 +1167,8 @@ private fun SessionExerciseForm(
exercise: ExerciseProfile,
sessionType: SessionType,
initialForm: SessionDraftForm,
initialPlan: SessionExercisePlan?,
initialOccurrence: SessionExerciseDraft?,
onCreateEquipment: () -> Unit,
onFormChanged: (SessionDraftForm) -> Unit,
onCancel: () -> Unit,
onAdd:
@ -904,6 +1178,7 @@ private fun SessionExerciseForm(
val colors =
LocalTrainlogColors.current
val strings = localizedContext()
val initialPlan = initialOccurrence?.plan
var setCountText by
remember(key) {
@ -969,13 +1244,11 @@ private fun SessionExerciseForm(
)
}
var equipmentRevision by remember(key) { mutableStateOf(0) }
val equipmentEntries = remember(equipmentRevision) { repository.listEquipment() }
val equipmentEntries = remember(key) { repository.listEquipment() }
val fixedEquipmentId = remember(exercise.exerciseId) {
repository.machineExerciseLegacyEquipmentId(exercise.exerciseId)
}
var equipmentSearch by remember(key) { mutableStateOf("") }
var customEquipmentName by remember(key) { mutableStateOf("") }
var selectedEquipmentId by remember(key) {
mutableStateOf(initialForm.selectedEquipmentId ?: fixedEquipmentId)
}
@ -1012,34 +1285,18 @@ private fun SessionExerciseForm(
value = equipmentSearch,
onValueChange = { equipmentSearch = it },
)
TrainlogInputField(
label = strings.getString(R.string.new_historical_context),
value = customEquipmentName,
onValueChange = { customEquipmentName = it },
)
TrainlogAction(
label = strings.getString(R.string.create_context),
description = strings.getString(R.string.context_compatibility),
label = strings.getString(R.string.create_equipment),
description = strings.getString(R.string.create_equipment_from_session),
accent = colors.success,
onClick = {
when (val result = repository.createCustomEquipment(customEquipmentName)) {
is CreateEquipmentResult.Created -> {
customEquipmentName = ""
equipmentRevision += 1
selectedEquipmentId = result.equipment.equipmentId
onFormChanged(currentForm(exercise, setCountText, repsText, durationText, speedText, distanceText, selectedEquipmentId, weightText, maxWeightText))
}
CreateEquipmentResult.Invalid -> error = strings.getString(R.string.context_name_invalid)
CreateEquipmentResult.Conflict -> error = strings.getString(R.string.context_exists)
is CreateEquipmentResult.DatabaseError -> error = strings.getString(R.string.context_create_error, localizedRepositoryMessage(strings, result.message))
}
},
modifier = Modifier.testTag("session-create-equipment"),
onClick = onCreateEquipment,
)
}
val selectedEquipment = equipmentEntries.firstOrNull { it.equipmentId == selectedEquipmentId }
val percentResult = remember(
exercise.exerciseId, selectedEquipmentId, targetPercentText,
targetChoice, equipmentRevision,
targetChoice,
) {
if (targetChoice == ManualTargetChoice.PERCENT_MAX)
repository.calculateManualPercentMaxTarget(
@ -1059,16 +1316,29 @@ private fun SessionExerciseForm(
},
)
}
if (fixedEquipmentId == null) repository.searchEquipment(equipmentSearch).take(8).forEach { equipment ->
TrainlogAction(
label = if (equipment.equipmentId == selectedEquipmentId) "✓ ${equipment.displayName}" else equipment.displayName,
description = equipment.labelName.ifBlank { equipment.type },
accent = if (equipment.equipmentId == selectedEquipmentId) colors.success else colors.muted,
onClick = {
selectedEquipmentId = equipment.equipmentId
onFormChanged(currentForm(exercise, setCountText, repsText, durationText, speedText, distanceText, equipment.equipmentId, weightText, maxWeightText))
},
)
if (fixedEquipmentId == null) {
/* WHY: alphabetical truncation made valid equipment beyond the
* eighth row impossible to select. CONTRACT: the bounded nested
* viewport exposes every search result while the surrounding
* exercise editor remains independently scrollable. */
Column(
Modifier
.heightIn(max = 320.dp)
.verticalScroll(rememberScrollState())
.testTag("session-equipment-results"),
) {
repository.searchEquipment(equipmentSearch).forEach { equipment ->
TrainlogAction(
label = if (equipment.equipmentId == selectedEquipmentId) "✓ ${equipment.displayName}" else equipment.displayName,
description = equipment.labelName.ifBlank { equipment.type },
accent = if (equipment.equipmentId == selectedEquipmentId) colors.success else colors.muted,
onClick = {
selectedEquipmentId = equipment.equipmentId
onFormChanged(currentForm(exercise, setCountText, repsText, durationText, speedText, distanceText, equipment.equipmentId, weightText, maxWeightText))
},
)
}
}
}
if (sessionType == SessionType.MAX_TEST) {
@ -1151,54 +1421,78 @@ private fun SessionExerciseForm(
strings.getString(R.string.field_weight)
}
setRows.forEachIndexed { index, row ->
TrainlogInfo(
text = strings.getString(R.string.set_number, index + 1),
color = colors.accent,
)
SessionNumberField(
label = strings.getString(R.string.set_reps_field, index + 1),
value = row.repsText,
testTag = "session-set-$index-reps",
onValueChange = { value ->
val updated = setRows.replaceAt(index, row.copy(repsText = value))
setRows = updated
repsText = encodeRawReps(updated)
weightText = encodeRawWeights(updated)
error = null
onFormChanged(
currentForm(
exercise, setCountText, repsText, durationText,
speedText, distanceText, selectedEquipmentId, weightText,
)
Column(
Modifier
.fillMaxWidth()
.padding(bottom = 8.dp)
.background(colors.surface, MaterialTheme.shapes.medium)
.padding(10.dp),
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
BasicText(
text = strings.getString(R.string.set_number, index + 1),
modifier = Modifier.padding(vertical = 12.dp),
style = TrainlogTypography.small.copy(
color = colors.accent,
fontWeight = FontWeight.Bold,
),
)
},
)
SessionNumberField(
label = strings.getString(R.string.set_load_field, index + 1, loadLabel),
value = row.weightText,
decimal = true,
testTag = "session-set-$index-weight",
onValueChange = { value ->
val updated = setRows.replaceAt(index, row.copy(weightText = value))
setRows = updated
repsText = encodeRawReps(updated)
weightText = encodeRawWeights(updated)
error = null
onFormChanged(
currentForm(
exercise, setCountText, repsText, durationText,
speedText, distanceText, selectedEquipmentId, weightText,
)
TrainlogIconAction(
icon = TrainlogIcons.DeleteOutline,
contentDescription = localizedContext().getString(R.string.a11y_delete_set),
modifier = Modifier.testTag("delete-set-${index + 1}"),
accent = colors.error,
onClick = { pendingSetRemoval = index },
)
},
)
TrainlogDeleteButton(
contentDescription = localizedContext().getString(com.labfytools.trainlog.R.string.a11y_delete_set),
modifier = Modifier.testTag("delete-set-${index + 1}"),
onClick = {
pendingSetRemoval = index
},
)
}
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
SessionNumberField(
label = strings.getString(R.string.field_reps),
value = row.repsText,
modifier = Modifier.weight(1f),
testTag = "session-set-$index-reps",
onValueChange = { value ->
val updated = setRows.replaceAt(index, row.copy(repsText = value))
setRows = updated
repsText = encodeRawReps(updated)
weightText = encodeRawWeights(updated)
error = null
onFormChanged(
currentForm(
exercise, setCountText, repsText, durationText,
speedText, distanceText, selectedEquipmentId, weightText,
)
)
},
)
SessionNumberField(
label = loadLabel,
value = row.weightText,
modifier = Modifier.weight(1f),
decimal = true,
testTag = "session-set-$index-weight",
onValueChange = { value ->
val updated = setRows.replaceAt(index, row.copy(weightText = value))
setRows = updated
repsText = encodeRawReps(updated)
weightText = encodeRawWeights(updated)
error = null
onFormChanged(
currentForm(
exercise, setCountText, repsText, durationText,
speedText, distanceText, selectedEquipmentId, weightText,
)
)
},
)
}
}
}
TrainlogAction(
label = strings.getString(R.string.add_set_plain),
@ -1275,6 +1569,7 @@ private fun SessionExerciseForm(
SessionNumberField(
label =
strings.getString(R.string.duration_minutes),
testTag = "session-continuous-duration",
value =
durationText,
onValueChange = {
@ -1361,7 +1656,7 @@ private fun SessionExerciseForm(
modifier = Modifier.fillMaxWidth().testTag("add-to-session"),
containerColor = colors.success,
onClick = {
val draft =
val capturedDraft =
buildSessionExerciseDraft(
exercise =
exercise,
@ -1383,6 +1678,26 @@ private fun SessionExerciseForm(
rawSetRows = setRows,
)
/* WHY: a prepared occurrence may legitimately have a target
* but no performed sets yet. CONTRACT: changing its equipment
* edits planning metadata in place without fabricating actual
* work. INVARIANT: any nonblank partial performed row still
* follows normal validation and can never be discarded here. */
val targetOnlyDraft = initialOccurrence?.takeIf {
sessionType == SessionType.TRAINING &&
it.plan != null &&
it.sets.isEmpty() &&
it.maxWeightKg == null &&
it.continuousDurationSeconds == 0 &&
setRows.all { row ->
row.repsText.isBlank() && row.weightText.isBlank()
}
}?.copy(
exercise = exercise,
equipmentId = selectedEquipmentId,
)
val draft = capturedDraft ?: targetOnlyDraft
if (draft == null) {
error =
if (sessionType == SessionType.MAX_TEST) {
@ -1406,7 +1721,9 @@ private fun SessionExerciseForm(
onFailure = { error = localizedTargetError(it.message, strings) },
)
} else {
onAdd(draft.copy(plan = null))
/* CONTRACT: editing performed continuous facts must not
* erase the independent target copied from preparation. */
onAdd(draft.copy(plan = initialPlan))
}
},
)
@ -1418,6 +1735,7 @@ private fun SessionExerciseForm(
strings.getString(R.string.return_catalog),
accent =
colors.muted,
modifier = Modifier.testTag("cancel-session-exercise-editor"),
onClick =
onCancel,
)
@ -1608,6 +1926,7 @@ private fun SessionNumberField(
label: String,
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
testTag: String? = null,
decimal: Boolean = false,
) {
@ -1623,6 +1942,7 @@ private fun SessionNumberField(
} else {
KeyboardOptions(keyboardType = KeyboardType.Number)
},
modifier = modifier,
)
}

View file

@ -0,0 +1,537 @@
package com.labfytools.trainlog.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.FilterChip
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
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
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.labfytools.trainlog.R
import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.model.SleepDiaryDraft
import com.labfytools.trainlog.model.SleepDiaryEntry
import com.labfytools.trainlog.model.SleepDiaryEvent
import com.labfytools.trainlog.model.SleepEventType
import com.labfytools.trainlog.model.SleepQuality
import com.labfytools.trainlog.model.SleepPublicationStatus
import com.labfytools.trainlog.model.MedicationIntake
import com.labfytools.trainlog.model.SleepMedication
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()
var startDate by remember(selected) { mutableStateOf(selected?.nightStartDate ?: today.minusDays(1).toString()) }
var endDate by remember(selected) { mutableStateOf(selected?.nightEndDate ?: today.toString()) }
var sleepQuality by remember(selected) { mutableStateOf(selected?.sleepQuality) }
var wakeQuality by remember(selected) { mutableStateOf(selected?.wakeQuality) }
var dayForm by remember(selected) { mutableStateOf(selected?.dayForm) }
var notes by remember(selected) { mutableStateOf(selected?.treatmentAndNotes.orEmpty()) }
var events by remember(selected) { mutableStateOf(selected?.events.orEmpty()) }
var intakes by remember(selected) { mutableStateOf(selected?.intakes.orEmpty()) }
val catalog = remember(revision) { repository.listSleepMedications() }
val medications = catalog.filter { it.active }
var intakeMedication by remember(medications) { mutableStateOf(medications.firstOrNull()) }
var intakeDose by remember(intakeMedication) { mutableStateOf(intakeMedication?.defaultDoseValue?.toString().orEmpty()) }
var intakeUnit by remember(intakeMedication) { mutableStateOf(intakeMedication?.defaultDoseUnit.orEmpty()) }
var medicationName by remember { mutableStateOf("") }
var medicationDose by remember { mutableStateOf("") }
var medicationUnit by remember { mutableStateOf("mg") }
var editingMedication by remember { mutableStateOf<SleepMedication?>(null) }
var editSerial by remember { mutableStateOf(0) }
val entries = repository.listSleepDiary()
fun changed() { editSerial++ }
LaunchedEffect(editSerial) {
if (editSerial > 0) {
val now = OffsetDateTime.now().toString()
val current = selected
val result = repository.saveSleepDiary(SleepDiaryDraft(current?.entryId,
current?.revisionId,startDate,endDate,current?.createdAt ?: now,now,sleepQuality,
wakeQuality,dayForm,notes,events,intakes))
if (result is TrainlogRepository.SaveSleepDiaryResult.Saved) {
selected = repository.listSleepDiary().firstOrNull { it.entryId == result.entryId }
}
}
}
LazyColumn(
modifier = Modifier.fillMaxSize().padding(12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
item { Text(stringResource(R.string.sleep_current_night)) }
items(entries, key = { it.entryId }) { entry ->
Card(onClick = { selected = entry }) {
Column(Modifier.fillMaxWidth().padding(12.dp)) {
Text("${entry.nightStartDate} → ${entry.nightEndDate}")
Text(stringResource(when (entry.publicationStatus) {
SleepPublicationStatus.DRAFT -> R.string.sleep_status_draft
SleepPublicationStatus.READY -> R.string.sleep_status_ready
SleepPublicationStatus.SYNCHRONIZED -> R.string.sleep_status_synchronized
SleepPublicationStatus.MODIFIED -> R.string.sleep_status_modified
}))
Text("${entry.events.size} · ${entry.sleepQuality?.wireValue ?: "—"} / ${entry.wakeQuality?.wireValue ?: "—"}")
}
}
}
item {
Card {
Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.sleep_morning))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(startDate, { startDate = it; changed() }, label = { Text("YYYY-MM-DD") }, modifier = Modifier.weight(1f))
OutlinedTextField(endDate, { endDate = it; changed() }, label = { Text("YYYY-MM-DD") }, modifier = Modifier.weight(1f))
}
QualityRow(stringResource(R.string.sleep_quality), sleepQuality) { sleepQuality = it; changed() }
QualityRow(stringResource(R.string.sleep_wake_quality), wakeQuality) { wakeQuality = it; changed() }
Text(stringResource(R.string.sleep_day))
QualityRow(stringResource(R.string.sleep_day_form), dayForm) { dayForm = it; changed() }
SleepEventType.entries.forEach { type ->
Button(onClick = {
val start = OffsetDateTime.now()
events = events + SleepDiaryEvent("sle_${UUID.randomUUID()}", type, start.toString(),
if (type in pointTypes) null else start.plusHours(1).toString())
changed()
}) { Text(eventLabel(type)) }
}
events.forEachIndexed { index, event ->
Card {
Column(Modifier.padding(8.dp)) {
Text(eventLabel(event.type))
TimeButton(event.startAt) { value ->
events = events.mapIndexed { item, current ->
if (item == index) current.copy(startAt = value) else current
}
changed()
}
OutlinedTextField(event.startAt, { value -> events = events.mapIndexed { item, current -> if (item == index) current.copy(startAt = value) else current }; changed() }, label = { Text("ISO-8601") })
event.endAt?.let { end ->
TimeButton(end) { value ->
events = events.mapIndexed { item, current ->
if (item == index) current.copy(endAt = value) else current
}
changed()
}
OutlinedTextField(end, { value -> events = events.mapIndexed { item, current -> if (item == index) current.copy(endAt = value) else current }; changed() }, label = { Text("ISO-8601 →") })
}
Button(onClick = { events = events.filterIndexed { item, _ -> item != index }; changed() }) { Text(stringResource(R.string.sleep_delete)) }
}
}
}
Text(stringResource(R.string.sleep_medications))
OutlinedTextField(medicationName, { medicationName = it },
label = { Text(stringResource(R.string.sleep_medications)) }, modifier = Modifier.fillMaxWidth())
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(medicationDose, { medicationDose = it },
label = { Text(stringResource(R.string.sleep_dose)) }, modifier = Modifier.weight(1f))
OutlinedTextField(medicationUnit, { medicationUnit = it },
label = { Text(stringResource(R.string.sleep_unit)) }, modifier = Modifier.weight(1f))
}
Button(onClick = {
val now = OffsetDateTime.now().toString()
val dose = medicationDose.toDoubleOrNull()
if (medicationName.isNotBlank() && (medicationDose.isEmpty() || dose != null && dose > 0.0)) {
val current = editingMedication
repository.saveSleepMedication(SleepMedication(current?.medicationId.orEmpty(),
current?.revisionId.orEmpty(), current?.createdAt ?: now, now,
medicationName.trim(), dose, if (dose == null) null else medicationUnit,
current?.form.orEmpty(), current?.note.orEmpty(), current?.active ?: true))
medicationName = ""; editingMedication = null; revision++
}
}) { Text(stringResource(R.string.sleep_add_medication)) }
medications.forEach { medication ->
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(selected = intakeMedication?.medicationId == medication.medicationId,
onClick = { intakeMedication = medication
intakeDose = medication.defaultDoseValue?.toString().orEmpty()
intakeUnit = medication.defaultDoseUnit.orEmpty() },
label = { Text(medication.name) })
Button(onClick = { editingMedication = medication; medicationName = medication.name
medicationDose = medication.defaultDoseValue?.toString().orEmpty()
medicationUnit = medication.defaultDoseUnit.orEmpty() }) {
Text(stringResource(R.string.sleep_edit))
}
Button(onClick = { repository.saveSleepMedication(medication.copy(
updatedAt = OffsetDateTime.now().toString(), active = false)); revision++ }) {
Text(stringResource(R.string.sleep_deactivate))
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(intakeDose, { intakeDose = it },
label = { Text(stringResource(R.string.sleep_dose)) }, modifier = Modifier.weight(1f))
OutlinedTextField(intakeUnit, { intakeUnit = it },
label = { Text(stringResource(R.string.sleep_unit)) }, modifier = Modifier.weight(1f))
}
Button(onClick = {
intakeMedication?.let { medication ->
val dose = intakeDose.toDoubleOrNull()
if (intakeDose.isEmpty() || dose != null && dose > 0.0) {
val now = OffsetDateTime.now().toString()
intakes = intakes + MedicationIntake("mdi_${UUID.randomUUID()}", medication.medicationId,
medication.name, now, dose, if (dose == null) null else intakeUnit,
"", now)
changed()
}
}
}) { Text(stringResource(R.string.sleep_add_intake)) }
intakes.forEachIndexed { index, intake -> Card {
Column(Modifier.fillMaxWidth().padding(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(intake.medicationName)
TimeButton(intake.takenAt) { value -> intakes = intakes.mapIndexed { item, current ->
if (item == index) current.copy(takenAt = value) else current }; changed() }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(intake.doseValue?.toString().orEmpty(), { value ->
intakes = intakes.mapIndexed { item, current -> if (item == index) current.copy(
doseValue = value.toDoubleOrNull(), doseUnit = if (value.isEmpty()) null else current.doseUnit ?: "mg") else current }
changed()
}, label = { Text(stringResource(R.string.sleep_dose)) }, modifier = Modifier.weight(1f))
OutlinedTextField(intake.doseUnit.orEmpty(), { value -> intakes = intakes.mapIndexed { item, current ->
if (item == index) current.copy(doseUnit = value) else current }
changed()
}, label = { Text(stringResource(R.string.sleep_unit)) }, modifier = Modifier.weight(1f))
}
Button(onClick = { intakes = intakes.filterIndexed { item, _ -> item != index }; changed() }) {
Text(stringResource(R.string.sleep_delete))
}
}
} }
OutlinedTextField(notes, { notes = it; changed() }, label = { Text(stringResource(R.string.sleep_notes)) }, modifier = Modifier.fillMaxWidth(), minLines = 3)
Button(onClick = {
val now = OffsetDateTime.now().toString()
val current = selected
val result = repository.saveSleepDiary(SleepDiaryDraft(current?.entryId,current?.revisionId,startDate,endDate,current?.createdAt ?: now,now,sleepQuality,wakeQuality,dayForm,notes,events,intakes))
if (result is TrainlogRepository.SaveSleepDiaryResult.Saved) { revision++; selected = repository.listSleepDiary().firstOrNull { it.entryId == result.entryId } }
}) { Text(stringResource(R.string.sleep_save)) }
selected?.let { entry -> Button(onClick = {
repository.validateSleepDiary(entry.entryId, entry.revisionId,
OffsetDateTime.now().toString())
selected = repository.listSleepDiary().firstOrNull { it.entryId == entry.entryId }
}) { Text(stringResource(R.string.sleep_validate)) } }
selected?.let { entry -> Button(onClick = { repository.deleteSleepDiary(entry.entryId,entry.revisionId,OffsetDateTime.now().toString()); selected = null; revision++ }) { Text(stringResource(R.string.sleep_delete)) } }
}
}
}
}
}
@Composable
private fun TimeButton(value: String, change: (String) -> Unit) {
val context = LocalContext.current
val parsed = runCatching { OffsetDateTime.parse(value) }.getOrNull()
Button(onClick = {
val current = parsed ?: OffsetDateTime.now()
android.app.TimePickerDialog(context, { _, hour, minute ->
change(current.withHour(hour).withMinute(minute).withSecond(0).withNano(0).toString())
}, current.hour, current.minute, true).show()
}) {
Text(stringResource(R.string.sleep_choose_time) + " " + (parsed?.toLocalTime()?.toString() ?: "—"))
}
}
private val pointTypes = setOf(SleepEventType.BED_TIME, SleepEventType.FINAL_GET_UP,
SleepEventType.NIGHT_GET_UP, SleepEventType.DAYTIME_SLEEPINESS)
@Composable
private fun QualityRow(label: String, selected: SleepQuality?, change: (SleepQuality?) -> Unit) {
Column { Text(label); Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
SleepQuality.entries.forEach { value -> FilterChip(selected == value, { change(if (selected == value) null else value) }, { Text(value.wireValue) }) }
} }
}
@Composable
private fun eventLabel(type: SleepEventType): String = stringResource(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
})

View file

@ -29,11 +29,19 @@ import com.labfytools.trainlog.data.SyncRequestOutbox
import com.labfytools.trainlog.data.SyncRequestResult
import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.data.ForegroundGenerationResult
import com.labfytools.trainlog.data.BackgroundSyncRunLedger
import com.labfytools.trainlog.data.SyncConversationArbiter
import com.labfytools.trainlog.data.SyncConversationIntent
import com.labfytools.trainlog.data.SyncGenerationForegroundCoordinator
import com.labfytools.trainlog.data.BackgroundSyncSettings
import com.labfytools.trainlog.data.SyncBackgroundService
import com.labfytools.trainlog.data.DriveSyncSettings
import com.labfytools.trainlog.data.AiExportDriveSettings
import com.labfytools.trainlog.data.BluetoothSyncSettings
import com.labfytools.trainlog.data.bondedBluetoothDesktops
import com.labfytools.trainlog.data.hasBluetoothConnectPermission
import com.labfytools.trainlog.data.canonicalExchangeDirectory
import com.labfytools.trainlog.data.AndroidMtpPublicationVisibility
import com.labfytools.trainlog.data.MtpPublicationVisibility
import com.labfytools.trainlog.data.logDirectExchange
import com.labfytools.trainlog.data.directStoragePermissionIntent
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
@ -49,26 +57,35 @@ internal suspend fun publishBundleAndRequest(
repository: TrainlogRepository,
exporter: SyncExporter,
requestOutbox: SyncRequestOutbox,
visibility: MtpPublicationVisibility = com.labfytools.trainlog.data.ImmediateMtpPublicationVisibility,
explicitClaim: SyncConversationArbiter.ExplicitClaim =
SyncConversationArbiter.process.announceExplicit(),
terminalLedger: BackgroundSyncRunLedger? = null,
): SyncRequestResult = withContext(Dispatchers.IO) {
val optIn = java.io.File(canonicalExchangeDirectory(), "trainlog-sync-generation-opt-in-v1.json")
if (optIn.isFile) {
val enabled = try {
val value = org.json.JSONObject(optIn.readText())
value.keys().asSequence().toSet() == setOf("format", "version", "enabled") &&
value.optString("format") == "trainlog-sync-generation-opt-in" &&
value.opt("version") is Int && value.getInt("version") == 1 && value.opt("enabled") == true
} catch (_: Exception) { false }
if (!enabled) return@withContext SyncRequestResult.Error("Invalid generation synchronization opt-in.")
val opened = exporter.openStorageSnapshot()
if (opened is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Error) {
return@withContext SyncRequestResult.Error("prepare:${opened.message}")
}
val snapshot = (opened as com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Ready).snapshot
try {
val directory = canonicalExchangeDirectory()
val coordinator = SyncGenerationForegroundCoordinator(repository, visibility)
return@withContext when (
val result = SyncGenerationForegroundCoordinator(repository).run(
canonicalExchangeDirectory(),
val result = coordinator.run(
directory,
afterPeerPublication = {
when (val request = requestOutbox.requestSync(snapshot)) {
val opened = exporter.openStorageSnapshot()
if (opened is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Error) {
throw IllegalStateException(opened.message)
}
val snapshot =
(opened as com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Ready)
.snapshot
val requestIntent = requestOutbox.newIntent()
/* WHY: a strict V3 snapshot cannot represent causal
* tombstones, but the complete generation can. CONTRACT:
* publish and confirm the modern trigger before attempting
* optional legacy compatibility. INVARIANT: an old desktop
* sees this intent only after a fresh V3 bundle succeeds. */
when (
val request =
requestOutbox.publishFullGeneration(snapshot, requestIntent)
) {
is SyncRequestResult.Requested -> Unit
SyncRequestResult.Unsupported ->
throw IllegalStateException("Generation synchronization is unsupported.")
@ -76,13 +93,67 @@ internal suspend fun publishBundleAndRequest(
is SyncRequestResult.Completed ->
throw IllegalStateException("Unexpected completed request signal.")
}
when (exporter.exportMobileBundle(snapshot)) {
is SyncExportResult.Exported ->
when (requestOutbox.publishLegacy(snapshot, requestIntent)) {
is SyncRequestResult.Requested ->
logDirectExchange(
"trainlog-sync-request-v1.json",
"legacy_compatibility_published",
)
else ->
logDirectExchange(
"trainlog-sync-request-v1.json",
"legacy_compatibility_unavailable",
)
}
SyncExportResult.Unsupported,
is SyncExportResult.Error ->
logDirectExchange(
"trainlog-sync-request-v1.json",
"legacy_compatibility_unavailable",
)
}
},
intent = SyncConversationIntent.NEW_EXPLICIT,
explicitClaim = explicitClaim,
)
) {
is ForegroundGenerationResult.Completed -> SyncRequestResult.Completed(result.runId)
is ForegroundGenerationResult.Error -> SyncRequestResult.Error(result.message)
ForegroundGenerationResult.Busy ->
SyncRequestResult.Error("sync_interrupted")
is ForegroundGenerationResult.Superseded ->
SyncRequestResult.Error("sync_interrupted").also {
result.runId?.let { runId ->
terminalLedger?.recordTerminal(
runId,
coordinator.remoteEvidence(directory, runId),
)
}
}
is ForegroundGenerationResult.Cancelled ->
SyncRequestResult.Error("sync_interrupted")
is ForegroundGenerationResult.Failed ->
SyncRequestResult.Error(result.message).also {
result.runId?.let { runId ->
terminalLedger?.recordTerminal(
runId,
coordinator.remoteEvidence(directory, runId),
)
}
}
}
} finally {
explicitClaim.close()
}
}
/* Compatibility tooling keeps the standalone V3 publisher strict and
* available, but production Android triggers no longer select this path. */
internal suspend fun publishLegacyBundleAndRequest(
exporter: SyncExporter,
requestOutbox: SyncRequestOutbox,
): SyncRequestResult = withContext(Dispatchers.IO) {
logDirectExchange("SYNC_BUNDLE", "coordinator.export.begin")
when (val opened = exporter.openStorageSnapshot()) {
is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Error ->
@ -90,9 +161,7 @@ internal suspend fun publishBundleAndRequest(
is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Ready ->
when (val publication = exporter.exportMobileBundle(opened.snapshot)) {
SyncExportResult.Unsupported -> SyncRequestResult.Unsupported
is SyncExportResult.Error -> SyncRequestResult.Error(
"prepare:${publication.message}",
)
is SyncExportResult.Error -> SyncRequestResult.Error("prepare:${publication.message}")
is SyncExportResult.Exported -> {
logDirectExchange("SYNC_BUNDLE", "coordinator.export.success")
logDirectExchange("trainlog-sync-request-v1.json", "coordinator.request.begin")
@ -138,8 +207,11 @@ fun SyncScreen(
add(strings.resources.getQuantityString(R.plurals.catalog_reconciled, reconciled, reconciled))
skipped?.let { add(strings.resources.getQuantityString(R.plurals.catalog_present, it, it)) }
}.joinToString(", ")
fun visibleError(raw: String): String = raw.removePrefix("prepare:").let {
if (raw.startsWith("prepare:")) strings.getString(R.string.sync_prepare_error, it) else raw
fun visibleError(raw: String): String = when {
raw == "sync_interrupted" -> strings.getString(R.string.sync_interrupted)
raw.startsWith("prepare:") ->
strings.getString(R.string.sync_prepare_error, raw.removePrefix("prepare:"))
else -> raw
}
var status by
@ -171,8 +243,20 @@ fun SyncScreen(
var syncRunning by remember { mutableStateOf(false) }
val backgroundSettings = remember { BackgroundSyncSettings(context) }
var backgroundEnabled by remember { mutableStateOf(backgroundSettings.enabled) }
val driveSettings = remember { DriveSyncSettings(context) }
val driveSettings = remember { AiExportDriveSettings(context) }
var driveConnected by remember { mutableStateOf(driveSettings.connected) }
val bluetoothSettings = remember { BluetoothSyncSettings(context) }
var bluetoothPermission by remember {
mutableStateOf(hasBluetoothConnectPermission(context))
}
var bluetoothDesktop by remember {
mutableStateOf(bluetoothSettings.desktopAddress)
}
var bondedDesktops by remember {
mutableStateOf(
if (bluetoothPermission) bondedBluetoothDesktops(context) else emptyList()
)
}
val driveFolderLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree(),
@ -187,6 +271,23 @@ fun SyncScreen(
}
}
val bluetoothPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
bluetoothPermission = granted || Build.VERSION.SDK_INT < Build.VERSION_CODES.S
bondedDesktops =
if (bluetoothPermission) bondedBluetoothDesktops(context) else emptyList()
if (bluetoothPermission && backgroundSettings.enabled) {
SyncBackgroundService.start(context)
}
status =
if (bluetoothPermission) {
strings.getString(R.string.bluetooth_sync_permission_granted)
} else {
strings.getString(R.string.bluetooth_sync_permission_required)
}
}
val notificationPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
@ -384,10 +485,27 @@ fun SyncScreen(
* published from one prepared repository snapshot before
* that signal becomes visible; otherwise the PC can mix a
* fresh V3 file with stale or absent causal companions. */
/* WHY: Samsung may freeze the activity after USB mode
* changes or a permission surface opens. CONTRACT:
* announce explicit priority before waking the service.
* The service may keep Android alive, but cannot become
* conversation owner while this claim is pending. */
val explicitClaim = SyncConversationArbiter.process.announceExplicit()
if (backgroundSettings.enabled) SyncBackgroundService.start(context)
syncRunning = true
status = strings.getString(R.string.sync_running)
coroutineScope.launch {
try {
when (val result = publishBundleAndRequest(repository, exporter, requestOutbox)) {
when (
val result = publishBundleAndRequest(
repository,
exporter,
requestOutbox,
AndroidMtpPublicationVisibility(context),
explicitClaim,
BackgroundSyncRunLedger(context),
)
) {
is SyncRequestResult.Completed -> {
// The generation coordinator has already consumed the
// desktop return and published its durable ACK. Waiting
@ -411,6 +529,7 @@ fun SyncScreen(
}
}
} finally {
explicitClaim.close()
syncRunning = false
}
}
@ -486,6 +605,69 @@ fun SyncScreen(
)
}
TrainlogFrame(title = strings.getString(R.string.bluetooth_sync_title)) {
TrainlogInfo(
text = strings.getString(R.string.bluetooth_sync_description),
color =
if (bluetoothDesktop != null && bluetoothPermission) colors.success
else colors.warning,
)
if (!bluetoothPermission) {
TrainlogAction(
label = strings.getString(R.string.bluetooth_sync_permission),
description = strings.getString(R.string.bluetooth_sync_permission_required),
accent = colors.success,
onClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
bluetoothPermissionLauncher.launch(Manifest.permission.BLUETOOTH_CONNECT)
} else {
bluetoothPermission = true
bondedDesktops = bondedBluetoothDesktops(context)
}
},
)
} else {
if (bondedDesktops.isEmpty()) {
TrainlogInfo(
text = strings.getString(R.string.bluetooth_sync_no_desktop),
color = colors.warning,
)
}
bondedDesktops.forEach { desktop ->
TrainlogAction(
label = strings.getString(R.string.bluetooth_sync_select, desktop.name),
description = strings.getString(R.string.bluetooth_sync_address, desktop.address),
accent =
if (bluetoothDesktop == desktop.address) colors.success else colors.accent,
onClick = {
bluetoothSettings.selectDesktop(desktop.address)
bluetoothDesktop = desktop.address
status =
strings.getString(R.string.bluetooth_sync_selected, desktop.name)
if (backgroundSettings.enabled) {
SyncBackgroundService.start(context)
}
},
)
}
if (bluetoothDesktop != null) {
TrainlogAction(
label = strings.getString(R.string.bluetooth_sync_disconnect),
description =
strings.getString(
R.string.bluetooth_sync_address,
bluetoothDesktop ?: "",
),
accent = colors.warning,
onClick = {
bluetoothSettings.disconnect()
bluetoothDesktop = null
},
)
}
}
}
TrainlogFrame(title = strings.getString(R.string.background_sync_title)) {
TrainlogInfo(
text = strings.getString(R.string.background_sync_description),

View file

@ -93,7 +93,6 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
when (val route = navigation.route) {
AppRoute.Home -> HomeScreen(
activeDraft, draftMessage ?: (draftLoad as? ActiveDraftLoadResult.Error)?.let { localizedRepositoryMessage(localized, it.message) } ?: (draftLoad as? ActiveDraftLoadResult.Loaded)?.warning?.let { localizedRepositoryMessage(localized, it) },
repository.listSessions().firstOrNull(), repository.listLatestExerciseMaxima().firstOrNull(),
repository.getBodyZoneHomeOverview(),
::openManualSession,
{ zoneId ->
@ -102,12 +101,33 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
exerciseState.searchQuery.value = ""
open(AppRoute.Exercises)
},
{ open(AppRoute.Statistics) },
{ open(AppRoute.BodyMeasurements) }, { open(AppRoute.SessionDetail(it)) }, { open(AppRoute.Sync) },
{ 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.SessionEditor -> SessionScreen(
repository,
catalogRevision,
{ draftRevision++; back() },
{ open(AppRoute.ExerciseCreate(AppRoute.SessionEditor)) },
{ editingEntryId ->
appState.pendingSessionEquipmentEntryId = editingEntryId
open(AppRoute.EquipmentCreate(AppRoute.SessionEditor))
},
{ exportSnapshot(); draftRevision++ },
restoreEditingEntryId = appState.pendingSessionEquipmentEntryId,
onEditingContextRestored = {
appState.pendingSessionEquipmentEntryId = null
},
)
AppRoute.Sessions -> SessionsHub(activeDraft, pendingAiDraftCount, { open(AppRoute.SessionEditor) }, ::openManualSession,
{ open(AppRoute.AiSessionDrafts) }, { open(AppRoute.CompletedSessions) }, { open(AppRoute.Programs) })
AppRoute.SessionEditor -> SessionScreen(repository, catalogRevision, { draftRevision++; back() }, { open(AppRoute.ExerciseCreate(AppRoute.SessionEditor)) }, { exportSnapshot(); draftRevision++ })
AppRoute.SessionGenerator -> SessionGeneratorScreen(repository, generatorState, { back() }, { generatorState.abandon(); draftRevision++; open(AppRoute.SessionEditor) }, {
draftMessage = strings.getString(R.string.existing_draft_warning)
draftRevision++
@ -125,6 +145,10 @@ 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)
@ -140,12 +164,51 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
is AppRoute.ExerciseEdit -> ExerciseEditorRoute(repository, exerciseState, route.exerciseId, false) { exportSnapshot(); catalogRevision++; back() }
AppRoute.Equipment -> EquipmentScreen(repository, equipmentState, { open(AppRoute.EquipmentCreate(AppRoute.Equipment)) }, { open(AppRoute.EquipmentDetail(it)) })
is AppRoute.EquipmentDetail -> EquipmentDetailScreen(repository, route.equipmentId) { open(AppRoute.ExerciseDetail(it)) }
is AppRoute.EquipmentCreate -> EquipmentCreateScreen(repository, equipmentState) { exportSnapshot(); catalogRevision++; back() }
AppRoute.Statistics -> StatisticsDashboard(repository)
is AppRoute.EquipmentCreate -> EquipmentCreateScreen(repository, equipmentState) { equipment ->
/* WHY: equipment creation temporarily leaves the active-session editor.
* CONTRACT: returning from that catalogue route preserves the durable
* form and selects the exact stable ID that was just created.
* INVARIANT: no equipment is synthesized; this runs only after the
* explicit EquipmentCreateScreen action succeeded. */
if (route.caller == AppRoute.SessionEditor) {
when (val loaded = repository.loadActiveSessionDraft()) {
is ActiveDraftLoadResult.Loaded -> when (
val saved = repository.saveActiveSessionDraft(
loaded.draft.copy(
form = loaded.draft.form.copy(
editingExerciseIndex = appState.pendingSessionEquipmentEntryId
?.let { entryId ->
loaded.draft.exercises.indexOfFirst {
it.entryId == entryId
}.takeIf { it >= 0 }
},
editingEntryId = appState.pendingSessionEquipmentEntryId,
selectedEquipmentId = equipment.equipmentId,
),
),
)
) {
ActiveDraftMutationResult.Saved -> draftRevision++
is ActiveDraftMutationResult.Error -> {
draftMessage = localizedRepositoryMessage(localized, saved.message)
}
}
is ActiveDraftLoadResult.Error -> {
draftMessage = localizedRepositoryMessage(localized, loaded.message)
}
ActiveDraftLoadResult.None -> Unit
}
}
exportSnapshot()
catalogRevision++
back()
}
AppRoute.BodyMeasurements -> BodyScreen(repository, bodyState, { exportSnapshot() }, { back() })
AppRoute.SleepDiary -> SleepDiaryScreen(repository)
AppRoute.LatestMaxima -> LatestMaximaScreen(repository)
AppRoute.Sync -> SyncScreen(repository, inbox, exporter, requestOutbox, { exportSnapshot(); catalogRevision++ }, { back() })
AppRoute.Settings -> SettingsScreen(repository, inbox) { exportSnapshot(); catalogRevision++ }
AppRoute.Settings -> SettingsScreen(repository, inbox, { open(AppRoute.HeartRateDiagnostics) }) { exportSnapshot(); catalogRevision++ }
AppRoute.HeartRateDiagnostics -> HeartRateDiagnosticsScreen { back() }
}}
}

View file

@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicText
@ -37,6 +38,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -164,7 +166,7 @@ fun TrainlogAction(
.padding(vertical = 3.dp)
.heightIn(min = 48.dp)
.height(IntrinsicSize.Min)
.background(Color.Transparent)
.background(colors.surface, MaterialTheme.shapes.medium)
.clickable(onClick = onClick)
) {
Column(
@ -199,6 +201,46 @@ fun TrainlogAction(
}
}
/** Presentation-only surface for dense object summaries and repeated rows. */
@Composable
fun TrainlogCompactCard(
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
val colors = LocalTrainlogColors.current
val interaction = if (onClick == null) Modifier else Modifier.clickable(onClick = onClick)
Column(
modifier
.fillMaxWidth()
.background(colors.surface, MaterialTheme.shapes.medium)
.then(interaction)
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
content = content,
)
}
/** Small non-interactive metadata token; business state remains caller-owned. */
@Composable
fun TrainlogMetaBadge(
text: String,
modifier: Modifier = Modifier,
accent: Color? = null,
) {
val colors = LocalTrainlogColors.current
Text(
text = text,
modifier = modifier.background(colors.surfaceAlt, MaterialTheme.shapes.small)
.padding(horizontal = 8.dp, vertical = 4.dp),
color = accent ?: colors.text,
style = TrainlogTypography.small,
maxLines = 1,
)
}
enum class TrainlogButtonStyle { PRIMARY, SECONDARY, SUCCESS, DESTRUCTIVE, GHOST }
/** Compact pictogram action with a mandatory accessible name. */
@Composable
fun TrainlogIconAction(
@ -225,11 +267,13 @@ fun TrainlogDeleteButton(
contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
fullWidth: Boolean = true,
) {
val colors = LocalTrainlogColors.current
Button(
onClick = onClick,
modifier = modifier.fillMaxWidth().heightIn(min = 52.dp),
modifier = (if (fullWidth) modifier.fillMaxWidth() else modifier).heightIn(min = 48.dp),
shape = MaterialTheme.shapes.medium,
colors = ButtonDefaults.buttonColors(containerColor = colors.error),
) {
Icon(TrainlogIcons.DeleteOutline, contentDescription = contentDescription)
@ -240,9 +284,7 @@ fun TrainlogDeleteButton(
fun TrainlogPrimaryAction(label: String, description: String, onClick: () -> Unit) {
val colors = LocalTrainlogColors.current
Column(Modifier.fillMaxWidth().padding(bottom = 12.dp)) {
Button(onClick = onClick, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) {
Text(label)
}
TrainlogButton(label, onClick, Modifier.fillMaxWidth())
if (description.isNotBlank()) {
BasicText(
description,
@ -270,7 +312,7 @@ fun TrainlogActionTile(
Column(
modifier
.heightIn(min = 112.dp)
.background(colors.surface, RoundedCornerShape(12.dp))
.background(colors.surface, MaterialTheme.shapes.medium)
.clickable(onClick = onClick)
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
@ -291,13 +333,33 @@ fun TrainlogButton(
containerColor: Color? = null,
maxLines: Int = 1,
enabled: Boolean = true,
style: TrainlogButtonStyle = TrainlogButtonStyle.PRIMARY,
) {
val colors = LocalTrainlogColors.current
val resolvedContainer = containerColor ?: when (style) {
TrainlogButtonStyle.PRIMARY -> colors.accent
TrainlogButtonStyle.SECONDARY -> colors.surfaceAlt
TrainlogButtonStyle.SUCCESS -> colors.success
TrainlogButtonStyle.DESTRUCTIVE -> colors.error
TrainlogButtonStyle.GHOST -> Color.Transparent
}
val resolvedContent = when (style) {
TrainlogButtonStyle.PRIMARY, TrainlogButtonStyle.SUCCESS,
TrainlogButtonStyle.DESTRUCTIVE -> colors.crust
TrainlogButtonStyle.SECONDARY, TrainlogButtonStyle.GHOST -> colors.text
}
Button(
onClick = onClick,
enabled = enabled,
modifier = modifier.heightIn(min = 52.dp),
colors = containerColor?.let { ButtonDefaults.buttonColors(containerColor = it) }
?: ButtonDefaults.buttonColors(),
shape = MaterialTheme.shapes.medium,
border = if (style == TrainlogButtonStyle.GHOST) BorderStroke(1.dp, colors.surfaceAlt) else null,
colors = ButtonDefaults.buttonColors(
containerColor = resolvedContainer,
contentColor = resolvedContent,
disabledContainerColor = colors.surfaceAlt,
disabledContentColor = colors.muted,
),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp),
) {
icon?.let {
@ -334,12 +396,15 @@ fun TrainlogInputField(
label: String,
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
keyboardOptions: KeyboardOptions =
KeyboardOptions.Default,
testTag: String? = null,
singleLine: Boolean = true,
minLines: Int = 1,
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
suffix: String? = null,
compact: Boolean = false,
) {
val colors =
LocalTrainlogColors.current
@ -350,8 +415,7 @@ fun TrainlogInputField(
}
Column(
modifier =
Modifier.padding(bottom = 10.dp)
modifier = modifier.padding(bottom = 10.dp)
) {
BasicText(
text = label.uppercase(),
@ -395,7 +459,7 @@ fun TrainlogInputField(
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.heightIn(min = if (compact) 44.dp else 48.dp)
.onFocusChanged {
focused = it.isFocused
}
@ -406,11 +470,22 @@ fun TrainlogInputField(
Modifier.testTag(testTag)
}
)
.background(colors.surface)
.background(colors.surface, MaterialTheme.shapes.medium)
.padding(
horizontal = 12.dp,
vertical = 10.dp,
horizontal = if (compact) 10.dp else 12.dp,
vertical = if (compact) 8.dp else 10.dp,
),
decorationBox = { innerTextField ->
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Box(Modifier.weight(1f)) { innerTextField() }
suffix?.let {
BasicText(it, style = TrainlogTypography.small.copy(color = colors.muted))
}
}
},
)
Box(

View file

@ -9,12 +9,15 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.darkColorScheme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.dp
data class TrainlogColors(
val background: Color,
@ -110,6 +113,19 @@ fun TrainlogTheme(
error = TrainlogDarkColors.error,
)
CompositionLocalProvider(LocalTrainlogColors provides TrainlogDarkColors) {
MaterialTheme(colorScheme = scheme, content = content)
/* INVARIANT: all Material controls share the same restrained corner
* language. Screen code may change size, but must not reintroduce the
* former pill-shaped action hierarchy. */
MaterialTheme(
colorScheme = scheme,
shapes = Shapes(
extraSmall = RoundedCornerShape(6.dp),
small = RoundedCornerShape(8.dp),
medium = RoundedCornerShape(10.dp),
large = RoundedCornerShape(12.dp),
extraLarge = RoundedCornerShape(14.dp),
),
content = content,
)
}
}

View file

@ -8,6 +8,31 @@
<string name="nav_statistics">Statistics</string>
<string name="nav_sync">Synchronization</string>
<string name="nav_settings">Settings</string>
<string name="nav_sleep">Sleep</string>
<string name="sleep_current_night">Current night</string>
<string name="sleep_morning">Morning</string>
<string name="sleep_day">Day</string>
<string name="sleep_bed_time">Bedtime</string>
<string name="sleep_final_get_up">Final get-up</string>
<string name="sleep_night_get_up">Night get-up</string>
<string name="sleep_sleep">Sleep</string>
<string name="sleep_nap">Nap</string>
<string name="sleep_long_awake">Long awakening</string>
<string name="sleep_half_sleep">Half-sleep</string>
<string name="sleep_sleepiness">Sleepiness</string>
<string name="sleep_quality">Sleep quality</string>
<string name="sleep_wake_quality">Wake quality</string>
<string name="sleep_day_form">Day form</string>
<string name="sleep_notes">Treatment and notes</string>
<string name="sleep_add_event">Add event</string>
<string name="sleep_save">Save</string>
<string name="sleep_validate">Validate day</string>
<string name="sleep_status_draft">Draft</string>
<string name="sleep_status_ready">Ready to sync</string>
<string name="sleep_status_synchronized">Synchronized</string>
<string name="sleep_status_modified">Modified since synchronization</string>
<string name="sleep_delete">Delete</string>
<string name="sleep_choose_time">Choose time</string>
<string name="route_session_editor">Current session</string>
<string name="route_session_generator">Plan a session</string>
<string name="route_ai_drafts">Drafts</string>
@ -94,6 +119,7 @@
<string name="sync_action">Synchronize</string>
<string name="sync_snapshot_automatic">The Android snapshot is maintained automatically.</string>
<string name="sync_running">Synchronization in progress…</string>
<string name="sync_interrupted">Synchronization interrupted. You can start a new attempt.</string>
<string name="sync_now">Synchronize now</string>
<string name="background_sync_title">Background synchronization</string>
<string name="background_sync_description">Allows Trainlog to receive PC synchronizations without opening the app.</string>
@ -334,7 +360,8 @@
<plurals name="repetition_count"><item quantity="one">%d repetition</item><item quantity="other">%d repetitions</item></plurals>
<string name="start">Start</string><string name="start_proposal_description">Copy this proposal into the current session.</string>
<string name="manual_preparation">Manual preparation</string>
<string name="start_preparation_description">Start this preparation without replacing an active session.</string>
<string name="start_preparation_description">Creates a protected active session.</string>
<string name="draft_rest_short">rest %1$d s</string>
<string name="draft_already_active">A session is already in progress. The imported draft was preserved.</string>
<string name="delete_draft_detail">Draft “%1$s” will be deleted.\nThis action cannot be undone.</string>
<string name="training">Training</string><string name="resume_current_session">Resume current session</string>
@ -390,7 +417,7 @@
<string name="regenerate_description">Run again with the same inputs and reference instant.</string><string name="accept_actual_description">Create the normal draft without prefilling performed sets.</string><string name="acknowledge_exposure_first">Confirm the recent-exposure warning first.</string>
<string name="reason_observed_dose">observed repeated dose</string><string name="reason_max_no_prescription">observed maximum without numeric prescription</string><string name="reason_user_max">user-selected MAX percentage</string><string name="reason_max_unavailable">compatible MAX unavailable</string><string name="reason_assistance_omitted">assistance load omitted</string><string name="reason_load_absent">numeric load absent</string><string name="reason_manual_load">manually entered load</string><string name="reason_preferred">preferred exercise</string><string name="reason_primary_zone">requested primary zone</string><string name="reason_secondary_zone">requested secondary zone</string><string name="reason_distinct_movement">distinct movement</string><string name="reason_recent_exercise">same exercise observed recently</string><string name="reason_recent_pattern">similar movement observed recently</string>
<string name="shortage_upper">missing upper region</string><string name="shortage_lower">missing lower region</string><string name="shortage_core">missing core</string><string name="shortage_push">missing upper-body push</string><string name="shortage_pull">missing upper-body pull</string><string name="shortage_lower_extension">missing lower-body extension</string><string name="shortage_lower_flexion">missing lower-body flexion</string><string name="shortage_exercises">fewer distinct exercises available</string>
<string name="create_equipment">Create equipment</string><string name="create_personal_machine">Add a personal machine.</string><string name="search_equipment">Search by name, label, or alias</string>
<string name="create_equipment">Create equipment</string><string name="create_personal_machine">Add a personal machine.</string><string name="create_equipment_from_session">Create it in the catalogue, then return to this exercise.</string><string name="search_equipment">Search by name, label, or alias</string>
<string name="unknown_reference">Unknown reference · %1$s</string><string name="definition">Definition</string><string name="personal">Personal</string><string name="provided">Provided</string><string name="label_value">Label: %1$s</string>
<string name="confidence_value">%1$s confidence</string><string name="verified_sources">Verified sources: %1$d</string><string name="personal_equipment">Personal equipment</string><string name="create">Create</string>
<string name="create_machine_description">Create a personal machine with the current semantics.</string><string name="equipment_name_invalid">Enter a name of 1 to 120 characters.</string><string name="equipment_name_exists">Equipment already has this name.</string><string name="creation_failed">Creation failed: %1$s.</string>
@ -446,4 +473,148 @@
<string name="settings_backup_complete">Complete backup exported.</string>
<string name="settings_backup_failed">Backup failed; any private staged copy was retained.</string>
<string name="settings_restore_failed">The backup is invalid or could not be restored.</string>
<string name="sleep_medications">Medications</string>
<string name="sleep_add_medication">Add medication</string>
<string name="sleep_add_intake">Add intake</string>
<string name="sleep_dose">Dose</string>
<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">30-minute awakening 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

@ -8,6 +8,31 @@
<string name="nav_statistics">Statistiques</string>
<string name="nav_sync">Synchronisation</string>
<string name="nav_settings">Paramètres</string>
<string name="nav_sleep">Sommeil</string>
<string name="sleep_current_night">Nuit actuelle</string>
<string name="sleep_morning">Matin</string>
<string name="sleep_day">Journée</string>
<string name="sleep_bed_time">Mise au lit</string>
<string name="sleep_final_get_up">Lever</string>
<string name="sleep_night_get_up">Lever nocturne</string>
<string name="sleep_sleep">Sommeil</string>
<string name="sleep_nap">Sieste</string>
<string name="sleep_long_awake">Long réveil</string>
<string name="sleep_half_sleep">Demi-sommeil</string>
<string name="sleep_sleepiness">Somnolence</string>
<string name="sleep_quality">Qualité du sommeil</string>
<string name="sleep_wake_quality">Qualité du réveil</string>
<string name="sleep_day_form">Forme de la journée</string>
<string name="sleep_notes">Traitement et remarques</string>
<string name="sleep_add_event">Ajouter un événement</string>
<string name="sleep_save">Enregistrer</string>
<string name="sleep_validate">Valider la journée</string>
<string name="sleep_status_draft">Brouillon</string>
<string name="sleep_status_ready">Prête à synchroniser</string>
<string name="sleep_status_synchronized">Synchronisée</string>
<string name="sleep_status_modified">Modifiée depuis la synchronisation</string>
<string name="sleep_delete">Supprimer</string>
<string name="sleep_choose_time">Choisir l’heure</string>
<string name="route_session_editor">Séance en cours</string>
<string name="route_session_generator">Programmer une séance</string>
<string name="route_ai_drafts">Brouillons</string>
@ -94,6 +119,7 @@
<string name="sync_action">Synchroniser</string>
<string name="sync_snapshot_automatic">Le snapshot Android est maintenu automatiquement.</string>
<string name="sync_running">Synchronisation en cours…</string>
<string name="sync_interrupted">Synchronisation interrompue. Une nouvelle tentative peut être lancée.</string>
<string name="sync_now">Synchroniser maintenant</string>
<string name="background_sync_title">Synchronisation en arrière-plan</string>
<string name="background_sync_description">Permet à Trainlog de recevoir les synchronisations du PC sans ouvrir l’application.</string>
@ -103,13 +129,23 @@
<string name="background_sync_notification_title">Trainlog est prêt à synchroniser</string>
<string name="background_sync_notification_text">Le service attend une demande du PC.</string>
<string name="background_sync_notification_required">Autorisez les notifications pour activer la synchronisation en arrière-plan.</string>
<string name="drive_sync_title">Google Drive privé</string>
<string name="drive_sync_description">Sélectionnez le dossier privé Trainlog/Sync/v1. Trainlog n’accède qu’à ce dossier et n’y copie jamais SQLite.</string>
<string name="drive_sync_connect">Connecter Google Drive</string>
<string name="drive_sync_disconnect">Déconnecter Google Drive</string>
<string name="drive_sync_latency">La vérification en arrière-plan est bornée par Android et peut prendre au moins 15 minutes.</string>
<string name="drive_sync_connected">Dossier Drive connecté. Une vérification est planifiée.</string>
<string name="drive_sync_connection_failed">Impossible de conserver l’accès au dossier Drive.</string>
<string name="bluetooth_sync_title">Bluetooth automatique</string>
<string name="bluetooth_sync_description">Trainlog se connecte automatiquement au PC appairé dès qu’il est à portée. MTP reste disponible en secours.</string>
<string name="bluetooth_sync_permission">Autoriser l’accès Bluetooth</string>
<string name="bluetooth_sync_permission_required">Autorisation Bluetooth requise pour la connexion automatique.</string>
<string name="bluetooth_sync_permission_granted">Bluetooth autorisé. Sélectionnez le PC appairé.</string>
<string name="bluetooth_sync_no_desktop">Aucun ordinateur appairé détecté.</string>
<string name="bluetooth_sync_select">Utiliser %1$s</string>
<string name="bluetooth_sync_selected">PC Bluetooth sélectionné : %1$s.</string>
<string name="bluetooth_sync_disconnect">Oublier le PC Bluetooth</string>
<string name="bluetooth_sync_address">%1$s</string>
<string name="drive_sync_title">Export IA Google Drive</string>
<string name="drive_sync_description">Sélectionnez une fois le dossier Trainlog/AI. Après chaque synchronisation, Android y publie automatiquement trainlog_ai_export_v1.json.</string>
<string name="drive_sync_connect">Choisir Trainlog/AI</string>
<string name="drive_sync_disconnect">Déconnecter l’export IA Drive</string>
<string name="drive_sync_latency">L’échec de Drive ne bloque jamais la synchronisation. Android conserve l’export local et réessaie automatiquement.</string>
<string name="drive_sync_connected">Dossier Trainlog/AI connecté. L’export sera publié automatiquement.</string>
<string name="drive_sync_connection_failed">Impossible de conserver l’accès au dossier Trainlog/AI.</string>
<string name="sync_flow_description">Android → PC puis PC → Android, en une seule opération.</string>
<string name="sync_authorize_first">Accès fichiers requis. Autorisez d’abord Documents/Trainlog.</string>
<string name="sync_request_sent">Demande envoyée · attente du PC…</string>
@ -334,7 +370,8 @@
<plurals name="repetition_count"><item quantity="one">%d répétition</item><item quantity="other">%d répétitions</item></plurals>
<string name="start">Démarrer</string><string name="start_proposal_description">Copier cette proposition dans la séance en cours.</string>
<string name="manual_preparation">Préparation manuelle</string>
<string name="start_preparation_description">Démarrer cette préparation sans écraser une séance en cours.</string>
<string name="start_preparation_description">Crée une séance en cours protégée.</string>
<string name="draft_rest_short">repos %1$d s</string>
<string name="draft_already_active">Une séance est déjà en cours. Le brouillon importé a été conservé.</string>
<string name="delete_draft_detail">Le brouillon « %1$s » sera supprimé.\nCette action est irréversible.</string>
<string name="training">Entraînement</string><string name="resume_current_session">Reprendre la séance en cours</string>
@ -390,7 +427,7 @@
<string name="regenerate_description">Relancer les mêmes entrées et le même instant de référence.</string><string name="accept_actual_description">Créer le brouillon normal sans préremplir les séries réalisées.</string><string name="acknowledge_exposure_first">Confirmez d’abord l’avertissement d’exposition récente.</string>
<string name="reason_observed_dose">dose répétée observée</string><string name="reason_max_no_prescription">maximum observé sans prescription numérique</string><string name="reason_user_max">pourcentage de MAX choisi par l’utilisateur</string><string name="reason_max_unavailable">MAX compatible indisponible</string><string name="reason_assistance_omitted">charge d’assistance omise</string><string name="reason_load_absent">charge numérique absente</string><string name="reason_manual_load">charge saisie manuellement</string><string name="reason_preferred">exercice préféré</string><string name="reason_primary_zone">zone principale demandée</string><string name="reason_secondary_zone">zone secondaire demandée</string><string name="reason_distinct_movement">mouvement distinct</string><string name="reason_recent_exercise">même exercice observé récemment</string><string name="reason_recent_pattern">mouvement similaire observé récemment</string>
<string name="shortage_upper">région supérieure manquante</string><string name="shortage_lower">région inférieure manquante</string><string name="shortage_core">tronc manquant</string><string name="shortage_push">poussée du haut du corps manquante</string><string name="shortage_pull">tirage du haut du corps manquant</string><string name="shortage_lower_extension">extension du bas du corps manquante</string><string name="shortage_lower_flexion">flexion du bas du corps manquante</string><string name="shortage_exercises">moins d’exercices distincts disponibles</string>
<string name="create_equipment">Créer un équipement</string><string name="create_personal_machine">Ajouter une machine personnelle.</string><string name="search_equipment">Rechercher par nom, étiquette ou alias</string>
<string name="create_equipment">Créer un équipement</string><string name="create_personal_machine">Ajouter une machine personnelle.</string><string name="create_equipment_from_session">Créer dans le catalogue puis revenir à cet exercice.</string><string name="search_equipment">Rechercher par nom, étiquette ou alias</string>
<string name="unknown_reference">Référence inconnue · %1$s</string><string name="definition">Définition</string><string name="personal">Personnel</string><string name="provided">Fourni</string><string name="label_value">Étiquette : %1$s</string>
<string name="confidence_value">Confiance %1$s</string><string name="verified_sources">Sources vérifiées : %1$d</string><string name="personal_equipment">Équipement personnel</string><string name="create">Créer</string>
<string name="create_machine_description">Créer une machine personnelle avec la sémantique actuelle.</string><string name="equipment_name_invalid">Saisissez un nom de 1 à 120 caractères.</string><string name="equipment_name_exists">Un équipement porte déjà ce nom.</string><string name="creation_failed">Création impossible : %1$s.</string>
@ -446,4 +483,148 @@
<string name="settings_backup_complete">Sauvegarde complète exportée.</string>
<string name="settings_backup_failed">Échec de la sauvegarde ; toute copie privée créée est conservée.</string>
<string name="settings_restore_failed">Sauvegarde invalide ou restauration impossible.</string>
<string name="sleep_medications">Médicaments</string>
<string name="sleep_add_medication">Ajouter un médicament</string>
<string name="sleep_add_intake">Ajouter une prise</string>
<string name="sleep_dose">Dosage</string>
<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 de 30 minutes 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

@ -12,6 +12,8 @@ import com.labfytools.trainlog.model.TrackingMode
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.util.UUID
import java.util.zip.ZipInputStream
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@ -24,6 +26,30 @@ import org.robolectric.annotation.Config
class AndroidBackupServiceTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun manifestUsesInjectedCanonicalProductVersion() {
val name = "backup-version-${UUID.randomUUID()}.db"
val repository = TrainlogRepository(context, name)
try {
val output = ByteArrayOutputStream()
val service = AndroidBackupService(context, "0.1.3-test")
assertTrue(service.create(repository, output) is AndroidBackupResult.Success)
var manifest: JSONObject? = null
ZipInputStream(ByteArrayInputStream(output.toByteArray())).use { zip ->
while (true) {
val entry = zip.nextEntry ?: break
if (entry.name == "manifest.json") {
manifest = JSONObject(zip.readBytes().toString(Charsets.UTF_8))
}
}
}
assertEquals("0.1.3-test", checkNotNull(manifest).getString("product_version"))
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun completeBackupRestoresDraftPeerLedgersAndPreferences() {
val name = "backup-${UUID.randomUUID()}.db"

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(25, scalar(db, "PRAGMA user_version"))
assertEquals(33, 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(25, scalar(db, "PRAGMA user_version"))
assertEquals(33, 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(25, scalar(db, "PRAGMA user_version"))
assertEquals(33, 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(25, scalar(db, "PRAGMA user_version"))
assertEquals(33, 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

@ -0,0 +1,50 @@
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.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class AndroidV26SleepMigrationTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun v25AddsEmptySleepDomainAndReopens() {
val name = "sleep-v25-${UUID.randomUUID()}.db"
TrainlogRepository(context, name).useForTest { assertTrueEmpty(it) }
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null, SQLiteDatabase.OPEN_READWRITE).use { db ->
db.execSQL("DROP TABLE sleep_diary_events")
db.execSQL("DROP TABLE sleep_diary_revisions")
db.execSQL("DROP TABLE sleep_diary_entries")
db.version = 25
}
var repository = TrainlogRepository(context, name)
try {
assertTrueEmpty(repository)
repository.close()
repository = TrainlogRepository(context, name)
assertTrueEmpty(repository)
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
).use { assertEquals(33, it.version) }
} finally {
repository.close()
context.deleteDatabase(name)
}
}
private fun assertTrueEmpty(repository: TrainlogRepository) = assertEquals(emptyList<Any>(), repository.listSleepDiary())
private fun TrainlogRepository.useForTest(block: (TrainlogRepository) -> Unit) {
try { block(this) } finally { close() }
}
}

View file

@ -0,0 +1,54 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import java.util.UUID
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class AndroidV27HeartRateMigrationTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun v26AddsEmptyHeartRateDomainAndReopens() {
val name = "heart-rate-v26-${UUID.randomUUID()}.db"
TrainlogRepository(context, name).also {
JSONObject(it.buildHeartRateV1Json())
it.close()
}
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READWRITE,
).use { db ->
db.execSQL("DROP TABLE heart_rate_generation_captures")
db.execSQL("DROP TABLE heart_rate_rr_intervals")
db.execSQL("DROP TABLE heart_rate_samples")
db.execSQL("DROP TABLE heart_rate_captures")
db.version = 26
}
var repository = TrainlogRepository(context, name)
try {
assertEquals(0, JSONObject(repository.buildHeartRateV1Json())
.getJSONArray("captures").length())
repository.close()
repository = TrainlogRepository(context, name)
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
).use { assertEquals(33, it.version) }
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

@ -0,0 +1,105 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.TrackingMode
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class AndroidV28SessionTimelineMigrationTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun v27SeedsOnlyAuthoritativeActiveSessionBoundary() {
val name = "timeline-v27-${UUID.randomUUID()}.db"
var repository = TrainlogRepository(context, name)
try {
assertEquals(
ActiveDraftMutationResult.Saved,
repository.startActiveSessionDraft(),
)
val exercise =
(repository.createExercise(
NewExerciseProfile(
"Timeline migration exercise",
RecordingMode.SETS,
TrackingMode.REPS,
0,
),
) as CreateExerciseResult.Created).exercise
val loaded =
repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
val sessionId = checkNotNull(loaded.draft.sessionId)
val startedAt = checkNotNull(loaded.draft.startedAt)
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
loaded.draft.copy(
exercises =
listOf(
SessionExerciseDraft(
exercise = exercise,
sets = listOf(SessionSetDraft(reps = 1)),
),
),
),
),
)
repository.close()
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READWRITE,
).use { db ->
db.execSQL("DROP TABLE session_timeline_generation_sessions")
db.execSQL("DROP TABLE session_timeline_exercises")
db.execSQL("DROP TABLE session_timeline_sessions")
db.version = 27
}
repository = TrainlogRepository(context, name)
assertNotNull(repository.activeSessionTimelineContext())
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
).use { db ->
assertEquals(33, 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

@ -0,0 +1,108 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionDraft
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.TrackingMode
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class AndroidV29CardioSessionMigrationTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun v28BackfillsCanonicalSessionKindFromFrozenLegacyType() {
val name = "cardio-v28-" + UUID.randomUUID() + ".db"
var repository = TrainlogRepository(context, name)
try {
val exercise =
(repository.createExercise(
NewExerciseProfile(
"Cardio migration exercise",
RecordingMode.SETS,
TrackingMode.REPS,
0,
),
) as CreateExerciseResult.Created).exercise
assertTrue(
repository.saveSession(
SessionDraft(
listOf(
SessionExerciseDraft(
exercise = exercise,
sets = listOf(SessionSetDraft(reps = 5)),
),
),
),
) is SaveSessionResult.Saved,
)
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
ActiveSessionDraft(
exercises =
listOf(
SessionExerciseDraft(
exercise = exercise,
sets = listOf(SessionSetDraft(reps = 6)),
),
),
),
),
)
repository.close()
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READWRITE,
).use { db ->
db.execSQL("UPDATE sessions SET session_kind='cardio'")
db.execSQL("UPDATE active_session_draft SET session_kind='cardio'")
db.version = 28
}
repository = TrainlogRepository(context, name)
assertEquals(1, repository.listSessions().size)
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
).use { db ->
assertEquals(33, 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

@ -0,0 +1,69 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class AndroidV30CardioCalibrationMigrationTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun v29AddsEmptyCalibrationDomainAndReservedExercise() {
val name = "calibration-v29-" + UUID.randomUUID() + ".db"
var repository = TrainlogRepository(context, name)
try {
repository.listExercises()
repository.close()
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READWRITE,
).use { db ->
db.execSQL("DROP TABLE cardio_calibration_generation")
db.execSQL("DROP TABLE cardio_calibration_recovery")
db.execSQL("DROP TABLE cardio_calibrations")
db.version = 29
}
repository = TrainlogRepository(context, name)
assertTrue(repository.listExercises().none { it.name == "Calibration cardio" })
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
).use { db ->
assertEquals(33, 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

@ -0,0 +1,65 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class AndroidV31CardioGuidanceMigrationTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun v30AddsEmptyGuidanceDomainAndReopens() {
val name = "guidance-v30-" + UUID.randomUUID() + ".db"
var repository = TrainlogRepository(context, name)
try {
repository.listSessions()
repository.close()
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READWRITE,
).use { db ->
db.execSQL("DROP TABLE cardio_guidance_generation")
db.execSQL("DROP TABLE cardio_guidance_events")
db.execSQL("DROP TABLE cardio_guidance_phases")
db.execSQL("DROP TABLE cardio_guidance_runs")
db.version = 30
}
repository = TrainlogRepository(context, name)
assertTrue(repository.activeCardioGuidancePhase() == null)
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
).use { db ->
assertEquals(33, 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

@ -0,0 +1,96 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.SleepMedication
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class AndroidV32SleepQuantityMigrationTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun v31IntakesHydrateToOneAndQuickPublicationStartsEmpty() {
val name = "sleep-quantity-v31-${UUID.randomUUID()}.db"
val initial = TrainlogRepository(context, name)
try {
val saved = initial.saveSleepMedication(
SleepMedication(
"", "", "2026-09-23T20:00:00+02:00", "2026-09-23T20:00:00+02:00",
"Migration medication", 75.0, "mg", "", "", true,
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
initial.quickSleepMedication(saved.entryId, "2026-09-23T21:00:00+02:00")
} finally {
initial.close()
}
val path = context.getDatabasePath(name).path
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READWRITE).use { db ->
db.execSQL("PRAGMA foreign_keys=OFF")
db.execSQL("DROP TABLE sleep_diary_quick_publication_state")
db.execSQL("ALTER TABLE sleep_medication_intakes RENAME TO sleep_medication_intakes_v32")
db.execSQL(
"""CREATE TABLE sleep_medication_intakes(
revision_id TEXT NOT NULL, intake_id TEXT NOT NULL,
medication_id TEXT NOT NULL, medication_name TEXT NOT NULL,
taken_at TEXT NOT NULL, dose_value REAL, dose_unit TEXT,
note TEXT, created_at TEXT NOT NULL,
PRIMARY KEY(revision_id,intake_id)
)""".trimIndent(),
)
db.execSQL(
"INSERT INTO sleep_medication_intakes SELECT revision_id,intake_id,medication_id," +
"medication_name,taken_at,dose_value,dose_unit,note,created_at " +
"FROM sleep_medication_intakes_v32",
)
db.execSQL("DROP TABLE sleep_medication_intakes_v32")
db.execSQL("PRAGMA user_version=31")
}
val migrated = TrainlogRepository(context, name)
migrated.listSleepDiary()
migrated.close()
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(33, 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

@ -0,0 +1,38 @@
package com.labfytools.trainlog.data
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class BluetoothSyncTransportContractTest {
@Test
fun connectedPeerStartsAutoSyncWithoutUserActionButHonorsCooldown() {
assertTrue(
shouldPublishBluetoothArrivalRequest(
autoSyncEnabled = true,
cooldownReady = true,
)
)
assertFalse(
shouldPublishBluetoothArrivalRequest(
autoSyncEnabled = false,
cooldownReady = true,
)
)
assertFalse(
shouldPublishBluetoothArrivalRequest(
autoSyncEnabled = true,
cooldownReady = false,
)
)
}
@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(25, cursor.getInt(0))
assertTrue(cursor.moveToFirst()); assertEquals(33, 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(25, it.version) }
.use { assertEquals(31, it.version) }
} finally {
repository.close()
context.deleteDatabase(name)

View file

@ -0,0 +1,187 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.CardioCalibrationPhase
import com.labfytools.trainlog.model.SessionType
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class CardioCalibrationRepositoryTest {
private val context: Context = ApplicationProvider.getApplicationContext()
private fun measurement(bpm: Int) =
ParsedHeartRateMeasurement(
bpm = bpm,
sensorContactDetected = null,
energyExpended = null,
rrIntervals1024 = listOf(1024),
)
@Test
fun calibrationTracksObservedPeakRecoveryAndFinalizesRealCardioSession() {
val name = "calibration-" + UUID.randomUUID() + ".db"
val repository = TrainlogRepository(context, name)
try {
val started =
repository.startCardioCalibration("2026-09-23T10:00:00+02:00")
as TrainlogRepository.CardioCalibrationResult.Applied
assertEquals(CardioCalibrationPhase.WARMUP, started.profile.phase)
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T10:00:10+02:00",
measurement(80),
)
assertEquals(
CardioCalibrationPhase.PROGRESSIVE,
(repository.advanceCardioCalibrationPhase("2026-09-23T10:01:00+02:00")
as TrainlogRepository.CardioCalibrationResult.Applied).profile.phase,
)
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T10:01:20+02:00",
measurement(112),
)
assertEquals(
CardioCalibrationPhase.HIGH_EFFORT,
(repository.advanceCardioCalibrationPhase("2026-09-23T10:02:00+02:00")
as TrainlogRepository.CardioCalibrationResult.Applied).profile.phase,
)
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T10:02:30+02:00",
measurement(150),
)
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T10:03:00+02:00",
measurement(160),
)
val recovery =
repository.markCardioCalibrationEffortEnd("2026-09-23T10:03:01+02:00")
as TrainlogRepository.CardioCalibrationResult.Applied
assertEquals(CardioCalibrationPhase.RECOVERY, recovery.profile.phase)
assertEquals(160, recovery.profile.observedPeakBpm)
assertTrue(recovery.profile.heartRateCaptureId != null)
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T10:04:01+02:00",
measurement(140),
)
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T10:05:02+02:00",
measurement(120),
)
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T10:06:01+02:00",
measurement(100),
)
val completed =
repository.finishCardioCalibration("2026-09-23T10:06:10+02:00")
as TrainlogRepository.CardioCalibrationResult.Applied
assertEquals(CardioCalibrationPhase.COMPLETED, completed.profile.phase)
assertEquals(
listOf(60 to 140, 120 to 120, 180 to 100),
completed.profile.recovery.map { it.targetOffsetSeconds to it.bpm },
)
assertNull(repository.activeCardioCalibration())
assertNull(repository.activeHeartRateCapture())
val session = repository.listSessions().single()
assertEquals(SessionType.CARDIO, session.sessionType)
val detail = checkNotNull(repository.getSessionDetail(session.sessionId))
assertEquals(370, detail.exercises.single().continuousDurationSeconds)
assertEquals(1, repository.listCompletedCardioCalibrations().size)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun recoveryDoesNotInventLateReferencePointsAndCannotFinishTooEarly() {
val name = "calibration-recovery-" + UUID.randomUUID() + ".db"
val repository = TrainlogRepository(context, name)
try {
repository.startCardioCalibration("2026-09-23T12:00:00+02:00")
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T12:00:10+02:00",
measurement(80),
)
repository.advanceCardioCalibrationPhase("2026-09-23T12:01:00+02:00")
repository.advanceCardioCalibrationPhase("2026-09-23T12:02:00+02:00")
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T12:02:30+02:00",
measurement(155),
)
repository.markCardioCalibrationEffortEnd("2026-09-23T12:03:00+02:00")
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T12:05:05+02:00",
measurement(118),
)
val duringRecovery = checkNotNull(repository.activeCardioCalibration())
assertEquals(
listOf(120 to 118),
duringRecovery.recovery.map { it.targetOffsetSeconds to it.bpm },
)
val tooEarly =
repository.finishCardioCalibration("2026-09-23T12:05:59+02:00")
assertTrue(tooEarly is TrainlogRepository.CardioCalibrationResult.Invalid)
assertTrue(repository.activeCardioCalibration() != null)
repository.recordLiveHeartRateForActiveSession(
"CYCPLUS H2",
"2026-09-23T12:06:03+02:00",
measurement(105),
)
val completed =
repository.finishCardioCalibration("2026-09-23T12:06:05+02:00")
as TrainlogRepository.CardioCalibrationResult.Applied
assertEquals(
listOf(120 to 118, 180 to 105),
completed.profile.recovery.map { it.targetOffsetSeconds to it.bpm },
)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun abortBeforeEffortEndNeverCreatesCompletedReference() {
val name = "calibration-abort-" + UUID.randomUUID() + ".db"
val repository = TrainlogRepository(context, name)
try {
repository.startCardioCalibration("2026-09-23T11:00:00+02:00")
val aborted =
repository.abortCardioCalibration("2026-09-23T11:01:00+02:00")
as TrainlogRepository.CardioCalibrationResult.Applied
assertEquals(CardioCalibrationPhase.ABORTED, aborted.profile.phase)
assertNull(aborted.profile.effortEndAt)
assertTrue(repository.loadActiveSessionDraft() is ActiveDraftLoadResult.None)
assertTrue(repository.listCompletedCardioCalibrations().isEmpty())
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

@ -0,0 +1,147 @@
package com.labfytools.trainlog.data
import com.labfytools.trainlog.model.CardioGuidanceInstruction
import com.labfytools.trainlog.model.CardioTargetDefinition
import com.labfytools.trainlog.model.CardioTargetSnapshot
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class CardioGuidanceEngineTest {
private val target = CardioTargetSnapshot(120, 140)
@Test
fun absoluteAndCalibrationTargetsResolveToImmutableBpmSnapshot() {
assertEquals(
CardioTargetSnapshot(120, 140),
CardioTargetResolver.resolve(CardioTargetDefinition.AbsoluteBpm(120, 140)),
)
assertEquals(
CardioTargetSnapshot(
minimumBpm = 100,
maximumBpm = 125,
calibrationId = "cal_11111111-1111-4111-8111-111111111111",
calibrationObservedPeakBpm = 167,
minimumPercent = 60,
maximumPercent = 75,
),
CardioTargetResolver.resolve(
CardioTargetDefinition.CalibrationPercent(
calibrationId = "cal_11111111-1111-4111-8111-111111111111",
observedPeakBpm = 167,
minimumPercent = 60,
maximumPercent = 75,
),
),
)
}
@Test
fun invalidTargetDefinitionsAreRejectedInsteadOfClamped() {
assertNull(CardioTargetResolver.resolve(CardioTargetDefinition.AbsoluteBpm(140, 120)))
assertNull(
CardioTargetResolver.resolve(
CardioTargetDefinition.CalibrationPercent("cal_x", 0, 60, 70),
),
)
assertNull(
CardioTargetResolver.resolve(
CardioTargetDefinition.CalibrationPercent("cal_x", 160, 80, 60),
),
)
}
@Test
fun lowBpmMustPersistBeforeAccelerateInstructionChanges() {
val engine = CardioGuidanceEngine(hysteresisBpm = 2, confirmationMs = 3_000)
assertEquals(
CardioGuidanceInstruction.MAINTAIN,
engine.evaluate(target, 110, true, 1_000).instruction,
)
assertEquals(
CardioGuidanceInstruction.MAINTAIN,
engine.evaluate(target, 111, true, 3_999).instruction,
)
val changed = engine.evaluate(target, 111, true, 4_000)
assertEquals(CardioGuidanceInstruction.ACCELERATE, changed.instruction)
assertNull(changed.pendingInstruction)
}
@Test
fun highBpmMustPersistBeforeSlowDownInstructionChanges() {
val engine = CardioGuidanceEngine(hysteresisBpm = 2, confirmationMs = 2_000)
engine.evaluate(target, 150, true, 0)
assertEquals(
CardioGuidanceInstruction.SLOW_DOWN,
engine.evaluate(target, 150, true, 2_000).instruction,
)
}
@Test
fun hysteresisPreventsBoundaryYoyo() {
val engine = CardioGuidanceEngine(hysteresisBpm = 2, confirmationMs = 0)
assertEquals(
CardioGuidanceInstruction.ACCELERATE,
engine.evaluate(target, 117, true, 0).instruction,
)
// 119 is still below target, but not enough to leave ACCELERATE.
assertEquals(
CardioGuidanceInstruction.ACCELERATE,
engine.evaluate(target, 119, true, 1).instruction,
)
assertEquals(
CardioGuidanceInstruction.MAINTAIN,
engine.evaluate(target, 122, true, 2).instruction,
)
// 141 is just above the zone and remains MAINTAIN within hysteresis.
assertEquals(
CardioGuidanceInstruction.MAINTAIN,
engine.evaluate(target, 141, true, 3).instruction,
)
assertEquals(
CardioGuidanceInstruction.SLOW_DOWN,
engine.evaluate(target, 143, true, 4).instruction,
)
}
@Test
fun isolatedOppositeSampleDoesNotReplaceCurrentInstruction() {
val engine = CardioGuidanceEngine(hysteresisBpm = 2, confirmationMs = 3_000)
engine.evaluate(target, 110, true, 0)
engine.evaluate(target, 110, true, 3_000)
assertEquals(
CardioGuidanceInstruction.ACCELERATE,
engine.evaluate(target, 150, true, 3_100).instruction,
)
assertEquals(
CardioGuidanceInstruction.ACCELERATE,
engine.evaluate(target, 112, true, 3_200).instruction,
)
}
@Test
fun staleOrMissingSignalSuspendsImmediatelyAndNeverUsesOldBpm() {
val engine = CardioGuidanceEngine(hysteresisBpm = 2, confirmationMs = 0)
assertEquals(
CardioGuidanceInstruction.ACCELERATE,
engine.evaluate(target, 110, true, 0).instruction,
)
val stale = engine.evaluate(target, 110, false, 1_000)
assertEquals(CardioGuidanceInstruction.SUSPENDED, stale.instruction)
assertNull(stale.bpm)
// A fresh sample must re-establish a candidate from real current data.
val resumed = engine.evaluate(target, 130, true, 2_000)
assertTrue(resumed.instruction != CardioGuidanceInstruction.SUSPENDED)
assertEquals(130, resumed.bpm)
}
@Test
fun noTargetSuspendsGuidance() {
val engine = CardioGuidanceEngine()
val output = engine.evaluate(null, 130, true, 0)
assertEquals(CardioGuidanceInstruction.SUSPENDED, output.instruction)
assertNull(output.bpm)
}
}

View file

@ -0,0 +1,260 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.CardioGuidanceInstruction
import com.labfytools.trainlog.model.CardioGuidanceOutput
import com.labfytools.trainlog.model.CardioGuidedPhase
import com.labfytools.trainlog.model.CardioPhaseExitCondition
import com.labfytools.trainlog.model.CardioPhaseKind
import com.labfytools.trainlog.model.CardioTargetSnapshot
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionExercisePlan
import com.labfytools.trainlog.model.SessionType
import com.labfytools.trainlog.model.TrackingMode
import java.nio.file.Files
import java.time.OffsetDateTime
import java.util.UUID
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class CardioGuidanceGenerationTest {
private val context: Context = ApplicationProvider.getApplicationContext()
private fun measurement(bpm: Int) =
ParsedHeartRateMeasurement(
bpm = bpm,
sensorContactDetected = null,
energyExpended = null,
rrIntervals1024 = listOf(1024),
)
@Test
fun completedGuidanceSynchronizesOnceAndAckStopsRepublication() {
val name = "guidance-generation-" + UUID.randomUUID() + ".db"
val repository = TrainlogRepository(context, name)
val root = Files.createTempDirectory("trainlog-guidance-generation-").toFile()
try {
val exercise =
(repository.createExercise(
NewExerciseProfile(
"Vélo guidé sync",
RecordingMode.CONTINUOUS,
TrackingMode.DURATION,
0,
),
) as CreateExerciseResult.Created).exercise
val entry =
SessionExerciseDraft(
exercise = exercise,
plan = SessionExercisePlan(sets = 0, durationSeconds = 30),
)
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
ActiveSessionDraft(
startedAt = OffsetDateTime.now().minusMinutes(1).toString(),
sessionType = SessionType.CARDIO,
exercises = listOf(entry),
),
),
)
val active =
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
val base = OffsetDateTime.parse(checkNotNull(active.startedAt))
val exerciseStart = base.plusSeconds(1).toString()
assertTrue(
repository.startActiveSessionExercise(entry.entryId, exerciseStart) is
TrainlogRepository.SessionExerciseTimingResult.Started,
)
val target = CardioTargetSnapshot(120, 140)
val phase =
CardioGuidedPhase(
phaseId = "cgp_" + UUID.randomUUID(),
kind = CardioPhaseKind.WORK,
target = target,
exitCondition = CardioPhaseExitCondition.FixedDuration(30),
)
assertTrue(
repository.startCardioGuidancePhase(
entry.entryId,
phase,
base.plusSeconds(2).toString(),
) is TrainlogRepository.CardioGuidanceMutationResult.Applied,
)
val lowAt = base.plusSeconds(3).toString()
repository.recordLiveHeartRateForActiveSession(
"Synthetic HR",
lowAt,
measurement(110),
)
repository.recordCardioGuidanceOutput(
phase.phaseId,
lowAt,
CardioGuidanceOutput(
CardioGuidanceInstruction.ACCELERATE,
110,
target,
),
)
val targetAt = base.plusSeconds(4).toString()
repository.recordLiveHeartRateForActiveSession(
"Synthetic HR",
targetAt,
measurement(130),
)
repository.recordCardioGuidanceOutput(
phase.phaseId,
targetAt,
CardioGuidanceOutput(
CardioGuidanceInstruction.MAINTAIN,
130,
target,
),
)
repository.finishCardioGuidancePhase(
phase.phaseId,
base.plusSeconds(5).toString(),
)
repository.finishActiveSessionExercise(
entry.entryId,
base.plusSeconds(6).toString(),
)
val completedDraft =
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
completedDraft.copy(
exercises =
completedDraft.exercises.map {
if (it.entryId == entry.entryId) {
it.copy(continuousDurationSeconds = 5)
} else {
it
}
},
),
),
)
assertTrue(repository.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
assertEquals(
1,
JSONObject(repository.buildCardioGuidanceV1Json())
.getJSONArray("runs")
.length(),
)
val repositoryRoot =
generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))) {
it.parentFile
}
.first { java.io.File(it, "tools/sync_generation_exchange.py").isFile }
fun run(vararg command: String): String {
val process =
ProcessBuilder(*command)
.directory(repositoryRoot)
.redirectErrorStream(true)
.start()
val output = process.inputStream.bufferedReader().readText()
assertEquals(
"command failed: ${command.joinToString(" ")}\n$output",
0,
process.waitFor(),
)
return output
}
val desktopDb = java.io.File(root, "desktop.sqlite")
run(
java.io.File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
desktopDb.absolutePath,
)
val desktopPeer =
Regex("PEER_ID=(peer_[^\\s]+)")
.find(
run(
"python3",
java.io.File(
repositoryRoot,
"tools/sync_generation_exchange.py",
).absolutePath,
"peer-id",
"--database",
desktopDb.absolutePath,
),
)!!
.groupValues[1]
val service = SyncGenerationService(repository)
val captured = service.capture(root, desktopPeer)
assertEquals(
1,
JSONObject(
java.io.File(
captured.stagingDirectory,
"cardio-guidance-v1.json",
).readText(),
)
.getJSONArray("runs")
.length(),
)
val published =
service.publish(captured, java.io.File(root, "objects"))
val ackFile = java.io.File(root, "ack.json")
run(
"python3",
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath,
"consume-desktop",
published.absolutePath,
"--database",
desktopDb.absolutePath,
"--ack-output",
ackFile.absolutePath,
)
assertEquals(
"consumed",
JSONObject(ackFile.readText()).getString("result"),
)
assertEquals(
"acknowledged",
service.acceptAcknowledgement(ackFile.readBytes()),
)
assertEquals(
0,
JSONObject(repository.buildCardioGuidanceV1Json())
.getJSONArray("runs")
.length(),
)
assertEquals(
"1|1|2",
run(
"sqlite3",
desktopDb.absolutePath,
"SELECT (SELECT count(*) FROM cardio_guidance_runs) || '|' || " +
"(SELECT count(*) FROM cardio_guidance_phases) || '|' || " +
"(SELECT count(*) FROM cardio_guidance_events);",
)
.trim(),
)
} finally {
repository.close()
context.deleteDatabase(name)
root.deleteRecursively()
}
}
}

View file

@ -0,0 +1,428 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.CardioGuidanceInstruction
import com.labfytools.trainlog.model.CardioGuidanceOutput
import com.labfytools.trainlog.model.CardioGuidedPhase
import com.labfytools.trainlog.model.CardioPhaseExitCondition
import com.labfytools.trainlog.model.CardioPhaseKind
import com.labfytools.trainlog.model.CardioTargetSnapshot
import com.labfytools.trainlog.model.HeartRateContextKind
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionExercisePlan
import com.labfytools.trainlog.model.SessionType
import com.labfytools.trainlog.model.TrackingMode
import java.time.OffsetDateTime
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class CardioGuidanceRepositoryTest {
private val context: Context = ApplicationProvider.getApplicationContext()
private data class ActiveCardio(
val repository: TrainlogRepository,
val databaseName: String,
val entryId: String,
val exerciseStartedAt: String,
) {
fun at(seconds: Long): String =
OffsetDateTime.parse(exerciseStartedAt).plusSeconds(seconds).toString()
}
private fun activeCardio(): ActiveCardio {
val name = "guidance-" + UUID.randomUUID() + ".db"
val repository = TrainlogRepository(context, name)
val exercise =
(repository.createExercise(
NewExerciseProfile(
"Tapis guidé",
RecordingMode.CONTINUOUS,
TrackingMode.DURATION,
0,
),
) as CreateExerciseResult.Created).exercise
val entry =
SessionExerciseDraft(
exercise = exercise,
plan = SessionExercisePlan(sets = 0, durationSeconds = 600),
)
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
ActiveSessionDraft(
startedAt = OffsetDateTime.now().minusMinutes(20).toString(),
sessionType = SessionType.CARDIO,
exercises = listOf(entry),
),
),
)
val loaded = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
val exerciseStartedAt =
OffsetDateTime.parse(checkNotNull(loaded.draft.startedAt))
.plusSeconds(10)
.toString()
val started =
repository.startActiveSessionExercise(
entry.entryId,
exerciseStartedAt,
)
assertTrue(started is TrainlogRepository.SessionExerciseTimingResult.Started)
return ActiveCardio(repository, name, entry.entryId, exerciseStartedAt)
}
@Test
fun trainingCaptureTransitionsToCardioWithoutStarvingGuidance() {
val name = "guidance-transition-" + UUID.randomUUID() + ".db"
val repository = TrainlogRepository(context, name)
try {
val exercise =
(repository.createExercise(
NewExerciseProfile(
"Vélo guidé après transition",
RecordingMode.CONTINUOUS,
TrackingMode.DURATION,
0,
),
) as CreateExerciseResult.Created).exercise
assertEquals(ActiveDraftMutationResult.Saved, repository.startActiveSessionDraft())
val training =
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
val base = OffsetDateTime.parse(checkNotNull(training.startedAt))
val firstMeasurement =
ParsedHeartRateMeasurement(
bpm = 101,
sensorContactDetected = true,
energyExpended = 12,
rrIntervals1024 = listOf(611),
)
assertTrue(
repository.recordLiveHeartRateForActiveSession(
"Synthetic HR",
base.plusSeconds(1).toString(),
firstMeasurement,
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
val originalCapture = checkNotNull(repository.activeHeartRateCapture())
assertEquals(HeartRateContextKind.SESSION, originalCapture.contextKind)
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(training.copy(sessionType = SessionType.CARDIO)),
)
val transitionedCapture = checkNotNull(repository.activeHeartRateCapture())
assertEquals(originalCapture.captureId, transitionedCapture.captureId)
assertEquals(HeartRateContextKind.CARDIO, transitionedCapture.contextKind)
val entry =
SessionExerciseDraft(
exercise = exercise,
plan = SessionExercisePlan(sets = 0, durationSeconds = 60),
)
val cardio =
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(cardio.copy(exercises = listOf(entry))),
)
assertTrue(
repository.startActiveSessionExercise(entry.entryId, base.plusSeconds(2).toString()) is
TrainlogRepository.SessionExerciseTimingResult.Started,
)
val target = CardioTargetSnapshot(100, 110)
val phase =
CardioGuidedPhase(
phaseId = "cgp_" + UUID.randomUUID(),
kind = CardioPhaseKind.WARMUP,
target = target,
exitCondition = CardioPhaseExitCondition.FixedDuration(60),
)
assertTrue(
repository.startCardioGuidancePhase(
entry.entryId,
phase,
base.plusSeconds(3).toString(),
) is TrainlogRepository.CardioGuidanceMutationResult.Applied,
)
val secondAt = base.plusSeconds(4).toString()
assertTrue(
repository.recordLiveHeartRateForActiveSession(
"Synthetic HR",
secondAt,
firstMeasurement.copy(bpm = 99),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
assertTrue(
repository.recordCardioGuidanceOutput(
phase.phaseId,
secondAt,
CardioGuidanceOutput(CardioGuidanceInstruction.ACCELERATE, 99, target),
) is TrainlogRepository.CardioGuidanceMutationResult.Applied,
)
assertEquals(
listOf(CardioGuidanceInstruction.ACCELERATE),
repository.listCardioGuidanceEvents(phase.phaseId).map { it.instruction },
)
val lateReverse =
repository.saveActiveSessionDraft(
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded)
.draft
.copy(sessionType = SessionType.TRAINING),
)
assertTrue(lateReverse is ActiveDraftMutationResult.Error)
assertEquals(
SessionType.CARDIO,
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded)
.draft
.sessionType,
)
assertEquals(
HeartRateContextKind.CARDIO,
checkNotNull(repository.activeHeartRateCapture()).contextKind,
)
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
).use { db ->
db.rawQuery(
"SELECT sequence,bpm,exercise_entry_id FROM heart_rate_samples " +
"WHERE capture_id=? ORDER BY sequence",
arrayOf(originalCapture.captureId),
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(0L, cursor.getLong(0))
assertEquals(101, cursor.getInt(1))
assertTrue(cursor.isNull(2))
assertTrue(cursor.moveToNext())
assertEquals(1L, cursor.getLong(0))
assertEquals(99, cursor.getInt(1))
assertEquals(entry.entryId, cursor.getString(2))
assertTrue(!cursor.moveToNext())
}
}
assertEquals(ActiveDraftMutationResult.Saved, repository.discardActiveSessionDraft())
assertNull(repository.activeHeartRateCapture())
assertNull(repository.activeCardioGuidancePhase())
assertTrue(repository.listActiveSessionExerciseTimings().isEmpty())
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun cardioCaptureCanReturnToTrainingBeforeCardioOnlyFactsExist() {
val name = "guidance-early-reverse-" + UUID.randomUUID() + ".db"
val repository = TrainlogRepository(context, name)
try {
assertEquals(ActiveDraftMutationResult.Saved, repository.startActiveSessionDraft())
val training =
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
val observedAt =
OffsetDateTime.parse(checkNotNull(training.startedAt)).plusSeconds(1).toString()
assertTrue(
repository.recordLiveHeartRateForActiveSession(
"Synthetic HR",
observedAt,
ParsedHeartRateMeasurement(103, null, null, listOf(600)),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
val captureId = checkNotNull(repository.activeHeartRateCapture()).captureId
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(training.copy(sessionType = SessionType.CARDIO)),
)
val cardio =
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(cardio.copy(sessionType = SessionType.TRAINING)),
)
val reversed = checkNotNull(repository.activeHeartRateCapture())
assertEquals(captureId, reversed.captureId)
assertEquals(HeartRateContextKind.SESSION, reversed.contextKind)
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
).use { db ->
db.rawQuery(
"SELECT COUNT(*),MIN(bpm) FROM heart_rate_samples WHERE capture_id=?",
arrayOf(captureId),
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(1, cursor.getInt(0))
assertEquals(103, cursor.getInt(1))
}
}
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun phaseRequiresActiveCardioExerciseAndRecordsOnlyInstructionChanges() {
val active = activeCardio()
val repository = active.repository
try {
val target = CardioTargetSnapshot(120, 140)
val phase =
CardioGuidedPhase(
phaseId = "cgp_" + UUID.randomUUID(),
kind = CardioPhaseKind.WORK,
target = target,
exitCondition = CardioPhaseExitCondition.FixedDuration(180),
)
val started =
repository.startCardioGuidancePhase(
active.entryId,
phase,
active.at(10),
) as TrainlogRepository.CardioGuidanceMutationResult.Applied
assertEquals(CardioGuidanceInstruction.MAINTAIN, started.phase!!.currentInstruction)
repository.recordCardioGuidanceOutput(
phase.phaseId,
active.at(13),
CardioGuidanceOutput(
CardioGuidanceInstruction.ACCELERATE,
110,
target,
),
)
repository.recordCardioGuidanceOutput(
phase.phaseId,
active.at(14),
CardioGuidanceOutput(
CardioGuidanceInstruction.ACCELERATE,
111,
target,
),
)
repository.recordCardioGuidanceOutput(
phase.phaseId,
active.at(30),
CardioGuidanceOutput(
CardioGuidanceInstruction.MAINTAIN,
130,
target,
),
)
assertEquals(
listOf(
CardioGuidanceInstruction.ACCELERATE,
CardioGuidanceInstruction.MAINTAIN,
),
repository.listCardioGuidanceEvents(phase.phaseId).map { it.instruction },
)
repository.finishActiveSessionExercise(
active.entryId,
active.at(600),
)
assertNull(repository.activeCardioGuidancePhase())
val completedDraft =
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
completedDraft.copy(
exercises =
completedDraft.exercises.map {
if (it.entryId == active.entryId) {
it.copy(continuousDurationSeconds = 600)
} else {
it
}
},
),
),
)
val finalized = repository.finalizeActiveSessionDraft()
assertTrue(finalized is FinalizeActiveDraftResult.Saved)
} finally {
repository.close()
context.deleteDatabase(active.databaseName)
}
}
@Test
fun guidanceCannotStartBeforeExerciseTimingIsActive() {
val name = "guidance-inactive-" + UUID.randomUUID() + ".db"
val repository = TrainlogRepository(context, name)
try {
val exercise =
(repository.createExercise(
NewExerciseProfile(
"Vélo guidé",
RecordingMode.CONTINUOUS,
TrackingMode.DURATION,
0,
),
) as CreateExerciseResult.Created).exercise
repository.startActiveSessionDraft()
val loaded = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
val entry = SessionExerciseDraft(exercise = exercise, continuousDurationSeconds = 60)
repository.saveActiveSessionDraft(
loaded.draft.copy(sessionType = SessionType.CARDIO, exercises = listOf(entry)),
)
val result =
repository.startCardioGuidancePhase(
entry.entryId,
CardioGuidedPhase(
"cgp_" + UUID.randomUUID(),
CardioPhaseKind.WARMUP,
CardioTargetSnapshot(100, 115),
CardioPhaseExitCondition.FixedDuration(60),
),
"2026-09-23T13:00:00+02:00",
)
assertTrue(result is TrainlogRepository.CardioGuidanceMutationResult.Invalid)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun abandonedDraftDeletesOnlyUnfinishedGuidanceRun() {
val active = activeCardio()
val repository = active.repository
try {
repository.startCardioGuidancePhase(
active.entryId,
CardioGuidedPhase(
"cgp_" + UUID.randomUUID(),
CardioPhaseKind.WORK,
CardioTargetSnapshot(120, 140),
CardioPhaseExitCondition.FixedDuration(120),
),
active.at(10),
)
assertTrue(repository.activeCardioGuidancePhase() != null)
repository.discardActiveSessionDraft()
assertNull(repository.activeCardioGuidancePhase())
} finally {
repository.close()
context.deleteDatabase(active.databaseName)
}
}
}

View file

@ -0,0 +1,67 @@
package com.labfytools.trainlog.data
import com.labfytools.trainlog.model.CardioGuidedPhase
import com.labfytools.trainlog.model.CardioPhaseExitCondition
import com.labfytools.trainlog.model.CardioPhaseKind
import com.labfytools.trainlog.model.CardioTargetSnapshot
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class CardioPhaseCompletionTest {
private val target = CardioTargetSnapshot(120, 140)
private fun phase(
exit: CardioPhaseExitCondition,
targetOverride: CardioTargetSnapshot? = target,
) = CardioGuidedPhase(
phaseId = "phase_test",
kind = CardioPhaseKind.WORK,
target = targetOverride,
exitCondition = exit,
)
@Test
fun fixedDurationUsesElapsedTimeOnly() {
val p = phase(CardioPhaseExitCondition.FixedDuration(180))
assertFalse(CardioPhaseCompletion.isComplete(p, 179, null, false))
assertTrue(CardioPhaseCompletion.isComplete(p, 180, null, false))
}
@Test
fun enterTargetRequiresFreshMeasurementInsideSnapshot() {
val p = phase(CardioPhaseExitCondition.EnterTarget)
assertFalse(CardioPhaseCompletion.isComplete(p, 10, 130, false))
assertFalse(CardioPhaseCompletion.isComplete(p, 10, 119, true))
assertTrue(CardioPhaseCompletion.isComplete(p, 10, 120, true))
assertTrue(CardioPhaseCompletion.isComplete(p, 10, 140, true))
}
@Test
fun enterTargetWithoutTargetNeverCompletes() {
val p = phase(CardioPhaseExitCondition.EnterTarget, null)
assertFalse(CardioPhaseCompletion.isComplete(p, 100, 130, true))
}
@Test
fun recoveryThresholdRequiresFreshMeasurement() {
val p = phase(CardioPhaseExitCondition.RecoverBelow(110), null)
assertFalse(CardioPhaseCompletion.isComplete(p, 30, 105, false))
assertFalse(CardioPhaseCompletion.isComplete(p, 30, 111, true))
assertTrue(CardioPhaseCompletion.isComplete(p, 30, 110, true))
}
@Test
fun durationOrRecoveryCanFinishFromEitherRealCondition() {
val p = phase(CardioPhaseExitCondition.DurationOrRecoverBelow(300, 110), null)
assertFalse(CardioPhaseCompletion.isComplete(p, 299, 120, true))
assertTrue(CardioPhaseCompletion.isComplete(p, 100, 108, true))
assertTrue(CardioPhaseCompletion.isComplete(p, 300, null, false))
}
@Test
fun negativeElapsedTimeCannotCompleteAnything() {
val p = phase(CardioPhaseExitCondition.FixedDuration(0))
assertFalse(CardioPhaseCompletion.isComplete(p, -1, 130, true))
}
}

View file

@ -0,0 +1,194 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.SessionType
import com.labfytools.trainlog.model.TrackingMode
import java.nio.file.Files
import java.util.UUID
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class CardioSessionSyncGenerationTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun dedicatedCardioCompanionImportsWithoutWideningFrozenHistory() {
val sourceName = "cardio-sync-" + UUID.randomUUID() + ".db"
val root = Files.createTempDirectory("trainlog-cardio-sync-").toFile()
val source = TrainlogRepository(context, sourceName)
try {
val exercise =
(source.createExercise(
NewExerciseProfile(
"Cardio sync exercise",
RecordingMode.SETS,
TrackingMode.REPS,
0,
),
) as CreateExerciseResult.Created).exercise
val entry =
SessionExerciseDraft(
exercise = exercise,
sets = listOf(SessionSetDraft(reps = 15)),
)
assertEquals(
ActiveDraftMutationResult.Saved,
source.saveActiveSessionDraft(
ActiveSessionDraft(
sessionType = SessionType.CARDIO,
exercises = listOf(entry),
),
),
)
assertTrue(
source.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved,
)
val sessionId = source.listSessions().single().sessionId
val repositoryRoot =
generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))) {
it.parentFile
}.first { java.io.File(it, "tools/sync_generation_exchange.py").isFile }
fun run(vararg command: String): String {
val process =
ProcessBuilder(*command)
.directory(repositoryRoot)
.redirectErrorStream(true)
.start()
val output = process.inputStream.bufferedReader().readText()
assertEquals(
"command failed: " + command.joinToString(" ") + "\n" + output,
0,
process.waitFor(),
)
return output
}
val desktopDb = java.io.File(root, "desktop.sqlite")
run(
java.io.File(
repositoryRoot,
"build/tui/sync-generation-fixture",
).absolutePath,
desktopDb.absolutePath,
)
val desktopPeer =
Regex("PEER_ID=(peer_[^\\s]+)")
.find(
run(
"python3",
java.io.File(
repositoryRoot,
"tools/sync_generation_exchange.py",
).absolutePath,
"peer-id",
"--database",
desktopDb.absolutePath,
),
)!!
.groupValues[1]
val service = SyncGenerationService(source)
val captured = service.capture(root, desktopPeer)
val history =
JSONObject(
java.io.File(
captured.stagingDirectory,
"history-v4.json",
).readText(),
)
assertEquals(0, history.getJSONArray("sessions").length())
val cardio =
JSONObject(
java.io.File(
captured.stagingDirectory,
"cardio-sessions-v1.json",
).readText(),
)
assertEquals("trainlog-cardio-sessions", cardio.getString("format"))
assertEquals(1, cardio.getJSONArray("sessions").length())
assertEquals(
"cardio",
cardio
.getJSONArray("sessions")
.getJSONObject(0)
.getString("session_type"),
)
val published = service.publish(captured, java.io.File(root, "objects"))
val ackFile = java.io.File(root, "ack.json")
run(
"python3",
java.io.File(
repositoryRoot,
"tools/sync_generation_exchange.py",
).absolutePath,
"consume-desktop",
published.absolutePath,
"--database",
desktopDb.absolutePath,
"--ack-output",
ackFile.absolutePath,
)
val ack = JSONObject(ackFile.readText())
assertEquals(
"desktop ACK diagnostic: " + ack.getString("diagnostic"),
"consumed",
ack.getString("result"),
)
assertEquals(
"training|cardio|15",
run(
"sqlite3",
desktopDb.absolutePath,
"SELECT s.session_type || '|' || s.session_kind || '|' || ps.reps " +
"FROM sessions s JOIN session_exercises se ON se.session_row_id=s.id " +
"JOIN performed_sets ps ON ps.session_exercise_row_id=se.id " +
"WHERE s.session_id='$sessionId';",
).trim(),
)
assertEquals(
"acknowledged",
service.acceptAcknowledgement(ackFile.readBytes()),
)
assertEquals(
ack.toString(),
JSONObject(
run(
"python3",
java.io.File(
repositoryRoot,
"tools/sync_generation_exchange.py",
).absolutePath,
"consume-desktop",
published.absolutePath,
"--database",
desktopDb.absolutePath,
"--ack-output",
java.io.File(root, "replay-ack.json").absolutePath,
).let { java.io.File(root, "replay-ack.json").readText() },
).toString(),
)
} finally {
source.close()
context.deleteDatabase(sourceName)
root.deleteRecursively()
}
}
}

View file

@ -0,0 +1,95 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.SessionType
import com.labfytools.trainlog.model.TrackingMode
import java.util.UUID
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class CardioSessionTypeTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun cardioIsCanonicalLocallyAndExcludedFromFrozenMobileHistory() {
val name = "cardio-kind-" + UUID.randomUUID() + ".db"
val repository = TrainlogRepository(context, name)
try {
val exercise =
(repository.createExercise(
NewExerciseProfile(
"Cardio kind exercise",
RecordingMode.SETS,
TrackingMode.REPS,
0,
),
) as CreateExerciseResult.Created).exercise
val occurrence =
SessionExerciseDraft(
exercise = exercise,
sets = listOf(SessionSetDraft(reps = 1)),
)
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
ActiveSessionDraft(
sessionType = SessionType.CARDIO,
exercises = listOf(occurrence),
),
),
)
val loaded =
repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
assertEquals(SessionType.CARDIO, loaded.draft.sessionType)
val db = context.openOrCreateDatabase(name, Context.MODE_PRIVATE, null)
db.rawQuery(
"SELECT session_type,session_kind FROM active_session_draft WHERE id=1",
null,
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals("training", cursor.getString(0))
assertEquals("cardio", cursor.getString(1))
}
db.close()
assertTrue(
repository.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved,
)
assertEquals(
SessionType.CARDIO,
repository.listSessions().single().sessionType,
)
val frozen = JSONObject(repository.buildMobileExportV4Json())
assertEquals(0, frozen.getJSONArray("sessions").length())
val verify = context.openOrCreateDatabase(name, Context.MODE_PRIVATE, null)
verify.rawQuery(
"SELECT session_type,session_kind FROM sessions",
null,
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals("training", cursor.getString(0))
assertEquals("cardio", cursor.getString(1))
}
verify.close()
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

@ -0,0 +1,42 @@
package com.labfytools.trainlog.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class HeartRateLiveStateTest {
@Test
fun liveStateDropsStaleBpmWithoutInventingMeasurement() {
HeartRateLiveState.disconnected()
val observed = mutableListOf<HeartRateLiveSnapshot>()
val subscription = HeartRateLiveState.subscribe { observed += it }
try {
HeartRateLiveState.connecting()
HeartRateLiveState.measurement(
ParsedHeartRateMeasurement(
bpm = 82,
sensorContactDetected = null,
energyExpended = null,
rrIntervals1024 = listOf(768),
),
)
assertEquals(HeartRateLivePhase.CONNECTED, HeartRateLiveState.current().phase)
assertEquals(82, HeartRateLiveState.current().bpm)
assertEquals(listOf(768), HeartRateLiveState.current().rrIntervals1024)
assertEquals(1L, HeartRateLiveState.current().notificationCount)
HeartRateLiveState.stale()
assertEquals(HeartRateLivePhase.STALE, HeartRateLiveState.current().phase)
assertNull(HeartRateLiveState.current().bpm)
assertEquals(listOf(768), HeartRateLiveState.current().rrIntervals1024)
HeartRateLiveState.disconnected()
assertEquals(HeartRateLivePhase.DISCONNECTED, HeartRateLiveState.current().phase)
assertNull(HeartRateLiveState.current().bpm)
assertEquals(5, observed.size)
} finally {
subscription.close()
HeartRateLiveState.disconnected()
}
}
}

View file

@ -0,0 +1,95 @@
package com.labfytools.trainlog.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class HeartRateMeasurementParserTest {
private fun parsed(vararg bytes: Int): ParsedHeartRateMeasurement {
val result =
HeartRateMeasurementParser.parse(
ByteArray(bytes.size) { index -> bytes[index].toByte() },
)
return (result as HeartRateMeasurementParseResult.Parsed).measurement
}
private fun invalid(vararg bytes: Int): HeartRateMeasurementParseResult.Invalid =
HeartRateMeasurementParser.parse(
ByteArray(bytes.size) { index -> bytes[index].toByte() },
) as HeartRateMeasurementParseResult.Invalid
@Test
fun parsesEightBitBpm() {
val value = parsed(0x00, 72)
assertEquals(72, value.bpm)
assertNull(value.sensorContactDetected)
assertNull(value.energyExpended)
assertTrue(value.rrIntervals1024.isEmpty())
}
@Test
fun parsesSixteenBitBpmLittleEndian() {
assertEquals(300, parsed(0x01, 0x2c, 0x01).bpm)
assertEquals(65535, parsed(0x01, 0xff, 0xff).bpm)
}
@Test
fun contactIsNullableAndOnlyMeaningfulWhenSupported() {
assertNull(parsed(0x02, 80).sensorContactDetected)
assertEquals(false, parsed(0x04, 80).sensorContactDetected)
assertEquals(true, parsed(0x06, 80).sensorContactDetected)
}
@Test
fun parsesEnergyExpended() {
val value = parsed(0x08, 90, 0x34, 0x12)
assertEquals(0x1234, value.energyExpended)
}
@Test
fun parsesOneAndMultipleRawRrIntervals() {
assertEquals(listOf(1024), parsed(0x10, 60, 0x00, 0x04).rrIntervals1024)
assertEquals(
listOf(1024, 1000, 65535),
parsed(0x10, 60, 0x00, 0x04, 0xe8, 0x03, 0xff, 0xff).rrIntervals1024,
)
}
@Test
fun parsesCombinedKnownFlagsInWireOrder() {
val value =
parsed(
0x1f,
0x96,
0x00,
0x07,
0x00,
0xf0,
0x03,
0x00,
0x04,
)
assertEquals(150, value.bpm)
assertEquals(true, value.sensorContactDetected)
assertEquals(7, value.energyExpended)
assertEquals(listOf(1008, 1024), value.rrIntervals1024)
}
@Test
fun rejectsTruncatedAndIncoherentPayloads() {
assertEquals("missing flags", invalid().reason)
assertEquals("truncated 16-bit BPM", invalid(0x01, 0x2c).reason)
assertEquals("truncated energy-expended value", invalid(0x08, 70, 0x01).reason)
assertEquals("RR flag has no interval", invalid(0x10, 70).reason)
assertEquals("truncated RR interval", invalid(0x10, 70, 0x01).reason)
assertEquals("unexpected trailing bytes", invalid(0x00, 70, 0x01).reason)
assertEquals("reserved flags are set", invalid(0x20, 70).reason)
}
@Test
fun preservesBoundaryBpmValues() {
assertEquals(0, parsed(0x00, 0).bpm)
assertEquals(255, parsed(0x00, 255).bpm)
}
}

View file

@ -0,0 +1,96 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.HeartRateContextKind
import com.labfytools.trainlog.model.HeartRateRrInterval
import com.labfytools.trainlog.model.SleepDiaryDraft
import java.util.UUID
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class HeartRateRepositoryTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun sleepCaptureStoresRawSamplesAndExportsOnlyAfterStop() {
val name = "heart-rate-${UUID.randomUUID()}.db"
val repository = TrainlogRepository(context, name)
try {
val created =
repository.saveSleepDiary(
SleepDiaryDraft(
nightStartDate = "2026-09-22",
nightEndDate = "2026-09-23",
createdAt = "2026-09-22T22:00:00+02:00",
updatedAt = "2026-09-22T22:00:00+02:00",
sleepQuality = null,
wakeQuality = null,
dayForm = null,
treatmentAndNotes = "",
events = emptyList(),
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
val started =
repository.startHeartRateCapture(
HeartRateContextKind.SLEEP,
created.entryId,
"2026-09-22T22:30:00+02:00",
"Synthetic HR sensor",
) as TrainlogRepository.StartHeartRateCaptureResult.Started
assertEquals(started.captureId, repository.activeHeartRateCapture()?.captureId)
val sample =
repository.appendHeartRateSample(
captureId = started.captureId,
observedAt = "2026-09-22T22:30:01+02:00",
bpm = 61,
sensorContactDetected = true,
energyExpended = 7,
rrIntervals = listOf(
HeartRateRrInterval(0, 1024),
HeartRateRrInterval(1, 1000),
),
) as TrainlogRepository.HeartRateMutationResult.Applied
assertEquals(0L, sample.sequence)
assertEquals(
0,
JSONObject(repository.buildHeartRateV1Json()).getJSONArray("captures").length(),
)
assertTrue(
repository.stopHeartRateCapture(
started.captureId,
"2026-09-23T06:30:00+02:00",
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
assertNull(repository.activeHeartRateCapture())
val root = JSONObject(repository.buildHeartRateV1Json())
assertEquals("trainlog-heart-rate", root.getString("format"))
assertEquals(1, root.getInt("version"))
val capture = root.getJSONArray("captures").getJSONObject(0)
assertEquals(created.entryId, capture.getString("context_id"))
assertEquals("sleep", capture.getString("context_kind"))
assertEquals("Synthetic HR sensor", capture.getString("sensor_name"))
val stored = capture.getJSONArray("samples").getJSONObject(0)
assertEquals(61, stored.getInt("bpm"))
assertTrue(stored.getBoolean("sensor_contact_detected"))
assertEquals(7, stored.getInt("energy_expended"))
assertEquals(1024, stored.getJSONArray("rr_intervals_1024").getInt(0))
assertEquals(1000, stored.getJSONArray("rr_intervals_1024").getInt(1))
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

@ -0,0 +1,32 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class HeartRateSensorPreferencesTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun selectionIsDurableAndClearable() {
val preferences = HeartRateSensorPreferences(context)
preferences.clear()
assertNull(preferences.selected())
preferences.select("AA:BB:CC:DD:EE:FF", "Synthetic HR")
assertEquals(
SelectedHeartRateSensor("AA:BB:CC:DD:EE:FF", "Synthetic HR"),
HeartRateSensorPreferences(context).selected(),
)
preferences.clear()
assertNull(HeartRateSensorPreferences(context).selected())
}
}

View file

@ -0,0 +1,490 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.HeartRateContextKind
import com.labfytools.trainlog.model.SessionSetDraft
import java.nio.file.Files
import java.time.OffsetDateTime
import java.util.UUID
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class ProgramSessionCardioReadinessTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun programSessionPreservesTimelineAndHeartRateOwnershipThroughDesktopReplay() {
val androidName = "program-cardio-readiness-${UUID.randomUUID()}.db"
val android = TrainlogRepository(context, androidName)
val root = Files.createTempDirectory("trainlog-program-cardio-readiness-").toFile()
val repositoryRoot =
generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))) {
it.parentFile
}.first { java.io.File(it, "tools/sync_generation_exchange.py").isFile }
val desktopDb = java.io.File(root, "desktop.sqlite")
val fixture = java.io.File(repositoryRoot, "build/tui/sync-generation-fixture")
val generationTool =
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath
fun run(vararg command: String): String {
val process =
ProcessBuilder(*command)
.directory(repositoryRoot)
.redirectErrorStream(true)
.start()
val output = process.inputStream.bufferedReader().readText()
assertEquals(
"command failed: ${command.joinToString(" ")}\n$output",
0,
process.waitFor(),
)
return output
}
fun measurement(bpm: Int, rr: Int) =
ParsedHeartRateMeasurement(
bpm = bpm,
sensorContactDetected = true,
energyExpended = null,
rrIntervals1024 = listOf(rr),
)
try {
val programId = "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
val programSessionId = "pgs_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
val programExerciseAId = "ex_dddddddd-dddd-4ddd-8ddd-dddddddddddd"
val programExerciseBId = "ex_11111111-1111-4111-8111-111111111111"
val catalog =
JSONObject()
.put("format", "trainlog-pc-catalog")
.put("version", 1)
.put(
"exercises",
JSONArray()
.put(
JSONObject()
.put("exercise_id", programExerciseAId)
.put("name", "Program exercise fixture A")
.put("recording_mode", "sets")
.put("tracking_mode", "reps")
.put("data_fields", 0),
)
.put(
JSONObject()
.put("exercise_id", programExerciseBId)
.put("name", "Program exercise fixture B")
.put("recording_mode", "sets")
.put("tracking_mode", "reps")
.put("data_fields", 0),
),
)
.toString()
assertTrue(android.applyPcCatalogJson(catalog) is PcCatalogImportResult.Applied)
val program =
JSONObject()
.put("program_id", programId)
.put("revision_id", "pgr_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.put("title", "Tomorrow readiness")
.put("note", JSONObject.NULL)
.put("state", "active")
.put("start_date", "2026-09-24")
.put("end_date", JSONObject.NULL)
.put("created_at", "2026-09-23T16:00:00Z")
.put("updated_at", "2026-09-23T16:00:00Z")
.put("source_format", "trainlog-program")
.put("source_version", 1)
.put("source_payload_sha256", "a".repeat(64))
.put(
"sessions",
JSONArray().put(
JSONObject()
.put("program_session_id", programSessionId)
.put("title", "Session A")
.put("session_type", "training")
.put("planned_for", "2026-09-24")
.put("note", JSONObject.NULL)
.put(
"occurrences",
JSONArray()
.put(
JSONObject()
.put(
"entry_id",
"pge_cccccccc-cccc-4ccc-8ccc-cccccccccccc",
)
.put("exercise_id", programExerciseAId)
.put("equipment_id", JSONObject.NULL)
.put("load_mode", "external")
.put("rest_seconds", 90)
.put("target_sets", 1)
.put("target_reps", 8)
.put("target_duration_seconds", JSONObject.NULL)
.put("target_weight_kg", 40.0)
.put("notes", JSONObject.NULL),
)
.put(
JSONObject()
.put(
"entry_id",
"pge_22222222-2222-4222-8222-222222222222",
)
.put("exercise_id", programExerciseBId)
.put("equipment_id", JSONObject.NULL)
.put("load_mode", "external")
.put("rest_seconds", 60)
.put("target_sets", 1)
.put("target_reps", 10)
.put("target_duration_seconds", JSONObject.NULL)
.put("target_weight_kg", 25.0)
.put("notes", JSONObject.NULL),
),
),
),
)
val programs =
JSONObject()
.put("format", "trainlog-programs")
.put("version", 1)
.put("generated_at", "2026-09-23T16:01:00Z")
.put("programs", JSONArray().put(program))
.put("deletions", JSONArray())
.toString()
assertEquals(
ProgramsImportResult.Applied(1, 0, 0),
android.applyProgramsV1Json(programs),
)
assertEquals(
StartProgramSessionResult.Started,
android.startSyncedProgramSession(programId, programSessionId),
)
val opened = android.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
val sessionId = checkNotNull(opened.draft.sessionId)
val sessionStartedAt = checkNotNull(opened.draft.startedAt)
assertEquals(programId, opened.draft.sourceProgramId)
assertEquals(programSessionId, opened.draft.sourceProgramSessionId)
assertEquals(2, opened.draft.exercises.size)
val exerciseA = opened.draft.exercises[0]
val exerciseBEntry = opened.draft.exercises[1]
assertEquals(programExerciseAId, exerciseA.exercise.exerciseId)
assertEquals(programExerciseBId, exerciseBEntry.exercise.exerciseId)
assertTrue(exerciseA.entryId != exerciseBEntry.entryId)
assertEquals(
ActiveDraftMutationResult.Saved,
android.saveActiveSessionDraft(
opened.draft.copy(
exercises =
listOf(
exerciseA.copy(
sets = listOf(SessionSetDraft(reps = 8, weightKg = 40.0)),
),
exerciseBEntry.copy(
sets = listOf(SessionSetDraft(reps = 10, weightKg = 25.0)),
),
),
),
),
)
val edited = android.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
assertEquals(sessionId, edited.draft.sessionId)
assertEquals(sessionStartedAt, edited.draft.startedAt)
assertEquals(programId, edited.draft.sourceProgramId)
assertEquals(programSessionId, edited.draft.sourceProgramSessionId)
val preExerciseAt = OffsetDateTime.now().toString()
assertTrue(
android.recordLiveHeartRateForActiveSession(
"Synthetic CYCPLUS",
preExerciseAt,
measurement(91, 675),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
val capture = checkNotNull(android.activeHeartRateCapture())
assertEquals(HeartRateContextKind.SESSION, capture.contextKind)
assertEquals(sessionId, capture.contextId)
assertEquals(sessionStartedAt, capture.startedAt)
val exerciseAStartedAt = OffsetDateTime.now().toString()
assertTrue(
android.startActiveSessionExercise(exerciseA.entryId, exerciseAStartedAt) is
TrainlogRepository.SessionExerciseTimingResult.Started,
)
val exerciseASampleAt = OffsetDateTime.now().toString()
assertTrue(
android.recordLiveHeartRateForActiveSession(
"Synthetic CYCPLUS",
exerciseASampleAt,
measurement(112, 548),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
val exerciseAEndedAt = OffsetDateTime.now().toString()
assertTrue(
android.finishActiveSessionExercise(exerciseA.entryId, exerciseAEndedAt) is
TrainlogRepository.SessionExerciseTimingResult.Finished,
)
val betweenExercisesAt = OffsetDateTime.now().toString()
assertTrue(
android.recordLiveHeartRateForActiveSession(
"Synthetic CYCPLUS",
betweenExercisesAt,
measurement(98, 620),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
val exerciseBStartedAt = OffsetDateTime.now().toString()
assertTrue(
android.startActiveSessionExercise(
exerciseBEntry.entryId,
exerciseBStartedAt,
) is TrainlogRepository.SessionExerciseTimingResult.Started,
)
val exerciseBSampleAt = OffsetDateTime.now().toString()
assertTrue(
android.recordLiveHeartRateForActiveSession(
"Synthetic CYCPLUS",
exerciseBSampleAt,
measurement(119, 525),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
val activeFinalize = android.finalizeActiveSessionDraft()
assertTrue(activeFinalize is FinalizeActiveDraftResult.Invalid)
assertTrue(
(activeFinalize as FinalizeActiveDraftResult.Invalid)
.message
.startsWith("ACTIVE_EXERCISE:"),
)
val exerciseBEndedAt = OffsetDateTime.now().toString()
assertTrue(
android.finishActiveSessionExercise(
exerciseBEntry.entryId,
exerciseBEndedAt,
) is TrainlogRepository.SessionExerciseTimingResult.Finished,
)
val finalized = android.finalizeActiveSessionDraft()
assertTrue(finalized is FinalizeActiveDraftResult.Saved)
assertEquals(sessionId, (finalized as FinalizeActiveDraftResult.Saved).sessionId)
assertNull(android.activeHeartRateCapture())
val timeline = JSONObject(android.buildSessionTimelineV1Json())
val timelineSession = timeline.getJSONArray("sessions").getJSONObject(0)
val sessionEndedAt = timelineSession.getString("ended_at")
assertEquals(sessionId, timelineSession.getString("session_id"))
assertEquals(sessionStartedAt, timelineSession.getString("started_at"))
assertTrue(OffsetDateTime.parse(sessionEndedAt) > OffsetDateTime.parse(sessionStartedAt))
val timelineExercises = timelineSession.getJSONArray("exercises")
assertEquals(2, timelineExercises.length())
assertEquals(exerciseA.entryId, timelineExercises.getJSONObject(0).getString("entry_id"))
assertEquals(exerciseAStartedAt, timelineExercises.getJSONObject(0).getString("started_at"))
assertEquals(exerciseAEndedAt, timelineExercises.getJSONObject(0).getString("ended_at"))
assertEquals(
exerciseBEntry.entryId,
timelineExercises.getJSONObject(1).getString("entry_id"),
)
assertEquals(exerciseBStartedAt, timelineExercises.getJSONObject(1).getString("started_at"))
assertEquals(exerciseBEndedAt, timelineExercises.getJSONObject(1).getString("ended_at"))
val heart = JSONObject(android.buildHeartRateV1Json())
val heartCapture = heart.getJSONArray("captures").getJSONObject(0)
assertEquals(capture.captureId, heartCapture.getString("capture_id"))
assertEquals("session", heartCapture.getString("context_kind"))
assertEquals(sessionId, heartCapture.getString("context_id"))
assertEquals(sessionStartedAt, heartCapture.getString("started_at"))
assertEquals(sessionEndedAt, heartCapture.getString("ended_at"))
val samples = heartCapture.getJSONArray("samples")
assertEquals(4, samples.length())
val expectedOwnership =
listOf(null, exerciseA.entryId, null, exerciseBEntry.entryId)
val expectedTimes =
listOf(preExerciseAt, exerciseASampleAt, betweenExercisesAt, exerciseBSampleAt)
val expectedRr = listOf(675, 548, 620, 525)
for (index in 0 until samples.length()) {
val sample = samples.getJSONObject(index)
assertEquals(index.toLong(), sample.getLong("sequence"))
assertEquals(expectedTimes[index], sample.getString("observed_at"))
if (expectedOwnership[index] == null) {
assertTrue(sample.isNull("exercise_entry_id"))
} else {
assertEquals(expectedOwnership[index], sample.getString("exercise_entry_id"))
}
assertEquals(expectedRr[index], sample.getJSONArray("rr_intervals_1024").getInt(0))
}
SQLiteDatabase.openDatabase(
context.getDatabasePath(androidName).absolutePath,
null,
SQLiteDatabase.OPEN_READONLY,
).use { db ->
db.rawQuery(
"SELECT source_program_id,source_program_session_id FROM sessions " +
"WHERE session_id=?",
arrayOf(sessionId),
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(programId, cursor.getString(0))
assertEquals(programSessionId, cursor.getString(1))
}
}
run(fixture.absolutePath, desktopDb.absolutePath, "program-create")
val desktopPeer =
Regex("PEER_ID=(peer_[^\\s]+)")
.find(
run(
"python3",
generationTool,
"peer-id",
"--database",
desktopDb.absolutePath,
),
)!!
.groupValues[1]
val service = SyncGenerationService(android)
val captured = service.capture(root, desktopPeer)
val published = service.publish(captured, java.io.File(root, "objects"))
val publishedTimeline =
JSONObject(java.io.File(published, "session-timeline-v1.json").readText())
val publishedHeart =
JSONObject(java.io.File(published, "heart-rate-v1.json").readText())
assertEquals(sessionId, publishedTimeline.getJSONArray("sessions").getJSONObject(0).getString("session_id"))
assertEquals(capture.captureId, publishedHeart.getJSONArray("captures").getJSONObject(0).getString("capture_id"))
val firstAck = java.io.File(root, "desktop-ack-first.json")
run(
"python3",
generationTool,
"consume-desktop",
published.absolutePath,
"--database",
desktopDb.absolutePath,
"--ack-output",
firstAck.absolutePath,
)
val countsBeforeReplay =
run(
"sqlite3",
desktopDb.absolutePath,
"SELECT (SELECT count(*) FROM session_exercise_timeline WHERE session_id='$sessionId') || '|' || " +
"(SELECT count(*) FROM heart_rate_samples WHERE capture_id='${capture.captureId}') || '|' || " +
"(SELECT count(*) FROM heart_rate_rr_intervals WHERE capture_id='${capture.captureId}');",
).trim()
assertEquals("2|4|4", countsBeforeReplay)
val replayAck = java.io.File(root, "desktop-ack-replay.json")
run(
"python3",
generationTool,
"consume-desktop",
published.absolutePath,
"--database",
desktopDb.absolutePath,
"--ack-output",
replayAck.absolutePath,
)
assertEquals(
countsBeforeReplay,
run(
"sqlite3",
desktopDb.absolutePath,
"SELECT (SELECT count(*) FROM session_exercise_timeline WHERE session_id='$sessionId') || '|' || " +
"(SELECT count(*) FROM heart_rate_samples WHERE capture_id='${capture.captureId}') || '|' || " +
"(SELECT count(*) FROM heart_rate_rr_intervals WHERE capture_id='${capture.captureId}');",
).trim(),
)
assertEquals(
"acknowledged",
service.acceptAcknowledgement(firstAck.readBytes()),
)
assertEquals(
0,
JSONObject(android.buildSessionTimelineV1Json()).getJSONArray("sessions").length(),
)
assertEquals(
0,
JSONObject(android.buildHeartRateV1Json()).getJSONArray("captures").length(),
)
val desktopFacts =
JSONObject(
run(
"sqlite3",
desktopDb.absolutePath,
"SELECT json_object(" +
"'program_id',x.program_id,'program_session_id',x.program_session_id," +
"'started_at',s.started_at,'ended_at',s.ended_at) FROM sessions s " +
"JOIN program_session_executions x ON x.session_id=s.session_id " +
"WHERE s.session_id='$sessionId' AND x.state='completed';",
).trim(),
)
assertEquals(programId, desktopFacts.getString("program_id"))
assertEquals(programSessionId, desktopFacts.getString("program_session_id"))
assertEquals(sessionStartedAt, desktopFacts.getString("started_at"))
assertEquals(sessionEndedAt, desktopFacts.getString("ended_at"))
assertEquals(
listOf(
"$exerciseAStartedAt|$exerciseAEndedAt|${exerciseA.entryId}",
"$exerciseBStartedAt|$exerciseBEndedAt|${exerciseBEntry.entryId}",
),
run(
"sqlite3",
"-separator",
"|",
desktopDb.absolutePath,
"SELECT started_at,ended_at,entry_id FROM session_exercise_timeline " +
"WHERE session_id='$sessionId' ORDER BY started_at,entry_id;",
).lineSequence().filter { it.isNotBlank() }.toList(),
)
assertEquals(
"session|$sessionId|$sessionStartedAt|$sessionEndedAt",
run(
"sqlite3",
"-separator",
"|",
desktopDb.absolutePath,
"SELECT context_kind,context_id,started_at,ended_at " +
"FROM heart_rate_captures WHERE capture_id='${capture.captureId}';",
).trim(),
)
assertEquals(
listOf(
"0|$preExerciseAt|",
"1|$exerciseASampleAt|${exerciseA.entryId}",
"2|$betweenExercisesAt|",
"3|$exerciseBSampleAt|${exerciseBEntry.entryId}",
),
run(
"sqlite3",
"-separator",
"|",
desktopDb.absolutePath,
"SELECT sequence,observed_at,COALESCE(exercise_entry_id,'') " +
"FROM heart_rate_samples " +
"WHERE capture_id='${capture.captureId}' ORDER BY sequence;",
).lineSequence().filter { it.isNotBlank() }.toList(),
)
assertEquals(
expectedRr,
run(
"sqlite3",
desktopDb.absolutePath,
"SELECT value_1024 FROM heart_rate_rr_intervals " +
"WHERE capture_id='${capture.captureId}' ORDER BY sample_sequence,rr_index;",
).lineSequence().filter { it.isNotBlank() }.map { it.toInt() }.toList(),
)
} finally {
android.close()
context.deleteDatabase(androidName)
root.deleteRecursively()
}
}
}

View file

@ -70,6 +70,57 @@ class ProgramsProjectionRepositoryTest {
.put("deletions", deletions)
.toString()
private fun preparationArtifact(
includeProgramProvenance: Boolean = true,
deliveryState: String = "pending",
): String {
val occurrence =
JSONObject()
.put("entry_id", "spe_11111111-1111-4111-8111-111111111111")
.put("position", 0)
.put("exercise_id", "ex_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee")
.put("equipment_id", JSONObject.NULL)
.put("recording_mode", "sets")
.put("tracking_mode", "reps")
.put("data_fields", 0)
.put("load_mode", "external")
.put("rest_seconds", 90)
.put("target_sets", 3)
.put("target_reps", 8)
.put("target_duration_seconds", JSONObject.NULL)
.put("target_weight_kg", 40.0)
.put("notes", JSONObject.NULL)
val delivery =
JSONObject()
.put("delivery_id", "spd_22222222-2222-4222-8222-222222222222")
.put("preparation_id", "sp_33333333-3333-4333-8333-333333333333")
.put("revision_id", "spr_44444444-4444-4444-8444-444444444444")
.put("execution_session_id", "se_55555555-5555-4555-8555-555555555555")
.put("state", deliveryState)
.put("title", "Séance A")
.put("session_type", "training")
.put("planned_for", JSONObject.NULL)
.put("notes", JSONObject.NULL)
.put("source_proposal_id", JSONObject.NULL)
.put("source_payload_sha256", JSONObject.NULL)
.put("occurrences", JSONArray().put(occurrence))
if (includeProgramProvenance) {
delivery
.put("source_program_id", "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.put(
"source_program_session_id",
"pgs_cccccccc-cccc-4ccc-8ccc-cccccccccccc",
)
}
return JSONObject()
.put("format", "trainlog-session-preparations")
.put("version", 2)
.put("generated_at", "2026-09-18T12:05:00Z")
.put("deliveries", JSONArray().put(delivery))
.put("withdrawals", JSONArray())
.toString()
}
private fun deletion(): JSONObject =
JSONObject()
.put("deletion_id", "pgd_ffffffff-ffff-4fff-8fff-ffffffffffff")
@ -194,4 +245,103 @@ class ProgramsProjectionRepositoryTest {
context.deleteDatabase(name)
}
}
@Test
fun deliveredProgramPreparationCompletesProgramProgressAcrossRestart() {
val name = "program-delivery-${UUID.randomUUID()}.db"
var repository = TrainlogRepository(context, name)
try {
val catalog =
JSONObject()
.put("format", "trainlog-pc-catalog")
.put("version", 1)
.put(
"exercises",
JSONArray().put(
JSONObject()
.put("exercise_id", "ex_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee")
.put("name", "Exercise fixture")
.put("recording_mode", "sets")
.put("tracking_mode", "reps")
.put("data_fields", 0),
),
)
.toString()
assertTrue(repository.applyPcCatalogJson(catalog) is PcCatalogImportResult.Applied)
assertEquals(
ProgramsImportResult.Applied(1, 0, 0),
repository.applyProgramsV1Json(artifact(JSONArray().put(program()))),
)
assertEquals(
AiSessionDraftImportResult.Applied(1, 0),
repository.applySessionPreparationsJson(
preparationArtifact(includeProgramProvenance = false),
),
)
assertEquals(
StartAiSessionDraftResult.Started,
repository.startPreparedSession("spd_22222222-2222-4222-8222-222222222222"),
)
val active =
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
assertEquals(null, active.sourceProgramId)
assertEquals(null, active.sourceProgramSessionId)
val performed =
active.copy(
exercises =
active.exercises.map { occurrence ->
occurrence.copy(
sets = listOf(SessionSetDraft(reps = 8, weightKg = 40.0)),
)
},
)
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(performed),
)
assertTrue(repository.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
assertEquals(
ProgramSessionExecutionState.TODO,
repository.getSyncedProgram("pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")!!
.sessions.single().executionState,
)
assertEquals(
AiSessionDraftImportResult.Applied(0, 1),
repository.applySessionPreparationsJson(
preparationArtifact(deliveryState = "remote_unknown"),
),
)
val detail = repository.getSyncedProgram("pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")!!
assertEquals(ProgramSessionExecutionState.COMPLETED, detail.sessions.single().executionState)
val execution =
JSONObject(repository.buildProgramExecutionsV1Json())
.getJSONArray("executions")
.getJSONObject(0)
assertEquals("completed", execution.getString("state"))
assertEquals(
"pgs_cccccccc-cccc-4ccc-8ccc-cccccccccccc",
execution.getString("program_session_id"),
)
} finally {
repository.close()
}
repository = TrainlogRepository(context, name)
try {
val detail = repository.getSyncedProgram("pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")!!
assertEquals(1, detail.sessions.count {
it.executionState == ProgramSessionExecutionState.COMPLETED
})
assertEquals(
StartProgramSessionResult.AlreadyCompleted,
repository.startSyncedProgramSession(
"pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
"pgs_cccccccc-cccc-4ccc-8ccc-cccccccccccc",
),
)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

@ -105,6 +105,106 @@ class SessionPreparationExchangeTest {
}
}
@Test
fun startingMixedPreparationCopiesTargetsWithoutPerformedRows() {
val name = "prepared-mixed-${System.nanoTime()}.db"
val repository = TrainlogRepository(context, name)
try {
assertTrue(
repository.createExercise(
NewExerciseProfile(
name = "Test prepared walk",
recordingMode = RecordingMode.CONTINUOUS,
trackingMode = TrackingMode.DURATION,
dataFields = 0,
),
) is CreateExerciseResult.Created,
)
assertTrue(
repository.createExercise(
NewExerciseProfile(
name = "Test prepared press",
recordingMode = RecordingMode.SETS,
trackingMode = TrackingMode.REPS,
dataFields = 0,
),
) is CreateExerciseResult.Created,
)
val continuous = repository.listExercises().single { it.name == "Test prepared walk" }
val sets = repository.listExercises().single { it.name == "Test prepared press" }
val payload = JSONObject(artifact(repository))
val delivery = payload.getJSONArray("deliveries").getJSONObject(0)
delivery.put(
"occurrences",
JSONArray()
.put(
JSONObject()
.put("entry_id", "spe_11111111-1111-4111-8111-111111111111")
.put("position", 0)
.put("exercise_id", continuous.exerciseId)
.put("equipment_id", JSONObject.NULL)
.put("recording_mode", "continuous")
.put("tracking_mode", "duration")
.put("data_fields", continuous.dataFields)
.put("load_mode", "none")
.put("rest_seconds", 0)
.put("target_sets", JSONObject.NULL)
.put("target_reps", JSONObject.NULL)
.put("target_duration_seconds", 600)
.put("target_weight_kg", JSONObject.NULL)
.put("notes", "Target only"),
)
.put(
JSONObject()
.put("entry_id", "spe_22222222-2222-4222-8222-222222222222")
.put("position", 1)
.put("exercise_id", sets.exerciseId)
.put("equipment_id", JSONObject.NULL)
.put("recording_mode", "sets")
.put("tracking_mode", "reps")
.put("data_fields", sets.dataFields)
.put("load_mode", "external")
.put("rest_seconds", 90)
.put("target_sets", 3)
.put("target_reps", 10)
.put("target_duration_seconds", JSONObject.NULL)
.put("target_weight_kg", 52.0)
.put("notes", JSONObject.NULL),
),
)
assertEquals(
AiSessionDraftImportResult.Applied(1, 0),
repository.applySessionPreparationsJson(payload.toString()),
)
assertEquals(
StartAiSessionDraftResult.Started,
repository.startPreparedSession("spd_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
)
val loadResult = repository.loadActiveSessionDraft()
assertTrue(loadResult.toString(), loadResult is ActiveDraftLoadResult.Loaded)
val active = loadResult as ActiveDraftLoadResult.Loaded
assertEquals(2, active.draft.exercises.size)
assertEquals(600, active.draft.exercises[0].plan?.durationSeconds)
assertEquals(0, active.draft.exercises[0].continuousDurationSeconds)
assertEquals(3, active.draft.exercises[1].plan?.sets)
assertEquals(10, active.draft.exercises[1].plan?.reps)
assertEquals(52.0, active.draft.exercises[1].plan?.weightKg)
assertTrue(active.draft.exercises[1].sets.isEmpty())
assertEquals(1, scalarInt(name, "SELECT COUNT(*) FROM active_session_draft"))
assertEquals(0, scalarInt(name, "SELECT COUNT(*) FROM draft_continuous_activity"))
assertEquals(0, scalarInt(name, "SELECT COUNT(*) FROM draft_performed_sets"))
assertEquals(0, scalarInt(name, "SELECT COUNT(*) FROM draft_max_results"))
assertEquals(0, scalarInt(name, "SELECT COUNT(*) FROM draft_exercise_feedback"))
assertEquals(0, scalarInt(name, "SELECT COUNT(*) FROM sessions"))
assertEquals("ok", scalarText(name, "PRAGMA integrity_check"))
assertEquals(0, scalarInt(name, "SELECT COUNT(*) FROM pragma_foreign_key_check"))
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun deliveryReplaysStartsExplicitlyAndNeverOverwritesActiveDraft() {
val name = "prepared-${System.nanoTime()}.db"
@ -260,7 +360,7 @@ class SessionPreparationExchangeTest {
repository = TrainlogRepository(context, name)
assertEquals(1, repository.listPreparedSessions().size)
repository.close()
assertEquals("25", scalarText(name, "PRAGMA user_version"))
assertEquals("33", scalarText(name, "PRAGMA user_version"))
assertEquals(
"0",
scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"),

View file

@ -0,0 +1,458 @@
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.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 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)
}
}
@Test
fun programSessionFinalizesMeasuredContinuousTargetAndExtraPerformedSet() {
val name = "program-session-finish-${UUID.randomUUID()}.db"
val repository = TrainlogRepository(context, name)
try {
val programId = "pg_11111111-1111-4111-8111-111111111111"
val programSessionId = "pgs_22222222-2222-4222-8222-222222222222"
val warmupId = "ex_33333333-3333-4333-8333-333333333333"
val rowId = "ex_44444444-4444-4444-8444-444444444444"
val pressId = "ex_55555555-5555-4555-8555-555555555555"
val curlId = "ex_66666666-6666-4666-8666-666666666666"
fun catalogExercise(
exerciseId: String,
exerciseName: String,
recordingMode: String,
trackingMode: String,
) =
JSONObject()
.put("exercise_id", exerciseId)
.put("name", exerciseName)
.put("recording_mode", recordingMode)
.put("tracking_mode", trackingMode)
.put("data_fields", 0)
val catalog =
JSONObject()
.put("format", "trainlog-pc-catalog")
.put("version", 1)
.put(
"exercises",
JSONArray()
.put(catalogExercise(warmupId, "Program warmup", "continuous", "duration"))
.put(catalogExercise(rowId, "Program row", "sets", "reps"))
.put(catalogExercise(pressId, "Program press", "sets", "reps"))
.put(catalogExercise(curlId, "Program arm curl", "sets", "reps")),
)
.toString()
assertTrue(repository.applyPcCatalogJson(catalog) is PcCatalogImportResult.Applied)
fun occurrence(
entryId: String,
exerciseId: String,
targetSets: Int?,
targetReps: Int?,
targetDuration: Int?,
targetWeight: Double?,
restSeconds: Int,
) =
JSONObject()
.put("entry_id", entryId)
.put("exercise_id", exerciseId)
.put("equipment_id", JSONObject.NULL)
.put("load_mode", if (targetWeight == null) "none" else "external")
.put("rest_seconds", restSeconds)
.put("target_sets", targetSets ?: JSONObject.NULL)
.put("target_reps", targetReps ?: JSONObject.NULL)
.put("target_duration_seconds", targetDuration ?: JSONObject.NULL)
.put("target_weight_kg", targetWeight ?: JSONObject.NULL)
.put("notes", JSONObject.NULL)
val program =
JSONObject()
.put("program_id", programId)
.put("revision_id", "pgr_77777777-7777-4777-8777-777777777777")
.put("title", "Session finalization regression")
.put("note", JSONObject.NULL)
.put("state", "active")
.put("start_date", "2026-09-24")
.put("end_date", JSONObject.NULL)
.put("created_at", "2026-09-24T08:00:00Z")
.put("updated_at", "2026-09-24T08: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", "Workout")
.put("session_type", "training")
.put("planned_for", "2026-09-24")
.put("note", JSONObject.NULL)
.put(
"occurrences",
JSONArray()
.put(
occurrence(
"pge_88888888-8888-4888-8888-888888888888",
warmupId,
null,
null,
480,
null,
0,
),
)
.put(
occurrence(
"pge_99999999-9999-4999-8999-999999999999",
rowId,
3,
10,
null,
39.0,
90,
),
)
.put(
occurrence(
"pge_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
pressId,
3,
10,
null,
32.0,
90,
),
)
.put(
occurrence(
"pge_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
curlId,
2,
10,
null,
23.0,
60,
),
),
),
),
)
val programs =
JSONObject()
.put("format", "trainlog-programs")
.put("version", 1)
.put("generated_at", "2026-09-24T08:01:00Z")
.put("programs", JSONArray().put(program))
.put("deletions", JSONArray())
.toString()
assertEquals(
ProgramsImportResult.Applied(1, 0, 0),
repository.applyProgramsV1Json(programs),
)
assertEquals(
StartProgramSessionResult.Started,
repository.startSyncedProgramSession(programId, programSessionId),
)
val opened = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
val sessionId = checkNotNull(opened.draft.sessionId)
val sessionStart = OffsetDateTime.parse(checkNotNull(opened.draft.startedAt))
val warmup = opened.draft.exercises[0]
val row = opened.draft.exercises[1]
val press = opened.draft.exercises[2]
val curl = opened.draft.exercises[3]
val performed =
opened.draft.copy(
exercises =
listOf(
warmup,
row.copy(
sets = List(3) { SessionSetDraft(reps = 10, weightKg = 39.0) },
),
press.copy(
sets = List(3) { SessionSetDraft(reps = 10, weightKg = 32.0) },
),
curl.copy(
sets = List(3) { SessionSetDraft(reps = 10, weightKg = 23.0) },
),
),
)
assertEquals(ActiveDraftMutationResult.Saved, repository.saveActiveSessionDraft(performed))
val warmupStart = sessionStart.plusSeconds(10)
val warmupEnd = warmupStart.plusSeconds(433)
assertTrue(
repository.startActiveSessionExercise(warmup.entryId, warmupStart.toString()) is
TrainlogRepository.SessionExerciseTimingResult.Started,
)
assertTrue(
repository.finishActiveSessionExercise(warmup.entryId, warmupEnd.toString()) is
TrainlogRepository.SessionExerciseTimingResult.Finished,
)
val materialized = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
assertEquals(433, materialized.draft.exercises[0].continuousDurationSeconds)
/* Recreate the exact v49 persisted state: its timeline was closed,
* but the target-only continuous occurrence had no performed row. */
context.openOrCreateDatabase(name, Context.MODE_PRIVATE, null).use { db ->
db.execSQL(
"DELETE FROM draft_continuous_activity WHERE draft_exercise_row_id=(" +
"SELECT id FROM draft_session_exercises WHERE draft_id=1 AND entry_id=?)",
arrayOf(warmup.entryId),
)
}
val legacy = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
assertEquals(0, legacy.draft.exercises[0].continuousDurationSeconds)
listOf(row, press, curl).forEachIndexed { index, exercise ->
val startedAt = warmupEnd.plusSeconds(30L + index * 90L)
val endedAt = startedAt.plusSeconds(60)
assertTrue(
repository.startActiveSessionExercise(exercise.entryId, startedAt.toString()) is
TrainlogRepository.SessionExerciseTimingResult.Started,
)
assertTrue(
repository.finishActiveSessionExercise(exercise.entryId, endedAt.toString()) is
TrainlogRepository.SessionExerciseTimingResult.Finished,
)
}
assertTrue(
repository.saveDraftExerciseFeedback(
curl.entryId,
"Valid effort feedback",
warmupEnd.plusSeconds(400).toString(),
) is SaveFeedbackResult.Saved,
)
val measurement =
ParsedHeartRateMeasurement(
bpm = 107,
sensorContactDetected = true,
energyExpended = null,
rrIntervals1024 = listOf(620),
)
val finalizationAt = warmupEnd.plusSeconds(500)
assertTrue(
repository.recordLiveHeartRateForActiveSession(
"Synthetic HR",
finalizationAt.minusSeconds(1).toString(),
measurement,
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
val lateSampleAt = finalizationAt.plusSeconds(10)
assertTrue(
repository.recordLiveHeartRateForActiveSession(
"Synthetic HR",
lateSampleAt.toString(),
measurement.copy(bpm = 99),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
val finalized = repository.finalizeActiveSessionDraft(finalizationAt.toString())
assertEquals(
sessionId,
(finalized as FinalizeActiveDraftResult.Saved).sessionId,
)
val detail = checkNotNull(repository.getSessionDetail(sessionId))
assertEquals(finalizationAt.toString(), detail.summary.endedAt)
assertEquals(4, detail.exercises.size)
assertEquals(433, detail.exercises[0].continuousDurationSeconds)
assertEquals(2, detail.exercises[3].plan?.sets)
assertEquals(3, detail.exercises[3].sets.size)
assertTrue(detail.exercises[3].sets.all { it.reps == 10 && it.weightKg == 23.0 })
assertEquals(1, detail.exercises[3].feedback.size)
val timeline = JSONObject(repository.buildSessionTimelineV1Json())
val timelineSession = timeline.getJSONArray("sessions").getJSONObject(0)
assertEquals(finalizationAt.toString(), timelineSession.getString("ended_at"))
assertEquals(4, timelineSession.getJSONArray("exercises").length())
val heart = JSONObject(repository.buildHeartRateV1Json())
val capture = heart.getJSONArray("captures").getJSONObject(0)
assertEquals(finalizationAt.toString(), capture.getString("ended_at"))
assertEquals(1, capture.getJSONArray("samples").length())
assertEquals(1, capture.getJSONArray("samples").getJSONObject(0)
.getJSONArray("rr_intervals_1024").length())
assertNull(repository.activeHeartRateCapture())
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

@ -0,0 +1,209 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.TrackingMode
import java.nio.file.Files
import java.time.OffsetDateTime
import java.util.UUID
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class SessionTimelineSyncGenerationTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun timelineAndHeartRateArriveTogetherAndStopPublishingAfterAck() {
val sourceName = "timeline-sync-" + UUID.randomUUID() + ".db"
val root = Files.createTempDirectory("trainlog-timeline-sync-").toFile()
val source = TrainlogRepository(context, sourceName)
try {
fun exercise(label: String) =
(source.createExercise(
NewExerciseProfile(label, RecordingMode.SETS, TrackingMode.REPS, 0),
) as CreateExerciseResult.Created).exercise
val a = SessionExerciseDraft(
exercise = exercise("Sync timeline A"),
sets = listOf(SessionSetDraft(reps = 8)),
)
val b = SessionExerciseDraft(
exercise = exercise("Sync timeline B"),
sets = listOf(SessionSetDraft(reps = 9)),
)
assertEquals(ActiveDraftMutationResult.Saved, source.startActiveSessionDraft())
val opened = source.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
assertEquals(
ActiveDraftMutationResult.Saved,
source.saveActiveSessionDraft(opened.draft.copy(exercises = listOf(a, b))),
)
assertTrue(
source.startActiveSessionExercise(a.entryId) is
TrainlogRepository.SessionExerciseTimingResult.Started,
)
val base = ParsedHeartRateMeasurement(96, null, null, listOf(640))
assertTrue(
source.recordLiveHeartRateForActiveSession(
"Synthetic HR",
OffsetDateTime.now().toString(),
base,
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
assertTrue(
source.finishActiveSessionExercise(a.entryId) is
TrainlogRepository.SessionExerciseTimingResult.Finished,
)
assertTrue(
source.recordLiveHeartRateForActiveSession(
"Synthetic HR",
OffsetDateTime.now().toString(),
base.copy(bpm = 84),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
assertTrue(
source.startActiveSessionExercise(b.entryId) is
TrainlogRepository.SessionExerciseTimingResult.Started,
)
assertTrue(
source.recordLiveHeartRateForActiveSession(
"Synthetic HR",
OffsetDateTime.now().toString(),
base.copy(bpm = 105),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
assertTrue(
source.finishActiveSessionExercise(b.entryId) is
TrainlogRepository.SessionExerciseTimingResult.Finished,
)
assertTrue(source.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
val repositoryRoot =
generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))) {
it.parentFile
}.first { java.io.File(it, "tools/sync_generation_exchange.py").isFile }
fun run(vararg command: String): String {
val process =
ProcessBuilder(*command)
.directory(repositoryRoot)
.redirectErrorStream(true)
.start()
val output = process.inputStream.bufferedReader().readText()
assertEquals(
"command failed: " + command.joinToString(" ") + "\n" + output,
0,
process.waitFor(),
)
return output
}
val desktopDb = java.io.File(root, "desktop.sqlite")
run(
java.io.File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
desktopDb.absolutePath,
)
val desktopPeer =
Regex("PEER_ID=(peer_[^\\s]+)")
.find(
run(
"python3",
java.io.File(
repositoryRoot,
"tools/sync_generation_exchange.py",
).absolutePath,
"peer-id",
"--database",
desktopDb.absolutePath,
),
)!!
.groupValues[1]
val service = SyncGenerationService(source)
val captured = service.capture(root, desktopPeer)
val timelineJson =
JSONObject(
java.io.File(
captured.stagingDirectory,
"session-timeline-v1.json",
).readText(),
)
assertEquals(1, timelineJson.getJSONArray("sessions").length())
assertEquals(
2,
timelineJson
.getJSONArray("sessions")
.getJSONObject(0)
.getJSONArray("exercises")
.length(),
)
val heartJson =
JSONObject(
java.io.File(captured.stagingDirectory, "heart-rate-v1.json").readText(),
)
assertEquals(
3,
heartJson
.getJSONArray("captures")
.getJSONObject(0)
.getJSONArray("samples")
.length(),
)
val published = service.publish(captured, java.io.File(root, "objects"))
val ackFile = java.io.File(root, "ack.json")
run(
"python3",
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath,
"consume-desktop",
published.absolutePath,
"--database",
desktopDb.absolutePath,
"--ack-output",
ackFile.absolutePath,
)
val ack = JSONObject(ackFile.readText())
assertEquals(
"desktop ACK diagnostic: " + ack.getString("diagnostic"),
"consumed",
ack.getString("result"),
)
assertEquals("acknowledged", service.acceptAcknowledgement(ackFile.readBytes()))
assertEquals(
"2|3|3",
run(
"sqlite3",
desktopDb.absolutePath,
"SELECT (SELECT count(*) FROM session_exercise_timeline) || '|' || " +
"(SELECT count(*) FROM heart_rate_samples) || '|' || " +
"(SELECT count(*) FROM heart_rate_rr_intervals);",
).trim(),
)
assertEquals(
0,
JSONObject(source.buildSessionTimelineV1Json())
.getJSONArray("sessions")
.length(),
)
assertEquals(
0,
JSONObject(source.buildHeartRateV1Json())
.getJSONArray("captures")
.length(),
)
} finally {
source.close()
context.deleteDatabase(sourceName)
root.deleteRecursively()
}
}
}

View file

@ -0,0 +1,434 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteException
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.SleepDiaryDraft
import com.labfytools.trainlog.model.SleepDiaryEvent
import com.labfytools.trainlog.model.SleepEventType
import com.labfytools.trainlog.model.SleepQuality
import com.labfytools.trainlog.model.SleepPublicationStatus
import com.labfytools.trainlog.model.MedicationIntake
import com.labfytools.trainlog.model.SleepMedication
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
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 SleepDiaryRepositoryTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test fun createEditConflictDeleteAndReopen() {
val name = "sleep-${UUID.randomUUID()}.db"
var repository = TrainlogRepository(context, name)
try {
val catalogTime = "2026-10-24T18:00:00+02:00"
val medicationResult = repository.saveSleepMedication(SleepMedication("", "", catalogTime,
catalogTime, "Synthetic medication", 5.0, "mg", "tablet", "", true)) as
TrainlogRepository.SaveSleepDiaryResult.Saved
val initial = SleepDiaryDraft(nightStartDate = "2026-10-24", nightEndDate = "2026-10-25",
createdAt = "2026-10-24T23:30:00+02:00", updatedAt = "2026-10-24T23:30:00+02:00",
sleepQuality = SleepQuality.B, wakeQuality = null, dayForm = null,
treatmentAndNotes = "Synthetic fixture", events = listOf(
SleepDiaryEvent("", SleepEventType.SLEEP, "2026-10-24T23:30:00+02:00", "2026-10-25T06:30:00+01:00"),
SleepDiaryEvent("", SleepEventType.DAYTIME_SLEEPINESS, "2026-10-25T14:00:00+01:00", null)),
intakes = listOf(MedicationIntake("", medicationResult.entryId,
"Synthetic medication", "2026-10-25T05:15:00+01:00", 10.0, "mg", "", catalogTime)))
val created = repository.saveSleepDiary(initial) as TrainlogRepository.SaveSleepDiaryResult.Saved
val loaded = repository.listSleepDiary().single()
assertEquals(SleepPublicationStatus.DRAFT, loaded.publicationStatus)
assertEquals(0, org.json.JSONObject(repository.buildSleepDiaryV1Json())
.getJSONArray("entries").length())
assertTrue(repository.validateSleepDiary(loaded.entryId, loaded.revisionId,
"2026-10-25T17:30:00+01:00") is TrainlogRepository.SaveSleepDiaryResult.Saved)
assertEquals(SleepPublicationStatus.READY,
repository.listSleepDiary().single().publicationStatus)
assertEquals(1, org.json.JSONObject(repository.buildSleepDiaryV1Json())
.getJSONArray("entries").length())
assertEquals(2, loaded.events.size)
assertEquals(10.0, loaded.intakes.single().doseValue!!, 0.0)
val medication = repository.listSleepMedications().single()
assertEquals(5.0, medication.defaultDoseValue!!, 0.0)
assertTrue(repository.saveSleepMedication(medication.copy(name = "Renamed medication",
updatedAt = "2026-10-25T12:00:00+01:00")) is
TrainlogRepository.SaveSleepDiaryResult.Saved)
assertEquals("Synthetic medication", loaded.intakes.single().medicationName)
val edited = repository.saveSleepDiary(initial.copy(entryId = created.entryId,
expectedRevision = created.revisionId, updatedAt = "2026-10-25T18:00:00+01:00",
wakeQuality = SleepQuality.MOY, dayForm = SleepQuality.TB))
assertTrue(edited is TrainlogRepository.SaveSleepDiaryResult.Saved)
assertTrue(repository.saveSleepDiary(initial.copy(entryId = created.entryId,
expectedRevision = created.revisionId)) is TrainlogRepository.SaveSleepDiaryResult.Conflict)
repository.close(); repository = TrainlogRepository(context, name)
val reopened = repository.listSleepDiary().single()
assertEquals(SleepQuality.MOY, reopened.wakeQuality)
assertEquals(SleepPublicationStatus.DRAFT, reopened.publicationStatus)
assertTrue(repository.deleteSleepDiary(reopened.entryId, reopened.revisionId,
"2026-10-26T12:00:00+01:00") is TrainlogRepository.SaveSleepDiaryResult.Saved)
assertTrue(repository.listSleepDiary().isEmpty())
} finally { repository.close(); context.deleteDatabase(name) }
}
@Test
fun persistedSleepRevisionPayloadIsAppendOnly() {
val name = "sleep-immutable-${UUID.randomUUID()}.db"
val repository = TrainlogRepository(context, name)
val saved = try {
repository.saveSleepDiary(
SleepDiaryDraft(
nightStartDate = "2026-10-24",
nightEndDate = "2026-10-25",
createdAt = "2026-10-24T22:00:00+02:00",
updatedAt = "2026-10-25T07:00:00+01:00",
sleepQuality = SleepQuality.B,
wakeQuality = null,
dayForm = null,
treatmentAndNotes = "",
events = emptyList(),
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
} finally {
repository.close()
}
val raw = SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READWRITE,
)
try {
assertEquals(33, raw.version)
assertThrows(SQLiteException::class.java) {
raw.execSQL(
"UPDATE sleep_diary_revisions SET sleep_quality='TB' WHERE revision_id=?",
arrayOf(saved.revisionId),
)
}
assertThrows(SQLiteException::class.java) {
raw.execSQL(
"DELETE FROM sleep_diary_revisions WHERE revision_id=?",
arrayOf(saved.revisionId),
)
}
assertEquals(
"B",
raw.rawQuery(
"SELECT sleep_quality FROM sleep_diary_revisions WHERE revision_id=?",
arrayOf(saved.revisionId),
).use { cursor ->
assertTrue(cursor.moveToFirst())
cursor.getString(0)
},
)
} finally {
raw.close()
context.deleteDatabase(name)
}
}
@Test
fun successiveSleepEditsKeepUniqueIdentitiesAndExactParentage() {
val name = "sleep-revision-chain-${UUID.randomUUID()}.db"
val repository = TrainlogRepository(context, name)
val identities = mutableListOf<String>()
val expectedParents = mutableMapOf<String, String?>()
val expectedNotes = mutableMapOf<String, String>()
try {
val created = repository.saveSleepDiary(
SleepDiaryDraft(
nightStartDate = "2026-10-24",
nightEndDate = "2026-10-25",
createdAt = "2026-10-24T22:00:00+02:00",
updatedAt = "2026-10-25T07:00:00+01:00",
sleepQuality = null,
wakeQuality = null,
dayForm = null,
treatmentAndNotes = "revision-root",
events = emptyList(),
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
identities += created.revisionId
expectedParents[created.revisionId] = null
expectedNotes[created.revisionId] = "revision-root"
repeat(64) { index ->
val current = repository.listSleepDiary().single()
val note = "revision-$index"
val hour = 8 + index / 60
val minute = index % 60
val saved = repository.saveSleepDiary(
SleepDiaryDraft(
entryId = current.entryId,
expectedRevision = current.revisionId,
nightStartDate = current.nightStartDate,
nightEndDate = current.nightEndDate,
createdAt = current.createdAt,
updatedAt = "2026-10-25T%02d:%02d:00+01:00".format(hour, minute),
sleepQuality = current.sleepQuality,
wakeQuality = current.wakeQuality,
dayForm = current.dayForm,
treatmentAndNotes = note,
events = current.events,
intakes = current.intakes,
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
expectedParents[saved.revisionId] = current.revisionId
expectedNotes[saved.revisionId] = note
identities += saved.revisionId
}
} finally {
repository.close()
}
assertEquals(65, identities.toSet().size)
val raw = SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
)
try {
identities.forEach { revisionId ->
raw.rawQuery(
"SELECT parent_revision_id,treatment_and_notes " +
"FROM sleep_diary_revisions WHERE revision_id=?",
arrayOf(revisionId),
).use { cursor ->
assertTrue(cursor.moveToFirst())
val parent = if (cursor.isNull(0)) null else cursor.getString(0)
assertEquals(expectedParents[revisionId], parent)
assertEquals(expectedNotes[revisionId], cursor.getString(1))
}
}
} finally {
raw.close()
context.deleteDatabase(name)
}
}
@Test
fun ancestryAllowsMultiHopRemoteAdvance() {
val sourceName = "sleep-stale-source-${UUID.randomUUID()}.db"
val destinationName = "sleep-stale-destination-${UUID.randomUUID()}.db"
val source = TrainlogRepository(context, sourceName)
val destination = TrainlogRepository(context, destinationName)
try {
val createdAt = "2026-09-20T20:00:00+02:00"
source.saveSleepMedication(
SleepMedication("", "", createdAt, createdAt, "Medication", 5.0, "mg", "", "", true),
)
val firstSaved = source.saveSleepDiary(
SleepDiaryDraft(
nightStartDate = "2026-09-20",
nightEndDate = "2026-09-21",
createdAt = createdAt,
updatedAt = createdAt,
sleepQuality = SleepQuality.B,
wakeQuality = null,
dayForm = null,
treatmentAndNotes = "",
events = listOf(
SleepDiaryEvent(
"",
SleepEventType.SLEEP,
"2026-09-20T23:00:00+02:00",
"2026-09-21T07:00:00+02:00",
),
),
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
assertTrue(
source.validateSleepDiary(
firstSaved.entryId,
firstSaved.revisionId,
"2026-09-21T08:00:00+02:00",
) is TrainlogRepository.SaveSleepDiaryResult.Saved,
)
val firstSnapshot = source.buildSleepDiaryV1Json()
assertEquals(
TrainlogRepository.SleepDiaryImportResult.Applied(1, 0),
destination.applySleepDiaryV1Json(firstSnapshot),
)
val medication = source.listSleepMedications().single()
source.saveSleepMedication(
medication.copy(
name = "Medication revised",
updatedAt = "2026-09-21T08:05:00+02:00",
),
)
val current = source.listSleepDiary().single()
val secondSaved = source.saveSleepDiary(
SleepDiaryDraft(
entryId = current.entryId,
expectedRevision = current.revisionId,
nightStartDate = current.nightStartDate,
nightEndDate = current.nightEndDate,
createdAt = current.createdAt,
updatedAt = "2026-09-21T08:10:00+02:00",
sleepQuality = current.sleepQuality,
wakeQuality = SleepQuality.MOY,
dayForm = current.dayForm,
treatmentAndNotes = current.treatmentAndNotes,
events = current.events,
intakes = current.intakes,
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
assertTrue(
source.validateSleepDiary(
secondSaved.entryId,
secondSaved.revisionId,
"2026-09-21T08:15:00+02:00",
) is TrainlogRepository.SaveSleepDiaryResult.Saved,
)
val afterSecond = source.listSleepDiary().single()
val thirdSaved = source.saveSleepDiary(
SleepDiaryDraft(
entryId = afterSecond.entryId,
expectedRevision = secondSaved.revisionId,
nightStartDate = afterSecond.nightStartDate,
nightEndDate = afterSecond.nightEndDate,
createdAt = afterSecond.createdAt,
updatedAt = "2026-09-21T08:20:00+02:00",
sleepQuality = afterSecond.sleepQuality,
wakeQuality = afterSecond.wakeQuality,
dayForm = SleepQuality.TB,
treatmentAndNotes = afterSecond.treatmentAndNotes,
events = afterSecond.events,
intakes = afterSecond.intakes,
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
assertTrue(
source.validateSleepDiary(
thirdSaved.entryId,
thirdSaved.revisionId,
"2026-09-21T08:25:00+02:00",
) is TrainlogRepository.SaveSleepDiaryResult.Saved,
)
val latestSnapshot = source.buildSleepDiaryV1Json()
val ancestry =
org.json.JSONObject(latestSnapshot)
.getJSONArray("entries")
.getJSONObject(0)
.getJSONArray("ancestry")
assertTrue((0 until ancestry.length()).any { ancestry.getString(it) == firstSaved.revisionId })
assertEquals(
TrainlogRepository.SleepDiaryImportResult.Applied(1, 0),
destination.applySleepDiaryV1Json(latestSnapshot),
)
assertEquals(thirdSaved.revisionId, destination.listSleepDiary().single().revisionId)
assertEquals(SleepQuality.MOY, destination.listSleepDiary().single().wakeQuality)
assertEquals(SleepQuality.TB, destination.listSleepDiary().single().dayForm)
assertEquals("Medication revised", destination.listSleepMedications().single().name)
val reusedAncestor = org.json.JSONObject(firstSnapshot)
reusedAncestor.getJSONArray("entries").getJSONObject(0).put("sleep_quality", "M")
assertEquals(
TrainlogRepository.SleepDiaryImportResult.Rejected(
"sleep diary revision identity reused with different content",
),
destination.applySleepDiaryV1Json(reusedAncestor.toString()),
)
} finally {
source.close()
destination.close()
context.deleteDatabase(sourceName)
context.deleteDatabase(destinationName)
}
}
@Test
fun currentSnapshotSeedsFreshPeerFromNonRootRevisions() {
val sourceName = "sleep-source-${UUID.randomUUID()}.db"
val destinationName = "sleep-destination-${UUID.randomUUID()}.db"
val source = TrainlogRepository(context, sourceName)
val destination = TrainlogRepository(context, destinationName)
try {
val time = "2026-09-21T20:00:00+02:00"
val createdMedication =
source.saveSleepMedication(
SleepMedication("", "", time, time, "Medication", 5.0, "mg", "", "", true),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
val medication = source.listSleepMedications().single()
source.saveSleepMedication(
medication.copy(name = "Medication revised", updatedAt = "2026-09-21T20:05:00+02:00"),
)
val createdEntry =
source.saveSleepDiary(
SleepDiaryDraft(
nightStartDate = "2026-09-20",
nightEndDate = "2026-09-21",
createdAt = time,
updatedAt = time,
sleepQuality = null,
wakeQuality = null,
dayForm = null,
treatmentAndNotes = "",
events = listOf(
SleepDiaryEvent(
"",
SleepEventType.SLEEP,
"2026-09-20T23:00:00+02:00",
"2026-09-21T07:00:00+02:00",
),
),
intakes = listOf(
MedicationIntake(
"",
createdMedication.entryId,
"Medication",
"2026-09-20T22:00:00+02:00",
5.0,
"mg",
"",
time,
),
),
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
val current = source.listSleepDiary().single()
val edited =
source.saveSleepDiary(
SleepDiaryDraft(
entryId = current.entryId,
expectedRevision = createdEntry.revisionId,
nightStartDate = current.nightStartDate,
nightEndDate = current.nightEndDate,
createdAt = current.createdAt,
updatedAt = "2026-09-21T20:10:00+02:00",
sleepQuality = current.sleepQuality,
wakeQuality = current.wakeQuality,
dayForm = SleepQuality.B,
treatmentAndNotes = current.treatmentAndNotes,
events = current.events,
intakes = current.intakes,
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
assertTrue(
source.validateSleepDiary(
current.entryId,
edited.revisionId,
"2026-09-21T20:15:00+02:00",
) is TrainlogRepository.SaveSleepDiaryResult.Saved,
)
assertEquals(
TrainlogRepository.SleepDiaryImportResult.Applied(1, 0),
destination.applySleepDiaryV1Json(source.buildSleepDiaryV1Json()),
)
assertEquals("Medication revised", destination.listSleepMedications().single().name)
assertEquals(SleepQuality.B, destination.listSleepDiary().single().dayForm)
} finally {
source.close()
destination.close()
context.deleteDatabase(sourceName)
context.deleteDatabase(destinationName)
}
}
}

View file

@ -0,0 +1,287 @@
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.LONG_AWAKE },
)
assertEquals(
listOf(
"2026-09-24T02:17:00+02:00" to "2026-09-24T02:47:00+02:00",
"2026-09-24T04:42:00+02:00" to "2026-09-24T05:12:00+02:00",
),
beforeGetUp.events
.filter { it.type == SleepEventType.LONG_AWAKE }
.map { it.startAt to it.endAt },
)
assertTrue(beforeGetUp.events.none { 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.LONG_AWAKE }
.map { it.startAt },
)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun repeatedWakeExtendsOneWindowAndFinalGetUpClampsIt() {
val name = "sleep-wake-window-" + UUID.randomUUID()
val repository = TrainlogRepository(context, name)
try {
repository.quickSleepBedTime("2026-09-23T22:05:00+02:00")
repository.quickSleepWake("2026-09-24T04:11:00+02:00")
repository.quickSleepWake("2026-09-24T04:14:00+02:00")
val extended = repository.listSleepDiary().single()
assertEquals(
listOf("2026-09-24T04:11:00+02:00" to "2026-09-24T04:44:00+02:00"),
extended.events
.filter { it.type == SleepEventType.LONG_AWAKE }
.map { it.startAt to it.endAt },
)
repository.quickSleepFinalGetUp("2026-09-24T04:14:30+02:00")
val completed = repository.listSleepDiary().single()
assertEquals(
listOf("2026-09-24T04:11:00+02:00" to "2026-09-24T04:14:30+02:00"),
completed.events
.filter { it.type == SleepEventType.LONG_AWAKE }
.map { it.startAt to it.endAt },
)
} 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) VALUES(?,?,?)", arrayOf(id, timestamp, type))
db.execSQL("INSERT INTO sessions(session_id,started_at,session_type,session_kind) VALUES(?,?,?,?)", arrayOf(id, timestamp, type, type))
}
private fun occurrence(

View file

@ -0,0 +1,251 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import java.io.File
import java.nio.file.Files
import java.time.Duration
import java.util.UUID
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
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 SyncConversationOwnershipTest {
private val context: Context = ApplicationProvider.getApplicationContext()
private fun request(root: File, runId: String, androidPeer: String, desktopPeer: String) {
File(root, "request-v1.json").writeText(
JSONObject()
.put("format", "trainlog-sync-generation-request")
.put("version", 1)
.put("run_id", runId)
.put("android_peer_id", androidPeer)
.put("desktop_peer_id", desktopPeer)
.toString()
)
}
private fun archiveAcknowledgements(
root: File,
runId: String,
androidPeer: String,
desktopPeer: String,
) {
File(root, "desktop-archive-acknowledgements-v1.json").writeText(
JSONObject()
.put("format", "trainlog-sync-archive-acknowledgements")
.put("version", 1)
.put("run_id", runId)
.put("android_peer_id", androidPeer)
.put("desktop_peer_id", desktopPeer)
.put("acknowledgements", JSONArray())
.toString()
)
}
@Test
fun explicitClickYieldsOldBackgroundAndPublishesFreshTriggerForNewRun() {
val databaseName = "generation-ownership-${UUID.randomUUID()}.db"
val root = Files.createTempDirectory("trainlog-generation-ownership-").toFile()
val repository = TrainlogRepository(context, databaseName)
val arbiter = SyncConversationArbiter()
val oldObserved = CountDownLatch(1)
val capturedRuns = mutableListOf<String>()
val desktopPeer = "peer_${UUID.randomUUID()}"
val oldRun = "sy_${UUID.randomUUID()}"
val newRun = "sy_${UUID.randomUUID()}"
val service = SyncGenerationService(repository)
val background =
SyncGenerationCoordinator(
repository,
arbiter = arbiter,
trace = SyncGenerationTrace { runId, phase, _, _, _ ->
if (phase == "generation_request_observed" && runId == oldRun) oldObserved.countDown()
},
)
val foreground =
SyncGenerationCoordinator(
repository,
arbiter = arbiter,
trace = SyncGenerationTrace { runId, phase, _, _, _ ->
if (phase == "generation_captured") synchronized(capturedRuns) {
capturedRuns += checkNotNull(runId)
}
},
)
val executor = java.util.concurrent.Executors.newFixedThreadPool(2)
try {
request(root, oldRun, service.peerId(), desktopPeer)
File(root, "trainlog-sync-request-v1.json").writeText(
"""{"format":"trainlog-sync-request","version":1,"request_id":"sr_old"}"""
)
val old = executor.submit<ForegroundGenerationResult> {
background.run(root, Duration.ofSeconds(3))
}
assertTrue(oldObserved.await(2, TimeUnit.SECONDS))
val claim = arbiter.announceExplicit()
val snapshot =
(DirectExchangePublisher { directExchangeDirectoryForPath(root) }.snapshot()
as DirectExchangeSnapshotResult.Ready).snapshot
val outbox = SyncRequestOutbox { directExchangeDirectoryForPath(root) }
var freshRequestId = ""
val explicit = executor.submit<ForegroundGenerationResult> {
foreground.run(
root,
Duration.ofMillis(700),
afterPeerPublication = {
freshRequestId =
(outbox.requestSync(snapshot) as SyncRequestResult.Requested).requestId
request(root, newRun, service.peerId(), desktopPeer)
archiveAcknowledgements(root, newRun, service.peerId(), desktopPeer)
},
intent = SyncConversationIntent.NEW_EXPLICIT,
explicitClaim = claim,
)
}
assertTrue(old.get(2, TimeUnit.SECONDS) is ForegroundGenerationResult.Superseded)
assertTrue(explicit.get(3, TimeUnit.SECONDS) is ForegroundGenerationResult.Failed)
assertNotEquals("sr_old", freshRequestId)
assertEquals(
freshRequestId,
JSONObject(File(root, "trainlog-sync-request-v1.json").readText())
.getString("request_id"),
)
assertEquals(listOf(newRun), capturedRuns)
repository.inSyncGenerationTransaction { database ->
database.rawQuery("SELECT run_id FROM sync_generations", null).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(newRun, cursor.getString(0))
assertEquals(false, cursor.moveToNext())
}
}
} finally {
executor.shutdownNow()
repository.close()
context.deleteDatabase(databaseName)
root.deleteRecursively()
}
}
@Test
fun terminalBackgroundRunStaysSuppressedUntilNewRequestOrRemoteEvidence() {
val databaseName = "generation-suppression-${UUID.randomUUID()}.db"
val root = Files.createTempDirectory("trainlog-generation-suppression-").toFile()
val repository = TrainlogRepository(context, databaseName)
try {
val service = SyncGenerationService(repository)
val coordinator = SyncGenerationCoordinator(repository, arbiter = SyncConversationArbiter())
val desktopPeer = "peer_${UUID.randomUUID()}"
val runId = "sy_${UUID.randomUUID()}"
request(root, runId, service.peerId(), desktopPeer)
service.capture(root, desktopPeer, runId)
val terminal = BackgroundTerminalRun(runId, coordinator.remoteEvidence(root, runId))
repeat(5) {
assertEquals(
BackgroundRequestActionability.RESUMABLE_BUT_STALE,
coordinator.classifyBackgroundRequest(root, terminal),
)
}
File(root, "desktop-consumption-ack-v1.json").writeText(
JSONObject()
.put("format", "trainlog-sync-ack")
.put("version", 1)
.put("run_id", runId)
.put("proof", "new")
.toString()
)
assertEquals(
BackgroundRequestActionability.NEW_REMOTE_EVIDENCE,
coordinator.classifyBackgroundRequest(root, terminal),
)
request(root, "sy_${UUID.randomUUID()}", service.peerId(), desktopPeer)
assertEquals(
BackgroundRequestActionability.NEW_REQUEST,
coordinator.classifyBackgroundRequest(root, terminal),
)
} finally {
repository.close()
context.deleteDatabase(databaseName)
root.deleteRecursively()
}
}
@Test
fun cancelledExplicitConversationHandsSameGenerationToBackgroundWithoutDuplication() {
val databaseName = "generation-handoff-${UUID.randomUUID()}.db"
val root = Files.createTempDirectory("trainlog-generation-handoff-").toFile()
val repository = TrainlogRepository(context, databaseName)
val arbiter = SyncConversationArbiter()
val published = CountDownLatch(1)
val result = AtomicReference<ForegroundGenerationResult>()
val desktopPeer = "peer_${UUID.randomUUID()}"
val oldRunId = "sy_${UUID.randomUUID()}"
val runId = "sy_${UUID.randomUUID()}"
try {
val service = SyncGenerationService(repository)
request(root, oldRunId, service.peerId(), desktopPeer)
val coordinator =
SyncGenerationCoordinator(
repository,
arbiter = arbiter,
trace = SyncGenerationTrace { _, phase, _, _, _ ->
if (phase == "generation_reference_published") published.countDown()
},
)
val claim = arbiter.announceExplicit()
val thread = Thread {
result.set(
coordinator.run(
root,
Duration.ofSeconds(5),
afterPeerPublication = {
request(root, runId, service.peerId(), desktopPeer)
archiveAcknowledgements(root, runId, service.peerId(), desktopPeer)
},
intent = SyncConversationIntent.NEW_EXPLICIT,
explicitClaim = claim,
)
)
}
thread.start()
assertTrue(published.await(3, TimeUnit.SECONDS))
thread.interrupt()
thread.join(3_000)
assertTrue(result.get() is ForegroundGenerationResult.Cancelled)
assertEquals(
BackgroundRequestActionability.ACTIVE_HANDOFF,
coordinator.classifyBackgroundRequest(root, null),
)
val resumed = coordinator.run(root, Duration.ofMillis(250))
assertTrue(resumed is ForegroundGenerationResult.Failed)
repository.inSyncGenerationTransaction { database ->
database.rawQuery("SELECT COUNT(*) FROM sync_generations WHERE run_id=?", arrayOf(runId))
.use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(1, cursor.getInt(0))
}
}
} finally {
repository.close()
context.deleteDatabase(databaseName)
root.deleteRecursively()
}
}
}

View file

@ -56,6 +56,7 @@ 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
@ -101,7 +102,15 @@ class SyncDeploymentConversationTest {
}
val conversations = if (suppliedTransport == null) 24 else 1
repeat(conversations) {
val coordinator = SyncGenerationForegroundCoordinator(repository)
val visibilityEvents = mutableListOf<List<String>>()
val tracePhases = mutableListOf<String>()
val coordinator = SyncGenerationForegroundCoordinator(
repository,
MtpPublicationVisibility { files ->
visibilityEvents += files.map(File::getName)
},
SyncGenerationTrace { _, phase, _, _, _ -> tracePhases += phase },
)
val executor = java.util.concurrent.Executors.newSingleThreadExecutor()
var requestSignals = 0
var refreshedAfterDesktopReference = false
@ -156,6 +165,35 @@ class SyncDeploymentConversationTest {
"transport is refreshed after the correlated desktop reference",
refreshedAfterDesktopReference,
)
val referenceVisibility = visibilityEvents.indexOfFirst {
it == listOf("android-generation-v1.json")
}
val objectVisibility = visibilityEvents.indexOfFirst { "manifest.json" in it }
assertTrue("generation objects must become visible", objectVisibility >= 0)
assertTrue(
"reference must follow complete object visibility",
referenceVisibility > objectVisibility,
)
assertTrue(
tracePhases.containsAll(
listOf(
"peer_published",
"full_generation_trigger_published",
"generation_request_observed",
"archive_ack_observed",
"android_archive_ack_published",
"generation_capture_started",
"generation_captured",
"generation_objects_published",
"generation_reference_published",
"desktop_ack_observed",
"desktop_generation_observed",
"desktop_generation_consumed",
"android_ack_published",
"completed",
)
)
)
var workerOutput = ""
localWorker?.let { worker ->
workerOutput = worker.inputStream.bufferedReader().readText()
@ -179,6 +217,71 @@ class SyncDeploymentConversationTest {
}
}
}
if (suppliedTransport == null && it == 11) {
/* Reproduce the inverse deployed failure: Android has
* durably consumed the newest desktop generation, but its
* ACK was lost before the desktop producer committed it.
* The next conversation must recover that exact Android
* evidence before capturing another desktop generation. */
SQLiteDatabase.openDatabase(
desktop.absolutePath,
null,
SQLiteDatabase.OPEN_READWRITE,
).use { database ->
val generation =
database.rawQuery(
"SELECT generation_id FROM sync_generations " +
"WHERE status='acknowledged' ORDER BY generated_at DESC LIMIT 1",
null,
).use { cursor ->
assertTrue(cursor.moveToFirst())
cursor.getString(0)
}
database.delete(
"sync_acknowledgements",
"generation_id=?",
arrayOf(generation),
)
database.execSQL(
"UPDATE sync_generations SET status='waiting_acknowledgement'," +
"acknowledged_at=NULL WHERE generation_id=?",
arrayOf(generation),
)
database.rawQuery(
"SELECT status FROM sync_generations WHERE generation_id=?",
arrayOf(generation),
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals("waiting_acknowledgement", cursor.getString(0))
}
lostDesktopAckGeneration = generation
}
}
if (suppliedTransport == null && it == 12) {
val recovered = checkNotNull(lostDesktopAckGeneration)
SQLiteDatabase.openDatabase(
desktop.absolutePath,
null,
SQLiteDatabase.OPEN_READONLY,
).use { database ->
database.rawQuery(
"SELECT status FROM sync_generations WHERE generation_id=?",
arrayOf(recovered),
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals("acknowledged", cursor.getString(0))
}
database.rawQuery(
"SELECT parent_generation_id,status FROM sync_generations " +
"ORDER BY generated_at DESC LIMIT 1",
null,
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(recovered, cursor.getString(0))
assertEquals("acknowledged", cursor.getString(1))
}
}
}
localWorker = null
}
repository.close()

View file

@ -0,0 +1,56 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import java.nio.file.Files
import java.time.Duration
import java.util.UUID
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
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 SyncGenerationConversationLockTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun foregroundConversationCannotBeStolenByBackgroundCoordinator() {
val databaseName = "generation-lock-${UUID.randomUUID()}.db"
val root = Files.createTempDirectory("trainlog-generation-lock-").toFile()
val repository = TrainlogRepository(context, databaseName)
val entered = CountDownLatch(1)
val executor = java.util.concurrent.Executors.newSingleThreadExecutor()
try {
val foreground = SyncGenerationCoordinator(repository)
val background = SyncGenerationCoordinator(repository)
val active = executor.submit<ForegroundGenerationResult> {
foreground.run(
root,
Duration.ofMillis(300),
afterPeerPublication = { entered.countDown() },
)
}
assertTrue(entered.await(2, TimeUnit.SECONDS))
val duplicate = background.run(root, Duration.ofMillis(10))
assertEquals(ForegroundGenerationResult.Busy, duplicate)
assertTrue(active.get(2, TimeUnit.SECONDS) is ForegroundGenerationResult.Failed)
repository.inSyncGenerationTransaction { database ->
database.rawQuery("SELECT COUNT(*) FROM sync_generations", null).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(0, cursor.getInt(0))
}
}
} finally {
executor.shutdownNow()
repository.close()
context.deleteDatabase(databaseName)
root.deleteRecursively()
}
}
}

View file

@ -3,11 +3,15 @@ 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.SleepMedication
import com.labfytools.trainlog.model.TrackingMode
import java.io.File
import java.nio.file.Files
@ -27,6 +31,299 @@ import org.robolectric.annotation.Config
class SyncGenerationServiceTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun sleepOnlyNightCrossesDesktopAckReplayAndSecondRevisionWithoutSession() {
val sourceName = "sleep-only-generation-${UUID.randomUUID()}.db"
val root = Files.createTempDirectory("trainlog-sleep-only-generation-").toFile()
val desktopDb = java.io.File(root, "desktop.sqlite")
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 generationTool =
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath
var source = TrainlogRepository(context, sourceName)
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
}
try {
run(
java.io.File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
desktopDb.absolutePath,
)
val desktopPeer =
Regex("PEER_ID=(peer_[^\\s]+)")
.find(
run(
"python3",
generationTool,
"peer-id",
"--database",
desktopDb.absolutePath,
)
)!!
.groupValues[1]
val medicationCreated =
source.saveSleepMedication(
SleepMedication(
medicationId = "",
revisionId = "",
createdAt = "2026-09-23T20:00:00+02:00",
updatedAt = "2026-09-23T20:00:00+02:00",
name = "Synthetic sleep-only medication",
defaultDoseValue = 5.0,
defaultDoseUnit = "mg",
form = "tablet",
note = "",
active = true,
)
) as TrainlogRepository.SaveSleepDiaryResult.Saved
val pendingRoot =
source.saveSleepDiary(
SleepDiaryDraft(
nightStartDate = "2026-09-23",
nightEndDate = "2026-09-24",
createdAt = "2026-09-23T22:00:00+02:00",
updatedAt = "2026-09-23T22:00:00+02:00",
sleepQuality = null,
wakeQuality = null,
dayForm = null,
treatmentAndNotes = "",
events = emptyList(),
intakes = emptyList(),
)
) as TrainlogRepository.SaveSleepDiaryResult.Saved
val preBed =
source.quickSleepMedication(
medicationCreated.entryId,
"2026-09-23T22:20:00+02:00",
quantity = 2,
) as TrainlogRepository.SleepQuickActionResult.Applied
assertEquals(pendingRoot.entryId, preBed.receipt.entryId)
source.quickSleepMedication(
medicationCreated.entryId,
"2026-09-23T22:21:00+02:00",
) as TrainlogRepository.SleepQuickActionResult.Applied
source.inSyncGenerationTransaction { db ->
assertEquals(
2,
db.rawQuery(
"SELECT COUNT(*) FROM sleep_medication_intakes WHERE revision_id=" +
"(SELECT current_revision_id FROM sleep_diary_entries WHERE entry_id=?)",
arrayOf(preBed.receipt.entryId),
).use { cursor ->
cursor.moveToFirst()
cursor.getInt(0)
},
)
}
source.close()
source = TrainlogRepository(context, sourceName)
assertEquals(listOf(2, 1), source.listSleepDiary().single().intakes.map { it.quantity })
val bed =
source.quickSleepBedTime("2026-09-23T22:30:00+02:00")
as TrainlogRepository.SleepQuickActionResult.Applied
assertEquals(preBed.receipt.entryId, bed.receipt.entryId)
assertEquals(2, source.listSleepDiary().single().intakes.size)
source.close()
source = TrainlogRepository(context, sourceName)
val firstMeasurement =
ParsedHeartRateMeasurement(
bpm = 61,
sensorContactDetected = true,
energyExpended = 7,
rrIntervals1024 = listOf(1000),
)
assertTrue(
source.recordLiveHeartRateForActiveSleep(
"Synthetic HR",
"2026-09-23T22:30:01+02:00",
firstMeasurement,
) is TrainlogRepository.HeartRateMutationResult.Applied
)
assertTrue(
source.quickSleepWake("2026-09-24T02:17:00+02:00")
is TrainlogRepository.SleepQuickActionResult.Applied
)
assertEquals(2, source.listSleepDiary().single().intakes.size)
source.close()
source = TrainlogRepository(context, sourceName)
assertTrue(
source.recordLiveHeartRateForActiveSleep(
"Synthetic HR",
"2026-09-24T02:17:01+02:00",
firstMeasurement.copy(bpm = 65, rrIntervals1024 = listOf(980)),
) is TrainlogRepository.HeartRateMutationResult.Applied
)
assertTrue(
source.quickSleepFinalGetUp("2026-09-24T06:31:00+02:00")
is TrainlogRepository.SleepQuickActionResult.Applied
)
source.close()
source = TrainlogRepository(context, sourceName)
assertTrue(source.listSessions().isEmpty())
val night = source.listSleepDiary().single()
val capture =
JSONObject(source.buildHeartRateV1Json())
.getJSONArray("captures")
.getJSONObject(0)
assertEquals(night.entryId, capture.getString("context_id"))
assertEquals(2, capture.getJSONArray("samples").length())
assertEquals(listOf(2, 1), night.intakes.map { it.quantity })
val service = SyncGenerationService(source)
val captured = service.capture(root, desktopPeer)
val diaryArtifact =
JSONObject(java.io.File(captured.stagingDirectory, "sleep-diary-v2.json").readText())
val heartArtifact =
JSONObject(java.io.File(captured.stagingDirectory, "heart-rate-v1.json").readText())
val historyArtifact =
JSONObject(java.io.File(captured.stagingDirectory, "history-v4.json").readText())
assertEquals(0, historyArtifact.getJSONArray("sessions").length())
val publishedNight = diaryArtifact.getJSONArray("entries").getJSONObject(0)
val publishedCapture = heartArtifact.getJSONArray("captures").getJSONObject(0)
assertEquals(night.entryId, publishedNight.getString("entry_id"))
assertEquals(2, publishedNight.getJSONArray("intakes").length())
assertEquals(
listOf(1, 2),
(0 until publishedNight.getJSONArray("intakes").length()).map { index ->
publishedNight.getJSONArray("intakes").getJSONObject(index).getInt("quantity")
}.sorted(),
)
assertEquals(capture.getString("capture_id"), publishedCapture.getString("capture_id"))
val published = service.publish(captured, java.io.File(root, "android-objects"))
val ack = java.io.File(root, "sleep-only-ack.json")
run(
"python3",
generationTool,
"consume-desktop",
published.absolutePath,
"--database",
desktopDb.absolutePath,
"--ack-output",
ack.absolutePath,
)
val ackDocument = JSONObject(ack.readText())
assertEquals(
"desktop ACK diagnostic: ${ackDocument.getString("diagnostic")}",
"consumed",
ackDocument.getString("result"),
)
assertEquals("acknowledged", service.acceptAcknowledgement(ack.readBytes()))
assertEquals("unchanged", service.acceptAcknowledgement(ack.readBytes()))
run(
"python3",
generationTool,
"consume-desktop",
published.absolutePath,
"--database",
desktopDb.absolutePath,
"--ack-output",
java.io.File(root, "sleep-only-replay-ack.json").absolutePath,
)
assertEquals(
// The desktop fixture owns one reserved calibration session;
// the empty Android history must not add a workout.
"1|2|2,1|2|2|1",
run(
"sqlite3",
desktopDb.absolutePath,
"SELECT (SELECT count(*) FROM sleep_diary_entries WHERE entry_id='${night.entryId}') || '|' || " +
"(SELECT count(*) FROM sleep_medication_intakes WHERE revision_id='${night.revisionId}') || '|' || " +
"(SELECT group_concat(quantity,',') FROM (SELECT quantity FROM sleep_medication_intakes WHERE revision_id='${night.revisionId}' ORDER BY taken_at,intake_id)) || '|' || " +
"(SELECT count(*) FROM heart_rate_samples WHERE capture_id='${capture.getString("capture_id")}') || '|' || " +
"(SELECT count(*) FROM heart_rate_rr_intervals WHERE capture_id='${capture.getString("capture_id")}') || '|' || " +
"(SELECT count(*) FROM sessions);",
)
.trim(),
)
val staleWithoutIntakes = JSONObject(source.buildSleepDiaryV1Json())
val staleEntry = staleWithoutIntakes.getJSONArray("entries").getJSONObject(0)
staleEntry.put("revision_id", pendingRoot.revisionId)
staleEntry.put("parent_revision_id", JSONObject.NULL)
staleEntry.put("ancestry", org.json.JSONArray().put(pendingRoot.revisionId))
staleEntry.put("updated_at", "2026-09-23T22:00:00+02:00")
staleEntry.put("events", org.json.JSONArray())
staleEntry.put("intakes", org.json.JSONArray())
val staleResult = source.applySleepDiaryV1Json(staleWithoutIntakes.toString())
assertTrue(staleResult is TrainlogRepository.SleepDiaryImportResult.Applied)
assertEquals(1, (staleResult as TrainlogRepository.SleepDiaryImportResult.Applied).unchanged)
assertEquals(listOf(2, 1), source.listSleepDiary().single().intakes.map { it.quantity })
val revised =
source.saveSleepDiary(
SleepDiaryDraft(
entryId = night.entryId,
expectedRevision = night.revisionId,
nightStartDate = night.nightStartDate,
nightEndDate = night.nightEndDate,
createdAt = night.createdAt,
updatedAt = "2026-09-24T07:00:00+02:00",
sleepQuality = night.sleepQuality,
wakeQuality = night.wakeQuality,
dayForm = night.dayForm,
treatmentAndNotes = "Sleep-only revision two",
events = night.events,
intakes = night.intakes,
)
) as TrainlogRepository.SaveSleepDiaryResult.Saved
assertTrue(
source.validateSleepDiary(
revised.entryId,
revised.revisionId,
"2026-09-24T07:01:00+02:00",
) is TrainlogRepository.SaveSleepDiaryResult.Saved
)
val second = service.capture(root, desktopPeer)
val secondPublished =
service.publish(second, java.io.File(root, "android-objects-second"))
val secondAck = java.io.File(root, "sleep-only-second-ack.json")
run(
"python3",
generationTool,
"consume-desktop",
secondPublished.absolutePath,
"--database",
desktopDb.absolutePath,
"--ack-output",
secondAck.absolutePath,
)
assertEquals("acknowledged", service.acceptAcknowledgement(secondAck.readBytes()))
assertEquals(
revised.revisionId,
run(
"sqlite3",
desktopDb.absolutePath,
"SELECT current_revision_id FROM sleep_diary_entries WHERE entry_id='${night.entryId}';",
)
.trim(),
)
assertEquals(2, source.listSleepDiary().single().intakes.size)
assertTrue(source.listSessions().isEmpty())
} finally {
source.close()
context.deleteDatabase(sourceName)
root.deleteRecursively()
}
}
@Test
fun desktopProgramDeletionGenerationIsAckedAndCannotResurrect() {
val repositoryRoot =
@ -319,6 +616,13 @@ class SyncGenerationServiceTest {
destinationService.peerId(),
runId,
)
val retryRun = "sy_${UUID.randomUUID()}"
val crossRunResume =
SyncGenerationCoordinator(source)
.resumableGeneration(retryRun, destinationService.peerId())
assertEquals(outbound.generationId, crossRunResume?.generationId)
assertEquals(retryRun, crossRunResume?.runId)
assertEquals(outbound.manifestSha256, crossRunResume?.manifestSha256)
val outboundDirectory =
sourceService.publish(outbound, java.io.File(root, "source-objects"))
sourceService.acceptAcknowledgement(
@ -350,6 +654,74 @@ class SyncGenerationServiceTest {
}
}
@Test
fun crossRunResumeAcceptsExactImmutableAcknowledgementFromOriginalRun() {
val generationId = "gen_${UUID.randomUUID()}"
val originalRunId = "sy_${UUID.randomUUID()}"
val retryRunId = "sy_${UUID.randomUUID()}"
val digest = "a".repeat(64)
val captured =
CapturedSyncGeneration(generationId, retryRunId, digest, java.io.File("unused"))
val acknowledgement =
JSONObject()
.put("run_id", originalRunId)
.put("generation_id", generationId)
.put("manifest_sha256", digest)
assertTrue(acknowledgementMatchesCapturedGeneration(acknowledgement, captured))
acknowledgement.put("manifest_sha256", "b".repeat(64))
assertTrue(!acknowledgementMatchesCapturedGeneration(acknowledgement, captured))
}
@Test
fun ledgerBackedSiblingMakesRetainedWaitingBranchesCapacityTerminal() {
val name = "generation-superseded-capacity-${UUID.randomUUID()}.db"
val root = Files.createTempDirectory("trainlog-generation-superseded-").toFile()
val repository = TrainlogRepository(context, name)
try {
val service = SyncGenerationService(repository)
val peer = "peer_11111111-1111-4111-8111-111111111111"
val branches = (0 until 4).map { service.capture(root, peer) }
val acknowledged = branches.last()
repository.inSyncGenerationTransaction { db ->
db.execSQL(
"UPDATE sync_generations SET parent_generation_id=? WHERE consumer_peer_id=?",
arrayOf("gen_22222222-2222-4222-8222-222222222222", peer),
)
db.execSQL(
"UPDATE sync_generations SET status='acknowledged',acknowledged_at=? " +
"WHERE generation_id=?",
arrayOf("2026-09-24T07:00:00+02:00", acknowledged.generationId),
)
db.execSQL(
"INSERT INTO sync_acknowledgements VALUES(?,?,?,?,?,?,?,?,?,?,?)",
arrayOf(
"ack_${UUID.randomUUID()}",
acknowledged.generationId,
acknowledged.runId,
service.peerId(),
peer,
acknowledged.manifestSha256,
"consumed",
"sqlite-commit-full",
"2026-09-24T07:00:00+02:00",
"",
"a".repeat(64),
),
)
}
// The three sibling branches remain durable, but the ledger-backed
// acknowledged sibling proves that none can be the causal tip.
assertEquals(3, branches.dropLast(1).size)
assertTrue(service.capture(root, peer).generationId.startsWith("gen_"))
} finally {
repository.close()
context.deleteDatabase(name)
root.deleteRecursively()
}
}
@Test
fun coordinatorOnlyTreatsCorrelatedFreshRequestAsActionable() {
val databaseName = "generation-actionable-${UUID.randomUUID()}.db"
@ -357,9 +729,15 @@ class SyncGenerationServiceTest {
val repository = TrainlogRepository(context, databaseName)
try {
val coordinator = SyncGenerationCoordinator(repository)
assertEquals(false, coordinator.hasActionableRequest(root))
assertEquals(
BackgroundRequestActionability.NOT_ACTIONABLE,
coordinator.classifyBackgroundRequest(root, null),
)
File(root, "request-v1.json").writeText("not-json")
assertEquals(false, coordinator.hasActionableRequest(root))
assertEquals(
BackgroundRequestActionability.NOT_ACTIONABLE,
coordinator.classifyBackgroundRequest(root, null),
)
val peer = SyncGenerationService(repository).peerId()
val request =
@ -370,11 +748,17 @@ class SyncGenerationServiceTest {
.put("android_peer_id", "peer_${UUID.randomUUID()}")
.put("desktop_peer_id", "peer_${UUID.randomUUID()}")
File(root, "request-v1.json").writeText(request.toString())
assertEquals(false, coordinator.hasActionableRequest(root))
assertEquals(
BackgroundRequestActionability.NOT_ACTIONABLE,
coordinator.classifyBackgroundRequest(root, null),
)
request.put("android_peer_id", peer)
File(root, "request-v1.json").writeText(request.toString())
assertEquals(true, coordinator.hasActionableRequest(root))
assertEquals(
BackgroundRequestActionability.NEW_REQUEST,
coordinator.classifyBackgroundRequest(root, null),
)
} finally {
repository.close()
context.deleteDatabase(databaseName)
@ -494,7 +878,7 @@ class SyncGenerationServiceTest {
.use { db ->
db.rawQuery("PRAGMA user_version", null).use {
assertTrue(it.moveToFirst())
assertEquals(25, it.getInt(0))
assertEquals(33, it.getInt(0))
}
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
assertTrue(it.moveToFirst())
@ -843,7 +1227,73 @@ 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
assertTrue(
source.validateSleepDiary(
sleepEntry.entryId,
sleepEntry.revisionId,
"2026-09-23T07:00:00+02:00",
) is 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 heartRateCorrections =
JSONObject(
java.io.File(
androidToDesktop.stagingDirectory,
"heart-rate-corrections-v1.json",
).readText(),
)
assertEquals(
"trainlog-heart-rate-corrections",
heartRateCorrections.getString("format"),
)
assertEquals(0, heartRateCorrections.getJSONArray("corrections").length())
val androidPublished =
sourceService.publish(
androidToDesktop,
@ -860,10 +1310,35 @@ 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(
"unchanged",
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",
@ -875,6 +1350,69 @@ class SyncGenerationServiceTest {
.trim(),
)
/* A second Sleep-only mutation must advance the same diary entry
* after the first correlated ACK. No new workout is created, and
* replaying either generation remains strictly idempotent. */
val revisedSleep =
source.saveSleepDiary(
SleepDiaryDraft(
entryId = sleepEntry.entryId,
expectedRevision = sleepEntry.revisionId,
nightStartDate = "2026-09-22",
nightEndDate = "2026-09-23",
createdAt = "2026-09-22T22:00:00+02:00",
updatedAt = "2026-09-23T08:00:00+02:00",
sleepQuality = null,
wakeQuality = null,
dayForm = null,
treatmentAndNotes = "second sleep-only revision",
events = emptyList(),
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
assertTrue(
source.validateSleepDiary(
revisedSleep.entryId,
revisedSleep.revisionId,
"2026-09-23T08:01:00+02:00",
) is TrainlogRepository.SaveSleepDiaryResult.Saved,
)
val secondSleepGeneration = sourceService.capture(root, desktopPeer)
val secondSleepPublished =
sourceService.publish(
secondSleepGeneration,
java.io.File(root, "android-desktop-objects"),
)
val secondAckFile = java.io.File(root, "android-desktop-second-ack.json")
run(
"python3",
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath,
"consume-desktop",
secondSleepPublished.absolutePath,
"--database",
desktopConsumerDb.absolutePath,
"--ack-output",
secondAckFile.absolutePath,
)
assertEquals(
"acknowledged",
sourceService.acceptAcknowledgement(secondAckFile.readBytes()),
)
assertEquals(
"unchanged",
sourceService.acceptAcknowledgement(secondAckFile.readBytes()),
)
assertEquals(
"1|${revisedSleep.revisionId}|1",
run(
"sqlite3",
desktopConsumerDb.absolutePath,
"SELECT count(*) || '|' || current_revision_id || '|' || " +
"(SELECT count(*) FROM sessions WHERE session_id='$androidSessionId') " +
"FROM sleep_diary_entries WHERE entry_id='${sleepEntry.entryId}';",
)
.trim(),
)
run(
java.io.File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
desktopDb.absolutePath,

View file

@ -0,0 +1,59 @@
package com.labfytools.trainlog.data
import java.nio.file.Files
import org.json.JSONObject
import org.junit.Assert.assertNotEquals
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 SyncRequestOutboxTest {
@Test
fun distinctExplicitRequestsAlwaysPublishFreshIdentities() {
val directory = Files.createTempDirectory("trainlog-request-outbox-").toFile()
try {
val outbox = SyncRequestOutbox { directExchangeDirectoryForPath(directory) }
val snapshot =
(DirectExchangePublisher { directExchangeDirectoryForPath(directory) }.snapshot()
as DirectExchangeSnapshotResult.Ready).snapshot
val first = outbox.requestSync(snapshot) as SyncRequestResult.Requested
val firstBytes = java.io.File(directory, "trainlog-sync-request-v1.json").readText()
val second = outbox.requestSync(snapshot) as SyncRequestResult.Requested
val secondBytes = java.io.File(directory, "trainlog-sync-request-v1.json").readText()
assertNotEquals(first.requestId, second.requestId)
assertNotEquals(firstBytes, secondBytes)
assertTrue(JSONObject(secondBytes).getString("request_id") == second.requestId)
} finally {
directory.deleteRecursively()
}
}
@Test
fun oneIntentUsesSameIdentityAcrossFullGenerationAndLegacyChannels() {
val directory = Files.createTempDirectory("trainlog-dual-request-").toFile()
try {
val outbox = SyncRequestOutbox { directExchangeDirectoryForPath(directory) }
val snapshot =
(DirectExchangePublisher { directExchangeDirectoryForPath(directory) }.snapshot()
as DirectExchangeSnapshotResult.Ready).snapshot
val intent = outbox.newIntent()
assertTrue(outbox.publishFullGeneration(snapshot, intent) is SyncRequestResult.Requested)
assertTrue(outbox.publishLegacy(snapshot, intent) is SyncRequestResult.Requested)
val full = JSONObject(java.io.File(directory, FULL_GENERATION_REQUEST_NAME).readText())
val legacy = JSONObject(java.io.File(directory, LEGACY_SYNC_REQUEST_NAME).readText())
assertEquals(intent.requestId, full.getString("request_id"))
assertEquals(intent.requestId, legacy.getString("request_id"))
assertEquals(full.toString(), legacy.toString())
} finally {
directory.deleteRecursively()
}
}
}

View file

@ -13,6 +13,8 @@ import com.labfytools.trainlog.model.BodyObservationDraft
import com.labfytools.trainlog.model.ExerciseDataFields
import com.labfytools.trainlog.model.ExerciseEditInput
import com.labfytools.trainlog.model.ExerciseProfile
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
@ -56,6 +58,126 @@ class TrainlogRepositoryDraftTest {
databaseName = "draft-test-${UUID.randomUUID()}.db"
}
@Test
fun delayedFactualFinalizationTruncatesHeartRateSamplesAndRrInclusively() {
val repo = openRepository()
val exercise = createExercise(repo, "Récupération cardio", RecordingMode.SETS, TrackingMode.REPS)
val sessionId = "se_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
val entryId = "sxe_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
val startedAt = "2026-09-24T10:00:00+02:00"
val cutoff = "2026-09-24T11:16:00+02:00"
assertEquals(
ActiveDraftMutationResult.Saved,
repo.saveActiveSessionDraft(
ActiveSessionDraft(
sessionId = sessionId,
startedAt = startedAt,
exercises = listOf(
SessionExerciseDraft(
entryId = entryId,
exercise = exercise,
sets = listOf(SessionSetDraft(reps = 8, weightKg = 20.0)),
),
),
),
),
)
val capture = repo.startHeartRateCapture(
HeartRateContextKind.SESSION,
sessionId,
startedAt,
"Synthetic",
) as TrainlogRepository.StartHeartRateCaptureResult.Started
assertTrue(repo.startActiveSessionExercise(
entryId, "2026-09-24T10:30:00+02:00",
) is TrainlogRepository.SessionExerciseTimingResult.Started)
assertTrue(repo.finishActiveSessionExercise(
entryId, "2026-09-24T11:15:00+02:00",
) is TrainlogRepository.SessionExerciseTimingResult.Finished)
listOf(
"2026-09-24T11:15:59.999999+02:00" to 100,
cutoff to 101,
"2026-09-24T11:16:00.000001+02:00" to 40,
).forEachIndexed { index, (observedAt, bpm) ->
assertTrue(
repo.appendHeartRateSample(
capture.captureId,
observedAt,
bpm,
rrIntervals = listOf(HeartRateRrInterval(0, 900 + index)),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
}
assertEquals(
FinalizeActiveDraftResult.Saved(sessionId),
repo.finalizeActiveSessionDraft(cutoff),
)
SQLiteDatabase.openDatabase(
context.getDatabasePath(databaseName).path,
null,
SQLiteDatabase.OPEN_READONLY,
).use { db ->
assertEquals(cutoff, db.rawQuery(
"SELECT ended_at FROM heart_rate_captures WHERE capture_id=?",
arrayOf(capture.captureId),
).use { it.moveToFirst(); it.getString(0) })
assertEquals(
listOf("2026-09-24T11:15:59.999999+02:00", cutoff),
db.rawQuery(
"SELECT observed_at FROM heart_rate_samples WHERE capture_id=? " +
"ORDER BY sequence",
arrayOf(capture.captureId),
).use { cursor -> buildList { while (cursor.moveToNext()) add(cursor.getString(0)) } },
)
assertEquals(2, db.rawQuery(
"SELECT COUNT(*) FROM heart_rate_rr_intervals WHERE capture_id=?",
arrayOf(capture.captureId),
).use { it.moveToFirst(); it.getInt(0) })
db.rawQuery("PRAGMA foreign_key_check", null).use { assertFalse(it.moveToFirst()) }
}
}
@Test
fun heartRateCorrectionsRemainOutsideFrozenCausalDeletionV1() {
val repo = openRepository()
repo.buildCausalDeletionExportV1Json()
SQLiteDatabase.openDatabase(
context.getDatabasePath(databaseName).path,
null,
SQLiteDatabase.OPEN_READWRITE,
).use { db ->
db.execSQL(
"INSERT INTO sync_causal_operations(" +
"operation_id,target_kind,target_id,creator_id,predecessor_revision_id," +
"created_at,payload_sha256,publication_context) VALUES(?,?,?,?,?,?,?,NULL)",
arrayOf(
"hrx_11111111-1111-4111-8111-111111111111",
"heart_rate_tail",
"hrc_22222222-2222-4222-8222-222222222222|" +
"2026-09-24T11:16:00+02:00",
"peer_test",
"lv_test",
"2026-09-24T12:00:00+02:00",
"0".repeat(64),
),
)
}
assertEquals(
0,
JSONObject(repo.buildCausalDeletionExportV1Json())
.getJSONArray("operations")
.length(),
)
val correction = JSONObject(repo.buildHeartRateCorrectionsV1Json())
assertEquals("trainlog-heart-rate-corrections", correction.getString("format"))
assertEquals(
"hrx_11111111-1111-4111-8111-111111111111",
correction.getJSONArray("corrections").getJSONObject(0).getString("operation_id"),
)
}
@After
fun tearDown() {
repository?.close()
@ -2260,7 +2382,7 @@ class TrainlogRepositoryDraftTest {
).use { db ->
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(25, cursor.getInt(0))
assertEquals(33, cursor.getInt(0))
}
db.rawQuery(
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
@ -2387,7 +2509,7 @@ class TrainlogRepositoryDraftTest {
).use { db ->
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(25, cursor.getInt(0))
assertEquals(33, cursor.getInt(0))
}
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
assertTrue(cursor.moveToFirst())
@ -2695,6 +2817,46 @@ class TrainlogRepositoryDraftTest {
} catch (_: IllegalStateException) { }
}
@Test
fun completedContinuousPlanKeepsPerformedFactAndExportsWithoutSetTarget() {
val repo = openRepository()
val exercise = createExercise(
repo,
"Marche planifiée",
RecordingMode.CONTINUOUS,
TrackingMode.DURATION,
)
val plan = SessionExercisePlan(sets = 0, durationSeconds = 600)
assertEquals(
ActiveDraftMutationResult.Saved,
repo.saveActiveSessionDraft(
ActiveSessionDraft(
exercises = listOf(
SessionExerciseDraft(
exercise = exercise,
plan = plan,
continuousDurationSeconds = 540,
),
),
),
),
)
val result = repo.finalizeActiveSessionDraft() as FinalizeActiveDraftResult.Saved
val detail = repo.getSessionDetail(result.sessionId)!!
assertEquals(plan, detail.exercises.single().plan)
val exported =
JSONObject(repo.buildMobileExportV4Json())
.getJSONArray("sessions")
.getJSONObject(0)
.getJSONArray("exercises")
.getJSONObject(0)
assertTrue(exported.isNull("target"))
assertEquals(540, exported.getJSONObject("continuous").getInt("duration_seconds"))
assertTrue(!exported.getJSONObject("continuous").has("speed_kmh"))
assertTrue(!exported.getJSONObject("continuous").has("distance_km"))
}
@Test
fun targetOnlyDraftPersistsButCannotFinalizeWithoutActualWork() {
val repo = openRepository()

View file

@ -12,8 +12,9 @@ import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextClearance
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.input.key.Key
@ -28,8 +29,12 @@ import com.labfytools.trainlog.data.ActiveDraftMutationResult
import com.labfytools.trainlog.data.AiSessionDraftImportResult
import com.labfytools.trainlog.data.CreateExerciseResult
import com.labfytools.trainlog.data.TrainlogRepository
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.SessionExercisePlan
import com.labfytools.trainlog.model.SessionLoadMode
import com.labfytools.trainlog.model.TrackingMode
import com.labfytools.trainlog.ui.theme.TrainlogTheme
import java.util.UUID
@ -115,7 +120,7 @@ class AiSessionDraftUiWiringTest {
assertEquals(ActiveDraftMutationResult.Saved, repository.startActiveSessionDraft())
var createCalls = 0
compose.setContent { TrainlogTheme {
SessionScreen(repository, 0, {}, { createCalls++ }, {})
SessionScreen(repository, 0, {}, { createCalls++ }, { _ -> }, {})
} }
val create = compose.onNodeWithTag("active-session-create-exercise")
@ -130,6 +135,84 @@ class AiSessionDraftUiWiringTest {
compose.runOnIdle { assertEquals(1, createCalls) }
}
@Test
fun modifyingTargetOnlyOccurrenceRendersItsPrefilledEditor() {
val walk = createExercise("Marche ciblée", RecordingMode.CONTINUOUS, TrackingMode.DURATION)
val press = createExercise("Presse ciblée", RecordingMode.SETS, TrackingMode.REPS)
val walkEntry = SessionExerciseDraft(
entryId = "spe_11111111-1111-4111-8111-111111111111",
exercise = walk,
plan = SessionExercisePlan(sets = 0, durationSeconds = 600),
)
val pressEntry = SessionExerciseDraft(
entryId = "spe_22222222-2222-4222-8222-222222222222",
exercise = press,
plan = SessionExercisePlan(
sets = 3,
reps = 10,
weightKg = 52.0,
loadMode = SessionLoadMode.EXTERNAL,
restSeconds = 90,
),
)
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
ActiveSessionDraft(exercises = listOf(walkEntry, pressEntry)),
),
)
val loaded = (repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
loaded.copy(form = formForExistingExercise(loaded.exercises[0], 0)),
),
)
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(loaded),
)
compose.setContent {
TrainlogTheme { SessionScreen(repository, 0, {}, {}, { _ -> }, {}) }
}
compose.onNodeWithTag("edit-draft-exercise-${walkEntry.entryId}")
.performSemanticsAction(SemanticsActions.OnClick)
compose.runOnIdle {
val active = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
assertEquals(walk.exerciseId, active.draft.form.selectedExercise?.exerciseId)
}
compose.onNodeWithText("SAISIE — Marche ciblée").assertExists()
compose.onNodeWithTag("session-continuous-duration").assertExists()
compose.onNodeWithText("Ajouter à la séance").assertExists()
compose.onNodeWithTag("session-continuous-duration").performTextClearance()
compose.onNodeWithTag("session-continuous-duration").performTextInput("12")
compose.onNodeWithTag("add-to-session")
.performSemanticsAction(SemanticsActions.OnClick)
compose.runOnIdle {
val active = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
assertEquals(2, active.draft.exercises.size)
assertEquals(walkEntry.entryId, active.draft.exercises[0].entryId)
assertEquals(720, active.draft.exercises[0].continuousDurationSeconds)
assertEquals(600, active.draft.exercises[0].plan?.durationSeconds)
assertEquals(pressEntry, active.draft.exercises[1])
}
compose.onNodeWithTag("edit-draft-exercise-${pressEntry.entryId}")
.performSemanticsAction(SemanticsActions.OnClick)
compose.onNodeWithText("SAISIE — Presse ciblée").assertExists()
compose.onNodeWithTag("cancel-session-exercise-editor")
.performSemanticsAction(SemanticsActions.OnClick)
compose.runOnIdle {
val active = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
assertEquals(720, active.draft.exercises[0].continuousDurationSeconds)
assertEquals(pressEntry, active.draft.exercises[1])
assertEquals(null, active.draft.form.selectedExercise)
}
}
@Test
fun draftScreenShowsDosBicepsAndTargetDetails() {
importDraft()
@ -139,8 +222,8 @@ class AiSessionDraftUiWiringTest {
} }
compose.onNodeWithText("Dos + biceps").assertIsDisplayed()
compose.onNodeWithText("Tractions supination · 3 × 10 répétitions · repos 90 s")
.assertIsDisplayed()
compose.onNodeWithText("Tractions supination").assertIsDisplayed()
compose.onNodeWithText("3 × 10 · repos 90 s").assertIsDisplayed()
compose.onNodeWithText("Accent sur le dos et les biceps.").assertIsDisplayed()
}
@ -299,6 +382,15 @@ class AiSessionDraftUiWiringTest {
return payload
}
private fun createExercise(
name: String,
recordingMode: RecordingMode,
trackingMode: TrackingMode,
) =
(repository.createExercise(
NewExerciseProfile(name, recordingMode, trackingMode, 0),
) as CreateExerciseResult.Created).exercise
private fun artifact(exerciseId: String): String {
val target = JSONObject().put("sets", 3).put("reps", 10)
.put("duration_seconds", JSONObject.NULL).put("weight_kg", JSONObject.NULL)

View file

@ -36,14 +36,10 @@ class AndroidUiRedesignTest {
HomeScreen(
activeDraft = null,
draftError = null,
latestSession = null,
latestMaximum = null,
bodyZoneOverview = BodyZoneHomeOverview(emptyList(), emptyList(), false, false),
onSession = {},
onOpenExercises = {},
onOpenStatistics = {},
onBody = { bodyCalls++ },
onOpenLatestSession = {},
onSync = { syncCalls++ },
)
}

View file

@ -36,13 +36,14 @@ class AppNavigationStateTest {
@After fun tearDown() { context.deleteDatabase(databaseName) }
@Test fun sevenSectionsHaveTypedRootsAndSelectionComesFromRoute() {
@Test fun captureSectionsHaveTypedRootsWithoutMobileStatistics() {
assertEquals(7, AppSection.entries.size)
assertFalse(AppSection.entries.any { it.name == "STATISTICS" })
AppSection.entries.forEach { assertEquals(it, it.rootRoute().section) }
assertEquals(AppSection.SESSIONS, AppRoute.SessionDetail("se_1").section)
assertEquals(AppSection.EXERCISES, AppRoute.ExerciseDetail("ex_1").section)
assertEquals(AppSection.EQUIPMENT, AppRoute.EquipmentDetail("eq_1").section)
assertEquals(AppSection.STATISTICS, AppRoute.LatestMaxima.section)
assertEquals(AppSection.EXERCISES, AppRoute.LatestMaxima.section)
}
@Test fun catalogueDetailsAndCreatorsReturnToTheirDeclaredCaller() {

View file

@ -0,0 +1,74 @@
package com.labfytools.trainlog.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.width
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.unit.dp
import com.labfytools.trainlog.ui.theme.TrainlogTheme
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
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 BodyMetricGridTest {
@get:Rule val compose = createComposeRule()
private val items = listOf(
BodyMetricItem("POIDS", "kg", "", {}),
BodyMetricItem("COU", "cm", "", {}),
BodyMetricItem("TOUR DE TAILLE", "cm", "", {}),
BodyMetricItem("HANCHES", "cm", "", {}),
)
@Test
fun deployedPhoneContentWidthUsesTwoColumns() {
// 1080 px / 480 dpi = 360 dp; the screen frame leaves about 312 dp.
assertEquals(2, bodyMetricColumnCount(312f, 0.9f))
}
@Test
fun genuinelyNarrowWidthStacksFields() {
assertEquals(1, bodyMetricColumnCount(280f, 1f))
}
@Test
fun largeAccessibilityTextStacksFields() {
assertEquals(1, bodyMetricColumnCount(312f, 1.5f))
}
@Test
fun deployedPhoneWidthRendersPairedFieldsWithoutClippingUnits() {
compose.setContent {
TrainlogTheme {
Column(Modifier.width(312.dp)) { BodyMetricGrid(items) }
}
}
val weight = compose.onNodeWithText("POIDS").fetchSemanticsNode().boundsInRoot
val neck = compose.onNodeWithText("COU").fetchSemanticsNode().boundsInRoot
assertTrue(neck.left > weight.left)
assertTrue(kotlin.math.abs(neck.top - weight.top) < 2f)
compose.onNodeWithText("kg").fetchSemanticsNode()
assertTrue(compose.onAllNodesWithText("cm", useUnmergedTree = true).fetchSemanticsNodes().isNotEmpty())
compose.onNodeWithText("TOUR DE TAILLE").fetchSemanticsNode()
}
@Test
fun trulyNarrowLayoutStacksPairedFields() {
compose.setContent {
TrainlogTheme {
Column(Modifier.width(280.dp)) { BodyMetricGrid(items.take(2)) }
}
}
val weight = compose.onNodeWithText("POIDS").fetchSemanticsNode().boundsInRoot
val neck = compose.onNodeWithText("COU").fetchSemanticsNode().boundsInRoot
assertTrue(neck.top > weight.top)
}
}

View file

@ -110,12 +110,10 @@ class BodyZoneHomeSemanticsTest {
@Test fun practicalWidthMapSelectionFeedsExistingNavigationActions() {
var openedZone: String? = null
var statisticsOpened = false
setSection(
widthDp = 380,
fontScale = 1f,
onOpenExercises = { openedZone = it },
onOpenStatistics = { statisticsOpened = true },
)
assertEquals("core", bodyZoneAtPoint("front", 180f, 236f, Offset(90f, 113.28f), allZoneIds.toSet()))
@ -126,16 +124,12 @@ class BodyZoneHomeSemanticsTest {
compose.onNodeWithText("Voir les exercices").assertHasClickAction()
.performSemanticsAction(SemanticsActions.OnClick)
assertEquals("core", openedZone)
compose.onNodeWithText("Voir les statistiques").assertHasClickAction()
.performSemanticsAction(SemanticsActions.OnClick)
assertTrue(statisticsOpened)
}
private fun setSection(
widthDp: Int,
fontScale: Float,
onOpenExercises: (String) -> Unit = {},
onOpenStatistics: () -> Unit = {},
) {
val zones = allZoneIds.map { id ->
status(id, if (id == "chest") BodyZoneHomeState.PRIORITIZE else BodyZoneHomeState.RECENT_WORK)
@ -147,7 +141,6 @@ class BodyZoneHomeSemanticsTest {
BodyZoneHomeSection(
BodyZoneHomeOverview(zones, listOf(zones.first()), true, false),
onOpenExercises,
onOpenStatistics,
)
}
}

View file

@ -67,7 +67,7 @@ class DraftFeedbackUiReadbackTest {
val completedSessionId = mutableStateOf<String?>(null)
compose.setContent { TrainlogTheme {
completedSessionId.value?.let { SessionDetailScreen(repository, it, {}, {}) }
?: SessionScreen(repository, 0, {}, {}, {})
?: SessionScreen(repository, 0, {}, {}, { _ -> }, {})
} }
compose.onNodeWithTag("draft-feedback-${entry.entryId}").fetchSemanticsNode()
compose.onNodeWithText("Ressenti · texte UI durable").fetchSemanticsNode()

View file

@ -100,7 +100,7 @@ class SyncBundlePublicationTest {
assertTrue(feedback is SaveFeedbackResult.Saved)
val writesBeforeFirstClick = directory.writeCounts.toMap()
assertTrue(publishBundleAndRequest(repository, exporter, outbox) is SyncRequestResult.Requested)
assertTrue(publishLegacyBundleAndRequest(exporter, outbox) is SyncRequestResult.Requested)
assertEquals(2, directory.enumerationCount)
val required = listOf(
"trainlog-mobile-export-v3.json",
@ -138,7 +138,7 @@ class SyncBundlePublicationTest {
ShadowLog.clear()
val bodyCreatesBefore = directory.createCount("trainlog-exercise-body-zones-v1.json")
val writesBeforeSecondClick = directory.writeCounts.toMap()
assertTrue(publishBundleAndRequest(repository, exporter, outbox) is SyncRequestResult.Requested)
assertTrue(publishLegacyBundleAndRequest(exporter, outbox) is SyncRequestResult.Requested)
assertEquals(3, directory.enumerationCount)
val phases = ShadowLog.getLogsForTag(DIRECT_EXCHANGE_LOG_TAG).map { it.msg }
assertEquals(1, phases.count { it.startsWith("SYNC_BUNDLE coordinator.export.begin") })

View file

@ -7,15 +7,22 @@ package com.labfytools.trainlog.ui
import android.content.Context
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.semantics.SemanticsActions
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.data.ActiveDraftMutationResult
import com.labfytools.trainlog.data.ActiveDraftLoadResult
import com.labfytools.trainlog.data.BodyZoneRecentExposure
import com.labfytools.trainlog.data.ExposureWindowSummary
import com.labfytools.trainlog.data.CreateExerciseResult
import com.labfytools.trainlog.data.GenerationWarningLevel
import com.labfytools.trainlog.data.SessionGenerationPreview
import com.labfytools.trainlog.data.SessionGenerationPreviewExercise
@ -26,6 +33,13 @@ import com.labfytools.trainlog.data.SyncExporter
import com.labfytools.trainlog.data.SyncRequestOutbox
import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.model.SessionExercisePlan
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.SessionLoadMode
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.TrackingMode
import com.labfytools.trainlog.ui.theme.TrainlogTheme
import java.security.MessageDigest
import java.util.UUID
@ -109,6 +123,106 @@ class TrainlogAppNavigationCallbackTest {
assertArrayEquals(before, databaseDigest())
}
@Test fun activeExerciseEditorCreatesEquipmentReturnsSelectsAndSaves() {
val exercise = (repository.createExercise(
NewExerciseProfile(
"Goblet squat haltère",
RecordingMode.SETS,
TrackingMode.REPS,
0,
),
) as CreateExerciseResult.Created).exercise
val occurrence = SessionExerciseDraft(
exercise = exercise,
plan = SessionExercisePlan(
sets = 3,
reps = 10,
weightKg = 8.0,
loadMode = SessionLoadMode.EXTERNAL,
restSeconds = 90,
),
)
val before = SessionExerciseDraft(
exercise = (repository.createExercise(
NewExerciseProfile("Marche témoin", RecordingMode.CONTINUOUS, TrackingMode.DURATION, 0),
) as CreateExerciseResult.Created).exercise,
continuousDurationSeconds = 600,
)
val after = SessionExerciseDraft(
exercise = (repository.createExercise(
NewExerciseProfile("Presse témoin", RecordingMode.SETS, TrackingMode.REPS, 0),
) as CreateExerciseResult.Created).exercise,
sets = listOf(SessionSetDraft(reps = 12)),
)
repository.createCustomEquipment("ZZZ équipement témoin")
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
ActiveSessionDraft(exercises = listOf(before, occurrence, after)),
),
)
val editorState = TrainlogAppState().also {
it.navigation.open(AppRoute.SessionEditor)
}
compose.setContent {
TrainlogTheme {
TrainlogApp(
repository,
SyncExporter(context, repository),
SyncCatalogInbox(context, repository),
SyncRequestOutbox(context),
editorState,
)
}
}
compose.onNodeWithTag("edit-draft-exercise-${occurrence.entryId}")
.performSemanticsAction(SemanticsActions.OnClick)
compose.onNodeWithText("SAISIE — Goblet squat haltère")
.performScrollTo()
.assertIsDisplayed()
compose.onNodeWithTag("session-equipment-results")
.performScrollToNode(hasText("ZZZ équipement témoin"))
compose.onNodeWithTag("session-create-equipment")
.performSemanticsAction(SemanticsActions.OnClick)
compose.waitForIdle()
assertEquals(AppRoute.EquipmentCreate(AppRoute.SessionEditor), editorState.navigation.route)
assertEquals(occurrence.entryId, editorState.pendingSessionEquipmentEntryId)
compose.onNodeWithTag("equipment-create-name").performTextInput("Haltère")
compose.onNodeWithText("Créer")
.performSemanticsAction(SemanticsActions.OnClick)
compose.waitForIdle()
assertEquals(AppRoute.SessionEditor, editorState.navigation.route)
compose.onNodeWithText("SAISIE — Goblet squat haltère")
.performScrollTo()
.assertIsDisplayed()
compose.onNode(
hasText("✓ Haltère") and hasText("Équipement sélectionné."),
).performScrollTo().assertIsDisplayed()
compose.onNodeWithTag("add-to-session")
.performSemanticsAction(SemanticsActions.OnClick)
compose.runOnIdle {
val loaded = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
assertEquals(3, loaded.draft.exercises.size)
assertEquals(before, loaded.draft.exercises[0])
assertEquals(occurrence.entryId, loaded.draft.exercises[1].entryId)
assertTrue(loaded.draft.exercises[1].sets.isEmpty())
assertEquals(3, loaded.draft.exercises[1].plan?.sets)
assertEquals(10, loaded.draft.exercises[1].plan?.reps)
assertEquals(90, loaded.draft.exercises[1].plan?.restSeconds)
assertEquals(8.0, loaded.draft.exercises[1].plan?.weightKg)
assertEquals(after, loaded.draft.exercises[2])
assertEquals(
"Haltère",
repository.listEquipment().single {
it.equipmentId == loaded.draft.exercises[1].equipmentId
}.displayName,
)
}
}
private fun databaseDigest(): ByteArray = MessageDigest.getInstance("SHA-256")
.digest(context.getDatabasePath(databaseName).readBytes())

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