Compare commits
82 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a8708c63b | ||
|
|
bb20aa44ef | ||
|
|
45bb44e45d | ||
|
|
bd795495df | ||
|
|
a652644e24 | ||
|
|
8646422bef | ||
|
|
77b5bbca85 | ||
|
|
ffcdc3efcf | ||
|
|
23717ba6da | ||
|
|
de4b77d59b | ||
|
|
4cb911d5c4 | ||
|
|
a60e1aa056 | ||
|
|
696dac82a3 | ||
|
|
fe79c44e51 | ||
|
|
2ce458d286 | ||
|
|
3a51211f01 | ||
|
|
2755d29f2d | ||
|
|
713238fd0e | ||
|
|
d7ca6a1924 | ||
|
|
e7fbd6ff86 | ||
|
|
7adeb59b06 | ||
|
|
f20e5c4d39 | ||
|
|
f2747b357b | ||
|
|
f2fd610b4f | ||
|
|
c8bfb7a759 | ||
|
|
21f56e3986 | ||
|
|
5a7f80049a | ||
|
|
41d0f1ff96 | ||
|
|
0750a47cef | ||
|
|
8f8b361915 | ||
|
|
246e47ee8f | ||
|
|
ad493a72aa | ||
|
|
8371d5e16c | ||
|
|
3ded6ee425 | ||
|
|
070a29298f | ||
|
|
dd39c85f94 | ||
|
|
1ca354af53 | ||
|
|
5bf90899bd | ||
|
|
984b1e058e | ||
|
|
14c3ac294e | ||
|
|
c13549338c | ||
|
|
e1d6372338 | ||
|
|
5d58071601 | ||
|
|
b4fd753ac5 | ||
|
|
8a334613f8 | ||
|
|
26d24d2ed7 | ||
|
|
1d8450ad1c | ||
|
|
d5b4584ae2 | ||
|
|
b580b63886 | ||
|
|
066a3a44a7 | ||
|
|
87931ca1ec | ||
|
|
c11ea07a19 | ||
|
|
3347de3dbb | ||
|
|
0a35ddacbf | ||
|
|
a88e85c235 | ||
|
|
0f7d793960 | ||
|
|
dd6d2a180d | ||
|
|
891c09179e | ||
|
|
e6e5e76f68 | ||
|
|
9652b35189 | ||
|
|
a04e41d252 | ||
|
|
20fe562cd8 | ||
|
|
a98a4440ed | ||
|
|
915728cec4 | ||
|
|
47ee128807 | ||
|
|
e320767516 | ||
|
|
c5e62c2e5e | ||
|
|
e2284be1e3 | ||
|
|
f48be35f3b | ||
|
|
0cf53228da | ||
|
|
afd1fcc92f | ||
|
|
53f15e1354 | ||
|
|
1096b13939 | ||
|
|
bf27e05e7a | ||
|
|
36a7e651ac | ||
|
|
30df3030e6 | ||
|
|
2565d0eafa | ||
|
|
6948e0bbd5 | ||
|
|
c31c394e4f | ||
|
|
109eb6fcd4 | ||
|
|
2bd68100e9 | ||
|
|
451d0a36a0 |
265 changed files with 44364 additions and 1890 deletions
347
CHANGELOG.md
347
CHANGELOG.md
|
|
@ -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
566
README.md
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ class DraftUiTestActivity : ComponentActivity() {
|
|||
sessionVisible = false
|
||||
},
|
||||
onCreateExercise = {},
|
||||
onCreateEquipment = { _ -> },
|
||||
/* CONTRACT: this host never writes shared export data. */
|
||||
onSessionSaved = {},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import com.labfytools.trainlog.model.CardioGuidanceInstruction
|
||||
import com.labfytools.trainlog.model.CardioGuidanceOutput
|
||||
import com.labfytools.trainlog.model.CardioTargetDefinition
|
||||
import com.labfytools.trainlog.model.CardioTargetSnapshot
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
internal object CardioTargetResolver {
|
||||
fun resolve(definition: CardioTargetDefinition): CardioTargetSnapshot? =
|
||||
when (definition) {
|
||||
is CardioTargetDefinition.AbsoluteBpm -> {
|
||||
if (!validBounds(definition.minimumBpm, definition.maximumBpm)) return null
|
||||
CardioTargetSnapshot(
|
||||
minimumBpm = definition.minimumBpm,
|
||||
maximumBpm = definition.maximumBpm,
|
||||
)
|
||||
}
|
||||
|
||||
is CardioTargetDefinition.CalibrationPercent -> {
|
||||
if (
|
||||
definition.calibrationId.isBlank() ||
|
||||
definition.observedPeakBpm !in 1..65535 ||
|
||||
definition.minimumPercent !in 1..100 ||
|
||||
definition.maximumPercent !in 1..100 ||
|
||||
definition.minimumPercent > definition.maximumPercent
|
||||
) return null
|
||||
val minimum =
|
||||
(definition.observedPeakBpm * definition.minimumPercent / 100.0)
|
||||
.roundToInt()
|
||||
val maximum =
|
||||
(definition.observedPeakBpm * definition.maximumPercent / 100.0)
|
||||
.roundToInt()
|
||||
if (!validBounds(minimum, maximum)) return null
|
||||
CardioTargetSnapshot(
|
||||
minimumBpm = minimum,
|
||||
maximumBpm = maximum,
|
||||
calibrationId = definition.calibrationId,
|
||||
calibrationObservedPeakBpm = definition.observedPeakBpm,
|
||||
minimumPercent = definition.minimumPercent,
|
||||
maximumPercent = definition.maximumPercent,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun validBounds(minimum: Int, maximum: Int): Boolean =
|
||||
minimum in 1..65535 && maximum in 1..65535 && minimum <= maximum
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure state machine for BPM guidance.
|
||||
*
|
||||
* Hysteresis and confirmation time are UI/control stability parameters, not
|
||||
* physiological zones. The engine never extrapolates a stale measurement.
|
||||
*/
|
||||
internal object CardioPhaseCompletion {
|
||||
fun isComplete(
|
||||
phase: com.labfytools.trainlog.model.CardioGuidedPhase,
|
||||
elapsedSeconds: Int,
|
||||
bpm: Int?,
|
||||
fresh: Boolean,
|
||||
): Boolean {
|
||||
if (elapsedSeconds < 0) return false
|
||||
return when (val exit = phase.exitCondition) {
|
||||
is com.labfytools.trainlog.model.CardioPhaseExitCondition.FixedDuration ->
|
||||
elapsedSeconds >= exit.seconds
|
||||
|
||||
com.labfytools.trainlog.model.CardioPhaseExitCondition.EnterTarget -> {
|
||||
val target = phase.target ?: return false
|
||||
fresh && bpm != null && bpm in target.minimumBpm..target.maximumBpm
|
||||
}
|
||||
|
||||
is com.labfytools.trainlog.model.CardioPhaseExitCondition.RecoverBelow ->
|
||||
fresh && bpm != null && bpm <= exit.bpm
|
||||
|
||||
is com.labfytools.trainlog.model.CardioPhaseExitCondition.DurationOrRecoverBelow ->
|
||||
elapsedSeconds >= exit.maximumSeconds ||
|
||||
(fresh && bpm != null && bpm <= exit.bpm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class CardioGuidanceEngine(
|
||||
private val hysteresisBpm: Int = DEFAULT_HYSTERESIS_BPM,
|
||||
private val confirmationMs: Long = DEFAULT_CONFIRMATION_MS,
|
||||
initialInstruction: CardioGuidanceInstruction = CardioGuidanceInstruction.MAINTAIN,
|
||||
) {
|
||||
private var current =
|
||||
initialInstruction.takeUnless { it == CardioGuidanceInstruction.SUSPENDED }
|
||||
?: CardioGuidanceInstruction.MAINTAIN
|
||||
private var candidate: CardioGuidanceInstruction? = null
|
||||
private var candidateSinceMs: Long? = null
|
||||
|
||||
init {
|
||||
require(hysteresisBpm in 0..20)
|
||||
require(confirmationMs in 0L..30_000L)
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
current = CardioGuidanceInstruction.MAINTAIN
|
||||
candidate = null
|
||||
candidateSinceMs = null
|
||||
}
|
||||
|
||||
fun evaluate(
|
||||
target: CardioTargetSnapshot?,
|
||||
bpm: Int?,
|
||||
fresh: Boolean,
|
||||
nowMs: Long,
|
||||
): CardioGuidanceOutput {
|
||||
require(nowMs >= 0L)
|
||||
if (!fresh || bpm == null || bpm !in 1..65535 || target == null) {
|
||||
candidate = null
|
||||
candidateSinceMs = null
|
||||
return CardioGuidanceOutput(
|
||||
instruction = CardioGuidanceInstruction.SUSPENDED,
|
||||
bpm = null,
|
||||
target = target,
|
||||
)
|
||||
}
|
||||
require(target.minimumBpm in 1..65535)
|
||||
require(target.maximumBpm in target.minimumBpm..65535)
|
||||
|
||||
val desired = desiredInstruction(target, bpm)
|
||||
if (desired == current) {
|
||||
candidate = null
|
||||
candidateSinceMs = null
|
||||
return CardioGuidanceOutput(current, bpm, target)
|
||||
}
|
||||
|
||||
if (candidate != desired) {
|
||||
candidate = desired
|
||||
candidateSinceMs = nowMs
|
||||
}
|
||||
val since = checkNotNull(candidateSinceMs)
|
||||
if (nowMs - since >= confirmationMs) {
|
||||
current = desired
|
||||
candidate = null
|
||||
candidateSinceMs = null
|
||||
}
|
||||
|
||||
return CardioGuidanceOutput(
|
||||
instruction = current,
|
||||
bpm = bpm,
|
||||
target = target,
|
||||
pendingInstruction = candidate,
|
||||
)
|
||||
}
|
||||
|
||||
private fun desiredInstruction(
|
||||
target: CardioTargetSnapshot,
|
||||
bpm: Int,
|
||||
): CardioGuidanceInstruction {
|
||||
// A target entered from either side immediately trends toward MAINTAIN.
|
||||
if (bpm in target.minimumBpm..target.maximumBpm) {
|
||||
return CardioGuidanceInstruction.MAINTAIN
|
||||
}
|
||||
|
||||
return when (current) {
|
||||
CardioGuidanceInstruction.ACCELERATE ->
|
||||
if (bpm >= target.minimumBpm + hysteresisBpm) {
|
||||
CardioGuidanceInstruction.MAINTAIN
|
||||
} else {
|
||||
CardioGuidanceInstruction.ACCELERATE
|
||||
}
|
||||
|
||||
CardioGuidanceInstruction.SLOW_DOWN ->
|
||||
if (bpm <= target.maximumBpm - hysteresisBpm) {
|
||||
CardioGuidanceInstruction.MAINTAIN
|
||||
} else {
|
||||
CardioGuidanceInstruction.SLOW_DOWN
|
||||
}
|
||||
|
||||
CardioGuidanceInstruction.MAINTAIN,
|
||||
CardioGuidanceInstruction.SUSPENDED ->
|
||||
when {
|
||||
bpm < target.minimumBpm - hysteresisBpm ->
|
||||
CardioGuidanceInstruction.ACCELERATE
|
||||
bpm > target.maximumBpm + hysteresisBpm ->
|
||||
CardioGuidanceInstruction.SLOW_DOWN
|
||||
else -> CardioGuidanceInstruction.MAINTAIN
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_HYSTERESIS_BPM = 2
|
||||
const val DEFAULT_CONFIRMATION_MS = 3_000L
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import com.labfytools.trainlog.model.CardioGuidanceInstruction
|
||||
import com.labfytools.trainlog.model.CardioPhaseKind
|
||||
import com.labfytools.trainlog.model.CardioTargetSnapshot
|
||||
import java.util.concurrent.CopyOnWriteArraySet
|
||||
|
||||
internal data class CardioGuidanceLiveSnapshot(
|
||||
val phaseId: String? = null,
|
||||
val phaseKind: CardioPhaseKind? = null,
|
||||
val instruction: CardioGuidanceInstruction = CardioGuidanceInstruction.SUSPENDED,
|
||||
val bpm: Int? = null,
|
||||
val target: CardioTargetSnapshot? = null,
|
||||
val startedAt: String? = null,
|
||||
)
|
||||
|
||||
internal object CardioGuidanceLiveState {
|
||||
private val listeners =
|
||||
CopyOnWriteArraySet<(CardioGuidanceLiveSnapshot) -> Unit>()
|
||||
|
||||
@Volatile
|
||||
private var snapshot = CardioGuidanceLiveSnapshot()
|
||||
|
||||
fun current(): CardioGuidanceLiveSnapshot = snapshot
|
||||
|
||||
fun publish(next: CardioGuidanceLiveSnapshot) {
|
||||
snapshot = next
|
||||
listeners.forEach { it(next) }
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
publish(CardioGuidanceLiveSnapshot())
|
||||
}
|
||||
|
||||
fun subscribe(listener: (CardioGuidanceLiveSnapshot) -> Unit): AutoCloseable {
|
||||
listeners += listener
|
||||
listener(snapshot)
|
||||
return AutoCloseable { listeners -= listener }
|
||||
}
|
||||
}
|
||||
|
|
@ -195,7 +195,8 @@ internal class DriveDocumentTransport(private val context: Context, treeUri: Uri
|
|||
localRoot.mkdirs()
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,482 @@
|
|||
/*
|
||||
* Android-only BLE Heart Rate diagnostics.
|
||||
*
|
||||
* This owner is intentionally bounded and screen-scoped. Long-running capture
|
||||
* belongs to the later foreground acquisition service, not this diagnostic.
|
||||
*/
|
||||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGattCallback
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.bluetooth.BluetoothGattDescriptor
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.bluetooth.BluetoothStatusCodes
|
||||
import android.bluetooth.le.ScanCallback
|
||||
import android.bluetooth.le.ScanFilter
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.bluetooth.le.ScanSettings
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.ParcelUuid
|
||||
import androidx.core.content.ContextCompat
|
||||
import java.util.UUID
|
||||
|
||||
internal val HEART_RATE_SERVICE_UUID: UUID =
|
||||
UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb")
|
||||
internal val HEART_RATE_MEASUREMENT_UUID: UUID =
|
||||
UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb")
|
||||
internal val BATTERY_SERVICE_UUID: UUID =
|
||||
UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")
|
||||
internal val CLIENT_CHARACTERISTIC_CONFIG_UUID: UUID =
|
||||
UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
||||
|
||||
internal enum class HeartRateDiagnosticPhase {
|
||||
IDLE,
|
||||
SCANNING,
|
||||
CONNECTING,
|
||||
DISCOVERING,
|
||||
SUBSCRIBING,
|
||||
RECEIVING,
|
||||
DISCONNECTED,
|
||||
ERROR,
|
||||
}
|
||||
|
||||
internal data class HeartRateDiagnosticDevice(
|
||||
val address: String,
|
||||
val name: String?,
|
||||
val rssi: Int,
|
||||
val advertisesHeartRate: Boolean,
|
||||
)
|
||||
|
||||
internal data class HeartRateDiagnosticSnapshot(
|
||||
val phase: HeartRateDiagnosticPhase = HeartRateDiagnosticPhase.IDLE,
|
||||
val devices: List<HeartRateDiagnosticDevice> = emptyList(),
|
||||
val selectedAddress: String? = null,
|
||||
val serviceFound: Boolean = false,
|
||||
val measurementFound: Boolean = false,
|
||||
val batteryServiceFound: Boolean = false,
|
||||
val notificationCount: Int = 0,
|
||||
val latestMeasurement: ParsedHeartRateMeasurement? = null,
|
||||
val diagnostic: String? = null,
|
||||
)
|
||||
|
||||
internal fun heartRateBleRuntimePermissions(): Array<String> =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
|
||||
} else {
|
||||
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
}
|
||||
|
||||
internal fun hasHeartRateBleRuntimePermissions(context: Context): Boolean =
|
||||
heartRateBleRuntimePermissions().all {
|
||||
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
internal class HeartRateBleDiagnostics(
|
||||
context: Context,
|
||||
private val onSnapshot: (HeartRateDiagnosticSnapshot) -> Unit,
|
||||
) : AutoCloseable {
|
||||
private val appContext = context.applicationContext
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
private val bluetoothManager =
|
||||
appContext.getSystemService(BluetoothManager::class.java)
|
||||
private val adapter get() = bluetoothManager?.adapter
|
||||
private val devices = linkedMapOf<String, BluetoothDevice>()
|
||||
private var snapshot = HeartRateDiagnosticSnapshot()
|
||||
private var scannerCallback: ScanCallback? = null
|
||||
private var scanStop: Runnable? = null
|
||||
private var gatt: BluetoothGatt? = null
|
||||
private var closed = false
|
||||
|
||||
private fun update(transform: (HeartRateDiagnosticSnapshot) -> HeartRateDiagnosticSnapshot) {
|
||||
if (closed) return
|
||||
if (Looper.myLooper() != Looper.getMainLooper()) {
|
||||
main.post { update(transform) }
|
||||
return
|
||||
}
|
||||
snapshot = transform(snapshot)
|
||||
onSnapshot(snapshot)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun startScan(includeAllBle: Boolean = false) {
|
||||
if (!hasHeartRateBleRuntimePermissions(appContext)) {
|
||||
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "permission") }
|
||||
return
|
||||
}
|
||||
val currentAdapter = adapter
|
||||
if (currentAdapter == null) {
|
||||
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "bluetooth_unavailable") }
|
||||
return
|
||||
}
|
||||
if (!currentAdapter.isEnabled) {
|
||||
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "bluetooth_disabled") }
|
||||
return
|
||||
}
|
||||
val scanner = currentAdapter.bluetoothLeScanner
|
||||
if (scanner == null) {
|
||||
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "scanner_unavailable") }
|
||||
return
|
||||
}
|
||||
|
||||
stopScan()
|
||||
devices.clear()
|
||||
update {
|
||||
HeartRateDiagnosticSnapshot(
|
||||
phase = HeartRateDiagnosticPhase.SCANNING,
|
||||
diagnostic = if (includeAllBle) "scan_all" else "scan_heart_rate",
|
||||
)
|
||||
}
|
||||
|
||||
val callback =
|
||||
object : ScanCallback() {
|
||||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||
handleScanResult(result)
|
||||
}
|
||||
|
||||
override fun onBatchScanResults(results: MutableList<ScanResult>) {
|
||||
results.forEach(::handleScanResult)
|
||||
}
|
||||
|
||||
override fun onScanFailed(errorCode: Int) {
|
||||
update {
|
||||
it.copy(
|
||||
phase = HeartRateDiagnosticPhase.ERROR,
|
||||
diagnostic = "scan_failed:" + errorCode,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
scannerCallback = callback
|
||||
val filters =
|
||||
if (includeAllBle) {
|
||||
emptyList()
|
||||
} else {
|
||||
listOf(
|
||||
ScanFilter.Builder()
|
||||
.setServiceUuid(ParcelUuid(HEART_RATE_SERVICE_UUID))
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
scanner.startScan(
|
||||
filters,
|
||||
ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build(),
|
||||
callback,
|
||||
)
|
||||
val stopper =
|
||||
Runnable {
|
||||
if (snapshot.phase == HeartRateDiagnosticPhase.SCANNING) {
|
||||
stopScan()
|
||||
update { it.copy(phase = HeartRateDiagnosticPhase.IDLE, diagnostic = "scan_complete") }
|
||||
}
|
||||
}
|
||||
scanStop = stopper
|
||||
main.postDelayed(stopper, SCAN_DURATION_MS)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun handleScanResult(result: ScanResult) {
|
||||
val record = result.scanRecord
|
||||
val advertised =
|
||||
record?.serviceUuids?.any { it.uuid == HEART_RATE_SERVICE_UUID } == true
|
||||
val device = result.device
|
||||
devices[device.address] = device
|
||||
val row =
|
||||
HeartRateDiagnosticDevice(
|
||||
address = device.address,
|
||||
name = record?.deviceName,
|
||||
rssi = result.rssi,
|
||||
advertisesHeartRate = advertised,
|
||||
)
|
||||
update { old ->
|
||||
val rows =
|
||||
(old.devices.filterNot { it.address == row.address } + row)
|
||||
.sortedWith(
|
||||
compareByDescending<HeartRateDiagnosticDevice> { it.advertisesHeartRate }
|
||||
.thenByDescending { it.rssi },
|
||||
)
|
||||
old.copy(devices = rows)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun connect(address: String) {
|
||||
if (!hasHeartRateBleRuntimePermissions(appContext)) {
|
||||
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "permission") }
|
||||
return
|
||||
}
|
||||
val device =
|
||||
devices[address]
|
||||
?: runCatching { adapter?.getRemoteDevice(address) }.getOrNull()
|
||||
?: run {
|
||||
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "device_missing") }
|
||||
return
|
||||
}
|
||||
stopScan()
|
||||
closeGatt()
|
||||
update {
|
||||
it.copy(
|
||||
phase = HeartRateDiagnosticPhase.CONNECTING,
|
||||
selectedAddress = address,
|
||||
serviceFound = false,
|
||||
measurementFound = false,
|
||||
batteryServiceFound = false,
|
||||
notificationCount = 0,
|
||||
latestMeasurement = null,
|
||||
diagnostic = "connecting",
|
||||
)
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
runCatching {
|
||||
device.connectGatt(
|
||||
appContext,
|
||||
false,
|
||||
gattCallback,
|
||||
BluetoothDevice.TRANSPORT_LE,
|
||||
)
|
||||
}
|
||||
.onSuccess { gatt = it }
|
||||
.onFailure { error ->
|
||||
update {
|
||||
it.copy(
|
||||
phase = HeartRateDiagnosticPhase.ERROR,
|
||||
diagnostic = "connect_failed:" + error.javaClass.simpleName,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val gattCallback =
|
||||
object : BluetoothGattCallback() {
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun onConnectionStateChange(
|
||||
callbackGatt: BluetoothGatt,
|
||||
status: Int,
|
||||
newState: Int,
|
||||
) {
|
||||
main.post {
|
||||
if (closed || callbackGatt !== gatt) return@post
|
||||
if (status == BluetoothGatt.GATT_SUCCESS &&
|
||||
newState == BluetoothProfile.STATE_CONNECTED
|
||||
) {
|
||||
update {
|
||||
it.copy(
|
||||
phase = HeartRateDiagnosticPhase.DISCOVERING,
|
||||
diagnostic = "connected",
|
||||
)
|
||||
}
|
||||
if (!callbackGatt.discoverServices()) {
|
||||
update {
|
||||
it.copy(
|
||||
phase = HeartRateDiagnosticPhase.ERROR,
|
||||
diagnostic = "discover_start_failed",
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
|
||||
update {
|
||||
it.copy(
|
||||
phase = HeartRateDiagnosticPhase.DISCONNECTED,
|
||||
diagnostic = "disconnected:" + status,
|
||||
)
|
||||
}
|
||||
closeGatt()
|
||||
} else if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
update {
|
||||
it.copy(
|
||||
phase = HeartRateDiagnosticPhase.ERROR,
|
||||
diagnostic = "gatt_status:" + status,
|
||||
)
|
||||
}
|
||||
closeGatt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun onServicesDiscovered(callbackGatt: BluetoothGatt, status: Int) {
|
||||
main.post {
|
||||
if (closed || callbackGatt !== gatt) return@post
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
update {
|
||||
it.copy(
|
||||
phase = HeartRateDiagnosticPhase.ERROR,
|
||||
diagnostic = "service_discovery:" + status,
|
||||
)
|
||||
}
|
||||
return@post
|
||||
}
|
||||
val service = callbackGatt.getService(HEART_RATE_SERVICE_UUID)
|
||||
val measurement = service?.getCharacteristic(HEART_RATE_MEASUREMENT_UUID)
|
||||
val battery = callbackGatt.getService(BATTERY_SERVICE_UUID) != null
|
||||
update {
|
||||
it.copy(
|
||||
serviceFound = service != null,
|
||||
measurementFound = measurement != null,
|
||||
batteryServiceFound = battery,
|
||||
)
|
||||
}
|
||||
if (measurement == null) {
|
||||
update {
|
||||
it.copy(
|
||||
phase = HeartRateDiagnosticPhase.ERROR,
|
||||
diagnostic = "heart_rate_measurement_missing",
|
||||
)
|
||||
}
|
||||
return@post
|
||||
}
|
||||
subscribe(callbackGatt, measurement)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDescriptorWrite(
|
||||
callbackGatt: BluetoothGatt,
|
||||
descriptor: BluetoothGattDescriptor,
|
||||
status: Int,
|
||||
) {
|
||||
if (descriptor.uuid != CLIENT_CHARACTERISTIC_CONFIG_UUID) return
|
||||
update {
|
||||
if (status == BluetoothGatt.GATT_SUCCESS) {
|
||||
it.copy(
|
||||
phase = HeartRateDiagnosticPhase.RECEIVING,
|
||||
diagnostic = "notifications_enabled",
|
||||
)
|
||||
} else {
|
||||
it.copy(
|
||||
phase = HeartRateDiagnosticPhase.ERROR,
|
||||
diagnostic = "descriptor_write:" + status,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated by Android API 33 callback with explicit value")
|
||||
override fun onCharacteristicChanged(
|
||||
callbackGatt: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
) {
|
||||
@Suppress("DEPRECATION")
|
||||
val value = characteristic.value ?: return
|
||||
handleMeasurement(characteristic.uuid, value)
|
||||
}
|
||||
|
||||
override fun onCharacteristicChanged(
|
||||
callbackGatt: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
value: ByteArray,
|
||||
) {
|
||||
handleMeasurement(characteristic.uuid, value)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun subscribe(
|
||||
callbackGatt: BluetoothGatt,
|
||||
measurement: BluetoothGattCharacteristic,
|
||||
) {
|
||||
if (!callbackGatt.setCharacteristicNotification(measurement, true)) {
|
||||
update {
|
||||
it.copy(
|
||||
phase = HeartRateDiagnosticPhase.ERROR,
|
||||
diagnostic = "notification_enable_failed",
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
val descriptor = measurement.getDescriptor(CLIENT_CHARACTERISTIC_CONFIG_UUID)
|
||||
if (descriptor == null) {
|
||||
update {
|
||||
it.copy(
|
||||
phase = HeartRateDiagnosticPhase.ERROR,
|
||||
diagnostic = "ccc_descriptor_missing",
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
update {
|
||||
it.copy(
|
||||
phase = HeartRateDiagnosticPhase.SUBSCRIBING,
|
||||
diagnostic = "enabling_notifications",
|
||||
)
|
||||
}
|
||||
val started =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
callbackGatt.writeDescriptor(
|
||||
descriptor,
|
||||
BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE,
|
||||
) == BluetoothStatusCodes.SUCCESS
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
run {
|
||||
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||
callbackGatt.writeDescriptor(descriptor)
|
||||
}
|
||||
}
|
||||
if (!started) {
|
||||
update {
|
||||
it.copy(
|
||||
phase = HeartRateDiagnosticPhase.ERROR,
|
||||
diagnostic = "descriptor_write_start_failed",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMeasurement(uuid: UUID, value: ByteArray) {
|
||||
if (uuid != HEART_RATE_MEASUREMENT_UUID) return
|
||||
when (val parsed = HeartRateMeasurementParser.parse(value)) {
|
||||
is HeartRateMeasurementParseResult.Invalid ->
|
||||
update {
|
||||
it.copy(
|
||||
phase = HeartRateDiagnosticPhase.ERROR,
|
||||
diagnostic = "measurement:" + parsed.reason,
|
||||
)
|
||||
}
|
||||
is HeartRateMeasurementParseResult.Parsed ->
|
||||
update {
|
||||
it.copy(
|
||||
phase = HeartRateDiagnosticPhase.RECEIVING,
|
||||
notificationCount = it.notificationCount + 1,
|
||||
latestMeasurement = parsed.measurement,
|
||||
diagnostic = "measurement",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun stopScan() {
|
||||
val callback = scannerCallback ?: return
|
||||
scanStop?.let(main::removeCallbacks)
|
||||
scanStop = null
|
||||
runCatching { adapter?.bluetoothLeScanner?.stopScan(callback) }
|
||||
scannerCallback = null
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun closeGatt() {
|
||||
val current = gatt
|
||||
gatt = null
|
||||
runCatching { current?.disconnect() }
|
||||
runCatching { current?.close() }
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
if (closed) return
|
||||
stopScan()
|
||||
closeGatt()
|
||||
closed = true
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SCAN_DURATION_MS = 10_000L
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import java.util.concurrent.CopyOnWriteArraySet
|
||||
|
||||
internal enum class HeartRateLivePhase {
|
||||
DISCONNECTED,
|
||||
CONNECTING,
|
||||
CONNECTED,
|
||||
STALE,
|
||||
}
|
||||
|
||||
internal data class HeartRateLiveSnapshot(
|
||||
val phase: HeartRateLivePhase = HeartRateLivePhase.DISCONNECTED,
|
||||
val bpm: Int? = null,
|
||||
val rrIntervals1024: List<Int> = emptyList(),
|
||||
val sensorContactDetected: Boolean? = null,
|
||||
val energyExpended: Int? = null,
|
||||
val notificationCount: Long = 0,
|
||||
)
|
||||
|
||||
internal object HeartRateLiveState {
|
||||
private val listeners =
|
||||
CopyOnWriteArraySet<(HeartRateLiveSnapshot) -> Unit>()
|
||||
|
||||
@Volatile
|
||||
private var snapshot = HeartRateLiveSnapshot()
|
||||
|
||||
fun current(): HeartRateLiveSnapshot = snapshot
|
||||
|
||||
fun publish(next: HeartRateLiveSnapshot) {
|
||||
snapshot = next
|
||||
listeners.forEach { it(next) }
|
||||
}
|
||||
|
||||
fun subscribe(listener: (HeartRateLiveSnapshot) -> Unit): AutoCloseable {
|
||||
listeners += listener
|
||||
listener(snapshot)
|
||||
return AutoCloseable { listeners -= listener }
|
||||
}
|
||||
|
||||
fun disconnected() {
|
||||
publish(HeartRateLiveSnapshot(phase = HeartRateLivePhase.DISCONNECTED))
|
||||
}
|
||||
|
||||
fun connecting() {
|
||||
publish(
|
||||
snapshot.copy(
|
||||
phase = HeartRateLivePhase.CONNECTING,
|
||||
bpm = null,
|
||||
rrIntervals1024 = emptyList(),
|
||||
sensorContactDetected = null,
|
||||
energyExpended = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun measurement(measurement: ParsedHeartRateMeasurement) {
|
||||
publish(
|
||||
HeartRateLiveSnapshot(
|
||||
phase = HeartRateLivePhase.CONNECTED,
|
||||
bpm = measurement.bpm,
|
||||
rrIntervals1024 = measurement.rrIntervals1024,
|
||||
sensorContactDetected = measurement.sensorContactDetected,
|
||||
energyExpended = measurement.energyExpended,
|
||||
notificationCount = snapshot.notificationCount + 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun stale() {
|
||||
if (snapshot.phase == HeartRateLivePhase.CONNECTED) {
|
||||
publish(
|
||||
snapshot.copy(
|
||||
phase = HeartRateLivePhase.STALE,
|
||||
bpm = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
/*
|
||||
* Bluetooth Heart Rate Measurement parser.
|
||||
*
|
||||
* This file deliberately has no Android Bluetooth dependency so protocol
|
||||
* decoding can be exhaustively unit-tested independently of GATT transport.
|
||||
*/
|
||||
package com.labfytools.trainlog.data
|
||||
|
||||
internal data class ParsedHeartRateMeasurement(
|
||||
val bpm: Int,
|
||||
val sensorContactDetected: Boolean?,
|
||||
val energyExpended: Int?,
|
||||
/** Raw Bluetooth Heart Rate Service units: 1/1024 second. */
|
||||
val rrIntervals1024: List<Int>,
|
||||
)
|
||||
|
||||
internal sealed interface HeartRateMeasurementParseResult {
|
||||
data class Parsed(val measurement: ParsedHeartRateMeasurement) :
|
||||
HeartRateMeasurementParseResult
|
||||
data class Invalid(val reason: String) : HeartRateMeasurementParseResult
|
||||
}
|
||||
|
||||
internal object HeartRateMeasurementParser {
|
||||
private const val FLAG_HEART_RATE_16_BIT = 1 shl 0
|
||||
private const val FLAG_CONTACT_DETECTED = 1 shl 1
|
||||
private const val FLAG_CONTACT_SUPPORTED = 1 shl 2
|
||||
private const val FLAG_ENERGY_PRESENT = 1 shl 3
|
||||
private const val FLAG_RR_PRESENT = 1 shl 4
|
||||
private const val KNOWN_FLAGS =
|
||||
FLAG_HEART_RATE_16_BIT or FLAG_CONTACT_DETECTED or FLAG_CONTACT_SUPPORTED or
|
||||
FLAG_ENERGY_PRESENT or FLAG_RR_PRESENT
|
||||
|
||||
fun parse(payload: ByteArray): HeartRateMeasurementParseResult {
|
||||
if (payload.isEmpty()) return HeartRateMeasurementParseResult.Invalid("missing flags")
|
||||
val flags = payload[0].toInt() and 0xff
|
||||
if ((flags and KNOWN_FLAGS.inv()) != 0) {
|
||||
return HeartRateMeasurementParseResult.Invalid("reserved flags are set")
|
||||
}
|
||||
|
||||
var offset = 1
|
||||
fun uint8(): Int? =
|
||||
payload.getOrNull(offset)?.let {
|
||||
offset += 1
|
||||
it.toInt() and 0xff
|
||||
}
|
||||
fun uint16(): Int? {
|
||||
if (offset + 2 > payload.size) return null
|
||||
val value =
|
||||
(payload[offset].toInt() and 0xff) or
|
||||
((payload[offset + 1].toInt() and 0xff) shl 8)
|
||||
offset += 2
|
||||
return value
|
||||
}
|
||||
|
||||
val bpm =
|
||||
if ((flags and FLAG_HEART_RATE_16_BIT) != 0) {
|
||||
uint16() ?: return HeartRateMeasurementParseResult.Invalid("truncated 16-bit BPM")
|
||||
} else {
|
||||
uint8() ?: return HeartRateMeasurementParseResult.Invalid("missing BPM")
|
||||
}
|
||||
|
||||
val contact =
|
||||
if ((flags and FLAG_CONTACT_SUPPORTED) == 0) {
|
||||
null
|
||||
} else {
|
||||
(flags and FLAG_CONTACT_DETECTED) != 0
|
||||
}
|
||||
|
||||
val energy =
|
||||
if ((flags and FLAG_ENERGY_PRESENT) != 0) {
|
||||
uint16()
|
||||
?: return HeartRateMeasurementParseResult.Invalid(
|
||||
"truncated energy-expended value",
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val rr = mutableListOf<Int>()
|
||||
if ((flags and FLAG_RR_PRESENT) != 0) {
|
||||
if (offset == payload.size) {
|
||||
return HeartRateMeasurementParseResult.Invalid("RR flag has no interval")
|
||||
}
|
||||
if ((payload.size - offset) % 2 != 0) {
|
||||
return HeartRateMeasurementParseResult.Invalid("truncated RR interval")
|
||||
}
|
||||
while (offset < payload.size) {
|
||||
rr += checkNotNull(uint16())
|
||||
}
|
||||
} else if (offset != payload.size) {
|
||||
return HeartRateMeasurementParseResult.Invalid("unexpected trailing bytes")
|
||||
}
|
||||
|
||||
return HeartRateMeasurementParseResult.Parsed(
|
||||
ParsedHeartRateMeasurement(
|
||||
bpm = bpm,
|
||||
sensorContactDetected = contact,
|
||||
energyExpended = energy,
|
||||
rrIntervals1024 = rr,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.content.Context
|
||||
|
||||
internal data class SelectedHeartRateSensor(
|
||||
val address: String,
|
||||
val name: String?,
|
||||
)
|
||||
|
||||
internal class HeartRateSensorPreferences(context: Context) {
|
||||
private val preferences =
|
||||
context.applicationContext.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||
|
||||
fun selected(): SelectedHeartRateSensor? {
|
||||
if (!preferences.getBoolean(ENABLED, false)) return null
|
||||
val address = preferences.getString(ADDRESS, null)?.takeIf { it.isNotBlank() } ?: return null
|
||||
return SelectedHeartRateSensor(
|
||||
address = address,
|
||||
name = preferences.getString(NAME, null)?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
}
|
||||
|
||||
fun select(address: String, name: String?) {
|
||||
require(address.isNotBlank())
|
||||
preferences.edit()
|
||||
.putBoolean(ENABLED, true)
|
||||
.putString(ADDRESS, address)
|
||||
.putString(NAME, name?.takeIf { it.isNotBlank() })
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
preferences.edit()
|
||||
.putBoolean(ENABLED, false)
|
||||
.remove(ADDRESS)
|
||||
.remove(NAME)
|
||||
.apply()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREFERENCES = "trainlog-heart-rate-sensor"
|
||||
private const val ENABLED = "enabled"
|
||||
private const val ADDRESS = "address"
|
||||
private const val NAME = "name"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,561 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGattCallback
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.bluetooth.BluetoothGattDescriptor
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.bluetooth.BluetoothStatusCodes
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.labfytools.trainlog.R
|
||||
import com.labfytools.trainlog.model.CardioGuidanceInstruction
|
||||
import java.time.Duration
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
class HeartRateSensorService : Service() {
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
private val preferences by lazy { HeartRateSensorPreferences(applicationContext) }
|
||||
private val bluetoothManager by lazy {
|
||||
applicationContext.getSystemService(BluetoothManager::class.java)
|
||||
}
|
||||
|
||||
private var selected: SelectedHeartRateSensor? = null
|
||||
private var gatt: BluetoothGatt? = null
|
||||
private var reconnectAttempt = 0
|
||||
private var lastMeasurementElapsed = 0L
|
||||
private var lastNotificationBpm: Int? = null
|
||||
private var repository: TrainlogRepository? = null
|
||||
private var guidancePhaseId: String? = null
|
||||
private var guidanceEngine: CardioGuidanceEngine? = null
|
||||
|
||||
private fun repository(): TrainlogRepository =
|
||||
repository ?: TrainlogRepository(applicationContext).also { repository = it }
|
||||
|
||||
private val reconnectRunnable = Runnable { connectSelected() }
|
||||
private val connectionTimeoutRunnable =
|
||||
Runnable {
|
||||
if (HeartRateLiveState.current().phase == HeartRateLivePhase.CONNECTING) {
|
||||
closeGatt()
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
private val staleRunnable =
|
||||
object : Runnable {
|
||||
override fun run() {
|
||||
val last = lastMeasurementElapsed
|
||||
if (last > 0L) {
|
||||
val age = SystemClock.elapsedRealtime() - last
|
||||
if (
|
||||
age >= STALE_AFTER_MS &&
|
||||
HeartRateLiveState.current().phase == HeartRateLivePhase.CONNECTED
|
||||
) {
|
||||
HeartRateLiveState.stale()
|
||||
suspendCardioGuidance()
|
||||
updateForegroundNotification()
|
||||
}
|
||||
if (age >= STALE_RECONNECT_AFTER_MS && gatt != null) {
|
||||
lastMeasurementElapsed = 0L
|
||||
closeGatt()
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
main.postDelayed(this, STALE_CHECK_MS)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createChannel()
|
||||
promoteForeground()
|
||||
main.post(staleRunnable)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent?.action == ACTION_STOP) {
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
selected = preferences.selected()
|
||||
if (selected == null || !hasHeartRateBleRuntimePermissions(this)) {
|
||||
HeartRateLiveState.disconnected()
|
||||
updateForegroundNotification()
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
if (bluetoothManager.adapter?.isEnabled != true) {
|
||||
scheduleReconnect()
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
promoteForeground()
|
||||
if (gatt == null) connectSelected()
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
@Suppress("DEPRECATION")
|
||||
private fun connectSelected() {
|
||||
main.removeCallbacks(reconnectRunnable)
|
||||
main.removeCallbacks(connectionTimeoutRunnable)
|
||||
|
||||
val sensor = preferences.selected()
|
||||
selected = sensor
|
||||
val adapter = bluetoothManager.adapter
|
||||
if (sensor == null || !hasHeartRateBleRuntimePermissions(this)) {
|
||||
HeartRateLiveState.disconnected()
|
||||
updateForegroundNotification()
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
if (adapter?.isEnabled != true) {
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
|
||||
closeGatt()
|
||||
lastMeasurementElapsed = 0L
|
||||
HeartRateLiveState.connecting()
|
||||
updateForegroundNotification()
|
||||
|
||||
val device =
|
||||
runCatching { adapter.getRemoteDevice(sensor.address) }.getOrNull()
|
||||
?: run {
|
||||
HeartRateLiveState.disconnected()
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
|
||||
runCatching {
|
||||
device.connectGatt(
|
||||
applicationContext,
|
||||
false,
|
||||
gattCallback,
|
||||
BluetoothDevice.TRANSPORT_LE,
|
||||
)
|
||||
}
|
||||
.onSuccess {
|
||||
gatt = it
|
||||
main.postDelayed(connectionTimeoutRunnable, CONNECTION_TIMEOUT_MS)
|
||||
}
|
||||
.onFailure {
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private val gattCallback =
|
||||
object : BluetoothGattCallback() {
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun onConnectionStateChange(
|
||||
callbackGatt: BluetoothGatt,
|
||||
status: Int,
|
||||
newState: Int,
|
||||
) {
|
||||
main.post {
|
||||
if (callbackGatt !== gatt) return@post
|
||||
if (
|
||||
status == BluetoothGatt.GATT_SUCCESS &&
|
||||
newState == BluetoothProfile.STATE_CONNECTED
|
||||
) {
|
||||
reconnectAttempt = 0
|
||||
main.removeCallbacks(connectionTimeoutRunnable)
|
||||
HeartRateLiveState.connecting()
|
||||
updateForegroundNotification()
|
||||
if (!callbackGatt.discoverServices()) {
|
||||
failConnection()
|
||||
} else {
|
||||
main.postDelayed(connectionTimeoutRunnable, CONNECTION_TIMEOUT_MS)
|
||||
}
|
||||
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
|
||||
failConnection()
|
||||
} else if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
failConnection()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun onServicesDiscovered(callbackGatt: BluetoothGatt, status: Int) {
|
||||
main.post {
|
||||
if (callbackGatt !== gatt) return@post
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
failConnection()
|
||||
return@post
|
||||
}
|
||||
val measurement =
|
||||
callbackGatt
|
||||
.getService(HEART_RATE_SERVICE_UUID)
|
||||
?.getCharacteristic(HEART_RATE_MEASUREMENT_UUID)
|
||||
if (measurement == null) {
|
||||
failConnection()
|
||||
return@post
|
||||
}
|
||||
main.removeCallbacks(connectionTimeoutRunnable)
|
||||
if (!subscribe(callbackGatt, measurement)) {
|
||||
failConnection()
|
||||
} else {
|
||||
main.postDelayed(connectionTimeoutRunnable, CONNECTION_TIMEOUT_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDescriptorWrite(
|
||||
callbackGatt: BluetoothGatt,
|
||||
descriptor: BluetoothGattDescriptor,
|
||||
status: Int,
|
||||
) {
|
||||
if (descriptor.uuid != CLIENT_CHARACTERISTIC_CONFIG_UUID) return
|
||||
main.post {
|
||||
if (callbackGatt !== gatt) return@post
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
failConnection()
|
||||
} else {
|
||||
main.removeCallbacks(connectionTimeoutRunnable)
|
||||
HeartRateLiveState.connecting()
|
||||
updateForegroundNotification()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated by Android API 33 callback with explicit value")
|
||||
override fun onCharacteristicChanged(
|
||||
callbackGatt: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
) {
|
||||
@Suppress("DEPRECATION")
|
||||
val value = characteristic.value ?: return
|
||||
onHeartRateNotification(callbackGatt, characteristic.uuid, value)
|
||||
}
|
||||
|
||||
override fun onCharacteristicChanged(
|
||||
callbackGatt: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
value: ByteArray,
|
||||
) {
|
||||
onHeartRateNotification(callbackGatt, characteristic.uuid, value)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun subscribe(
|
||||
callbackGatt: BluetoothGatt,
|
||||
measurement: BluetoothGattCharacteristic,
|
||||
): Boolean {
|
||||
if (!callbackGatt.setCharacteristicNotification(measurement, true)) return false
|
||||
val descriptor = measurement.getDescriptor(CLIENT_CHARACTERISTIC_CONFIG_UUID) ?: return false
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
callbackGatt.writeDescriptor(
|
||||
descriptor,
|
||||
BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE,
|
||||
) == BluetoothStatusCodes.SUCCESS
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
run {
|
||||
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||
callbackGatt.writeDescriptor(descriptor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onHeartRateNotification(
|
||||
callbackGatt: BluetoothGatt,
|
||||
uuid: java.util.UUID,
|
||||
value: ByteArray,
|
||||
) {
|
||||
if (callbackGatt !== gatt || uuid != HEART_RATE_MEASUREMENT_UUID) return
|
||||
val parsed = HeartRateMeasurementParser.parse(value)
|
||||
if (parsed !is HeartRateMeasurementParseResult.Parsed) return
|
||||
lastMeasurementElapsed = SystemClock.elapsedRealtime()
|
||||
HeartRateLiveState.measurement(parsed.measurement)
|
||||
val observedAt = OffsetDateTime.now().toString()
|
||||
val repository = repository()
|
||||
val sessionRecorded =
|
||||
repository.recordLiveHeartRateForActiveSession(
|
||||
sensorName = selected?.name,
|
||||
observedAt = observedAt,
|
||||
measurement = parsed.measurement,
|
||||
)
|
||||
if (sessionRecorded != null) {
|
||||
processCardioGuidance(repository, observedAt, parsed.measurement.bpm)
|
||||
} else {
|
||||
repository.recordLiveHeartRateForActiveSleep(
|
||||
sensorName = selected?.name,
|
||||
observedAt = observedAt,
|
||||
measurement = parsed.measurement,
|
||||
)
|
||||
clearCardioGuidanceEngine()
|
||||
}
|
||||
if (lastNotificationBpm != parsed.measurement.bpm) {
|
||||
lastNotificationBpm = parsed.measurement.bpm
|
||||
updateForegroundNotification()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun processCardioGuidance(
|
||||
repository: TrainlogRepository,
|
||||
observedAt: String,
|
||||
bpm: Int,
|
||||
) {
|
||||
val phase = repository.activeCardioGuidancePhase()
|
||||
if (phase == null) {
|
||||
clearCardioGuidanceEngine()
|
||||
return
|
||||
}
|
||||
if (guidancePhaseId != phase.phase.phaseId || guidanceEngine == null) {
|
||||
guidancePhaseId = phase.phase.phaseId
|
||||
guidanceEngine =
|
||||
CardioGuidanceEngine(initialInstruction = phase.currentInstruction)
|
||||
}
|
||||
val engine = checkNotNull(guidanceEngine)
|
||||
val output =
|
||||
engine.evaluate(
|
||||
target = phase.phase.target,
|
||||
bpm = bpm,
|
||||
fresh = true,
|
||||
nowMs = SystemClock.elapsedRealtime(),
|
||||
)
|
||||
repository.recordCardioGuidanceOutput(
|
||||
phase.phase.phaseId,
|
||||
observedAt,
|
||||
output,
|
||||
)
|
||||
CardioGuidanceLiveState.publish(
|
||||
CardioGuidanceLiveSnapshot(
|
||||
phaseId = phase.phase.phaseId,
|
||||
phaseKind = phase.phase.kind,
|
||||
instruction = output.instruction,
|
||||
bpm = output.bpm,
|
||||
target = phase.phase.target,
|
||||
startedAt = phase.startedAt,
|
||||
),
|
||||
)
|
||||
maybeFinishCardioGuidancePhase(repository, phase, observedAt, bpm, fresh = true)
|
||||
}
|
||||
|
||||
private fun suspendCardioGuidance() {
|
||||
val repository = repository ?: return
|
||||
val phase = repository.activeCardioGuidancePhase() ?: run {
|
||||
clearCardioGuidanceEngine()
|
||||
return
|
||||
}
|
||||
if (guidancePhaseId != phase.phase.phaseId || guidanceEngine == null) {
|
||||
guidancePhaseId = phase.phase.phaseId
|
||||
guidanceEngine =
|
||||
CardioGuidanceEngine(initialInstruction = phase.currentInstruction)
|
||||
}
|
||||
val output =
|
||||
checkNotNull(guidanceEngine).evaluate(
|
||||
target = phase.phase.target,
|
||||
bpm = null,
|
||||
fresh = false,
|
||||
nowMs = SystemClock.elapsedRealtime(),
|
||||
)
|
||||
val observedAt = OffsetDateTime.now().toString()
|
||||
repository.recordCardioGuidanceOutput(
|
||||
phase.phase.phaseId,
|
||||
observedAt,
|
||||
output,
|
||||
)
|
||||
CardioGuidanceLiveState.publish(
|
||||
CardioGuidanceLiveSnapshot(
|
||||
phaseId = phase.phase.phaseId,
|
||||
phaseKind = phase.phase.kind,
|
||||
instruction = CardioGuidanceInstruction.SUSPENDED,
|
||||
bpm = null,
|
||||
target = phase.phase.target,
|
||||
startedAt = phase.startedAt,
|
||||
),
|
||||
)
|
||||
maybeFinishCardioGuidancePhase(repository, phase, observedAt, null, fresh = false)
|
||||
}
|
||||
|
||||
private fun maybeFinishCardioGuidancePhase(
|
||||
repository: TrainlogRepository,
|
||||
phase: com.labfytools.trainlog.model.CardioGuidancePhaseRecord,
|
||||
observedAt: String,
|
||||
bpm: Int?,
|
||||
fresh: Boolean,
|
||||
) {
|
||||
val elapsedSeconds =
|
||||
runCatching {
|
||||
Duration.between(
|
||||
OffsetDateTime.parse(phase.startedAt),
|
||||
OffsetDateTime.parse(observedAt),
|
||||
).seconds
|
||||
}
|
||||
.getOrNull()
|
||||
?.coerceIn(0L, Int.MAX_VALUE.toLong())
|
||||
?.toInt()
|
||||
?: return
|
||||
if (
|
||||
CardioPhaseCompletion.isComplete(
|
||||
phase.phase,
|
||||
elapsedSeconds,
|
||||
bpm,
|
||||
fresh,
|
||||
)
|
||||
) {
|
||||
repository.finishCardioGuidancePhase(phase.phase.phaseId, observedAt)
|
||||
clearCardioGuidanceEngine()
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearCardioGuidanceEngine() {
|
||||
guidancePhaseId = null
|
||||
guidanceEngine = null
|
||||
CardioGuidanceLiveState.clear()
|
||||
}
|
||||
|
||||
private fun failConnection() {
|
||||
main.removeCallbacks(connectionTimeoutRunnable)
|
||||
closeGatt()
|
||||
lastMeasurementElapsed = 0L
|
||||
lastNotificationBpm = null
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
private fun scheduleReconnect() {
|
||||
if (preferences.selected() == null) {
|
||||
HeartRateLiveState.disconnected()
|
||||
updateForegroundNotification()
|
||||
return
|
||||
}
|
||||
HeartRateLiveState.connecting()
|
||||
updateForegroundNotification()
|
||||
reconnectAttempt = (reconnectAttempt + 1).coerceAtMost(6)
|
||||
val delay =
|
||||
(RECONNECT_BASE_MS * reconnectAttempt.toLong()).coerceAtMost(RECONNECT_MAX_MS)
|
||||
main.removeCallbacks(reconnectRunnable)
|
||||
main.postDelayed(reconnectRunnable, delay)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun closeGatt() {
|
||||
val current = gatt
|
||||
gatt = null
|
||||
runCatching { current?.disconnect() }
|
||||
runCatching { current?.close() }
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
main.removeCallbacks(reconnectRunnable)
|
||||
main.removeCallbacks(connectionTimeoutRunnable)
|
||||
main.removeCallbacks(staleRunnable)
|
||||
closeGatt()
|
||||
repository?.close()
|
||||
repository = null
|
||||
HeartRateLiveState.disconnected()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
private fun createChannel() {
|
||||
getSystemService(NotificationManager::class.java)
|
||||
.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL,
|
||||
getString(R.string.heart_rate_service_channel),
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun promoteForeground() {
|
||||
val notification = buildNotification()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(
|
||||
NOTIFICATION_ID,
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE,
|
||||
)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateForegroundNotification() {
|
||||
runCatching {
|
||||
getSystemService(NotificationManager::class.java)
|
||||
.notify(NOTIFICATION_ID, buildNotification())
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildNotification(): android.app.Notification {
|
||||
val live = HeartRateLiveState.current()
|
||||
val text =
|
||||
when (live.phase) {
|
||||
HeartRateLivePhase.CONNECTED ->
|
||||
live.bpm?.let { getString(R.string.heart_rate_service_bpm, it) }
|
||||
?: getString(R.string.heart_rate_service_connecting)
|
||||
HeartRateLivePhase.CONNECTING ->
|
||||
getString(R.string.heart_rate_service_connecting)
|
||||
HeartRateLivePhase.STALE ->
|
||||
getString(R.string.heart_rate_service_reconnecting)
|
||||
HeartRateLivePhase.DISCONNECTED ->
|
||||
getString(R.string.heart_rate_service_disconnected)
|
||||
}
|
||||
return NotificationCompat.Builder(this, CHANNEL)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(getString(R.string.heart_rate_service_title))
|
||||
.setContentText(text)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL = "trainlog_heart_rate"
|
||||
private const val NOTIFICATION_ID = 1402
|
||||
private const val ACTION_START = "com.labfytools.trainlog.heart_rate.START"
|
||||
private const val ACTION_STOP = "com.labfytools.trainlog.heart_rate.STOP"
|
||||
private const val CONNECTION_TIMEOUT_MS = 12_000L
|
||||
private const val STALE_AFTER_MS = 3_000L
|
||||
private const val STALE_RECONNECT_AFTER_MS = 8_000L
|
||||
private const val STALE_CHECK_MS = 1_000L
|
||||
private const val RECONNECT_BASE_MS = 2_000L
|
||||
private const val RECONNECT_MAX_MS = 15_000L
|
||||
|
||||
fun start(context: Context) {
|
||||
if (
|
||||
HeartRateSensorPreferences(context).selected() == null ||
|
||||
!hasHeartRateBleRuntimePermissions(context)
|
||||
) {
|
||||
HeartRateLiveState.disconnected()
|
||||
return
|
||||
}
|
||||
ContextCompat.startForegroundService(
|
||||
context,
|
||||
Intent(context, HeartRateSensorService::class.java).setAction(ACTION_START),
|
||||
)
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
context.startService(
|
||||
Intent(context, HeartRateSensorService::class.java).setAction(ACTION_STOP),
|
||||
)
|
||||
}
|
||||
|
||||
fun forget(context: Context) {
|
||||
HeartRateSensorPreferences(context).clear()
|
||||
stop(context)
|
||||
HeartRateLiveState.disconnected()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,37 @@
|
|||
package com.labfytools.trainlog.model
|
||||
|
||||
enum class CardioCalibrationPhase(val wireValue: String) {
|
||||
WARMUP("warmup"),
|
||||
PROGRESSIVE("progressive"),
|
||||
HIGH_EFFORT("high_effort"),
|
||||
RECOVERY("recovery"),
|
||||
COMPLETED("completed"),
|
||||
ABORTED("aborted");
|
||||
|
||||
companion object {
|
||||
fun fromWire(value: String): CardioCalibrationPhase =
|
||||
entries.firstOrNull { it.wireValue == value }
|
||||
?: error("Unknown cardio calibration phase: $value")
|
||||
}
|
||||
}
|
||||
|
||||
data class CardioCalibrationRecoveryPoint(
|
||||
val targetOffsetSeconds: Int,
|
||||
val observedAt: String,
|
||||
val bpm: Int,
|
||||
)
|
||||
|
||||
data class CardioCalibrationProfile(
|
||||
val calibrationId: String,
|
||||
val protocolVersion: Int,
|
||||
val sessionId: String,
|
||||
val entryId: String,
|
||||
val phase: CardioCalibrationPhase,
|
||||
val startedAt: String,
|
||||
val phaseStartedAt: String,
|
||||
val effortEndAt: String?,
|
||||
val endedAt: String?,
|
||||
val heartRateCaptureId: String?,
|
||||
val observedPeakBpm: Int?,
|
||||
val recovery: List<CardioCalibrationRecoveryPoint>,
|
||||
)
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
package com.labfytools.trainlog.model
|
||||
|
||||
enum class CardioPhaseKind(val wireValue: String) {
|
||||
WARMUP("warmup"),
|
||||
WORK("work"),
|
||||
RECOVERY("recovery"),
|
||||
COOLDOWN("cooldown"),
|
||||
}
|
||||
|
||||
sealed interface CardioTargetDefinition {
|
||||
data class AbsoluteBpm(
|
||||
val minimumBpm: Int,
|
||||
val maximumBpm: Int,
|
||||
) : CardioTargetDefinition
|
||||
|
||||
data class CalibrationPercent(
|
||||
val calibrationId: String,
|
||||
val observedPeakBpm: Int,
|
||||
val minimumPercent: Int,
|
||||
val maximumPercent: Int,
|
||||
) : CardioTargetDefinition
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutable target actually used by one phase.
|
||||
*
|
||||
* The calibration identity and percentages are provenance only. Runtime
|
||||
* guidance consumes the resolved BPM bounds so later calibration changes
|
||||
* cannot rewrite an already-started session.
|
||||
*/
|
||||
data class CardioTargetSnapshot(
|
||||
val minimumBpm: Int,
|
||||
val maximumBpm: Int,
|
||||
val calibrationId: String? = null,
|
||||
val calibrationObservedPeakBpm: Int? = null,
|
||||
val minimumPercent: Int? = null,
|
||||
val maximumPercent: Int? = null,
|
||||
)
|
||||
|
||||
sealed interface CardioPhaseExitCondition {
|
||||
data class FixedDuration(val seconds: Int) : CardioPhaseExitCondition
|
||||
data object EnterTarget : CardioPhaseExitCondition
|
||||
data class RecoverBelow(val bpm: Int) : CardioPhaseExitCondition
|
||||
|
||||
data class DurationOrRecoverBelow(
|
||||
val maximumSeconds: Int,
|
||||
val bpm: Int,
|
||||
) : CardioPhaseExitCondition
|
||||
}
|
||||
|
||||
data class CardioGuidedPhase(
|
||||
val phaseId: String,
|
||||
val kind: CardioPhaseKind,
|
||||
val target: CardioTargetSnapshot?,
|
||||
val exitCondition: CardioPhaseExitCondition,
|
||||
)
|
||||
|
||||
enum class CardioGuidanceInstruction {
|
||||
ACCELERATE,
|
||||
MAINTAIN,
|
||||
SLOW_DOWN,
|
||||
SUSPENDED,
|
||||
}
|
||||
|
||||
data class CardioGuidanceOutput(
|
||||
val instruction: CardioGuidanceInstruction,
|
||||
val bpm: Int?,
|
||||
val target: CardioTargetSnapshot?,
|
||||
val pendingInstruction: CardioGuidanceInstruction? = null,
|
||||
)
|
||||
|
||||
data class CardioGuidancePhaseRecord(
|
||||
val runId: String,
|
||||
val sessionId: String,
|
||||
val entryId: String,
|
||||
val phase: CardioGuidedPhase,
|
||||
val position: Int,
|
||||
val startedAt: String,
|
||||
val endedAt: String?,
|
||||
val currentInstruction: CardioGuidanceInstruction,
|
||||
)
|
||||
|
||||
data class CardioGuidanceEvent(
|
||||
val sequence: Long,
|
||||
val observedAt: String,
|
||||
val instruction: CardioGuidanceInstruction,
|
||||
val bpm: Int?,
|
||||
val targetMinimumBpm: Int?,
|
||||
val targetMaximumBpm: Int?,
|
||||
)
|
||||
|
||||
data class CardioGuidanceRun(
|
||||
val runId: String,
|
||||
val sessionId: String,
|
||||
val startedAt: String,
|
||||
val endedAt: String?,
|
||||
val phases: List<CardioGuidancePhaseRecord>,
|
||||
)
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* Android HeartRateModels model.
|
||||
*
|
||||
* Android owns sensor acquisition. Desktop/Web only consume synchronized
|
||||
* measured facts and never connect to the heart-rate sensor.
|
||||
*/
|
||||
package com.labfytools.trainlog.model
|
||||
|
||||
enum class HeartRateContextKind(val wireValue: String) {
|
||||
SESSION("session"),
|
||||
CARDIO("cardio"),
|
||||
SLEEP("sleep");
|
||||
|
||||
companion object {
|
||||
fun fromWire(value: String): HeartRateContextKind =
|
||||
entries.firstOrNull { it.wireValue == value }
|
||||
?: error("Unknown heart-rate context: $value")
|
||||
}
|
||||
}
|
||||
|
||||
data class HeartRateRrInterval(
|
||||
val index: Int,
|
||||
/** Raw Bluetooth Heart Rate Service unit: 1/1024 second. */
|
||||
val value1024: Int,
|
||||
)
|
||||
|
||||
data class HeartRateSample(
|
||||
val sequence: Long,
|
||||
val observedAt: String,
|
||||
val bpm: Int,
|
||||
val exerciseEntryId: String? = null,
|
||||
val sensorContactDetected: Boolean? = null,
|
||||
val energyExpended: Int? = null,
|
||||
val rrIntervals: List<HeartRateRrInterval> = emptyList(),
|
||||
)
|
||||
|
||||
data class HeartRateCapture(
|
||||
val captureId: String,
|
||||
val contextKind: HeartRateContextKind,
|
||||
val contextId: String,
|
||||
val startedAt: String,
|
||||
val endedAt: String?,
|
||||
val activeExerciseEntryId: String?,
|
||||
val sensorName: String?,
|
||||
val samples: List<HeartRateSample> = emptyList(),
|
||||
)
|
||||
|
|
@ -9,17 +9,14 @@ enum class SessionType(
|
|||
val wireValue: String,
|
||||
) {
|
||||
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. */
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
@ -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() } },
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,172 @@
|
|||
package com.labfytools.trainlog.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.labfytools.trainlog.R
|
||||
import com.labfytools.trainlog.data.TrainlogRepository
|
||||
import com.labfytools.trainlog.model.CardioCalibrationPhase
|
||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||
|
||||
@Composable
|
||||
fun CardioCalibrationScreen(
|
||||
repository: TrainlogRepository,
|
||||
onChanged: () -> Unit,
|
||||
) {
|
||||
val strings = localizedContext()
|
||||
val colors = LocalTrainlogColors.current
|
||||
var revision by remember { mutableIntStateOf(0) }
|
||||
var message by remember { mutableStateOf<String?>(null) }
|
||||
val active = remember(revision) { repository.activeCardioCalibration() }
|
||||
val latest = remember(revision) {
|
||||
repository.listCompletedCardioCalibrations(1).firstOrNull()
|
||||
}
|
||||
|
||||
fun apply(result: TrainlogRepository.CardioCalibrationResult) {
|
||||
message =
|
||||
when (result) {
|
||||
is TrainlogRepository.CardioCalibrationResult.Applied -> {
|
||||
revision++
|
||||
onChanged()
|
||||
null
|
||||
}
|
||||
is TrainlogRepository.CardioCalibrationResult.Invalid -> result.message
|
||||
TrainlogRepository.CardioCalibrationResult.Conflict ->
|
||||
strings.getString(R.string.calibration_conflict)
|
||||
is TrainlogRepository.CardioCalibrationResult.DatabaseError ->
|
||||
result.message
|
||||
}
|
||||
}
|
||||
|
||||
fun phaseLabel(phase: CardioCalibrationPhase): String =
|
||||
strings.getString(
|
||||
when (phase) {
|
||||
CardioCalibrationPhase.WARMUP -> R.string.calibration_phase_warmup
|
||||
CardioCalibrationPhase.PROGRESSIVE -> R.string.calibration_phase_progressive
|
||||
CardioCalibrationPhase.HIGH_EFFORT -> R.string.calibration_phase_high_effort
|
||||
CardioCalibrationPhase.RECOVERY -> R.string.calibration_phase_recovery
|
||||
CardioCalibrationPhase.COMPLETED -> R.string.calibration_phase_completed
|
||||
CardioCalibrationPhase.ABORTED -> R.string.calibration_phase_aborted
|
||||
},
|
||||
)
|
||||
|
||||
TrainlogScreen(
|
||||
strings.getString(R.string.route_cardio_calibration),
|
||||
scrollKey = "cardio-calibration",
|
||||
) {
|
||||
TrainlogInfo(strings.getString(R.string.calibration_explanation), colors.muted)
|
||||
|
||||
if (active == null) {
|
||||
TrainlogPrimaryAction(
|
||||
strings.getString(R.string.calibration_start),
|
||||
strings.getString(R.string.calibration_start_description),
|
||||
) {
|
||||
apply(repository.startCardioCalibration())
|
||||
}
|
||||
latest?.let { profile ->
|
||||
TrainlogFrame(strings.getString(R.string.calibration_latest)) {
|
||||
TrainlogInfo(
|
||||
strings.getString(
|
||||
R.string.calibration_peak_value,
|
||||
profile.observedPeakBpm ?: 0,
|
||||
),
|
||||
colors.accent,
|
||||
)
|
||||
profile.recovery.forEach { point ->
|
||||
TrainlogInfo(
|
||||
strings.getString(
|
||||
R.string.calibration_recovery_value,
|
||||
point.targetOffsetSeconds / 60,
|
||||
point.bpm,
|
||||
),
|
||||
colors.muted,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
TrainlogFrame(strings.getString(R.string.calibration_current)) {
|
||||
TrainlogInfo(
|
||||
strings.getString(
|
||||
R.string.calibration_phase_value,
|
||||
phaseLabel(active.phase),
|
||||
),
|
||||
colors.accent,
|
||||
)
|
||||
active.observedPeakBpm?.let { bpm ->
|
||||
TrainlogInfo(
|
||||
strings.getString(R.string.calibration_peak_value, bpm),
|
||||
colors.warning,
|
||||
)
|
||||
}
|
||||
active.recovery.forEach { point ->
|
||||
TrainlogInfo(
|
||||
strings.getString(
|
||||
R.string.calibration_recovery_value,
|
||||
point.targetOffsetSeconds / 60,
|
||||
point.bpm,
|
||||
),
|
||||
colors.muted,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
when (active.phase) {
|
||||
CardioCalibrationPhase.WARMUP -> {
|
||||
TrainlogPrimaryAction(
|
||||
strings.getString(R.string.calibration_to_progressive),
|
||||
strings.getString(R.string.calibration_progressive_description),
|
||||
) {
|
||||
apply(repository.advanceCardioCalibrationPhase())
|
||||
}
|
||||
}
|
||||
CardioCalibrationPhase.PROGRESSIVE -> {
|
||||
TrainlogPrimaryAction(
|
||||
strings.getString(R.string.calibration_to_high_effort),
|
||||
strings.getString(R.string.calibration_high_effort_description),
|
||||
) {
|
||||
apply(repository.advanceCardioCalibrationPhase())
|
||||
}
|
||||
}
|
||||
CardioCalibrationPhase.HIGH_EFFORT -> {
|
||||
TrainlogPrimaryAction(
|
||||
strings.getString(R.string.calibration_end_effort),
|
||||
strings.getString(R.string.calibration_end_effort_description),
|
||||
) {
|
||||
apply(repository.markCardioCalibrationEffortEnd())
|
||||
}
|
||||
}
|
||||
CardioCalibrationPhase.RECOVERY -> {
|
||||
TrainlogPrimaryAction(
|
||||
strings.getString(R.string.calibration_finish),
|
||||
strings.getString(R.string.calibration_finish_description),
|
||||
) {
|
||||
apply(repository.finishCardioCalibration())
|
||||
}
|
||||
}
|
||||
CardioCalibrationPhase.COMPLETED,
|
||||
CardioCalibrationPhase.ABORTED -> Unit
|
||||
}
|
||||
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
TrainlogButton(
|
||||
strings.getString(R.string.calibration_abort),
|
||||
{ apply(repository.abortCardioCalibration()) },
|
||||
style = TrainlogButtonStyle.DESTRUCTIVE,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
message?.let { TrainlogInfo(it, colors.error) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,385 @@
|
|||
package com.labfytools.trainlog.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.labfytools.trainlog.R
|
||||
import com.labfytools.trainlog.data.CardioGuidanceLiveState
|
||||
import com.labfytools.trainlog.data.CardioTargetResolver
|
||||
import com.labfytools.trainlog.data.TrainlogRepository
|
||||
import com.labfytools.trainlog.model.CardioGuidanceInstruction
|
||||
import com.labfytools.trainlog.model.CardioGuidedPhase
|
||||
import com.labfytools.trainlog.model.CardioPhaseExitCondition
|
||||
import com.labfytools.trainlog.model.CardioPhaseKind
|
||||
import com.labfytools.trainlog.model.CardioTargetDefinition
|
||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||
import java.util.UUID
|
||||
|
||||
@Composable
|
||||
internal fun CardioGuidancePanel(
|
||||
repository: TrainlogRepository,
|
||||
entryId: String,
|
||||
) {
|
||||
val strings = localizedContext()
|
||||
val colors = LocalTrainlogColors.current
|
||||
var revision by remember(entryId) { mutableStateOf(0) }
|
||||
val active =
|
||||
remember(entryId, revision) {
|
||||
repository.activeCardioGuidancePhase()?.takeIf { it.entryId == entryId }
|
||||
}
|
||||
val calibration =
|
||||
remember(revision) {
|
||||
repository.listCompletedCardioCalibrations(1).firstOrNull()
|
||||
}
|
||||
var live by remember { mutableStateOf(CardioGuidanceLiveState.current()) }
|
||||
|
||||
DisposableEffect(entryId) {
|
||||
val subscription = CardioGuidanceLiveState.subscribe { live = it }
|
||||
onDispose { subscription.close() }
|
||||
}
|
||||
|
||||
TrainlogFrame(
|
||||
title = strings.getString(R.string.cardio_guidance_title),
|
||||
active = active != null,
|
||||
) {
|
||||
if (active != null) {
|
||||
val phase = active.phase
|
||||
val current = live.takeIf { it.phaseId == phase.phaseId }
|
||||
val instruction = current?.instruction ?: active.currentInstruction
|
||||
val bpm = current?.bpm
|
||||
|
||||
TrainlogInfo(
|
||||
strings.getString(
|
||||
R.string.cardio_guidance_phase_value,
|
||||
phaseKindLabel(strings, phase.kind),
|
||||
),
|
||||
colors.muted,
|
||||
)
|
||||
phase.target?.let { target ->
|
||||
TrainlogInfo(
|
||||
strings.getString(
|
||||
R.string.cardio_guidance_target_value,
|
||||
target.minimumBpm,
|
||||
target.maximumBpm,
|
||||
),
|
||||
colors.accent,
|
||||
)
|
||||
if (target.calibrationId != null) {
|
||||
TrainlogInfo(
|
||||
strings.getString(
|
||||
R.string.cardio_guidance_calibration_value,
|
||||
target.calibrationObservedPeakBpm ?: 0,
|
||||
target.minimumPercent ?: 0,
|
||||
target.maximumPercent ?: 0,
|
||||
),
|
||||
colors.muted,
|
||||
)
|
||||
}
|
||||
}
|
||||
TrainlogInfo(
|
||||
guidanceInstructionLabel(strings, instruction),
|
||||
when (instruction) {
|
||||
CardioGuidanceInstruction.MAINTAIN -> colors.success
|
||||
CardioGuidanceInstruction.ACCELERATE -> colors.accent
|
||||
CardioGuidanceInstruction.SLOW_DOWN,
|
||||
CardioGuidanceInstruction.SUSPENDED -> colors.warning
|
||||
},
|
||||
)
|
||||
TrainlogInfo(
|
||||
if (bpm == null) {
|
||||
strings.getString(R.string.cardio_guidance_bpm_missing)
|
||||
} else {
|
||||
strings.getString(R.string.cardio_guidance_bpm_value, bpm)
|
||||
},
|
||||
if (bpm == null) colors.warning else colors.text,
|
||||
)
|
||||
TrainlogButton(
|
||||
label = strings.getString(R.string.cardio_guidance_finish_phase),
|
||||
onClick = {
|
||||
if (
|
||||
repository.finishCardioGuidancePhase(phase.phaseId) is
|
||||
TrainlogRepository.CardioGuidanceMutationResult.Applied
|
||||
) {
|
||||
CardioGuidanceLiveState.clear()
|
||||
revision++
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
style = TrainlogButtonStyle.SECONDARY,
|
||||
)
|
||||
return@TrainlogFrame
|
||||
}
|
||||
|
||||
var kind by remember(entryId) { mutableStateOf(CardioPhaseKind.WARMUP) }
|
||||
var targetMode by
|
||||
remember(entryId, calibration?.calibrationId) {
|
||||
mutableStateOf(if (calibration == null) "bpm" else "calibration")
|
||||
}
|
||||
var minimumBpm by remember(entryId) { mutableStateOf("") }
|
||||
var maximumBpm by remember(entryId) { mutableStateOf("") }
|
||||
var minimumPercent by remember(entryId) { mutableStateOf("") }
|
||||
var maximumPercent by remember(entryId) { mutableStateOf("") }
|
||||
var exitMode by remember(entryId) { mutableStateOf("duration") }
|
||||
var durationSeconds by remember(entryId) { mutableStateOf("") }
|
||||
var recoveryBpm by remember(entryId) { mutableStateOf("") }
|
||||
var message by remember(entryId) { mutableStateOf<String?>(null) }
|
||||
|
||||
TrainlogChoiceChips(
|
||||
choices =
|
||||
listOf(
|
||||
"warmup" to strings.getString(R.string.cardio_phase_warmup),
|
||||
"work" to strings.getString(R.string.cardio_phase_work),
|
||||
"recovery" to strings.getString(R.string.cardio_phase_recovery),
|
||||
"cooldown" to strings.getString(R.string.cardio_phase_cooldown),
|
||||
),
|
||||
selectedId = kind.wireValue,
|
||||
onSelected = { selected ->
|
||||
kind = CardioPhaseKind.entries.first { it.wireValue == selected }
|
||||
},
|
||||
)
|
||||
|
||||
TrainlogChoiceChips(
|
||||
choices =
|
||||
buildList {
|
||||
add("bpm" to strings.getString(R.string.cardio_target_absolute))
|
||||
if (calibration?.observedPeakBpm != null) {
|
||||
add(
|
||||
"calibration" to
|
||||
strings.getString(R.string.cardio_target_calibration),
|
||||
)
|
||||
}
|
||||
},
|
||||
selectedId = targetMode,
|
||||
onSelected = { targetMode = it },
|
||||
)
|
||||
|
||||
if (targetMode == "calibration") {
|
||||
calibration?.observedPeakBpm?.let { peak ->
|
||||
TrainlogInfo(
|
||||
strings.getString(R.string.cardio_guidance_latest_calibration, peak),
|
||||
colors.muted,
|
||||
)
|
||||
}
|
||||
NumberPair(
|
||||
leftLabel = strings.getString(R.string.cardio_target_min_percent),
|
||||
left = minimumPercent,
|
||||
onLeft = { minimumPercent = digits(it, 3) },
|
||||
rightLabel = strings.getString(R.string.cardio_target_max_percent),
|
||||
right = maximumPercent,
|
||||
onRight = { maximumPercent = digits(it, 3) },
|
||||
suffix = "%",
|
||||
)
|
||||
} else {
|
||||
NumberPair(
|
||||
leftLabel = strings.getString(R.string.cardio_target_min_bpm),
|
||||
left = minimumBpm,
|
||||
onLeft = { minimumBpm = digits(it, 5) },
|
||||
rightLabel = strings.getString(R.string.cardio_target_max_bpm),
|
||||
right = maximumBpm,
|
||||
onRight = { maximumBpm = digits(it, 5) },
|
||||
suffix = "BPM",
|
||||
)
|
||||
}
|
||||
|
||||
TrainlogChoiceChips(
|
||||
choices =
|
||||
listOf(
|
||||
"duration" to strings.getString(R.string.cardio_exit_duration),
|
||||
"target" to strings.getString(R.string.cardio_exit_target),
|
||||
"recover" to strings.getString(R.string.cardio_exit_recover),
|
||||
"bounded" to strings.getString(R.string.cardio_exit_bounded),
|
||||
),
|
||||
selectedId = exitMode,
|
||||
onSelected = { exitMode = it },
|
||||
)
|
||||
|
||||
if (exitMode == "duration" || exitMode == "bounded") {
|
||||
TrainlogInputField(
|
||||
label = strings.getString(R.string.cardio_exit_seconds),
|
||||
value = durationSeconds,
|
||||
onValueChange = { durationSeconds = digits(it, 5) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
suffix = "s",
|
||||
compact = true,
|
||||
)
|
||||
}
|
||||
if (exitMode == "recover" || exitMode == "bounded") {
|
||||
TrainlogInputField(
|
||||
label = strings.getString(R.string.cardio_exit_recovery_bpm),
|
||||
value = recoveryBpm,
|
||||
onValueChange = { recoveryBpm = digits(it, 5) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
suffix = "BPM",
|
||||
compact = true,
|
||||
)
|
||||
}
|
||||
|
||||
val target =
|
||||
if (targetMode == "calibration") {
|
||||
val peak = calibration?.observedPeakBpm
|
||||
val min = minimumPercent.toIntOrNull()
|
||||
val max = maximumPercent.toIntOrNull()
|
||||
if (calibration == null || peak == null || min == null || max == null) {
|
||||
null
|
||||
} else {
|
||||
CardioTargetResolver.resolve(
|
||||
CardioTargetDefinition.CalibrationPercent(
|
||||
calibration.calibrationId,
|
||||
peak,
|
||||
min,
|
||||
max,
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val min = minimumBpm.toIntOrNull()
|
||||
val max = maximumBpm.toIntOrNull()
|
||||
if (min == null || max == null) null
|
||||
else CardioTargetResolver.resolve(
|
||||
CardioTargetDefinition.AbsoluteBpm(min, max),
|
||||
)
|
||||
}
|
||||
|
||||
target?.let {
|
||||
TrainlogInfo(
|
||||
strings.getString(
|
||||
R.string.cardio_guidance_resolved_target,
|
||||
it.minimumBpm,
|
||||
it.maximumBpm,
|
||||
),
|
||||
colors.accent,
|
||||
)
|
||||
}
|
||||
|
||||
val exit =
|
||||
when (exitMode) {
|
||||
"duration" ->
|
||||
durationSeconds.toIntOrNull()
|
||||
?.takeIf { it in 1..86400 }
|
||||
?.let(CardioPhaseExitCondition::FixedDuration)
|
||||
"target" -> CardioPhaseExitCondition.EnterTarget
|
||||
"recover" ->
|
||||
recoveryBpm.toIntOrNull()
|
||||
?.takeIf { it in 1..65535 }
|
||||
?.let(CardioPhaseExitCondition::RecoverBelow)
|
||||
"bounded" -> {
|
||||
val seconds = durationSeconds.toIntOrNull()
|
||||
val bpm = recoveryBpm.toIntOrNull()
|
||||
if (seconds != null && bpm != null &&
|
||||
seconds in 1..86400 && bpm in 1..65535
|
||||
) {
|
||||
CardioPhaseExitCondition.DurationOrRecoverBelow(seconds, bpm)
|
||||
} else null
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
message?.let { TrainlogInfo(it, colors.warning) }
|
||||
|
||||
TrainlogButton(
|
||||
label = strings.getString(R.string.cardio_guidance_start_phase),
|
||||
onClick = {
|
||||
val actualTarget = target
|
||||
val actualExit = exit
|
||||
if (actualTarget == null || actualExit == null) {
|
||||
message =
|
||||
strings.getString(R.string.cardio_guidance_invalid_configuration)
|
||||
return@TrainlogButton
|
||||
}
|
||||
val result =
|
||||
repository.startCardioGuidancePhase(
|
||||
entryId,
|
||||
CardioGuidedPhase(
|
||||
phaseId = "cgp_" + UUID.randomUUID(),
|
||||
kind = kind,
|
||||
target = actualTarget,
|
||||
exitCondition = actualExit,
|
||||
),
|
||||
)
|
||||
if (result is TrainlogRepository.CardioGuidanceMutationResult.Applied) {
|
||||
message = null
|
||||
revision++
|
||||
} else {
|
||||
message = strings.getString(R.string.cardio_guidance_start_failed)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = target != null && exit != null,
|
||||
style = TrainlogButtonStyle.SUCCESS,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NumberPair(
|
||||
leftLabel: String,
|
||||
left: String,
|
||||
onLeft: (String) -> Unit,
|
||||
rightLabel: String,
|
||||
right: String,
|
||||
onRight: (String) -> Unit,
|
||||
suffix: String,
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
TrainlogInputField(
|
||||
label = leftLabel,
|
||||
value = left,
|
||||
onValueChange = onLeft,
|
||||
modifier = Modifier.weight(1f),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
suffix = suffix,
|
||||
compact = true,
|
||||
)
|
||||
TrainlogInputField(
|
||||
label = rightLabel,
|
||||
value = right,
|
||||
onValueChange = onRight,
|
||||
modifier = Modifier.weight(1f),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
suffix = suffix,
|
||||
compact = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun phaseKindLabel(
|
||||
strings: android.content.Context,
|
||||
kind: CardioPhaseKind,
|
||||
): String =
|
||||
strings.getString(
|
||||
when (kind) {
|
||||
CardioPhaseKind.WARMUP -> R.string.cardio_phase_warmup
|
||||
CardioPhaseKind.WORK -> R.string.cardio_phase_work
|
||||
CardioPhaseKind.RECOVERY -> R.string.cardio_phase_recovery
|
||||
CardioPhaseKind.COOLDOWN -> R.string.cardio_phase_cooldown
|
||||
},
|
||||
)
|
||||
|
||||
private fun guidanceInstructionLabel(
|
||||
strings: android.content.Context,
|
||||
instruction: CardioGuidanceInstruction,
|
||||
): String =
|
||||
strings.getString(
|
||||
when (instruction) {
|
||||
CardioGuidanceInstruction.ACCELERATE -> R.string.cardio_guidance_accelerate
|
||||
CardioGuidanceInstruction.MAINTAIN -> R.string.cardio_guidance_maintain
|
||||
CardioGuidanceInstruction.SLOW_DOWN -> R.string.cardio_guidance_slow_down
|
||||
CardioGuidanceInstruction.SUSPENDED -> R.string.cardio_guidance_suspended
|
||||
},
|
||||
)
|
||||
|
||||
private fun digits(value: String, maximum: Int): String =
|
||||
value.filter(Char::isDigit).take(maximum)
|
||||
|
|
@ -0,0 +1,203 @@
|
|||
package com.labfytools.trainlog.ui
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.labfytools.trainlog.R
|
||||
import com.labfytools.trainlog.data.HeartRateBleDiagnostics
|
||||
import com.labfytools.trainlog.data.HeartRateDiagnosticPhase
|
||||
import com.labfytools.trainlog.data.HeartRateDiagnosticSnapshot
|
||||
import com.labfytools.trainlog.data.HeartRateSensorPreferences
|
||||
import com.labfytools.trainlog.data.HeartRateSensorService
|
||||
import com.labfytools.trainlog.data.hasHeartRateBleRuntimePermissions
|
||||
import com.labfytools.trainlog.data.heartRateBleRuntimePermissions
|
||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||
|
||||
@Composable
|
||||
fun HeartRateDiagnosticsScreen(onSelected: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val strings = localizedContext()
|
||||
val colors = LocalTrainlogColors.current
|
||||
var snapshot by remember { mutableStateOf(HeartRateDiagnosticSnapshot()) }
|
||||
var pendingExpandedScan by remember { mutableStateOf(false) }
|
||||
var permissionDenied by remember { mutableStateOf(false) }
|
||||
val diagnostics =
|
||||
remember(context) {
|
||||
HeartRateBleDiagnostics(context) { next -> snapshot = next }
|
||||
}
|
||||
|
||||
DisposableEffect(diagnostics) {
|
||||
onDispose { diagnostics.close() }
|
||||
}
|
||||
|
||||
val permissionLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions(),
|
||||
) { results ->
|
||||
if (heartRateBleRuntimePermissions().all { results[it] == true }) {
|
||||
permissionDenied = false
|
||||
diagnostics.startScan(pendingExpandedScan)
|
||||
} else {
|
||||
permissionDenied = true
|
||||
}
|
||||
}
|
||||
|
||||
fun scan(expanded: Boolean) {
|
||||
pendingExpandedScan = expanded
|
||||
if (hasHeartRateBleRuntimePermissions(context)) {
|
||||
permissionDenied = false
|
||||
diagnostics.startScan(expanded)
|
||||
} else {
|
||||
permissionLauncher.launch(heartRateBleRuntimePermissions())
|
||||
}
|
||||
}
|
||||
|
||||
fun phaseLabel(): String =
|
||||
strings.getString(
|
||||
when (snapshot.phase) {
|
||||
HeartRateDiagnosticPhase.IDLE -> R.string.hr_diag_idle
|
||||
HeartRateDiagnosticPhase.SCANNING -> R.string.hr_diag_scanning
|
||||
HeartRateDiagnosticPhase.CONNECTING -> R.string.hr_diag_connecting
|
||||
HeartRateDiagnosticPhase.DISCOVERING -> R.string.hr_diag_discovering
|
||||
HeartRateDiagnosticPhase.SUBSCRIBING -> R.string.hr_diag_subscribing
|
||||
HeartRateDiagnosticPhase.RECEIVING -> R.string.hr_diag_receiving
|
||||
HeartRateDiagnosticPhase.DISCONNECTED -> R.string.hr_diag_disconnected
|
||||
HeartRateDiagnosticPhase.ERROR -> R.string.hr_diag_error
|
||||
},
|
||||
)
|
||||
|
||||
TrainlogScreen(strings.getString(R.string.route_heart_rate_diagnostics)) {
|
||||
TrainlogFrame(strings.getString(R.string.hr_diag_status)) {
|
||||
TrainlogInfo(
|
||||
phaseLabel(),
|
||||
when (snapshot.phase) {
|
||||
HeartRateDiagnosticPhase.RECEIVING -> colors.success
|
||||
HeartRateDiagnosticPhase.ERROR -> colors.error
|
||||
HeartRateDiagnosticPhase.CONNECTING,
|
||||
HeartRateDiagnosticPhase.DISCOVERING,
|
||||
HeartRateDiagnosticPhase.SUBSCRIBING,
|
||||
HeartRateDiagnosticPhase.SCANNING -> colors.warning
|
||||
else -> colors.muted
|
||||
},
|
||||
)
|
||||
if (permissionDenied) {
|
||||
TrainlogInfo(strings.getString(R.string.hr_diag_permission_denied), colors.error)
|
||||
}
|
||||
snapshot.diagnostic?.let {
|
||||
TrainlogInfo(strings.getString(R.string.hr_diag_technical, it), colors.muted)
|
||||
}
|
||||
TrainlogInfo(
|
||||
strings.getString(
|
||||
R.string.hr_diag_services,
|
||||
if (snapshot.serviceFound) strings.getString(R.string.yes) else strings.getString(R.string.no),
|
||||
if (snapshot.measurementFound) strings.getString(R.string.yes) else strings.getString(R.string.no),
|
||||
if (snapshot.batteryServiceFound) strings.getString(R.string.yes) else strings.getString(R.string.no),
|
||||
),
|
||||
)
|
||||
snapshot.latestMeasurement?.let { measurement ->
|
||||
TrainlogInfo(
|
||||
strings.getString(R.string.hr_diag_bpm, measurement.bpm),
|
||||
colors.success,
|
||||
)
|
||||
TrainlogInfo(
|
||||
strings.getString(
|
||||
R.string.hr_diag_measurement_meta,
|
||||
measurement.rrIntervals1024.size,
|
||||
measurement.sensorContactDetected?.let { if (it) strings.getString(R.string.yes) else strings.getString(R.string.no) } ?: "—",
|
||||
measurement.energyExpended?.toString() ?: "—",
|
||||
),
|
||||
colors.muted,
|
||||
)
|
||||
}
|
||||
TrainlogInfo(
|
||||
strings.getString(R.string.hr_diag_notifications, snapshot.notificationCount),
|
||||
colors.muted,
|
||||
)
|
||||
if (
|
||||
snapshot.phase == HeartRateDiagnosticPhase.RECEIVING &&
|
||||
snapshot.selectedAddress != null
|
||||
) {
|
||||
TrainlogButton(
|
||||
strings.getString(R.string.hr_diag_use_sensor),
|
||||
{
|
||||
val address = checkNotNull(snapshot.selectedAddress)
|
||||
val name =
|
||||
snapshot.devices
|
||||
.firstOrNull { it.address == address }
|
||||
?.name
|
||||
diagnostics.close()
|
||||
HeartRateSensorPreferences(context).select(address, name)
|
||||
HeartRateSensorService.start(context)
|
||||
onSelected()
|
||||
},
|
||||
Modifier.fillMaxWidth(),
|
||||
style = TrainlogButtonStyle.SUCCESS,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TrainlogFrame(strings.getString(R.string.hr_diag_scan_section)) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
TrainlogButton(
|
||||
strings.getString(R.string.hr_diag_scan),
|
||||
{ scan(false) },
|
||||
Modifier.weight(1f),
|
||||
enabled = snapshot.phase != HeartRateDiagnosticPhase.SCANNING,
|
||||
)
|
||||
TrainlogButton(
|
||||
strings.getString(R.string.hr_diag_scan_all),
|
||||
{ scan(true) },
|
||||
Modifier.weight(1f),
|
||||
enabled = snapshot.phase != HeartRateDiagnosticPhase.SCANNING,
|
||||
)
|
||||
}
|
||||
TrainlogInfo(strings.getString(R.string.hr_diag_scan_description), colors.muted)
|
||||
}
|
||||
|
||||
TrainlogFrame(
|
||||
strings.getString(R.string.hr_diag_devices),
|
||||
active = snapshot.devices.isNotEmpty(),
|
||||
) {
|
||||
if (snapshot.devices.isEmpty()) {
|
||||
TrainlogInfo(strings.getString(R.string.hr_diag_no_devices), colors.muted)
|
||||
}
|
||||
snapshot.devices.forEach { device ->
|
||||
val title =
|
||||
device.name?.takeIf { it.isNotBlank() }
|
||||
?: strings.getString(R.string.hr_diag_unnamed_device)
|
||||
val advertised =
|
||||
strings.getString(
|
||||
if (device.advertisesHeartRate) {
|
||||
R.string.hr_diag_hrs_advertised
|
||||
} else {
|
||||
R.string.hr_diag_hrs_not_advertised
|
||||
},
|
||||
)
|
||||
TrainlogAction(
|
||||
title,
|
||||
strings.getString(
|
||||
R.string.hr_diag_device_description,
|
||||
device.address,
|
||||
device.rssi,
|
||||
advertised,
|
||||
),
|
||||
{ diagnostics.connect(device.address) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
package com.labfytools.trainlog.ui
|
||||
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.labfytools.trainlog.R
|
||||
import com.labfytools.trainlog.data.HeartRateLivePhase
|
||||
import com.labfytools.trainlog.data.HeartRateLiveState
|
||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||
|
||||
@Composable
|
||||
internal fun HeartRateIndicator() {
|
||||
var live by remember { mutableStateOf(HeartRateLiveState.current()) }
|
||||
DisposableEffect(Unit) {
|
||||
val subscription = HeartRateLiveState.subscribe { live = it }
|
||||
onDispose { subscription.close() }
|
||||
}
|
||||
|
||||
val colors = LocalTrainlogColors.current
|
||||
val strings = localizedContext()
|
||||
val bpm =
|
||||
live.bpm?.takeIf {
|
||||
live.phase == HeartRateLivePhase.CONNECTED
|
||||
}
|
||||
val color =
|
||||
when (live.phase) {
|
||||
HeartRateLivePhase.CONNECTED -> colors.success
|
||||
HeartRateLivePhase.CONNECTING,
|
||||
HeartRateLivePhase.STALE -> colors.warning
|
||||
HeartRateLivePhase.DISCONNECTED -> colors.muted
|
||||
}
|
||||
|
||||
val beatPeriodMs =
|
||||
bpm?.takeIf { it > 0 }
|
||||
?.let { (60_000 / it).coerceIn(300, 2_000) }
|
||||
?: 1_000
|
||||
val transition = rememberInfiniteTransition(label = "heart-rate-pulse")
|
||||
val scale by
|
||||
transition.animateFloat(
|
||||
initialValue = 1f,
|
||||
targetValue = if (bpm != null) 1.14f else 1f,
|
||||
animationSpec =
|
||||
infiniteRepeatable(
|
||||
animation = tween(durationMillis = beatPeriodMs / 2),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
),
|
||||
label = "heart-rate-scale",
|
||||
)
|
||||
|
||||
val description =
|
||||
when (live.phase) {
|
||||
HeartRateLivePhase.CONNECTED ->
|
||||
strings.getString(R.string.heart_rate_a11y_connected, bpm ?: 0)
|
||||
HeartRateLivePhase.CONNECTING ->
|
||||
strings.getString(R.string.heart_rate_a11y_connecting)
|
||||
HeartRateLivePhase.STALE ->
|
||||
strings.getString(R.string.heart_rate_a11y_reconnecting)
|
||||
HeartRateLivePhase.DISCONNECTED ->
|
||||
strings.getString(R.string.heart_rate_a11y_disconnected)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(horizontal = 4.dp)
|
||||
.semantics { contentDescription = description },
|
||||
horizontalArrangement = Arrangement.spacedBy(5.dp),
|
||||
) {
|
||||
androidx.compose.material3.Text(
|
||||
"♥",
|
||||
color = color,
|
||||
fontSize = 20.sp,
|
||||
modifier = Modifier.scale(scale),
|
||||
)
|
||||
androidx.compose.material3.Text(
|
||||
bpm?.let { "$it BPM" } ?: "-- BPM",
|
||||
color = color,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
|||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.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)) })
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,187 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.labfytools.trainlog.model.CardioCalibrationPhase
|
||||
import com.labfytools.trainlog.model.SessionType
|
||||
import java.util.UUID
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
class CardioCalibrationRepositoryTest {
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
|
||||
private fun measurement(bpm: Int) =
|
||||
ParsedHeartRateMeasurement(
|
||||
bpm = bpm,
|
||||
sensorContactDetected = null,
|
||||
energyExpended = null,
|
||||
rrIntervals1024 = listOf(1024),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun calibrationTracksObservedPeakRecoveryAndFinalizesRealCardioSession() {
|
||||
val name = "calibration-" + UUID.randomUUID() + ".db"
|
||||
val repository = TrainlogRepository(context, name)
|
||||
try {
|
||||
val started =
|
||||
repository.startCardioCalibration("2026-09-23T10:00:00+02:00")
|
||||
as TrainlogRepository.CardioCalibrationResult.Applied
|
||||
assertEquals(CardioCalibrationPhase.WARMUP, started.profile.phase)
|
||||
|
||||
repository.recordLiveHeartRateForActiveSession(
|
||||
"CYCPLUS H2",
|
||||
"2026-09-23T10:00:10+02:00",
|
||||
measurement(80),
|
||||
)
|
||||
assertEquals(
|
||||
CardioCalibrationPhase.PROGRESSIVE,
|
||||
(repository.advanceCardioCalibrationPhase("2026-09-23T10:01:00+02:00")
|
||||
as TrainlogRepository.CardioCalibrationResult.Applied).profile.phase,
|
||||
)
|
||||
repository.recordLiveHeartRateForActiveSession(
|
||||
"CYCPLUS H2",
|
||||
"2026-09-23T10:01:20+02:00",
|
||||
measurement(112),
|
||||
)
|
||||
assertEquals(
|
||||
CardioCalibrationPhase.HIGH_EFFORT,
|
||||
(repository.advanceCardioCalibrationPhase("2026-09-23T10:02:00+02:00")
|
||||
as TrainlogRepository.CardioCalibrationResult.Applied).profile.phase,
|
||||
)
|
||||
repository.recordLiveHeartRateForActiveSession(
|
||||
"CYCPLUS H2",
|
||||
"2026-09-23T10:02:30+02:00",
|
||||
measurement(150),
|
||||
)
|
||||
repository.recordLiveHeartRateForActiveSession(
|
||||
"CYCPLUS H2",
|
||||
"2026-09-23T10:03:00+02:00",
|
||||
measurement(160),
|
||||
)
|
||||
|
||||
val recovery =
|
||||
repository.markCardioCalibrationEffortEnd("2026-09-23T10:03:01+02:00")
|
||||
as TrainlogRepository.CardioCalibrationResult.Applied
|
||||
assertEquals(CardioCalibrationPhase.RECOVERY, recovery.profile.phase)
|
||||
assertEquals(160, recovery.profile.observedPeakBpm)
|
||||
assertTrue(recovery.profile.heartRateCaptureId != null)
|
||||
|
||||
repository.recordLiveHeartRateForActiveSession(
|
||||
"CYCPLUS H2",
|
||||
"2026-09-23T10:04:01+02:00",
|
||||
measurement(140),
|
||||
)
|
||||
repository.recordLiveHeartRateForActiveSession(
|
||||
"CYCPLUS H2",
|
||||
"2026-09-23T10:05:02+02:00",
|
||||
measurement(120),
|
||||
)
|
||||
repository.recordLiveHeartRateForActiveSession(
|
||||
"CYCPLUS H2",
|
||||
"2026-09-23T10:06:01+02:00",
|
||||
measurement(100),
|
||||
)
|
||||
|
||||
val completed =
|
||||
repository.finishCardioCalibration("2026-09-23T10:06:10+02:00")
|
||||
as TrainlogRepository.CardioCalibrationResult.Applied
|
||||
assertEquals(CardioCalibrationPhase.COMPLETED, completed.profile.phase)
|
||||
assertEquals(
|
||||
listOf(60 to 140, 120 to 120, 180 to 100),
|
||||
completed.profile.recovery.map { it.targetOffsetSeconds to it.bpm },
|
||||
)
|
||||
assertNull(repository.activeCardioCalibration())
|
||||
assertNull(repository.activeHeartRateCapture())
|
||||
|
||||
val session = repository.listSessions().single()
|
||||
assertEquals(SessionType.CARDIO, session.sessionType)
|
||||
val detail = checkNotNull(repository.getSessionDetail(session.sessionId))
|
||||
assertEquals(370, detail.exercises.single().continuousDurationSeconds)
|
||||
assertEquals(1, repository.listCompletedCardioCalibrations().size)
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recoveryDoesNotInventLateReferencePointsAndCannotFinishTooEarly() {
|
||||
val name = "calibration-recovery-" + UUID.randomUUID() + ".db"
|
||||
val repository = TrainlogRepository(context, name)
|
||||
try {
|
||||
repository.startCardioCalibration("2026-09-23T12:00:00+02:00")
|
||||
repository.recordLiveHeartRateForActiveSession(
|
||||
"CYCPLUS H2",
|
||||
"2026-09-23T12:00:10+02:00",
|
||||
measurement(80),
|
||||
)
|
||||
repository.advanceCardioCalibrationPhase("2026-09-23T12:01:00+02:00")
|
||||
repository.advanceCardioCalibrationPhase("2026-09-23T12:02:00+02:00")
|
||||
repository.recordLiveHeartRateForActiveSession(
|
||||
"CYCPLUS H2",
|
||||
"2026-09-23T12:02:30+02:00",
|
||||
measurement(155),
|
||||
)
|
||||
repository.markCardioCalibrationEffortEnd("2026-09-23T12:03:00+02:00")
|
||||
|
||||
repository.recordLiveHeartRateForActiveSession(
|
||||
"CYCPLUS H2",
|
||||
"2026-09-23T12:05:05+02:00",
|
||||
measurement(118),
|
||||
)
|
||||
val duringRecovery = checkNotNull(repository.activeCardioCalibration())
|
||||
assertEquals(
|
||||
listOf(120 to 118),
|
||||
duringRecovery.recovery.map { it.targetOffsetSeconds to it.bpm },
|
||||
)
|
||||
|
||||
val tooEarly =
|
||||
repository.finishCardioCalibration("2026-09-23T12:05:59+02:00")
|
||||
assertTrue(tooEarly is TrainlogRepository.CardioCalibrationResult.Invalid)
|
||||
assertTrue(repository.activeCardioCalibration() != null)
|
||||
|
||||
repository.recordLiveHeartRateForActiveSession(
|
||||
"CYCPLUS H2",
|
||||
"2026-09-23T12:06:03+02:00",
|
||||
measurement(105),
|
||||
)
|
||||
val completed =
|
||||
repository.finishCardioCalibration("2026-09-23T12:06:05+02:00")
|
||||
as TrainlogRepository.CardioCalibrationResult.Applied
|
||||
assertEquals(
|
||||
listOf(120 to 118, 180 to 105),
|
||||
completed.profile.recovery.map { it.targetOffsetSeconds to it.bpm },
|
||||
)
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun abortBeforeEffortEndNeverCreatesCompletedReference() {
|
||||
val name = "calibration-abort-" + UUID.randomUUID() + ".db"
|
||||
val repository = TrainlogRepository(context, name)
|
||||
try {
|
||||
repository.startCardioCalibration("2026-09-23T11:00:00+02:00")
|
||||
val aborted =
|
||||
repository.abortCardioCalibration("2026-09-23T11:01:00+02:00")
|
||||
as TrainlogRepository.CardioCalibrationResult.Applied
|
||||
assertEquals(CardioCalibrationPhase.ABORTED, aborted.profile.phase)
|
||||
assertNull(aborted.profile.effortEndAt)
|
||||
assertTrue(repository.loadActiveSessionDraft() is ActiveDraftLoadResult.None)
|
||||
assertTrue(repository.listCompletedCardioCalibrations().isEmpty())
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import com.labfytools.trainlog.model.CardioGuidanceInstruction
|
||||
import com.labfytools.trainlog.model.CardioTargetDefinition
|
||||
import com.labfytools.trainlog.model.CardioTargetSnapshot
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class CardioGuidanceEngineTest {
|
||||
private val target = CardioTargetSnapshot(120, 140)
|
||||
|
||||
@Test
|
||||
fun absoluteAndCalibrationTargetsResolveToImmutableBpmSnapshot() {
|
||||
assertEquals(
|
||||
CardioTargetSnapshot(120, 140),
|
||||
CardioTargetResolver.resolve(CardioTargetDefinition.AbsoluteBpm(120, 140)),
|
||||
)
|
||||
assertEquals(
|
||||
CardioTargetSnapshot(
|
||||
minimumBpm = 100,
|
||||
maximumBpm = 125,
|
||||
calibrationId = "cal_11111111-1111-4111-8111-111111111111",
|
||||
calibrationObservedPeakBpm = 167,
|
||||
minimumPercent = 60,
|
||||
maximumPercent = 75,
|
||||
),
|
||||
CardioTargetResolver.resolve(
|
||||
CardioTargetDefinition.CalibrationPercent(
|
||||
calibrationId = "cal_11111111-1111-4111-8111-111111111111",
|
||||
observedPeakBpm = 167,
|
||||
minimumPercent = 60,
|
||||
maximumPercent = 75,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidTargetDefinitionsAreRejectedInsteadOfClamped() {
|
||||
assertNull(CardioTargetResolver.resolve(CardioTargetDefinition.AbsoluteBpm(140, 120)))
|
||||
assertNull(
|
||||
CardioTargetResolver.resolve(
|
||||
CardioTargetDefinition.CalibrationPercent("cal_x", 0, 60, 70),
|
||||
),
|
||||
)
|
||||
assertNull(
|
||||
CardioTargetResolver.resolve(
|
||||
CardioTargetDefinition.CalibrationPercent("cal_x", 160, 80, 60),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lowBpmMustPersistBeforeAccelerateInstructionChanges() {
|
||||
val engine = CardioGuidanceEngine(hysteresisBpm = 2, confirmationMs = 3_000)
|
||||
assertEquals(
|
||||
CardioGuidanceInstruction.MAINTAIN,
|
||||
engine.evaluate(target, 110, true, 1_000).instruction,
|
||||
)
|
||||
assertEquals(
|
||||
CardioGuidanceInstruction.MAINTAIN,
|
||||
engine.evaluate(target, 111, true, 3_999).instruction,
|
||||
)
|
||||
val changed = engine.evaluate(target, 111, true, 4_000)
|
||||
assertEquals(CardioGuidanceInstruction.ACCELERATE, changed.instruction)
|
||||
assertNull(changed.pendingInstruction)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun highBpmMustPersistBeforeSlowDownInstructionChanges() {
|
||||
val engine = CardioGuidanceEngine(hysteresisBpm = 2, confirmationMs = 2_000)
|
||||
engine.evaluate(target, 150, true, 0)
|
||||
assertEquals(
|
||||
CardioGuidanceInstruction.SLOW_DOWN,
|
||||
engine.evaluate(target, 150, true, 2_000).instruction,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hysteresisPreventsBoundaryYoyo() {
|
||||
val engine = CardioGuidanceEngine(hysteresisBpm = 2, confirmationMs = 0)
|
||||
assertEquals(
|
||||
CardioGuidanceInstruction.ACCELERATE,
|
||||
engine.evaluate(target, 117, true, 0).instruction,
|
||||
)
|
||||
// 119 is still below target, but not enough to leave ACCELERATE.
|
||||
assertEquals(
|
||||
CardioGuidanceInstruction.ACCELERATE,
|
||||
engine.evaluate(target, 119, true, 1).instruction,
|
||||
)
|
||||
assertEquals(
|
||||
CardioGuidanceInstruction.MAINTAIN,
|
||||
engine.evaluate(target, 122, true, 2).instruction,
|
||||
)
|
||||
// 141 is just above the zone and remains MAINTAIN within hysteresis.
|
||||
assertEquals(
|
||||
CardioGuidanceInstruction.MAINTAIN,
|
||||
engine.evaluate(target, 141, true, 3).instruction,
|
||||
)
|
||||
assertEquals(
|
||||
CardioGuidanceInstruction.SLOW_DOWN,
|
||||
engine.evaluate(target, 143, true, 4).instruction,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isolatedOppositeSampleDoesNotReplaceCurrentInstruction() {
|
||||
val engine = CardioGuidanceEngine(hysteresisBpm = 2, confirmationMs = 3_000)
|
||||
engine.evaluate(target, 110, true, 0)
|
||||
engine.evaluate(target, 110, true, 3_000)
|
||||
assertEquals(
|
||||
CardioGuidanceInstruction.ACCELERATE,
|
||||
engine.evaluate(target, 150, true, 3_100).instruction,
|
||||
)
|
||||
assertEquals(
|
||||
CardioGuidanceInstruction.ACCELERATE,
|
||||
engine.evaluate(target, 112, true, 3_200).instruction,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staleOrMissingSignalSuspendsImmediatelyAndNeverUsesOldBpm() {
|
||||
val engine = CardioGuidanceEngine(hysteresisBpm = 2, confirmationMs = 0)
|
||||
assertEquals(
|
||||
CardioGuidanceInstruction.ACCELERATE,
|
||||
engine.evaluate(target, 110, true, 0).instruction,
|
||||
)
|
||||
val stale = engine.evaluate(target, 110, false, 1_000)
|
||||
assertEquals(CardioGuidanceInstruction.SUSPENDED, stale.instruction)
|
||||
assertNull(stale.bpm)
|
||||
|
||||
// A fresh sample must re-establish a candidate from real current data.
|
||||
val resumed = engine.evaluate(target, 130, true, 2_000)
|
||||
assertTrue(resumed.instruction != CardioGuidanceInstruction.SUSPENDED)
|
||||
assertEquals(130, resumed.bpm)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noTargetSuspendsGuidance() {
|
||||
val engine = CardioGuidanceEngine()
|
||||
val output = engine.evaluate(null, 130, true, 0)
|
||||
assertEquals(CardioGuidanceInstruction.SUSPENDED, output.instruction)
|
||||
assertNull(output.bpm)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||
import com.labfytools.trainlog.model.CardioGuidanceInstruction
|
||||
import com.labfytools.trainlog.model.CardioGuidanceOutput
|
||||
import com.labfytools.trainlog.model.CardioGuidedPhase
|
||||
import com.labfytools.trainlog.model.CardioPhaseExitCondition
|
||||
import com.labfytools.trainlog.model.CardioPhaseKind
|
||||
import com.labfytools.trainlog.model.CardioTargetSnapshot
|
||||
import com.labfytools.trainlog.model.NewExerciseProfile
|
||||
import com.labfytools.trainlog.model.RecordingMode
|
||||
import com.labfytools.trainlog.model.SessionExerciseDraft
|
||||
import com.labfytools.trainlog.model.SessionExercisePlan
|
||||
import com.labfytools.trainlog.model.SessionType
|
||||
import com.labfytools.trainlog.model.TrackingMode
|
||||
import java.nio.file.Files
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
class CardioGuidanceGenerationTest {
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
|
||||
private fun measurement(bpm: Int) =
|
||||
ParsedHeartRateMeasurement(
|
||||
bpm = bpm,
|
||||
sensorContactDetected = null,
|
||||
energyExpended = null,
|
||||
rrIntervals1024 = listOf(1024),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun completedGuidanceSynchronizesOnceAndAckStopsRepublication() {
|
||||
val name = "guidance-generation-" + UUID.randomUUID() + ".db"
|
||||
val repository = TrainlogRepository(context, name)
|
||||
val root = Files.createTempDirectory("trainlog-guidance-generation-").toFile()
|
||||
try {
|
||||
val exercise =
|
||||
(repository.createExercise(
|
||||
NewExerciseProfile(
|
||||
"Vélo guidé sync",
|
||||
RecordingMode.CONTINUOUS,
|
||||
TrackingMode.DURATION,
|
||||
0,
|
||||
),
|
||||
) as CreateExerciseResult.Created).exercise
|
||||
|
||||
val entry =
|
||||
SessionExerciseDraft(
|
||||
exercise = exercise,
|
||||
plan = SessionExercisePlan(sets = 0, durationSeconds = 30),
|
||||
)
|
||||
assertEquals(
|
||||
ActiveDraftMutationResult.Saved,
|
||||
repository.saveActiveSessionDraft(
|
||||
ActiveSessionDraft(
|
||||
startedAt = OffsetDateTime.now().minusMinutes(1).toString(),
|
||||
sessionType = SessionType.CARDIO,
|
||||
exercises = listOf(entry),
|
||||
),
|
||||
),
|
||||
)
|
||||
val active =
|
||||
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
||||
val base = OffsetDateTime.parse(checkNotNull(active.startedAt))
|
||||
val exerciseStart = base.plusSeconds(1).toString()
|
||||
assertTrue(
|
||||
repository.startActiveSessionExercise(entry.entryId, exerciseStart) is
|
||||
TrainlogRepository.SessionExerciseTimingResult.Started,
|
||||
)
|
||||
|
||||
val target = CardioTargetSnapshot(120, 140)
|
||||
val phase =
|
||||
CardioGuidedPhase(
|
||||
phaseId = "cgp_" + UUID.randomUUID(),
|
||||
kind = CardioPhaseKind.WORK,
|
||||
target = target,
|
||||
exitCondition = CardioPhaseExitCondition.FixedDuration(30),
|
||||
)
|
||||
assertTrue(
|
||||
repository.startCardioGuidancePhase(
|
||||
entry.entryId,
|
||||
phase,
|
||||
base.plusSeconds(2).toString(),
|
||||
) is TrainlogRepository.CardioGuidanceMutationResult.Applied,
|
||||
)
|
||||
|
||||
val lowAt = base.plusSeconds(3).toString()
|
||||
repository.recordLiveHeartRateForActiveSession(
|
||||
"Synthetic HR",
|
||||
lowAt,
|
||||
measurement(110),
|
||||
)
|
||||
repository.recordCardioGuidanceOutput(
|
||||
phase.phaseId,
|
||||
lowAt,
|
||||
CardioGuidanceOutput(
|
||||
CardioGuidanceInstruction.ACCELERATE,
|
||||
110,
|
||||
target,
|
||||
),
|
||||
)
|
||||
val targetAt = base.plusSeconds(4).toString()
|
||||
repository.recordLiveHeartRateForActiveSession(
|
||||
"Synthetic HR",
|
||||
targetAt,
|
||||
measurement(130),
|
||||
)
|
||||
repository.recordCardioGuidanceOutput(
|
||||
phase.phaseId,
|
||||
targetAt,
|
||||
CardioGuidanceOutput(
|
||||
CardioGuidanceInstruction.MAINTAIN,
|
||||
130,
|
||||
target,
|
||||
),
|
||||
)
|
||||
|
||||
repository.finishCardioGuidancePhase(
|
||||
phase.phaseId,
|
||||
base.plusSeconds(5).toString(),
|
||||
)
|
||||
repository.finishActiveSessionExercise(
|
||||
entry.entryId,
|
||||
base.plusSeconds(6).toString(),
|
||||
)
|
||||
val completedDraft =
|
||||
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
||||
assertEquals(
|
||||
ActiveDraftMutationResult.Saved,
|
||||
repository.saveActiveSessionDraft(
|
||||
completedDraft.copy(
|
||||
exercises =
|
||||
completedDraft.exercises.map {
|
||||
if (it.entryId == entry.entryId) {
|
||||
it.copy(continuousDurationSeconds = 5)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
assertTrue(repository.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
|
||||
assertEquals(
|
||||
1,
|
||||
JSONObject(repository.buildCardioGuidanceV1Json())
|
||||
.getJSONArray("runs")
|
||||
.length(),
|
||||
)
|
||||
|
||||
val repositoryRoot =
|
||||
generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))) {
|
||||
it.parentFile
|
||||
}
|
||||
.first { java.io.File(it, "tools/sync_generation_exchange.py").isFile }
|
||||
fun run(vararg command: String): String {
|
||||
val process =
|
||||
ProcessBuilder(*command)
|
||||
.directory(repositoryRoot)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
val output = process.inputStream.bufferedReader().readText()
|
||||
assertEquals(
|
||||
"command failed: ${command.joinToString(" ")}\n$output",
|
||||
0,
|
||||
process.waitFor(),
|
||||
)
|
||||
return output
|
||||
}
|
||||
|
||||
val desktopDb = java.io.File(root, "desktop.sqlite")
|
||||
run(
|
||||
java.io.File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
|
||||
desktopDb.absolutePath,
|
||||
)
|
||||
val desktopPeer =
|
||||
Regex("PEER_ID=(peer_[^\\s]+)")
|
||||
.find(
|
||||
run(
|
||||
"python3",
|
||||
java.io.File(
|
||||
repositoryRoot,
|
||||
"tools/sync_generation_exchange.py",
|
||||
).absolutePath,
|
||||
"peer-id",
|
||||
"--database",
|
||||
desktopDb.absolutePath,
|
||||
),
|
||||
)!!
|
||||
.groupValues[1]
|
||||
|
||||
val service = SyncGenerationService(repository)
|
||||
val captured = service.capture(root, desktopPeer)
|
||||
assertEquals(
|
||||
1,
|
||||
JSONObject(
|
||||
java.io.File(
|
||||
captured.stagingDirectory,
|
||||
"cardio-guidance-v1.json",
|
||||
).readText(),
|
||||
)
|
||||
.getJSONArray("runs")
|
||||
.length(),
|
||||
)
|
||||
val published =
|
||||
service.publish(captured, java.io.File(root, "objects"))
|
||||
val ackFile = java.io.File(root, "ack.json")
|
||||
run(
|
||||
"python3",
|
||||
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath,
|
||||
"consume-desktop",
|
||||
published.absolutePath,
|
||||
"--database",
|
||||
desktopDb.absolutePath,
|
||||
"--ack-output",
|
||||
ackFile.absolutePath,
|
||||
)
|
||||
assertEquals(
|
||||
"consumed",
|
||||
JSONObject(ackFile.readText()).getString("result"),
|
||||
)
|
||||
assertEquals(
|
||||
"acknowledged",
|
||||
service.acceptAcknowledgement(ackFile.readBytes()),
|
||||
)
|
||||
assertEquals(
|
||||
0,
|
||||
JSONObject(repository.buildCardioGuidanceV1Json())
|
||||
.getJSONArray("runs")
|
||||
.length(),
|
||||
)
|
||||
assertEquals(
|
||||
"1|1|2",
|
||||
run(
|
||||
"sqlite3",
|
||||
desktopDb.absolutePath,
|
||||
"SELECT (SELECT count(*) FROM cardio_guidance_runs) || '|' || " +
|
||||
"(SELECT count(*) FROM cardio_guidance_phases) || '|' || " +
|
||||
"(SELECT count(*) FROM cardio_guidance_events);",
|
||||
)
|
||||
.trim(),
|
||||
)
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,428 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||
import com.labfytools.trainlog.model.CardioGuidanceInstruction
|
||||
import com.labfytools.trainlog.model.CardioGuidanceOutput
|
||||
import com.labfytools.trainlog.model.CardioGuidedPhase
|
||||
import com.labfytools.trainlog.model.CardioPhaseExitCondition
|
||||
import com.labfytools.trainlog.model.CardioPhaseKind
|
||||
import com.labfytools.trainlog.model.CardioTargetSnapshot
|
||||
import com.labfytools.trainlog.model.HeartRateContextKind
|
||||
import com.labfytools.trainlog.model.NewExerciseProfile
|
||||
import com.labfytools.trainlog.model.RecordingMode
|
||||
import com.labfytools.trainlog.model.SessionExerciseDraft
|
||||
import com.labfytools.trainlog.model.SessionExercisePlan
|
||||
import com.labfytools.trainlog.model.SessionType
|
||||
import com.labfytools.trainlog.model.TrackingMode
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
class CardioGuidanceRepositoryTest {
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
|
||||
private data class ActiveCardio(
|
||||
val repository: TrainlogRepository,
|
||||
val databaseName: String,
|
||||
val entryId: String,
|
||||
val exerciseStartedAt: String,
|
||||
) {
|
||||
fun at(seconds: Long): String =
|
||||
OffsetDateTime.parse(exerciseStartedAt).plusSeconds(seconds).toString()
|
||||
}
|
||||
|
||||
private fun activeCardio(): ActiveCardio {
|
||||
val name = "guidance-" + UUID.randomUUID() + ".db"
|
||||
val repository = TrainlogRepository(context, name)
|
||||
val exercise =
|
||||
(repository.createExercise(
|
||||
NewExerciseProfile(
|
||||
"Tapis guidé",
|
||||
RecordingMode.CONTINUOUS,
|
||||
TrackingMode.DURATION,
|
||||
0,
|
||||
),
|
||||
) as CreateExerciseResult.Created).exercise
|
||||
val entry =
|
||||
SessionExerciseDraft(
|
||||
exercise = exercise,
|
||||
plan = SessionExercisePlan(sets = 0, durationSeconds = 600),
|
||||
)
|
||||
assertEquals(
|
||||
ActiveDraftMutationResult.Saved,
|
||||
repository.saveActiveSessionDraft(
|
||||
ActiveSessionDraft(
|
||||
startedAt = OffsetDateTime.now().minusMinutes(20).toString(),
|
||||
sessionType = SessionType.CARDIO,
|
||||
exercises = listOf(entry),
|
||||
),
|
||||
),
|
||||
)
|
||||
val loaded = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
|
||||
val exerciseStartedAt =
|
||||
OffsetDateTime.parse(checkNotNull(loaded.draft.startedAt))
|
||||
.plusSeconds(10)
|
||||
.toString()
|
||||
val started =
|
||||
repository.startActiveSessionExercise(
|
||||
entry.entryId,
|
||||
exerciseStartedAt,
|
||||
)
|
||||
assertTrue(started is TrainlogRepository.SessionExerciseTimingResult.Started)
|
||||
return ActiveCardio(repository, name, entry.entryId, exerciseStartedAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trainingCaptureTransitionsToCardioWithoutStarvingGuidance() {
|
||||
val name = "guidance-transition-" + UUID.randomUUID() + ".db"
|
||||
val repository = TrainlogRepository(context, name)
|
||||
try {
|
||||
val exercise =
|
||||
(repository.createExercise(
|
||||
NewExerciseProfile(
|
||||
"Vélo guidé après transition",
|
||||
RecordingMode.CONTINUOUS,
|
||||
TrackingMode.DURATION,
|
||||
0,
|
||||
),
|
||||
) as CreateExerciseResult.Created).exercise
|
||||
assertEquals(ActiveDraftMutationResult.Saved, repository.startActiveSessionDraft())
|
||||
val training =
|
||||
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
||||
val base = OffsetDateTime.parse(checkNotNull(training.startedAt))
|
||||
val firstMeasurement =
|
||||
ParsedHeartRateMeasurement(
|
||||
bpm = 101,
|
||||
sensorContactDetected = true,
|
||||
energyExpended = 12,
|
||||
rrIntervals1024 = listOf(611),
|
||||
)
|
||||
assertTrue(
|
||||
repository.recordLiveHeartRateForActiveSession(
|
||||
"Synthetic HR",
|
||||
base.plusSeconds(1).toString(),
|
||||
firstMeasurement,
|
||||
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||
)
|
||||
val originalCapture = checkNotNull(repository.activeHeartRateCapture())
|
||||
assertEquals(HeartRateContextKind.SESSION, originalCapture.contextKind)
|
||||
|
||||
assertEquals(
|
||||
ActiveDraftMutationResult.Saved,
|
||||
repository.saveActiveSessionDraft(training.copy(sessionType = SessionType.CARDIO)),
|
||||
)
|
||||
val transitionedCapture = checkNotNull(repository.activeHeartRateCapture())
|
||||
assertEquals(originalCapture.captureId, transitionedCapture.captureId)
|
||||
assertEquals(HeartRateContextKind.CARDIO, transitionedCapture.contextKind)
|
||||
|
||||
val entry =
|
||||
SessionExerciseDraft(
|
||||
exercise = exercise,
|
||||
plan = SessionExercisePlan(sets = 0, durationSeconds = 60),
|
||||
)
|
||||
val cardio =
|
||||
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
||||
assertEquals(
|
||||
ActiveDraftMutationResult.Saved,
|
||||
repository.saveActiveSessionDraft(cardio.copy(exercises = listOf(entry))),
|
||||
)
|
||||
assertTrue(
|
||||
repository.startActiveSessionExercise(entry.entryId, base.plusSeconds(2).toString()) is
|
||||
TrainlogRepository.SessionExerciseTimingResult.Started,
|
||||
)
|
||||
val target = CardioTargetSnapshot(100, 110)
|
||||
val phase =
|
||||
CardioGuidedPhase(
|
||||
phaseId = "cgp_" + UUID.randomUUID(),
|
||||
kind = CardioPhaseKind.WARMUP,
|
||||
target = target,
|
||||
exitCondition = CardioPhaseExitCondition.FixedDuration(60),
|
||||
)
|
||||
assertTrue(
|
||||
repository.startCardioGuidancePhase(
|
||||
entry.entryId,
|
||||
phase,
|
||||
base.plusSeconds(3).toString(),
|
||||
) is TrainlogRepository.CardioGuidanceMutationResult.Applied,
|
||||
)
|
||||
val secondAt = base.plusSeconds(4).toString()
|
||||
assertTrue(
|
||||
repository.recordLiveHeartRateForActiveSession(
|
||||
"Synthetic HR",
|
||||
secondAt,
|
||||
firstMeasurement.copy(bpm = 99),
|
||||
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||
)
|
||||
assertTrue(
|
||||
repository.recordCardioGuidanceOutput(
|
||||
phase.phaseId,
|
||||
secondAt,
|
||||
CardioGuidanceOutput(CardioGuidanceInstruction.ACCELERATE, 99, target),
|
||||
) is TrainlogRepository.CardioGuidanceMutationResult.Applied,
|
||||
)
|
||||
assertEquals(
|
||||
listOf(CardioGuidanceInstruction.ACCELERATE),
|
||||
repository.listCardioGuidanceEvents(phase.phaseId).map { it.instruction },
|
||||
)
|
||||
|
||||
val lateReverse =
|
||||
repository.saveActiveSessionDraft(
|
||||
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded)
|
||||
.draft
|
||||
.copy(sessionType = SessionType.TRAINING),
|
||||
)
|
||||
assertTrue(lateReverse is ActiveDraftMutationResult.Error)
|
||||
assertEquals(
|
||||
SessionType.CARDIO,
|
||||
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded)
|
||||
.draft
|
||||
.sessionType,
|
||||
)
|
||||
assertEquals(
|
||||
HeartRateContextKind.CARDIO,
|
||||
checkNotNull(repository.activeHeartRateCapture()).contextKind,
|
||||
)
|
||||
|
||||
SQLiteDatabase.openDatabase(
|
||||
context.getDatabasePath(name).path,
|
||||
null,
|
||||
SQLiteDatabase.OPEN_READONLY,
|
||||
).use { db ->
|
||||
db.rawQuery(
|
||||
"SELECT sequence,bpm,exercise_entry_id FROM heart_rate_samples " +
|
||||
"WHERE capture_id=? ORDER BY sequence",
|
||||
arrayOf(originalCapture.captureId),
|
||||
).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(0L, cursor.getLong(0))
|
||||
assertEquals(101, cursor.getInt(1))
|
||||
assertTrue(cursor.isNull(2))
|
||||
assertTrue(cursor.moveToNext())
|
||||
assertEquals(1L, cursor.getLong(0))
|
||||
assertEquals(99, cursor.getInt(1))
|
||||
assertEquals(entry.entryId, cursor.getString(2))
|
||||
assertTrue(!cursor.moveToNext())
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(ActiveDraftMutationResult.Saved, repository.discardActiveSessionDraft())
|
||||
assertNull(repository.activeHeartRateCapture())
|
||||
assertNull(repository.activeCardioGuidancePhase())
|
||||
assertTrue(repository.listActiveSessionExerciseTimings().isEmpty())
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cardioCaptureCanReturnToTrainingBeforeCardioOnlyFactsExist() {
|
||||
val name = "guidance-early-reverse-" + UUID.randomUUID() + ".db"
|
||||
val repository = TrainlogRepository(context, name)
|
||||
try {
|
||||
assertEquals(ActiveDraftMutationResult.Saved, repository.startActiveSessionDraft())
|
||||
val training =
|
||||
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
||||
val observedAt =
|
||||
OffsetDateTime.parse(checkNotNull(training.startedAt)).plusSeconds(1).toString()
|
||||
assertTrue(
|
||||
repository.recordLiveHeartRateForActiveSession(
|
||||
"Synthetic HR",
|
||||
observedAt,
|
||||
ParsedHeartRateMeasurement(103, null, null, listOf(600)),
|
||||
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||
)
|
||||
val captureId = checkNotNull(repository.activeHeartRateCapture()).captureId
|
||||
assertEquals(
|
||||
ActiveDraftMutationResult.Saved,
|
||||
repository.saveActiveSessionDraft(training.copy(sessionType = SessionType.CARDIO)),
|
||||
)
|
||||
val cardio =
|
||||
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
||||
assertEquals(
|
||||
ActiveDraftMutationResult.Saved,
|
||||
repository.saveActiveSessionDraft(cardio.copy(sessionType = SessionType.TRAINING)),
|
||||
)
|
||||
val reversed = checkNotNull(repository.activeHeartRateCapture())
|
||||
assertEquals(captureId, reversed.captureId)
|
||||
assertEquals(HeartRateContextKind.SESSION, reversed.contextKind)
|
||||
|
||||
SQLiteDatabase.openDatabase(
|
||||
context.getDatabasePath(name).path,
|
||||
null,
|
||||
SQLiteDatabase.OPEN_READONLY,
|
||||
).use { db ->
|
||||
db.rawQuery(
|
||||
"SELECT COUNT(*),MIN(bpm) FROM heart_rate_samples WHERE capture_id=?",
|
||||
arrayOf(captureId),
|
||||
).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(1, cursor.getInt(0))
|
||||
assertEquals(103, cursor.getInt(1))
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun phaseRequiresActiveCardioExerciseAndRecordsOnlyInstructionChanges() {
|
||||
val active = activeCardio()
|
||||
val repository = active.repository
|
||||
try {
|
||||
val target = CardioTargetSnapshot(120, 140)
|
||||
val phase =
|
||||
CardioGuidedPhase(
|
||||
phaseId = "cgp_" + UUID.randomUUID(),
|
||||
kind = CardioPhaseKind.WORK,
|
||||
target = target,
|
||||
exitCondition = CardioPhaseExitCondition.FixedDuration(180),
|
||||
)
|
||||
val started =
|
||||
repository.startCardioGuidancePhase(
|
||||
active.entryId,
|
||||
phase,
|
||||
active.at(10),
|
||||
) as TrainlogRepository.CardioGuidanceMutationResult.Applied
|
||||
assertEquals(CardioGuidanceInstruction.MAINTAIN, started.phase!!.currentInstruction)
|
||||
|
||||
repository.recordCardioGuidanceOutput(
|
||||
phase.phaseId,
|
||||
active.at(13),
|
||||
CardioGuidanceOutput(
|
||||
CardioGuidanceInstruction.ACCELERATE,
|
||||
110,
|
||||
target,
|
||||
),
|
||||
)
|
||||
repository.recordCardioGuidanceOutput(
|
||||
phase.phaseId,
|
||||
active.at(14),
|
||||
CardioGuidanceOutput(
|
||||
CardioGuidanceInstruction.ACCELERATE,
|
||||
111,
|
||||
target,
|
||||
),
|
||||
)
|
||||
repository.recordCardioGuidanceOutput(
|
||||
phase.phaseId,
|
||||
active.at(30),
|
||||
CardioGuidanceOutput(
|
||||
CardioGuidanceInstruction.MAINTAIN,
|
||||
130,
|
||||
target,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
listOf(
|
||||
CardioGuidanceInstruction.ACCELERATE,
|
||||
CardioGuidanceInstruction.MAINTAIN,
|
||||
),
|
||||
repository.listCardioGuidanceEvents(phase.phaseId).map { it.instruction },
|
||||
)
|
||||
|
||||
repository.finishActiveSessionExercise(
|
||||
active.entryId,
|
||||
active.at(600),
|
||||
)
|
||||
assertNull(repository.activeCardioGuidancePhase())
|
||||
val completedDraft =
|
||||
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
||||
assertEquals(
|
||||
ActiveDraftMutationResult.Saved,
|
||||
repository.saveActiveSessionDraft(
|
||||
completedDraft.copy(
|
||||
exercises =
|
||||
completedDraft.exercises.map {
|
||||
if (it.entryId == active.entryId) {
|
||||
it.copy(continuousDurationSeconds = 600)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
val finalized = repository.finalizeActiveSessionDraft()
|
||||
assertTrue(finalized is FinalizeActiveDraftResult.Saved)
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(active.databaseName)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun guidanceCannotStartBeforeExerciseTimingIsActive() {
|
||||
val name = "guidance-inactive-" + UUID.randomUUID() + ".db"
|
||||
val repository = TrainlogRepository(context, name)
|
||||
try {
|
||||
val exercise =
|
||||
(repository.createExercise(
|
||||
NewExerciseProfile(
|
||||
"Vélo guidé",
|
||||
RecordingMode.CONTINUOUS,
|
||||
TrackingMode.DURATION,
|
||||
0,
|
||||
),
|
||||
) as CreateExerciseResult.Created).exercise
|
||||
repository.startActiveSessionDraft()
|
||||
val loaded = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
|
||||
val entry = SessionExerciseDraft(exercise = exercise, continuousDurationSeconds = 60)
|
||||
repository.saveActiveSessionDraft(
|
||||
loaded.draft.copy(sessionType = SessionType.CARDIO, exercises = listOf(entry)),
|
||||
)
|
||||
val result =
|
||||
repository.startCardioGuidancePhase(
|
||||
entry.entryId,
|
||||
CardioGuidedPhase(
|
||||
"cgp_" + UUID.randomUUID(),
|
||||
CardioPhaseKind.WARMUP,
|
||||
CardioTargetSnapshot(100, 115),
|
||||
CardioPhaseExitCondition.FixedDuration(60),
|
||||
),
|
||||
"2026-09-23T13:00:00+02:00",
|
||||
)
|
||||
assertTrue(result is TrainlogRepository.CardioGuidanceMutationResult.Invalid)
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun abandonedDraftDeletesOnlyUnfinishedGuidanceRun() {
|
||||
val active = activeCardio()
|
||||
val repository = active.repository
|
||||
try {
|
||||
repository.startCardioGuidancePhase(
|
||||
active.entryId,
|
||||
CardioGuidedPhase(
|
||||
"cgp_" + UUID.randomUUID(),
|
||||
CardioPhaseKind.WORK,
|
||||
CardioTargetSnapshot(120, 140),
|
||||
CardioPhaseExitCondition.FixedDuration(120),
|
||||
),
|
||||
active.at(10),
|
||||
)
|
||||
assertTrue(repository.activeCardioGuidancePhase() != null)
|
||||
repository.discardActiveSessionDraft()
|
||||
assertNull(repository.activeCardioGuidancePhase())
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(active.databaseName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import com.labfytools.trainlog.model.CardioGuidedPhase
|
||||
import com.labfytools.trainlog.model.CardioPhaseExitCondition
|
||||
import com.labfytools.trainlog.model.CardioPhaseKind
|
||||
import com.labfytools.trainlog.model.CardioTargetSnapshot
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class CardioPhaseCompletionTest {
|
||||
private val target = CardioTargetSnapshot(120, 140)
|
||||
|
||||
private fun phase(
|
||||
exit: CardioPhaseExitCondition,
|
||||
targetOverride: CardioTargetSnapshot? = target,
|
||||
) = CardioGuidedPhase(
|
||||
phaseId = "phase_test",
|
||||
kind = CardioPhaseKind.WORK,
|
||||
target = targetOverride,
|
||||
exitCondition = exit,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun fixedDurationUsesElapsedTimeOnly() {
|
||||
val p = phase(CardioPhaseExitCondition.FixedDuration(180))
|
||||
assertFalse(CardioPhaseCompletion.isComplete(p, 179, null, false))
|
||||
assertTrue(CardioPhaseCompletion.isComplete(p, 180, null, false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun enterTargetRequiresFreshMeasurementInsideSnapshot() {
|
||||
val p = phase(CardioPhaseExitCondition.EnterTarget)
|
||||
assertFalse(CardioPhaseCompletion.isComplete(p, 10, 130, false))
|
||||
assertFalse(CardioPhaseCompletion.isComplete(p, 10, 119, true))
|
||||
assertTrue(CardioPhaseCompletion.isComplete(p, 10, 120, true))
|
||||
assertTrue(CardioPhaseCompletion.isComplete(p, 10, 140, true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun enterTargetWithoutTargetNeverCompletes() {
|
||||
val p = phase(CardioPhaseExitCondition.EnterTarget, null)
|
||||
assertFalse(CardioPhaseCompletion.isComplete(p, 100, 130, true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recoveryThresholdRequiresFreshMeasurement() {
|
||||
val p = phase(CardioPhaseExitCondition.RecoverBelow(110), null)
|
||||
assertFalse(CardioPhaseCompletion.isComplete(p, 30, 105, false))
|
||||
assertFalse(CardioPhaseCompletion.isComplete(p, 30, 111, true))
|
||||
assertTrue(CardioPhaseCompletion.isComplete(p, 30, 110, true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun durationOrRecoveryCanFinishFromEitherRealCondition() {
|
||||
val p = phase(CardioPhaseExitCondition.DurationOrRecoverBelow(300, 110), null)
|
||||
assertFalse(CardioPhaseCompletion.isComplete(p, 299, 120, true))
|
||||
assertTrue(CardioPhaseCompletion.isComplete(p, 100, 108, true))
|
||||
assertTrue(CardioPhaseCompletion.isComplete(p, 300, null, false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun negativeElapsedTimeCannotCompleteAnything() {
|
||||
val p = phase(CardioPhaseExitCondition.FixedDuration(0))
|
||||
assertFalse(CardioPhaseCompletion.isComplete(p, -1, 130, true))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||
import com.labfytools.trainlog.model.NewExerciseProfile
|
||||
import com.labfytools.trainlog.model.RecordingMode
|
||||
import com.labfytools.trainlog.model.SessionExerciseDraft
|
||||
import com.labfytools.trainlog.model.SessionSetDraft
|
||||
import com.labfytools.trainlog.model.SessionType
|
||||
import com.labfytools.trainlog.model.TrackingMode
|
||||
import java.nio.file.Files
|
||||
import java.util.UUID
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
class CardioSessionSyncGenerationTest {
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
|
||||
@Test
|
||||
fun dedicatedCardioCompanionImportsWithoutWideningFrozenHistory() {
|
||||
val sourceName = "cardio-sync-" + UUID.randomUUID() + ".db"
|
||||
val root = Files.createTempDirectory("trainlog-cardio-sync-").toFile()
|
||||
val source = TrainlogRepository(context, sourceName)
|
||||
try {
|
||||
val exercise =
|
||||
(source.createExercise(
|
||||
NewExerciseProfile(
|
||||
"Cardio sync exercise",
|
||||
RecordingMode.SETS,
|
||||
TrackingMode.REPS,
|
||||
0,
|
||||
),
|
||||
) as CreateExerciseResult.Created).exercise
|
||||
val entry =
|
||||
SessionExerciseDraft(
|
||||
exercise = exercise,
|
||||
sets = listOf(SessionSetDraft(reps = 15)),
|
||||
)
|
||||
assertEquals(
|
||||
ActiveDraftMutationResult.Saved,
|
||||
source.saveActiveSessionDraft(
|
||||
ActiveSessionDraft(
|
||||
sessionType = SessionType.CARDIO,
|
||||
exercises = listOf(entry),
|
||||
),
|
||||
),
|
||||
)
|
||||
assertTrue(
|
||||
source.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved,
|
||||
)
|
||||
val sessionId = source.listSessions().single().sessionId
|
||||
|
||||
val repositoryRoot =
|
||||
generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))) {
|
||||
it.parentFile
|
||||
}.first { java.io.File(it, "tools/sync_generation_exchange.py").isFile }
|
||||
|
||||
fun run(vararg command: String): String {
|
||||
val process =
|
||||
ProcessBuilder(*command)
|
||||
.directory(repositoryRoot)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
val output = process.inputStream.bufferedReader().readText()
|
||||
assertEquals(
|
||||
"command failed: " + command.joinToString(" ") + "\n" + output,
|
||||
0,
|
||||
process.waitFor(),
|
||||
)
|
||||
return output
|
||||
}
|
||||
|
||||
val desktopDb = java.io.File(root, "desktop.sqlite")
|
||||
run(
|
||||
java.io.File(
|
||||
repositoryRoot,
|
||||
"build/tui/sync-generation-fixture",
|
||||
).absolutePath,
|
||||
desktopDb.absolutePath,
|
||||
)
|
||||
val desktopPeer =
|
||||
Regex("PEER_ID=(peer_[^\\s]+)")
|
||||
.find(
|
||||
run(
|
||||
"python3",
|
||||
java.io.File(
|
||||
repositoryRoot,
|
||||
"tools/sync_generation_exchange.py",
|
||||
).absolutePath,
|
||||
"peer-id",
|
||||
"--database",
|
||||
desktopDb.absolutePath,
|
||||
),
|
||||
)!!
|
||||
.groupValues[1]
|
||||
|
||||
val service = SyncGenerationService(source)
|
||||
val captured = service.capture(root, desktopPeer)
|
||||
val history =
|
||||
JSONObject(
|
||||
java.io.File(
|
||||
captured.stagingDirectory,
|
||||
"history-v4.json",
|
||||
).readText(),
|
||||
)
|
||||
assertEquals(0, history.getJSONArray("sessions").length())
|
||||
|
||||
val cardio =
|
||||
JSONObject(
|
||||
java.io.File(
|
||||
captured.stagingDirectory,
|
||||
"cardio-sessions-v1.json",
|
||||
).readText(),
|
||||
)
|
||||
assertEquals("trainlog-cardio-sessions", cardio.getString("format"))
|
||||
assertEquals(1, cardio.getJSONArray("sessions").length())
|
||||
assertEquals(
|
||||
"cardio",
|
||||
cardio
|
||||
.getJSONArray("sessions")
|
||||
.getJSONObject(0)
|
||||
.getString("session_type"),
|
||||
)
|
||||
|
||||
val published = service.publish(captured, java.io.File(root, "objects"))
|
||||
val ackFile = java.io.File(root, "ack.json")
|
||||
run(
|
||||
"python3",
|
||||
java.io.File(
|
||||
repositoryRoot,
|
||||
"tools/sync_generation_exchange.py",
|
||||
).absolutePath,
|
||||
"consume-desktop",
|
||||
published.absolutePath,
|
||||
"--database",
|
||||
desktopDb.absolutePath,
|
||||
"--ack-output",
|
||||
ackFile.absolutePath,
|
||||
)
|
||||
val ack = JSONObject(ackFile.readText())
|
||||
assertEquals(
|
||||
"desktop ACK diagnostic: " + ack.getString("diagnostic"),
|
||||
"consumed",
|
||||
ack.getString("result"),
|
||||
)
|
||||
assertEquals(
|
||||
"training|cardio|15",
|
||||
run(
|
||||
"sqlite3",
|
||||
desktopDb.absolutePath,
|
||||
"SELECT s.session_type || '|' || s.session_kind || '|' || ps.reps " +
|
||||
"FROM sessions s JOIN session_exercises se ON se.session_row_id=s.id " +
|
||||
"JOIN performed_sets ps ON ps.session_exercise_row_id=se.id " +
|
||||
"WHERE s.session_id='$sessionId';",
|
||||
).trim(),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"acknowledged",
|
||||
service.acceptAcknowledgement(ackFile.readBytes()),
|
||||
)
|
||||
assertEquals(
|
||||
ack.toString(),
|
||||
JSONObject(
|
||||
run(
|
||||
"python3",
|
||||
java.io.File(
|
||||
repositoryRoot,
|
||||
"tools/sync_generation_exchange.py",
|
||||
).absolutePath,
|
||||
"consume-desktop",
|
||||
published.absolutePath,
|
||||
"--database",
|
||||
desktopDb.absolutePath,
|
||||
"--ack-output",
|
||||
java.io.File(root, "replay-ack.json").absolutePath,
|
||||
).let { java.io.File(root, "replay-ack.json").readText() },
|
||||
).toString(),
|
||||
)
|
||||
} finally {
|
||||
source.close()
|
||||
context.deleteDatabase(sourceName)
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||
import com.labfytools.trainlog.model.NewExerciseProfile
|
||||
import com.labfytools.trainlog.model.RecordingMode
|
||||
import com.labfytools.trainlog.model.SessionExerciseDraft
|
||||
import com.labfytools.trainlog.model.SessionSetDraft
|
||||
import com.labfytools.trainlog.model.SessionType
|
||||
import com.labfytools.trainlog.model.TrackingMode
|
||||
import java.util.UUID
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
class CardioSessionTypeTest {
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
|
||||
@Test
|
||||
fun cardioIsCanonicalLocallyAndExcludedFromFrozenMobileHistory() {
|
||||
val name = "cardio-kind-" + UUID.randomUUID() + ".db"
|
||||
val repository = TrainlogRepository(context, name)
|
||||
try {
|
||||
val exercise =
|
||||
(repository.createExercise(
|
||||
NewExerciseProfile(
|
||||
"Cardio kind exercise",
|
||||
RecordingMode.SETS,
|
||||
TrackingMode.REPS,
|
||||
0,
|
||||
),
|
||||
) as CreateExerciseResult.Created).exercise
|
||||
val occurrence =
|
||||
SessionExerciseDraft(
|
||||
exercise = exercise,
|
||||
sets = listOf(SessionSetDraft(reps = 1)),
|
||||
)
|
||||
assertEquals(
|
||||
ActiveDraftMutationResult.Saved,
|
||||
repository.saveActiveSessionDraft(
|
||||
ActiveSessionDraft(
|
||||
sessionType = SessionType.CARDIO,
|
||||
exercises = listOf(occurrence),
|
||||
),
|
||||
),
|
||||
)
|
||||
val loaded =
|
||||
repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
|
||||
assertEquals(SessionType.CARDIO, loaded.draft.sessionType)
|
||||
|
||||
val db = context.openOrCreateDatabase(name, Context.MODE_PRIVATE, null)
|
||||
db.rawQuery(
|
||||
"SELECT session_type,session_kind FROM active_session_draft WHERE id=1",
|
||||
null,
|
||||
).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals("training", cursor.getString(0))
|
||||
assertEquals("cardio", cursor.getString(1))
|
||||
}
|
||||
db.close()
|
||||
|
||||
assertTrue(
|
||||
repository.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved,
|
||||
)
|
||||
assertEquals(
|
||||
SessionType.CARDIO,
|
||||
repository.listSessions().single().sessionType,
|
||||
)
|
||||
|
||||
val frozen = JSONObject(repository.buildMobileExportV4Json())
|
||||
assertEquals(0, frozen.getJSONArray("sessions").length())
|
||||
|
||||
val verify = context.openOrCreateDatabase(name, Context.MODE_PRIVATE, null)
|
||||
verify.rawQuery(
|
||||
"SELECT session_type,session_kind FROM sessions",
|
||||
null,
|
||||
).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals("training", cursor.getString(0))
|
||||
assertEquals("cardio", cursor.getString(1))
|
||||
}
|
||||
verify.close()
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class HeartRateLiveStateTest {
|
||||
@Test
|
||||
fun liveStateDropsStaleBpmWithoutInventingMeasurement() {
|
||||
HeartRateLiveState.disconnected()
|
||||
val observed = mutableListOf<HeartRateLiveSnapshot>()
|
||||
val subscription = HeartRateLiveState.subscribe { observed += it }
|
||||
try {
|
||||
HeartRateLiveState.connecting()
|
||||
HeartRateLiveState.measurement(
|
||||
ParsedHeartRateMeasurement(
|
||||
bpm = 82,
|
||||
sensorContactDetected = null,
|
||||
energyExpended = null,
|
||||
rrIntervals1024 = listOf(768),
|
||||
),
|
||||
)
|
||||
assertEquals(HeartRateLivePhase.CONNECTED, HeartRateLiveState.current().phase)
|
||||
assertEquals(82, HeartRateLiveState.current().bpm)
|
||||
assertEquals(listOf(768), HeartRateLiveState.current().rrIntervals1024)
|
||||
assertEquals(1L, HeartRateLiveState.current().notificationCount)
|
||||
|
||||
HeartRateLiveState.stale()
|
||||
assertEquals(HeartRateLivePhase.STALE, HeartRateLiveState.current().phase)
|
||||
assertNull(HeartRateLiveState.current().bpm)
|
||||
assertEquals(listOf(768), HeartRateLiveState.current().rrIntervals1024)
|
||||
|
||||
HeartRateLiveState.disconnected()
|
||||
assertEquals(HeartRateLivePhase.DISCONNECTED, HeartRateLiveState.current().phase)
|
||||
assertNull(HeartRateLiveState.current().bpm)
|
||||
assertEquals(5, observed.size)
|
||||
} finally {
|
||||
subscription.close()
|
||||
HeartRateLiveState.disconnected()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class HeartRateMeasurementParserTest {
|
||||
private fun parsed(vararg bytes: Int): ParsedHeartRateMeasurement {
|
||||
val result =
|
||||
HeartRateMeasurementParser.parse(
|
||||
ByteArray(bytes.size) { index -> bytes[index].toByte() },
|
||||
)
|
||||
return (result as HeartRateMeasurementParseResult.Parsed).measurement
|
||||
}
|
||||
|
||||
private fun invalid(vararg bytes: Int): HeartRateMeasurementParseResult.Invalid =
|
||||
HeartRateMeasurementParser.parse(
|
||||
ByteArray(bytes.size) { index -> bytes[index].toByte() },
|
||||
) as HeartRateMeasurementParseResult.Invalid
|
||||
|
||||
@Test
|
||||
fun parsesEightBitBpm() {
|
||||
val value = parsed(0x00, 72)
|
||||
assertEquals(72, value.bpm)
|
||||
assertNull(value.sensorContactDetected)
|
||||
assertNull(value.energyExpended)
|
||||
assertTrue(value.rrIntervals1024.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesSixteenBitBpmLittleEndian() {
|
||||
assertEquals(300, parsed(0x01, 0x2c, 0x01).bpm)
|
||||
assertEquals(65535, parsed(0x01, 0xff, 0xff).bpm)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contactIsNullableAndOnlyMeaningfulWhenSupported() {
|
||||
assertNull(parsed(0x02, 80).sensorContactDetected)
|
||||
assertEquals(false, parsed(0x04, 80).sensorContactDetected)
|
||||
assertEquals(true, parsed(0x06, 80).sensorContactDetected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesEnergyExpended() {
|
||||
val value = parsed(0x08, 90, 0x34, 0x12)
|
||||
assertEquals(0x1234, value.energyExpended)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesOneAndMultipleRawRrIntervals() {
|
||||
assertEquals(listOf(1024), parsed(0x10, 60, 0x00, 0x04).rrIntervals1024)
|
||||
assertEquals(
|
||||
listOf(1024, 1000, 65535),
|
||||
parsed(0x10, 60, 0x00, 0x04, 0xe8, 0x03, 0xff, 0xff).rrIntervals1024,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesCombinedKnownFlagsInWireOrder() {
|
||||
val value =
|
||||
parsed(
|
||||
0x1f,
|
||||
0x96,
|
||||
0x00,
|
||||
0x07,
|
||||
0x00,
|
||||
0xf0,
|
||||
0x03,
|
||||
0x00,
|
||||
0x04,
|
||||
)
|
||||
assertEquals(150, value.bpm)
|
||||
assertEquals(true, value.sensorContactDetected)
|
||||
assertEquals(7, value.energyExpended)
|
||||
assertEquals(listOf(1008, 1024), value.rrIntervals1024)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsTruncatedAndIncoherentPayloads() {
|
||||
assertEquals("missing flags", invalid().reason)
|
||||
assertEquals("truncated 16-bit BPM", invalid(0x01, 0x2c).reason)
|
||||
assertEquals("truncated energy-expended value", invalid(0x08, 70, 0x01).reason)
|
||||
assertEquals("RR flag has no interval", invalid(0x10, 70).reason)
|
||||
assertEquals("truncated RR interval", invalid(0x10, 70, 0x01).reason)
|
||||
assertEquals("unexpected trailing bytes", invalid(0x00, 70, 0x01).reason)
|
||||
assertEquals("reserved flags are set", invalid(0x20, 70).reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun preservesBoundaryBpmValues() {
|
||||
assertEquals(0, parsed(0x00, 0).bpm)
|
||||
assertEquals(255, parsed(0x00, 255).bpm)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.labfytools.trainlog.model.HeartRateContextKind
|
||||
import com.labfytools.trainlog.model.HeartRateRrInterval
|
||||
import com.labfytools.trainlog.model.SleepDiaryDraft
|
||||
import java.util.UUID
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
class HeartRateRepositoryTest {
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
|
||||
@Test
|
||||
fun sleepCaptureStoresRawSamplesAndExportsOnlyAfterStop() {
|
||||
val name = "heart-rate-${UUID.randomUUID()}.db"
|
||||
val repository = TrainlogRepository(context, name)
|
||||
try {
|
||||
val created =
|
||||
repository.saveSleepDiary(
|
||||
SleepDiaryDraft(
|
||||
nightStartDate = "2026-09-22",
|
||||
nightEndDate = "2026-09-23",
|
||||
createdAt = "2026-09-22T22:00:00+02:00",
|
||||
updatedAt = "2026-09-22T22:00:00+02:00",
|
||||
sleepQuality = null,
|
||||
wakeQuality = null,
|
||||
dayForm = null,
|
||||
treatmentAndNotes = "",
|
||||
events = emptyList(),
|
||||
),
|
||||
) as TrainlogRepository.SaveSleepDiaryResult.Saved
|
||||
|
||||
val started =
|
||||
repository.startHeartRateCapture(
|
||||
HeartRateContextKind.SLEEP,
|
||||
created.entryId,
|
||||
"2026-09-22T22:30:00+02:00",
|
||||
"Synthetic HR sensor",
|
||||
) as TrainlogRepository.StartHeartRateCaptureResult.Started
|
||||
assertEquals(started.captureId, repository.activeHeartRateCapture()?.captureId)
|
||||
|
||||
val sample =
|
||||
repository.appendHeartRateSample(
|
||||
captureId = started.captureId,
|
||||
observedAt = "2026-09-22T22:30:01+02:00",
|
||||
bpm = 61,
|
||||
sensorContactDetected = true,
|
||||
energyExpended = 7,
|
||||
rrIntervals = listOf(
|
||||
HeartRateRrInterval(0, 1024),
|
||||
HeartRateRrInterval(1, 1000),
|
||||
),
|
||||
) as TrainlogRepository.HeartRateMutationResult.Applied
|
||||
assertEquals(0L, sample.sequence)
|
||||
|
||||
assertEquals(
|
||||
0,
|
||||
JSONObject(repository.buildHeartRateV1Json()).getJSONArray("captures").length(),
|
||||
)
|
||||
assertTrue(
|
||||
repository.stopHeartRateCapture(
|
||||
started.captureId,
|
||||
"2026-09-23T06:30:00+02:00",
|
||||
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||
)
|
||||
assertNull(repository.activeHeartRateCapture())
|
||||
|
||||
val root = JSONObject(repository.buildHeartRateV1Json())
|
||||
assertEquals("trainlog-heart-rate", root.getString("format"))
|
||||
assertEquals(1, root.getInt("version"))
|
||||
val capture = root.getJSONArray("captures").getJSONObject(0)
|
||||
assertEquals(created.entryId, capture.getString("context_id"))
|
||||
assertEquals("sleep", capture.getString("context_kind"))
|
||||
assertEquals("Synthetic HR sensor", capture.getString("sensor_name"))
|
||||
val stored = capture.getJSONArray("samples").getJSONObject(0)
|
||||
assertEquals(61, stored.getInt("bpm"))
|
||||
assertTrue(stored.getBoolean("sensor_contact_detected"))
|
||||
assertEquals(7, stored.getInt("energy_expended"))
|
||||
assertEquals(1024, stored.getJSONArray("rr_intervals_1024").getInt(0))
|
||||
assertEquals(1000, stored.getJSONArray("rr_intervals_1024").getInt(1))
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
class HeartRateSensorPreferencesTest {
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
|
||||
@Test
|
||||
fun selectionIsDurableAndClearable() {
|
||||
val preferences = HeartRateSensorPreferences(context)
|
||||
preferences.clear()
|
||||
assertNull(preferences.selected())
|
||||
|
||||
preferences.select("AA:BB:CC:DD:EE:FF", "Synthetic HR")
|
||||
assertEquals(
|
||||
SelectedHeartRateSensor("AA:BB:CC:DD:EE:FF", "Synthetic HR"),
|
||||
HeartRateSensorPreferences(context).selected(),
|
||||
)
|
||||
|
||||
preferences.clear()
|
||||
assertNull(HeartRateSensorPreferences(context).selected())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,490 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.labfytools.trainlog.model.HeartRateContextKind
|
||||
import com.labfytools.trainlog.model.SessionSetDraft
|
||||
import java.nio.file.Files
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
class ProgramSessionCardioReadinessTest {
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
|
||||
@Test
|
||||
fun programSessionPreservesTimelineAndHeartRateOwnershipThroughDesktopReplay() {
|
||||
val androidName = "program-cardio-readiness-${UUID.randomUUID()}.db"
|
||||
val android = TrainlogRepository(context, androidName)
|
||||
val root = Files.createTempDirectory("trainlog-program-cardio-readiness-").toFile()
|
||||
val repositoryRoot =
|
||||
generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))) {
|
||||
it.parentFile
|
||||
}.first { java.io.File(it, "tools/sync_generation_exchange.py").isFile }
|
||||
val desktopDb = java.io.File(root, "desktop.sqlite")
|
||||
val fixture = java.io.File(repositoryRoot, "build/tui/sync-generation-fixture")
|
||||
val generationTool =
|
||||
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath
|
||||
|
||||
fun run(vararg command: String): String {
|
||||
val process =
|
||||
ProcessBuilder(*command)
|
||||
.directory(repositoryRoot)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
val output = process.inputStream.bufferedReader().readText()
|
||||
assertEquals(
|
||||
"command failed: ${command.joinToString(" ")}\n$output",
|
||||
0,
|
||||
process.waitFor(),
|
||||
)
|
||||
return output
|
||||
}
|
||||
|
||||
fun measurement(bpm: Int, rr: Int) =
|
||||
ParsedHeartRateMeasurement(
|
||||
bpm = bpm,
|
||||
sensorContactDetected = true,
|
||||
energyExpended = null,
|
||||
rrIntervals1024 = listOf(rr),
|
||||
)
|
||||
|
||||
try {
|
||||
val programId = "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
||||
val programSessionId = "pgs_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
|
||||
val programExerciseAId = "ex_dddddddd-dddd-4ddd-8ddd-dddddddddddd"
|
||||
val programExerciseBId = "ex_11111111-1111-4111-8111-111111111111"
|
||||
val catalog =
|
||||
JSONObject()
|
||||
.put("format", "trainlog-pc-catalog")
|
||||
.put("version", 1)
|
||||
.put(
|
||||
"exercises",
|
||||
JSONArray()
|
||||
.put(
|
||||
JSONObject()
|
||||
.put("exercise_id", programExerciseAId)
|
||||
.put("name", "Program exercise fixture A")
|
||||
.put("recording_mode", "sets")
|
||||
.put("tracking_mode", "reps")
|
||||
.put("data_fields", 0),
|
||||
)
|
||||
.put(
|
||||
JSONObject()
|
||||
.put("exercise_id", programExerciseBId)
|
||||
.put("name", "Program exercise fixture B")
|
||||
.put("recording_mode", "sets")
|
||||
.put("tracking_mode", "reps")
|
||||
.put("data_fields", 0),
|
||||
),
|
||||
)
|
||||
.toString()
|
||||
assertTrue(android.applyPcCatalogJson(catalog) is PcCatalogImportResult.Applied)
|
||||
val program =
|
||||
JSONObject()
|
||||
.put("program_id", programId)
|
||||
.put("revision_id", "pgr_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
|
||||
.put("title", "Tomorrow readiness")
|
||||
.put("note", JSONObject.NULL)
|
||||
.put("state", "active")
|
||||
.put("start_date", "2026-09-24")
|
||||
.put("end_date", JSONObject.NULL)
|
||||
.put("created_at", "2026-09-23T16:00:00Z")
|
||||
.put("updated_at", "2026-09-23T16:00:00Z")
|
||||
.put("source_format", "trainlog-program")
|
||||
.put("source_version", 1)
|
||||
.put("source_payload_sha256", "a".repeat(64))
|
||||
.put(
|
||||
"sessions",
|
||||
JSONArray().put(
|
||||
JSONObject()
|
||||
.put("program_session_id", programSessionId)
|
||||
.put("title", "Session A")
|
||||
.put("session_type", "training")
|
||||
.put("planned_for", "2026-09-24")
|
||||
.put("note", JSONObject.NULL)
|
||||
.put(
|
||||
"occurrences",
|
||||
JSONArray()
|
||||
.put(
|
||||
JSONObject()
|
||||
.put(
|
||||
"entry_id",
|
||||
"pge_cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
||||
)
|
||||
.put("exercise_id", programExerciseAId)
|
||||
.put("equipment_id", JSONObject.NULL)
|
||||
.put("load_mode", "external")
|
||||
.put("rest_seconds", 90)
|
||||
.put("target_sets", 1)
|
||||
.put("target_reps", 8)
|
||||
.put("target_duration_seconds", JSONObject.NULL)
|
||||
.put("target_weight_kg", 40.0)
|
||||
.put("notes", JSONObject.NULL),
|
||||
)
|
||||
.put(
|
||||
JSONObject()
|
||||
.put(
|
||||
"entry_id",
|
||||
"pge_22222222-2222-4222-8222-222222222222",
|
||||
)
|
||||
.put("exercise_id", programExerciseBId)
|
||||
.put("equipment_id", JSONObject.NULL)
|
||||
.put("load_mode", "external")
|
||||
.put("rest_seconds", 60)
|
||||
.put("target_sets", 1)
|
||||
.put("target_reps", 10)
|
||||
.put("target_duration_seconds", JSONObject.NULL)
|
||||
.put("target_weight_kg", 25.0)
|
||||
.put("notes", JSONObject.NULL),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
val programs =
|
||||
JSONObject()
|
||||
.put("format", "trainlog-programs")
|
||||
.put("version", 1)
|
||||
.put("generated_at", "2026-09-23T16:01:00Z")
|
||||
.put("programs", JSONArray().put(program))
|
||||
.put("deletions", JSONArray())
|
||||
.toString()
|
||||
assertEquals(
|
||||
ProgramsImportResult.Applied(1, 0, 0),
|
||||
android.applyProgramsV1Json(programs),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
StartProgramSessionResult.Started,
|
||||
android.startSyncedProgramSession(programId, programSessionId),
|
||||
)
|
||||
val opened = android.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
|
||||
val sessionId = checkNotNull(opened.draft.sessionId)
|
||||
val sessionStartedAt = checkNotNull(opened.draft.startedAt)
|
||||
assertEquals(programId, opened.draft.sourceProgramId)
|
||||
assertEquals(programSessionId, opened.draft.sourceProgramSessionId)
|
||||
assertEquals(2, opened.draft.exercises.size)
|
||||
val exerciseA = opened.draft.exercises[0]
|
||||
val exerciseBEntry = opened.draft.exercises[1]
|
||||
assertEquals(programExerciseAId, exerciseA.exercise.exerciseId)
|
||||
assertEquals(programExerciseBId, exerciseBEntry.exercise.exerciseId)
|
||||
assertTrue(exerciseA.entryId != exerciseBEntry.entryId)
|
||||
assertEquals(
|
||||
ActiveDraftMutationResult.Saved,
|
||||
android.saveActiveSessionDraft(
|
||||
opened.draft.copy(
|
||||
exercises =
|
||||
listOf(
|
||||
exerciseA.copy(
|
||||
sets = listOf(SessionSetDraft(reps = 8, weightKg = 40.0)),
|
||||
),
|
||||
exerciseBEntry.copy(
|
||||
sets = listOf(SessionSetDraft(reps = 10, weightKg = 25.0)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
val edited = android.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
|
||||
assertEquals(sessionId, edited.draft.sessionId)
|
||||
assertEquals(sessionStartedAt, edited.draft.startedAt)
|
||||
assertEquals(programId, edited.draft.sourceProgramId)
|
||||
assertEquals(programSessionId, edited.draft.sourceProgramSessionId)
|
||||
|
||||
val preExerciseAt = OffsetDateTime.now().toString()
|
||||
assertTrue(
|
||||
android.recordLiveHeartRateForActiveSession(
|
||||
"Synthetic CYCPLUS",
|
||||
preExerciseAt,
|
||||
measurement(91, 675),
|
||||
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||
)
|
||||
val capture = checkNotNull(android.activeHeartRateCapture())
|
||||
assertEquals(HeartRateContextKind.SESSION, capture.contextKind)
|
||||
assertEquals(sessionId, capture.contextId)
|
||||
assertEquals(sessionStartedAt, capture.startedAt)
|
||||
|
||||
val exerciseAStartedAt = OffsetDateTime.now().toString()
|
||||
assertTrue(
|
||||
android.startActiveSessionExercise(exerciseA.entryId, exerciseAStartedAt) is
|
||||
TrainlogRepository.SessionExerciseTimingResult.Started,
|
||||
)
|
||||
val exerciseASampleAt = OffsetDateTime.now().toString()
|
||||
assertTrue(
|
||||
android.recordLiveHeartRateForActiveSession(
|
||||
"Synthetic CYCPLUS",
|
||||
exerciseASampleAt,
|
||||
measurement(112, 548),
|
||||
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||
)
|
||||
val exerciseAEndedAt = OffsetDateTime.now().toString()
|
||||
assertTrue(
|
||||
android.finishActiveSessionExercise(exerciseA.entryId, exerciseAEndedAt) is
|
||||
TrainlogRepository.SessionExerciseTimingResult.Finished,
|
||||
)
|
||||
|
||||
val betweenExercisesAt = OffsetDateTime.now().toString()
|
||||
assertTrue(
|
||||
android.recordLiveHeartRateForActiveSession(
|
||||
"Synthetic CYCPLUS",
|
||||
betweenExercisesAt,
|
||||
measurement(98, 620),
|
||||
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||
)
|
||||
val exerciseBStartedAt = OffsetDateTime.now().toString()
|
||||
assertTrue(
|
||||
android.startActiveSessionExercise(
|
||||
exerciseBEntry.entryId,
|
||||
exerciseBStartedAt,
|
||||
) is TrainlogRepository.SessionExerciseTimingResult.Started,
|
||||
)
|
||||
val exerciseBSampleAt = OffsetDateTime.now().toString()
|
||||
assertTrue(
|
||||
android.recordLiveHeartRateForActiveSession(
|
||||
"Synthetic CYCPLUS",
|
||||
exerciseBSampleAt,
|
||||
measurement(119, 525),
|
||||
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||
)
|
||||
val activeFinalize = android.finalizeActiveSessionDraft()
|
||||
assertTrue(activeFinalize is FinalizeActiveDraftResult.Invalid)
|
||||
assertTrue(
|
||||
(activeFinalize as FinalizeActiveDraftResult.Invalid)
|
||||
.message
|
||||
.startsWith("ACTIVE_EXERCISE:"),
|
||||
)
|
||||
val exerciseBEndedAt = OffsetDateTime.now().toString()
|
||||
assertTrue(
|
||||
android.finishActiveSessionExercise(
|
||||
exerciseBEntry.entryId,
|
||||
exerciseBEndedAt,
|
||||
) is TrainlogRepository.SessionExerciseTimingResult.Finished,
|
||||
)
|
||||
val finalized = android.finalizeActiveSessionDraft()
|
||||
assertTrue(finalized is FinalizeActiveDraftResult.Saved)
|
||||
assertEquals(sessionId, (finalized as FinalizeActiveDraftResult.Saved).sessionId)
|
||||
assertNull(android.activeHeartRateCapture())
|
||||
|
||||
val timeline = JSONObject(android.buildSessionTimelineV1Json())
|
||||
val timelineSession = timeline.getJSONArray("sessions").getJSONObject(0)
|
||||
val sessionEndedAt = timelineSession.getString("ended_at")
|
||||
assertEquals(sessionId, timelineSession.getString("session_id"))
|
||||
assertEquals(sessionStartedAt, timelineSession.getString("started_at"))
|
||||
assertTrue(OffsetDateTime.parse(sessionEndedAt) > OffsetDateTime.parse(sessionStartedAt))
|
||||
val timelineExercises = timelineSession.getJSONArray("exercises")
|
||||
assertEquals(2, timelineExercises.length())
|
||||
assertEquals(exerciseA.entryId, timelineExercises.getJSONObject(0).getString("entry_id"))
|
||||
assertEquals(exerciseAStartedAt, timelineExercises.getJSONObject(0).getString("started_at"))
|
||||
assertEquals(exerciseAEndedAt, timelineExercises.getJSONObject(0).getString("ended_at"))
|
||||
assertEquals(
|
||||
exerciseBEntry.entryId,
|
||||
timelineExercises.getJSONObject(1).getString("entry_id"),
|
||||
)
|
||||
assertEquals(exerciseBStartedAt, timelineExercises.getJSONObject(1).getString("started_at"))
|
||||
assertEquals(exerciseBEndedAt, timelineExercises.getJSONObject(1).getString("ended_at"))
|
||||
|
||||
val heart = JSONObject(android.buildHeartRateV1Json())
|
||||
val heartCapture = heart.getJSONArray("captures").getJSONObject(0)
|
||||
assertEquals(capture.captureId, heartCapture.getString("capture_id"))
|
||||
assertEquals("session", heartCapture.getString("context_kind"))
|
||||
assertEquals(sessionId, heartCapture.getString("context_id"))
|
||||
assertEquals(sessionStartedAt, heartCapture.getString("started_at"))
|
||||
assertEquals(sessionEndedAt, heartCapture.getString("ended_at"))
|
||||
val samples = heartCapture.getJSONArray("samples")
|
||||
assertEquals(4, samples.length())
|
||||
val expectedOwnership =
|
||||
listOf(null, exerciseA.entryId, null, exerciseBEntry.entryId)
|
||||
val expectedTimes =
|
||||
listOf(preExerciseAt, exerciseASampleAt, betweenExercisesAt, exerciseBSampleAt)
|
||||
val expectedRr = listOf(675, 548, 620, 525)
|
||||
for (index in 0 until samples.length()) {
|
||||
val sample = samples.getJSONObject(index)
|
||||
assertEquals(index.toLong(), sample.getLong("sequence"))
|
||||
assertEquals(expectedTimes[index], sample.getString("observed_at"))
|
||||
if (expectedOwnership[index] == null) {
|
||||
assertTrue(sample.isNull("exercise_entry_id"))
|
||||
} else {
|
||||
assertEquals(expectedOwnership[index], sample.getString("exercise_entry_id"))
|
||||
}
|
||||
assertEquals(expectedRr[index], sample.getJSONArray("rr_intervals_1024").getInt(0))
|
||||
}
|
||||
|
||||
SQLiteDatabase.openDatabase(
|
||||
context.getDatabasePath(androidName).absolutePath,
|
||||
null,
|
||||
SQLiteDatabase.OPEN_READONLY,
|
||||
).use { db ->
|
||||
db.rawQuery(
|
||||
"SELECT source_program_id,source_program_session_id FROM sessions " +
|
||||
"WHERE session_id=?",
|
||||
arrayOf(sessionId),
|
||||
).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(programId, cursor.getString(0))
|
||||
assertEquals(programSessionId, cursor.getString(1))
|
||||
}
|
||||
}
|
||||
|
||||
run(fixture.absolutePath, desktopDb.absolutePath, "program-create")
|
||||
val desktopPeer =
|
||||
Regex("PEER_ID=(peer_[^\\s]+)")
|
||||
.find(
|
||||
run(
|
||||
"python3",
|
||||
generationTool,
|
||||
"peer-id",
|
||||
"--database",
|
||||
desktopDb.absolutePath,
|
||||
),
|
||||
)!!
|
||||
.groupValues[1]
|
||||
val service = SyncGenerationService(android)
|
||||
val captured = service.capture(root, desktopPeer)
|
||||
val published = service.publish(captured, java.io.File(root, "objects"))
|
||||
val publishedTimeline =
|
||||
JSONObject(java.io.File(published, "session-timeline-v1.json").readText())
|
||||
val publishedHeart =
|
||||
JSONObject(java.io.File(published, "heart-rate-v1.json").readText())
|
||||
assertEquals(sessionId, publishedTimeline.getJSONArray("sessions").getJSONObject(0).getString("session_id"))
|
||||
assertEquals(capture.captureId, publishedHeart.getJSONArray("captures").getJSONObject(0).getString("capture_id"))
|
||||
|
||||
val firstAck = java.io.File(root, "desktop-ack-first.json")
|
||||
run(
|
||||
"python3",
|
||||
generationTool,
|
||||
"consume-desktop",
|
||||
published.absolutePath,
|
||||
"--database",
|
||||
desktopDb.absolutePath,
|
||||
"--ack-output",
|
||||
firstAck.absolutePath,
|
||||
)
|
||||
val countsBeforeReplay =
|
||||
run(
|
||||
"sqlite3",
|
||||
desktopDb.absolutePath,
|
||||
"SELECT (SELECT count(*) FROM session_exercise_timeline WHERE session_id='$sessionId') || '|' || " +
|
||||
"(SELECT count(*) FROM heart_rate_samples WHERE capture_id='${capture.captureId}') || '|' || " +
|
||||
"(SELECT count(*) FROM heart_rate_rr_intervals WHERE capture_id='${capture.captureId}');",
|
||||
).trim()
|
||||
assertEquals("2|4|4", countsBeforeReplay)
|
||||
|
||||
val replayAck = java.io.File(root, "desktop-ack-replay.json")
|
||||
run(
|
||||
"python3",
|
||||
generationTool,
|
||||
"consume-desktop",
|
||||
published.absolutePath,
|
||||
"--database",
|
||||
desktopDb.absolutePath,
|
||||
"--ack-output",
|
||||
replayAck.absolutePath,
|
||||
)
|
||||
assertEquals(
|
||||
countsBeforeReplay,
|
||||
run(
|
||||
"sqlite3",
|
||||
desktopDb.absolutePath,
|
||||
"SELECT (SELECT count(*) FROM session_exercise_timeline WHERE session_id='$sessionId') || '|' || " +
|
||||
"(SELECT count(*) FROM heart_rate_samples WHERE capture_id='${capture.captureId}') || '|' || " +
|
||||
"(SELECT count(*) FROM heart_rate_rr_intervals WHERE capture_id='${capture.captureId}');",
|
||||
).trim(),
|
||||
)
|
||||
assertEquals(
|
||||
"acknowledged",
|
||||
service.acceptAcknowledgement(firstAck.readBytes()),
|
||||
)
|
||||
assertEquals(
|
||||
0,
|
||||
JSONObject(android.buildSessionTimelineV1Json()).getJSONArray("sessions").length(),
|
||||
)
|
||||
assertEquals(
|
||||
0,
|
||||
JSONObject(android.buildHeartRateV1Json()).getJSONArray("captures").length(),
|
||||
)
|
||||
|
||||
val desktopFacts =
|
||||
JSONObject(
|
||||
run(
|
||||
"sqlite3",
|
||||
desktopDb.absolutePath,
|
||||
"SELECT json_object(" +
|
||||
"'program_id',x.program_id,'program_session_id',x.program_session_id," +
|
||||
"'started_at',s.started_at,'ended_at',s.ended_at) FROM sessions s " +
|
||||
"JOIN program_session_executions x ON x.session_id=s.session_id " +
|
||||
"WHERE s.session_id='$sessionId' AND x.state='completed';",
|
||||
).trim(),
|
||||
)
|
||||
assertEquals(programId, desktopFacts.getString("program_id"))
|
||||
assertEquals(programSessionId, desktopFacts.getString("program_session_id"))
|
||||
assertEquals(sessionStartedAt, desktopFacts.getString("started_at"))
|
||||
assertEquals(sessionEndedAt, desktopFacts.getString("ended_at"))
|
||||
assertEquals(
|
||||
listOf(
|
||||
"$exerciseAStartedAt|$exerciseAEndedAt|${exerciseA.entryId}",
|
||||
"$exerciseBStartedAt|$exerciseBEndedAt|${exerciseBEntry.entryId}",
|
||||
),
|
||||
run(
|
||||
"sqlite3",
|
||||
"-separator",
|
||||
"|",
|
||||
desktopDb.absolutePath,
|
||||
"SELECT started_at,ended_at,entry_id FROM session_exercise_timeline " +
|
||||
"WHERE session_id='$sessionId' ORDER BY started_at,entry_id;",
|
||||
).lineSequence().filter { it.isNotBlank() }.toList(),
|
||||
)
|
||||
assertEquals(
|
||||
"session|$sessionId|$sessionStartedAt|$sessionEndedAt",
|
||||
run(
|
||||
"sqlite3",
|
||||
"-separator",
|
||||
"|",
|
||||
desktopDb.absolutePath,
|
||||
"SELECT context_kind,context_id,started_at,ended_at " +
|
||||
"FROM heart_rate_captures WHERE capture_id='${capture.captureId}';",
|
||||
).trim(),
|
||||
)
|
||||
assertEquals(
|
||||
listOf(
|
||||
"0|$preExerciseAt|",
|
||||
"1|$exerciseASampleAt|${exerciseA.entryId}",
|
||||
"2|$betweenExercisesAt|",
|
||||
"3|$exerciseBSampleAt|${exerciseBEntry.entryId}",
|
||||
),
|
||||
run(
|
||||
"sqlite3",
|
||||
"-separator",
|
||||
"|",
|
||||
desktopDb.absolutePath,
|
||||
"SELECT sequence,observed_at,COALESCE(exercise_entry_id,'') " +
|
||||
"FROM heart_rate_samples " +
|
||||
"WHERE capture_id='${capture.captureId}' ORDER BY sequence;",
|
||||
).lineSequence().filter { it.isNotBlank() }.toList(),
|
||||
)
|
||||
assertEquals(
|
||||
expectedRr,
|
||||
run(
|
||||
"sqlite3",
|
||||
desktopDb.absolutePath,
|
||||
"SELECT value_1024 FROM heart_rate_rr_intervals " +
|
||||
"WHERE capture_id='${capture.captureId}' ORDER BY sample_sequence,rr_index;",
|
||||
).lineSequence().filter { it.isNotBlank() }.map { it.toInt() }.toList(),
|
||||
)
|
||||
} finally {
|
||||
android.close()
|
||||
context.deleteDatabase(androidName)
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.labfytools.trainlog.model.NewExerciseProfile
|
||||
import com.labfytools.trainlog.model.RecordingMode
|
||||
import com.labfytools.trainlog.model.SessionExerciseDraft
|
||||
import com.labfytools.trainlog.model.SessionSetDraft
|
||||
import com.labfytools.trainlog.model.TrackingMode
|
||||
import java.nio.file.Files
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
class SessionTimelineSyncGenerationTest {
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
|
||||
@Test
|
||||
fun timelineAndHeartRateArriveTogetherAndStopPublishingAfterAck() {
|
||||
val sourceName = "timeline-sync-" + UUID.randomUUID() + ".db"
|
||||
val root = Files.createTempDirectory("trainlog-timeline-sync-").toFile()
|
||||
val source = TrainlogRepository(context, sourceName)
|
||||
try {
|
||||
fun exercise(label: String) =
|
||||
(source.createExercise(
|
||||
NewExerciseProfile(label, RecordingMode.SETS, TrackingMode.REPS, 0),
|
||||
) as CreateExerciseResult.Created).exercise
|
||||
val a = SessionExerciseDraft(
|
||||
exercise = exercise("Sync timeline A"),
|
||||
sets = listOf(SessionSetDraft(reps = 8)),
|
||||
)
|
||||
val b = SessionExerciseDraft(
|
||||
exercise = exercise("Sync timeline B"),
|
||||
sets = listOf(SessionSetDraft(reps = 9)),
|
||||
)
|
||||
assertEquals(ActiveDraftMutationResult.Saved, source.startActiveSessionDraft())
|
||||
val opened = source.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
|
||||
assertEquals(
|
||||
ActiveDraftMutationResult.Saved,
|
||||
source.saveActiveSessionDraft(opened.draft.copy(exercises = listOf(a, b))),
|
||||
)
|
||||
assertTrue(
|
||||
source.startActiveSessionExercise(a.entryId) is
|
||||
TrainlogRepository.SessionExerciseTimingResult.Started,
|
||||
)
|
||||
val base = ParsedHeartRateMeasurement(96, null, null, listOf(640))
|
||||
assertTrue(
|
||||
source.recordLiveHeartRateForActiveSession(
|
||||
"Synthetic HR",
|
||||
OffsetDateTime.now().toString(),
|
||||
base,
|
||||
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||
)
|
||||
assertTrue(
|
||||
source.finishActiveSessionExercise(a.entryId) is
|
||||
TrainlogRepository.SessionExerciseTimingResult.Finished,
|
||||
)
|
||||
assertTrue(
|
||||
source.recordLiveHeartRateForActiveSession(
|
||||
"Synthetic HR",
|
||||
OffsetDateTime.now().toString(),
|
||||
base.copy(bpm = 84),
|
||||
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||
)
|
||||
assertTrue(
|
||||
source.startActiveSessionExercise(b.entryId) is
|
||||
TrainlogRepository.SessionExerciseTimingResult.Started,
|
||||
)
|
||||
assertTrue(
|
||||
source.recordLiveHeartRateForActiveSession(
|
||||
"Synthetic HR",
|
||||
OffsetDateTime.now().toString(),
|
||||
base.copy(bpm = 105),
|
||||
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||
)
|
||||
assertTrue(
|
||||
source.finishActiveSessionExercise(b.entryId) is
|
||||
TrainlogRepository.SessionExerciseTimingResult.Finished,
|
||||
)
|
||||
assertTrue(source.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
|
||||
|
||||
val repositoryRoot =
|
||||
generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))) {
|
||||
it.parentFile
|
||||
}.first { java.io.File(it, "tools/sync_generation_exchange.py").isFile }
|
||||
fun run(vararg command: String): String {
|
||||
val process =
|
||||
ProcessBuilder(*command)
|
||||
.directory(repositoryRoot)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
val output = process.inputStream.bufferedReader().readText()
|
||||
assertEquals(
|
||||
"command failed: " + command.joinToString(" ") + "\n" + output,
|
||||
0,
|
||||
process.waitFor(),
|
||||
)
|
||||
return output
|
||||
}
|
||||
|
||||
val desktopDb = java.io.File(root, "desktop.sqlite")
|
||||
run(
|
||||
java.io.File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
|
||||
desktopDb.absolutePath,
|
||||
)
|
||||
val desktopPeer =
|
||||
Regex("PEER_ID=(peer_[^\\s]+)")
|
||||
.find(
|
||||
run(
|
||||
"python3",
|
||||
java.io.File(
|
||||
repositoryRoot,
|
||||
"tools/sync_generation_exchange.py",
|
||||
).absolutePath,
|
||||
"peer-id",
|
||||
"--database",
|
||||
desktopDb.absolutePath,
|
||||
),
|
||||
)!!
|
||||
.groupValues[1]
|
||||
|
||||
val service = SyncGenerationService(source)
|
||||
val captured = service.capture(root, desktopPeer)
|
||||
val timelineJson =
|
||||
JSONObject(
|
||||
java.io.File(
|
||||
captured.stagingDirectory,
|
||||
"session-timeline-v1.json",
|
||||
).readText(),
|
||||
)
|
||||
assertEquals(1, timelineJson.getJSONArray("sessions").length())
|
||||
assertEquals(
|
||||
2,
|
||||
timelineJson
|
||||
.getJSONArray("sessions")
|
||||
.getJSONObject(0)
|
||||
.getJSONArray("exercises")
|
||||
.length(),
|
||||
)
|
||||
val heartJson =
|
||||
JSONObject(
|
||||
java.io.File(captured.stagingDirectory, "heart-rate-v1.json").readText(),
|
||||
)
|
||||
assertEquals(
|
||||
3,
|
||||
heartJson
|
||||
.getJSONArray("captures")
|
||||
.getJSONObject(0)
|
||||
.getJSONArray("samples")
|
||||
.length(),
|
||||
)
|
||||
|
||||
val published = service.publish(captured, java.io.File(root, "objects"))
|
||||
val ackFile = java.io.File(root, "ack.json")
|
||||
run(
|
||||
"python3",
|
||||
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath,
|
||||
"consume-desktop",
|
||||
published.absolutePath,
|
||||
"--database",
|
||||
desktopDb.absolutePath,
|
||||
"--ack-output",
|
||||
ackFile.absolutePath,
|
||||
)
|
||||
val ack = JSONObject(ackFile.readText())
|
||||
assertEquals(
|
||||
"desktop ACK diagnostic: " + ack.getString("diagnostic"),
|
||||
"consumed",
|
||||
ack.getString("result"),
|
||||
)
|
||||
assertEquals("acknowledged", service.acceptAcknowledgement(ackFile.readBytes()))
|
||||
|
||||
assertEquals(
|
||||
"2|3|3",
|
||||
run(
|
||||
"sqlite3",
|
||||
desktopDb.absolutePath,
|
||||
"SELECT (SELECT count(*) FROM session_exercise_timeline) || '|' || " +
|
||||
"(SELECT count(*) FROM heart_rate_samples) || '|' || " +
|
||||
"(SELECT count(*) FROM heart_rate_rr_intervals);",
|
||||
).trim(),
|
||||
)
|
||||
assertEquals(
|
||||
0,
|
||||
JSONObject(source.buildSessionTimelineV1Json())
|
||||
.getJSONArray("sessions")
|
||||
.length(),
|
||||
)
|
||||
assertEquals(
|
||||
0,
|
||||
JSONObject(source.buildHeartRateV1Json())
|
||||
.getJSONArray("captures")
|
||||
.length(),
|
||||
)
|
||||
} finally {
|
||||
source.close()
|
||||
context.deleteDatabase(sourceName)
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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 } }
|
||||
]
|
||||
}
|
||||
|
|
|
|||
112
docs/android.md
112
docs/android.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
37
docs/assets/trainlog-badges.svg
Normal file
37
docs/assets/trainlog-badges.svg
Normal 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 |
66
docs/assets/trainlog-banner.svg
Normal file
66
docs/assets/trainlog-banner.svg
Normal 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 |
|
|
@ -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.
|
||||
|
|
|
|||
153
docs/database.md
153
docs/database.md
|
|
@ -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
413
docs/design/cardio_v1.md
Normal 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.
|
||||
196
docs/design/sync_bluetooth_transport_v1.md
Normal file
196
docs/design/sync_bluetooth_transport_v1.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
57
docs/releases/v0.1.4.md
Normal 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
59
docs/releases/v0.1.5.md
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
Trainlog v0.1.5 completes the factual cardio capture cycle and makes the
|
||||
Android-to-Web Sleep workflow ready for real nightly use without adding
|
||||
medical, sleep-stage, or causal inference.
|
||||
|
||||
## Heart-rate capture and cardio sessions
|
||||
|
||||
- Android persistently acquires real CYCPLUS Heart Rate Service measurements,
|
||||
retains factual observation timestamps, BPM and raw RR values, and exposes a
|
||||
global fresh/stale/disconnected indicator.
|
||||
- Training occurrences have explicit start/end timelines so synchronized
|
||||
heart-rate samples retain their stable exercise ownership instead of
|
||||
inferring it later from time overlap.
|
||||
- Dedicated `cardio` sessions, measured calibration profiles and fresh-signal
|
||||
BPM-guided phases use separate versioned companions and preserve the frozen
|
||||
historical session formats.
|
||||
- Guidance suspends on stale signal and records measured facts only; it does
|
||||
not estimate fitness, recovery, readiness or medical state.
|
||||
|
||||
## Factual Web timelines
|
||||
|
||||
- History and Sleep display synchronized heart-rate samples through one
|
||||
bounded native read model and responsive Web timeline.
|
||||
- Full-source min/mean/max and RR counts remain exact while large visual
|
||||
projections are bounded.
|
||||
- Cardio timelines show factual occurrence bounds, resolved targets,
|
||||
instruction changes and measured calibration points.
|
||||
- Sleep highlights notable relative rises and falls with a deterministic local
|
||||
median rule, explicitly without assigning a sleep stage or cause.
|
||||
|
||||
## Sleep capture and medication quantity
|
||||
|
||||
- Medication can be recorded before Couché. The first intake creates one
|
||||
durable pending 18:00-to-18:00 night; Couché advances the same stable entry,
|
||||
Réveil leaves heart-rate capture running, and Levé closes it at the factual
|
||||
final-get-up timestamp.
|
||||
- A missing sensor fabricates neither a capture nor a sample.
|
||||
- Sleep Diary V2 carries structured per-intake quantity while published V1
|
||||
remains strict and readable; historical intakes default to quantity one.
|
||||
- The Web agenda renders every night in the selected period on one aligned
|
||||
18:00-to-18:00 axis, and the selected night owns the full-width heart-rate
|
||||
detail. PDF reports retain their multi-night behavior and display quantity
|
||||
without replacing per-unit dose.
|
||||
|
||||
## Compatibility and validation
|
||||
|
||||
- Desktop schema v35 and Android schema v32 are additive migrations.
|
||||
- `TRAINLOG_FORMAT_V1`, mobile export V3, stable identities, causal revision
|
||||
lineage, generation/ACK semantics and Bluetooth/MTP transport ownership are
|
||||
unchanged.
|
||||
- Native tests, Android unit tests and APK assembly, Web tests/typecheck/build,
|
||||
JSON/import validation, changed-C formatting and exact Sleep V2 replay all
|
||||
pass at the release tag.
|
||||
|
||||
## Assets
|
||||
|
||||
- `trainlog-android-v0.1.5.apk`: signed public Android APK.
|
||||
- `trainlog-tui-linux-x86_64-v0.1.5`: Linux x86-64 runtime bundle containing
|
||||
the TUI, local Web adapter, synchronization helpers and required catalogs.
|
||||
- `SHA256SUMS`: SHA-256 checksums for both artifacts.
|
||||
64
docs/releases/v0.1.6.md
Normal file
64
docs/releases/v0.1.6.md
Normal 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.
|
||||
BIN
docs/reviews/evidence/sleep-diary-alignment-firefox.png
Normal file
BIN
docs/reviews/evidence/sleep-diary-alignment-firefox.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
BIN
docs/reviews/evidence/sleep-diary-empty-night-firefox.png
Normal file
BIN
docs/reviews/evidence/sleep-diary-empty-night-firefox.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 138 KiB |
387
docs/reviews/evidence/sleep-diary-fr-7-days.pdf
Normal file
387
docs/reviews/evidence/sleep-diary-fr-7-days.pdf
Normal 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
|
||||
BIN
docs/reviews/evidence/sleep-diary-fr-7-days.png
Normal file
BIN
docs/reviews/evidence/sleep-diary-fr-7-days.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 124 KiB |
BIN
docs/reviews/evidence/sleep-diary-live-agenda-firefox.png
Normal file
BIN
docs/reviews/evidence/sleep-diary-live-agenda-firefox.png
Normal file
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
Loading…
Reference in a new issue