Compare commits

...

82 commits
v0.1.3 ... 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
265 changed files with 44364 additions and 1890 deletions

View file

@ -1,5 +1,352 @@
# 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

566
README.md
View file

@ -1,372 +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, Programmes, and Exercises surfaces while reserving Analyse
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.3 development) | Display the Dashboard, the operational Programmes calendar, Sessions administration, and the desktop exercise catalogue through typed Trainlog Core/API boundaries. Exercises supports search/filter, detail, creation, editing and causal retirement; equipment is read-only. Analyse remains a placeholder. |
## ✦ 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.
Web Exercises administration is implemented for 0.1.3 development. The next
separately contracted priority is the Android UX redesign.
<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.3** on Android and desktop; this is one shared
Trainlog version, not separate interface versions. Version 0.1.2 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.3 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.3 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;
`TRAINLOG_WEB_EXERCISES_V1=PASS`; Analyse remains a placeholder;
- `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
@ -375,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
@ -389,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")
} ?: 4
} ?: 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.3"
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 = {},
)

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

@ -225,7 +225,7 @@ internal class AndroidBackupService(
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."
}
@ -319,7 +319,7 @@ internal class AndroidBackupService(
}
)
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",
@ -318,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)

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

@ -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,7 +62,11 @@ class SyncBackgroundService : Service() {
stopSelf()
return START_NOT_STICKY
}
promoteForeground()
if (!launched.compareAndSet(false, true)) return START_NOT_STICKY
executor.execute {
bluetoothClient?.run(stopped)
}
executor.execute {
val coordinator = SyncGenerationCoordinator(
requireNotNull(repository),
@ -116,6 +112,7 @@ class SyncBackgroundService : Service() {
is ForegroundGenerationResult.Cancelled -> Unit
}
}
AiExportDriveSettings.enqueueImmediate(applicationContext)
if (!stopped.get()) runCatching { Thread.sleep(5_000) }
}
stopSelf()
@ -125,6 +122,8 @@ class SyncBackgroundService : Service() {
override fun onDestroy() {
stopped.set(true)
bluetoothClient?.shutdown()
bluetoothClient = null
executor.shutdownNow()
repository?.close()
repository = null
@ -141,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

@ -45,6 +45,13 @@ internal object AndroidSyncGenerationTrace : SyncGenerationTrace {
}
}
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,
@ -54,6 +61,11 @@ internal class SyncGenerationCoordinator(
) {
private val service = SyncGenerationService(repository)
private companion object {
const val ACK_RECOVERY_CAPABILITY = "generation-ack-recovery-v1"
const val MAX_COORDINATION_BYTES = 65_536
}
private fun phase(
runId: String?,
phase: String,
@ -64,15 +76,9 @@ internal class SyncGenerationCoordinator(
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 {
@ -88,6 +94,29 @@ internal class SyncGenerationCoordinator(
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),
)
}
private fun request(directory: File): JSONObject? {
@ -196,6 +225,45 @@ internal class SyncGenerationCoordinator(
if (actual != expected) throw SyncGenerationException("invalid $label fields")
}
private fun requestSupports(request: JSONObject, capability: String): Boolean {
val capabilities = request.optJSONArray("capabilities") ?: return false
for (index in 0 until capabilities.length()) {
if (capabilities.optString(index) == capability) return true
}
return false
}
private fun recoveryAcknowledgements(
desktopPeerId: String,
androidPeerId: String,
): JSONArray =
repository.inSyncGenerationTransaction { db ->
val acknowledgements = JSONArray()
db.rawQuery(
"SELECT ack_json FROM sync_consumed_generations WHERE producer_peer_id=? " +
"AND consumer_peer_id=? AND result IN('consumed','rejected') " +
"ORDER BY consumed_at DESC,generation_id DESC LIMIT 32",
arrayOf(desktopPeerId, androidPeerId),
)
.use { cursor ->
while (cursor.moveToNext()) {
val acknowledgement = JSONObject(cursor.getString(0))
/* ACKs written before cross-runtime rejection diagnostics
* were normalized can hash '/' differently in Android
* and Python JSON canonicalizers. They remain immutable
* evidence, but must not block recovery of newer,
* compatible ACKs. Consumed ACKs are unaffected. */
if (
acknowledgement.optString("result") != "rejected" ||
!acknowledgement.optString("diagnostic").contains("/")
) {
acknowledgements.put(acknowledgement)
}
}
}
acknowledgements
}
private fun reconcileArchiveAcknowledgements(
raw: ByteArray,
runId: String,
@ -299,6 +367,7 @@ internal class SyncGenerationCoordinator(
"mobile-history-v4",
"execution-draft-v1",
"generation-archive-v1",
ACK_RECOVERY_CAPABILITY,
"session-preparations-v2",
"programs-v1",
)
@ -359,6 +428,7 @@ internal class SyncGenerationCoordinator(
directory: File,
timeout: Duration = Duration.ofMinutes(5),
afterPeerPublication: (() -> Unit)? = null,
afterRecoveryAcknowledgements: (() -> Unit)? = null,
afterPublication: (() -> Unit)? = null,
pollTransport: (() -> Unit)? = null,
afterAcknowledgement: (() -> Unit)? = null,
@ -433,6 +503,44 @@ internal class SyncGenerationCoordinator(
request.getString("desktop_peer_id"),
)
phase(runId, "archive_ack_observed", "desktop-archive-acknowledgements-v1.json")
if (requestSupports(request, ACK_RECOVERY_CAPABILITY)) {
/* WHY: Android may commit consumption after the desktop
* producer has already published its immutable generation, then
* transport can fail before the ACK reaches that producer.
* CONTRACT: return only exact durable consumer ACK evidence,
* bounded to the newest 32 rows, before either side captures a
* new generation. INVARIANT: recovery never fabricates lineage
* and never rewrites Android consumption history. */
val recoveryPath =
File(directory, "android-archive-acknowledgements-v1.json")
val recoveryEnvelope =
JSONObject()
.put("format", "trainlog-sync-archive-acknowledgements")
.put("version", 1)
.put("run_id", runId)
.put("android_peer_id", peer)
.put("desktop_peer_id", request.getString("desktop_peer_id"))
.put(
"acknowledgements",
recoveryAcknowledgements(
request.getString("desktop_peer_id"),
peer,
),
)
.toString()
if (recoveryEnvelope.toByteArray(Charsets.UTF_8).size > MAX_COORDINATION_BYTES)
throw SyncGenerationException(
"Android archive acknowledgement envelope exceeds bound"
)
publish(recoveryPath, recoveryEnvelope)
visibility.confirm(listOf(recoveryPath))
afterRecoveryAcknowledgements?.invoke()
phase(
runId,
"android_archive_ack_published",
recoveryPath.name,
)
}
/* WHY: transport can fail after immutable generation publication.
* CONTRACT: an explicit retry for the same run republishes that
* exact generation and resumes ACK handling; it never recaptures
@ -486,12 +594,15 @@ internal class SyncGenerationCoordinator(
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
acknowledgementMatchesCapturedGeneration(it, captured)
}, pollTransport, lease)
service.acceptAcknowledgement(desktopAck)
phase(runId, "desktop_ack_observed", "desktop-consumption-ack-v1.json", captured.generationId)
@ -541,6 +652,12 @@ internal class SyncGenerationCoordinator(
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())

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

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

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

@ -43,6 +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.SLEEP to R.drawable.ic_statistics,
AppSection.SYNC to R.drawable.ic_sync,
AppSection.SETTINGS to R.drawable.ic_settings,
)
@ -60,6 +61,7 @@ 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.SLEEP -> R.string.nav_sleep
AppSection.SYNC -> R.string.nav_sync; AppSection.SETTINGS -> R.string.nav_settings
})
fun title(): String = strings.getString(when (route) {
@ -67,13 +69,16 @@ fun AndroidAppShell(
AppRoute.SessionEditor -> R.string.route_session_editor; AppRoute.SessionGenerator -> R.string.route_session_generator
AppRoute.AiSessionDrafts -> R.string.route_ai_drafts; AppRoute.CompletedSessions -> R.string.route_completed_sessions
AppRoute.Programs -> R.string.route_programs; is AppRoute.ProgramDetail -> R.string.route_program_detail
AppRoute.CardioCalibration -> R.string.route_cardio_calibration
is AppRoute.SessionDetail -> R.string.route_session_detail; is AppRoute.SessionCorrection -> R.string.route_session_correction
AppRoute.Exercises -> R.string.nav_exercises; is AppRoute.ExerciseDetail -> R.string.route_exercise_detail
is AppRoute.ExerciseEdit -> R.string.route_exercise_edit; is AppRoute.ExerciseCreate -> R.string.route_exercise_create
AppRoute.Equipment -> R.string.nav_equipment; is AppRoute.EquipmentDetail -> R.string.route_equipment_detail
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()
@ -142,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, 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 }
@ -35,9 +36,11 @@ sealed interface AppRoute {
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 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) {
@ -45,6 +48,7 @@ fun AppSection.rootRoute(): AppRoute = when (this) {
AppSection.SESSIONS -> AppRoute.Sessions
AppSection.EXERCISES -> AppRoute.Exercises
AppSection.EQUIPMENT -> AppRoute.Equipment
AppSection.SLEEP -> AppRoute.SleepDiary
AppSection.SYNC -> AppRoute.Sync
AppSection.SETTINGS -> AppRoute.Settings
}
@ -169,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

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

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

@ -9,6 +9,7 @@ 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
@ -39,6 +40,8 @@ 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
@ -55,6 +58,7 @@ fun SessionsHub(
onDrafts: () -> Unit,
onHistory: () -> Unit,
onPrograms: () -> Unit = {},
onCardioCalibration: () -> Unit = {},
) {
val colors = LocalTrainlogColors.current
val strings = localizedContext()
@ -74,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"))
@ -423,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)
@ -460,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)
@ -521,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,10 +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
@ -46,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
@ -69,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(
@ -77,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
@ -97,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<
@ -133,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 =
@ -172,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(
@ -224,6 +269,19 @@ fun SessionScreen(
)
},
)
val cardio = currentDraft.sessionType == SessionType.CARDIO
TrainlogButton(
label = strings.getString(R.string.session_cardio),
modifier = Modifier.weight(1f),
style = if (cardio) TrainlogButtonStyle.SUCCESS else TrainlogButtonStyle.SECONDARY,
maxLines = 2,
onClick = {
persistDraft(
currentDraft.copy(sessionType = SessionType.CARDIO),
null,
)
},
)
}
}
@ -247,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
},
)
@ -338,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) {
@ -433,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 ->
@ -496,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"),
@ -567,7 +726,19 @@ 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 {
@ -618,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))
}
},
)
}
}
}
@ -860,7 +1167,8 @@ private fun SessionExerciseForm(
exercise: ExerciseProfile,
sessionType: SessionType,
initialForm: SessionDraftForm,
initialPlan: SessionExercisePlan?,
initialOccurrence: SessionExerciseDraft?,
onCreateEquipment: () -> Unit,
onFormChanged: (SessionDraftForm) -> Unit,
onCancel: () -> Unit,
onAdd:
@ -870,6 +1178,7 @@ private fun SessionExerciseForm(
val colors =
LocalTrainlogColors.current
val strings = localizedContext()
val initialPlan = initialOccurrence?.plan
var setCountText by
remember(key) {
@ -935,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)
}
@ -978,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(
@ -1025,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) {
@ -1265,6 +1569,7 @@ private fun SessionExerciseForm(
SessionNumberField(
label =
strings.getString(R.string.duration_minutes),
testTag = "session-continuous-duration",
value =
durationText,
onValueChange = {
@ -1351,7 +1656,7 @@ private fun SessionExerciseForm(
modifier = Modifier.fillMaxWidth().testTag("add-to-session"),
containerColor = colors.success,
onClick = {
val draft =
val capturedDraft =
buildSessionExerciseDraft(
exercise =
exercise,
@ -1373,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) {
@ -1396,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))
}
},
)
@ -1408,6 +1735,7 @@ private fun SessionExerciseForm(
strings.getString(R.string.return_catalog),
accent =
colors.muted,
modifier = Modifier.testTag("cancel-session-exercise-editor"),
onClick =
onCancel,
)

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

@ -35,7 +35,10 @@ 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
@ -240,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(),
@ -256,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 ->
@ -573,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

@ -103,9 +103,31 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
},
{ open(AppRoute.BodyMeasurements) }, { open(AppRoute.Sync) },
)
AppRoute.Sessions -> SessionsHub(activeDraft, pendingAiDraftCount, { open(AppRoute.SessionEditor) }, ::openManualSession,
{ open(AppRoute.AiSessionDrafts) }, { open(AppRoute.CompletedSessions) }, { open(AppRoute.Programs) })
AppRoute.SessionEditor -> SessionScreen(repository, catalogRevision, { draftRevision++; back() }, { open(AppRoute.ExerciseCreate(AppRoute.SessionEditor)) }, { exportSnapshot(); draftRevision++ })
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.SessionGenerator -> SessionGeneratorScreen(repository, generatorState, { back() }, { generatorState.abandon(); draftRevision++; open(AppRoute.SessionEditor) }, {
draftMessage = strings.getString(R.string.existing_draft_warning)
draftRevision++
@ -123,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)
@ -138,11 +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() }
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

@ -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>
@ -392,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>
@ -448,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>
@ -104,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>
@ -392,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>
@ -448,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

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

@ -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
@ -180,6 +181,7 @@ class SyncDeploymentConversationTest {
"full_generation_trigger_published",
"generation_request_observed",
"archive_ack_observed",
"android_archive_ack_published",
"generation_capture_started",
"generation_captured",
"generation_objects_published",
@ -215,6 +217,71 @@ class SyncDeploymentConversationTest {
}
}
}
if (suppliedTransport == null && it == 11) {
/* Reproduce the inverse deployed failure: Android has
* durably consumed the newest desktop generation, but its
* ACK was lost before the desktop producer committed it.
* The next conversation must recover that exact Android
* evidence before capturing another desktop generation. */
SQLiteDatabase.openDatabase(
desktop.absolutePath,
null,
SQLiteDatabase.OPEN_READWRITE,
).use { database ->
val generation =
database.rawQuery(
"SELECT generation_id FROM sync_generations " +
"WHERE status='acknowledged' ORDER BY generated_at DESC LIMIT 1",
null,
).use { cursor ->
assertTrue(cursor.moveToFirst())
cursor.getString(0)
}
database.delete(
"sync_acknowledgements",
"generation_id=?",
arrayOf(generation),
)
database.execSQL(
"UPDATE sync_generations SET status='waiting_acknowledgement'," +
"acknowledged_at=NULL WHERE generation_id=?",
arrayOf(generation),
)
database.rawQuery(
"SELECT status FROM sync_generations WHERE generation_id=?",
arrayOf(generation),
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals("waiting_acknowledgement", cursor.getString(0))
}
lostDesktopAckGeneration = generation
}
}
if (suppliedTransport == null && it == 12) {
val recovered = checkNotNull(lostDesktopAckGeneration)
SQLiteDatabase.openDatabase(
desktop.absolutePath,
null,
SQLiteDatabase.OPEN_READONLY,
).use { database ->
database.rawQuery(
"SELECT status FROM sync_generations WHERE generation_id=?",
arrayOf(recovered),
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals("acknowledged", cursor.getString(0))
}
database.rawQuery(
"SELECT parent_generation_id,status FROM sync_generations " +
"ORDER BY generated_at DESC LIMIT 1",
null,
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(recovered, cursor.getString(0))
assertEquals("acknowledged", cursor.getString(1))
}
}
}
localWorker = null
}
repository.close()

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"
@ -506,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())
@ -855,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,
@ -872,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",
@ -887,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

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

@ -37,7 +37,7 @@ class AppNavigationStateTest {
@After fun tearDown() { context.deleteDatabase(databaseName) }
@Test fun captureSectionsHaveTypedRootsWithoutMobileStatistics() {
assertEquals(6, AppSection.entries.size)
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)

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

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

View file

@ -4,12 +4,12 @@
"columns": 12,
"max_y": 200,
"tiles": [
{ "id": "next-session", "min_width": 4, "max_width": 8, "min_height": 3, "max_height": 7, "default": { "x": 0, "y": 0, "width": 5, "height": 4 } },
{ "id": "activity", "min_width": 5, "max_width": 12, "min_height": 3, "max_height": 7, "default": { "x": 5, "y": 0, "width": 7, "height": 4 } },
{ "id": "progression", "min_width": 6, "max_width": 12, "min_height": 4, "max_height": 9, "default": { "x": 0, "y": 4, "width": 8, "height": 5 } },
{ "id": "last-session", "min_width": 3, "max_width": 7, "min_height": 3, "max_height": 6, "default": { "x": 8, "y": 4, "width": 4, "height": 3 } },
{ "id": "max-records", "min_width": 3, "max_width": 8, "min_height": 3, "max_height": 7, "default": { "x": 8, "y": 7, "width": 4, "height": 3 } },
{ "id": "muscle-distribution", "min_width": 5, "max_width": 12, "min_height": 4, "max_height": 9, "default": { "x": 0, "y": 9, "width": 6, "height": 5 } },
{ "id": "cardio-recovery", "min_width": 5, "max_width": 12, "min_height": 4, "max_height": 8, "default": { "x": 6, "y": 10, "width": 6, "height": 4 } }
{ "id": "next-session", "min_width": 4, "max_width": 12, "min_height": 3, "max_height": 10, "default": { "x": 0, "y": 0, "width": 5, "height": 4 } },
{ "id": "activity", "min_width": 5, "max_width": 12, "min_height": 3, "max_height": 12, "default": { "x": 5, "y": 0, "width": 7, "height": 4 } },
{ "id": "progression", "min_width": 6, "max_width": 12, "min_height": 4, "max_height": 12, "default": { "x": 0, "y": 4, "width": 8, "height": 5 } },
{ "id": "last-session", "min_width": 3, "max_width": 12, "min_height": 3, "max_height": 10, "default": { "x": 8, "y": 4, "width": 4, "height": 3 } },
{ "id": "max-records", "min_width": 3, "max_width": 12, "min_height": 3, "max_height": 10, "default": { "x": 8, "y": 7, "width": 4, "height": 3 } },
{ "id": "muscle-distribution", "min_width": 5, "max_width": 12, "min_height": 4, "max_height": 12, "default": { "x": 0, "y": 9, "width": 6, "height": 5 } },
{ "id": "cardio-recovery", "min_width": 5, "max_width": 12, "min_height": 4, "max_height": 12, "default": { "x": 6, "y": 10, "width": 6, "height": 4 } }
]
}

View file

@ -1,5 +1,73 @@
# Android application
## Sleep Diary V1 and active V2 companion
Android schema v26 additively mirrors the Sleep Diary revision/event model for
local-first capture. The unreleased schema-v26 contract also includes the
revisioned medication catalog and entry-revision-owned intake snapshots. A
catalog default dose is only a form default; each intake retains its actual
optional positive dose and unit. Existing development databases already at
v26 receive the missing additive tables during the normal open transaction.
local-first field capture. Its compact Morning and Day sections edit one
durable night/day entry without reproducing the desktop/PDF grid. Creation,
correction and logical deletion are revision guarded and survive repository
reopen. Full-generation exchange transports the companion, never SQLite.
Each event, intake, appreciation, date or note edit is saved locally at once;
closing the screen or killing the process does not require validation or sync.
The screen resumes the most recent entry and distinguishes draft, ready,
synchronized and modified tips. “Validate day” marks the current revision for
the existing global synchronization action and never makes it immutable.
Android schema v32 additively stores each immutable medication-intake
`quantity` as an integer in `1..99`, with `1` backfilled for historical rows.
It also adds a separate quick-publication marker for the exact current
revision. Quick publication makes a current fact eligible for generation; it
does not validate a day and does not change the existing validation/ACK
lifecycle.
Android schema v33 enforces that same immutable identity boundary inside
SQLite. Eight append-only triggers reject update or deletion of Sleep diary
revisions, events, medication intakes, and medication revisions. Repository
imports compare a known ancestor's complete canonical payload before the stale
ancestor shortcut, so a reused ID with a different parent or content cannot be
hidden below a later local tip. All user edits continue to create a fresh
UUIDv4 revision and preserve the previous payload and causal parent.
Medication-intake writes name all columns explicitly, so the additive
`quantity` column cannot reorder note or creation-time values in upgraded
schema-v33 databases.
The quick Sleep capture derives one night identity from the existing
18:00-to-18:00 date rule. A medication action can create that pending entry
before `BED_TIME`, later medication actions reuse it, and Couché appends
`BED_TIME` to the same stable `entry_id`. Réveil and Levé remain unavailable
until bedtime. The compact quantity control defaults to one and resets after a
successful intake; the per-unit dose and unit remain independent structured
facts. Pending entries persist through restart and can synchronize without
inventing bedtime.
The persistence regression begins from an empty pending revision, records two
pre-bed intakes (one with quantity two), and reopens the repository after
intake, Couché, Réveil and Levé. It then proves the same stable entry and intake
snapshots through generation, desktop import/ACK, exact replay, rejection of a
stale intake-free ancestor and a later current revision. This is the executable
contract for lifecycle and synchronization retention; it does not infer facts
that are absent from a device database.
The one-tap Réveil action records `long_awake` rather than a point
`night_get_up`: its factual start is the tap timestamp and its policy end is 30
minutes later. Another tap inside that window extends the same interval to 30
minutes after the latest tap; Levé clamps an overlapping interval to the exact
final-get-up timestamp. This explicit default requires no Rendormi interaction,
remains revisioned/undoable, and is not derived from heart-rate measurements.
Sleep-owned HR capture remains sample-driven. A pending medication alone opens
no capture. After Couché, the first fresh real measurement creates or resumes
one `context_kind=sleep` capture for that entry, with `started_at` equal to the
factual bedtime and each measurement retaining its observed timestamp, BPM and
raw RR values. Réveil neither stops nor splits it; Levé closes it at the exact
final-get-up timestamp. No sensor measurement means no fabricated capture or
sample.
## Background USB and private Drive synchronization
SyncScreen and the explicitly enabled background service invoke one
@ -23,7 +91,16 @@ Android schema v22 adds a pending manual-preparation store which is separate
from AI proposals and the exactly-one active-session singleton. A received
preparation is inert until explicit start; an occupied singleton is never
overwritten. Starting preserves the desktop-reserved execution `session_id`,
copies targets only and creates no performed facts.
copies targets only and creates no performed facts. A Program-owned preparation
also preserves `source_program_id` and `source_program_session_id` through the
active draft and completed session. A legacy stored delivery may receive those
two omitted fields later only when its revision, complete prior payload and
reserved execution identity match exactly; this repairs provenance without
altering performed workout facts.
Acknowledged historical deliveries use the same exact-identity recovery. A
local continuous duration target is validated during generation capture while
the completed duration, speed and distance remain represented by the existing
`continuous` history payload rather than an invented set target.
Android schema v23 adds the durable manual-preparation withdrawal ledger. The
schema is unchanged by Web proposal withdrawal: the existing durable AI draft
@ -612,7 +689,24 @@ resume action after relaunch.
Final save validates the durable draft, inserts the completed session and actual
values, and removes the draft in one SQLite transaction. Failure rolls back and
retains the draft for retry; repeated completion does not create duplicates.
The existing completed-session save-time timestamp behavior is unchanged.
Planned targets remain prescriptions: finalization accepts fewer or more valid
performed sets and actual repetitions or loads that differ from their targets.
Finishing a target-only continuous occurrence with no supplemental fields
materializes its performed duration from that occurrence's already persisted
timeline start/end interval. This also repairs a preserved draft whose interval
was closed by an older build without creating the performed row. Existing
performed values are never overwritten, and continuous occurrences that require
speed or distance still require those measured values explicitly.
Ordinary completion uses the save-time timestamp. A private recovery build may
embed one exact session identity and one explicit offset-aware factual end time;
only that matching draft uses the supplied time. The repository rejects an end
before the session start or latest exercise marker. When heart-rate samples
arrived after a failed factual session end, delayed finalization keeps samples
at or before that inclusive end and transactionally deletes the strict tail.
RR children follow their BPM sample through the declared foreign-key cascade,
and the stopped capture uses the exact factual end. Retained sample identities
and values are never retimed or renumbered.
PC catalog reconciliation preserves draft references through catalog row
ownership. If an editing selection no longer resolves, only the selection is
@ -866,8 +960,18 @@ decimal input is accepted (`12,5`); one value applies to all sets or values may
be separated with `;`. Assisted equipment is explicitly labelled
`Assistance (kg)`. Empty load remains distinct from an entered zero.
`Nouvelle machine` in that same selector creates a persistent local custom
equipment entry with a generated stable `eq_…` ID and selects it immediately.
`Créer un équipement` in that selector opens the existing catalogue creation
route. An explicit successful creation returns automatically to the same
exercise editor, restores the stable occurrence being edited, and selects the
new persistent `eq_…` ID; the draft's other raw fields and target remain
unchanged. No equipment is synthesized merely by opening the route.
The selector presents every matching catalogue entry inside a bounded,
independently scrollable result list; it does not truncate the selectable
catalogue to its first rows. An existing target-only prepared occurrence may
therefore change or create its equipment association and save in place while
retaining its `entry_id`, plan and sibling occurrences. This operation does not
fabricate a performed set; a partially entered performed row still follows the
normal validation path and is never silently discarded.
The shared bundled catalogue uses reserved canonical IDs. User-created IDs are
exchanged first by definitions V1, so a V2 association can resolve them on the
receiving side; conflicts remain explicit and are never converted to null.

View file

@ -61,6 +61,14 @@ long-term history. Core owns data, persistence, business logic, calculations,
read models, command services, and versioned contracts. An interface must not
query tables directly to reconstruct its own business rules.
The Web Dashboard and Analyse workspace share `GET /api/v1/analysis`, backed by
one C17 read model. Period filtering, active-Program progress, compatible
exercise metrics, explicit MAX results, primary BODY ZONE exposures, and body
measurement first/last/delta calculations occur in Core. React validates the
bounded response and presents it; it does not load raw history or issue SQL.
Absent values remain null, and the model never estimates physiological or
performance scores.
Android local SQLite is a capture store, not a synchronization format.
## 2. Components

View file

@ -0,0 +1,37 @@
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="52" viewBox="0 0 960 52" role="img" aria-labelledby="title desc">
<title id="title">Trainlog technology badges</title>
<desc id="desc">Local-first, C17, Android, local Web, GPLv3, and development version 0.1.6.</desc>
<style>
.label { font: 600 16px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; fill: #cdd6f4; dominant-baseline: middle; text-anchor: middle; }
</style>
<g>
<rect x="0" y="4" width="172" height="44" rx="12" fill="#1e1e2e" stroke="#b4befe" stroke-width="2"/>
<circle cx="24" cy="26" r="6" fill="#b4befe"/>
<text class="label" x="100" y="27">LOCAL-FIRST</text>
</g>
<g>
<rect x="184" y="4" width="116" height="44" rx="12" fill="#1e1e2e" stroke="#89b4fa" stroke-width="2"/>
<circle cx="208" cy="26" r="6" fill="#89b4fa"/>
<text class="label" x="258" y="27">C17</text>
</g>
<g>
<rect x="312" y="4" width="160" height="44" rx="12" fill="#1e1e2e" stroke="#a6e3a1" stroke-width="2"/>
<circle cx="336" cy="26" r="6" fill="#a6e3a1"/>
<text class="label" x="405" y="27">ANDROID</text>
</g>
<g>
<rect x="484" y="4" width="176" height="44" rx="12" fill="#1e1e2e" stroke="#74c7ec" stroke-width="2"/>
<circle cx="508" cy="26" r="6" fill="#74c7ec"/>
<text class="label" x="584" y="27">LOCAL WEB</text>
</g>
<g>
<rect x="672" y="4" width="132" height="44" rx="12" fill="#1e1e2e" stroke="#cba6f7" stroke-width="2"/>
<circle cx="696" cy="26" r="6" fill="#cba6f7"/>
<text class="label" x="753" y="27">GPLv3</text>
</g>
<g>
<rect x="816" y="4" width="144" height="44" rx="12" fill="#1e1e2e" stroke="#fab387" stroke-width="2"/>
<circle cx="840" cy="26" r="6" fill="#fab387"/>
<text class="label" x="896" y="27">v0.1.6</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -0,0 +1,66 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="480" viewBox="0 0 1440 480" role="img" aria-labelledby="title desc">
<title id="title">Trainlog</title>
<desc id="desc">A Catppuccin Mocha banner for a local-first training data system.</desc>
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#11111b"/>
<stop offset="0.58" stop-color="#181825"/>
<stop offset="1" stop-color="#1e1e2e"/>
</linearGradient>
<linearGradient id="lavender" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#b4befe"/>
<stop offset="1" stop-color="#cba6f7"/>
</linearGradient>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M40 0H0V40" fill="none" stroke="#45475a" stroke-width="1" opacity="0.22"/>
</pattern>
<filter id="glow" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="7" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<rect width="1440" height="480" rx="28" fill="url(#bg)"/>
<rect width="1440" height="480" rx="28" fill="url(#grid)"/>
<path d="M0 25Q0 0 25 0H1415Q1440 0 1440 25" fill="none" stroke="#b4befe" stroke-width="4"/>
<g transform="translate(72 64)">
<circle cx="12" cy="12" r="7" fill="#f38ba8"/>
<circle cx="36" cy="12" r="7" fill="#f9e2af"/>
<circle cx="60" cy="12" r="7" fill="#a6e3a1"/>
<text x="90" y="19" fill="#6c7086" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="21">~/trainlog</text>
<text x="0" y="164" fill="#cdd6f4" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="92" font-weight="700" letter-spacing="10">TRAINLOG</text>
<rect x="2" y="190" width="610" height="5" rx="2.5" fill="url(#lavender)"/>
<text x="0" y="243" fill="#b4befe" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="27" letter-spacing="1">capture · synchronize · inspect · understand</text>
<text x="0" y="293" fill="#a6adc8" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="21">Android / C17 + Notcurses / Local Web / SQLite</text>
</g>
<g transform="translate(890 72)">
<rect width="470" height="336" rx="22" fill="#11111b" stroke="#45475a" stroke-width="2"/>
<path d="M0 58H470" stroke="#45475a" stroke-width="2"/>
<circle cx="28" cy="29" r="6" fill="#f38ba8"/>
<circle cx="50" cy="29" r="6" fill="#f9e2af"/>
<circle cx="72" cy="29" r="6" fill="#a6e3a1"/>
<text x="96" y="36" fill="#6c7086" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="17">facts.timeline</text>
<path d="M34 222H436" stroke="#313244" stroke-width="2"/>
<path d="M34 118H436M34 170H436M34 274H436" stroke="#313244" stroke-width="1" stroke-dasharray="5 8"/>
<path d="M34 221H78L94 219L111 145L128 263L147 212H188L207 194L226 205L245 114L264 248L286 218H327L345 176L364 203L384 202L402 155L420 221H436" fill="none" stroke="#b4befe" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" filter="url(#glow)"/>
<circle cx="111" cy="145" r="6" fill="#f38ba8"/>
<circle cx="245" cy="114" r="6" fill="#f38ba8"/>
<circle cx="402" cy="155" r="6" fill="#a6e3a1"/>
<rect x="34" y="292" width="112" height="24" rx="12" fill="#313244"/>
<circle cx="50" cy="304" r="5" fill="#a6e3a1"/>
<text x="63" y="310" fill="#cdd6f4" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="14">local</text>
<rect x="158" y="292" width="136" height="24" rx="12" fill="#313244"/>
<circle cx="174" cy="304" r="5" fill="#89b4fa"/>
<text x="187" y="310" fill="#cdd6f4" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="14">versioned</text>
<rect x="306" y="292" width="130" height="24" rx="12" fill="#313244"/>
<circle cx="322" cy="304" r="5" fill="#b4befe"/>
<text x="335" y="310" fill="#cdd6f4" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="14">factual</text>
</g>
<rect x="1" y="1" width="1438" height="478" rx="27" fill="none" stroke="#313244" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -1,20 +1,31 @@
# Current implementation state
Full-generation Web synchronization now selects an actually usable MTP peer
before USB, falls back to a separately configured private Drive generation
namespace, and mirrors successful USB conversations to Drive on a best-effort
basis without rolling back USB. Both transports move the same generation/ACK
bytes; neither transports SQLite. Android has an explicitly enabled foreground
USB listener with mandatory notification and a private-folder Drive grant with
bounded WorkManager checks. Both use the same concurrency-guarded generation
Full-generation synchronization uses one generation/ACK business engine across
versioned transports. On the paired 0.1.4 deployment, automatic local selection
is Bluetooth Classic RFCOMM first and direct USB/MTP second. The desktop BlueZ
service accepts only the configured bonded Android address, Android proves the
persisted Trainlog peer identity in the first bounded frame, and reconnect noise
is rate-limited so proximity does not create repeated generations. MTP remains
the wired recovery path. The separately configured private Drive transport
remains available to its legacy mirror/fallback workflow. No transport copies
SQLite.
Automatic Bluetooth restart recovery preserves the immutable generation
identity across conversation-run changes and recognizes the exact original-run
ACK by immutable generation ID and manifest digest before full SQLite
validation. An unacknowledged branch is excluded
from the bounded admission count only when a different sibling has a durable,
ledger-backed consumed acknowledgement; the superseded bytes and row remain
retained. The desktop service installer retires the exact obsolete private
ExecStart override that omitted required runtime paths and installs both
`trainlog-syncd` and the BlueZ RFCOMM agent.
Android synchronization paths share the same concurrency-guarded generation
coordinator as SyncScreen. That coordinator has one process-wide ownership
arbiter: explicit foreground intent has priority over automatic background
resumption, while the foreground service may remain alive without owning the
resumption, while a foreground service may remain alive without owning the
conversation. An automatic run yields cooperatively at safe boundaries; its
durable generation is retained for a correlated handoff rather than deleted.
Background actionability distinguishes a new request or new correlated remote
evidence from a merely stale resumable generation, preventing five-second
terminal retry loops.
Direct-MTP publication now has an explicit Android visibility boundary.
Durable immutable generation files are submitted to MediaProvider and all scan
@ -45,23 +56,33 @@ and an accessible send action. Draft/local advances the same preparation to a
ready revision before delivery; ready/local delivers the current revision.
Completed history hides the active preparation exclusively through the
delivery's stable execution-session identity and retains all provenance.
Starting a delivered mixed preparation preserves continuous duration targets
and set/repetition/load/rest targets in the active singleton without creating
performed continuous activity, performed sets, MAX results, history, or
feedback before explicit user capture.
The active-session editor accepts target-only continuous and set-based
occurrences: opening an existing stable entry renders its prefilled form,
keeps the independent target intact on save, and leaves sibling occurrences
unchanged on save or cancellation. A set-based target-only occurrence can add
or change equipment without fabricating a performed set.
Session completion now also turns a completed target-only continuous timeline
into its measured performed duration when no supplemental field is required.
Program targets remain independent from facts, so a valid execution may contain
fewer or more sets and different repetitions or loads. A strictly session-bound
private recovery build can supply a past factual end without retiming exercise
markers or immutable heart-rate samples.
The opt-in full-generation path is operational on the private daily
installation. Android explicit synchronization uses a dedicated coordination
filename carrying the unchanged request-v1 payload. Legacy V3 signalling is
additive and occurs only after a fresh strict V3 snapshot succeeds; daemon
request identities are deduplicated across both bounded channels. The loopback
Web control and foreground Android coordinator use
the production direct-libmtp adapter, correlated manifest/ACK V1 objects, and
persistent peer identities. Controlled USB and private Drive validation proved
the complete Web-initiated request, generation, import, publication, and ACK
conversation without opening SyncScreen for each run. USB remains preferred;
the private `Trainlog/Sync/v1` namespace is configured as a verified mirror and
fallback, independently from `Trainlog/AI`. Cross-transport replay is
idempotent and neither transport carries SQLite.
The full-generation path is operational on the paired daily installation.
Bluetooth Classic is the primary local byte transport, MTP remains the wired
recovery transport, and the existing private Drive namespace remains separately
configured for its supported mirror/fallback path. All carry the same bounded
generation/ACK artifacts and persistent peer identities; cross-transport replay
is idempotent and no transport carries SQLite. The Bluetooth transport does not
yet ingest heart-rate sensor data.
The public Android release chain advances from v0.1.1 versionCode 2 through
v0.1.2 versionCode 3 to v0.1.3 versionCode 4 under certificate SHA-256
v0.1.2 versionCode 3, v0.1.3 versionCode 4, v0.1.4 versionCode 5 and v0.1.5
versionCode 6 to v0.1.6 versionCode 7 under certificate SHA-256
`5ef41117da107c9805ae3215c5ec823cff06216610e6e4f7542a754161c71bd2`.
Higher-numbered private validation builds used a separate Android Debug
certificate and are not members of the public update-signature chain.
@ -76,7 +97,7 @@ The rollout packager now keeps the legacy request daemon and one-shot helper in
the same relocatable, hashed bundle as the Web generation coordinator, avoiding
mixed-checkout user-service execution during the controlled rollout.
Snapshot date: **2026-09-20**.
Snapshot date: **2026-09-25**.
`TRAINLOG_CODE_READABILITY_V1` normalizes the recent generation-MTP,
generation/ACK, Android-backup, Web Dashboard serialization, tests, doubles and
@ -94,9 +115,9 @@ Android is the field companion: it captures training and body data, preserves
the active draft, receives AI proposals, triggers synchronization, and shows
quick summaries. The C17/Notcurses TUI is the administration, inspection,
maintenance, import/export, and technical-tooling surface. The local Web
sibling now provides its embedded application shell and implemented Dashboard;
its future business surfaces will own broader analysis, program/session
preparation, and exercise workflows through Trainlog Core.
sibling provides its embedded application shell, Dashboard, Sessions,
Programmes, Exercises, factual Analyse views, and Sleep Diary
workflow through Trainlog Core.
Trainlog Core owns business truth. The desktop SQLite database remains the
canonical local source of truth and long-term history; no interface owns a
@ -107,11 +128,11 @@ parallel implementation of its rules.
| Boundary | Current state |
|---|---|
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
| Desktop SQLite | schema v28 |
| Android SQLite | schema v25 |
| Desktop SQLite | schema v36; v36 makes Sleep revision payload tables append-only; v35 gives every Sleep medication intake a structured `quantity` (1..99), defaulting historical rows to 1 |
| Android SQLite | schema v33; v33 makes Sleep revision payload tables append-only; v32 gives every Sleep medication intake a structured `quantity` (1..99), defaults historical rows to 1, and records a distinct quick-publication tip |
| Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport |
| Desktop terminal backend | Notcurses only |
| Trainlog product version | `0.1.3` stable, synchronized across Android and desktop; latest stable release: `v0.1.3` |
| Trainlog product version | `0.1.6` stable, synchronized across Android and desktop; latest stable release: `v0.1.6` |
| Interface language | `TRAINLOG_I18N_V0_1_1=PASS`: French default; English selectable in Settings → Language on both surfaces |
| AI history export | `TRAINLOG_AI_EXPORT_V1` active |
| AI session proposals | `TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING` |
@ -129,6 +150,7 @@ parallel implementation of its rules.
| Web Dashboard tiles | `WEB_DASHBOARD_TILES_V1=PASS/FROZEN` |
| Web Dashboard visualizations | `WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` |
| Web Dashboard V1 | `WEB_DASHBOARD_V1=PASS/FROZEN` |
| Sleep Diary V1/V2 | `SLEEP_DIARY_V1=PASS` remains frozen and readable. The active Sleep exchange requires `sleep-diary-v2` / `sleep-diary-v2.json`: structured per-intake quantity and quick-publishable current revisions supplement the existing causal diary model without changing V1. |
| Web Sessions V1 | `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN` (controlled desktop/Android deployment validated) |
| Web Sessions deletion and Programs V1 | `TRAINLOG_WEB_SESSIONS_DELETE_AND_PROGRAMS_V1=PASS` (private grouped rollout validated) |
| Programs presentation, Android projection, and deletion | `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=PASS` (private coordinated deployment and restart/replay validated) |
@ -138,21 +160,67 @@ parallel implementation of its rules.
| Web sync API V1 | `TRAINLOG_WEB_SYNC_API_V1=PASS/FROZEN` |
| Web sync button V1 | `TRAINLOG_WEB_SYNC_BUTTON_V1=PASS/FROZEN` |
| Sync Web end-to-end wrapper | `TRAINLOG_SYNC_WEB_END_TO_END_V1=PASS/FROZEN` (isolated) |
| Private full-generation rollout | `TRAINLOG_SYNC_FINALIZATION_AND_ROLLOUT_V1=PASS` (direct MTP, one authorized phone) |
| Private full-generation rollout | `TRAINLOG_SYNC_FINALIZATION_AND_ROLLOUT_V1=PASS` (authenticated Bluetooth primary transport with direct MTP recovery on one authorized phone) |
| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` |
| Web Exercises V1 | `TRAINLOG_WEB_EXERCISES_V1=PASS` |
| Current operational cursor | `TRAINLOG_ANDROID_UI_REDESIGN_V1_CONTRACT` |
| Cardio persistence/sync | `TRAINLOG_CARDIO_PERSISTENCE_SYNC_V1=PASS`: Android v27 + desktop v30, strict optional `trainlog-heart-rate` V1 Android→desktop companion, generation relation and correlated ACK |
| Recovered session HR tail | `TRAINLOG_HR_TRUNCATE_AFTER_SESSION_END_V1=PASS`: delayed Android finalization truncates BPM/RR strictly after the inclusive factual end, and the separate `trainlog-heart-rate-corrections` V1 companion reuses causal state without widening frozen causal-deletions V1. The paired-device recovery for `se_fa845379-794d-4bb6-8cd3-48187ebbd134` retained 3,429 BPM/RR facts through 11:16; two successive full MTP exchanges received consumed ACKs, and replay of the original 4,919-sample Heart Rate V1 artifact did not resurrect the tail. |
| Cardio BLE diagnostics | `TRAINLOG_CARDIO_BLE_DIAGNOSTICS_V1=PASS`: real CYCPLUS H2 validated on Android; `0x180D` + `0x2A37`, Battery Service, approximately 1 Hz notifications and one raw RR interval per observed notification; contact and energy fields absent in the observed payloads |
| Cardio persistent acquisition | `TRAINLOG_CARDIO_PERSISTENT_ACQUISITION_V1=PASS`: selected sensor persists, dedicated connected-device foreground service reconnects, stale BPM is cleared, and the global Android `♥ BPM` indicator is grey/orange/green by connection state; real CYCPLUS restart and Bluetooth-off/on recovery validated on private vc40 |
| Training timeline | `TRAINLOG_CARDIO_TRAINING_TIMELINE_V1=PASS`: real session bounds reuse canonical lifecycle timestamps; exercise start/end are explicit user actions; live BPM samples snapshot the active stable `entry_id`; session finalization refuses an unclosed exercise; timeline sync is Android→desktop idempotent |
| Sleep Android one-tap | `TRAINLOG_SLEEP_ANDROID_ONE_TAP_V1=PASS`: one-tap Couché / medication / Réveil / Levé, guarded 10 s undo, correction route, and sleep-entry-owned cardio capture remain intact. Réveil now creates a structured 30-minute `long_awake` window, repeated taps extend it, and Levé clamps it. The validated V2 development delta additionally creates/reuses one 18:00-to-18:00 pending entry for pre-bed medication, reuses its stable `entry_id` at Couché, and exposes a compact quantity control. |
| Cardio session type | `TRAINLOG_CARDIO_SESSION_TYPE_V1=PASS`: Android v29 + desktop v32 use canonical `session_kind=cardio`; frozen mobile/history and draft formats keep legacy training/max_test; dedicated `trainlog-cardio-sessions` V1 syncs completed cardio sessions Android→desktop idempotently |
| Cardio calibration | `TRAINLOG_CARDIO_CALIBRATION_V1=PASS`: reserved system Calibration cardio exercise, real measured observed peak, ≥3 min recovery with bounded +1/+2/+3 min factual samples, immutable profile and Android→desktop calibration companion |
| Cardio BPM guidance | `TRAINLOG_CARDIO_BPM_GUIDANCE_V1=PASS`: absolute/calibration-derived immutable BPM targets, 2 BPM hysteresis, 3 s confirmation, stale-signal suspension, durable phase/condition history, Android live guidance UI and strict Android→desktop `trainlog-cardio-guidance` V1 sync |
| Cardio Web visualization | `TRAINLOG_CARDIO_WEB_VISUALIZATION_V1=PASS`: one bounded factual heart-rate timeline read model/HTTP endpoint powers Web History and Sleep with raw BPM/RR, real session/exercise/Sleep markers, guided targets/instructions and calibration facts; no sleep-stage or medical inference |
| Sleep tonight readiness delta | `TRAINLOG_SLEEP_TONIGHT_CLOSEOUT_V1=PASS`: Meson 111/111, import checks 6/6, Android `testDebugUnitTest` plus `assembleDebug`, Web 191/191 plus typecheck/build, exact V2 quantity replay, private vc47 schema-v32 migration, fresh CYCPLUS BPM and non-mutating Android/Web visual review. |
| 0.1.5 validation | `TRAINLOG_0_1_5_VALIDATION_V1=PASS`: native 111/111; Android unit-test task and APK build; Web 191/191 plus typecheck/build; JSON/import, C-format and diff checks pass. Private vc47 preserves real data through schema v32, renders pre-bed medication quantity controls, reconnects the CYCPLUS and presents the multi-night Web Sleep agenda. |
| Tomorrow session readiness | `TRAINLOG_TOMORROW_SESSION_CARDIO_READINESS_V1=PASS`: the private vc46 is clean on schema v31 with fresh CYCPLUS BPM and the next Program session is unambiguous; one combined regression proves two ordered occurrences loaded from the synchronized Program, Program provenance, factual timeline/HR ownership, finalization, full generation, desktop import, replay and correlated ACK |
| 0.1.6 validation | `TRAINLOG_0_1_6_VALIDATION_V1=PASS`: native Meson 114/114 and Clang ASan/UBSan 114/114; Android 317 tests (312 passed, five documented optional skips), debug APK and signed release APK; Web 32 files / 209 tests plus typecheck/build; JSON/import, changed-C formatting and diff checks pass. |
| Current operational cursor | Stable v0.1.6 closes the Web refinement, Sleep timeline, synchronization recovery and factual session-finalization cycle; subsequent stabilization opens as 0.1.7 after the immutable release tag. |
| Sleep awake/selection/PDF refinement | `TRAINLOG_SLEEP_AWAKE_SELECTION_PDF_V1=PASS`: one temporal projection drives agenda and selected HR detail; a separate inclusive night-start date range drives preview/export from the loaded period. The agenda axis and rows share 24 exact hour columns; estimated sleep duration is consistent and remains non-persistent. |
| Sleep Réveil interval policy | Android Réveil creates a revisioned 30-minute `long_awake` window; repeated taps extend it and Levé clamps it. The real 2026-09-23 night is causally repaired on Android/desktop and renders two orange intervals in agenda, HR and PDF; Android 307 passed plus five optional skips, and Web 208/208 passed. |
| Sleep revision collision recovery | `TRAINLOG_SLEEP_REVISION_COLLISION_RECOVERY_V1=PASS`: one unpublished divergent desktop tip was re-identified without payload loss, the immutable Android identity was restored exactly, and a fresh successor retained the non-conflicting union. The original generation was consumed with a full SQLite ACK; the real 24/09 session, Sleep facts, cardio samples, timeline, feedback, and Program provenance are visible on Desktop/Web. Schema v36/v33 and importer payload checks prevent recurrence. |
| Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered |
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |
| Synchronization data/lifecycle slice | `TRAINLOG_SYNC_DATA_LIFECYCLE_V1=PASS/FROZEN` |
| Synchronization causal deletion slice | `TRAINLOG_SYNC_CAUSAL_DELETE_V1=PASS/FROZEN` |
| Synchronization generation/ACK slice | `TRAINLOG_SYNC_GENERATION_ACK_V1=PASS/FROZEN` (explicit staged entry points; active transport remains V3) |
| Generation MTP and Android backup | Software complete and validated in the authorized private physical-device rollout |
| Bluetooth synchronization transport | `TRAINLOG_SYNC_BLUETOOTH_TRANSPORT_V1=PASS/FROZEN`; independent from future heart-rate sensor ingestion |
| Generation MTP and Android backup | Software complete and retained as the authorized wired recovery path |
Desktop and Android schema numbers are independent. Neither changes the frozen
Trainlog JSON V1 contract.
The active Sleep V2 delta keeps the Web agenda period-owned: every returned
night has one row on the shared 18:00-to-18:00 geometry. Selecting a row is
presentation-only and atomically drives the detail dates, editor content, and
full-width heart-rate detail; it performs no database mutation. PDF preview
and download instead share an explicit inclusive range over night start dates,
initialized to the loaded agenda bounds and independent of detail selection. Its
rise/fall overlays are descriptive relative measured
variation only (five-minute trailing reference excluding the current 60-second
window; threshold `max(10 BPM, 15%)`; gaps over three seconds excluded). They
are not sleep events, sleep stages, causes, readiness, or medical inference.
The detail shares one normalized time projection with declared sleep and
awakening intervals, exact Couché/Réveil/Levé markers, medication markers, and
full-hour ticks between exact visible bounds. Medication presentation retains
unit dose and quantity as separate facts. A missing declared sleep interval
uses only a labeled, non-medical Web estimate from Couché plus 45 minutes to
Levé minus 10 minutes when both bounds exist; it never changes canonical Sleep
data. Agenda, selected-night graph, and ranged PDF consume the same projection of
sleep intervals, every structured long-awake interval, point events, medication
intakes, and bounds. The agenda renders an estimated interval with distinct
hatching, never substitutes it for a factual interval, and retains the exact
shared 18:00-to-18:00 geometry, reinforced by 24 equal hour columns shared by
every row, with each hour label centered inside the cell it names. Displayed
events use one factual night offset even when revision metadata is UTC, and
French agenda headings use `DD/MM/YYYY`. Displayed sleep duration is the sum of factual
sleep intervals or, in their absence, the displayed estimate minus overlapping
structured awakenings. The overlays remain available without an HR capture and
do not fabricate BPM.
Language is device-local presentation state. Android keeps it in dedicated
SharedPreferences; desktop keeps it in the XDG configuration file
`trainlog/presentation.conf`. A successful selection updates the UI
@ -228,6 +296,10 @@ Android currently provides:
- exactly one durable active session draft with identity-preserving continuous
multi-position drag reorder,
resume, confirmed discard, and atomic completion;
- complete scrollable equipment selection and caller-aware creation from an
active occurrence editor, with the explicit catalogue-created ID selected on
return and the occurrence identity, plan, raw form and siblings retained for
in-place save;
- completed-session consultation, transient drag reorder, and atomic factual or
order correction while retaining session/occurrence/feedback identities for
synchronization;
@ -275,6 +347,53 @@ the result. The Web aggregate now exposes the distinct factual snapshot through
## Local Web
The 0.1.6 refinement adds a persistent French/English preference and a dedicated
Settings route without moving the global synchronization action. Dashboard
activity is grouped by calendar month/ISO week, its progression tile is now a
global factual synthesis, and Analyse progresses from primary BODY ZONE to
exercise to individual detail. Exercise work time is derived exclusively from
`session_exercise_timeline` start/end facts; missing timelines remain unknown.
Measurements first expose unit-safe relative change cards before real-unit
detail. Dashboard and Analyse reuse the same human-proportioned BODY ZONES SVG.
The Dashboard layout keeps a 12-column canonical grid but permits every card
to reach full width and several useful heights; per-card minima, overlap
validation, exact save/cancel/reset semantics, and keyboard resizing remain
authoritative. Settings and synchronization form the rightmost Header action
cluster. Program progress uses the shared success color while retaining its
factual completed/total text. Responsive charts preserve their viewBox aspect
ratio, and Analyse explicitly distinguishes unavailable exercise timing from
partial measured coverage instead of deriving time from sets or repetitions.
The shared Sleep agenda owns one label/track/facts grid. Its 25 exact hourly
positions and every event, interval and medication marker use the same
coordinates: 18:00 at 0%, 00:00 at 25%, 06:00 at 50%, 12:00 at 75%, and 18:00
at 100%. Ordinary desktop widths show the 13 two-hour labels, wide layouts show
all hours, and narrow layouts retain the five six-hour anchors without changing
the underlying geometry.
The Equipment route projects supplied and custom definitions with exercise,
history, preparation and Program reference counts. Its guarded merge accepts a
custom duplicate only, transfers relational references in one SQLite IMMEDIATE
transaction, preserves occurrence/MAX facts indirectly through occurrence
identity, and writes the existing causal deletion boundary. An immutable
execution-draft JSON reference rejects the operation rather than being edited.
Browser mutation identities use the shared UUIDv4 helper backed by
`crypto.getRandomValues()`; no Web mutation depends on `crypto.randomUUID()`,
so `http://trainlog.perf` remains supported.
An `execution_drafts` row marked active but containing no `exercises` or
legacy `entries` is an orphaned projection, not a resumable workout. Web may
delete that exact row through the existing revision-guarded causal transaction;
the durable tombstone prevents synchronization replay from resurrecting it.
An active draft with any occurrence remains protected from Web deletion.
Generation admission archives only rows backed by an exact correlated consumed
ACK and retains the newest two acknowledged lineage members. Legacy terminal
Android rows that predate the durable ACK ledger are neither fabricated nor
deleted, but no longer consume the active admission window. Captured,
published, waiting-ACK, and current acknowledged rows continue to count until
safe archival, so capacity recovery never prunes unacknowledged evidence.
`TRAINLOG_WEB_V1` architecture, API independence, local-network boundary,
browser shell, Dashboard, layout ownership, build/runtime separation, and
security invariants are canonical. The CLI/local HTTP infrastructure and
@ -291,7 +410,24 @@ explicit proposal derivation and generation-backed Android delivery. The
top-level `/programmes` route is an operational daily active-Program calendar;
Sessions → Programmes remains the technical administration/import, list,
detail, archive, and delete surface. `/exercices` is the operational desktop
catalogue administration surface; Analyse remains an explicit placeholder.
catalogue administration surface. `/analyse` is implemented with Overview,
Exercises, Distribution, and Measurements sections over one bounded Core read
model. Its global 7/30/90-day/all filter is applied in the backend; Dashboard
uses the same 30-day calculations for activity, active-Program progress,
exercise progression, measurements, and BODY ZONE links.
The Analyse API validates period, exercise identity, measurement metric and
page, rejects unknown or duplicate parameters, and bounds exercises and chart
points. Missing facts remain null. Exercise output respects persisted
tracking/load modes, explicit MAX rows are never estimated, and duration,
distance, speed, external load, and external volume appear only when their
source observations support them. BODY ZONES are labelled as recorded primary
zone exposures/associated sets rather than physiological percentages. All
fourteen stored body metrics expose period-first, period-last, and an absolute
delta only with at least two points. The route and Dashboard deep links have
safe invalid-query fallbacks, French/English labels, keyboard controls, and
responsive no-overflow layouts. Visual inspection remains pending before any
freeze marker.
Exercises uses bounded Core-owned list/detail reads, canonical name
normalization, BODY ZONES taxonomy, profiled creation/update commands and
@ -377,10 +513,10 @@ The full Android lint comparison against an isolated clean `main` worktree is
unchanged at ten pre-existing errors: one `NewApi`, one
`LocalContextConfigurationRead`, and eight bilingual `StringFormatMatches`
diagnostics. This UI tranche adds no lint error and changes no lint policy.
The next private daily Android candidate remains on development
`versionName=0.1.3`; only its strictly increasing `versionCode` may be supplied
as a build-time override. No public tag or release is created by that private
candidate process.
The stable Android v0.1.6 tag uses `versionName=0.1.6` and public versionCode 7.
Private validation builds may still supply only a strictly increasing
`versionCode` as a build-time override; that does not create a public release
or change schemas and protocols.
The private Program execution rollout installed desktop schema v27 and the
signed nondebuggable Android versionCode 17/schema v25 update without
@ -399,6 +535,17 @@ ACK without duplicating the execution or resurrecting the Program. A final
correlated exchange remained completed with no missing capability. The real
Program remains active and unique with 24 sessions and 164 ordered entries.
Program-owned manual preparations now carry their stable Program provenance to
Android. Starting and finalizing such a delivery retains both identities, and a
strict same-delivery enrichment repairs completed sessions produced from the
earlier omission without changing workout facts. Android and Web therefore
derive the completed count and per-session state from the same execution
identity after synchronization and restart.
The historical S1 A delivery was recovered onto its original completed
`session_id`; Android and Web now both report one completed session out of 24.
Generation capture also accepts its valid local continuous duration plans
without projecting them as unsupported set targets.
The private Programs rollout migrated desktop schema v25 to v26 and Android
schema v23 to v24 after verified backups, then installed the signed
non-debuggable versionCode 16 APK without uninstalling or clearing data. The
@ -587,9 +734,8 @@ tests, Android debug assembly, and 211 Android tests (207 passed, four optional
historical-fixture skips). Strict desktop builds use Clang 22.1.8; the compared
pre-existing GCC 16.2.1 Web warning gate remains documented rather than green.
- `TRAINLOG_WEB_EXERCISES_V1=PASS`. The next planned work is
`TRAINLOG_ANDROID_UI_REDESIGN_V1`; it remains unimplemented. Analyse remains
a placeholder and is not a v0.1.3 priority.
- `TRAINLOG_WEB_EXERCISES_V1=PASS`. `TRAINLOG_WEB_ANALYSIS_V1` is implemented
on the 0.1.4 branch and awaits the requested Dashboard/Analyse visual review.
- `APP_SHELL_V1` retains a recorded legacy visual/accessibility review gate; its
observations are inputs to, not decisions for, the new 0.1.3 Android contract.
- Session Generator V1 is hidden while V2 planning semantics are developed.

View file

@ -1,5 +1,158 @@
# Desktop database
## Causal heart-rate tail correction (schema v36, no migration)
The separate `trainlog-heart-rate-corrections` V1 companion reuses the existing
`sync_causal_operations` and `sync_causal_state` tables for the additive local
`heart_rate_tail` state. The frozen `trainlog-causal-deletions` V1 artifact
continues to emit and accept exactly its original seven target kinds. A heart-
rate correction's composite target contains the stable capture identity and an
inclusive offset-aware cutoff. Applying it deletes only BPM samples strictly
after the cutoff; declared RR foreign keys cascade those exact children, and
the capture end becomes the cutoff in the same transaction. The predecessor
revision hashes the original ordered raw capture, including RR values, so a
correction cannot be silently applied to a different measurement history.
The immutable Heart Rate V1 generation remains audit evidence. Import applies
known causal cutoffs before stable-identity comparison or insertion, which
makes old-generation replay idempotent without rewriting its manifest or ACK.
No schema or frozen `TRAINLOG_FORMAT_V1` change is involved.
## Schema v36: append-only Sleep revision payloads
Schema v36 adds database-level `BEFORE UPDATE` and `BEFORE DELETE` guards for
Sleep diary revisions, their events and medication intakes, and medication
catalog revisions. A stable revision identity therefore cannot be detached
from its canonical parent or payload even through a direct SQLite write.
Normal correction remains append-only: it inserts a fresh UUIDv4 revision and
advances the entry or medication current pointer transactionally. The migration
is additive and does not rewrite any existing Sleep identity, payload, causal
edge, generation membership, publication state, or acknowledgement.
Importers additionally compare the immutable payload of every already-known
Sleep revision before treating an incoming ancestor as stale/idempotent. Thus
an exact historical replay remains accepted, while reuse of the same identity
with a different parent or content is an explicit collision.
## Schema v35: Sleep Diary quantity V2
Schema v35 additively adds `sleep_medication_intakes.quantity`, a required
integer in `1..99` with default `1`. It is the count of the separately stored
per-unit dose, not a free-text convention and not a total-dose replacement.
Thus `75 mg` with quantity `2` retains `dose_value=75`, `dose_unit=mg`, and
`quantity=2`. Existing immutable intake rows read as one unit; the migration
does not alter their revision identity, timestamps, causal ancestry, events,
or medication snapshot.
Sleep Diary V1 remains a frozen historical input. Quantity is carried by the
separate V2 companion; desktop v35 validates the V2 bounded value while still
hydrating historical V1 input as quantity one.
## Schema v34: synchronized Cardio guidance V1
Schema v34 additively stores completed Android-owned guided-cardio runs. `cardio_guidance_runs` keeps the stable run/session identity and exact run bounds. `cardio_guidance_phases` stores the ordered `entry_id`-owned phase, its immutable resolved BPM target, optional exact calibration provenance, exit condition, phase bounds and final instruction. `cardio_guidance_events` stores only instruction changes, not another copy of the raw heart-rate curve.
The importer requires the referenced canonical session to be `cardio`, every phase `entry_id` to exist in that session, and every phase interval to remain inside the synchronized exercise timeline. Calibration-derived targets must reference an imported calibration with the same observed peak. Any guidance event carrying BPM must match an exact synchronized raw heart-rate sample at the same timestamp; suspended events intentionally carry no BPM.
No migration invents phases, instructions, BPM or calibration references.
## Schema v33: synchronized Cardio calibration V1
Schema v33 additively stores completed Android-owned Cardio calibration profiles. `cardio_calibrations` is keyed by stable `calibration_id` and retains protocol version, canonical cardio `session_id`, stable occurrence `entry_id`, exact calibration/effort/end timestamps, the synchronized heart-rate `capture_id`, observed peak BPM and import time.
`cardio_calibration_recovery` stores only factual recovery samples for the defined +60, +120 and +180 second references. The source curve remains Heart Rate V1; the calibration importer verifies that the observed peak and every recovery `(observed_at, bpm)` pair exist in that raw capture. It never derives, interpolates or rewrites HR samples.
Android owns the active calibration lifecycle. Desktop stores only completed profiles. A later calibration creates a new stable profile and never rewrites an older session or reference.
## Schema v32: canonical cardio session kind
Schema v32 adds the product-owned `sessions.session_kind` column with the
explicit domain `training | max_test | cardio`. The pre-existing
`sessions.session_type` column remains intentionally frozen to
`training | max_test` for compatibility with historical codecs and MAX
semantics. A cardio row therefore stores `session_type=training` only as a
legacy compatibility projection and `session_kind=cardio` as the canonical
product fact.
The v31→v32 migration introspects historical `sessions` layouts before
backfill. When the legacy `session_type` column exists it seeds
`session_kind` from it; very old historical fixtures that legitimately lack
that column keep the session kind produced by their earlier migration chain.
No existing session identity, occurrence, set, timeline, Program provenance or
heart-rate fact is rewritten.
Completed cardio sessions synchronize Android→desktop through the separate
optional `trainlog-cardio-sessions` V1 generation companion. The frozen
mobile/history V4 and execution-draft formats are not widened to a third
session type.
## Schema v31: synchronized session exercise timeline
Schema v31 additively owns Android-recorded execution timing for completed
session occurrences in `session_exercise_timeline`. Each row is keyed by the
stable `session_id + entry_id` pair and stores the matching catalogue
`exercise_id`, the real user-action `started_at` and `ended_at`, and import
time. The importer requires the existing canonical session and occurrence to
match; it never creates workout history or infers exercise timing from order,
sets, plans, or heart-rate samples.
The session bounds remain the existing canonical `sessions.started_at` and
`sessions.ended_at`. The separate `trainlog-session-timeline` V1 companion
must match those exact values before occurrence timing can be imported.
## Schema v30: synchronized Heart Rate V1
Schema v30 additively owns Android-recorded heart-rate captures after
full-generation synchronization. `heart_rate_captures` stores one immutable
stopped capture with its stable `hrc_` identity, owner kind (`session` or
`sleep`), stable owner identity, exact start/end timestamps, optional sensor
name, and import time.
`heart_rate_samples` stores the received timestamp, measured BPM, optional
exercise `entry_id` snapshot, optional sensor-contact state, and optional
cumulative energy value. `heart_rate_rr_intervals` preserves zero or more raw
Heart Rate Service RR values in their native 1/1024-second units. Desktop never
creates these measurements and never connects to the sensor.
The v29→v30 migration is additive and creates no cardio row. Heart-rate data
travels Android→desktop through the separate optional `trainlog-heart-rate`
V1 generation companion. Exact replay is idempotent; a stable capture/sample
identity with different measured content is rejected. Existing workout,
Sleep Diary, and frozen mobile formats are unchanged.
## Schema v29: Sleep Diary V1
Schema v29 additively owns `sleep_diary_entries`, immutable
`sleep_diary_revisions`, and revision-owned `sleep_diary_events`. An entry is
one night and its associated day. Point events have an offset-bearing
`start_at`; intervals additionally have an absolute `end_at` strictly after
their start. The 18:00-to-18:00 agenda is only a projection and is never stored
as cells.
The same stable v0.1.4 migration owns `sleep_medications`, immutable
`sleep_medication_revisions`, and revision-owned `sleep_medication_intakes`.
Catalog defaults assist capture but do not own historical facts: every intake
stores the displayed medication name and effective optional positive dose/unit
snapshot. Renaming or deactivating a catalog item therefore cannot rewrite a
past entry. Development databases that had already opened the earlier v29
draft are completed idempotently on open before the additive v30 migration;
no existing sleep row is rewritten.
Mutations advance `current_revision_id` only when the caller supplies the
current parent. Logical deletion advances the revision and retains the
tombstone. Qualities preserve `TB`, `B`, `Moy`, `M`, and `TM` exactly.
`sleep_diary_publication_state` separates the durable local tip from the tip
explicitly validated for publication and the exact revision covered by a
full-generation ACK. `sleep_diary_generation_entries` binds captured revisions
to immutable generation identities. Consequently a new entry is a draft, a
validated tip is ready, an ACKed tip is synchronized, and any later local tip
is modified without rewriting its acknowledged ancestor. One stable entry is
owned by each `night_start_date`; old drafts remain editable indefinitely.
The absolute timestamps are the future heart-rate correlation boundary. Sleep
Diary V1 does not store, infer, or simulate heart-rate data.
## Schema v28: terminal Program execution provenance
Schema v28 extends `program_session_executions.state` with the terminal

413
docs/design/cardio_v1.md Normal file
View file

@ -0,0 +1,413 @@
# Cardio V1
Status: `TRAINLOG_CARDIO_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`
This document is the implementation contract for Trainlog 0.1.5 cardio,
training-timeline correlation, and Android one-tap sleep capture. It does not
declare the implementation complete.
## Ownership and hard boundaries
Android is the only heart-rate sensor client. It owns BLE discovery,
connection, reconnection, Heart Rate Measurement parsing, local persistence,
capture lifecycle, and the live BPM presentation.
Desktop, TUI and Web never scan, pair with, or connect directly to a
heart-rate sensor. They consume only measurements already recorded by Android
and synchronized through Trainlog.
Heart-rate BLE/GATT and Trainlog Bluetooth synchronization are independent:
```text
heart-rate sensor -- BLE/GATT --> Android
Android <-------- Trainlog full-generation --------> desktop
over
Bluetooth Classic RFCOMM or MTP recovery
```
The cardio implementation must not reuse the frozen Bluetooth Classic service
UUID, framing, peer identity, connection state, or transport protocol.
## Standard sensor target
Cardio V1 targets the Bluetooth LE Heart Rate Service:
- Heart Rate Service: `0x180D`;
- Heart Rate Measurement: `0x2A37`.
The parser must support the standard 8-bit and 16-bit BPM forms, optional
sensor-contact state, optional cumulative energy expended, and zero or more RR
intervals. RR intervals are preserved as raw integer units of 1/1024 second.
Battery Service may be observed as optional device metadata but is not required
to retain a heart-rate sample.
Malformed notifications are rejected. Trainlog never manufactures missing BPM
or RR values.
## Global Android heart-rate indicator
The Android application shell exposes one compact indicator at the top right:
```text
♥ 82 BPM
```
Only the heart and BPM are shown. Sensor name, address, and technical state are
not shown in the global header.
Heart colour describes connection freshness only:
- grey: known sensor unavailable/disconnected;
- orange: connecting, reconnecting, or the last measurement is stale;
- green: connected and receiving fresh valid measurements.
Colour never indicates a medical zone or whether the BPM is high or low.
When no fresh measurement exists, the value is `-- BPM`. A stale previous
number must never remain visible as if it were current.
With a fresh BPM the heart uses a restrained pulse animation derived from
`60 / bpm`. This is only a visual representation of the current measured
rate; it does not claim knowledge of the exact instant of each contraction.
## Capture identities and contexts
One capture has a stable `hrc_<uuid-v4>` identity and exactly one semantic
context:
- `session`: a normal training or MAX session identified by stable
`se_<uuid-v4>`;
- `cardio`: a dedicated cardio session identified by stable
`se_<uuid-v4>`;
- `sleep`: one Sleep Diary entry identified by stable `sl_<uuid-v4>`.
Context is never inferred from a title, clock date, exercise name, or display
position.
A workout/cardio sample may additionally snapshot the stable active occurrence
`sxe_<uuid-v4>` at the instant the sample is received.
## Raw measurement contract
Each received measurement stores at minimum:
- `capture_id`;
- monotonically increasing `sequence` within that capture;
- offset-bearing `observed_at` reception timestamp;
- integer `bpm`.
When actually supplied by the sensor it may also store:
- sensor-contact state;
- cumulative energy-expended value;
- zero or more RR intervals in 1/1024-second units.
For workout/cardio captures it may store the active `entry_id` snapshot.
Raw storage preserves signal gaps. It does not interpolate, smooth, estimate,
diagnose, infer sleep stages, infer a return to sleep, or synthesize missing
measurements.
## Android durability
Android SQLite owns authoritative field capture. An active capture remains
durable across application navigation and ordinary process/lifecycle
transitions supported by Android. Long-running acquisition uses a dedicated
foreground service distinct from synchronization.
Cardio V1 permits one active heart-rate capture at a time.
Sensor disconnect does not delete or close a capture. Reconnection resumes
future measurements and leaves the missing interval as a real gap.
Explicit stop closes the capture with its actual `ended_at`.
## Synchronization contract
Recorded cardio is synchronized Android -> desktop as the separate optional
full-generation companion:
```text
logical_name = heart-rate
format = trainlog-heart-rate
version = 1
filename = heart-rate-v1.json
```
Desktop persists and presents these measurements but is not a sensor producer.
Normal publication contains completed/stopped, not-yet-acknowledged captures.
An active capture is retained locally and is not emitted as a mutable
full-generation snapshot.
Identity is idempotent:
- capture key: `capture_id`;
- sample key: `capture_id + sequence`;
- RR key: `capture_id + sequence + rr_index`.
Exact replay is a no-op. Reusing an identity with different measured content
is rejected.
The exact captured `capture_id -> generation_id` relation is durable. Only a
correlated consumed ACK marks those captures acknowledged. A rejected
generation does not acknowledge them. Acknowledged captures remain in Android
history but are excluded from ordinary future publication.
The companion does not alter `TRAINLOG_FORMAT_V1`, mobile-export V3/V4,
Sleep Diary V1, generation/ACK framing, or Bluetooth Classic sync V1.
## Training timeline correlation
A training session must expose real time boundaries, never reconstructed
timings.
Session start/end use the existing stable session identity and canonical
`started_at` / `ended_at` lifecycle when those values are already the
authoritative timestamps.
Each session occurrence has an explicit execution lifecycle:
```text
not_started -> in_progress -> completed
```
Starting an exercise records its actual start timestamp against
`session_id + entry_id`. Ending it records its actual end timestamp.
Only one occurrence may be active at a time. Starting another occurrence while
one remains active requires an explicit resolution rather than silently
inventing an end.
Samples received while an occurrence is active snapshot that `entry_id`.
Samples between occurrences remain valid session samples with no fabricated
exercise ownership and represent rest/transition.
Ending a session while an occurrence is active requires an explicit UI action
to close the occurrence at the validated finalization instant.
The synchronized history must permit factual curve markers for:
- session start;
- exercise start;
- exercise end;
- session end.
Future set-level `set_start` / `set_end` events may extend this model, but
Cardio V1 must not infer them from reps, duration targets, or form edits.
## Dedicated cardio session type
Trainlog 0.1.5 adds a real third session type:
```text
training
max_test
cardio
```
Every existing boundary that assumes only `training|max_test` must be audited.
A frozen artifact may not be silently widened. If an existing wire codec
cannot safely express `cardio`, add a separately versioned compatible
extension while retaining legacy readers.
A cardio session consists of cardio exercises and optional phases. Initial
exercise families include treadmill, bicycle, elliptical, rower, stepper,
walking, running, and custom cardio.
A phase may express a type, target, duration, and exit condition. Required
phase concepts include warm-up, work, recovery, and cool-down.
The motor must be able to represent fixed duration, reaching a BPM zone,
recovering below a threshold, and bounded combinations of time and
heart-rate conditions.
Machine speed, resistance, incline, and distance may be recorded, but BPM is
the primary guidance signal.
## BPM-guidance motor
Cardio V1 guidance outputs are:
```text
ACCÉLÈRE
MAINTIENS
RALENTIS
```
Guidance is based only on fresh measurements and the phase target.
Hysteresis and a minimum persistence interval prevent oscillation around a
boundary. One isolated sample must not cause rapid instruction changes.
When the signal becomes stale, guidance is suspended. The global heart becomes
orange and BPM becomes unavailable rather than continuing from an old value.
## Calibration cardio exercise
The first dedicated cardio exercise is `Calibration cardio`.
Its purpose is to establish a personal measured reference for later guided
exercise. Trainlog records an **observed peak BPM**, not an automatically
asserted physiological maximum.
The protocol is progressive:
```text
warm-up -> easy stage -> progressive stages -> user-chosen highest tolerated
effort for that day -> stop effort -> recovery observation
```
A manual stop is always immediately available. The motor never attempts to
beat the previous observed peak automatically.
Each calibration is immutable/versioned and retains at minimum:
- stable `calibration_id`;
- protocol version;
- performance timestamp;
- associated heart-rate capture;
- observed peak BPM;
- effort-end timestamp;
- recovery timeline.
Recovery projections may expose +1, +2 and +3 minute facts while the complete
raw curve remains authoritative.
A later calibration creates a new reference. It never rewrites an older
calibration or a prior guided session.
Every guided cardio session snapshots the exact `calibration_id` and target
thresholds used at that time.
Measured, user-configured, and calculated/estimated values remain explicitly
distinguishable.
## Android Sleep entry point
Android adds a first-class `Sommeil` menu entry backed by the existing Sleep
Diary domain. It must not create a parallel sleep database or incompatible
sleep truth.
Night-time interaction is deliberately minimal. The primary screen exposes:
```text
[ COUCHÉ ]
[ médicament 1 ] [ médicament 2 ]
[ médicament 3 ] [ + ]
[ RÉVEIL ]
[ LEVÉ ]
```
Buttons are large, visually consistent with Trainlog, and usable while only
partly awake.
### Couché
One tap records the actual `bed_time` timestamp in the correct stable Sleep
Diary entry. If heart-rate acquisition is available, the nocturnal capture is
linked directly to that `sleep entry_id`.
### Médicaments
Active configured medications are one-tap actions. A tap stores the actual
intake timestamp plus stable medication identity and the effective
name/dose/unit snapshot. Full editing remains available later.
### Réveil
There is no `Rendormi` button. One tap records a structured `long_awake`
interval beginning at the factual tap time and ending 30 minutes later under
the explicit capture policy. Nothing else is required before the user tries to
return to sleep. A second tap within that window resets its end to 30 minutes
after the latest tap. Levé truncates an overlapping interval to the factual
final-get-up timestamp.
The 30-minute end is user-approved product policy, not an inference from BPM or
RR data. Later correction remains available when the actual duration is known.
### Levé
One tap records the actual `final_get_up` timestamp, closes the night, and
stops the linked nocturnal heart-rate capture when one is active.
### Immediate undo and later correction
After Couché, medication, Réveil, or Levé, Android shows a short-lived action
such as:
```text
Réveil enregistré à 02:17 [ ANNULER ]
```
Undo is scoped to that exact action and must not roll back unrelated newer
state.
`Corriger la nuit` provides later editing of timestamps, medication
intakes, awakening intervals, forgotten events, and existing subjective
Sleep Diary fields.
The night-time rule is: one tap, one 30-minute awakening window, then no further
required interaction.
## Sleep + cardio factual timeline
A nocturnal capture is owned by stable `sleep entry_id`, not only a date.
After synchronization, Analyse may superpose factual events and measurements:
- Couché;
- medication intakes;
- structured 30-minute awakening windows;
- Levé;
- BPM;
- RR when supplied;
- real sensor disconnect/reconnect gaps.
Trainlog does not diagnose sleep, invent sleep stages, or claim a certain
return-to-sleep instant from heart rate alone.
## Web/Analyse projection
Web and desktop consume synchronized facts only.
Training views may show the BPM curve, session bounds, exercise intervals,
rest/transition gaps, and factual min/max/mean projections.
Cardio views may additionally show phase targets, chronological guidance,
calibration identity, and measured response versus target.
Sleep views may overlay Sleep Diary events with BPM/RR measurements.
Graphs are projections. Persistent measurements, identities, and timestamps
remain the source of truth.
## Performance bounds
One night can contain tens of thousands of samples. Storage and exchange must
use bounded artifacts, indexed temporal queries, transactional import, and
avoid per-sample N+1 behavior on analysis paths.
Trainlog does not arbitrarily downsample the authoritative raw capture merely
for transport convenience. Presentation may derive bounded projections while
retaining the recorded source data.
## Implementation order
1. audit/freeze this contract;
2. Android + desktop persistence and strict heart-rate companion exchange;
3. standard Heart Rate Measurement parser and real-sensor diagnostics;
4. Android foreground acquisition service and global ♥ BPM indicator;
5. training start/end and exercise start/end timeline;
6. Android one-tap Sleep UI and sleep-linked capture;
7. true `cardio` session type;
8. versioned Calibration cardio exercise;
9. BPM-guided phase motor;
10. Web factual visualization;
11. complete regression, migration, hardware, replay, and restart validation.
No stage permits Web/TUI sensor connection.

View file

@ -0,0 +1,196 @@
# Bluetooth synchronization transport V1
## Status
`TRAINLOG_SYNC_BLUETOOTH_TRANSPORT_V1=PASS/FROZEN`
This transport is additive. It does not change generation manifests, artifact
formats, causal deletion, revision ancestry, acknowledgement semantics, SQLite
schemas, or the frozen Trainlog session format.
## Product behavior
Normal synchronization is automatic when the authorized Android phone and
desktop are within Bluetooth range.
1. Android's already user-enabled sync foreground service maintains a bonded
Bluetooth Classic RFCOMM connection to the selected desktop.
2. The desktop advertises one Trainlog-specific RFCOMM profile.
3. On a fresh Bluetooth connection Android publishes one bounded
full-generation request. Reconnect noise is rate-limited.
4. The existing full-generation orchestrator performs the same bidirectional
generation/ACK conversation through the Bluetooth byte transport.
5. If Bluetooth is unavailable, the existing MTP transport remains the wired
fallback.
6. Google Drive is not a synchronization transport in the target configuration.
After convergence, the desktop AI export is transferred to Android and
Android publishes it to the user-selected `Trainlog/AI` Drive folder.
Drive publication failure never rolls back a completed sync.
The manual Web and Android Synchroniser actions remain as recovery/forced-sync
controls.
## Private live-device evidence — 2026-09-22
The authorized Samsung Android peer and ArchASP completed a physical Bluetooth
Classic/RFCOMM synchronization without MTP or Drive as a sync transport.
- request: `sr_86ccc07f-cc5b-4506-86c1-06c00bd8b06a`;
- run: `sy_df8f87fb-e4c1-44f5-b3f7-8fe87942d19e`;
- inbound Android generation:
`gen_9609d163-4a3a-4187-a806-84379a1bcf4c`;
- outbound desktop generation:
`gen_bc1d5e4b-4b16-49bc-b228-c3ff1f1a1705`;
- final transport: `bt`;
- final Bluetooth state: `success`;
- final phase/result: `completed/completed`;
- desktop SQLite integrity: `ok`;
- post-sync AI export: `delivered_to_android`;
- Android copy:
`Documents/Trainlog/trainlog_ai_export_v1.json`, 66,971 bytes,
SHA-256 `49f7f02fdeebd15699655e43fdb475bb20d9dd2d1bc59d2bdb4cd099ae50ecda`.
The initial legacy bond contained mismatched link-key state. The private device
was explicitly re-paired; Android then reported the bond ready and established
the Trainlog RFCOMM service. Android now also repairs a missing bond through the
normal platform pairing flow before attempting RFCOMM.
The Android SAF destination `Trainlog/AI` was then selected explicitly by
the user. A Web-triggered Bluetooth-only run completed immediately afterward:
- request: `sy_ab1869d3-7f25-497f-a338-97213ec08370`;
- run: `sy_f3ab061f-b4f8-44e9-9911-c0ad3283b457`;
- inbound Android generation:
`gen_c74705f9-04dc-4ffe-9e67-6ae5bf08d1a2`;
- outbound desktop generation:
`gen_0b33d209-35f1-41c0-9f98-6adf843b2a5f`;
- `transport=bt`, `bluetooth_state=success`;
- both directions are durable `sqlite-commit-full`;
- `ai_post_sync=delivered_to_android`;
- desktop SQLite integrity: `ok`.
The resulting local AI export was 66,971 bytes with SHA-256
`73ce8d3ca6cef3080ed2da77d58ef600bb79e6d2089f3472632b59aea76a8b90`.
The exact Google Drive object in `Trainlog/AI/trainlog_ai_export_v1.json` was
modified at `2026-09-22T14:50:11.875Z`, had the same 66,971-byte size, and
the downloaded Drive object had the exact same SHA-256. This closes the
Android-SAF publication proof without rclone.
## Trust and pairing
Bluetooth device names are presentation only and are never authority.
Desktop trusted configuration pins:
- Android sync `peer_id`;
- bonded Android Bluetooth device address;
- Trainlog RFCOMM service UUID.
Android persists the selected bonded desktop address after an explicit one-time
selection. The RFCOMM service uses normal bonded-device authentication.
Current private deployment values are installation configuration, not protocol
constants.
## RFCOMM service
Trainlog service UUID:
`f0d1c0de-7a11-4f62-9b7c-545241494e4c`
The desktop registers an `org.bluez.Profile1` server with BlueZ. Android is
the RFCOMM client. This direction lets Android resolve the advertised service
through SDP without hidden Android channel APIs.
The desktop Bluetooth agent is persistent and owns the BlueZ profile. Local
sync workers never talk to BlueZ directly; a bounded local adapter talks to the
agent over a mode-0600 Unix socket.
## Bluetooth byte envelope
Each Bluetooth frame is:
- 4-byte unsigned big-endian JSON header length;
- UTF-8 JSON header (maximum 64 KiB);
- optional payload whose length and SHA-256 are declared in the header.
Payload limit is 256 MiB and individual exchange files remain subject to their
existing generation bounds. A receiver validates length and SHA-256 before
making received bytes visible.
Initial frame:
```json
{
"format": "trainlog-bt-frame",
"version": 1,
"type": "hello",
"peer_id": "peer_<uuid>",
"protocol": "trainlog-bt-files-v1"
}
```
The local adapter exposes only `pull` and `push`, matching the existing MTP
adapter boundary. Transfers use a bounded ZIP archive containing relative
exchange paths. Extraction rejects absolute paths, `..`, symbolic links,
duplicates and excess file/byte counts. Each destination is replaced from a
same-directory temporary after complete bytes are durable.
## Pull ownership
Android pull snapshots include only Android-owned/current coordination objects:
- `android-peer-v1.json`;
- `trainlog-sync-full-generation-request-v1.json`;
- `trainlog-sync-request-v1.json`;
- `android-generation-v1.json`;
- `android-consumption-ack-v1.json`;
- `android-generation-error-v1.json`;
- the immutable generation referenced by `android-generation-v1.json`.
Desktop-owned coordination files are never treated as Android source
authority.
## Push ownership
Desktop workers already construct phase-specific disposable outboxes. Bluetooth
push accepts exactly that bounded outbox and atomically publishes those files
under Android's canonical `Documents/Trainlog` directory.
## Concurrency
Bluetooth and MTP have independent physical transport locks. The common
business `sync.lock` still serializes synchronization conversations.
A background discovery probe must never own `sync.lock`. A Web-triggered
conversation must never fail merely because an idle transport probe is in
progress.
## Automatic arrival trigger
A successful RFCOMM connection is an arrival event. Android publishes at most
one automatic request per connection and persists a short reconnect cooldown.
The same connection is not a repeating timer; no connection means no automatic
request.
A later optimization may compare explicit dirty revisions before generating a
request, but V1 correctness does not depend on such an optimization.
## MTP fallback
MTP remains supported and keeps its current fail-closed semantics. Automatic
desktop selection is:
`Bluetooth -> MTP -> device_unavailable`
Drive is never an implicit fallback in the target configuration.
## AI export publication
The desktop remains the canonical producer of
`trainlog_ai_export_v1.json`. After a completed bidirectional sync it sends
the exact export bytes to Android. Android publishes/replaces the export in the
persisted SAF tree selected for `Trainlog/AI`.
The export has its own pending/success state. A Drive provider error is not a
sync error and is retried later by Android.

View file

@ -691,6 +691,30 @@ enumerate at most 4096 operations plus one sentinel row, and a local deletion
is refused before its effect if the complete protection artifact would exceed
either bound.
The V1 `target_kind` set remains the frozen seven-kind set: `session`,
`execution_draft`, `exercise`, `body_observation`, `custom_equipment`,
`feedback`, and `body_zone_relation`. Heart-rate correction does not widen this
published contract.
## Heart-rate corrections V1
`trainlog-heart-rate-corrections` V1 is a separate optional Android→desktop
full-generation companion named `heart-rate-corrections`, stored as
`heart-rate-corrections-v1.json`. Its root contains `format`, integer `version`,
`generated_at`, and at most 256 `corrections`; the complete UTF-8 artifact is
limited to 4 MiB. Each correction contains a stable UUIDv4-based `operation_id`,
stable `capture_id`, inclusive offset-aware `cutoff`, `creator_id`, exact
`predecessor_revision_id`, `created_at`, and canonical `payload_sha256`.
The predecessor hashes the complete original capture metadata, ordered raw BPM
samples, and ordered RR children. The consumer requires the cutoff to equal the
canonical session end and not precede the session start or latest exercise end.
It deletes only samples strictly after the cutoff in one transaction, relies on
the declared RR cascade, records durable causal state, and applies that state
before any Heart Rate V1 snapshot in the same generation. Unknown fields,
duplicate keys/identities, digest mismatch, ancestry mismatch, and incompatible
capture/session ownership reject the containing generation atomically.
## Staged generation manifest and acknowledgement V1
`trainlog-sync-manifest` V1 is a strict independent envelope. It contains

View file

@ -6,6 +6,9 @@ than later development. GitHub and Forgejo publication must preserve the
existing release, tag, status, and assets while keeping their note bodies equal
to the corresponding source file.
- [Trainlog v0.1.6](v0.1.6.md)
- [Trainlog v0.1.5](v0.1.5.md)
- [Trainlog v0.1.4](v0.1.4.md)
- [Trainlog v0.1.3](v0.1.3.md)
- [Trainlog v0.1.2](v0.1.2.md)
- [Trainlog v0.1.1](v0.1.1.md)

57
docs/releases/v0.1.4.md Normal file
View file

@ -0,0 +1,57 @@
Trainlog v0.1.4 extends the local-first training workflow with factual
analysis, a synchronized Sleep Diary, improved prepared-session execution, and
an authenticated Bluetooth synchronization transport.
## Analyse and Dashboard
- The local Web Analyse workspace exposes factual 7/30/90-day and all-history
views through Trainlog Core.
- Activity, active-Program progress, compatible exercise metrics, persisted MAX
results, BODY ZONE exposure, and body measurements share the bounded Core
read model used by Dashboard summaries.
- Body-measurement trends are charted from persisted observations, and the next
session projection refreshes after synchronization.
- Analyse deliberately does not invent readiness, fatigue, recovery, body-fat,
or estimated performance values.
## Sleep Diary
- Sleep Diary V1 is available across desktop schema v29 and Android schema v26.
- Entries use absolute timestamped events, causal revisions, appreciations,
notes, medication catalog entries, and timestamped intake snapshots.
- The Web agenda uses one exact 18:00-to-18:00 projection and follows the latest
locally persisted revision without leaking another selected night.
- Local vector PDF reports are available in French and English.
- Sleep Diary participates in the full-generation synchronization and
acknowledgement flow.
## Android session execution
- Prepared continuous-duration exercises remain planning-only until actual work
is explicitly recorded.
- Target-only continuous and set-based occurrences can be edited without
fabricating performed activity or duplicate occurrences.
- Equipment can be created and selected from the active-session editor while
preserving the current durable draft.
- Program provenance is preserved through preparation, Android execution,
completion, synchronization, and progress display, including safe recovery
of compatible historical deliveries.
## Bluetooth synchronization
- The paired deployment adds Bluetooth Classic RFCOMM as the primary local
Trainlog synchronization transport and retains direct MTP as wired recovery.
- The desktop accepts only the configured bonded Android device, and Android
proves the persisted Trainlog peer identity before exchanging bounded frames.
- Reconnect noise is rate-limited and does not create repeated generations.
- This Bluetooth feature transports Trainlog synchronization data only.
Heart-rate sensor ingestion is intentionally deferred to the 0.1.5 cardio
cycle.
## Assets
- `trainlog-android-v0.1.4.apk`: signed public Android APK.
- `trainlog-tui-linux-x86_64-v0.1.4`: Linux x86-64 runtime bundle containing
the TUI, local Web adapter, synchronization helpers, Bluetooth transport
helper, and required catalogs.
- `SHA256SUMS`: SHA-256 checksums for both artifacts.

59
docs/releases/v0.1.5.md Normal file
View file

@ -0,0 +1,59 @@
Trainlog v0.1.5 completes the factual cardio capture cycle and makes the
Android-to-Web Sleep workflow ready for real nightly use without adding
medical, sleep-stage, or causal inference.
## Heart-rate capture and cardio sessions
- Android persistently acquires real CYCPLUS Heart Rate Service measurements,
retains factual observation timestamps, BPM and raw RR values, and exposes a
global fresh/stale/disconnected indicator.
- Training occurrences have explicit start/end timelines so synchronized
heart-rate samples retain their stable exercise ownership instead of
inferring it later from time overlap.
- Dedicated `cardio` sessions, measured calibration profiles and fresh-signal
BPM-guided phases use separate versioned companions and preserve the frozen
historical session formats.
- Guidance suspends on stale signal and records measured facts only; it does
not estimate fitness, recovery, readiness or medical state.
## Factual Web timelines
- History and Sleep display synchronized heart-rate samples through one
bounded native read model and responsive Web timeline.
- Full-source min/mean/max and RR counts remain exact while large visual
projections are bounded.
- Cardio timelines show factual occurrence bounds, resolved targets,
instruction changes and measured calibration points.
- Sleep highlights notable relative rises and falls with a deterministic local
median rule, explicitly without assigning a sleep stage or cause.
## Sleep capture and medication quantity
- Medication can be recorded before Couché. The first intake creates one
durable pending 18:00-to-18:00 night; Couché advances the same stable entry,
Réveil leaves heart-rate capture running, and Levé closes it at the factual
final-get-up timestamp.
- A missing sensor fabricates neither a capture nor a sample.
- Sleep Diary V2 carries structured per-intake quantity while published V1
remains strict and readable; historical intakes default to quantity one.
- The Web agenda renders every night in the selected period on one aligned
18:00-to-18:00 axis, and the selected night owns the full-width heart-rate
detail. PDF reports retain their multi-night behavior and display quantity
without replacing per-unit dose.
## Compatibility and validation
- Desktop schema v35 and Android schema v32 are additive migrations.
- `TRAINLOG_FORMAT_V1`, mobile export V3, stable identities, causal revision
lineage, generation/ACK semantics and Bluetooth/MTP transport ownership are
unchanged.
- Native tests, Android unit tests and APK assembly, Web tests/typecheck/build,
JSON/import validation, changed-C formatting and exact Sleep V2 replay all
pass at the release tag.
## Assets
- `trainlog-android-v0.1.5.apk`: signed public Android APK.
- `trainlog-tui-linux-x86_64-v0.1.5`: Linux x86-64 runtime bundle containing
the TUI, local Web adapter, synchronization helpers and required catalogs.
- `SHA256SUMS`: SHA-256 checksums for both artifacts.

64
docs/releases/v0.1.6.md Normal file
View file

@ -0,0 +1,64 @@
Trainlog v0.1.6 closes the Web refinement and synchronization-recovery cycle.
It improves factual Sleep and training presentation, preserves immutable
history through difficult replay and recovery cases, and gives the repository
a durable Catppuccin-based project entry point without widening frozen product
contracts.
## Local Web refinement
- Dashboard activity uses calendar months and ISO weeks, progression is a
factual global synthesis, and Analyse drills from BODY ZONES to exercises and
measured detail without inventing missing exercise time.
- A persistent French/English preference, Settings route, denser shared
surfaces, responsive charts, and a shared BODY ZONES figure refine the local
interface while presentation state remains outside business SQLite.
- Equipment administration can transactionally merge a custom duplicate into
a canonical identity while preserving history, MAX context, preparations,
Programs, and causal deletion evidence.
## Sleep facts and heart-rate timelines
- The multi-night agenda, selected-night heart-rate detail, and vector PDF use
one temporal projection of factual sleep intervals, awakenings, point events,
medication occurrences, and exact bounds.
- The shared 18:00-to-18:00 agenda keeps exact hourly geometry at desktop and
narrow widths. A clearly labelled fallback estimate is presentation-only,
non-medical, and never written to Sleep data.
- Android wake capture records revisioned long-awake intervals, medication
quantity survives persistence and synchronization, and Sleep revision
payloads are append-only on both platforms.
## Synchronization and recovery
- Bluetooth restart recovery retains immutable generation identity and accepts
the exact original generation/digest acknowledgement across conversation
runs; direct MTP remains the wired recovery transport.
- Android foreground and automatic synchronization share one ownership arbiter,
and direct-MTP publication waits for MediaProvider visibility before exposing
a mutable generation reference.
- Divergent Sleep revision identities can be recovered without choosing an
arbitrary winner. Strict payload checks reject reuse of a historical revision
identity with different parentage or content.
- A separate heart-rate correction companion removes samples strictly after a
factual session end and prevents an older immutable generation from
resurrecting the removed tail.
## Compatibility and validation
- Desktop schema v36 and Android schema v33 are additive migrations.
- `TRAINLOG_FORMAT_V1`, mobile export V3, stable identities, generation/ACK
semantics, and Bluetooth/MTP transport ownership remain unchanged.
- The default automatic exchange remains V3; full-generation synchronization
remains behind its trusted local opt-in.
- Native Meson and Clang ASan/UBSan suites each pass 114/114. Android passes
317 tests (312 passed and five documented optional skips), debug assembly,
signed release assembly, and signature verification. Web passes 32 files / 209
tests plus typecheck/build; JSON/import validation, changed-C formatting, and
diff checks also pass at the release tag.
## Assets
- `trainlog-android-v0.1.6.apk`: signed public Android APK.
- `trainlog-tui-linux-x86_64-v0.1.6`: Linux x86-64 runtime bundle containing
the TUI, local Web adapter, synchronization helpers, and required catalogs.
- `SHA256SUMS`: SHA-256 checksums for both artifacts.

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

View file

@ -0,0 +1,387 @@
%PDF-1.4
%Trainlog
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [4 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>
endobj
4 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 842 595] /Resources << /Font << /F1 3 0 R >> >> /Contents 5 0 R >>
endobj
5 0 obj
<< /Length 17665 >>
stream
0 G 0 g
0 g BT /F1 14 Tf 30.0 565.0 Td (AGENDA TRAINLOG DE VIGILANCE ET DE SOMMEIL) Tj ET
0 g BT /F1 7 Tf 740.0 565.0 Td (1/1) Tj ET
0 g BT /F1 7 Tf 30.0 548.0 Td (DATE) Tj ET
0 g BT /F1 4 Tf 112.0 548.0 Td (18) Tj ET
0 g BT /F1 4 Tf 132.8 548.0 Td (19) Tj ET
0 g BT /F1 4 Tf 153.7 548.0 Td (20) Tj ET
0 g BT /F1 4 Tf 174.5 548.0 Td (21) Tj ET
0 g BT /F1 4 Tf 195.3 548.0 Td (22) Tj ET
0 g BT /F1 4 Tf 216.2 548.0 Td (23) Tj ET
0 g BT /F1 4 Tf 237.0 548.0 Td (0) Tj ET
0 g BT /F1 4 Tf 257.8 548.0 Td (1) Tj ET
0 g BT /F1 4 Tf 278.7 548.0 Td (2) Tj ET
0 g BT /F1 4 Tf 299.5 548.0 Td (3) Tj ET
0 g BT /F1 4 Tf 320.3 548.0 Td (4) Tj ET
0 g BT /F1 4 Tf 341.2 548.0 Td (5) Tj ET
0 g BT /F1 4 Tf 362.0 548.0 Td (6) Tj ET
0 g BT /F1 4 Tf 382.8 548.0 Td (7) Tj ET
0 g BT /F1 4 Tf 403.7 548.0 Td (8) Tj ET
0 g BT /F1 4 Tf 424.5 548.0 Td (9) Tj ET
0 g BT /F1 4 Tf 445.3 548.0 Td (10) Tj ET
0 g BT /F1 4 Tf 466.2 548.0 Td (11) Tj ET
0 g BT /F1 4 Tf 487.0 548.0 Td (12) Tj ET
0 g BT /F1 4 Tf 507.8 548.0 Td (13) Tj ET
0 g BT /F1 4 Tf 528.7 548.0 Td (14) Tj ET
0 g BT /F1 4 Tf 549.5 548.0 Td (15) Tj ET
0 g BT /F1 4 Tf 570.3 548.0 Td (16) Tj ET
0 g BT /F1 4 Tf 591.2 548.0 Td (17) Tj ET
0 g BT /F1 4 Tf 612.0 548.0 Td (18) Tj ET
0 g BT /F1 4 Tf 616.0 552.0 Td (QUALIT\311 DU) Tj ET
0 g BT /F1 4 Tf 616.0 545.0 Td (SOMMEIL) Tj ET
0 g BT /F1 4 Tf 654.0 552.0 Td (QUALIT\311 DU) Tj ET
0 g BT /F1 4 Tf 654.0 545.0 Td (R\311VEIL) Tj ET
0 g BT /F1 4 Tf 692.0 552.0 Td (FORME DE LA) Tj ET
0 g BT /F1 4 Tf 692.0 545.0 Td (JOURN\311E) Tj ET
0 g BT /F1 4 Tf 728.0 552.0 Td (TRAITEMENT ET) Tj ET
0 g BT /F1 4 Tf 728.0 545.0 Td (REMARQUES) Tj ET
0.7 G 30 498 782 40 re S
0 g BT /F1 5 Tf 33.0 525.0 Td (23/09/2026) Tj ET
0 g BT /F1 5 Tf 33.0 515.0 Td (au 24/09/2026) Tj ET
0.9 G 112 498 m 112 538 l S
0.9 G 132.83333333333334 498 m 132.83333333333334 538 l S
0.9 G 153.66666666666666 498 m 153.66666666666666 538 l S
0.9 G 174.5 498 m 174.5 538 l S
0.9 G 195.33333333333331 498 m 195.33333333333331 538 l S
0.9 G 216.16666666666669 498 m 216.16666666666669 538 l S
0.9 G 237 498 m 237 538 l S
0.9 G 257.83333333333337 498 m 257.83333333333337 538 l S
0.9 G 278.66666666666663 498 m 278.66666666666663 538 l S
0.9 G 299.5 498 m 299.5 538 l S
0.9 G 320.33333333333337 498 m 320.33333333333337 538 l S
0.9 G 341.16666666666663 498 m 341.16666666666663 538 l S
0.9 G 362 498 m 362 538 l S
0.9 G 382.8333333333333 498 m 382.8333333333333 538 l S
0.9 G 403.6666666666667 498 m 403.6666666666667 538 l S
0.9 G 424.5 498 m 424.5 538 l S
0.9 G 445.3333333333333 498 m 445.3333333333333 538 l S
0.9 G 466.1666666666667 498 m 466.1666666666667 538 l S
0.9 G 487 498 m 487 538 l S
0.9 G 507.8333333333333 498 m 507.8333333333333 538 l S
0.9 G 528.6666666666667 498 m 528.6666666666667 538 l S
0.9 G 549.5 498 m 549.5 538 l S
0.9 G 570.3333333333333 498 m 570.3333333333333 538 l S
0.9 G 591.1666666666667 498 m 591.1666666666667 538 l S
0.9 G 612 498 m 612 538 l S
0 g BT /F1 8 Tf 211.0 513.0 Td (v) Tj ET
0.75 g 221.38 509.00 78.13 18.00 re f 0 G 221.38 509.00 78.13 18.00 re S
0 g BT /F1 5 Tf 223.4 515.0 Td (SOMMEIL) Tj ET
0.5 g 299.50 509.00 10.42 18.00 re f 0 G 299.50 509.00 10.42 18.00 re S
0.75 g 309.92 509.00 67.71 18.00 re f 0 G 309.92 509.00 67.71 18.00 re S
0 g BT /F1 5 Tf 311.9 515.0 Td (SOMMEIL) Tj ET
0 g BT /F1 8 Tf 386.3 513.0 Td (^) Tj ET
0 g BT /F1 6 Tf 195.3 502.0 Td (M) Tj ET
0 g BT /F1 6 Tf 195.3 502.0 Td (M) Tj ET
0 g BT /F1 7 Tf 620.0 522.0 Td (-) Tj ET
0 g BT /F1 7 Tf 658.0 522.0 Td (-) Tj ET
0 g BT /F1 7 Tf 696.0 522.0 Td (-) Tj ET
0 g BT /F1 4 Tf 730.0 531.0 Td (22:00 \227 venlafaxine \227 37,5 mg ; 22:00 \227 ) Tj ET
0 g BT /F1 4 Tf 730.0 522.0 Td (venlafaxine \227 75 mg) Tj ET
0 g BT /F1 4 Tf 730.0 513.0 Td () Tj ET
0 g BT /F1 4 Tf 730.0 504.0 Td () Tj ET
0.7 G 30 458 782 40 re S
0 g BT /F1 5 Tf 33.0 485.0 Td (19/09/2026) Tj ET
0 g BT /F1 5 Tf 33.0 475.0 Td (au 20/09/2026) Tj ET
0.9 G 112 458 m 112 498 l S
0.9 G 132.83333333333334 458 m 132.83333333333334 498 l S
0.9 G 153.66666666666666 458 m 153.66666666666666 498 l S
0.9 G 174.5 458 m 174.5 498 l S
0.9 G 195.33333333333331 458 m 195.33333333333331 498 l S
0.9 G 216.16666666666669 458 m 216.16666666666669 498 l S
0.9 G 237 458 m 237 498 l S
0.9 G 257.83333333333337 458 m 257.83333333333337 498 l S
0.9 G 278.66666666666663 458 m 278.66666666666663 498 l S
0.9 G 299.5 458 m 299.5 498 l S
0.9 G 320.33333333333337 458 m 320.33333333333337 498 l S
0.9 G 341.16666666666663 458 m 341.16666666666663 498 l S
0.9 G 362 458 m 362 498 l S
0.9 G 382.8333333333333 458 m 382.8333333333333 498 l S
0.9 G 403.6666666666667 458 m 403.6666666666667 498 l S
0.9 G 424.5 458 m 424.5 498 l S
0.9 G 445.3333333333333 458 m 445.3333333333333 498 l S
0.9 G 466.1666666666667 458 m 466.1666666666667 498 l S
0.9 G 487 458 m 487 498 l S
0.9 G 507.8333333333333 458 m 507.8333333333333 498 l S
0.9 G 528.6666666666667 458 m 528.6666666666667 498 l S
0.9 G 549.5 458 m 549.5 498 l S
0.9 G 570.3333333333333 458 m 570.3333333333333 498 l S
0.9 G 591.1666666666667 458 m 591.1666666666667 498 l S
0.9 G 612 458 m 612 498 l S
0.75 g 205.75 469.00 104.17 18.00 re f 0 G 205.75 469.00 104.17 18.00 re S
0 g BT /F1 5 Tf 207.8 475.0 Td (SOMMEIL) Tj ET
0 g BT /F1 8 Tf 205.8 473.0 Td (v) Tj ET
0 g BT /F1 8 Tf 336.0 473.0 Td (^) Tj ET
0 g BT /F1 8 Tf 559.9 473.0 Td (S) Tj ET
0 g BT /F1 6 Tf 205.8 462.0 Td (M) Tj ET
0 g BT /F1 7 Tf 620.0 482.0 Td (B) Tj ET
0 g BT /F1 7 Tf 658.0 482.0 Td (TB) Tj ET
0 g BT /F1 7 Tf 696.0 482.0 Td (Moy) Tj ET
0 g BT /F1 4 Tf 730.0 491.0 Td (22:30 \227 venlafaxine \227 75 mg \3272 | Nuit de) Tj ET
0 g BT /F1 4 Tf 730.0 482.0 Td ( contr\364le g\351om\351trique.) Tj ET
0 g BT /F1 4 Tf 730.0 473.0 Td () Tj ET
0 g BT /F1 4 Tf 730.0 464.0 Td () Tj ET
0.7 G 30 418 782 40 re S
0 g BT /F1 5 Tf 33.0 445.0 Td (18/09/2026) Tj ET
0 g BT /F1 5 Tf 33.0 435.0 Td (au 19/09/2026) Tj ET
0.9 G 112 418 m 112 458 l S
0.9 G 132.83333333333334 418 m 132.83333333333334 458 l S
0.9 G 153.66666666666666 418 m 153.66666666666666 458 l S
0.9 G 174.5 418 m 174.5 458 l S
0.9 G 195.33333333333331 418 m 195.33333333333331 458 l S
0.9 G 216.16666666666669 418 m 216.16666666666669 458 l S
0.9 G 237 418 m 237 458 l S
0.9 G 257.83333333333337 418 m 257.83333333333337 458 l S
0.9 G 278.66666666666663 418 m 278.66666666666663 458 l S
0.9 G 299.5 418 m 299.5 458 l S
0.9 G 320.33333333333337 418 m 320.33333333333337 458 l S
0.9 G 341.16666666666663 418 m 341.16666666666663 458 l S
0.9 G 362 418 m 362 458 l S
0.9 G 382.8333333333333 418 m 382.8333333333333 458 l S
0.9 G 403.6666666666667 418 m 403.6666666666667 458 l S
0.9 G 424.5 418 m 424.5 458 l S
0.9 G 445.3333333333333 418 m 445.3333333333333 458 l S
0.9 G 466.1666666666667 418 m 466.1666666666667 458 l S
0.9 G 487 418 m 487 458 l S
0.9 G 507.8333333333333 418 m 507.8333333333333 458 l S
0.9 G 528.6666666666667 418 m 528.6666666666667 458 l S
0.9 G 549.5 418 m 549.5 458 l S
0.9 G 570.3333333333333 418 m 570.3333333333333 458 l S
0.9 G 591.1666666666667 418 m 591.1666666666667 458 l S
0.9 G 612 418 m 612 458 l S
0 g BT /F1 8 Tf 205.8 433.0 Td (v) Tj ET
0.75 g 216.17 429.00 83.33 18.00 re f 0 G 216.17 429.00 83.33 18.00 re S
0 g BT /F1 5 Tf 218.2 435.0 Td (SOMMEIL) Tj ET
0.5 g 299.50 429.00 15.63 18.00 re f 0 G 299.50 429.00 15.63 18.00 re S
0.75 g 315.13 429.00 67.71 18.00 re f 0 G 315.13 429.00 67.71 18.00 re S
0 g BT /F1 5 Tf 317.1 435.0 Td (SOMMEIL) Tj ET
0.87 g 382.83 429.00 3.47 18.00 re f 0 G 382.83 429.00 3.47 18.00 re S
0 g BT /F1 8 Tf 388.0 433.0 Td (^) Tj ET
0.93 g 528.67 429.00 12.15 18.00 re f 0 G 528.67 429.00 12.15 18.00 re S
0 g BT /F1 8 Tf 549.5 433.0 Td (S) Tj ET
0 g BT /F1 7 Tf 620.0 442.0 Td (B) Tj ET
0 g BT /F1 7 Tf 658.0 442.0 Td (Moy) Tj ET
0 g BT /F1 7 Tf 696.0 442.0 Td (TB) Tj ET
0 g BT /F1 4 Tf 730.0 451.0 Td (Remarque fran\347aise synth\351tique.) Tj ET
0 g BT /F1 4 Tf 730.0 442.0 Td () Tj ET
0 g BT /F1 4 Tf 730.0 433.0 Td () Tj ET
0 g BT /F1 4 Tf 730.0 424.0 Td () Tj ET
0.7 G 30 378 782 40 re S
0 g BT /F1 5 Tf 33.0 405.0 Td (17/09/2026) Tj ET
0 g BT /F1 5 Tf 33.0 395.0 Td (au 18/09/2026) Tj ET
0.9 G 112 378 m 112 418 l S
0.9 G 132.83333333333334 378 m 132.83333333333334 418 l S
0.9 G 153.66666666666666 378 m 153.66666666666666 418 l S
0.9 G 174.5 378 m 174.5 418 l S
0.9 G 195.33333333333331 378 m 195.33333333333331 418 l S
0.9 G 216.16666666666669 378 m 216.16666666666669 418 l S
0.9 G 237 378 m 237 418 l S
0.9 G 257.83333333333337 378 m 257.83333333333337 418 l S
0.9 G 278.66666666666663 378 m 278.66666666666663 418 l S
0.9 G 299.5 378 m 299.5 418 l S
0.9 G 320.33333333333337 378 m 320.33333333333337 418 l S
0.9 G 341.16666666666663 378 m 341.16666666666663 418 l S
0.9 G 362 378 m 362 418 l S
0.9 G 382.8333333333333 378 m 382.8333333333333 418 l S
0.9 G 403.6666666666667 378 m 403.6666666666667 418 l S
0.9 G 424.5 378 m 424.5 418 l S
0.9 G 445.3333333333333 378 m 445.3333333333333 418 l S
0.9 G 466.1666666666667 378 m 466.1666666666667 418 l S
0.9 G 487 378 m 487 418 l S
0.9 G 507.8333333333333 378 m 507.8333333333333 418 l S
0.9 G 528.6666666666667 378 m 528.6666666666667 418 l S
0.9 G 549.5 378 m 549.5 418 l S
0.9 G 570.3333333333333 378 m 570.3333333333333 418 l S
0.9 G 591.1666666666667 378 m 591.1666666666667 418 l S
0.9 G 612 378 m 612 418 l S
0 g BT /F1 8 Tf 205.8 393.0 Td (v) Tj ET
0.75 g 216.17 389.00 83.33 18.00 re f 0 G 216.17 389.00 83.33 18.00 re S
0 g BT /F1 5 Tf 218.2 395.0 Td (SOMMEIL) Tj ET
0.5 g 299.50 389.00 15.63 18.00 re f 0 G 299.50 389.00 15.63 18.00 re S
0.75 g 315.13 389.00 67.71 18.00 re f 0 G 315.13 389.00 67.71 18.00 re S
0 g BT /F1 5 Tf 317.1 395.0 Td (SOMMEIL) Tj ET
0.87 g 382.83 389.00 3.47 18.00 re f 0 G 382.83 389.00 3.47 18.00 re S
0 g BT /F1 8 Tf 388.0 393.0 Td (^) Tj ET
0.93 g 528.67 389.00 12.15 18.00 re f 0 G 528.67 389.00 12.15 18.00 re S
0 g BT /F1 8 Tf 549.5 393.0 Td (S) Tj ET
0 g BT /F1 7 Tf 620.0 402.0 Td (B) Tj ET
0 g BT /F1 7 Tf 658.0 402.0 Td (Moy) Tj ET
0 g BT /F1 7 Tf 696.0 402.0 Td (TB) Tj ET
0 g BT /F1 4 Tf 730.0 411.0 Td (Remarque fran\347aise synth\351tique.) Tj ET
0 g BT /F1 4 Tf 730.0 402.0 Td () Tj ET
0 g BT /F1 4 Tf 730.0 393.0 Td () Tj ET
0 g BT /F1 4 Tf 730.0 384.0 Td () Tj ET
0.7 G 30 338 782 40 re S
0 g BT /F1 5 Tf 33.0 365.0 Td (16/09/2026) Tj ET
0 g BT /F1 5 Tf 33.0 355.0 Td (au 17/09/2026) Tj ET
0.9 G 112 338 m 112 378 l S
0.9 G 132.83333333333334 338 m 132.83333333333334 378 l S
0.9 G 153.66666666666666 338 m 153.66666666666666 378 l S
0.9 G 174.5 338 m 174.5 378 l S
0.9 G 195.33333333333331 338 m 195.33333333333331 378 l S
0.9 G 216.16666666666669 338 m 216.16666666666669 378 l S
0.9 G 237 338 m 237 378 l S
0.9 G 257.83333333333337 338 m 257.83333333333337 378 l S
0.9 G 278.66666666666663 338 m 278.66666666666663 378 l S
0.9 G 299.5 338 m 299.5 378 l S
0.9 G 320.33333333333337 338 m 320.33333333333337 378 l S
0.9 G 341.16666666666663 338 m 341.16666666666663 378 l S
0.9 G 362 338 m 362 378 l S
0.9 G 382.8333333333333 338 m 382.8333333333333 378 l S
0.9 G 403.6666666666667 338 m 403.6666666666667 378 l S
0.9 G 424.5 338 m 424.5 378 l S
0.9 G 445.3333333333333 338 m 445.3333333333333 378 l S
0.9 G 466.1666666666667 338 m 466.1666666666667 378 l S
0.9 G 487 338 m 487 378 l S
0.9 G 507.8333333333333 338 m 507.8333333333333 378 l S
0.9 G 528.6666666666667 338 m 528.6666666666667 378 l S
0.9 G 549.5 338 m 549.5 378 l S
0.9 G 570.3333333333333 338 m 570.3333333333333 378 l S
0.9 G 591.1666666666667 338 m 591.1666666666667 378 l S
0.9 G 612 338 m 612 378 l S
0 g BT /F1 8 Tf 205.8 353.0 Td (v) Tj ET
0.75 g 216.17 349.00 83.33 18.00 re f 0 G 216.17 349.00 83.33 18.00 re S
0 g BT /F1 5 Tf 218.2 355.0 Td (SOMMEIL) Tj ET
0.5 g 299.50 349.00 15.63 18.00 re f 0 G 299.50 349.00 15.63 18.00 re S
0.75 g 315.13 349.00 67.71 18.00 re f 0 G 315.13 349.00 67.71 18.00 re S
0 g BT /F1 5 Tf 317.1 355.0 Td (SOMMEIL) Tj ET
0.87 g 382.83 349.00 3.47 18.00 re f 0 G 382.83 349.00 3.47 18.00 re S
0 g BT /F1 8 Tf 388.0 353.0 Td (^) Tj ET
0.93 g 528.67 349.00 12.15 18.00 re f 0 G 528.67 349.00 12.15 18.00 re S
0 g BT /F1 8 Tf 549.5 353.0 Td (S) Tj ET
0 g BT /F1 7 Tf 620.0 362.0 Td (B) Tj ET
0 g BT /F1 7 Tf 658.0 362.0 Td (Moy) Tj ET
0 g BT /F1 7 Tf 696.0 362.0 Td (TB) Tj ET
0 g BT /F1 4 Tf 730.0 371.0 Td (Remarque fran\347aise synth\351tique.) Tj ET
0 g BT /F1 4 Tf 730.0 362.0 Td () Tj ET
0 g BT /F1 4 Tf 730.0 353.0 Td () Tj ET
0 g BT /F1 4 Tf 730.0 344.0 Td () Tj ET
0.7 G 30 298 782 40 re S
0 g BT /F1 5 Tf 33.0 325.0 Td (15/09/2026) Tj ET
0 g BT /F1 5 Tf 33.0 315.0 Td (au 16/09/2026) Tj ET
0.9 G 112 298 m 112 338 l S
0.9 G 132.83333333333334 298 m 132.83333333333334 338 l S
0.9 G 153.66666666666666 298 m 153.66666666666666 338 l S
0.9 G 174.5 298 m 174.5 338 l S
0.9 G 195.33333333333331 298 m 195.33333333333331 338 l S
0.9 G 216.16666666666669 298 m 216.16666666666669 338 l S
0.9 G 237 298 m 237 338 l S
0.9 G 257.83333333333337 298 m 257.83333333333337 338 l S
0.9 G 278.66666666666663 298 m 278.66666666666663 338 l S
0.9 G 299.5 298 m 299.5 338 l S
0.9 G 320.33333333333337 298 m 320.33333333333337 338 l S
0.9 G 341.16666666666663 298 m 341.16666666666663 338 l S
0.9 G 362 298 m 362 338 l S
0.9 G 382.8333333333333 298 m 382.8333333333333 338 l S
0.9 G 403.6666666666667 298 m 403.6666666666667 338 l S
0.9 G 424.5 298 m 424.5 338 l S
0.9 G 445.3333333333333 298 m 445.3333333333333 338 l S
0.9 G 466.1666666666667 298 m 466.1666666666667 338 l S
0.9 G 487 298 m 487 338 l S
0.9 G 507.8333333333333 298 m 507.8333333333333 338 l S
0.9 G 528.6666666666667 298 m 528.6666666666667 338 l S
0.9 G 549.5 298 m 549.5 338 l S
0.9 G 570.3333333333333 298 m 570.3333333333333 338 l S
0.9 G 591.1666666666667 298 m 591.1666666666667 338 l S
0.9 G 612 298 m 612 338 l S
0 g BT /F1 8 Tf 205.8 313.0 Td (v) Tj ET
0.75 g 216.17 309.00 83.33 18.00 re f 0 G 216.17 309.00 83.33 18.00 re S
0 g BT /F1 5 Tf 218.2 315.0 Td (SOMMEIL) Tj ET
0.5 g 299.50 309.00 15.63 18.00 re f 0 G 299.50 309.00 15.63 18.00 re S
0.75 g 315.13 309.00 67.71 18.00 re f 0 G 315.13 309.00 67.71 18.00 re S
0 g BT /F1 5 Tf 317.1 315.0 Td (SOMMEIL) Tj ET
0.87 g 382.83 309.00 3.47 18.00 re f 0 G 382.83 309.00 3.47 18.00 re S
0 g BT /F1 8 Tf 388.0 313.0 Td (^) Tj ET
0.93 g 528.67 309.00 12.15 18.00 re f 0 G 528.67 309.00 12.15 18.00 re S
0 g BT /F1 8 Tf 549.5 313.0 Td (S) Tj ET
0 g BT /F1 7 Tf 620.0 322.0 Td (B) Tj ET
0 g BT /F1 7 Tf 658.0 322.0 Td (Moy) Tj ET
0 g BT /F1 7 Tf 696.0 322.0 Td (TB) Tj ET
0 g BT /F1 4 Tf 730.0 331.0 Td (Remarque fran\347aise synth\351tique.) Tj ET
0 g BT /F1 4 Tf 730.0 322.0 Td () Tj ET
0 g BT /F1 4 Tf 730.0 313.0 Td () Tj ET
0 g BT /F1 4 Tf 730.0 304.0 Td () Tj ET
0.7 G 30 258 782 40 re S
0 g BT /F1 5 Tf 33.0 285.0 Td (14/09/2026) Tj ET
0 g BT /F1 5 Tf 33.0 275.0 Td (au 15/09/2026) Tj ET
0.9 G 112 258 m 112 298 l S
0.9 G 132.83333333333334 258 m 132.83333333333334 298 l S
0.9 G 153.66666666666666 258 m 153.66666666666666 298 l S
0.9 G 174.5 258 m 174.5 298 l S
0.9 G 195.33333333333331 258 m 195.33333333333331 298 l S
0.9 G 216.16666666666669 258 m 216.16666666666669 298 l S
0.9 G 237 258 m 237 298 l S
0.9 G 257.83333333333337 258 m 257.83333333333337 298 l S
0.9 G 278.66666666666663 258 m 278.66666666666663 298 l S
0.9 G 299.5 258 m 299.5 298 l S
0.9 G 320.33333333333337 258 m 320.33333333333337 298 l S
0.9 G 341.16666666666663 258 m 341.16666666666663 298 l S
0.9 G 362 258 m 362 298 l S
0.9 G 382.8333333333333 258 m 382.8333333333333 298 l S
0.9 G 403.6666666666667 258 m 403.6666666666667 298 l S
0.9 G 424.5 258 m 424.5 298 l S
0.9 G 445.3333333333333 258 m 445.3333333333333 298 l S
0.9 G 466.1666666666667 258 m 466.1666666666667 298 l S
0.9 G 487 258 m 487 298 l S
0.9 G 507.8333333333333 258 m 507.8333333333333 298 l S
0.9 G 528.6666666666667 258 m 528.6666666666667 298 l S
0.9 G 549.5 258 m 549.5 298 l S
0.9 G 570.3333333333333 258 m 570.3333333333333 298 l S
0.9 G 591.1666666666667 258 m 591.1666666666667 298 l S
0.9 G 612 258 m 612 298 l S
0 g BT /F1 8 Tf 205.8 273.0 Td (v) Tj ET
0.75 g 216.17 269.00 83.33 18.00 re f 0 G 216.17 269.00 83.33 18.00 re S
0 g BT /F1 5 Tf 218.2 275.0 Td (SOMMEIL) Tj ET
0.5 g 299.50 269.00 15.63 18.00 re f 0 G 299.50 269.00 15.63 18.00 re S
0.75 g 315.13 269.00 67.71 18.00 re f 0 G 315.13 269.00 67.71 18.00 re S
0 g BT /F1 5 Tf 317.1 275.0 Td (SOMMEIL) Tj ET
0.87 g 382.83 269.00 3.47 18.00 re f 0 G 382.83 269.00 3.47 18.00 re S
0 g BT /F1 8 Tf 388.0 273.0 Td (^) Tj ET
0.93 g 528.67 269.00 12.15 18.00 re f 0 G 528.67 269.00 12.15 18.00 re S
0 g BT /F1 8 Tf 549.5 273.0 Td (S) Tj ET
0 g BT /F1 7 Tf 620.0 282.0 Td (B) Tj ET
0 g BT /F1 7 Tf 658.0 282.0 Td (Moy) Tj ET
0 g BT /F1 7 Tf 696.0 282.0 Td (TB) Tj ET
0 g BT /F1 4 Tf 730.0 291.0 Td (Remarque fran\347aise synth\351tique.) Tj ET
0 g BT /F1 4 Tf 730.0 282.0 Td () Tj ET
0 g BT /F1 4 Tf 730.0 273.0 Td () Tj ET
0 g BT /F1 4 Tf 730.0 264.0 Td () Tj ET
0 g BT /F1 9 Tf 30.0 240.0 Td (OBSERVATIONS) Tj ET
0 g BT /F1 6 Tf 30.0 226.0 Td (7 nuits \267 Sommeil d\351clar\351 : 48 h 15 min \267 Longs r\351veils : 6 \267 Siestes : 5 \267 Somnolences : 6 \267 Prises de m\351dicaments : 3) Tj ET
0 g BT /F1 6 Tf 30.0 216.0 Td (AVERTISSEMENT : 7 jours non valid\351s dans cet export.) Tj ET
0 g BT /F1 6 Tf 30.0 204.0 Td (19/09/2026 : Nuit de contr\364le g\351om\351trique.) Tj ET
0 g BT /F1 6 Tf 30.0 194.0 Td (18/09/2026 : Remarque fran\347aise synth\351tique.) Tj ET
0 g BT /F1 6 Tf 30.0 184.0 Td (17/09/2026 : Remarque fran\347aise synth\351tique.) Tj ET
0 g BT /F1 6 Tf 30.0 28.0 Td (L\351gende : v Mise au lit ; ^ Lever ; ^ Lever nocturne ; S Somnolence ; M Prise de m\351dicament.) Tj ET
0 g BT /F1 6 Tf 30.0 18.0 Td (Sommeil : plage de sommeil ; Sieste : plage de sieste ; Long r\351veil : interruption prolong\351e ; Demi-sommeil : plage de demi-sommeil.) Tj ET
endstream
endobj
xref
0 6
0000000000 65535 f
0000000019 00000 n
0000000068 00000 n
0000000125 00000 n
0000000222 00000 n
0000000348 00000 n
trailer
<< /Size 6 /Root 1 0 R >>
startxref
18065
%%EOF

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

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