Compare commits
103 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 | ||
|
|
bedf392542 | ||
|
|
0262433615 | ||
|
|
ab3986200e | ||
|
|
eb6d70fb62 | ||
|
|
38d304d014 | ||
|
|
ca2ea9379b | ||
|
|
0e13c3947a | ||
|
|
60b08f225a | ||
|
|
a2eaf224e8 | ||
|
|
ba5021b436 | ||
|
|
99e428c558 | ||
|
|
2a68e5a505 | ||
|
|
1e9c8aa741 | ||
|
|
f27ef5af5f | ||
|
|
53baa7ad24 | ||
|
|
e41949557b | ||
|
|
d56bd3b54f | ||
|
|
cb4010dbd5 | ||
|
|
88e0eb0fb7 | ||
|
|
0a9639bb94 | ||
|
|
5701e2a541 |
299 changed files with 49403 additions and 2384 deletions
417
CHANGELOG.md
417
CHANGELOG.md
|
|
@ -1,5 +1,422 @@
|
|||
# Changelog
|
||||
|
||||
## 0.1.6 — 2026-09-25
|
||||
|
||||
- Corrected delayed session finalization so a heart-rate capture cannot extend
|
||||
beyond the explicit factual session end. Android now transactionally removes
|
||||
strict post-end BPM/RR tails while retaining an exact-cutoff sample. For an
|
||||
already acknowledged capture, the separately versioned
|
||||
`trainlog-heart-rate-corrections` V1 companion carries an immutable
|
||||
`capture_id|cutoff` correction through the existing causal ledger without
|
||||
widening frozen causal-deletions V1. Old Heart Rate V1 generation replay
|
||||
applies that cutoff before identity comparison and cannot resurrect removed
|
||||
measurements. A guarded dry-run recovery tool reports before/after counts and
|
||||
statistics without rewriting published manifests or ACKs.
|
||||
- Completed the paired-device recovery for session
|
||||
`se_fa845379-794d-4bb6-8cd3-48187ebbd134`: Android and desktop now retain
|
||||
3,429 BPM samples and 3,429 RR values through the inclusive 11:16 cutoff,
|
||||
and two successive full MTP generation exchanges completed with durable
|
||||
consumed ACKs. Replaying the original 4,919-sample artifact cannot restore
|
||||
the removed tail. Generation parent selection now excludes archived or
|
||||
legacy acknowledgement rows lacking durable ACK evidence, while consumers
|
||||
accept the producer's declared parent when it is one of multiple retained
|
||||
current tips. Android Sleep V2 persistence also names medication-intake
|
||||
columns explicitly, preserving `quantity`, note, and creation time across
|
||||
the additive v33 layout.
|
||||
|
||||
- Recovered a real divergent Sleep revision identity without choosing an
|
||||
arbitrary winner. The unpublished desktop branch was re-identified with its
|
||||
exact content, the immutable Android revision retained its published ID and
|
||||
parent, and a fresh successor preserved the non-conflicting qualities,
|
||||
events and medication occurrences. The original immutable generation now
|
||||
has a `consumed` / `sqlite-commit-full` ACK and the repaired 24 September
|
||||
session is available through Desktop and Web with its timeline, feedback,
|
||||
cardio and Program provenance.
|
||||
- Added Desktop schema v36 and Android schema v33 append-only guards for Sleep
|
||||
revision payloads. Both importers now reject a known historical revision ID
|
||||
whose parent or canonical content differs, even when that revision is below
|
||||
the current tip. A dedicated transactional recovery utility and regressions
|
||||
cover exact replay, parent/content collisions, medication quantities,
|
||||
rollback, immutable generation retry, and long UUID/parent chains.
|
||||
|
||||
- Fixed Android finalization of Program-derived sessions containing a completed
|
||||
target-only continuous occurrence. The repository now materializes the
|
||||
performed duration from the persisted exercise timeline, including preserved
|
||||
drafts closed by an older build, while keeping planned set counts independent
|
||||
from valid actual sets, repetitions and loads. A strictly identity-bound
|
||||
private recovery build may supply the factual session end; strict late
|
||||
heart-rate BPM/RR tails are now removed transactionally at that boundary.
|
||||
- Hardened desktop Sleep generation consumption when an identical durable
|
||||
revision has no local publication row. Content comparison now reads the
|
||||
durable revision directly, acknowledges an exact match, and reports a real
|
||||
revision-identity conflict instead of an empty rejection diagnostic when the
|
||||
same ID carries different content.
|
||||
|
||||
Development opened after the stable v0.1.5 release. The Web refinement tranche
|
||||
adds persistent French/English settings, denser shared surfaces, ISO-week
|
||||
Dashboard activity, factual exercise-count overview charts, measured-timeline
|
||||
muscle-group drill-down, normalized measurement summaries, one shared refined
|
||||
BODY ZONES silhouette, and a compact Program header. A new Equipment workspace
|
||||
lists factual references and transactionally absorbs a custom duplicate into a
|
||||
canonical identity while retaining history/MAX context and recording a causal
|
||||
deletion marker; immutable embedded drafts cause an explicit conflict.
|
||||
The post-review correction keeps acknowledged-generation capacity recoverable
|
||||
for legacy Android rows without ACK-ledger evidence, proves two successive
|
||||
Sleep-only generation/ACK exchanges, and never evicts an unacknowledged
|
||||
generation. Web can now causally remove only an orphaned zero-occurrence active
|
||||
draft while retaining protection for real active drafts. Mutation UUIDs use
|
||||
`crypto.getRandomValues()` on loopback HTTP origins. Sleep timeline geometry,
|
||||
Dashboard sizing, Program progress contrast, chart proportions, duration
|
||||
coverage messaging, and the right-aligned Settings/Sync action cluster are
|
||||
also refined.
|
||||
|
||||
- Restored automatic Sleep-only Bluetooth synchronization after a real-night
|
||||
trace exposed several restart and first-import gaps. The user-service
|
||||
installer now removes only its obsolete `syncd` ExecStart override and
|
||||
installs the RFCOMM agent;
|
||||
Android resumes the same unsuperseded immutable generation across a new
|
||||
conversation run and accepts its exact generation/digest ACK even though
|
||||
the immutable ACK retains the original run identity. Ledger-proven
|
||||
superseded branches remain retained without exhausting admission. Desktop
|
||||
progress validation accepts the existing ACK-recovery phase, and a fresh
|
||||
desktop can seed a non-root Sleep V2 current snapshot when its validated
|
||||
ancestry is present. Regression
|
||||
coverage carries medication quantity, Couché/Réveil/Levé and Sleep BPM/RR
|
||||
through import, ACK, replay and a second Sleep-only generation with no
|
||||
Android workout session.
|
||||
- Enriched the selected Sleep night's heart-rate chart with declared sleep
|
||||
ranges, explicit awakening ranges and point events, medication markers that
|
||||
preserve unit dose and quantity, exact start/end bounds, and every
|
||||
intervening hourly tick. When no declared sleep interval exists, the Web
|
||||
presentation alone shows the conservative bedtime +45 minutes to final
|
||||
get-up -10 minutes estimate, labels it as estimated/non-medical, and never
|
||||
writes it to Sleep data. Nights without synchronized HR retain these factual
|
||||
and estimated timeline overlays without inventing BPM samples. The private
|
||||
`trainlog.perf` deployment and read-only Firefox smoke validate the latest
|
||||
real HR night and the latest real night containing medications from code
|
||||
commit `2ce458d`.
|
||||
- Reused that same read-only fallback projection in the multi-night Sleep
|
||||
agenda, with a visibly estimated hatched interval and no Sleep write. The
|
||||
agenda now derives an exact 18:00-to-18:00 hourly grid, presents at least
|
||||
two-hour labels on ordinary desktop widths, expands to every hour when wide,
|
||||
and reduces to six-hour anchors on narrow screens. A read-only real-device
|
||||
investigation of the 2026-09-23 night found no medication intake in any
|
||||
retained local revision and established that later synchronization only
|
||||
published the already-empty current revision. Android regression coverage
|
||||
now proves two pre-bed intakes, including quantity two, through repeated
|
||||
repository reopen, bedtime, wake, final get-up, generation, desktop ACK,
|
||||
replay, stale-ancestor input and a subsequent revision. The private service
|
||||
serves Web code commit `a60e1aa`; a non-mutating Firefox smoke on the real
|
||||
night confirms the identical 22:50–04:04 agenda/detail projection and the
|
||||
responsive 13/25/5-label axis densities.
|
||||
- Unified the Web Sleep agenda, selected-night HR detail, and vector PDF on one
|
||||
temporal projection. Every structured long-awake interval remains a distinct
|
||||
band, row selection updates the detail dates and content without persistence,
|
||||
and preview/download export exactly the active night with observations
|
||||
recomputed for that subset. Sleep duration now consistently sums factual
|
||||
sleep intervals or, only when none exist, uses the displayed Couché +45 min
|
||||
to Levé -10 min estimate minus structured awakenings; the estimate is never
|
||||
written to SQLite.
|
||||
- Changed future Android one-tap Réveil capture from punctual `night_get_up`
|
||||
facts to structured `long_awake` windows under the user-approved 30-minute
|
||||
default. A repeated tap inside the window extends its end to 30 minutes after
|
||||
the latest tap, while Levé clamps it to the factual final-get-up timestamp.
|
||||
The policy is revisioned, undoable, and independent of BPM/RR inference.
|
||||
- Centered the Sleep agenda's hour labels inside a shared 24-column visual grid
|
||||
and added an inclusive night-start date range for PDF preview and export.
|
||||
The duplicate terminal `18` is removed; detail/HR night selection remains
|
||||
independent from the report range, whose date fields now use a grouped
|
||||
Trainlog form treatment.
|
||||
- Fixed a real Sleep agenda offset bug when revision metadata used UTC `Z` but
|
||||
factual events used the user's local offset: the row now anchors its complete
|
||||
18:00 axis to one factual night offset. Agenda night headings use French
|
||||
`DD/MM/YYYY` rather than persistence-oriented ISO dates in the French UI.
|
||||
|
||||
## 0.1.5 — 2026-09-23
|
||||
|
||||
- Completed the Sleep Tonight readiness delta and its non-destructive private
|
||||
vc47 rollout. Pre-bed medication now creates or
|
||||
reuses one durable pending 18:00-to-18:00 Sleep entry; Couché appends bedtime
|
||||
to that same stable identity, while Réveil/Levé remain unavailable before
|
||||
bedtime. Pending quick facts are publishable through a distinct marker and
|
||||
are not day validation.
|
||||
- Added structured per-intake medication quantity (`1..99`) in Android schema
|
||||
v32 and desktop schema v35. The active `trainlog-sleep-diary` V2 companion
|
||||
(`sleep-diary-v2.json`) carries quantity; frozen V1 remains readable and
|
||||
interprets missing quantity as one. Per-unit dose/unit remain separate from
|
||||
quantity, which is rendered in Android, Web and PDF as appropriate (for
|
||||
example `75 mg ×2`).
|
||||
- Kept sleep HR capture factual and sample-driven: pre-bed medication alone
|
||||
creates no capture; the first real post-Couché sample opens/reuses the
|
||||
entry-owned capture at bedtime; Réveil does not split it; and Levé closes it
|
||||
exactly at final get-up. A no-sensor night fabricates neither capture nor
|
||||
sample.
|
||||
- Updated the Web Sleep agenda to render every returned night in the selected
|
||||
period on one shared 18:00-to-18:00 axis. Row selection is navigation only
|
||||
and shows the selected entry’s full-width HR detail. Relative measured HR
|
||||
rises/falls use a trailing five-minute reference excluding the current
|
||||
60-second window, thresholds of `max(10 BPM, 15%)`, and ignore gaps over
|
||||
three seconds; labels remain descriptive and make no sleep-stage, medical,
|
||||
or causal inference.
|
||||
|
||||
- Added the Cardio V1 persistence/synchronization foundation. Android schema
|
||||
v27 owns durable timestamped heart-rate captures, samples and raw RR values;
|
||||
desktop schema v30 imports the optional `trainlog-heart-rate` V1 companion
|
||||
transactionally. Exact generation membership is retained until a correlated
|
||||
consumed ACK, after which those stopped captures remain local history but are
|
||||
excluded from ordinary re-publication. Desktop/Web remain sensor consumers
|
||||
only; Bluetooth Classic Trainlog synchronization is unchanged.
|
||||
- Added the Android Heart Rate Service diagnostic foundation. The independent
|
||||
`0x2A37` parser covers 8/16-bit BPM, contact, energy and raw RR flags with
|
||||
strict malformed-payload rejection. A settings diagnostic provides bounded
|
||||
HRS-filtered and expanded BLE scans, GATT service discovery and notification
|
||||
subscription without turning desktop/Web into sensor clients. Android 12+
|
||||
scan/connect permissions and the Android 11-and-older scan-location boundary
|
||||
are explicit. Real CYCPLUS H2 characterization then confirmed the standard
|
||||
service/measurement path, Battery Service, ~1 Hz notifications and raw RR.
|
||||
- Added persistent Android heart-rate acquisition and the global `♥ BPM`
|
||||
indicator. A validated sensor is remembered, a dedicated connected-device
|
||||
foreground service reconnects across app restarts and Bluetooth loss, stale
|
||||
measurements are cleared instead of displayed indefinitely, and the heart
|
||||
uses grey/orange/green for disconnected/reconnecting/fresh states. The real
|
||||
CYCPLUS H2 was validated through force-stop/restart and Bluetooth off/on
|
||||
recovery on the private 0.1.5 versionCode 40 installation.
|
||||
- Added the training execution timeline used for cardio correlation. Android
|
||||
schema v28 records explicit start/end actions for stable exercise occurrences
|
||||
while preserving the session lifecycle `started_at`/`ended_at` as canonical
|
||||
bounds. Only one exercise can be active; session finalization refuses an
|
||||
unclosed occurrence. Live heart-rate samples snapshot the active `entry_id`,
|
||||
rest intervals remain unowned, and the optional `trainlog-session-timeline`
|
||||
V1 companion imports idempotently into desktop schema v31.
|
||||
- Added Android one-tap Sleep capture on top of the existing Sleep Diary V1:
|
||||
Couché, active medication buttons, point Réveil events and Levé each record
|
||||
the current timestamp in one action. A guarded short-lived Annuler creates a
|
||||
causal compensating revision, while Corriger la nuit retains the complete
|
||||
editor. Heart-rate acquisition links directly to the stable sleep `entry_id`
|
||||
and closes at Levé; no return-to-sleep or sleep-stage inference is added.
|
||||
- Added the true `cardio` session type without widening frozen session
|
||||
codecs. Android schema v29 and desktop schema v32 introduce canonical
|
||||
`session_kind=training|max_test|cardio` while the historical
|
||||
`session_type` compatibility column remains `training|max_test`.
|
||||
Completed cardio sessions are excluded from `history-v4` and move through
|
||||
the separate optional `trainlog-cardio-sessions` V1 generation companion;
|
||||
exact replay is idempotent and identity collision with non-cardio history is
|
||||
rejected.
|
||||
|
||||
- Added the first versioned Cardio calibration workflow. Android schema v30 creates one reserved system exercise outside the normal exercise catalogue, starts a real cardio session and HR capture, records only measured observed peak BPM, and requires at least three minutes of recovery. +1/+2/+3 minute recovery facts accept only real samples within a bounded ten-second window; missing signal therefore remains missing instead of being backfilled from a later reconnect. Completed immutable profiles synchronize Android→desktop through `trainlog-cardio-calibrations` V1 into desktop schema v33, where the importer proves peak and recovery points against the synchronized raw HR capture before accepting them.
|
||||
|
||||
|
||||
- Added fresh-signal BPM-guided cardio phases. Android schema v31 stores
|
||||
ordered phase runs against the active cardio occurrence, immutable resolved
|
||||
BPM targets, optional exact calibration provenance, exit conditions and
|
||||
instruction changes. The engine uses a 2 BPM hysteresis band and a 3-second
|
||||
confirmation interval to avoid boundary oscillation; stale signal suspends
|
||||
guidance immediately and never reuses an old BPM. Android exposes live
|
||||
Accélère / Maintiens / Ralentis guidance during the active cardio exercise.
|
||||
Completed runs synchronize once through `trainlog-cardio-guidance` V1 into
|
||||
desktop schema v34. Desktop verifies cardio-session ownership, exercise
|
||||
timeline, calibration provenance and exact raw HR evidence for instruction
|
||||
events carrying BPM before accepting them.
|
||||
|
||||
- Fixed live Cardio guidance after changing an already-started manual draft
|
||||
from Training to Cardio. Draft persistence now atomically reconciles the
|
||||
same active heart-rate capture from `session` to `cardio` without replacing
|
||||
its identity or losing existing BPM/RR samples and sequence. A transition
|
||||
away from Cardio remains possible before Cardio-only facts exist, but is
|
||||
rejected once durable guidance or calibration facts would make it
|
||||
ambiguous. Repository coverage reproduces the real ordering through an
|
||||
active exercise and guidance event. The private vc46 re-smoke retained the
|
||||
original capture and more than 31 existing samples across the transition,
|
||||
then displayed a measured 92 BPM and `ACCÉLÈRE`; the Sommeil and Calibration
|
||||
cardio screens also rendered without creating persistent data.
|
||||
|
||||
- Added one end-to-end tomorrow-session readiness regression. A synchronized
|
||||
Program session retains its stable session and Program identities through
|
||||
edits, two ordered Program-derived occurrence intervals,
|
||||
pre-exercise/inter-exercise/active heart-rate ownership, finalization,
|
||||
full-generation publication, desktop import, replay and correlated ACK. Raw
|
||||
observation timestamps, BPM, RR, nullable stable exercise ownership and
|
||||
capture/session bounds are compared on both platforms without inferring
|
||||
ownership from time overlap.
|
||||
|
||||
- Added factual synchronized heart-rate timelines to the local Web. One bounded C17 read model and loopback GET endpoint now project raw BPM/RR measurements with canonical workout exercise intervals or Sleep Diary/medication markers. Cardio history additionally presents resolved phase targets, instruction changes and calibration peak/recovery facts. History and Sleep reuse one responsive SVG component; full-sample min/max/mean statistics remain exact while only very large chart projections retain bounded bucket extrema. No sleep stage, HRV, readiness or medical inference is added.
|
||||
|
||||
Development opened after stable v0.1.4 release.
|
||||
|
||||
## 0.1.4 — 2026-09-22
|
||||
|
||||
- Added the frozen Bluetooth Classic RFCOMM synchronization transport without
|
||||
changing full-generation business semantics. The paired deployment selects
|
||||
Bluetooth before MTP, authenticates the configured bonded Android peer with
|
||||
the persisted Trainlog peer identity, rate-limits reconnect noise, and keeps
|
||||
MTP as the wired recovery transport. This transport is for Trainlog data
|
||||
synchronization only; heart-rate sensor ingestion remains future 0.1.5 work.
|
||||
- Refreshed the Web next-session projection after synchronization and added
|
||||
factual body-measurement trend charts through the existing bounded analysis
|
||||
read model without introducing health interpretation or estimated metrics.
|
||||
- Fixed Program progress for sessions started from a delivered manual
|
||||
preparation. The preparation companion now carries its stable Program and
|
||||
Program-session provenance through Android start, finalization, restart and
|
||||
the existing execution companion; legacy stored deliveries are safely
|
||||
enriched only when every pre-existing field and execution identity matches.
|
||||
Android and Web consequently present the completed session and progress from
|
||||
the same persisted identity without fabricating history.
|
||||
Historical acknowledged deliveries now complete that recovery on the
|
||||
original session even when the preparation contained planned continuous
|
||||
activities, and current sleep snapshots can initialize a fresh peer from a
|
||||
non-root revision without discarding causal identity.
|
||||
- Aligned the Sleep Diary Agenda ticks, interval bands and point markers on one
|
||||
exact 18:00-to-18:00 timestamp projection. Selecting another night now clears
|
||||
the previous entry immediately, adopts only an exact-date result and rejects
|
||||
stale out-of-order reads; an absent night remains unpersisted and empty until
|
||||
the first real edit. The editor presents local completion as Draft,
|
||||
Day validated or Modified without exposing synchronization transport state.
|
||||
- Fully localized the French Sleep Diary PDF without changing its canonical
|
||||
data source or event semantics. Column headings, timeline labels, legend,
|
||||
factual summary, draft warning, dates and plurals are now French, accented
|
||||
text uses the PDF WinAnsi encoding, and durations use human-readable units.
|
||||
Long awakenings remain proportional intervals between independent sleep
|
||||
ranges, while medication intake snapshots retain localized dose and unit.
|
||||
The English report keeps an independent English vocabulary.
|
||||
- Made the Sleep Diary Agenda a live projection of the latest locally
|
||||
persisted entry revision. Event and intake mutations now re-read and adopt
|
||||
the canonical Core snapshot, serialized autosaves carry the returned
|
||||
revision forward, and stale asynchronous reads cannot regress the visible
|
||||
state. The Agenda shows factual bedtime, final get-up, declared-sleep and
|
||||
time-in-bed durations, long awakenings, naps, sleepiness and grouped
|
||||
medication markers without requiring reload or publication. Medication
|
||||
catalog entries and selectors now include their usual localized dose and
|
||||
unit, keep same-name/different-dose records distinct, and prefill editable
|
||||
intake doses. Schemas, domain semantics, synchronization and PDF generation
|
||||
are unchanged.
|
||||
- Fixed the Sleep Diary Web workspace becoming entirely blank after adding an
|
||||
event or medication intake on the loopback-routed HTTP origin. Sleep event
|
||||
and intake UUIDv4 identities now use secure random bytes without depending
|
||||
on the secure-context-only `crypto.randomUUID()` API, and a section-local
|
||||
error boundary preserves the Trainlog shell if an unrelated render failure
|
||||
occurs. The capture workspace now reuses Trainlog cards, controls, status,
|
||||
primary/secondary/danger actions and responsive spacing for a structured
|
||||
night header, appreciations, event timeline, medication catalog and intake
|
||||
editor. Domain data, schemas, revisions, synchronization and PDF semantics
|
||||
are unchanged.
|
||||
- Fixed equipment creation from the Android active-session exercise editor.
|
||||
The selector now exposes the complete catalogue in a bounded scrollable
|
||||
list, reuses the catalogue creation route, returns to the same stable
|
||||
occurrence with all draft fields intact, and selects the explicitly created
|
||||
equipment. Target-only prepared occurrences can save that equipment in
|
||||
place without fabricating performed sets or appending a duplicate occurrence.
|
||||
- Fixed the Android active-session exercise editor for target-only continuous
|
||||
preparation entries. Opening an occurrence now renders its prefilled form,
|
||||
preserves its independent plan while editing performed values, updates only
|
||||
the selected stable occurrence, and reports persistence failures instead of
|
||||
displaying a false editor-opened confirmation.
|
||||
- Fixed Android activation of mixed session preparations containing continuous
|
||||
duration targets and set-based exercises. Continuous target metadata now
|
||||
remains planning-only, SQL `NULL` set counts round-trip correctly, and no
|
||||
performed continuous activity or set is created before user capture.
|
||||
- Added Sleep Diary V1 across desktop schema v29, Android schema v26, Analyse,
|
||||
full-generation synchronization and a deterministic local vector PDF agenda.
|
||||
Entries retain causal revisions, absolute timestamped events, the five-value
|
||||
appreciation scale and user-authored notes; no medical interpretation or
|
||||
heart-rate data is introduced. Sleep Diary also includes a revisioned local
|
||||
medication catalog and timestamped intake snapshots with optional positive
|
||||
dose/unit, synchronized and rendered factually in the agenda and PDF without
|
||||
efficacy, interaction, causality, dosage, or clinical advice.
|
||||
Progressive daily capture now persists every edit locally while keeping
|
||||
draft, ready, synchronized, and post-sync modified states distinct. Explicit
|
||||
validation publishes only the guarded current revision through the existing
|
||||
full-generation/ACK path; validation never makes a day immutable.
|
||||
|
||||
Development opened after stable v0.1.3 release.
|
||||
|
||||
- Implemented the loopback-only Web Analyse V1 workspace and shared bounded
|
||||
Core read model. The 7/30/90-day and all-history filters, factual activity,
|
||||
active-Program progress, compatible exercise metrics, explicit persisted
|
||||
MAX results, primary BODY ZONE exposures, and fourteen body measurements are
|
||||
calculated by the C17 backend. Dashboard summaries consume the same model
|
||||
while preserving all existing tile identities and layout behavior. Deep
|
||||
links, safe query fallbacks, null/empty states, French/English presentation,
|
||||
keyboard focus, and responsive charts are covered without estimating 1RM,
|
||||
readiness, fatigue, recovery, body fat, or performance.
|
||||
- Refined the Analyse V1 and Dashboard presentation without changing their
|
||||
read models or API contracts. Period controls now share the section toolbar;
|
||||
durations use the shared human-readable formatter; incompatible exercise
|
||||
metrics remain hidden; activity, exercise, and measurement charts expose
|
||||
chronological dates, units, and factual tooltips; BODY ZONE silhouettes and
|
||||
exposure comparisons are more legible; and active-Program summaries use a
|
||||
compact structured progression layout. Persisted Dashboard tile identities
|
||||
and layout behavior remain unchanged.
|
||||
|
||||
## 0.1.3 — 2026-09-20
|
||||
|
||||
- Decoupled the Android full-generation request from the strict legacy V3
|
||||
compatibility signal. One explicit intent now publishes the dedicated
|
||||
`trainlog-sync-full-generation-request-v1.json` channel first; the legacy
|
||||
request is published with the same identity only after a fresh V3 snapshot
|
||||
succeeds. The desktop daemon prioritizes this channel and durably
|
||||
deduplicates a bounded set of request identities across both channels.
|
||||
|
||||
Development opened after the stable v0.1.2 release.
|
||||
|
||||
- Made explicit Android synchronization intent authoritative over automatic
|
||||
background resumption. A process-wide conversation arbiter now yields an
|
||||
older background wait before the foreground publishes its fresh request
|
||||
UUID, and `NEW_EXPLICIT` ignores the request/run that existed before the
|
||||
click. Background USB and Drive workers classify new requests, new
|
||||
correlated remote evidence, legitimate handoff, and stale resumable state;
|
||||
terminal runs are no longer retried every five seconds without new proof.
|
||||
Durable generations are retained across yield and handoff, and desktop
|
||||
request-ID deduplication remains strict. No schema, protocol, causal rule or
|
||||
exchange format changed.
|
||||
|
||||
- Hardened direct-MTP full-generation conversations after a private Android
|
||||
run was frozen before generation capture. Android now emits bounded
|
||||
run/phase metadata, refreshes the user-enabled background listener before a
|
||||
foreground request, and confirms MediaProvider/MTP visibility for immutable
|
||||
generation objects before publishing their reference. Desktop MTP pulls are
|
||||
throttled to one complete refresh every three seconds, keep a separate
|
||||
30-second operation budget, and report expiration of the conversation as a
|
||||
correlated protocol timeout instead of a misleading final one-second
|
||||
transport timeout. Explicit requests continue to receive fresh UUIDs.
|
||||
Mensurations now retains two compact columns at the deployed phone's 312 dp
|
||||
content width, with a narrow/large-font fallback. Schemas, causal semantics,
|
||||
generation formats, ACK formats and standalone V3 strictness are unchanged.
|
||||
|
||||
- Routed Android foreground and daemon-triggered daily synchronization through
|
||||
the existing full-generation orchestrator whenever its trusted local
|
||||
configuration is present. The standalone mobile V3 importer remains strict
|
||||
and still rejects causally protected live replay; complete generations apply
|
||||
tombstones before filtering dominated snapshot facts. No schema, exchange
|
||||
format, causal rule, or synchronization protocol changed. Android backup
|
||||
manifests now obtain `product_version` from installed package metadata
|
||||
instead of retaining the historical `0.1.2` literal.
|
||||
|
||||
- Unified the Android capture interface around compact dark cards, action
|
||||
tiles, restrained-radius button variants, wrapping selection grids, and
|
||||
two-column performed-set entry. Home now focuses on session capture, recent
|
||||
BODY ZONES exposure, synchronization, and measurements; mobile Statistics
|
||||
navigation was removed without changing stored data or exchange contracts.
|
||||
Programs now show compact session metadata, completed progress, and explicit
|
||||
start/resume/completed states using the existing schema-v25 durable Program
|
||||
execution provenance. Web Program administration gained adaptive cards, a
|
||||
visible progress bar, and green completed-session treatment while retaining
|
||||
its revision-guarded trash confirmation and causal deletion behavior.
|
||||
Android full lint remains at the exact ten-error `main` baseline (one
|
||||
`NewApi`, one `LocalContextConfigurationRead`, and eight bilingual
|
||||
`StringFormatMatches` diagnostics); this lot adds no lint error and disables
|
||||
no rule. Private daily candidates retain development `versionName=0.1.3` and
|
||||
may override only an increasing `versionCode` at build time.
|
||||
|
||||
- Added the loopback-only `/exercices` desktop catalogue administration
|
||||
surface with bounded search/profile/BODY ZONES filtering, detail and
|
||||
read-only equipment presentation, Core-owned creation and editing, complete
|
||||
optimistic concurrency, and protected causal retirement. Profile changes
|
||||
preserve stable exercise IDs and all historical occurrence snapshots.
|
||||
Mutations remain desktop-local until the existing USB-priority, Drive
|
||||
mirror/fallback synchronization publishes the existing catalogue companions;
|
||||
no Android code, schema, exchange format, or automatic synchronization was
|
||||
added.
|
||||
|
||||
## 0.1.2 — 2026-09-20
|
||||
|
||||
- Added USB-priority full-generation synchronization with a separately
|
||||
|
|
|
|||
564
README.md
564
README.md
|
|
@ -1,370 +1,124 @@
|
|||
# Trainlog
|
||||
<p align="center">
|
||||
<img src="docs/assets/trainlog-banner.svg" alt="Trainlog — local-first training data system" width="100%">
|
||||
</p>
|
||||
|
||||
Synchronization uses one generation/ACK engine over USB/MTP first and an
|
||||
optional private Google Drive mirror/fallback. Drive contains only versioned
|
||||
Trainlog artifacts under its separate Sync namespace; desktop and Android
|
||||
SQLite databases always remain local. Android can process PC USB requests from
|
||||
an explicitly enabled, visibly notified background service, while Drive checks
|
||||
follow Android's bounded background scheduling.
|
||||
<p align="center">
|
||||
<img src="docs/assets/trainlog-badges.svg" alt="Trainlog badges: local-first, C17, Android, local Web, GPLv3" width="720">
|
||||
</p>
|
||||
|
||||
Trainlog is a local-first workout and body-data system. Trainlog Core owns
|
||||
business truth and canonical desktop persistence. Its native Android app is the
|
||||
field companion, its C17/Notcurses desktop TUI is the administration and
|
||||
technical surface, and its local browser Web sibling provides the implemented
|
||||
Dashboard, Sessions, and Programmes surfaces while reserving Analyse and
|
||||
Exercises for separately contracted work.
|
||||
Trainlog is a **local-first system for recording, synchronizing, and examining
|
||||
training and body data**. Its Android companion handles capture in the field;
|
||||
the C17 desktop core keeps the canonical long-term history; a keyboard-first
|
||||
Notcurses TUI and a loopback-only Web application provide complementary ways
|
||||
to inspect and manage it.
|
||||
|
||||
Android and desktop each own a local SQLite database. The desktop database is
|
||||
the canonical long-term history. Trainlog synchronizes versioned JSON artifacts
|
||||
over direct MTP; it never copies SQLite database files between devices.
|
||||
The system is built around explicit facts, stable identities, versioned JSON
|
||||
exchanges, and user-controlled storage. Plans never become performed work until
|
||||
they are actually recorded, continuous activities are not disguised as sets,
|
||||
and estimates are kept distinct from measurements.
|
||||
|
||||
## Product split
|
||||
> **Release line:** The latest stable release is
|
||||
> [`v0.1.6`](https://github.com/labfytools/trainlog/releases/tag/v0.1.6).
|
||||
|
||||
| Surface | Primary responsibility |
|
||||
|---|---|
|
||||
| Android | Capture and quickly correct sets, repetitions, loads, durations, and continuous activities; reorder active and completed-session occurrences; capture feedback, J+1 follow-ups, body measurements, and AI proposals; trigger sync; show quick summaries. |
|
||||
| Desktop TUI | Administer, inspect, maintain, import/export, correct canonical history, and provide technical tools. |
|
||||
| Local Web (0.1.2) | Display the Dashboard, the operational top-level Programmes daily active-program calendar, and the implemented Sessions inventory, deletion, manual preparation, Android-delivery, and Programs administration workflow through typed Trainlog Core/API boundaries. Programs has responsive detail, terminal logical deletion, and a privately deployed read-only Android projection. Analyse and Exercises remain placeholders. |
|
||||
## ✦ Showcase
|
||||
|
||||
No interface reconstructs business truth from SQLite tables. The local Web is
|
||||
a sibling adapter, not an extension of the TUI. On `main`, its loopback-only
|
||||
CLI/HTTP adapter, embedded frontend, read-only Dashboard API, factual tiles,
|
||||
visualizations, and private layout persistence are implemented. This does not
|
||||
make the remaining placeholder routes functional.
|
||||
The top-level `/programmes` route is an operational daily calendar for active
|
||||
Programs. Sessions → Programmes remains the technical administration/import,
|
||||
list, detail, archive, and delete surface.
|
||||
<p align="center">
|
||||
<img src="docs/reviews/evidence/sleep-diary-alignment-firefox.png" alt="Trainlog Web Analyse workspace showing the Sleep Diary agenda and factual heart-rate timeline" width="100%">
|
||||
</p>
|
||||
|
||||
## Releases
|
||||
<p align="center"><sub>Web Analyse in Trainlog 0.1.6 — synchronized Sleep facts and measured heart-rate data on one timeline.</sub></p>
|
||||
|
||||
When a Trainlog release is published, its prebuilt assets are available from
|
||||
both official mirrors:
|
||||
The repository currently tracks real-browser evidence for the Web interface.
|
||||
Its Android overview asset is a clearly labelled static design study with
|
||||
fictional data, not a runtime screenshot; no current TUI screenshot is tracked.
|
||||
Those assets are therefore not presented here as product captures.
|
||||
|
||||
- [GitHub Releases](https://github.com/labfytools/trainlog/releases)
|
||||
- [Forgejo Releases](https://git.labfytools.com/fy59/trainlog/releases)
|
||||
## ✦ Features
|
||||
|
||||
Each published stable release provides:
|
||||
| Area | Available in the current development tree |
|
||||
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Training capture** | Metadata-driven sets, repetitions, durations and continuous activities; heterogeneous actual sets; durable active draft; exercise timelines; explicit measured MAX results. |
|
||||
| **Planning** | Manual preparations, imported Programs, Android delivery, execution provenance and strict separation between targets and facts. |
|
||||
| **Context** | Exercise and equipment catalogues, BODY ZONES, body measurements, immediate feedback, wording revisions and J+1 follow-ups. |
|
||||
| **Cardio** | Android BLE heart-rate acquisition, factual BPM/RR history, cardio sessions, measured calibration and fresh-signal phase guidance. |
|
||||
| **Sleep** | One-tap Android capture, causal Sleep Diary revisions, medication occurrences, synchronized factual timelines and local vector PDF export. |
|
||||
| **Desktop** | C17 Core, canonical SQLite history, keyboard-first Notcurses administration, correction, statistics, graphs and synchronization diagnostics. |
|
||||
| **Local Web** | Loopback-only Dashboard, Analyse, Programmes, Sessions, Exercises, Equipment, Settings and synchronization surfaces backed by typed Core services. |
|
||||
| **Synchronization** | One generation/ACK business engine over authenticated Bluetooth Classic RFCOMM, direct USB/MTP recovery and a separately configured private Drive workflow; SQLite files are never exchanged. |
|
||||
|
||||
- a signed Android APK;
|
||||
- a Linux x86-64 desktop runtime bundle;
|
||||
- SHA-256 checksums.
|
||||
### Boundaries and work in progress
|
||||
|
||||
Both mirrors publish the same Trainlog product version and release assets. The
|
||||
latest stable version is **0.1.2** on Android and desktop; this is one shared
|
||||
Trainlog version, not separate interface versions. Version 0.1.1 is the
|
||||
previous stable release.
|
||||
- `TRAINLOG_FORMAT_V1` remains frozen. The active mobile snapshot is V3; V1
|
||||
and V2 remain readable, while V4 codecs are staged but not selected by the
|
||||
active transport.
|
||||
- Full-generation synchronization is deployed only behind a trusted local
|
||||
opt-in; the default automatic exchange remains V3.
|
||||
- AI history export is active. AI session proposals still have a pending real
|
||||
Drive plus Android-triggered bidirectional smoke test.
|
||||
- Session Generator V1 is implemented but hidden while the V2 planning model
|
||||
is developed.
|
||||
- Web Analyse is implemented; its recorded visual-review gate remains open.
|
||||
|
||||
Trainlog presents French by default, with English selectable
|
||||
from **Settings → Language** on Android and the desktop TUI. The selection is
|
||||
local to that installation and updates the visible interface immediately; it
|
||||
does not translate user exercise/catalogue names or change training data,
|
||||
stable IDs, databases, schemas, exchange artifacts, synchronization protocols,
|
||||
AI, MAX, or feedback. Synchronization status is rendered locally from typed
|
||||
status and counters; raw protocol and operational summaries are not translated
|
||||
or injected into the other surface.
|
||||
The canonical status and limitations live in
|
||||
[`docs/current_state.md`](docs/current_state.md); future work belongs in the
|
||||
[`roadmap`](docs/roadmap.md).
|
||||
|
||||
See the [documentation](docs/README.md) and [changelog](CHANGELOG.md) for more
|
||||
details.
|
||||
|
||||
## Installation
|
||||
|
||||
### Download a prebuilt release
|
||||
|
||||
For a published release, download Trainlog from either official mirror:
|
||||
|
||||
- [GitHub](https://github.com/labfytools/trainlog/releases)
|
||||
- [Forgejo](https://git.labfytools.com/fy59/trainlog/releases)
|
||||
|
||||
Both mirrors contain the same Android APK, Linux x86-64 runtime bundle, and SHA-256
|
||||
checksums for each stable Trainlog release. Verify the downloaded binaries
|
||||
against `SHA256SUMS` before installing them.
|
||||
|
||||
The Linux artifact is an architecture-specific tar archive whose published
|
||||
filename intentionally remains `trainlog-tui-linux-x86_64-v<version>`. It
|
||||
contains the TUI/Web executable, synchronization daemon and helpers, catalogs,
|
||||
and a hashed inventory from the same release commit. Its native executables are
|
||||
dynamically linked, so the host must provide compatible runtime libraries
|
||||
listed under [Dependencies](#dependencies).
|
||||
|
||||
```bash
|
||||
mkdir trainlog-v<version>
|
||||
tar -xf trainlog-tui-linux-x86_64-v<version> -C trainlog-v<version>
|
||||
./trainlog-v<version>/bin/trainlog
|
||||
```
|
||||
|
||||
The extracted bundle can remain in a user-managed installation directory.
|
||||
Linking its launchers from `bin/` into `~/.local/bin` is optional.
|
||||
|
||||
For Android, download the release APK, allow installation from the browser or
|
||||
file manager when Android requests it, and install the package. Android 8.0
|
||||
(API 26) or later is required. Grant Trainlog all-files access only when direct
|
||||
`Documents/Trainlog` synchronization is needed. Desktop synchronization also
|
||||
requires the Linux side and its MTP dependencies to be installed and configured.
|
||||
|
||||
Android and the TUI in each stable release share the same Trainlog product
|
||||
version on both mirrors. Database schemas and JSON protocol versions are
|
||||
independent.
|
||||
|
||||
### Daily synchronization on an opted-in installation
|
||||
|
||||
After explicitly enabling Android background synchronization once, connect and
|
||||
unlock the paired phone and select **Synchronize** in the local Web Dashboard.
|
||||
USB/MTP is preferred when the Trainlog peer is usable; the separately selected
|
||||
private Drive folder is updated as a mirror and is the fallback transport. Wait
|
||||
until Web reports the correlated generation and acknowledgements. A failure
|
||||
remains durable and should be diagnosed before retrying; do not delete
|
||||
generation, ACK, database, or backup files. Full-generation mode and Drive
|
||||
remain trusted per-installation configuration.
|
||||
|
||||
### Build from source
|
||||
|
||||
Desktop:
|
||||
|
||||
```bash
|
||||
meson setup build
|
||||
meson compile -C build
|
||||
meson test -C build --print-errorlogs
|
||||
```
|
||||
|
||||
Android:
|
||||
|
||||
```bash
|
||||
cd android
|
||||
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew test
|
||||
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew assembleRelease
|
||||
```
|
||||
|
||||
`assembleRelease` produces a distributable stable APK only when release signing
|
||||
is configured by the release operator. The repository does not contain signing
|
||||
credentials and does not silently generate them.
|
||||
|
||||
Release hosts provide `TRAINLOG_RELEASE_STORE_FILE`,
|
||||
`TRAINLOG_RELEASE_STORE_PASSWORD`, `TRAINLOG_RELEASE_KEY_ALIAS`, and
|
||||
`TRAINLOG_RELEASE_KEY_PASSWORD`, or a private
|
||||
`~/.config/trainlog/release-signing.properties` file containing the equivalent
|
||||
`storeFile`, `storePassword`, `keyAlias`, and `keyPassword` keys. Environment
|
||||
variables take precedence. Keystores and local credential files are ignored by
|
||||
Git; neither belongs in source control, build logs, or release notes.
|
||||
|
||||
The Android release key is the permanent update identity for the application.
|
||||
The release operator must preserve an encrypted external backup of both the
|
||||
keystore and its credentials. Losing either prevents future compatible Android
|
||||
updates; generating a replacement key is not a normal release procedure.
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Linux runtime dependencies
|
||||
|
||||
The published v0.1.2 x86-64 desktop runtime is dynamically linked. Its tagged
|
||||
source directly requires compatible versions of:
|
||||
|
||||
- glibc and the GCC support runtime;
|
||||
- SQLite 3;
|
||||
- libuuid;
|
||||
- utf8proc;
|
||||
- libudev;
|
||||
- libmtp;
|
||||
- Notcurses Core;
|
||||
- the standard math library.
|
||||
|
||||
Version 0.1.2 additionally links GNU libmicrohttpd and yyjson for the local Web
|
||||
adapter and Dashboard layout configuration. Those are not retroactive
|
||||
requirements of the pre-Web v0.1.1 tagged source.
|
||||
|
||||
Distribution packages may pull additional transitive libraries, including
|
||||
libusb, ncursesw, unistring, gpm, libgcrypt, libgpg-error, and libdeflate.
|
||||
Package names and ABI versions vary by distribution; inspect the release's
|
||||
`ldd` evidence and install packages from the target distribution rather than
|
||||
assuming one universal package command.
|
||||
|
||||
### Optional runtime and integration dependencies
|
||||
|
||||
- Android is optional when using the desktop TUI alone. USB/MTP synchronization
|
||||
requires the Android companion, libudev, libmtp, and an unlocked connected
|
||||
device.
|
||||
- `trainlog-syncd` installation expects a systemd user session. Manual TUI
|
||||
synchronization does not require the user service.
|
||||
- `rclone` is required for desktop Google Drive synchronization and automatic
|
||||
AI Drive workflows. Core capture, local history, analytics, and direct MTP
|
||||
synchronization continue to work without `rclone`.
|
||||
|
||||
### Build dependencies
|
||||
|
||||
Desktop builds require:
|
||||
|
||||
- Meson and Ninja;
|
||||
- a C17 compiler toolchain;
|
||||
- `pkg-config`;
|
||||
- Notcurses Core development headers;
|
||||
- SQLite, libuuid, utf8proc, libudev, libmtp, GNU libmicrohttpd, and yyjson
|
||||
development headers for the current development source;
|
||||
- Python 3 for generated sources, validators, import/export helpers, and tests.
|
||||
|
||||
The embedded Web frontend additionally uses Node and npm at build time only.
|
||||
Prepare its exact locked dependencies explicitly, then enable the release
|
||||
frontend build:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm ci
|
||||
npm run typecheck
|
||||
npm test
|
||||
cd ..
|
||||
meson setup build -Dweb=enabled
|
||||
meson compile -C build
|
||||
```
|
||||
|
||||
Meson never runs `npm install` or downloads packages. Run `npm ci` explicitly
|
||||
for the exact lockfile before configuring a Web-enabled build. The default `web=auto`
|
||||
embeds the frontend when Node, npm, and the prepared `web/node_modules` are
|
||||
available; otherwise it preserves a desktop-only build and `trainlog -w`
|
||||
reports that frontend support is absent. Release builds must use
|
||||
`-Dweb=enabled`. `-Dweb=disabled` is the explicit desktop-only choice. Node,
|
||||
npm, `node_modules`, and `web/dist` are not runtime dependencies.
|
||||
|
||||
For an existing build directory, change the option with `meson configure
|
||||
build -Dweb=enabled` (or the required value) instead of running `meson setup`
|
||||
as though the directory were new.
|
||||
|
||||
### Optional systemd user service for local Web development
|
||||
|
||||
The versioned `systemd/trainlog-web.service` unit can keep `trainlog -w`
|
||||
available on `127.0.0.1:8080` during an interactive user session. Without
|
||||
systemd user lingering, enabling it does not guarantee startup before login.
|
||||
This is a development convenience, not a requirement of the final Trainlog
|
||||
distribution.
|
||||
It intentionally runs the canonical user entry point
|
||||
`~/.local/bin/trainlog`; in a source checkout that entry point may be a symlink
|
||||
to the current `build/tui/trainlog` binary, but the symlink must resolve to an
|
||||
executable before the service is started.
|
||||
|
||||
The unit prevents privilege acquisition, gives the process a private temporary
|
||||
directory, makes system paths read-only, and applies kernel/control-group and
|
||||
setuid restrictions that are compatible with the local HTTP server. It does
|
||||
not protect or hide the home directory because SQLite and Trainlog
|
||||
configuration must remain writable under `~/.local/share/trainlog` and
|
||||
`~/.config/trainlog`. Failed starts use a ten-second restart delay and are
|
||||
limited to three attempts per minute, preventing a persistent port-8080
|
||||
conflict from producing an uncontrolled loop.
|
||||
|
||||
Install the development unit as a symlink so repository updates are picked up
|
||||
after `daemon-reload`:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/systemd/user
|
||||
ln -s "$(pwd)/systemd/trainlog-web.service" ~/.config/systemd/user/trainlog-web.service
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now trainlog-web.service
|
||||
```
|
||||
|
||||
Useful lifecycle and diagnostic commands are:
|
||||
|
||||
```bash
|
||||
systemctl --user status trainlog-web
|
||||
systemctl --user restart trainlog-web
|
||||
systemctl --user stop trainlog-web
|
||||
systemctl --user start trainlog-web
|
||||
journalctl --user -u trainlog-web
|
||||
```
|
||||
|
||||
An operator may separately configure a local nginx proxy such as
|
||||
`http://trainlog.perf`. That hostname and proxy are machine-local conveniences,
|
||||
not a public Trainlog domain or a runtime dependency, and Trainlog does not
|
||||
configure them automatically.
|
||||
|
||||
Uninstalling the integration disables the unit before removing only its user
|
||||
configuration symlink:
|
||||
|
||||
```bash
|
||||
systemctl --user disable --now trainlog-web.service
|
||||
rm ~/.config/systemd/user/trainlog-web.service
|
||||
systemctl --user daemon-reload
|
||||
```
|
||||
|
||||
Android builds require JDK 17, an Android SDK supporting the configured API
|
||||
levels, and the Gradle wrapper committed in this repository. People installing
|
||||
the APK do not need Java, Gradle, or the Android SDK.
|
||||
|
||||
## Documentation
|
||||
|
||||
| Topic | Document | Purpose |
|
||||
|---|---|---|
|
||||
| Documentation map | [docs/README.md](docs/README.md) | Canonical index and ownership map. |
|
||||
| Current state | [docs/current_state.md](docs/current_state.md) | Current implemented schemas, capabilities, validation, and limitations. |
|
||||
| Architecture | [docs/architecture.md](docs/architecture.md) | Component, storage, process, and ownership boundaries. |
|
||||
| Android | [docs/android.md](docs/android.md) | Field-companion behavior and local persistence. |
|
||||
| Desktop TUI | [docs/tui.md](docs/tui.md) | Notcurses workflows, correction, and analytics. |
|
||||
| Database | [docs/database.md](docs/database.md) | Desktop persistence schema and migration contracts. |
|
||||
| Exercise model | [docs/exercise_data_model.md](docs/exercise_data_model.md) | Exercise, occurrence, load, and MAX semantics. |
|
||||
| Synchronization | [docs/sync_exchange.md](docs/sync_exchange.md) | Active MTP flow and companion artifacts. |
|
||||
| Exchange formats | [docs/exchange_format.md](docs/exchange_format.md) | Frozen and separately versioned JSON contracts. |
|
||||
| Training feedback | [docs/training_feedback.md](docs/training_feedback.md) | Immediate feedback, revisions, and J+1 follow-ups. |
|
||||
| Tests | [docs/tests.md](docs/tests.md) | Durable validation commands and coverage strategy. |
|
||||
| Roadmap | [docs/roadmap.md](docs/roadmap.md) | Current cursor and future work only. |
|
||||
| Development contract | [AGENTS.md](AGENTS.md) | Repository invariants and contribution rules. |
|
||||
| Change history | [CHANGELOG.md](CHANGELOG.md) | Chronological implementation history. |
|
||||
|
||||
## Architecture overview
|
||||
## ✦ Architecture
|
||||
|
||||
```text
|
||||
Android local SQLite
|
||||
|
|
||||
| versioned JSON snapshots and requests
|
||||
v
|
||||
Documents/Trainlog on Android storage
|
||||
|
|
||||
| direct MTP / libmtp
|
||||
v
|
||||
trainlog_sync_run() <---- trainlog-syncd or desktop TUI
|
||||
|
|
||||
+---- import Android snapshot into desktop SQLite
|
||||
+---- publish desktop catalog companions to Android
|
||||
+---- write a synchronization receipt
|
||||
|
||||
desktop SQLite = canonical long-term history
|
||||
┌──────────────────────────────┐
|
||||
│ Android field companion │
|
||||
│ private SQLite capture store │
|
||||
└──────────────┬───────────────┘
|
||||
│ versioned JSON generations + acknowledgements
|
||||
│ Bluetooth RFCOMM primary · direct USB/MTP recovery
|
||||
▼
|
||||
┌──────────────────────────────┐ optional, separately configured
|
||||
│ Synchronization orchestration│◄───── private Drive workflow
|
||||
└──────────────┬───────────────┘
|
||||
│ typed imports, commands and read models
|
||||
▼
|
||||
┌──────────────────────────────┐
|
||||
│ Trainlog Core · C17 │
|
||||
│ desktop SQLite = canonical │
|
||||
│ long-term history │
|
||||
└──────────────┬───────────────┘
|
||||
│
|
||||
┌──────┴──────────┐
|
||||
▼ ▼
|
||||
┌──────────────┐ ┌──────────────────────┐
|
||||
│ Notcurses TUI│ │ 127.0.0.1 Web adapter│──► browser
|
||||
└──────────────┘ └──────────────────────┘
|
||||
```
|
||||
|
||||
The optional desktop AI flow exports read-only history and exchanges session
|
||||
proposals through Google Drive via the external `rclone` process. Android does
|
||||
not contain cloud credentials and does not run `rclone`.
|
||||
The TUI, Web application, and Android client are sibling adapters with
|
||||
different responsibilities. Business rules, calculations, persistence, and
|
||||
typed read models belong to Core; presentation code does not reconstruct them
|
||||
from raw database tables. See the full
|
||||
[`architecture contract`](docs/architecture.md).
|
||||
|
||||
## Current status
|
||||
## ✦ Your data, your system
|
||||
|
||||
As of 2026-09-18:
|
||||
- Trainlog runs on the user's own Android and Linux systems; no hosted Trainlog
|
||||
account or proprietary cloud service is required for core capture, history,
|
||||
analytics, or direct synchronization.
|
||||
- Android and desktop each keep a local SQLite database; the desktop database
|
||||
is the canonical long-term history.
|
||||
- Devices exchange bounded, versioned JSON artifacts—not database files.
|
||||
- The local Web server binds to `127.0.0.1` by default and never silently falls
|
||||
back to a LAN address or another port.
|
||||
- Direct Android storage uses `Documents/Trainlog`; desktop USB access uses
|
||||
`libudev` and `libmtp` without requiring a GVFS/FUSE mount.
|
||||
- The optional Drive path is operator-configured through external `rclone`.
|
||||
Android holds no cloud credentials.
|
||||
|
||||
- `TRAINLOG_FORMAT_V1=PASS/FROZEN`;
|
||||
- desktop SQLite schema v28 and Android SQLite schema v25;
|
||||
- Notcurses is the only active desktop terminal backend;
|
||||
- direct storage is `/storage/emulated/0/Documents/Trainlog` under Android's
|
||||
all-files access setting;
|
||||
- mobile export V3 is active; V1/V2 remain readable legacy inputs; explicit V4
|
||||
history and execution-draft codecs are staged but not selected by transport;
|
||||
- `STATS_V1=IMPLEMENTED`, `TRAINING_KNOWLEDGE_V1=PASS`, and
|
||||
`SESSION_GENERATOR_V1=PASS` (the generator is hidden pending V2);
|
||||
- `APP_SHELL_V1=IMPLEMENTED_AWAITING_VISUAL_REVIEW_2`;
|
||||
- `TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING` pending a real Drive plus
|
||||
Android-triggered bidirectional smoke test.
|
||||
- `WEB_FRONTEND_SHELL_V1=PASS/FROZEN` and
|
||||
`WEB_DASHBOARD_V1=PASS/FROZEN` and `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN`;
|
||||
`/programmes` is an operational active-Program calendar, while
|
||||
Sessions → Programmes remains the administration surface; Analyse and
|
||||
Exercises remain placeholders;
|
||||
- `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=PASS`: the coordinated
|
||||
private desktop/Android deployment, real Firefox presentation, correlated
|
||||
`programs-v1` deletion ACK, restart/replay, and non-resurrection checks pass;
|
||||
- `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`;
|
||||
- `TRAINLOG_SYNC_GENERATION_ACK_V1=PASS/FROZEN` through explicit staged
|
||||
services; automatic synchronization still selects V3. The trusted local
|
||||
opt-in path is deployed on the paired private installation and has completed
|
||||
real direct-MTP restart/replay validation; it is not a public rollout.
|
||||
## ✦ Build and run
|
||||
|
||||
The latest executable result belongs in
|
||||
[current state](docs/current_state.md), not in multiple README narratives.
|
||||
### Desktop and TUI
|
||||
|
||||
## Desktop development quick start
|
||||
|
||||
Requirements include Meson, Ninja, SQLite3, utf8proc, libuuid, libudev,
|
||||
libmtp, and Notcurses.
|
||||
The desktop build requires Meson, Ninja, a C17 toolchain, `pkg-config`, Python
|
||||
3, and development files for SQLite, libuuid, utf8proc, libudev, libmtp,
|
||||
Notcurses Core, GNU libmicrohttpd, and yyjson.
|
||||
|
||||
```bash
|
||||
meson setup build
|
||||
|
|
@ -373,13 +127,36 @@ meson test -C build --print-errorlogs
|
|||
./build/tui/trainlog
|
||||
```
|
||||
|
||||
The database path is `$XDG_DATA_HOME/trainlog/trainlog.db`, or
|
||||
The desktop database is stored at
|
||||
`$XDG_DATA_HOME/trainlog/trainlog.db`, or
|
||||
`~/.local/share/trainlog/trainlog.db` when `XDG_DATA_HOME` is unset.
|
||||
|
||||
## Android development quick start
|
||||
### Embedded local Web application
|
||||
|
||||
Use Java 17 and provide the Android SDK through `ANDROID_HOME` or the untracked
|
||||
`android/local.properties` file.
|
||||
Node and npm are build-time dependencies only. Meson does not download npm
|
||||
packages.
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm ci
|
||||
npm run typecheck
|
||||
npm test
|
||||
cd ..
|
||||
|
||||
meson setup build-web -Dweb=enabled
|
||||
meson compile -C build-web
|
||||
./build-web/tui/trainlog --web
|
||||
```
|
||||
|
||||
The Web application listens on `127.0.0.1:8080` by default. Use
|
||||
`--port <port>` for an explicit alternative. For an existing build directory,
|
||||
use `meson configure build -Dweb=enabled` instead of running `meson setup`
|
||||
again.
|
||||
|
||||
### Android
|
||||
|
||||
Android builds use the committed Gradle wrapper, JDK 17, and an Android SDK for
|
||||
the configured API levels (`minSdk 26`, `targetSdk 36`, `compileSdk 37`).
|
||||
|
||||
```bash
|
||||
cd android
|
||||
|
|
@ -387,38 +164,81 @@ JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew test
|
|||
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew assembleDebug
|
||||
```
|
||||
|
||||
Instrumented tests belong on an emulator. Do not run
|
||||
`connectedDebugAndroidTest` on the primary personal phone.
|
||||
Instrumented tests belong on an emulator; the primary personal phone is
|
||||
reserved for manual, non-destructive installation and synchronization checks.
|
||||
Release APK signing is intentionally operator-local and is not configured with
|
||||
credentials from this repository.
|
||||
|
||||
## Synchronization quick start
|
||||
<details>
|
||||
<summary>Optional user-session services</summary>
|
||||
|
||||
After a desktop build, the repository provides an installer for the private
|
||||
automatic synchronization services:
|
||||
|
||||
```bash
|
||||
meson compile -C build
|
||||
bash tools/install_syncd_user.sh
|
||||
systemctl --user is-active trainlog-syncd.service
|
||||
```
|
||||
|
||||
On Android, grant all-files access for the fixed `Documents/Trainlog`
|
||||
directory, then use **Synchronisation → Synchroniser maintenant**. Hardware MTP
|
||||
validation requires an unlocked connected device and remains an explicit manual
|
||||
check.
|
||||
This modifies the current user's `~/.local/bin` and systemd user configuration.
|
||||
Bluetooth generation transport starts only when its separate Trainlog
|
||||
configuration explicitly enables it. The loopback Web development unit in
|
||||
`systemd/trainlog-web.service` is another optional integration; neither service
|
||||
is required for manual TUI use.
|
||||
|
||||
## Development principles
|
||||
</details>
|
||||
|
||||
- Preserve frozen, versioned compatibility boundaries.
|
||||
- Use stable IDs; display names are not identities.
|
||||
- Keep performed work separate from plans and continuous activity separate
|
||||
from fake sets.
|
||||
- Keep Android capture, desktop analytics, persistence, transport, and
|
||||
rendering responsibilities distinct.
|
||||
- Prefer explicit failure to silent data loss or corruption.
|
||||
- Treat migrations, idempotence, tests, and documentation as part of feature
|
||||
completion.
|
||||
- Keep scientific catalogs separate from runtime user data and preserve stated
|
||||
uncertainty.
|
||||
Prebuilt stable APKs and Linux x86-64 runtime bundles are published on both
|
||||
official mirrors: [Forgejo Releases](https://git.labfytools.com/fy59/trainlog/releases)
|
||||
and [GitHub Releases](https://github.com/labfytools/trainlog/releases). Verify
|
||||
downloaded artifacts against the accompanying `SHA256SUMS`.
|
||||
|
||||
## License and project notes
|
||||
## ✦ Repository layout
|
||||
|
||||
See [LICENSE](LICENSE). Forgejo is the primary repository; GitHub is a mirror.
|
||||
Repository layout, detailed contracts, and historical review records are linked
|
||||
from [docs/README.md](docs/README.md).
|
||||
```text
|
||||
trainlog/
|
||||
├── android/ Kotlin + Jetpack Compose field companion
|
||||
├── tui/ C17 Core, Notcurses TUI, Web adapter and native tests
|
||||
├── web/ React + TypeScript + Vite frontend
|
||||
├── catalog/ versioned scientific and product knowledge
|
||||
├── format/ JSON schemas and frozen format material
|
||||
├── contracts/ machine-readable presentation contracts
|
||||
├── tools/ validators, import/export and sync utilities
|
||||
├── tests/ cross-surface and protocol regression tests
|
||||
├── systemd/ optional user-session integration
|
||||
└── docs/ canonical documentation and review evidence
|
||||
```
|
||||
|
||||
## ✦ Documentation
|
||||
|
||||
| Start here | Scope |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------------------------- |
|
||||
| [`docs/current_state.md`](docs/current_state.md) | Implemented capabilities, versions, validation evidence and active limitations. |
|
||||
| [`docs/architecture.md`](docs/architecture.md) | Component, storage, process, transport and ownership boundaries. |
|
||||
| [`docs/roadmap.md`](docs/roadmap.md) | Current cursor and future work. |
|
||||
| [`docs/android.md`](docs/android.md) | Android capture, persistence and synchronization behavior. |
|
||||
| [`docs/tui.md`](docs/tui.md) | Notcurses workflows, correction, analytics and technical tools. |
|
||||
| [`docs/sync_exchange.md`](docs/sync_exchange.md) | Active synchronization flow, replay and acknowledgement semantics. |
|
||||
| [`docs/exchange_format.md`](docs/exchange_format.md) | Frozen and separately versioned JSON contracts. |
|
||||
| [`docs/exercise_data_model.md`](docs/exercise_data_model.md) | Exercise, occurrence, planning, load and MAX semantics. |
|
||||
| [`docs/database.md`](docs/database.md) | Desktop SQLite schema and migration contracts. |
|
||||
| [`docs/tests.md`](docs/tests.md) | Canonical validation commands and coverage strategy. |
|
||||
|
||||
The complete ownership map is in [`docs/README.md`](docs/README.md), with
|
||||
chronological detail in the [`CHANGELOG`](CHANGELOG.md).
|
||||
|
||||
## ✦ Project status
|
||||
|
||||
| Boundary | Current state |
|
||||
| ------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| Product | `0.1.6` stable |
|
||||
| Desktop persistence | SQLite schema v36 |
|
||||
| Android persistence | SQLite schema v33 |
|
||||
| Mobile exchange | V3 active; V1/V2 readable; V4 staged only |
|
||||
| Desktop terminal | C17 + Notcurses, active backend |
|
||||
| Local Web | Dashboard and operational Analyse, Programmes, Sessions, Exercises, Equipment, Settings and sync surfaces |
|
||||
| Interface language | French by default; English selectable on Android and desktop |
|
||||
| Compatibility | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
|
||||
|
||||
Forgejo is the primary repository; GitHub is its release mirror. Trainlog is
|
||||
licensed under the [GNU General Public License v3](LICENSE).
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
} ?: 3
|
||||
} ?: 7
|
||||
|
||||
/*
|
||||
* WHY: a user may need to finish one preserved draft after a completion bug
|
||||
* has been repaired, while its factual end time predates the repair build.
|
||||
* CONTRACT: both values are private-build inputs, must be supplied together,
|
||||
* and are embedded only after strict identity/timestamp validation.
|
||||
* INVARIANT: ordinary builds use empty values and every non-matching future
|
||||
* session continues to finalize at the live clock time.
|
||||
*/
|
||||
val recoverySessionId =
|
||||
providers.environmentVariable("TRAINLOG_ANDROID_RECOVERY_SESSION_ID").orNull.orEmpty()
|
||||
val recoveryEndedAt =
|
||||
providers.environmentVariable("TRAINLOG_ANDROID_RECOVERY_ENDED_AT").orNull.orEmpty()
|
||||
if (recoverySessionId.isEmpty() != recoveryEndedAt.isEmpty()) {
|
||||
throw GradleException(
|
||||
"TRAINLOG_ANDROID_RECOVERY_SESSION_ID and " +
|
||||
"TRAINLOG_ANDROID_RECOVERY_ENDED_AT must be supplied together",
|
||||
)
|
||||
}
|
||||
if (recoverySessionId.isNotEmpty()) {
|
||||
val sessionPattern =
|
||||
Regex("^se_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
|
||||
if (!sessionPattern.matches(recoverySessionId)) {
|
||||
throw GradleException("TRAINLOG_ANDROID_RECOVERY_SESSION_ID is invalid")
|
||||
}
|
||||
try {
|
||||
OffsetDateTime.parse(recoveryEndedAt)
|
||||
} catch (_: Exception) {
|
||||
throw GradleException("TRAINLOG_ANDROID_RECOVERY_ENDED_AT is invalid")
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.labfytools.trainlog"
|
||||
|
|
@ -55,7 +87,9 @@ android {
|
|||
targetSdk = 36
|
||||
|
||||
versionCode = trainlogVersionCode
|
||||
versionName = "0.1.2"
|
||||
versionName = "0.1.6"
|
||||
buildConfigField("String", "ACTIVE_SESSION_RECOVERY_ID", "\"$recoverySessionId\"")
|
||||
buildConfigField("String", "ACTIVE_SESSION_RECOVERY_ENDED_AT", "\"$recoveryEndedAt\"")
|
||||
testInstrumentationRunner =
|
||||
"androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
|
@ -86,6 +120,7 @@ android {
|
|||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ class DraftUiTestActivity : ComponentActivity() {
|
|||
sessionVisible = false
|
||||
},
|
||||
onCreateExercise = {},
|
||||
onCreateEquipment = { _ -> },
|
||||
/* CONTRACT: this host never writes shared export data. */
|
||||
onSessionSaved = {},
|
||||
)
|
||||
|
|
@ -82,8 +83,6 @@ class DraftUiTestActivity : ComponentActivity() {
|
|||
?: (loaded as?
|
||||
ActiveDraftLoadResult.Loaded)
|
||||
?.warning,
|
||||
latestSession = null,
|
||||
latestMaximum = null,
|
||||
bodyZoneOverview = repository.getBodyZoneHomeOverview(),
|
||||
onSession = {
|
||||
when (
|
||||
|
|
@ -102,9 +101,7 @@ class DraftUiTestActivity : ComponentActivity() {
|
|||
}
|
||||
},
|
||||
onOpenExercises = {},
|
||||
onOpenStatistics = {},
|
||||
onBody = {},
|
||||
onOpenLatestSession = {},
|
||||
onSync = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -26,7 +26,13 @@ internal data class VerifiedAndroidBackup(
|
|||
)
|
||||
|
||||
/** Complete same-installation backup container; plaintext is explicit by design. */
|
||||
internal class AndroidBackupService(private val context: Context) {
|
||||
internal class AndroidBackupService(
|
||||
private val context: Context,
|
||||
private val productVersion: String =
|
||||
checkNotNull(
|
||||
context.packageManager.getPackageInfo(context.packageName, 0).versionName,
|
||||
),
|
||||
) {
|
||||
companion object {
|
||||
const val FORMAT = "trainlog-android-backup"
|
||||
const val VERSION = 1
|
||||
|
|
@ -140,7 +146,7 @@ internal class AndroidBackupService(private val context: Context) {
|
|||
.put("version", VERSION)
|
||||
.put("package", context.packageName)
|
||||
.put("schema", schema)
|
||||
.put("product_version", "0.1.2")
|
||||
.put("product_version", productVersion)
|
||||
.put("plaintext", true)
|
||||
.put("created_at", OffsetDateTime.now().toString())
|
||||
.put(
|
||||
|
|
@ -219,7 +225,7 @@ internal class AndroidBackupService(private val context: Context) {
|
|||
manifest.getInt("version") == VERSION &&
|
||||
manifest.getString("package") == context.packageName &&
|
||||
manifest.getBoolean("plaintext") &&
|
||||
manifest.getInt("schema") in 17..25
|
||||
manifest.getInt("schema") in 17..33
|
||||
) {
|
||||
"Unsupported backup."
|
||||
}
|
||||
|
|
@ -313,7 +319,7 @@ internal class AndroidBackupService(private val context: Context) {
|
|||
}
|
||||
)
|
||||
require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() })
|
||||
require(db.version in 17..25) { "Unsupported Android schema ${db.version}." }
|
||||
require(db.version in 17..33) { "Unsupported Android schema ${db.version}." }
|
||||
return db.version
|
||||
} finally {
|
||||
db.close()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
@ -291,10 +299,15 @@ class DriveSyncWorker(context: Context, parameters: WorkerParameters) :
|
|||
transport.pull(local)
|
||||
stage = "coordinate"
|
||||
val coordinator = SyncGenerationCoordinator(repository)
|
||||
val terminalLedger = BackgroundSyncRunLedger(applicationContext)
|
||||
coordinator.publishPeer(local)
|
||||
stage = "peer-publication"
|
||||
transport.pushPeer(local)
|
||||
if (!coordinator.hasActionableRequest(local)) {
|
||||
val actionability = coordinator.classifyBackgroundRequest(local, terminalLedger.terminal())
|
||||
if (
|
||||
actionability == BackgroundRequestActionability.NOT_ACTIONABLE ||
|
||||
actionability == BackgroundRequestActionability.RESUMABLE_BUT_STALE
|
||||
) {
|
||||
if (coordinator.hasReplayableAcknowledgement(local)) {
|
||||
stage = "acknowledgement-replay"
|
||||
transport.pushAcknowledgement(local)
|
||||
|
|
@ -313,6 +326,10 @@ class DriveSyncWorker(context: Context, parameters: WorkerParameters) :
|
|||
local,
|
||||
Duration.ofMinutes(10),
|
||||
afterPeerPublication = { transport.pushPeer(local) },
|
||||
afterRecoveryAcknowledgements = {
|
||||
stage = "recovery-acknowledgement"
|
||||
transport.pushRecoveryAcknowledgements(local)
|
||||
},
|
||||
afterPublication = {
|
||||
stage = "generation-publication"
|
||||
transport.pushGeneration(local)
|
||||
|
|
@ -322,8 +339,31 @@ class DriveSyncWorker(context: Context, parameters: WorkerParameters) :
|
|||
stage = "acknowledgement"
|
||||
transport.pushAcknowledgement(local)
|
||||
},
|
||||
intent = SyncConversationIntent.RESUME_BACKGROUND,
|
||||
)
|
||||
if (result is ForegroundGenerationResult.Completed) Result.success() else Result.retry()
|
||||
when (result) {
|
||||
is ForegroundGenerationResult.Completed -> {
|
||||
terminalLedger.recordTerminal(
|
||||
result.runId,
|
||||
coordinator.remoteEvidence(local, result.runId),
|
||||
)
|
||||
Result.success()
|
||||
}
|
||||
ForegroundGenerationResult.Busy,
|
||||
is ForegroundGenerationResult.Cancelled -> Result.success()
|
||||
is ForegroundGenerationResult.Superseded -> {
|
||||
result.runId?.let {
|
||||
terminalLedger.recordTerminal(it, coordinator.remoteEvidence(local, it))
|
||||
}
|
||||
Result.success()
|
||||
}
|
||||
is ForegroundGenerationResult.Failed -> {
|
||||
result.runId?.let {
|
||||
terminalLedger.recordTerminal(it, coordinator.remoteEvidence(local, it))
|
||||
}
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
/* SECURITY: stage and exception class are fixed application
|
||||
* metadata. Provider messages may contain private URIs and are
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ internal fun canonicalExchangeDirectory(): File =
|
|||
internal fun exchangeArtifactIdentity(displayName: String): String = when (displayName) {
|
||||
"trainlog-exercise-body-zones-v1.json" -> "BODY_ZONES"
|
||||
"trainlog-sync-request-v1.json" -> "SYNC_REQUEST"
|
||||
"trainlog-sync-full-generation-request-v1.json" -> "FULL_GENERATION_SYNC_REQUEST"
|
||||
"trainlog-mobile-export-v3.json" -> "MOBILE_EXPORT"
|
||||
"trainlog-mobile-equipment-definitions-v1.json" -> "EQUIPMENT_DEFINITIONS"
|
||||
"trainlog-equipment-associations-v2.json" -> "EQUIPMENT_ASSOCIATIONS"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaScannerConnection
|
||||
import java.io.File
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/** Confirms that durable external-storage files have reached Android's MTP index. */
|
||||
internal fun interface MtpPublicationVisibility {
|
||||
fun confirm(files: List<File>)
|
||||
}
|
||||
|
||||
internal object ImmediateMtpPublicationVisibility : MtpPublicationVisibility {
|
||||
override fun confirm(files: List<File>) = Unit
|
||||
}
|
||||
|
||||
internal class AndroidMtpPublicationVisibility(context: Context) : MtpPublicationVisibility {
|
||||
private val applicationContext = context.applicationContext
|
||||
|
||||
override fun confirm(files: List<File>) {
|
||||
val paths = files.asSequence().filter(File::isFile).map { it.absolutePath }.distinct().toList()
|
||||
if (paths.isEmpty()) return
|
||||
val completed = CountDownLatch(paths.size)
|
||||
/* WHY: direct File writes are durable before MediaProvider necessarily
|
||||
* advertises them through Samsung's MTP database. CONTRACT: every
|
||||
* immutable artifact is scanned before its mutable reference, and a
|
||||
* publication is not reported complete until every callback arrives.
|
||||
* INVARIANT: scanning changes transport visibility only; canonical
|
||||
* names, bytes, generation identities and commit markers are intact. */
|
||||
MediaScannerConnection.scanFile(
|
||||
applicationContext,
|
||||
paths.toTypedArray(),
|
||||
Array(paths.size) { "application/json" },
|
||||
) { _, _ -> completed.countDown() }
|
||||
if (!completed.await(15, TimeUnit.SECONDS)) {
|
||||
throw SyncGenerationException("timeout confirming MTP publication visibility")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.content.Context
|
||||
|
||||
internal enum class BackgroundRequestActionability {
|
||||
NEW_REQUEST,
|
||||
NEW_REMOTE_EVIDENCE,
|
||||
ACTIVE_HANDOFF,
|
||||
RESUMABLE_BUT_STALE,
|
||||
NOT_ACTIONABLE,
|
||||
}
|
||||
|
||||
internal data class BackgroundTerminalRun(
|
||||
val runId: String,
|
||||
val remoteEvidence: String,
|
||||
)
|
||||
|
||||
/** One-entry durable suppression ledger for the current background request. */
|
||||
internal class BackgroundSyncRunLedger(context: Context) {
|
||||
private val preferences =
|
||||
context.applicationContext.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||
|
||||
fun terminal(): BackgroundTerminalRun? {
|
||||
val runId = preferences.getString(RUN_ID, null) ?: return null
|
||||
return BackgroundTerminalRun(runId, preferences.getString(REMOTE_EVIDENCE, "").orEmpty())
|
||||
}
|
||||
|
||||
fun recordTerminal(runId: String, remoteEvidence: String) {
|
||||
/* CONTRACT: only the request currently present on the transport needs
|
||||
* suppression. A replacement request has a distinct run_id and is
|
||||
* immediately actionable, so this ledger remains permanently bounded
|
||||
* to one record and is not a second synchronization database. */
|
||||
preferences.edit()
|
||||
.putString(RUN_ID, runId)
|
||||
.putString(REMOTE_EVIDENCE, remoteEvidence)
|
||||
.apply()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREFERENCES = "trainlog-background-sync-terminal"
|
||||
private const val RUN_ID = "run_id"
|
||||
private const val REMOTE_EVIDENCE = "remote_evidence"
|
||||
}
|
||||
}
|
||||
|
|
@ -32,37 +32,29 @@ internal class BackgroundSyncSettings(context: Context) {
|
|||
}
|
||||
|
||||
/**
|
||||
* User-enabled foreground listener for PC-initiated USB generation requests.
|
||||
* User-enabled foreground listener for automatic Bluetooth and MTP synchronization.
|
||||
*
|
||||
* WHY: a Web request must be consumed while Compose is not visible, but modern
|
||||
* Android does not permit an invisible permanent background loop.
|
||||
* CONTRACT: the service shows a mandatory notification, invokes the same
|
||||
* generation coordinator as SyncScreen, and never starts an exercise.
|
||||
* INVARIANT: filesystem visibility only triggers protocol validation; stable
|
||||
* WHY: the phone must reconnect to the paired desktop while Compose is not
|
||||
* visible, but modern Android does not permit an invisible permanent loop.
|
||||
* CONTRACT: the service shows a mandatory notification, owns the RFCOMM client,
|
||||
* invokes the same generation coordinator as SyncScreen, and never starts an
|
||||
* exercise. MTP remains a filesystem fallback.
|
||||
* INVARIANT: transport visibility only triggers protocol validation; stable
|
||||
* run/generation/ACK identities remain the sole business authority.
|
||||
*/
|
||||
class SyncBackgroundService : Service() {
|
||||
private val stopped = AtomicBoolean(false)
|
||||
private val launched = AtomicBoolean(false)
|
||||
private val executor = Executors.newSingleThreadExecutor()
|
||||
private val executor = Executors.newFixedThreadPool(2)
|
||||
private var repository: TrainlogRepository? = null
|
||||
private var bluetoothClient: BluetoothSyncClient? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createChannel()
|
||||
val notification = NotificationCompat.Builder(this, CHANNEL)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(getString(R.string.background_sync_notification_title))
|
||||
.setContentText(getString(R.string.background_sync_notification_text))
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.build()
|
||||
if (Build.VERSION.SDK_INT >= 29) {
|
||||
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
promoteForeground()
|
||||
repository = TrainlogRepository(applicationContext)
|
||||
bluetoothClient = BluetoothSyncClient(applicationContext, requireNotNull(repository))
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
|
|
@ -70,9 +62,17 @@ class SyncBackgroundService : Service() {
|
|||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
promoteForeground()
|
||||
if (!launched.compareAndSet(false, true)) return START_NOT_STICKY
|
||||
executor.execute {
|
||||
val coordinator = SyncGenerationCoordinator(requireNotNull(repository))
|
||||
bluetoothClient?.run(stopped)
|
||||
}
|
||||
executor.execute {
|
||||
val coordinator = SyncGenerationCoordinator(
|
||||
requireNotNull(repository),
|
||||
AndroidMtpPublicationVisibility(applicationContext),
|
||||
)
|
||||
val terminalLedger = BackgroundSyncRunLedger(applicationContext)
|
||||
while (!stopped.get() && BackgroundSyncSettings(this).enabled) {
|
||||
val directory = canonicalExchangeDirectory()
|
||||
/* WHY: waiting inside run() owns the process-wide conversation
|
||||
|
|
@ -81,9 +81,38 @@ class SyncBackgroundService : Service() {
|
|||
* the coordinator still validates all protocol evidence.
|
||||
* INVARIANT: no request means no business work and no lock. */
|
||||
coordinator.publishPeer(directory)
|
||||
if (coordinator.hasActionableRequest(directory)) {
|
||||
coordinator.run(directory, Duration.ofMinutes(5))
|
||||
val actionability =
|
||||
coordinator.classifyBackgroundRequest(directory, terminalLedger.terminal())
|
||||
if (
|
||||
actionability == BackgroundRequestActionability.NEW_REQUEST ||
|
||||
actionability == BackgroundRequestActionability.NEW_REMOTE_EVIDENCE ||
|
||||
actionability == BackgroundRequestActionability.ACTIVE_HANDOFF
|
||||
) {
|
||||
when (
|
||||
val result = coordinator.run(
|
||||
directory,
|
||||
Duration.ofMinutes(5),
|
||||
intent = SyncConversationIntent.RESUME_BACKGROUND,
|
||||
)
|
||||
) {
|
||||
is ForegroundGenerationResult.Completed ->
|
||||
terminalLedger.recordTerminal(
|
||||
result.runId,
|
||||
coordinator.remoteEvidence(directory, result.runId),
|
||||
)
|
||||
is ForegroundGenerationResult.Failed ->
|
||||
result.runId?.let {
|
||||
terminalLedger.recordTerminal(it, coordinator.remoteEvidence(directory, it))
|
||||
}
|
||||
is ForegroundGenerationResult.Superseded ->
|
||||
result.runId?.let {
|
||||
terminalLedger.recordTerminal(it, coordinator.remoteEvidence(directory, it))
|
||||
}
|
||||
ForegroundGenerationResult.Busy,
|
||||
is ForegroundGenerationResult.Cancelled -> Unit
|
||||
}
|
||||
}
|
||||
AiExportDriveSettings.enqueueImmediate(applicationContext)
|
||||
if (!stopped.get()) runCatching { Thread.sleep(5_000) }
|
||||
}
|
||||
stopSelf()
|
||||
|
|
@ -93,6 +122,8 @@ class SyncBackgroundService : Service() {
|
|||
|
||||
override fun onDestroy() {
|
||||
stopped.set(true)
|
||||
bluetoothClient?.shutdown()
|
||||
bluetoothClient = null
|
||||
executor.shutdownNow()
|
||||
repository?.close()
|
||||
repository = null
|
||||
|
|
@ -109,6 +140,28 @@ class SyncBackgroundService : Service() {
|
|||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
private fun promoteForeground() {
|
||||
val notification =
|
||||
NotificationCompat.Builder(this, CHANNEL)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(getString(R.string.background_sync_notification_title))
|
||||
.setContentText(getString(R.string.background_sync_notification_text))
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.build()
|
||||
if (Build.VERSION.SDK_INT >= 29) {
|
||||
val serviceType =
|
||||
if (hasBluetoothConnectPermission(this)) {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
|
||||
} else {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||
}
|
||||
startForeground(NOTIFICATION_ID, notification, serviceType)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createChannel() {
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager.createNotificationChannel(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,116 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import java.time.Duration
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.withLock
|
||||
|
||||
internal enum class SyncConversationOwner {
|
||||
FOREGROUND_EXPLICIT,
|
||||
BACKGROUND_AUTOMATIC,
|
||||
}
|
||||
|
||||
internal class SyncConversationYieldedException : Exception("conversation superseded by explicit sync")
|
||||
|
||||
/** Process-wide ownership authority for Android generation conversations. */
|
||||
internal class SyncConversationArbiter {
|
||||
private val lock = ReentrantLock()
|
||||
private val ownershipChanged = lock.newCondition()
|
||||
private var active: Lease? = null
|
||||
private var pendingExplicit = 0
|
||||
|
||||
/**
|
||||
* Announces user intent before the foreground service is refreshed.
|
||||
*
|
||||
* WHY: starting the service first allowed it to acquire the old binary
|
||||
* lock and silently absorb the click. CONTRACT: an announcement prevents
|
||||
* new background ownership and cooperatively supersedes an existing
|
||||
* background lease. INVARIANT: ownership is still exclusive until that
|
||||
* lease observes yield and releases in its finally block.
|
||||
*/
|
||||
fun announceExplicit(): ExplicitClaim = lock.withLock {
|
||||
pendingExplicit += 1
|
||||
active?.takeIf { it.owner == SyncConversationOwner.BACKGROUND_AUTOMATIC }
|
||||
?.requestYield()
|
||||
ownershipChanged.signalAll()
|
||||
ExplicitClaim(this)
|
||||
}
|
||||
|
||||
fun tryAcquireBackground(): Lease? = lock.withLock {
|
||||
if (active != null || pendingExplicit > 0) return null
|
||||
Lease(this, SyncConversationOwner.BACKGROUND_AUTOMATIC).also { active = it }
|
||||
}
|
||||
|
||||
private fun acquireExplicit(claim: ExplicitClaim, timeout: Duration): Lease? {
|
||||
return lock.withLock {
|
||||
check(claim.announced)
|
||||
var remaining = timeout.toNanos()
|
||||
while (active != null) {
|
||||
if (remaining <= 0L) {
|
||||
releaseAnnouncement(claim)
|
||||
return@withLock null
|
||||
}
|
||||
remaining = ownershipChanged.awaitNanos(remaining)
|
||||
}
|
||||
releaseAnnouncement(claim)
|
||||
Lease(this, SyncConversationOwner.FOREGROUND_EXPLICIT).also { active = it }
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseAnnouncement(claim: ExplicitClaim) {
|
||||
if (!claim.announced) return
|
||||
claim.announced = false
|
||||
pendingExplicit -= 1
|
||||
check(pendingExplicit >= 0)
|
||||
ownershipChanged.signalAll()
|
||||
}
|
||||
|
||||
private fun release(lease: Lease) = lock.withLock {
|
||||
if (active === lease) {
|
||||
active = null
|
||||
ownershipChanged.signalAll()
|
||||
}
|
||||
}
|
||||
|
||||
internal class ExplicitClaim internal constructor(private val arbiter: SyncConversationArbiter) :
|
||||
AutoCloseable {
|
||||
internal var announced = true
|
||||
private var acquired = false
|
||||
|
||||
fun acquire(timeout: Duration): Lease? {
|
||||
check(!acquired)
|
||||
val lease = arbiter.acquireExplicit(this, timeout)
|
||||
acquired = lease != null
|
||||
return lease
|
||||
}
|
||||
|
||||
override fun close() = arbiter.lock.withLock {
|
||||
if (!acquired) arbiter.releaseAnnouncement(this)
|
||||
}
|
||||
}
|
||||
|
||||
internal class Lease internal constructor(
|
||||
private val arbiter: SyncConversationArbiter,
|
||||
val owner: SyncConversationOwner,
|
||||
) : AutoCloseable {
|
||||
@Volatile private var yieldRequested = false
|
||||
@Volatile private var closed = false
|
||||
|
||||
internal fun requestYield() {
|
||||
yieldRequested = true
|
||||
}
|
||||
|
||||
fun throwIfYieldRequested() {
|
||||
if (yieldRequested) throw SyncConversationYieldedException()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
if (closed) return
|
||||
closed = true
|
||||
arbiter.release(this)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val process = SyncConversationArbiter()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.nio.file.AtomicMoveNotSupportedException
|
||||
|
|
@ -12,33 +13,72 @@ import org.json.JSONObject
|
|||
|
||||
internal sealed interface ForegroundGenerationResult {
|
||||
data class Completed(val runId: String) : ForegroundGenerationResult
|
||||
|
||||
data class Error(val message: String) : ForegroundGenerationResult
|
||||
data object Busy : ForegroundGenerationResult
|
||||
data class Superseded(val runId: String?) : ForegroundGenerationResult
|
||||
data class Cancelled(val runId: String?) : ForegroundGenerationResult
|
||||
data class Failed(val message: String, val runId: String?) : ForegroundGenerationResult
|
||||
}
|
||||
|
||||
internal enum class SyncConversationIntent {
|
||||
NEW_EXPLICIT,
|
||||
RESUME_BACKGROUND,
|
||||
}
|
||||
|
||||
internal fun interface SyncGenerationTrace {
|
||||
fun record(runId: String?, phase: String, artifact: String?, generationId: String?, result: String?)
|
||||
}
|
||||
|
||||
internal object AndroidSyncGenerationTrace : SyncGenerationTrace {
|
||||
override fun record(
|
||||
runId: String?, phase: String, artifact: String?, generationId: String?, result: String?
|
||||
) {
|
||||
Log.i(
|
||||
"TrainlogSyncGeneration",
|
||||
listOfNotNull(
|
||||
"run_id=${runId ?: "pending"}",
|
||||
"phase=$phase",
|
||||
artifact?.let { "artifact=$it" },
|
||||
generationId?.let { "generation_id=$it" },
|
||||
result?.let { "result=$it" },
|
||||
).joinToString(" "),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun acknowledgementMatchesCapturedGeneration(
|
||||
acknowledgement: JSONObject,
|
||||
captured: CapturedSyncGeneration,
|
||||
): Boolean =
|
||||
acknowledgement.optString("generation_id") == captured.generationId &&
|
||||
acknowledgement.optString("manifest_sha256") == captured.manifestSha256
|
||||
|
||||
/** Owns the bounded, user-initiated Android side of a generation conversation. */
|
||||
internal class SyncGenerationCoordinator(private val repository: TrainlogRepository) {
|
||||
internal class SyncGenerationCoordinator(
|
||||
private val repository: TrainlogRepository,
|
||||
private val visibility: MtpPublicationVisibility = ImmediateMtpPublicationVisibility,
|
||||
private val trace: SyncGenerationTrace = AndroidSyncGenerationTrace,
|
||||
private val arbiter: SyncConversationArbiter = SyncConversationArbiter.process,
|
||||
) {
|
||||
private val service = SyncGenerationService(repository)
|
||||
|
||||
companion object {
|
||||
/* WHY: UI and background triggers share one repository protocol and
|
||||
* must never create concurrent runs. CONTRACT: this process-wide lock
|
||||
* guards only one bounded conversation; SQLite remains the durable
|
||||
* replay authority after process restart. */
|
||||
private val conversationLock = java.util.concurrent.locks.ReentrantLock()
|
||||
private companion object {
|
||||
const val ACK_RECOVERY_CAPABILITY = "generation-ack-recovery-v1"
|
||||
const val MAX_COORDINATION_BYTES = 65_536
|
||||
}
|
||||
|
||||
private fun phase(
|
||||
runId: String?,
|
||||
phase: String,
|
||||
artifact: String? = null,
|
||||
generationId: String? = null,
|
||||
result: String? = null,
|
||||
) = trace.record(runId, phase, artifact, generationId, result)
|
||||
|
||||
internal fun resumableGeneration(runId: String, consumerPeerId: String? = null): CapturedSyncGeneration? =
|
||||
repository.inSyncGenerationTransaction { db ->
|
||||
db.rawQuery(
|
||||
"SELECT generation_id,manifest_sha256,staging_path FROM sync_generations " +
|
||||
"WHERE run_id=? AND (status IN('captured','published','waiting_acknowledgement') " +
|
||||
"OR (status='acknowledged' AND NOT EXISTS(" +
|
||||
"SELECT 1 FROM sync_consumed_generations c WHERE c.run_id=sync_generations.run_id " +
|
||||
"AND c.result IN('consumed','rejected')))) " +
|
||||
(if (consumerPeerId == null) "" else "AND consumer_peer_id=? ") +
|
||||
"LIMIT 2",
|
||||
if (consumerPeerId == null) arrayOf(runId) else arrayOf(runId, consumerPeerId),
|
||||
fun query(sql: String, arguments: Array<String>): CapturedSyncGeneration? = db.rawQuery(
|
||||
sql,
|
||||
arguments,
|
||||
).use { cursor ->
|
||||
if (!cursor.moveToFirst()) null
|
||||
else {
|
||||
|
|
@ -54,12 +94,35 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
|
|||
captured
|
||||
}
|
||||
}
|
||||
query(
|
||||
"SELECT generation_id,manifest_sha256,staging_path FROM sync_generations " +
|
||||
"WHERE run_id=? AND (status IN('captured','published','waiting_acknowledgement') " +
|
||||
"OR (status='acknowledged' AND NOT EXISTS(" +
|
||||
"SELECT 1 FROM sync_consumed_generations c WHERE c.run_id=sync_generations.run_id " +
|
||||
"AND c.result IN('consumed','rejected')))) " +
|
||||
(if (consumerPeerId == null) "" else "AND consumer_peer_id=? ") +
|
||||
"LIMIT 2",
|
||||
if (consumerPeerId == null) arrayOf(runId) else arrayOf(runId, consumerPeerId),
|
||||
) ?: if (consumerPeerId == null) null else query(
|
||||
/* WHY: an RFCOMM reset creates a new conversation run while
|
||||
* the immutable generation still awaits its correlated ACK.
|
||||
* CONTRACT: resume only the unsuperseded generation for the
|
||||
* same peer; the reference adopts the new run while manifest
|
||||
* and generation identity remain unchanged. */
|
||||
"SELECT g.generation_id,g.manifest_sha256,g.staging_path FROM sync_generations g " +
|
||||
"WHERE g.consumer_peer_id=? AND g.status IN('captured','published','waiting_acknowledgement') " +
|
||||
"AND NOT EXISTS(SELECT 1 FROM sync_generations sibling JOIN sync_acknowledgements a " +
|
||||
"ON a.generation_id=sibling.generation_id WHERE sibling.parent_generation_id=g.parent_generation_id " +
|
||||
"AND sibling.generation_id<>g.generation_id AND sibling.status='acknowledged') " +
|
||||
"ORDER BY g.generated_at DESC LIMIT 2",
|
||||
arrayOf(consumerPeerId),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun hasActionableRequest(directory: File): Boolean {
|
||||
private fun request(directory: File): JSONObject? {
|
||||
val path = File(directory, "request-v1.json")
|
||||
if (!path.isFile || path.length() !in 1..64 * 1024) return false
|
||||
val request = runCatching { JSONObject(path.readText()) }.getOrNull() ?: return false
|
||||
if (!path.isFile || path.length() !in 1..64 * 1024) return null
|
||||
val request = runCatching { JSONObject(path.readText()) }.getOrNull() ?: return null
|
||||
val runId = request.optString("run_id")
|
||||
val desktopPeerId = request.optString("desktop_peer_id")
|
||||
if (
|
||||
|
|
@ -68,16 +131,64 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
|
|||
!runId.matches(Regex("^sy_[0-9a-f-]{36}$")) ||
|
||||
!desktopPeerId.matches(Regex("^peer_[0-9a-f-]{36}$")) ||
|
||||
request.optString("android_peer_id") != service.peerId()
|
||||
) return false
|
||||
if (resumableGeneration(runId, desktopPeerId) != null) return true
|
||||
return !repository.inSyncGenerationTransaction { db ->
|
||||
) return null
|
||||
return request
|
||||
}
|
||||
|
||||
private fun hasGeneration(runId: String): Boolean =
|
||||
repository.inSyncGenerationTransaction { db ->
|
||||
db.rawQuery(
|
||||
"SELECT 1 FROM sync_generations WHERE run_id=? LIMIT 1",
|
||||
arrayOf(runId),
|
||||
).use { it.moveToFirst() }
|
||||
}
|
||||
|
||||
internal fun remoteEvidence(directory: File, runId: String): String {
|
||||
val evidence =
|
||||
listOf(
|
||||
Triple("desktop-consumption-ack-v1.json", "trainlog-sync-ack", 1),
|
||||
Triple("desktop-generation-v1.json", "trainlog-sync-generation-reference", 1),
|
||||
)
|
||||
.mapNotNull { (name, format, version) ->
|
||||
val path = File(directory, name)
|
||||
if (!path.isFile || path.length() !in 1..64 * 1024) return@mapNotNull null
|
||||
val raw = runCatching { path.readBytes() }.getOrNull() ?: return@mapNotNull null
|
||||
val value = runCatching { JSONObject(raw.toString(Charsets.UTF_8)) }.getOrNull()
|
||||
?: return@mapNotNull null
|
||||
if (
|
||||
value.optString("format") != format ||
|
||||
value.optInt("version", -1) != version ||
|
||||
value.optString("run_id") != runId
|
||||
) return@mapNotNull null
|
||||
"$name:${java.security.MessageDigest.getInstance("SHA-256").digest(raw)
|
||||
.joinToString("") { "%02x".format(it) }}"
|
||||
}
|
||||
return evidence.sorted().joinToString("|")
|
||||
}
|
||||
|
||||
internal fun classifyBackgroundRequest(
|
||||
directory: File,
|
||||
terminal: BackgroundTerminalRun?,
|
||||
): BackgroundRequestActionability {
|
||||
val request = request(directory) ?: return BackgroundRequestActionability.NOT_ACTIONABLE
|
||||
val runId = request.getString("run_id")
|
||||
val desktopPeerId = request.getString("desktop_peer_id")
|
||||
val resumable = resumableGeneration(runId, desktopPeerId) != null
|
||||
if (terminal?.runId == runId) {
|
||||
val currentEvidence = remoteEvidence(directory, runId)
|
||||
return if (currentEvidence.isNotEmpty() && currentEvidence != terminal.remoteEvidence)
|
||||
BackgroundRequestActionability.NEW_REMOTE_EVIDENCE
|
||||
else if (resumable) BackgroundRequestActionability.RESUMABLE_BUT_STALE
|
||||
else BackgroundRequestActionability.NOT_ACTIONABLE
|
||||
}
|
||||
if (!hasGeneration(runId)) return BackgroundRequestActionability.NEW_REQUEST
|
||||
if (resumable) return BackgroundRequestActionability.ACTIVE_HANDOFF
|
||||
return BackgroundRequestActionability.NOT_ACTIONABLE
|
||||
}
|
||||
|
||||
internal fun currentRequestRunId(directory: File): String? =
|
||||
request(directory)?.optString("run_id")
|
||||
|
||||
internal fun hasReplayableAcknowledgement(directory: File): Boolean {
|
||||
val request =
|
||||
runCatching { JSONObject(File(directory, "request-v1.json").readText()) }.getOrNull()
|
||||
|
|
@ -114,6 +225,45 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
|
|||
if (actual != expected) throw SyncGenerationException("invalid $label fields")
|
||||
}
|
||||
|
||||
private fun requestSupports(request: JSONObject, capability: String): Boolean {
|
||||
val capabilities = request.optJSONArray("capabilities") ?: return false
|
||||
for (index in 0 until capabilities.length()) {
|
||||
if (capabilities.optString(index) == capability) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun recoveryAcknowledgements(
|
||||
desktopPeerId: String,
|
||||
androidPeerId: String,
|
||||
): JSONArray =
|
||||
repository.inSyncGenerationTransaction { db ->
|
||||
val acknowledgements = JSONArray()
|
||||
db.rawQuery(
|
||||
"SELECT ack_json FROM sync_consumed_generations WHERE producer_peer_id=? " +
|
||||
"AND consumer_peer_id=? AND result IN('consumed','rejected') " +
|
||||
"ORDER BY consumed_at DESC,generation_id DESC LIMIT 32",
|
||||
arrayOf(desktopPeerId, androidPeerId),
|
||||
)
|
||||
.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val acknowledgement = JSONObject(cursor.getString(0))
|
||||
/* ACKs written before cross-runtime rejection diagnostics
|
||||
* were normalized can hash '/' differently in Android
|
||||
* and Python JSON canonicalizers. They remain immutable
|
||||
* evidence, but must not block recovery of newer,
|
||||
* compatible ACKs. Consumed ACKs are unaffected. */
|
||||
if (
|
||||
acknowledgement.optString("result") != "rejected" ||
|
||||
!acknowledgement.optString("diagnostic").contains("/")
|
||||
) {
|
||||
acknowledgements.put(acknowledgement)
|
||||
}
|
||||
}
|
||||
}
|
||||
acknowledgements
|
||||
}
|
||||
|
||||
private fun reconcileArchiveAcknowledgements(
|
||||
raw: ByteArray,
|
||||
runId: String,
|
||||
|
|
@ -217,6 +367,7 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
|
|||
"mobile-history-v4",
|
||||
"execution-draft-v1",
|
||||
"generation-archive-v1",
|
||||
ACK_RECOVERY_CAPABILITY,
|
||||
"session-preparations-v2",
|
||||
"programs-v1",
|
||||
)
|
||||
|
|
@ -229,9 +380,12 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
|
|||
* an identical canonical descriptor is already a complete durable
|
||||
* publication. INVARIANT: changed or unreadable bytes still use the
|
||||
* crash-safe replacement path above. */
|
||||
if (runCatching { path.isFile && path.readText() == descriptor }.getOrDefault(false))
|
||||
if (runCatching { path.isFile && path.readText() == descriptor }.getOrDefault(false)) {
|
||||
visibility.confirm(listOf(path))
|
||||
return peer
|
||||
}
|
||||
publish(path, descriptor)
|
||||
visibility.confirm(listOf(path))
|
||||
return peer
|
||||
}
|
||||
|
||||
|
|
@ -250,8 +404,10 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
|
|||
deadline: Long,
|
||||
predicate: (JSONObject) -> Boolean,
|
||||
pump: (() -> Unit)? = null,
|
||||
lease: SyncConversationArbiter.Lease,
|
||||
): ByteArray {
|
||||
while (true) {
|
||||
lease.throwIfYieldRequested()
|
||||
if (path.isFile && path.length() <= 64 * 1024) {
|
||||
try {
|
||||
val raw = path.readBytes()
|
||||
|
|
@ -272,36 +428,61 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
|
|||
directory: File,
|
||||
timeout: Duration = Duration.ofMinutes(5),
|
||||
afterPeerPublication: (() -> Unit)? = null,
|
||||
afterRecoveryAcknowledgements: (() -> Unit)? = null,
|
||||
afterPublication: (() -> Unit)? = null,
|
||||
pollTransport: (() -> Unit)? = null,
|
||||
afterAcknowledgement: (() -> Unit)? = null,
|
||||
intent: SyncConversationIntent = SyncConversationIntent.RESUME_BACKGROUND,
|
||||
explicitClaim: SyncConversationArbiter.ExplicitClaim? = null,
|
||||
): ForegroundGenerationResult {
|
||||
if (!conversationLock.tryLock()) {
|
||||
return ForegroundGenerationResult.Error("Synchronization already in progress.")
|
||||
val claim =
|
||||
if (intent == SyncConversationIntent.NEW_EXPLICIT)
|
||||
explicitClaim ?: arbiter.announceExplicit()
|
||||
else null
|
||||
val lease = try {
|
||||
claim?.acquire(timeout) ?: if (claim == null) arbiter.tryAcquireBackground() else null
|
||||
} catch (_: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
claim?.close()
|
||||
return ForegroundGenerationResult.Cancelled(null)
|
||||
}
|
||||
if (lease == null) {
|
||||
claim?.close()
|
||||
return ForegroundGenerationResult.Busy
|
||||
}
|
||||
var currentRunId: String? = null
|
||||
return try {
|
||||
val peer = publishPeer(directory)
|
||||
phase(null, "peer_published", "android-peer-v1.json")
|
||||
val baselineRunId = request(directory)?.optString("run_id")
|
||||
/* WHY: trainlog-syncd is intentionally driven by the established
|
||||
* Android request artifact, not by polling generation internals.
|
||||
* CONTRACT: each explicit foreground attempt publishes the peer
|
||||
* identity before its caller emits one fresh request signal.
|
||||
* INVARIANT: a failed attempt's request, run, generations, and
|
||||
* ACK evidence remain immutable; retry creates new correlation. */
|
||||
afterPeerPublication?.invoke()
|
||||
afterPeerPublication?.let {
|
||||
it.invoke()
|
||||
phase(
|
||||
null,
|
||||
"full_generation_trigger_published",
|
||||
FULL_GENERATION_REQUEST_NAME,
|
||||
)
|
||||
}
|
||||
val deadline = System.nanoTime() + timeout.toNanos()
|
||||
val requestRaw =
|
||||
awaitCorrelated(File(directory, "request-v1.json"), deadline, { candidate ->
|
||||
val runId = candidate.optString("run_id")
|
||||
runId.startsWith("sy_") &&
|
||||
(resumableGeneration(runId) != null ||
|
||||
!repository.inSyncGenerationTransaction { db ->
|
||||
db.rawQuery(
|
||||
"SELECT 1 FROM sync_generations WHERE run_id=? LIMIT 1",
|
||||
arrayOf(runId),
|
||||
).use { it.moveToFirst() }
|
||||
})
|
||||
}, pollTransport)
|
||||
runId.startsWith("sy_") && when (intent) {
|
||||
SyncConversationIntent.NEW_EXPLICIT -> runId != baselineRunId
|
||||
SyncConversationIntent.RESUME_BACKGROUND ->
|
||||
resumableGeneration(runId) != null || !hasGeneration(runId)
|
||||
}
|
||||
}, pollTransport, lease)
|
||||
val request = JSONObject(requestRaw.toString(Charsets.UTF_8))
|
||||
val runId = request.getString("run_id")
|
||||
currentRunId = runId
|
||||
phase(runId, "generation_request_observed", "request-v1.json")
|
||||
if (request.getString("android_peer_id") != peer)
|
||||
throw SyncGenerationException("request targets another Android peer")
|
||||
val archiveAckRaw =
|
||||
|
|
@ -313,6 +494,7 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
|
|||
it.optString("android_peer_id") == peer
|
||||
},
|
||||
pollTransport,
|
||||
lease,
|
||||
)
|
||||
reconcileArchiveAcknowledgements(
|
||||
archiveAckRaw,
|
||||
|
|
@ -320,6 +502,45 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
|
|||
peer,
|
||||
request.getString("desktop_peer_id"),
|
||||
)
|
||||
phase(runId, "archive_ack_observed", "desktop-archive-acknowledgements-v1.json")
|
||||
if (requestSupports(request, ACK_RECOVERY_CAPABILITY)) {
|
||||
/* WHY: Android may commit consumption after the desktop
|
||||
* producer has already published its immutable generation, then
|
||||
* transport can fail before the ACK reaches that producer.
|
||||
* CONTRACT: return only exact durable consumer ACK evidence,
|
||||
* bounded to the newest 32 rows, before either side captures a
|
||||
* new generation. INVARIANT: recovery never fabricates lineage
|
||||
* and never rewrites Android consumption history. */
|
||||
val recoveryPath =
|
||||
File(directory, "android-archive-acknowledgements-v1.json")
|
||||
val recoveryEnvelope =
|
||||
JSONObject()
|
||||
.put("format", "trainlog-sync-archive-acknowledgements")
|
||||
.put("version", 1)
|
||||
.put("run_id", runId)
|
||||
.put("android_peer_id", peer)
|
||||
.put("desktop_peer_id", request.getString("desktop_peer_id"))
|
||||
.put(
|
||||
"acknowledgements",
|
||||
recoveryAcknowledgements(
|
||||
request.getString("desktop_peer_id"),
|
||||
peer,
|
||||
),
|
||||
)
|
||||
.toString()
|
||||
if (recoveryEnvelope.toByteArray(Charsets.UTF_8).size > MAX_COORDINATION_BYTES)
|
||||
throw SyncGenerationException(
|
||||
"Android archive acknowledgement envelope exceeds bound"
|
||||
)
|
||||
publish(recoveryPath, recoveryEnvelope)
|
||||
visibility.confirm(listOf(recoveryPath))
|
||||
afterRecoveryAcknowledgements?.invoke()
|
||||
phase(
|
||||
runId,
|
||||
"android_archive_ack_published",
|
||||
recoveryPath.name,
|
||||
)
|
||||
}
|
||||
/* WHY: transport can fail after immutable generation publication.
|
||||
* CONTRACT: an explicit retry for the same run republishes that
|
||||
* exact generation and resumes ACK handling; it never recaptures
|
||||
|
|
@ -327,6 +548,8 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
|
|||
* INVARIANT: a completed inbound ledger makes the run ineligible;
|
||||
* an acknowledged outbound generation remains resumable only to
|
||||
* finish its missing inbound half. */
|
||||
phase(runId, "generation_capture_started")
|
||||
lease.throwIfYieldRequested()
|
||||
val captured =
|
||||
resumableGeneration(
|
||||
request.getString("run_id"),
|
||||
|
|
@ -336,9 +559,22 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
|
|||
request.getString("desktop_peer_id"),
|
||||
request.getString("run_id"),
|
||||
)
|
||||
lease.throwIfYieldRequested()
|
||||
phase(runId, "generation_captured", generationId = captured.generationId)
|
||||
val published = service.publish(captured, File(directory, "android-objects"))
|
||||
visibility.confirm(
|
||||
published.walkTopDown().filter(File::isFile).sortedBy { it.name == "manifest.json" }.toList()
|
||||
)
|
||||
lease.throwIfYieldRequested()
|
||||
phase(
|
||||
runId,
|
||||
"generation_objects_published",
|
||||
published.relativeTo(directory).path,
|
||||
captured.generationId,
|
||||
)
|
||||
val generationReference = File(directory, "android-generation-v1.json")
|
||||
publish(
|
||||
File(directory, "android-generation-v1.json"),
|
||||
generationReference,
|
||||
JSONObject()
|
||||
.put("format", "trainlog-sync-generation-reference")
|
||||
.put("version", 1)
|
||||
|
|
@ -348,21 +584,39 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
|
|||
.put("relative_path", published.relativeTo(directory).path)
|
||||
.toString(),
|
||||
)
|
||||
visibility.confirm(listOf(generationReference))
|
||||
phase(
|
||||
runId,
|
||||
"generation_reference_published",
|
||||
generationReference.name,
|
||||
captured.generationId,
|
||||
)
|
||||
afterPublication?.invoke()
|
||||
lease.throwIfYieldRequested()
|
||||
// CONTRACT: durable objects from earlier conversations remain in
|
||||
// the exchange directory. Only this run's exact generation ACK
|
||||
// can advance its lineage; stale bytes are retained and ignored.
|
||||
// the exchange directory. A cross-run retry preserves the
|
||||
// immutable manifest's original run_id, so correlate its ACK by
|
||||
// generation identity and manifest digest. acceptAcknowledgement
|
||||
// then revalidates the complete durable ACK against SQLite.
|
||||
// INVARIANT: unrelated or same-ID/different-bytes ACKs remain
|
||||
// ignored; an exact replay is deliberately accepted.
|
||||
val desktopAck =
|
||||
awaitCorrelated(File(directory, "desktop-consumption-ack-v1.json"), deadline, {
|
||||
it.optString("run_id") == captured.runId &&
|
||||
it.optString("generation_id") == captured.generationId
|
||||
}, pollTransport)
|
||||
acknowledgementMatchesCapturedGeneration(it, captured)
|
||||
}, pollTransport, lease)
|
||||
service.acceptAcknowledgement(desktopAck)
|
||||
phase(runId, "desktop_ack_observed", "desktop-consumption-ack-v1.json", captured.generationId)
|
||||
val referenceRaw =
|
||||
awaitCorrelated(File(directory, "desktop-generation-v1.json"), deadline, {
|
||||
it.optString("run_id") == captured.runId
|
||||
}, pollTransport)
|
||||
}, pollTransport, lease)
|
||||
val reference = JSONObject(referenceRaw.toString(Charsets.UTF_8))
|
||||
phase(
|
||||
runId,
|
||||
"desktop_generation_observed",
|
||||
"desktop-generation-v1.json",
|
||||
reference.optString("generation_id"),
|
||||
)
|
||||
/* WHY: non-atomic transports can expose a newly downloaded
|
||||
* reference locally before the corresponding immutable objects
|
||||
* have reached this process. CONTRACT: correlation validates the
|
||||
|
|
@ -373,10 +627,37 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
|
|||
pollTransport?.invoke()
|
||||
val acknowledgement =
|
||||
service.consume(File(directory, reference.getString("relative_path")))
|
||||
publish(File(directory, "android-consumption-ack-v1.json"), acknowledgement)
|
||||
phase(
|
||||
runId,
|
||||
"desktop_generation_consumed",
|
||||
generationId = reference.optString("generation_id"),
|
||||
)
|
||||
val acknowledgementPath = File(directory, "android-consumption-ack-v1.json")
|
||||
publish(acknowledgementPath, acknowledgement)
|
||||
visibility.confirm(listOf(acknowledgementPath))
|
||||
phase(
|
||||
runId,
|
||||
"android_ack_published",
|
||||
acknowledgementPath.name,
|
||||
reference.optString("generation_id"),
|
||||
)
|
||||
afterAcknowledgement?.invoke()
|
||||
phase(runId, "completed", result = "completed")
|
||||
ForegroundGenerationResult.Completed(captured.runId)
|
||||
} catch (_: SyncConversationYieldedException) {
|
||||
phase(currentRunId, "superseded", result = "foreground_priority")
|
||||
ForegroundGenerationResult.Superseded(currentRunId)
|
||||
} catch (_: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
phase(currentRunId, "cancelled", result = "interrupted")
|
||||
ForegroundGenerationResult.Cancelled(currentRunId)
|
||||
} catch (error: Exception) {
|
||||
Log.e(
|
||||
"TrainlogSyncGeneration",
|
||||
"run_id=${currentRunId ?: "pending"} phase=error " +
|
||||
"type=${error.javaClass.name} message=${error.message}",
|
||||
error,
|
||||
)
|
||||
if (error.message == "generation retention capacity exhausted") {
|
||||
try {
|
||||
val request = JSONObject(File(directory, "request-v1.json").readText())
|
||||
|
|
@ -391,14 +672,26 @@ internal class SyncGenerationCoordinator(private val repository: TrainlogReposit
|
|||
.put("action", "Archive acknowledged generations, then retry explicitly.")
|
||||
.toString(),
|
||||
)
|
||||
visibility.confirm(listOf(File(directory, "android-generation-error-v1.json")))
|
||||
} catch (_: Exception) {
|
||||
// Preserve the original local failure when error reporting
|
||||
// cannot itself be durably published.
|
||||
}
|
||||
}
|
||||
ForegroundGenerationResult.Error(error.message ?: "generation synchronization failed")
|
||||
phase(
|
||||
runCatching {
|
||||
JSONObject(File(directory, "request-v1.json").readText()).optString("run_id")
|
||||
}.getOrNull(),
|
||||
"error",
|
||||
result = error.javaClass.simpleName,
|
||||
)
|
||||
ForegroundGenerationResult.Failed(
|
||||
error.message ?: "generation synchronization failed",
|
||||
currentRunId,
|
||||
)
|
||||
} finally {
|
||||
conversationLock.unlock()
|
||||
lease.close()
|
||||
claim?.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,23 @@ import java.util.UUID
|
|||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
internal const val FULL_GENERATION_REQUEST_NAME =
|
||||
"trainlog-sync-full-generation-request-v1.json"
|
||||
internal const val LEGACY_SYNC_REQUEST_NAME = "trainlog-sync-request-v1.json"
|
||||
|
||||
internal data class SyncRequestIntent(
|
||||
val requestId: String,
|
||||
val requestedAt: String,
|
||||
) {
|
||||
fun payload(): String =
|
||||
JSONObject()
|
||||
.put("format", "trainlog-sync-request")
|
||||
.put("version", 1)
|
||||
.put("request_id", requestId)
|
||||
.put("requested_at", requestedAt)
|
||||
.toString()
|
||||
}
|
||||
|
||||
sealed interface SyncRequestResult {
|
||||
/** The full-generation coordinator completed both durable peer ACKs. */
|
||||
data class Completed(
|
||||
|
|
@ -33,13 +50,16 @@ sealed interface SyncRequestResult {
|
|||
|
||||
class SyncRequestOutbox private constructor(
|
||||
private val publisher: DirectExchangePublisher,
|
||||
private val visibility: MtpPublicationVisibility,
|
||||
) {
|
||||
constructor(context: Context) : this(
|
||||
DirectExchangePublisher { directExchangeDirectory(context) },
|
||||
AndroidMtpPublicationVisibility(context),
|
||||
)
|
||||
|
||||
internal constructor(directoryProvider: () -> DirectExchangeDirectoryAccess?) : this(
|
||||
DirectExchangePublisher(directoryProvider),
|
||||
ImmediateMtpPublicationVisibility,
|
||||
)
|
||||
|
||||
suspend fun requestSync(): SyncRequestResult = withContext(Dispatchers.IO) {
|
||||
|
|
@ -49,6 +69,16 @@ class SyncRequestOutbox private constructor(
|
|||
}
|
||||
}
|
||||
|
||||
internal fun requestFullGeneration(): SyncRequestResult {
|
||||
return when (val opened = publisher.snapshot()) {
|
||||
is DirectExchangeSnapshotResult.Ready -> {
|
||||
val intent = newIntent()
|
||||
publishFullGeneration(opened.snapshot, intent)
|
||||
}
|
||||
is DirectExchangeSnapshotResult.Error -> SyncRequestResult.Error(opened.message)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun requestSync(snapshot: DirectExchangeSnapshot): SyncRequestResult {
|
||||
if (
|
||||
Build.VERSION.SDK_INT <
|
||||
|
|
@ -57,37 +87,42 @@ class SyncRequestOutbox private constructor(
|
|||
return SyncRequestResult.Unsupported
|
||||
}
|
||||
|
||||
val requestId =
|
||||
"sr_" +
|
||||
UUID.randomUUID()
|
||||
.toString()
|
||||
val intent = newIntent()
|
||||
return publishLegacy(snapshot, intent)
|
||||
}
|
||||
|
||||
val payload =
|
||||
JSONObject()
|
||||
.put(
|
||||
"format",
|
||||
"trainlog-sync-request",
|
||||
)
|
||||
.put(
|
||||
"version",
|
||||
1,
|
||||
)
|
||||
.put(
|
||||
"request_id",
|
||||
requestId,
|
||||
)
|
||||
.put(
|
||||
"requested_at",
|
||||
OffsetDateTime.now()
|
||||
.toString(),
|
||||
)
|
||||
.toString()
|
||||
internal fun newIntent(): SyncRequestIntent =
|
||||
SyncRequestIntent(
|
||||
requestId = "sr_${UUID.randomUUID()}",
|
||||
requestedAt = OffsetDateTime.now().toString(),
|
||||
)
|
||||
|
||||
val displayName =
|
||||
"trainlog-sync-request-v1.json"
|
||||
val error = snapshot.writeJson(displayName, payload)
|
||||
internal fun publishFullGeneration(
|
||||
snapshot: DirectExchangeSnapshot,
|
||||
intent: SyncRequestIntent,
|
||||
): SyncRequestResult = publish(snapshot, FULL_GENERATION_REQUEST_NAME, intent)
|
||||
|
||||
internal fun publishLegacy(
|
||||
snapshot: DirectExchangeSnapshot,
|
||||
intent: SyncRequestIntent,
|
||||
): SyncRequestResult = publish(snapshot, LEGACY_SYNC_REQUEST_NAME, intent)
|
||||
|
||||
private fun publish(
|
||||
snapshot: DirectExchangeSnapshot,
|
||||
displayName: String,
|
||||
intent: SyncRequestIntent,
|
||||
): SyncRequestResult {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
return SyncRequestResult.Unsupported
|
||||
}
|
||||
val error = snapshot.writeJson(displayName, intent.payload())
|
||||
return if (error == null) {
|
||||
SyncRequestResult.Requested(requestId)
|
||||
try {
|
||||
visibility.confirm(listOf(java.io.File(canonicalExchangeDirectory(), displayName)))
|
||||
SyncRequestResult.Requested(intent.requestId)
|
||||
} catch (error: Exception) {
|
||||
SyncRequestResult.Error(error.message ?: "Publication MTP impossible.")
|
||||
}
|
||||
} else {
|
||||
SyncRequestResult.Error(error)
|
||||
}
|
||||
|
|
|
|||
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(),
|
||||
)
|
||||
|
|
@ -19,6 +19,7 @@ import androidx.compose.material3.IconButton
|
|||
import androidx.compose.material3.ModalDrawerSheet
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.NavigationDrawerItem
|
||||
import androidx.compose.material3.NavigationDrawerItemDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
|
|
@ -32,6 +33,7 @@ import androidx.compose.ui.res.painterResource
|
|||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import com.labfytools.trainlog.R
|
||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -41,7 +43,7 @@ private val drawerIcons = mapOf(
|
|||
AppSection.SESSIONS to R.drawable.ic_sessions,
|
||||
AppSection.EXERCISES to R.drawable.ic_exercises,
|
||||
AppSection.EQUIPMENT to R.drawable.ic_equipment,
|
||||
AppSection.STATISTICS to R.drawable.ic_statistics,
|
||||
AppSection.SLEEP to R.drawable.ic_statistics,
|
||||
AppSection.SYNC to R.drawable.ic_sync,
|
||||
AppSection.SETTINGS to R.drawable.ic_settings,
|
||||
)
|
||||
|
|
@ -59,21 +61,24 @@ fun AndroidAppShell(
|
|||
fun sectionLabel(section: AppSection): String = strings.getString(when (section) {
|
||||
AppSection.HOME -> R.string.nav_home; AppSection.SESSIONS -> R.string.nav_sessions
|
||||
AppSection.EXERCISES -> R.string.nav_exercises; AppSection.EQUIPMENT -> R.string.nav_equipment
|
||||
AppSection.STATISTICS -> R.string.nav_statistics; AppSection.SYNC -> R.string.nav_sync
|
||||
AppSection.SETTINGS -> R.string.nav_settings
|
||||
AppSection.SLEEP -> R.string.nav_sleep
|
||||
AppSection.SYNC -> R.string.nav_sync; AppSection.SETTINGS -> R.string.nav_settings
|
||||
})
|
||||
fun title(): String = strings.getString(when (route) {
|
||||
AppRoute.Home -> R.string.nav_home; AppRoute.Sessions -> R.string.nav_sessions
|
||||
AppRoute.SessionEditor -> R.string.route_session_editor; AppRoute.SessionGenerator -> R.string.route_session_generator
|
||||
AppRoute.AiSessionDrafts -> R.string.route_ai_drafts; AppRoute.CompletedSessions -> R.string.route_completed_sessions
|
||||
AppRoute.Programs -> R.string.route_programs; is AppRoute.ProgramDetail -> R.string.route_program_detail
|
||||
AppRoute.CardioCalibration -> R.string.route_cardio_calibration
|
||||
is AppRoute.SessionDetail -> R.string.route_session_detail; is AppRoute.SessionCorrection -> R.string.route_session_correction
|
||||
AppRoute.Exercises -> R.string.nav_exercises; is AppRoute.ExerciseDetail -> R.string.route_exercise_detail
|
||||
is AppRoute.ExerciseEdit -> R.string.route_exercise_edit; is AppRoute.ExerciseCreate -> R.string.route_exercise_create
|
||||
AppRoute.Equipment -> R.string.nav_equipment; is AppRoute.EquipmentDetail -> R.string.route_equipment_detail
|
||||
is AppRoute.EquipmentCreate -> R.string.route_equipment_create; AppRoute.Statistics -> R.string.nav_statistics
|
||||
is AppRoute.EquipmentCreate -> R.string.route_equipment_create
|
||||
AppRoute.BodyMeasurements -> R.string.route_body_measurements; AppRoute.LatestMaxima -> R.string.route_latest_maxima
|
||||
AppRoute.SleepDiary -> R.string.nav_sleep
|
||||
AppRoute.Sync -> R.string.nav_sync; AppRoute.Settings -> R.string.nav_settings
|
||||
AppRoute.HeartRateDiagnostics -> R.string.route_heart_rate_diagnostics
|
||||
})
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
|
@ -107,7 +112,16 @@ fun AndroidAppShell(
|
|||
onOpenSection(section)
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = NavigationDrawerItemDefaults.colors(
|
||||
selectedContainerColor = colors.surfaceAlt,
|
||||
selectedIconColor = colors.accent,
|
||||
selectedTextColor = colors.text,
|
||||
unselectedContainerColor = colors.mantle,
|
||||
unselectedIconColor = colors.muted,
|
||||
unselectedTextColor = colors.muted,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -133,6 +147,7 @@ fun AndroidAppShell(
|
|||
}
|
||||
},
|
||||
actions = {
|
||||
HeartRateIndicator()
|
||||
if (!root) {
|
||||
IconButton(
|
||||
onClick = { scope.launch { drawerState.open() } },
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import androidx.lifecycle.ViewModel
|
|||
|
||||
enum class AppSection {
|
||||
HOME, SESSIONS, EXERCISES,
|
||||
EQUIPMENT, STATISTICS, SYNC, SETTINGS,
|
||||
EQUIPMENT, SLEEP, SYNC, SETTINGS,
|
||||
}
|
||||
|
||||
sealed interface AppRoute {
|
||||
|
|
@ -24,6 +24,7 @@ sealed interface AppRoute {
|
|||
data object AiSessionDrafts : AppRoute { override val section = AppSection.SESSIONS }
|
||||
data object CompletedSessions : AppRoute { override val section = AppSection.SESSIONS }
|
||||
data object Programs : AppRoute { override val section = AppSection.SESSIONS }
|
||||
data object CardioCalibration : AppRoute { override val section = AppSection.SESSIONS }
|
||||
data class ProgramDetail(val programId: String) : AppRoute { override val section = AppSection.SESSIONS }
|
||||
data class SessionDetail(val sessionId: String) : AppRoute { override val section = AppSection.SESSIONS }
|
||||
data class SessionCorrection(val sessionId: String) : AppRoute { override val section = AppSection.SESSIONS }
|
||||
|
|
@ -34,11 +35,12 @@ sealed interface AppRoute {
|
|||
data object Equipment : AppRoute { override val section = AppSection.EQUIPMENT }
|
||||
data class EquipmentDetail(val equipmentId: String) : AppRoute { override val section = AppSection.EQUIPMENT }
|
||||
data class EquipmentCreate(val caller: AppRoute) : AppRoute { override val section = caller.section }
|
||||
data object Statistics : AppRoute { override val section = AppSection.STATISTICS }
|
||||
data object BodyMeasurements : AppRoute { override val section = AppSection.STATISTICS }
|
||||
data object LatestMaxima : AppRoute { override val section = AppSection.STATISTICS }
|
||||
data object BodyMeasurements : AppRoute { override val section = AppSection.HOME }
|
||||
data object SleepDiary : AppRoute { override val section = AppSection.SLEEP }
|
||||
data object LatestMaxima : AppRoute { override val section = AppSection.EXERCISES }
|
||||
data object Sync : AppRoute { override val section = AppSection.SYNC }
|
||||
data object Settings : AppRoute { override val section = AppSection.SETTINGS }
|
||||
data object HeartRateDiagnostics : AppRoute { override val section = AppSection.SETTINGS }
|
||||
}
|
||||
|
||||
fun AppSection.rootRoute(): AppRoute = when (this) {
|
||||
|
|
@ -46,7 +48,7 @@ fun AppSection.rootRoute(): AppRoute = when (this) {
|
|||
AppSection.SESSIONS -> AppRoute.Sessions
|
||||
AppSection.EXERCISES -> AppRoute.Exercises
|
||||
AppSection.EQUIPMENT -> AppRoute.Equipment
|
||||
AppSection.STATISTICS -> AppRoute.Statistics
|
||||
AppSection.SLEEP -> AppRoute.SleepDiary
|
||||
AppSection.SYNC -> AppRoute.Sync
|
||||
AppSection.SETTINGS -> AppRoute.Settings
|
||||
}
|
||||
|
|
@ -171,5 +173,11 @@ class TrainlogAppState : ViewModel() {
|
|||
val equipment = EquipmentScreenState()
|
||||
val exercise = ExerciseScreenState()
|
||||
val body = BodyScreenState()
|
||||
/* WHY: the durable form intentionally does not serialize transient edit
|
||||
* position, but a catalogue detour must still return to the same row.
|
||||
* CONTRACT: this stable occurrence ID spans only that caller-aware detour;
|
||||
* all actual form fields remain repository-owned and durable.
|
||||
* INVARIANT: null means a new occurrence, never an inferred row. */
|
||||
var pendingSessionEquipmentEntryId: String? = null
|
||||
val navigationController = AppNavigationController(navigation, generator, equipment, exercise, body)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,13 @@ package com.labfytools.trainlog.ui
|
|||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
|
|
@ -89,148 +96,74 @@ fun BodyScreen(
|
|||
TrainlogFrame(
|
||||
title = strings.getString(R.string.general)
|
||||
) {
|
||||
BodyMetricField(
|
||||
label = strings.getString(R.string.weight),
|
||||
unit = "kg",
|
||||
value = bodyWeight,
|
||||
onValueChange = {
|
||||
bodyWeight = it
|
||||
message = null
|
||||
},
|
||||
)
|
||||
|
||||
BodyMetricField(
|
||||
label = strings.getString(R.string.neck),
|
||||
unit = "cm",
|
||||
value = neck,
|
||||
onValueChange = {
|
||||
neck = it
|
||||
message = null
|
||||
},
|
||||
)
|
||||
|
||||
BodyMetricField(
|
||||
label = strings.getString(R.string.shoulders),
|
||||
unit = "cm",
|
||||
value = shoulders,
|
||||
onValueChange = {
|
||||
shoulders = it
|
||||
message = null
|
||||
},
|
||||
)
|
||||
|
||||
BodyMetricField(
|
||||
label = strings.getString(R.string.chest),
|
||||
unit = "cm",
|
||||
value = chest,
|
||||
onValueChange = {
|
||||
chest = it
|
||||
message = null
|
||||
},
|
||||
)
|
||||
|
||||
BodyMetricField(
|
||||
label = strings.getString(R.string.waist),
|
||||
unit = "cm",
|
||||
value = waist,
|
||||
onValueChange = {
|
||||
waist = it
|
||||
message = null
|
||||
},
|
||||
)
|
||||
|
||||
BodyMetricField(
|
||||
label = strings.getString(R.string.hips),
|
||||
unit = "cm",
|
||||
value = hips,
|
||||
onValueChange = {
|
||||
hips = it
|
||||
message = null
|
||||
},
|
||||
BodyMetricGrid(
|
||||
listOf(
|
||||
BodyMetricItem(strings.getString(R.string.weight), "kg", bodyWeight) {
|
||||
bodyWeight = it
|
||||
message = null
|
||||
},
|
||||
BodyMetricItem(strings.getString(R.string.neck), "cm", neck) {
|
||||
neck = it
|
||||
message = null
|
||||
},
|
||||
BodyMetricItem(strings.getString(R.string.shoulders), "cm", shoulders) {
|
||||
shoulders = it
|
||||
message = null
|
||||
},
|
||||
BodyMetricItem(strings.getString(R.string.chest), "cm", chest) {
|
||||
chest = it
|
||||
message = null
|
||||
},
|
||||
BodyMetricItem(strings.getString(R.string.waist), "cm", waist) {
|
||||
waist = it
|
||||
message = null
|
||||
},
|
||||
BodyMetricItem(strings.getString(R.string.hips), "cm", hips) {
|
||||
hips = it
|
||||
message = null
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
TrainlogFrame(
|
||||
title = strings.getString(R.string.limbs)
|
||||
) {
|
||||
BodyMetricField(
|
||||
label = strings.getString(R.string.left_arm),
|
||||
unit = "cm",
|
||||
value = leftArm,
|
||||
onValueChange = {
|
||||
leftArm = it
|
||||
message = null
|
||||
},
|
||||
)
|
||||
|
||||
BodyMetricField(
|
||||
label = strings.getString(R.string.right_arm),
|
||||
unit = "cm",
|
||||
value = rightArm,
|
||||
onValueChange = {
|
||||
rightArm = it
|
||||
message = null
|
||||
},
|
||||
)
|
||||
|
||||
BodyMetricField(
|
||||
label = strings.getString(R.string.left_forearm),
|
||||
unit = "cm",
|
||||
value = leftForearm,
|
||||
onValueChange = {
|
||||
leftForearm = it
|
||||
message = null
|
||||
},
|
||||
)
|
||||
|
||||
BodyMetricField(
|
||||
label = strings.getString(R.string.right_forearm),
|
||||
unit = "cm",
|
||||
value = rightForearm,
|
||||
onValueChange = {
|
||||
rightForearm = it
|
||||
message = null
|
||||
},
|
||||
)
|
||||
|
||||
BodyMetricField(
|
||||
label = strings.getString(R.string.left_thigh),
|
||||
unit = "cm",
|
||||
value = leftThigh,
|
||||
onValueChange = {
|
||||
leftThigh = it
|
||||
message = null
|
||||
},
|
||||
)
|
||||
|
||||
BodyMetricField(
|
||||
label = strings.getString(R.string.right_thigh),
|
||||
unit = "cm",
|
||||
value = rightThigh,
|
||||
onValueChange = {
|
||||
rightThigh = it
|
||||
message = null
|
||||
},
|
||||
)
|
||||
|
||||
BodyMetricField(
|
||||
label = strings.getString(R.string.left_calf),
|
||||
unit = "cm",
|
||||
value = leftCalf,
|
||||
onValueChange = {
|
||||
leftCalf = it
|
||||
message = null
|
||||
},
|
||||
)
|
||||
|
||||
BodyMetricField(
|
||||
label = strings.getString(R.string.right_calf),
|
||||
unit = "cm",
|
||||
value = rightCalf,
|
||||
onValueChange = {
|
||||
rightCalf = it
|
||||
message = null
|
||||
},
|
||||
BodyMetricGrid(
|
||||
listOf(
|
||||
BodyMetricItem(strings.getString(R.string.left_arm), "cm", leftArm) {
|
||||
leftArm = it
|
||||
message = null
|
||||
},
|
||||
BodyMetricItem(strings.getString(R.string.right_arm), "cm", rightArm) {
|
||||
rightArm = it
|
||||
message = null
|
||||
},
|
||||
BodyMetricItem(strings.getString(R.string.left_forearm), "cm", leftForearm) {
|
||||
leftForearm = it
|
||||
message = null
|
||||
},
|
||||
BodyMetricItem(strings.getString(R.string.right_forearm), "cm", rightForearm) {
|
||||
rightForearm = it
|
||||
message = null
|
||||
},
|
||||
BodyMetricItem(strings.getString(R.string.left_thigh), "cm", leftThigh) {
|
||||
leftThigh = it
|
||||
message = null
|
||||
},
|
||||
BodyMetricItem(strings.getString(R.string.right_thigh), "cm", rightThigh) {
|
||||
rightThigh = it
|
||||
message = null
|
||||
},
|
||||
BodyMetricItem(strings.getString(R.string.left_calf), "cm", leftCalf) {
|
||||
leftCalf = it
|
||||
message = null
|
||||
},
|
||||
BodyMetricItem(strings.getString(R.string.right_calf), "cm", rightCalf) {
|
||||
rightCalf = it
|
||||
message = null
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -426,12 +359,14 @@ private fun BodyMetricField(
|
|||
unit: String,
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
TrainlogInputField(
|
||||
label = "$label ($unit)",
|
||||
label = label,
|
||||
value = value,
|
||||
onValueChange =
|
||||
onValueChange,
|
||||
modifier = modifier,
|
||||
keyboardOptions =
|
||||
KeyboardOptions(
|
||||
keyboardType =
|
||||
|
|
@ -439,9 +374,51 @@ private fun BodyMetricField(
|
|||
imeAction =
|
||||
ImeAction.Next,
|
||||
),
|
||||
suffix = unit,
|
||||
compact = true,
|
||||
)
|
||||
}
|
||||
|
||||
internal data class BodyMetricItem(
|
||||
val label: String,
|
||||
val unit: String,
|
||||
val value: String,
|
||||
val onValueChange: (String) -> Unit,
|
||||
)
|
||||
|
||||
/** Two columns retain scanability; very narrow accessibility layouts stack. */
|
||||
internal fun bodyMetricColumnCount(availableWidthDp: Float, fontScale: Float): Int {
|
||||
val twoCompactFieldsDp = 2 * 140f + 10f
|
||||
return if (availableWidthDp >= twoCompactFieldsDp && fontScale < 1.5f) 2 else 1
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ColumnScope.BodyMetricGrid(items: List<BodyMetricItem>) {
|
||||
BoxWithConstraints(Modifier.fillMaxWidth()) {
|
||||
/* WHY: this 360 dp device has 312 dp after screen padding; the old
|
||||
* 330 dp threshold therefore stacked fields that need only 140 dp.
|
||||
* CONTRACT: two compact fields plus their 10 dp gutter are preferred
|
||||
* unless width or accessibility font scaling makes them unreadable. */
|
||||
val columns = bodyMetricColumnCount(maxWidth.value, LocalDensity.current.fontScale)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(0.dp)) {
|
||||
items.chunked(columns).forEach { rowItems ->
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
rowItems.forEach { item ->
|
||||
BodyMetricField(
|
||||
item.label,
|
||||
item.unit,
|
||||
item.value,
|
||||
item.onValueChange,
|
||||
Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
repeat(columns - rowItems.size) { androidx.compose.foundation.layout.Spacer(Modifier.weight(1f)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed interface MetricParse {
|
||||
data object Empty :
|
||||
MetricParse
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -8,9 +8,12 @@ package com.labfytools.trainlog.ui
|
|||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
|
|
@ -852,7 +855,11 @@ private fun TrainlogChoiceGroup(
|
|||
),
|
||||
)
|
||||
|
||||
content()
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -869,15 +876,15 @@ private fun TrainlogChoice(
|
|||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 2.dp)
|
||||
.widthIn(min = 132.dp)
|
||||
.heightIn(min = 48.dp)
|
||||
.background(
|
||||
if (selected) {
|
||||
colors.surfaceAlt
|
||||
} else {
|
||||
colors.surface
|
||||
}
|
||||
},
|
||||
androidx.compose.material3.MaterialTheme.shapes.medium,
|
||||
)
|
||||
.semantics { this.selected = selected }
|
||||
.clickable(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import com.labfytools.trainlog.model.SessionType
|
|||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||
import com.labfytools.trainlog.R
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@Composable
|
||||
|
|
@ -133,7 +134,8 @@ internal fun formatStartedAt(
|
|||
internal fun formatDate(value: String): String {
|
||||
val locale = presentationLocale()
|
||||
val pattern = uiString(R.string.date_pattern)
|
||||
return runCatching {
|
||||
OffsetDateTime.parse(value).format(DateTimeFormatter.ofPattern(pattern, locale))
|
||||
}.getOrElse { value.take(10) }
|
||||
val formatter = DateTimeFormatter.ofPattern(pattern, locale)
|
||||
return runCatching { LocalDate.parse(value.take(10)).format(formatter) }
|
||||
.recoverCatching { OffsetDateTime.parse(value).format(formatter) }
|
||||
.getOrElse { value.take(10) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import androidx.compose.foundation.layout.IntrinsicSize
|
|||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
|
|
@ -52,8 +51,6 @@ import com.labfytools.trainlog.data.BodyZoneHomeOverview
|
|||
import com.labfytools.trainlog.data.BodyZoneHomeState
|
||||
import com.labfytools.trainlog.data.BodyZoneHomeStatus
|
||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||
import com.labfytools.trainlog.model.LatestExerciseMax
|
||||
import com.labfytools.trainlog.model.SessionSummary
|
||||
import com.labfytools.trainlog.model.SessionType
|
||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||
import com.labfytools.trainlog.ui.theme.TrainlogColors
|
||||
|
|
@ -64,19 +61,14 @@ import com.labfytools.trainlog.R
|
|||
fun HomeScreen(
|
||||
activeDraft: ActiveSessionDraft?,
|
||||
draftError: String?,
|
||||
latestSession: SessionSummary?,
|
||||
latestMaximum: LatestExerciseMax?,
|
||||
bodyZoneOverview: BodyZoneHomeOverview,
|
||||
onSession: () -> Unit,
|
||||
onOpenExercises: (String) -> Unit,
|
||||
onOpenStatistics: () -> Unit,
|
||||
onBody: () -> Unit,
|
||||
onOpenLatestSession: (String) -> Unit,
|
||||
onSync: () -> Unit,
|
||||
) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
val strings = localizedContext()
|
||||
val locale = presentationLocale()
|
||||
TrainlogScreen(strings.getString(R.string.nav_home), scrollKey = "home") {
|
||||
draftError?.let { TrainlogInfo(it, colors.error) }
|
||||
TrainlogFrame(strings.getString(R.string.home_session)) {
|
||||
|
|
@ -87,17 +79,7 @@ fun HomeScreen(
|
|||
TrainlogPrimaryAction(strings.getString(R.string.new_manual_session), strings.getString(R.string.new_manual_description), onSession)
|
||||
}
|
||||
}
|
||||
BodyZoneHomeSection(bodyZoneOverview, onOpenExercises, onOpenStatistics)
|
||||
latestSession?.let { session ->
|
||||
TrainlogFrame(strings.getString(R.string.latest_session)) {
|
||||
TrainlogAction(formatStartedAt(session.startedAt), sessionTypeLabel(session.sessionType) + " · " +
|
||||
strings.resources.getQuantityString(R.plurals.exercise_count, session.exerciseCount, session.exerciseCount), { onOpenLatestSession(session.sessionId) })
|
||||
}
|
||||
}
|
||||
latestMaximum?.let { maximum ->
|
||||
val weight = "%.2f".format(locale, maximum.maxWeightKg).trimEnd('0').trimEnd(',', '.')
|
||||
TrainlogInfo(strings.getString(R.string.latest_max_value, maximum.exerciseName, weight, formatDate(maximum.startedAt)), colors.warning)
|
||||
}
|
||||
BodyZoneHomeSection(bodyZoneOverview, onOpenExercises)
|
||||
TrainlogFrame(strings.getString(R.string.quick_access), active = false) {
|
||||
Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min).testTag("home-quick-actions-row"),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
|
|
@ -119,7 +101,6 @@ fun HomeScreen(
|
|||
internal fun BodyZoneHomeSection(
|
||||
overview: BodyZoneHomeOverview,
|
||||
onOpenExercises: (String) -> Unit,
|
||||
onOpenStatistics: () -> Unit,
|
||||
) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
val strings = localizedContext()
|
||||
|
|
@ -159,7 +140,9 @@ internal fun BodyZoneHomeSection(
|
|||
}
|
||||
selected?.let { zone ->
|
||||
Column(
|
||||
Modifier.fillMaxWidth().padding(top = 8.dp).background(colors.surface, RoundedCornerShape(8.dp)).padding(12.dp),
|
||||
Modifier.fillMaxWidth().padding(top = 8.dp)
|
||||
.background(colors.surface, androidx.compose.material3.MaterialTheme.shapes.medium)
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
BasicText(localizedBodyZoneName(strings, zone.zoneId, zone.displayName), style = TrainlogTypography.section.copy(color = colors.text))
|
||||
|
|
@ -179,7 +162,6 @@ internal fun BodyZoneHomeSection(
|
|||
)
|
||||
if (zone.availableExerciseCount == 0) TrainlogInfo(BodyZoneHomeState.UNSUPPORTED.label, colors.muted)
|
||||
TrainlogAction(strings.getString(R.string.view_exercises), "", { onOpenExercises(zone.zoneId) })
|
||||
TrainlogAction(strings.getString(R.string.view_statistics), "", onOpenStatistics)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,17 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||
import com.labfytools.trainlog.R
|
||||
import com.labfytools.trainlog.data.StartProgramSessionResult
|
||||
import com.labfytools.trainlog.data.TrainlogRepository
|
||||
|
|
@ -18,13 +29,34 @@ import com.labfytools.trainlog.model.ProgramSessionExecutionState
|
|||
fun ProgramsScreen(repository: TrainlogRepository, onOpen: (String) -> Unit) {
|
||||
val strings = localizedContext()
|
||||
val programs = repository.listSyncedPrograms()
|
||||
val colors = LocalTrainlogColors.current
|
||||
TrainlogScreen(strings.getString(R.string.route_programs)) {
|
||||
if (programs.isEmpty()) TrainlogInfo(strings.getString(R.string.programs_none))
|
||||
programs.forEach { program ->
|
||||
TrainlogFrame(program.title, active = program.state == "active") {
|
||||
val detail = remember(program.programId) { repository.getSyncedProgram(program.programId) }
|
||||
val completed = detail?.sessions?.count {
|
||||
it.executionState == ProgramSessionExecutionState.COMPLETED
|
||||
} ?: 0
|
||||
Column(
|
||||
Modifier.fillMaxWidth().padding(bottom = 10.dp)
|
||||
.background(colors.surface, MaterialTheme.shapes.medium).padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
program.title,
|
||||
modifier = Modifier.weight(1f).padding(end = 8.dp),
|
||||
color = colors.text,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
ProgramStateBadge(
|
||||
if (program.state == "active") strings.getString(R.string.program_active)
|
||||
else strings.getString(R.string.program_archived),
|
||||
program.state == "active",
|
||||
)
|
||||
}
|
||||
TrainlogInfo(
|
||||
listOfNotNull(
|
||||
strings.getString(if (program.state == "active") R.string.program_active else R.string.program_archived),
|
||||
program.startDate?.let { strings.getString(R.string.program_start_date, formatDate(it)) },
|
||||
program.endDate?.let { strings.getString(R.string.program_end_date, formatDate(it)) },
|
||||
strings.resources.getQuantityString(
|
||||
|
|
@ -34,6 +66,7 @@ fun ProgramsScreen(repository: TrainlogRepository, onOpen: (String) -> Unit) {
|
|||
),
|
||||
).joinToString(" · "),
|
||||
)
|
||||
TrainlogInfo("$completed / ${program.sessionCount} · ${strings.getString(R.string.program_session_completed)}", if (completed > 0) colors.success else colors.muted)
|
||||
TrainlogButton(
|
||||
strings.getString(R.string.program_open),
|
||||
{ onOpen(program.programId) },
|
||||
|
|
@ -84,22 +117,41 @@ fun ProgramDetailScreen(
|
|||
program.startDate?.let { TrainlogInfo(strings.getString(R.string.program_start_date, formatDate(it))) }
|
||||
program.endDate?.let { TrainlogInfo(strings.getString(R.string.program_end_date, formatDate(it))) }
|
||||
program.note?.let { TrainlogInfo(it) }
|
||||
val completed = program.sessions.count { it.executionState == ProgramSessionExecutionState.COMPLETED }
|
||||
TrainlogInfo("$completed / ${program.sessions.size} · ${strings.getString(R.string.program_session_completed)}", if (completed > 0) LocalTrainlogColors.current.success else LocalTrainlogColors.current.muted)
|
||||
}
|
||||
message?.let { TrainlogInfo(it) }
|
||||
program.sessions.forEachIndexed { index, session ->
|
||||
var expanded by remember(session.programSessionId) { mutableStateOf(false) }
|
||||
val completed = session.executionState == ProgramSessionExecutionState.COMPLETED
|
||||
TrainlogFrame("${index + 1}. ${session.title}", active = completed) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().padding(bottom = 10.dp)
|
||||
.background(
|
||||
if (completed) LocalTrainlogColors.current.success.copy(alpha = .12f)
|
||||
else LocalTrainlogColors.current.surface,
|
||||
MaterialTheme.shapes.medium,
|
||||
).padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
val stateLabel = when (session.executionState) {
|
||||
ProgramSessionExecutionState.TODO -> R.string.program_session_todo
|
||||
ProgramSessionExecutionState.IN_PROGRESS -> R.string.program_session_in_progress
|
||||
ProgramSessionExecutionState.COMPLETED -> R.string.program_session_completed
|
||||
}
|
||||
TrainlogInfo(strings.getString(stateLabel))
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
"${index + 1}. ${session.title}",
|
||||
modifier = Modifier.weight(1f).padding(end = 8.dp),
|
||||
color = LocalTrainlogColors.current.text,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
ProgramStateBadge(strings.getString(stateLabel), completed)
|
||||
}
|
||||
TrainlogInfo(
|
||||
listOfNotNull(
|
||||
strings.getString(if (session.sessionType == "max_test") R.string.session_max_test else R.string.session_training),
|
||||
session.plannedFor?.let { strings.getString(R.string.planned_for, formatDate(it)) },
|
||||
strings.resources.getQuantityString(R.plurals.exercise_count, session.occurrences.size, session.occurrences.size),
|
||||
).joinToString(" · "),
|
||||
)
|
||||
session.note?.let { TrainlogInfo(it) }
|
||||
|
|
@ -141,3 +193,18 @@ fun ProgramDetailScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Presentation-only state badge; execution truth remains repository-owned. */
|
||||
@Composable
|
||||
private fun ProgramStateBadge(label: String, positive: Boolean) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
Text(
|
||||
label,
|
||||
modifier = Modifier.background(
|
||||
(if (positive) colors.success else colors.surfaceAlt).copy(alpha = .22f),
|
||||
MaterialTheme.shapes.small,
|
||||
).padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
color = if (positive) colors.success else colors.muted,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,17 +9,23 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
|||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
|
@ -34,8 +40,12 @@ import com.labfytools.trainlog.data.StartAiSessionDraftResult
|
|||
import com.labfytools.trainlog.data.TrainlogRepository
|
||||
import com.labfytools.trainlog.data.AndroidBackupService
|
||||
import com.labfytools.trainlog.data.AndroidBackupResult
|
||||
import com.labfytools.trainlog.data.HeartRateSensorPreferences
|
||||
import com.labfytools.trainlog.data.HeartRateSensorService
|
||||
import com.labfytools.trainlog.data.directStoragePermissionIntent
|
||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||
import com.labfytools.trainlog.model.SessionExerciseDraft
|
||||
import com.labfytools.trainlog.ui.theme.TrainlogTypography
|
||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||
import com.labfytools.trainlog.R
|
||||
|
||||
|
|
@ -48,6 +58,7 @@ fun SessionsHub(
|
|||
onDrafts: () -> Unit,
|
||||
onHistory: () -> Unit,
|
||||
onPrograms: () -> Unit = {},
|
||||
onCardioCalibration: () -> Unit = {},
|
||||
) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
val strings = localizedContext()
|
||||
|
|
@ -67,6 +78,13 @@ fun SessionsHub(
|
|||
Modifier.weight(1f).fillMaxHeight().testTag("sessions-drafts-action"))
|
||||
}
|
||||
}
|
||||
TrainlogButton(
|
||||
strings.getString(R.string.route_cardio_calibration),
|
||||
onCardioCalibration,
|
||||
Modifier.fillMaxWidth().testTag("sessions-cardio-calibration-action"),
|
||||
style = TrainlogButtonStyle.SUCCESS,
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
TrainlogButton(strings.getString(R.string.route_completed_sessions), onHistory,
|
||||
Modifier.fillMaxWidth().testTag("sessions-history-action"))
|
||||
Spacer(Modifier.height(16.dp).testTag("sessions-programs-spacing"))
|
||||
|
|
@ -93,21 +111,14 @@ fun AiSessionDraftsScreen(
|
|||
message?.let { TrainlogInfo(it) }
|
||||
if (drafts.isEmpty() && preparations.isEmpty()) TrainlogInfo(strings.getString(R.string.draft_none))
|
||||
preparations.forEach { preparation ->
|
||||
TrainlogFrame(preparation.title, active = true) {
|
||||
TrainlogInfo(listOfNotNull(
|
||||
strings.getString(R.string.manual_preparation),
|
||||
preparation.plannedFor?.let { strings.getString(R.string.planned_for, formatDate(it)) },
|
||||
strings.resources.getQuantityString(R.plurals.exercise_count, preparation.entries.size, preparation.entries.size),
|
||||
).joinToString(" · "))
|
||||
preparation.notes?.let { TrainlogInfo(it) }
|
||||
preparation.entries.forEach { entry ->
|
||||
val plan = checkNotNull(entry.plan)
|
||||
val metric = plan.reps?.let { strings.resources.getQuantityString(R.plurals.repetition_count, it, it) }
|
||||
?: plan.durationSeconds?.let { strings.resources.getQuantityString(R.plurals.seconds_count, it, it) }.orEmpty()
|
||||
val weight = plan.weightKg?.let { " · $it kg" }.orEmpty()
|
||||
TrainlogInfo(strings.getString(R.string.plan_draft_summary, entry.exercise.name, plan.sets, metric, weight, plan.restSeconds))
|
||||
}
|
||||
TrainlogPrimaryAction(strings.getString(R.string.start), strings.getString(R.string.start_preparation_description)) {
|
||||
DraftSummaryCard(
|
||||
title = preparation.title,
|
||||
provenance = strings.getString(R.string.manual_preparation),
|
||||
plannedFor = preparation.plannedFor,
|
||||
notes = preparation.notes,
|
||||
entries = preparation.entries,
|
||||
startDescription = strings.getString(R.string.start_preparation_description),
|
||||
onStart = {
|
||||
when (val result = repository.startPreparedSession(preparation.deliveryId)) {
|
||||
StartAiSessionDraftResult.Started -> { onPendingChanged(); onStarted() }
|
||||
StartAiSessionDraftResult.ExistingActiveDraft ->
|
||||
|
|
@ -115,24 +126,18 @@ fun AiSessionDraftsScreen(
|
|||
StartAiSessionDraftResult.NotPending -> { message = strings.getString(R.string.draft_missing); revision++; onPendingChanged() }
|
||||
is StartAiSessionDraftResult.Error -> message = localizedRepositoryMessage(strings, result.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
drafts.forEach { draft ->
|
||||
TrainlogFrame(draft.title ?: strings.getString(R.string.session_proposal), active = true) {
|
||||
TrainlogInfo(listOfNotNull(
|
||||
draft.plannedFor?.let { strings.getString(R.string.planned_for, formatDate(it)) },
|
||||
strings.resources.getQuantityString(R.plurals.exercise_count, draft.entries.size, draft.entries.size),
|
||||
).joinToString(" · "))
|
||||
draft.notes?.let { TrainlogInfo(it) }
|
||||
draft.entries.forEach { entry ->
|
||||
val plan = checkNotNull(entry.plan)
|
||||
val metric = plan.reps?.let { strings.resources.getQuantityString(R.plurals.repetition_count, it, it) }
|
||||
?: plan.durationSeconds?.let { strings.resources.getQuantityString(R.plurals.seconds_count, it, it) }.orEmpty()
|
||||
val weight = plan.weightKg?.let { " · $it kg" }.orEmpty()
|
||||
TrainlogInfo(strings.getString(R.string.plan_draft_summary, entry.exercise.name, plan.sets, metric, weight, plan.restSeconds))
|
||||
}
|
||||
TrainlogPrimaryAction(strings.getString(R.string.start), strings.getString(R.string.start_proposal_description)) {
|
||||
DraftSummaryCard(
|
||||
title = draft.title ?: strings.getString(R.string.session_proposal),
|
||||
provenance = strings.getString(R.string.session_proposal),
|
||||
plannedFor = draft.plannedFor,
|
||||
notes = draft.notes,
|
||||
entries = draft.entries,
|
||||
startDescription = strings.getString(R.string.start_proposal_description),
|
||||
onStart = {
|
||||
when (val result = repository.startAiSessionDraft(draft.draftId)) {
|
||||
StartAiSessionDraftResult.Started -> {
|
||||
onPendingChanged()
|
||||
|
|
@ -147,15 +152,24 @@ fun AiSessionDraftsScreen(
|
|||
}
|
||||
is StartAiSessionDraftResult.Error -> message = localizedRepositoryMessage(strings, result.message)
|
||||
}
|
||||
}
|
||||
TrainlogDeleteButton(
|
||||
contentDescription = localizedContext().getString(R.string.a11y_delete_draft),
|
||||
modifier = Modifier.testTag("delete-ai-draft-${draft.draftId}"),
|
||||
onClick = {
|
||||
pendingDeletion = draft.draftId to (draft.title ?: strings.getString(R.string.session_proposal))
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
footer = {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
TrainlogDeleteButton(
|
||||
contentDescription = localizedContext().getString(R.string.a11y_delete_draft),
|
||||
modifier = Modifier.testTag("delete-ai-draft-${draft.draftId}"),
|
||||
fullWidth = false,
|
||||
onClick = {
|
||||
pendingDeletion = draft.draftId to
|
||||
(draft.title ?: strings.getString(R.string.session_proposal))
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
pendingDeletion?.let { (draftId, title) ->
|
||||
|
|
@ -181,6 +195,138 @@ fun AiSessionDraftsScreen(
|
|||
}
|
||||
}
|
||||
|
||||
/** Compact presentation of an inert preparation; starting remains caller-owned. */
|
||||
@Composable
|
||||
private fun DraftSummaryCard(
|
||||
title: String,
|
||||
provenance: String,
|
||||
plannedFor: String?,
|
||||
notes: String?,
|
||||
entries: List<SessionExerciseDraft>,
|
||||
startDescription: String,
|
||||
onStart: () -> Unit,
|
||||
footer: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
val strings = localizedContext()
|
||||
Column(Modifier.fillMaxWidth().padding(bottom = 12.dp)) {
|
||||
TrainlogCompactCard {
|
||||
androidx.compose.material3.Text(
|
||||
title,
|
||||
color = colors.text,
|
||||
style = TrainlogTypography.section,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
TrainlogMetaBadge(provenance, Modifier.weight(1f))
|
||||
TrainlogMetaBadge(
|
||||
strings.resources.getQuantityString(
|
||||
R.plurals.exercise_count,
|
||||
entries.size,
|
||||
entries.size,
|
||||
),
|
||||
)
|
||||
}
|
||||
plannedFor?.let {
|
||||
androidx.compose.material3.Text(
|
||||
strings.getString(R.string.planned_for, formatDate(it)),
|
||||
color = colors.muted,
|
||||
style = TrainlogTypography.small,
|
||||
)
|
||||
}
|
||||
notes?.takeIf { it.isNotBlank() }?.let {
|
||||
androidx.compose.material3.Text(
|
||||
it,
|
||||
color = colors.muted,
|
||||
style = TrainlogTypography.small,
|
||||
maxLines = 2,
|
||||
)
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
entries.forEach { DraftExerciseRow(it) }
|
||||
}
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
TrainlogButton(
|
||||
strings.getString(R.string.start),
|
||||
onStart,
|
||||
Modifier.widthIn(min = 128.dp),
|
||||
style = TrainlogButtonStyle.SUCCESS,
|
||||
)
|
||||
androidx.compose.material3.Text(
|
||||
startDescription,
|
||||
modifier = Modifier.weight(1f),
|
||||
color = colors.muted,
|
||||
style = TrainlogTypography.small,
|
||||
maxLines = 2,
|
||||
)
|
||||
}
|
||||
footer?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DraftExerciseRow(entry: SessionExerciseDraft) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
val plan = checkNotNull(entry.plan)
|
||||
Column(
|
||||
Modifier.fillMaxWidth()
|
||||
.background(colors.background, androidx.compose.material3.MaterialTheme.shapes.small)
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
) {
|
||||
androidx.compose.material3.Text(
|
||||
entry.exercise.name,
|
||||
color = colors.text,
|
||||
style = TrainlogTypography.small,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
androidx.compose.material3.Text(
|
||||
draftPlanSummary(plan.sets, plan.reps, plan.durationSeconds, plan.weightKg, plan.restSeconds),
|
||||
color = colors.muted,
|
||||
style = TrainlogTypography.small,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun draftPlanSummary(
|
||||
sets: Int,
|
||||
reps: Int?,
|
||||
durationSeconds: Int?,
|
||||
weightKg: Double?,
|
||||
restSeconds: Int,
|
||||
): String {
|
||||
val strings = localizedContext()
|
||||
val metric = when {
|
||||
reps != null -> "$sets × $reps"
|
||||
durationSeconds != null && sets > 0 -> "$sets × ${humanDuration(durationSeconds)}"
|
||||
durationSeconds != null -> humanDuration(durationSeconds)
|
||||
else -> "—"
|
||||
}
|
||||
return buildList {
|
||||
add(metric)
|
||||
weightKg?.let { add("${it.toString().trimEnd('0').trimEnd('.')} kg") }
|
||||
if (restSeconds > 0) add(strings.getString(R.string.draft_rest_short, restSeconds))
|
||||
}.joinToString(" · ")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun humanDuration(seconds: Int): String {
|
||||
val strings = localizedContext()
|
||||
return if (seconds % 60 == 0) {
|
||||
val minutes = seconds / 60
|
||||
strings.resources.getQuantityString(R.plurals.duration_minutes_value, minutes, minutes)
|
||||
} else {
|
||||
strings.resources.getQuantityString(R.plurals.duration_seconds_value, seconds, seconds)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LatestMaximaScreen(repository: TrainlogRepository) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
|
|
@ -188,13 +334,29 @@ fun LatestMaximaScreen(repository: TrainlogRepository) {
|
|||
val locale = presentationLocale()
|
||||
val strings = localizedContext()
|
||||
TrainlogScreen(strings.getString(R.string.route_latest_maxima)) {
|
||||
TrainlogFrame(strings.getString(R.string.max_capacities), active = maxima.isNotEmpty()) {
|
||||
if (maxima.isEmpty()) TrainlogInfo(strings.getString(R.string.max_none))
|
||||
maxima.forEach { max ->
|
||||
val weight = "%.2f".format(locale, max.maxWeightKg).trimEnd('0').trimEnd(',', '.')
|
||||
TrainlogInfo("${max.exerciseName} · $weight kg", colors.warning)
|
||||
TrainlogInfo("${formatDate(max.startedAt)} · " + strings.getString(R.string.equipment_value,
|
||||
max.equipmentDisplayName ?: strings.getString(R.string.value_none)), colors.muted)
|
||||
if (maxima.isEmpty()) TrainlogInfo(strings.getString(R.string.max_none))
|
||||
maxima.forEach { max ->
|
||||
val weight = "%.2f".format(locale, max.maxWeightKg).trimEnd('0').trimEnd(',', '.')
|
||||
androidx.compose.foundation.layout.Column(
|
||||
Modifier.fillMaxWidth().padding(bottom = 8.dp)
|
||||
.background(colors.surface, androidx.compose.material3.MaterialTheme.shapes.medium)
|
||||
.padding(12.dp),
|
||||
) {
|
||||
androidx.compose.material3.Text(
|
||||
max.exerciseName,
|
||||
color = colors.text,
|
||||
style = com.labfytools.trainlog.ui.theme.TrainlogTypography.section,
|
||||
)
|
||||
androidx.compose.foundation.layout.Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
androidx.compose.material3.Text("$weight kg", color = colors.warning)
|
||||
androidx.compose.material3.Text(formatDate(max.startedAt), color = colors.muted)
|
||||
}
|
||||
max.equipmentDisplayName?.let {
|
||||
TrainlogInfo(strings.getString(R.string.equipment_value, it), colors.muted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -272,17 +434,29 @@ fun EquipmentDetailScreen(repository: TrainlogRepository, equipmentId: String, o
|
|||
}
|
||||
|
||||
@Composable
|
||||
fun EquipmentCreateScreen(repository: TrainlogRepository, state: EquipmentScreenState, onCreated: () -> Unit) {
|
||||
fun EquipmentCreateScreen(
|
||||
repository: TrainlogRepository,
|
||||
state: EquipmentScreenState,
|
||||
onCreated: (EquipmentCatalogEntry) -> Unit,
|
||||
) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
val strings = localizedContext()
|
||||
TrainlogScreen(strings.getString(R.string.create_equipment), scrollKey = "equipment-create") {
|
||||
TrainlogFrame(strings.getString(R.string.personal_equipment)) {
|
||||
TrainlogInputField(strings.getString(R.string.name), state.customName, { state.customName = it; state.message = null })
|
||||
TrainlogInputField(
|
||||
strings.getString(R.string.name),
|
||||
state.customName,
|
||||
{ state.customName = it; state.message = null },
|
||||
testTag = "equipment-create-name",
|
||||
)
|
||||
TrainlogPrimaryAction(strings.getString(R.string.create), strings.getString(R.string.create_machine_description)) {
|
||||
when (val result = repository.createCustomEquipment(state.customName)) {
|
||||
is CreateEquipmentResult.Created -> {
|
||||
state.selectedEquipmentId = result.equipment.equipmentId
|
||||
state.customName = ""; state.message = null; state.revision++; onCreated()
|
||||
state.customName = ""
|
||||
state.message = null
|
||||
state.revision++
|
||||
onCreated(result.equipment)
|
||||
}
|
||||
CreateEquipmentResult.Invalid -> state.message = strings.getString(R.string.equipment_name_invalid)
|
||||
CreateEquipmentResult.Conflict -> state.message = strings.getString(R.string.equipment_name_exists)
|
||||
|
|
@ -309,13 +483,22 @@ private fun catalogCountsText(context: android.content.Context, imported: Int, r
|
|||
).joinToString(", ")
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(repository: TrainlogRepository, inbox: SyncCatalogInbox, onCatalogChanged: () -> Unit) {
|
||||
fun SettingsScreen(
|
||||
repository: TrainlogRepository,
|
||||
inbox: SyncCatalogInbox,
|
||||
onHeartRateDiagnostics: () -> Unit,
|
||||
onCatalogChanged: () -> Unit,
|
||||
) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
val context = LocalContext.current
|
||||
val language = LocalLanguagePresentation.current
|
||||
val strings = localizedContext()
|
||||
var authorized by remember { mutableStateOf(inbox.hasFolderAccess()) }
|
||||
var message by remember { mutableStateOf<String?>(null) }
|
||||
val heartRatePreferences = remember(context) { HeartRateSensorPreferences(context) }
|
||||
var heartRateRevision by remember { mutableIntStateOf(0) }
|
||||
val selectedHeartRateSensor =
|
||||
remember(heartRateRevision) { heartRatePreferences.selected() }
|
||||
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
authorized = inbox.hasFolderAccess()
|
||||
message = strings.getString(if (authorized) R.string.settings_access_granted else R.string.settings_access_required)
|
||||
|
|
@ -370,6 +553,29 @@ fun SettingsScreen(repository: TrainlogRepository, inbox: SyncCatalogInbox, onCa
|
|||
)
|
||||
}
|
||||
}
|
||||
TrainlogFrame(strings.getString(R.string.settings_cardio_section)) {
|
||||
if (selectedHeartRateSensor != null) {
|
||||
TrainlogInfo(
|
||||
strings.getString(R.string.settings_cardio_configured),
|
||||
colors.success,
|
||||
)
|
||||
}
|
||||
TrainlogAction(
|
||||
strings.getString(R.string.settings_cardio_diagnostics),
|
||||
strings.getString(R.string.settings_cardio_diagnostics_description),
|
||||
onHeartRateDiagnostics,
|
||||
)
|
||||
if (selectedHeartRateSensor != null) {
|
||||
TrainlogAction(
|
||||
strings.getString(R.string.settings_cardio_forget),
|
||||
strings.getString(R.string.settings_cardio_forget_description),
|
||||
{
|
||||
HeartRateSensorService.forget(context)
|
||||
heartRateRevision++
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
TrainlogFrame(strings.getString(R.string.settings_exchange_folder)) {
|
||||
TrainlogInfo(strings.getString(if (authorized) R.string.settings_folder_granted else R.string.settings_folder_required), if (authorized) colors.success else colors.warning)
|
||||
TrainlogAction(strings.getString(if (authorized) R.string.settings_open_access else R.string.settings_authorize), strings.getString(R.string.settings_authorize_description), { launcher.launch(directStoragePermissionIntent(context)) })
|
||||
|
|
|
|||
|
|
@ -25,9 +25,16 @@ import androidx.compose.foundation.layout.IntrinsicSize
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
|
|
@ -45,14 +52,16 @@ import androidx.compose.ui.semantics.semantics
|
|||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.labfytools.trainlog.BuildConfig
|
||||
import com.labfytools.trainlog.R
|
||||
import com.labfytools.trainlog.data.ActiveDraftLoadResult
|
||||
import com.labfytools.trainlog.data.ActiveDraftMutationResult
|
||||
import com.labfytools.trainlog.data.CreateEquipmentResult
|
||||
import com.labfytools.trainlog.data.EquipmentLoadSemantics
|
||||
import com.labfytools.trainlog.data.FinalizeActiveDraftResult
|
||||
import com.labfytools.trainlog.data.ManualPercentMaxResult
|
||||
import com.labfytools.trainlog.data.SaveFeedbackResult
|
||||
import com.labfytools.trainlog.data.TrainlogRepository
|
||||
import com.labfytools.trainlog.data.TrainlogRepository.SessionExerciseTimingResult
|
||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||
import com.labfytools.trainlog.model.ExerciseDataFields
|
||||
import com.labfytools.trainlog.model.ExerciseProfile
|
||||
|
|
@ -68,7 +77,18 @@ import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
|||
import com.labfytools.trainlog.ui.theme.TrainlogTypography
|
||||
import java.text.Normalizer
|
||||
import java.util.Locale
|
||||
import com.labfytools.trainlog.R
|
||||
|
||||
/**
|
||||
* CONTRACT: a private recovery build may override finalization time for one
|
||||
* exact stable lifecycle identity only. Empty build inputs and every other
|
||||
* draft retain the normal repository clock default.
|
||||
*/
|
||||
private fun recoveryEndedAt(draft: ActiveSessionDraft): String? =
|
||||
BuildConfig.ACTIVE_SESSION_RECOVERY_ENDED_AT.takeIf {
|
||||
BuildConfig.ACTIVE_SESSION_RECOVERY_ID.isNotEmpty() &&
|
||||
BuildConfig.ACTIVE_SESSION_RECOVERY_ID == draft.sessionId &&
|
||||
it.isNotEmpty()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SessionScreen(
|
||||
|
|
@ -76,7 +96,10 @@ fun SessionScreen(
|
|||
catalogRevision: Int,
|
||||
onBack: () -> Unit,
|
||||
onCreateExercise: () -> Unit,
|
||||
onCreateEquipment: (editingEntryId: String?) -> Unit,
|
||||
onSessionSaved: () -> Unit,
|
||||
restoreEditingEntryId: String? = null,
|
||||
onEditingContextRestored: () -> Unit = {},
|
||||
) {
|
||||
val colors =
|
||||
LocalTrainlogColors.current
|
||||
|
|
@ -96,13 +119,27 @@ fun SessionScreen(
|
|||
|
||||
var activeDraft by
|
||||
remember(catalogRevision) {
|
||||
val loadedDraft = (initialLoad as? ActiveDraftLoadResult.Loaded)?.draft
|
||||
val restoredIndex = restoreEditingEntryId?.let { entryId ->
|
||||
loadedDraft?.exercises?.indexOfFirst { it.entryId == entryId }
|
||||
?.takeIf { it >= 0 }
|
||||
}
|
||||
mutableStateOf(
|
||||
(initialLoad as?
|
||||
ActiveDraftLoadResult.Loaded)
|
||||
?.draft
|
||||
if (restoredIndex == null) loadedDraft else loadedDraft?.let { draft ->
|
||||
draft.copy(
|
||||
form = draft.form.copy(
|
||||
editingExerciseIndex = restoredIndex,
|
||||
editingEntryId = restoreEditingEntryId,
|
||||
),
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(restoreEditingEntryId) {
|
||||
if (restoreEditingEntryId != null) onEditingContextRestored()
|
||||
}
|
||||
|
||||
var message by
|
||||
remember(catalogRevision) {
|
||||
mutableStateOf<
|
||||
|
|
@ -132,6 +169,9 @@ fun SessionScreen(
|
|||
var pendingDestructiveEdit by remember { mutableStateOf<Pair<Int, SessionExerciseDraft>?>(null) }
|
||||
var feedbackRevision by remember { mutableStateOf(0) }
|
||||
var reorderedDuringGesture by remember { mutableStateOf(false) }
|
||||
var timelineRevision by remember { mutableStateOf(0) }
|
||||
var pendingExerciseSwitch by remember { mutableStateOf<Pair<String, String>?>(null) }
|
||||
var pendingFinalizeActiveEntryId by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val persistDraft:
|
||||
(ActiveSessionDraft, String?) -> Unit =
|
||||
|
|
@ -171,6 +211,12 @@ fun SessionScreen(
|
|||
}
|
||||
|
||||
val currentDraft = activeDraft!!
|
||||
val exerciseTimings =
|
||||
remember(timelineRevision, currentDraft.exercises.map { it.entryId }) {
|
||||
repository.listActiveSessionExerciseTimings()
|
||||
}
|
||||
val timingByEntry = exerciseTimings.associateBy { it.entryId }
|
||||
val activeTiming = exerciseTimings.firstOrNull { it.active }
|
||||
|
||||
val lastWriteFailed =
|
||||
message?.startsWith(
|
||||
|
|
@ -196,69 +242,47 @@ fun SessionScreen(
|
|||
TrainlogFrame(
|
||||
title = strings.getString(R.string.session_type)
|
||||
) {
|
||||
TrainlogAction(
|
||||
label =
|
||||
if (
|
||||
currentDraft.sessionType ==
|
||||
SessionType.TRAINING
|
||||
) {
|
||||
strings.getString(R.string.training_selected)
|
||||
} else {
|
||||
strings.getString(R.string.training_unselected)
|
||||
},
|
||||
description =
|
||||
strings.getString(R.string.normal_session_description),
|
||||
accent =
|
||||
if (
|
||||
currentDraft.sessionType ==
|
||||
SessionType.TRAINING
|
||||
) {
|
||||
colors.success
|
||||
} else {
|
||||
colors.muted
|
||||
},
|
||||
onClick = {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
val training = currentDraft.sessionType == SessionType.TRAINING
|
||||
TrainlogButton(
|
||||
label = strings.getString(R.string.training),
|
||||
modifier = Modifier.weight(1f),
|
||||
style = if (training) TrainlogButtonStyle.SUCCESS else TrainlogButtonStyle.SECONDARY,
|
||||
maxLines = 2,
|
||||
onClick = {
|
||||
persistDraft(
|
||||
currentDraft.copy(
|
||||
sessionType =
|
||||
SessionType.TRAINING
|
||||
),
|
||||
currentDraft.copy(sessionType = SessionType.TRAINING),
|
||||
null,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
TrainlogAction(
|
||||
label =
|
||||
if (
|
||||
currentDraft.sessionType ==
|
||||
SessionType.MAX_TEST
|
||||
) {
|
||||
strings.getString(R.string.max_test_selected)
|
||||
} else {
|
||||
strings.getString(R.string.max_test_unselected)
|
||||
},
|
||||
description =
|
||||
strings.getString(R.string.max_session_description),
|
||||
accent =
|
||||
if (
|
||||
currentDraft.sessionType ==
|
||||
SessionType.MAX_TEST
|
||||
) {
|
||||
colors.warning
|
||||
} else {
|
||||
colors.muted
|
||||
},
|
||||
onClick = {
|
||||
)
|
||||
val maxTest = currentDraft.sessionType == SessionType.MAX_TEST
|
||||
TrainlogButton(
|
||||
label = strings.getString(R.string.session_max_test),
|
||||
modifier = Modifier.weight(1f),
|
||||
style = if (maxTest) TrainlogButtonStyle.SUCCESS else TrainlogButtonStyle.SECONDARY,
|
||||
maxLines = 2,
|
||||
onClick = {
|
||||
persistDraft(
|
||||
currentDraft.copy(
|
||||
sessionType =
|
||||
SessionType.MAX_TEST
|
||||
),
|
||||
currentDraft.copy(sessionType = SessionType.MAX_TEST),
|
||||
null,
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
val cardio = currentDraft.sessionType == SessionType.CARDIO
|
||||
TrainlogButton(
|
||||
label = strings.getString(R.string.session_cardio),
|
||||
modifier = Modifier.weight(1f),
|
||||
style = if (cardio) TrainlogButtonStyle.SUCCESS else TrainlogButtonStyle.SECONDARY,
|
||||
maxLines = 2,
|
||||
onClick = {
|
||||
persistDraft(
|
||||
currentDraft.copy(sessionType = SessionType.CARDIO),
|
||||
null,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ExercisePicker(
|
||||
|
|
@ -281,23 +305,19 @@ fun SessionScreen(
|
|||
) {
|
||||
TrainlogInfo(
|
||||
text =
|
||||
strings.getString(R.string.session_type_value,
|
||||
if (
|
||||
currentDraft.sessionType ==
|
||||
SessionType.MAX_TEST
|
||||
) {
|
||||
strings.getString(R.string.session_max_test)
|
||||
} else {
|
||||
strings.getString(R.string.session_training)
|
||||
}),
|
||||
strings.getString(
|
||||
R.string.session_type_value,
|
||||
when (currentDraft.sessionType) {
|
||||
SessionType.TRAINING -> strings.getString(R.string.session_training)
|
||||
SessionType.MAX_TEST -> strings.getString(R.string.session_max_test)
|
||||
SessionType.CARDIO -> strings.getString(R.string.session_cardio)
|
||||
},
|
||||
),
|
||||
color =
|
||||
if (
|
||||
currentDraft.sessionType ==
|
||||
SessionType.MAX_TEST
|
||||
) {
|
||||
colors.warning
|
||||
} else {
|
||||
colors.accent
|
||||
when (currentDraft.sessionType) {
|
||||
SessionType.MAX_TEST -> colors.warning
|
||||
SessionType.CARDIO -> colors.success
|
||||
SessionType.TRAINING -> colors.accent
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -372,18 +392,110 @@ fun SessionScreen(
|
|||
)
|
||||
}
|
||||
|
||||
val timing = timingByEntry[draft.entryId]
|
||||
when {
|
||||
timing == null -> {
|
||||
TrainlogButton(
|
||||
label = strings.getString(R.string.exercise_timeline_start),
|
||||
onClick = {
|
||||
when (
|
||||
val result =
|
||||
repository.startActiveSessionExercise(draft.entryId)
|
||||
) {
|
||||
is SessionExerciseTimingResult.Started -> {
|
||||
timelineRevision++
|
||||
message =
|
||||
strings.getString(
|
||||
R.string.exercise_timeline_started,
|
||||
draft.exercise.name,
|
||||
)
|
||||
}
|
||||
is SessionExerciseTimingResult.ActiveConflict -> {
|
||||
pendingExerciseSwitch =
|
||||
result.activeEntryId to draft.entryId
|
||||
}
|
||||
is SessionExerciseTimingResult.Invalid ->
|
||||
message = result.message
|
||||
is SessionExerciseTimingResult.DatabaseError ->
|
||||
message = result.message
|
||||
is SessionExerciseTimingResult.Finished -> Unit
|
||||
}
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("start-exercise-${draft.entryId}"),
|
||||
style = TrainlogButtonStyle.SUCCESS,
|
||||
)
|
||||
}
|
||||
timing.active -> {
|
||||
TrainlogButton(
|
||||
label = strings.getString(R.string.exercise_timeline_finish),
|
||||
onClick = {
|
||||
when (
|
||||
val result =
|
||||
repository.finishActiveSessionExercise(draft.entryId)
|
||||
) {
|
||||
is SessionExerciseTimingResult.Finished -> {
|
||||
timelineRevision++
|
||||
message =
|
||||
strings.getString(
|
||||
R.string.exercise_timeline_finished,
|
||||
draft.exercise.name,
|
||||
)
|
||||
}
|
||||
is SessionExerciseTimingResult.Invalid ->
|
||||
message = result.message
|
||||
is SessionExerciseTimingResult.DatabaseError ->
|
||||
message = result.message
|
||||
is SessionExerciseTimingResult.Started,
|
||||
is SessionExerciseTimingResult.ActiveConflict -> Unit
|
||||
}
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("finish-exercise-${draft.entryId}"),
|
||||
containerColor = colors.warning,
|
||||
style = TrainlogButtonStyle.SECONDARY,
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
TrainlogButton(
|
||||
label = strings.getString(R.string.exercise_timeline_completed),
|
||||
onClick = {},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("completed-exercise-${draft.entryId}"),
|
||||
enabled = false,
|
||||
style = TrainlogButtonStyle.SECONDARY,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
currentDraft.sessionType == SessionType.CARDIO &&
|
||||
timing?.active == true
|
||||
) {
|
||||
CardioGuidancePanel(
|
||||
repository = repository,
|
||||
entryId = draft.entryId,
|
||||
)
|
||||
}
|
||||
|
||||
TrainlogAction(
|
||||
label = strings.getString(R.string.modify_name, draft.exercise.name),
|
||||
description = strings.getString(R.string.edit_exercise_description),
|
||||
accent = colors.accent,
|
||||
modifier = Modifier.testTag("edit-draft-exercise-${draft.entryId}"),
|
||||
onClick = {
|
||||
persistDraft(
|
||||
currentDraft.copy(
|
||||
form = formForExistingExercise(draft, index),
|
||||
),
|
||||
null,
|
||||
strings.getString(R.string.editor_opened, draft.exercise.name),
|
||||
)
|
||||
message = strings.getString(R.string.editor_opened, draft.exercise.name)
|
||||
},
|
||||
)
|
||||
if (currentDraft.form.editingEntryId == draft.entryId) {
|
||||
|
|
@ -467,8 +579,11 @@ fun SessionScreen(
|
|||
sessionType = currentDraft.sessionType,
|
||||
initialForm =
|
||||
currentDraft.form,
|
||||
initialPlan = currentDraft.form.editingExerciseIndex?.let {
|
||||
currentDraft.exercises.getOrNull(it)?.plan
|
||||
initialOccurrence = currentDraft.form.editingExerciseIndex?.let {
|
||||
currentDraft.exercises.getOrNull(it)
|
||||
},
|
||||
onCreateEquipment = {
|
||||
onCreateEquipment(currentDraft.form.editingEntryId)
|
||||
},
|
||||
onFormChanged = {
|
||||
form ->
|
||||
|
|
@ -530,22 +645,32 @@ fun SessionScreen(
|
|||
Modifier.weight(1f).fillMaxHeight().testTag("active-session-create-exercise"),
|
||||
containerColor = colors.surfaceAlt, maxLines = 2)
|
||||
TrainlogButton(strings.getString(R.string.session_save), onClick = {
|
||||
when (
|
||||
val result =
|
||||
repository.finalizeActiveSessionDraft()
|
||||
) {
|
||||
is FinalizeActiveDraftResult.Saved -> {
|
||||
onSessionSaved()
|
||||
onBack()
|
||||
}
|
||||
val activeEntryId = repository.activeSessionTimelineContext()?.activeEntryId
|
||||
if (activeEntryId != null) {
|
||||
pendingFinalizeActiveEntryId = activeEntryId
|
||||
} else {
|
||||
when (
|
||||
val result =
|
||||
repository.finalizeActiveSessionDraft(
|
||||
recoveryEndedAt(currentDraft),
|
||||
)
|
||||
) {
|
||||
is FinalizeActiveDraftResult.Saved -> {
|
||||
onSessionSaved()
|
||||
onBack()
|
||||
}
|
||||
|
||||
is FinalizeActiveDraftResult.Invalid -> {
|
||||
message = localizedRepositoryMessage(strings, result.message)
|
||||
}
|
||||
is FinalizeActiveDraftResult.Invalid -> {
|
||||
message = localizedRepositoryMessage(strings, result.message)
|
||||
}
|
||||
|
||||
is FinalizeActiveDraftResult.DatabaseError -> {
|
||||
message =
|
||||
strings.getString(R.string.finalize_failed, localizedRepositoryMessage(strings, result.message))
|
||||
is FinalizeActiveDraftResult.DatabaseError -> {
|
||||
message =
|
||||
strings.getString(
|
||||
R.string.finalize_failed,
|
||||
localizedRepositoryMessage(strings, result.message),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, modifier = Modifier.weight(1f).fillMaxHeight().testTag("active-session-save"),
|
||||
|
|
@ -601,12 +726,24 @@ fun SessionScreen(
|
|||
strings.getString(R.string.exercise_added) ||
|
||||
message ==
|
||||
strings.getString(R.string.exercise_removed) ||
|
||||
message == strings.getString(R.string.exercise_order_saved)
|
||||
message == strings.getString(R.string.exercise_order_saved) ||
|
||||
currentDraft.exercises.any { exercise ->
|
||||
message ==
|
||||
strings.getString(
|
||||
R.string.exercise_timeline_started,
|
||||
exercise.exercise.name,
|
||||
) ||
|
||||
message ==
|
||||
strings.getString(
|
||||
R.string.exercise_timeline_finished,
|
||||
exercise.exercise.name,
|
||||
)
|
||||
}
|
||||
) {
|
||||
colors.success
|
||||
} else {
|
||||
colors.error
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -652,6 +789,142 @@ fun SessionScreen(
|
|||
},
|
||||
)
|
||||
}
|
||||
|
||||
pendingExerciseSwitch?.let { (activeEntryId, targetEntryId) ->
|
||||
val activeName =
|
||||
currentDraft.exercises.firstOrNull { it.entryId == activeEntryId }
|
||||
?.exercise?.name
|
||||
?: strings.getString(R.string.exercise_title)
|
||||
val targetName =
|
||||
currentDraft.exercises.firstOrNull { it.entryId == targetEntryId }
|
||||
?.exercise?.name
|
||||
?: strings.getString(R.string.exercise_title)
|
||||
AlertDialog(
|
||||
onDismissRequest = { pendingExerciseSwitch = null },
|
||||
title = { Text(strings.getString(R.string.exercise_timeline_switch_title)) },
|
||||
text = {
|
||||
Text(
|
||||
strings.getString(
|
||||
R.string.exercise_timeline_switch_detail,
|
||||
activeName,
|
||||
targetName,
|
||||
),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
when (
|
||||
val finished =
|
||||
repository.finishActiveSessionExercise(activeEntryId)
|
||||
) {
|
||||
is SessionExerciseTimingResult.Finished -> {
|
||||
when (
|
||||
val started =
|
||||
repository.startActiveSessionExercise(targetEntryId)
|
||||
) {
|
||||
is SessionExerciseTimingResult.Started -> {
|
||||
timelineRevision++
|
||||
message =
|
||||
strings.getString(
|
||||
R.string.exercise_timeline_started,
|
||||
targetName,
|
||||
)
|
||||
}
|
||||
is SessionExerciseTimingResult.Invalid ->
|
||||
message = started.message
|
||||
is SessionExerciseTimingResult.DatabaseError ->
|
||||
message = started.message
|
||||
is SessionExerciseTimingResult.ActiveConflict,
|
||||
is SessionExerciseTimingResult.Finished -> Unit
|
||||
}
|
||||
}
|
||||
is SessionExerciseTimingResult.Invalid ->
|
||||
message = finished.message
|
||||
is SessionExerciseTimingResult.DatabaseError ->
|
||||
message = finished.message
|
||||
is SessionExerciseTimingResult.Started,
|
||||
is SessionExerciseTimingResult.ActiveConflict -> Unit
|
||||
}
|
||||
pendingExerciseSwitch = null
|
||||
},
|
||||
) {
|
||||
Text(strings.getString(R.string.exercise_timeline_switch_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { pendingExerciseSwitch = null }) {
|
||||
Text(strings.getString(R.string.dialog_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pendingFinalizeActiveEntryId?.let { activeEntryId ->
|
||||
val activeName =
|
||||
currentDraft.exercises.firstOrNull { it.entryId == activeEntryId }
|
||||
?.exercise?.name
|
||||
?: strings.getString(R.string.exercise_title)
|
||||
AlertDialog(
|
||||
onDismissRequest = { pendingFinalizeActiveEntryId = null },
|
||||
title = { Text(strings.getString(R.string.exercise_timeline_finalize_title)) },
|
||||
text = {
|
||||
Text(
|
||||
strings.getString(
|
||||
R.string.exercise_timeline_finalize_detail,
|
||||
activeName,
|
||||
),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
when (
|
||||
val finished =
|
||||
repository.finishActiveSessionExercise(activeEntryId)
|
||||
) {
|
||||
is SessionExerciseTimingResult.Finished -> {
|
||||
timelineRevision++
|
||||
when (
|
||||
val result = repository.finalizeActiveSessionDraft(
|
||||
recoveryEndedAt(currentDraft),
|
||||
)
|
||||
) {
|
||||
is FinalizeActiveDraftResult.Saved -> {
|
||||
pendingFinalizeActiveEntryId = null
|
||||
onSessionSaved()
|
||||
onBack()
|
||||
}
|
||||
is FinalizeActiveDraftResult.Invalid ->
|
||||
message = localizedRepositoryMessage(strings, result.message)
|
||||
is FinalizeActiveDraftResult.DatabaseError ->
|
||||
message =
|
||||
strings.getString(
|
||||
R.string.finalize_failed,
|
||||
localizedRepositoryMessage(strings, result.message),
|
||||
)
|
||||
}
|
||||
}
|
||||
is SessionExerciseTimingResult.Invalid ->
|
||||
message = finished.message
|
||||
is SessionExerciseTimingResult.DatabaseError ->
|
||||
message = finished.message
|
||||
is SessionExerciseTimingResult.Started,
|
||||
is SessionExerciseTimingResult.ActiveConflict -> Unit
|
||||
}
|
||||
pendingFinalizeActiveEntryId = null
|
||||
},
|
||||
) {
|
||||
Text(strings.getString(R.string.exercise_timeline_finalize_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { pendingFinalizeActiveEntryId = null }) {
|
||||
Text(strings.getString(R.string.dialog_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -894,7 +1167,8 @@ private fun SessionExerciseForm(
|
|||
exercise: ExerciseProfile,
|
||||
sessionType: SessionType,
|
||||
initialForm: SessionDraftForm,
|
||||
initialPlan: SessionExercisePlan?,
|
||||
initialOccurrence: SessionExerciseDraft?,
|
||||
onCreateEquipment: () -> Unit,
|
||||
onFormChanged: (SessionDraftForm) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
onAdd:
|
||||
|
|
@ -904,6 +1178,7 @@ private fun SessionExerciseForm(
|
|||
val colors =
|
||||
LocalTrainlogColors.current
|
||||
val strings = localizedContext()
|
||||
val initialPlan = initialOccurrence?.plan
|
||||
|
||||
var setCountText by
|
||||
remember(key) {
|
||||
|
|
@ -969,13 +1244,11 @@ private fun SessionExerciseForm(
|
|||
)
|
||||
}
|
||||
|
||||
var equipmentRevision by remember(key) { mutableStateOf(0) }
|
||||
val equipmentEntries = remember(equipmentRevision) { repository.listEquipment() }
|
||||
val equipmentEntries = remember(key) { repository.listEquipment() }
|
||||
val fixedEquipmentId = remember(exercise.exerciseId) {
|
||||
repository.machineExerciseLegacyEquipmentId(exercise.exerciseId)
|
||||
}
|
||||
var equipmentSearch by remember(key) { mutableStateOf("") }
|
||||
var customEquipmentName by remember(key) { mutableStateOf("") }
|
||||
var selectedEquipmentId by remember(key) {
|
||||
mutableStateOf(initialForm.selectedEquipmentId ?: fixedEquipmentId)
|
||||
}
|
||||
|
|
@ -1012,34 +1285,18 @@ private fun SessionExerciseForm(
|
|||
value = equipmentSearch,
|
||||
onValueChange = { equipmentSearch = it },
|
||||
)
|
||||
TrainlogInputField(
|
||||
label = strings.getString(R.string.new_historical_context),
|
||||
value = customEquipmentName,
|
||||
onValueChange = { customEquipmentName = it },
|
||||
)
|
||||
TrainlogAction(
|
||||
label = strings.getString(R.string.create_context),
|
||||
description = strings.getString(R.string.context_compatibility),
|
||||
label = strings.getString(R.string.create_equipment),
|
||||
description = strings.getString(R.string.create_equipment_from_session),
|
||||
accent = colors.success,
|
||||
onClick = {
|
||||
when (val result = repository.createCustomEquipment(customEquipmentName)) {
|
||||
is CreateEquipmentResult.Created -> {
|
||||
customEquipmentName = ""
|
||||
equipmentRevision += 1
|
||||
selectedEquipmentId = result.equipment.equipmentId
|
||||
onFormChanged(currentForm(exercise, setCountText, repsText, durationText, speedText, distanceText, selectedEquipmentId, weightText, maxWeightText))
|
||||
}
|
||||
CreateEquipmentResult.Invalid -> error = strings.getString(R.string.context_name_invalid)
|
||||
CreateEquipmentResult.Conflict -> error = strings.getString(R.string.context_exists)
|
||||
is CreateEquipmentResult.DatabaseError -> error = strings.getString(R.string.context_create_error, localizedRepositoryMessage(strings, result.message))
|
||||
}
|
||||
},
|
||||
modifier = Modifier.testTag("session-create-equipment"),
|
||||
onClick = onCreateEquipment,
|
||||
)
|
||||
}
|
||||
val selectedEquipment = equipmentEntries.firstOrNull { it.equipmentId == selectedEquipmentId }
|
||||
val percentResult = remember(
|
||||
exercise.exerciseId, selectedEquipmentId, targetPercentText,
|
||||
targetChoice, equipmentRevision,
|
||||
targetChoice,
|
||||
) {
|
||||
if (targetChoice == ManualTargetChoice.PERCENT_MAX)
|
||||
repository.calculateManualPercentMaxTarget(
|
||||
|
|
@ -1059,16 +1316,29 @@ private fun SessionExerciseForm(
|
|||
},
|
||||
)
|
||||
}
|
||||
if (fixedEquipmentId == null) repository.searchEquipment(equipmentSearch).take(8).forEach { equipment ->
|
||||
TrainlogAction(
|
||||
label = if (equipment.equipmentId == selectedEquipmentId) "✓ ${equipment.displayName}" else equipment.displayName,
|
||||
description = equipment.labelName.ifBlank { equipment.type },
|
||||
accent = if (equipment.equipmentId == selectedEquipmentId) colors.success else colors.muted,
|
||||
onClick = {
|
||||
selectedEquipmentId = equipment.equipmentId
|
||||
onFormChanged(currentForm(exercise, setCountText, repsText, durationText, speedText, distanceText, equipment.equipmentId, weightText, maxWeightText))
|
||||
},
|
||||
)
|
||||
if (fixedEquipmentId == null) {
|
||||
/* WHY: alphabetical truncation made valid equipment beyond the
|
||||
* eighth row impossible to select. CONTRACT: the bounded nested
|
||||
* viewport exposes every search result while the surrounding
|
||||
* exercise editor remains independently scrollable. */
|
||||
Column(
|
||||
Modifier
|
||||
.heightIn(max = 320.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.testTag("session-equipment-results"),
|
||||
) {
|
||||
repository.searchEquipment(equipmentSearch).forEach { equipment ->
|
||||
TrainlogAction(
|
||||
label = if (equipment.equipmentId == selectedEquipmentId) "✓ ${equipment.displayName}" else equipment.displayName,
|
||||
description = equipment.labelName.ifBlank { equipment.type },
|
||||
accent = if (equipment.equipmentId == selectedEquipmentId) colors.success else colors.muted,
|
||||
onClick = {
|
||||
selectedEquipmentId = equipment.equipmentId
|
||||
onFormChanged(currentForm(exercise, setCountText, repsText, durationText, speedText, distanceText, equipment.equipmentId, weightText, maxWeightText))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionType == SessionType.MAX_TEST) {
|
||||
|
|
@ -1151,54 +1421,78 @@ private fun SessionExerciseForm(
|
|||
strings.getString(R.string.field_weight)
|
||||
}
|
||||
setRows.forEachIndexed { index, row ->
|
||||
TrainlogInfo(
|
||||
text = strings.getString(R.string.set_number, index + 1),
|
||||
color = colors.accent,
|
||||
)
|
||||
SessionNumberField(
|
||||
label = strings.getString(R.string.set_reps_field, index + 1),
|
||||
value = row.repsText,
|
||||
testTag = "session-set-$index-reps",
|
||||
onValueChange = { value ->
|
||||
val updated = setRows.replaceAt(index, row.copy(repsText = value))
|
||||
setRows = updated
|
||||
repsText = encodeRawReps(updated)
|
||||
weightText = encodeRawWeights(updated)
|
||||
error = null
|
||||
onFormChanged(
|
||||
currentForm(
|
||||
exercise, setCountText, repsText, durationText,
|
||||
speedText, distanceText, selectedEquipmentId, weightText,
|
||||
)
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp)
|
||||
.background(colors.surface, MaterialTheme.shapes.medium)
|
||||
.padding(10.dp),
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
BasicText(
|
||||
text = strings.getString(R.string.set_number, index + 1),
|
||||
modifier = Modifier.padding(vertical = 12.dp),
|
||||
style = TrainlogTypography.small.copy(
|
||||
color = colors.accent,
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
SessionNumberField(
|
||||
label = strings.getString(R.string.set_load_field, index + 1, loadLabel),
|
||||
value = row.weightText,
|
||||
decimal = true,
|
||||
testTag = "session-set-$index-weight",
|
||||
onValueChange = { value ->
|
||||
val updated = setRows.replaceAt(index, row.copy(weightText = value))
|
||||
setRows = updated
|
||||
repsText = encodeRawReps(updated)
|
||||
weightText = encodeRawWeights(updated)
|
||||
error = null
|
||||
onFormChanged(
|
||||
currentForm(
|
||||
exercise, setCountText, repsText, durationText,
|
||||
speedText, distanceText, selectedEquipmentId, weightText,
|
||||
)
|
||||
TrainlogIconAction(
|
||||
icon = TrainlogIcons.DeleteOutline,
|
||||
contentDescription = localizedContext().getString(R.string.a11y_delete_set),
|
||||
modifier = Modifier.testTag("delete-set-${index + 1}"),
|
||||
accent = colors.error,
|
||||
onClick = { pendingSetRemoval = index },
|
||||
)
|
||||
},
|
||||
)
|
||||
TrainlogDeleteButton(
|
||||
contentDescription = localizedContext().getString(com.labfytools.trainlog.R.string.a11y_delete_set),
|
||||
modifier = Modifier.testTag("delete-set-${index + 1}"),
|
||||
onClick = {
|
||||
pendingSetRemoval = index
|
||||
},
|
||||
)
|
||||
}
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
SessionNumberField(
|
||||
label = strings.getString(R.string.field_reps),
|
||||
value = row.repsText,
|
||||
modifier = Modifier.weight(1f),
|
||||
testTag = "session-set-$index-reps",
|
||||
onValueChange = { value ->
|
||||
val updated = setRows.replaceAt(index, row.copy(repsText = value))
|
||||
setRows = updated
|
||||
repsText = encodeRawReps(updated)
|
||||
weightText = encodeRawWeights(updated)
|
||||
error = null
|
||||
onFormChanged(
|
||||
currentForm(
|
||||
exercise, setCountText, repsText, durationText,
|
||||
speedText, distanceText, selectedEquipmentId, weightText,
|
||||
)
|
||||
)
|
||||
},
|
||||
)
|
||||
SessionNumberField(
|
||||
label = loadLabel,
|
||||
value = row.weightText,
|
||||
modifier = Modifier.weight(1f),
|
||||
decimal = true,
|
||||
testTag = "session-set-$index-weight",
|
||||
onValueChange = { value ->
|
||||
val updated = setRows.replaceAt(index, row.copy(weightText = value))
|
||||
setRows = updated
|
||||
repsText = encodeRawReps(updated)
|
||||
weightText = encodeRawWeights(updated)
|
||||
error = null
|
||||
onFormChanged(
|
||||
currentForm(
|
||||
exercise, setCountText, repsText, durationText,
|
||||
speedText, distanceText, selectedEquipmentId, weightText,
|
||||
)
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
TrainlogAction(
|
||||
label = strings.getString(R.string.add_set_plain),
|
||||
|
|
@ -1275,6 +1569,7 @@ private fun SessionExerciseForm(
|
|||
SessionNumberField(
|
||||
label =
|
||||
strings.getString(R.string.duration_minutes),
|
||||
testTag = "session-continuous-duration",
|
||||
value =
|
||||
durationText,
|
||||
onValueChange = {
|
||||
|
|
@ -1361,7 +1656,7 @@ private fun SessionExerciseForm(
|
|||
modifier = Modifier.fillMaxWidth().testTag("add-to-session"),
|
||||
containerColor = colors.success,
|
||||
onClick = {
|
||||
val draft =
|
||||
val capturedDraft =
|
||||
buildSessionExerciseDraft(
|
||||
exercise =
|
||||
exercise,
|
||||
|
|
@ -1383,6 +1678,26 @@ private fun SessionExerciseForm(
|
|||
rawSetRows = setRows,
|
||||
)
|
||||
|
||||
/* WHY: a prepared occurrence may legitimately have a target
|
||||
* but no performed sets yet. CONTRACT: changing its equipment
|
||||
* edits planning metadata in place without fabricating actual
|
||||
* work. INVARIANT: any nonblank partial performed row still
|
||||
* follows normal validation and can never be discarded here. */
|
||||
val targetOnlyDraft = initialOccurrence?.takeIf {
|
||||
sessionType == SessionType.TRAINING &&
|
||||
it.plan != null &&
|
||||
it.sets.isEmpty() &&
|
||||
it.maxWeightKg == null &&
|
||||
it.continuousDurationSeconds == 0 &&
|
||||
setRows.all { row ->
|
||||
row.repsText.isBlank() && row.weightText.isBlank()
|
||||
}
|
||||
}?.copy(
|
||||
exercise = exercise,
|
||||
equipmentId = selectedEquipmentId,
|
||||
)
|
||||
val draft = capturedDraft ?: targetOnlyDraft
|
||||
|
||||
if (draft == null) {
|
||||
error =
|
||||
if (sessionType == SessionType.MAX_TEST) {
|
||||
|
|
@ -1406,7 +1721,9 @@ private fun SessionExerciseForm(
|
|||
onFailure = { error = localizedTargetError(it.message, strings) },
|
||||
)
|
||||
} else {
|
||||
onAdd(draft.copy(plan = null))
|
||||
/* CONTRACT: editing performed continuous facts must not
|
||||
* erase the independent target copied from preparation. */
|
||||
onAdd(draft.copy(plan = initialPlan))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
@ -1418,6 +1735,7 @@ private fun SessionExerciseForm(
|
|||
strings.getString(R.string.return_catalog),
|
||||
accent =
|
||||
colors.muted,
|
||||
modifier = Modifier.testTag("cancel-session-exercise-editor"),
|
||||
onClick =
|
||||
onCancel,
|
||||
)
|
||||
|
|
@ -1608,6 +1926,7 @@ private fun SessionNumberField(
|
|||
label: String,
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
testTag: String? = null,
|
||||
decimal: Boolean = false,
|
||||
) {
|
||||
|
|
@ -1623,6 +1942,7 @@ private fun SessionNumberField(
|
|||
} else {
|
||||
KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||
},
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
@ -29,11 +29,19 @@ import com.labfytools.trainlog.data.SyncRequestOutbox
|
|||
import com.labfytools.trainlog.data.SyncRequestResult
|
||||
import com.labfytools.trainlog.data.TrainlogRepository
|
||||
import com.labfytools.trainlog.data.ForegroundGenerationResult
|
||||
import com.labfytools.trainlog.data.BackgroundSyncRunLedger
|
||||
import com.labfytools.trainlog.data.SyncConversationArbiter
|
||||
import com.labfytools.trainlog.data.SyncConversationIntent
|
||||
import com.labfytools.trainlog.data.SyncGenerationForegroundCoordinator
|
||||
import com.labfytools.trainlog.data.BackgroundSyncSettings
|
||||
import com.labfytools.trainlog.data.SyncBackgroundService
|
||||
import com.labfytools.trainlog.data.DriveSyncSettings
|
||||
import com.labfytools.trainlog.data.AiExportDriveSettings
|
||||
import com.labfytools.trainlog.data.BluetoothSyncSettings
|
||||
import com.labfytools.trainlog.data.bondedBluetoothDesktops
|
||||
import com.labfytools.trainlog.data.hasBluetoothConnectPermission
|
||||
import com.labfytools.trainlog.data.canonicalExchangeDirectory
|
||||
import com.labfytools.trainlog.data.AndroidMtpPublicationVisibility
|
||||
import com.labfytools.trainlog.data.MtpPublicationVisibility
|
||||
import com.labfytools.trainlog.data.logDirectExchange
|
||||
import com.labfytools.trainlog.data.directStoragePermissionIntent
|
||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||
|
|
@ -49,26 +57,35 @@ internal suspend fun publishBundleAndRequest(
|
|||
repository: TrainlogRepository,
|
||||
exporter: SyncExporter,
|
||||
requestOutbox: SyncRequestOutbox,
|
||||
visibility: MtpPublicationVisibility = com.labfytools.trainlog.data.ImmediateMtpPublicationVisibility,
|
||||
explicitClaim: SyncConversationArbiter.ExplicitClaim =
|
||||
SyncConversationArbiter.process.announceExplicit(),
|
||||
terminalLedger: BackgroundSyncRunLedger? = null,
|
||||
): SyncRequestResult = withContext(Dispatchers.IO) {
|
||||
val optIn = java.io.File(canonicalExchangeDirectory(), "trainlog-sync-generation-opt-in-v1.json")
|
||||
if (optIn.isFile) {
|
||||
val enabled = try {
|
||||
val value = org.json.JSONObject(optIn.readText())
|
||||
value.keys().asSequence().toSet() == setOf("format", "version", "enabled") &&
|
||||
value.optString("format") == "trainlog-sync-generation-opt-in" &&
|
||||
value.opt("version") is Int && value.getInt("version") == 1 && value.opt("enabled") == true
|
||||
} catch (_: Exception) { false }
|
||||
if (!enabled) return@withContext SyncRequestResult.Error("Invalid generation synchronization opt-in.")
|
||||
val opened = exporter.openStorageSnapshot()
|
||||
if (opened is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Error) {
|
||||
return@withContext SyncRequestResult.Error("prepare:${opened.message}")
|
||||
}
|
||||
val snapshot = (opened as com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Ready).snapshot
|
||||
try {
|
||||
val directory = canonicalExchangeDirectory()
|
||||
val coordinator = SyncGenerationForegroundCoordinator(repository, visibility)
|
||||
return@withContext when (
|
||||
val result = SyncGenerationForegroundCoordinator(repository).run(
|
||||
canonicalExchangeDirectory(),
|
||||
val result = coordinator.run(
|
||||
directory,
|
||||
afterPeerPublication = {
|
||||
when (val request = requestOutbox.requestSync(snapshot)) {
|
||||
val opened = exporter.openStorageSnapshot()
|
||||
if (opened is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Error) {
|
||||
throw IllegalStateException(opened.message)
|
||||
}
|
||||
val snapshot =
|
||||
(opened as com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Ready)
|
||||
.snapshot
|
||||
val requestIntent = requestOutbox.newIntent()
|
||||
/* WHY: a strict V3 snapshot cannot represent causal
|
||||
* tombstones, but the complete generation can. CONTRACT:
|
||||
* publish and confirm the modern trigger before attempting
|
||||
* optional legacy compatibility. INVARIANT: an old desktop
|
||||
* sees this intent only after a fresh V3 bundle succeeds. */
|
||||
when (
|
||||
val request =
|
||||
requestOutbox.publishFullGeneration(snapshot, requestIntent)
|
||||
) {
|
||||
is SyncRequestResult.Requested -> Unit
|
||||
SyncRequestResult.Unsupported ->
|
||||
throw IllegalStateException("Generation synchronization is unsupported.")
|
||||
|
|
@ -76,13 +93,67 @@ internal suspend fun publishBundleAndRequest(
|
|||
is SyncRequestResult.Completed ->
|
||||
throw IllegalStateException("Unexpected completed request signal.")
|
||||
}
|
||||
when (exporter.exportMobileBundle(snapshot)) {
|
||||
is SyncExportResult.Exported ->
|
||||
when (requestOutbox.publishLegacy(snapshot, requestIntent)) {
|
||||
is SyncRequestResult.Requested ->
|
||||
logDirectExchange(
|
||||
"trainlog-sync-request-v1.json",
|
||||
"legacy_compatibility_published",
|
||||
)
|
||||
else ->
|
||||
logDirectExchange(
|
||||
"trainlog-sync-request-v1.json",
|
||||
"legacy_compatibility_unavailable",
|
||||
)
|
||||
}
|
||||
SyncExportResult.Unsupported,
|
||||
is SyncExportResult.Error ->
|
||||
logDirectExchange(
|
||||
"trainlog-sync-request-v1.json",
|
||||
"legacy_compatibility_unavailable",
|
||||
)
|
||||
}
|
||||
},
|
||||
intent = SyncConversationIntent.NEW_EXPLICIT,
|
||||
explicitClaim = explicitClaim,
|
||||
)
|
||||
) {
|
||||
is ForegroundGenerationResult.Completed -> SyncRequestResult.Completed(result.runId)
|
||||
is ForegroundGenerationResult.Error -> SyncRequestResult.Error(result.message)
|
||||
ForegroundGenerationResult.Busy ->
|
||||
SyncRequestResult.Error("sync_interrupted")
|
||||
is ForegroundGenerationResult.Superseded ->
|
||||
SyncRequestResult.Error("sync_interrupted").also {
|
||||
result.runId?.let { runId ->
|
||||
terminalLedger?.recordTerminal(
|
||||
runId,
|
||||
coordinator.remoteEvidence(directory, runId),
|
||||
)
|
||||
}
|
||||
}
|
||||
is ForegroundGenerationResult.Cancelled ->
|
||||
SyncRequestResult.Error("sync_interrupted")
|
||||
is ForegroundGenerationResult.Failed ->
|
||||
SyncRequestResult.Error(result.message).also {
|
||||
result.runId?.let { runId ->
|
||||
terminalLedger?.recordTerminal(
|
||||
runId,
|
||||
coordinator.remoteEvidence(directory, runId),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
explicitClaim.close()
|
||||
}
|
||||
}
|
||||
|
||||
/* Compatibility tooling keeps the standalone V3 publisher strict and
|
||||
* available, but production Android triggers no longer select this path. */
|
||||
internal suspend fun publishLegacyBundleAndRequest(
|
||||
exporter: SyncExporter,
|
||||
requestOutbox: SyncRequestOutbox,
|
||||
): SyncRequestResult = withContext(Dispatchers.IO) {
|
||||
logDirectExchange("SYNC_BUNDLE", "coordinator.export.begin")
|
||||
when (val opened = exporter.openStorageSnapshot()) {
|
||||
is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Error ->
|
||||
|
|
@ -90,9 +161,7 @@ internal suspend fun publishBundleAndRequest(
|
|||
is com.labfytools.trainlog.data.DirectExchangeSnapshotResult.Ready ->
|
||||
when (val publication = exporter.exportMobileBundle(opened.snapshot)) {
|
||||
SyncExportResult.Unsupported -> SyncRequestResult.Unsupported
|
||||
is SyncExportResult.Error -> SyncRequestResult.Error(
|
||||
"prepare:${publication.message}",
|
||||
)
|
||||
is SyncExportResult.Error -> SyncRequestResult.Error("prepare:${publication.message}")
|
||||
is SyncExportResult.Exported -> {
|
||||
logDirectExchange("SYNC_BUNDLE", "coordinator.export.success")
|
||||
logDirectExchange("trainlog-sync-request-v1.json", "coordinator.request.begin")
|
||||
|
|
@ -138,8 +207,11 @@ fun SyncScreen(
|
|||
add(strings.resources.getQuantityString(R.plurals.catalog_reconciled, reconciled, reconciled))
|
||||
skipped?.let { add(strings.resources.getQuantityString(R.plurals.catalog_present, it, it)) }
|
||||
}.joinToString(", ")
|
||||
fun visibleError(raw: String): String = raw.removePrefix("prepare:").let {
|
||||
if (raw.startsWith("prepare:")) strings.getString(R.string.sync_prepare_error, it) else raw
|
||||
fun visibleError(raw: String): String = when {
|
||||
raw == "sync_interrupted" -> strings.getString(R.string.sync_interrupted)
|
||||
raw.startsWith("prepare:") ->
|
||||
strings.getString(R.string.sync_prepare_error, raw.removePrefix("prepare:"))
|
||||
else -> raw
|
||||
}
|
||||
|
||||
var status by
|
||||
|
|
@ -171,8 +243,20 @@ fun SyncScreen(
|
|||
var syncRunning by remember { mutableStateOf(false) }
|
||||
val backgroundSettings = remember { BackgroundSyncSettings(context) }
|
||||
var backgroundEnabled by remember { mutableStateOf(backgroundSettings.enabled) }
|
||||
val driveSettings = remember { DriveSyncSettings(context) }
|
||||
val driveSettings = remember { AiExportDriveSettings(context) }
|
||||
var driveConnected by remember { mutableStateOf(driveSettings.connected) }
|
||||
val bluetoothSettings = remember { BluetoothSyncSettings(context) }
|
||||
var bluetoothPermission by remember {
|
||||
mutableStateOf(hasBluetoothConnectPermission(context))
|
||||
}
|
||||
var bluetoothDesktop by remember {
|
||||
mutableStateOf(bluetoothSettings.desktopAddress)
|
||||
}
|
||||
var bondedDesktops by remember {
|
||||
mutableStateOf(
|
||||
if (bluetoothPermission) bondedBluetoothDesktops(context) else emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
val driveFolderLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||
|
|
@ -187,6 +271,23 @@ fun SyncScreen(
|
|||
}
|
||||
}
|
||||
|
||||
val bluetoothPermissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
bluetoothPermission = granted || Build.VERSION.SDK_INT < Build.VERSION_CODES.S
|
||||
bondedDesktops =
|
||||
if (bluetoothPermission) bondedBluetoothDesktops(context) else emptyList()
|
||||
if (bluetoothPermission && backgroundSettings.enabled) {
|
||||
SyncBackgroundService.start(context)
|
||||
}
|
||||
status =
|
||||
if (bluetoothPermission) {
|
||||
strings.getString(R.string.bluetooth_sync_permission_granted)
|
||||
} else {
|
||||
strings.getString(R.string.bluetooth_sync_permission_required)
|
||||
}
|
||||
}
|
||||
|
||||
val notificationPermissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
|
|
@ -384,10 +485,27 @@ fun SyncScreen(
|
|||
* published from one prepared repository snapshot before
|
||||
* that signal becomes visible; otherwise the PC can mix a
|
||||
* fresh V3 file with stale or absent causal companions. */
|
||||
/* WHY: Samsung may freeze the activity after USB mode
|
||||
* changes or a permission surface opens. CONTRACT:
|
||||
* announce explicit priority before waking the service.
|
||||
* The service may keep Android alive, but cannot become
|
||||
* conversation owner while this claim is pending. */
|
||||
val explicitClaim = SyncConversationArbiter.process.announceExplicit()
|
||||
if (backgroundSettings.enabled) SyncBackgroundService.start(context)
|
||||
syncRunning = true
|
||||
status = strings.getString(R.string.sync_running)
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
when (val result = publishBundleAndRequest(repository, exporter, requestOutbox)) {
|
||||
when (
|
||||
val result = publishBundleAndRequest(
|
||||
repository,
|
||||
exporter,
|
||||
requestOutbox,
|
||||
AndroidMtpPublicationVisibility(context),
|
||||
explicitClaim,
|
||||
BackgroundSyncRunLedger(context),
|
||||
)
|
||||
) {
|
||||
is SyncRequestResult.Completed -> {
|
||||
// The generation coordinator has already consumed the
|
||||
// desktop return and published its durable ACK. Waiting
|
||||
|
|
@ -411,6 +529,7 @@ fun SyncScreen(
|
|||
}
|
||||
}
|
||||
} finally {
|
||||
explicitClaim.close()
|
||||
syncRunning = false
|
||||
}
|
||||
}
|
||||
|
|
@ -486,6 +605,69 @@ fun SyncScreen(
|
|||
)
|
||||
}
|
||||
|
||||
TrainlogFrame(title = strings.getString(R.string.bluetooth_sync_title)) {
|
||||
TrainlogInfo(
|
||||
text = strings.getString(R.string.bluetooth_sync_description),
|
||||
color =
|
||||
if (bluetoothDesktop != null && bluetoothPermission) colors.success
|
||||
else colors.warning,
|
||||
)
|
||||
if (!bluetoothPermission) {
|
||||
TrainlogAction(
|
||||
label = strings.getString(R.string.bluetooth_sync_permission),
|
||||
description = strings.getString(R.string.bluetooth_sync_permission_required),
|
||||
accent = colors.success,
|
||||
onClick = {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
bluetoothPermissionLauncher.launch(Manifest.permission.BLUETOOTH_CONNECT)
|
||||
} else {
|
||||
bluetoothPermission = true
|
||||
bondedDesktops = bondedBluetoothDesktops(context)
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
if (bondedDesktops.isEmpty()) {
|
||||
TrainlogInfo(
|
||||
text = strings.getString(R.string.bluetooth_sync_no_desktop),
|
||||
color = colors.warning,
|
||||
)
|
||||
}
|
||||
bondedDesktops.forEach { desktop ->
|
||||
TrainlogAction(
|
||||
label = strings.getString(R.string.bluetooth_sync_select, desktop.name),
|
||||
description = strings.getString(R.string.bluetooth_sync_address, desktop.address),
|
||||
accent =
|
||||
if (bluetoothDesktop == desktop.address) colors.success else colors.accent,
|
||||
onClick = {
|
||||
bluetoothSettings.selectDesktop(desktop.address)
|
||||
bluetoothDesktop = desktop.address
|
||||
status =
|
||||
strings.getString(R.string.bluetooth_sync_selected, desktop.name)
|
||||
if (backgroundSettings.enabled) {
|
||||
SyncBackgroundService.start(context)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
if (bluetoothDesktop != null) {
|
||||
TrainlogAction(
|
||||
label = strings.getString(R.string.bluetooth_sync_disconnect),
|
||||
description =
|
||||
strings.getString(
|
||||
R.string.bluetooth_sync_address,
|
||||
bluetoothDesktop ?: "",
|
||||
),
|
||||
accent = colors.warning,
|
||||
onClick = {
|
||||
bluetoothSettings.disconnect()
|
||||
bluetoothDesktop = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TrainlogFrame(title = strings.getString(R.string.background_sync_title)) {
|
||||
TrainlogInfo(
|
||||
text = strings.getString(R.string.background_sync_description),
|
||||
|
|
|
|||
|
|
@ -93,7 +93,6 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
|||
when (val route = navigation.route) {
|
||||
AppRoute.Home -> HomeScreen(
|
||||
activeDraft, draftMessage ?: (draftLoad as? ActiveDraftLoadResult.Error)?.let { localizedRepositoryMessage(localized, it.message) } ?: (draftLoad as? ActiveDraftLoadResult.Loaded)?.warning?.let { localizedRepositoryMessage(localized, it) },
|
||||
repository.listSessions().firstOrNull(), repository.listLatestExerciseMaxima().firstOrNull(),
|
||||
repository.getBodyZoneHomeOverview(),
|
||||
::openManualSession,
|
||||
{ zoneId ->
|
||||
|
|
@ -102,12 +101,33 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
|||
exerciseState.searchQuery.value = ""
|
||||
open(AppRoute.Exercises)
|
||||
},
|
||||
{ open(AppRoute.Statistics) },
|
||||
{ open(AppRoute.BodyMeasurements) }, { open(AppRoute.SessionDetail(it)) }, { open(AppRoute.Sync) },
|
||||
{ open(AppRoute.BodyMeasurements) }, { open(AppRoute.Sync) },
|
||||
)
|
||||
AppRoute.Sessions -> SessionsHub(
|
||||
activeDraft,
|
||||
pendingAiDraftCount,
|
||||
{ open(AppRoute.SessionEditor) },
|
||||
::openManualSession,
|
||||
{ open(AppRoute.AiSessionDrafts) },
|
||||
{ open(AppRoute.CompletedSessions) },
|
||||
{ open(AppRoute.Programs) },
|
||||
{ open(AppRoute.CardioCalibration) },
|
||||
)
|
||||
AppRoute.SessionEditor -> SessionScreen(
|
||||
repository,
|
||||
catalogRevision,
|
||||
{ draftRevision++; back() },
|
||||
{ open(AppRoute.ExerciseCreate(AppRoute.SessionEditor)) },
|
||||
{ editingEntryId ->
|
||||
appState.pendingSessionEquipmentEntryId = editingEntryId
|
||||
open(AppRoute.EquipmentCreate(AppRoute.SessionEditor))
|
||||
},
|
||||
{ exportSnapshot(); draftRevision++ },
|
||||
restoreEditingEntryId = appState.pendingSessionEquipmentEntryId,
|
||||
onEditingContextRestored = {
|
||||
appState.pendingSessionEquipmentEntryId = null
|
||||
},
|
||||
)
|
||||
AppRoute.Sessions -> SessionsHub(activeDraft, pendingAiDraftCount, { open(AppRoute.SessionEditor) }, ::openManualSession,
|
||||
{ open(AppRoute.AiSessionDrafts) }, { open(AppRoute.CompletedSessions) }, { open(AppRoute.Programs) })
|
||||
AppRoute.SessionEditor -> SessionScreen(repository, catalogRevision, { draftRevision++; back() }, { open(AppRoute.ExerciseCreate(AppRoute.SessionEditor)) }, { exportSnapshot(); draftRevision++ })
|
||||
AppRoute.SessionGenerator -> SessionGeneratorScreen(repository, generatorState, { back() }, { generatorState.abandon(); draftRevision++; open(AppRoute.SessionEditor) }, {
|
||||
draftMessage = strings.getString(R.string.existing_draft_warning)
|
||||
draftRevision++
|
||||
|
|
@ -125,6 +145,10 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
|||
)
|
||||
AppRoute.CompletedSessions -> HistoryScreen(repository, { back() }) { open(AppRoute.SessionDetail(it)) }
|
||||
AppRoute.Programs -> ProgramsScreen(repository) { open(AppRoute.ProgramDetail(it)) }
|
||||
AppRoute.CardioCalibration -> CardioCalibrationScreen(repository) {
|
||||
draftRevision++
|
||||
exportSnapshot()
|
||||
}
|
||||
is AppRoute.ProgramDetail -> ProgramDetailScreen(repository, route.programId) {
|
||||
draftRevision++
|
||||
open(AppRoute.SessionEditor)
|
||||
|
|
@ -140,12 +164,51 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
|||
is AppRoute.ExerciseEdit -> ExerciseEditorRoute(repository, exerciseState, route.exerciseId, false) { exportSnapshot(); catalogRevision++; back() }
|
||||
AppRoute.Equipment -> EquipmentScreen(repository, equipmentState, { open(AppRoute.EquipmentCreate(AppRoute.Equipment)) }, { open(AppRoute.EquipmentDetail(it)) })
|
||||
is AppRoute.EquipmentDetail -> EquipmentDetailScreen(repository, route.equipmentId) { open(AppRoute.ExerciseDetail(it)) }
|
||||
is AppRoute.EquipmentCreate -> EquipmentCreateScreen(repository, equipmentState) { exportSnapshot(); catalogRevision++; back() }
|
||||
AppRoute.Statistics -> StatisticsDashboard(repository)
|
||||
is AppRoute.EquipmentCreate -> EquipmentCreateScreen(repository, equipmentState) { equipment ->
|
||||
/* WHY: equipment creation temporarily leaves the active-session editor.
|
||||
* CONTRACT: returning from that catalogue route preserves the durable
|
||||
* form and selects the exact stable ID that was just created.
|
||||
* INVARIANT: no equipment is synthesized; this runs only after the
|
||||
* explicit EquipmentCreateScreen action succeeded. */
|
||||
if (route.caller == AppRoute.SessionEditor) {
|
||||
when (val loaded = repository.loadActiveSessionDraft()) {
|
||||
is ActiveDraftLoadResult.Loaded -> when (
|
||||
val saved = repository.saveActiveSessionDraft(
|
||||
loaded.draft.copy(
|
||||
form = loaded.draft.form.copy(
|
||||
editingExerciseIndex = appState.pendingSessionEquipmentEntryId
|
||||
?.let { entryId ->
|
||||
loaded.draft.exercises.indexOfFirst {
|
||||
it.entryId == entryId
|
||||
}.takeIf { it >= 0 }
|
||||
},
|
||||
editingEntryId = appState.pendingSessionEquipmentEntryId,
|
||||
selectedEquipmentId = equipment.equipmentId,
|
||||
),
|
||||
),
|
||||
)
|
||||
) {
|
||||
ActiveDraftMutationResult.Saved -> draftRevision++
|
||||
is ActiveDraftMutationResult.Error -> {
|
||||
draftMessage = localizedRepositoryMessage(localized, saved.message)
|
||||
}
|
||||
}
|
||||
is ActiveDraftLoadResult.Error -> {
|
||||
draftMessage = localizedRepositoryMessage(localized, loaded.message)
|
||||
}
|
||||
ActiveDraftLoadResult.None -> Unit
|
||||
}
|
||||
}
|
||||
exportSnapshot()
|
||||
catalogRevision++
|
||||
back()
|
||||
}
|
||||
AppRoute.BodyMeasurements -> BodyScreen(repository, bodyState, { exportSnapshot() }, { back() })
|
||||
AppRoute.SleepDiary -> SleepDiaryScreen(repository)
|
||||
AppRoute.LatestMaxima -> LatestMaximaScreen(repository)
|
||||
AppRoute.Sync -> SyncScreen(repository, inbox, exporter, requestOutbox, { exportSnapshot(); catalogRevision++ }, { back() })
|
||||
AppRoute.Settings -> SettingsScreen(repository, inbox) { exportSnapshot(); catalogRevision++ }
|
||||
AppRoute.Settings -> SettingsScreen(repository, inbox, { open(AppRoute.HeartRateDiagnostics) }) { exportSnapshot(); catalogRevision++ }
|
||||
AppRoute.HeartRateDiagnostics -> HeartRateDiagnosticsScreen { back() }
|
||||
}}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
|
|
@ -37,6 +38,7 @@ import androidx.compose.material3.Icon
|
|||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
|
|
@ -164,7 +166,7 @@ fun TrainlogAction(
|
|||
.padding(vertical = 3.dp)
|
||||
.heightIn(min = 48.dp)
|
||||
.height(IntrinsicSize.Min)
|
||||
.background(Color.Transparent)
|
||||
.background(colors.surface, MaterialTheme.shapes.medium)
|
||||
.clickable(onClick = onClick)
|
||||
) {
|
||||
Column(
|
||||
|
|
@ -199,6 +201,46 @@ fun TrainlogAction(
|
|||
}
|
||||
}
|
||||
|
||||
/** Presentation-only surface for dense object summaries and repeated rows. */
|
||||
@Composable
|
||||
fun TrainlogCompactCard(
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: (() -> Unit)? = null,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
val interaction = if (onClick == null) Modifier else Modifier.clickable(onClick = onClick)
|
||||
Column(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.background(colors.surface, MaterialTheme.shapes.medium)
|
||||
.then(interaction)
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
/** Small non-interactive metadata token; business state remains caller-owned. */
|
||||
@Composable
|
||||
fun TrainlogMetaBadge(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
accent: Color? = null,
|
||||
) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
Text(
|
||||
text = text,
|
||||
modifier = modifier.background(colors.surfaceAlt, MaterialTheme.shapes.small)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
color = accent ?: colors.text,
|
||||
style = TrainlogTypography.small,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
|
||||
enum class TrainlogButtonStyle { PRIMARY, SECONDARY, SUCCESS, DESTRUCTIVE, GHOST }
|
||||
|
||||
/** Compact pictogram action with a mandatory accessible name. */
|
||||
@Composable
|
||||
fun TrainlogIconAction(
|
||||
|
|
@ -225,11 +267,13 @@ fun TrainlogDeleteButton(
|
|||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
fullWidth: Boolean = true,
|
||||
) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
Button(
|
||||
onClick = onClick,
|
||||
modifier = modifier.fillMaxWidth().heightIn(min = 52.dp),
|
||||
modifier = (if (fullWidth) modifier.fillMaxWidth() else modifier).heightIn(min = 48.dp),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = colors.error),
|
||||
) {
|
||||
Icon(TrainlogIcons.DeleteOutline, contentDescription = contentDescription)
|
||||
|
|
@ -240,9 +284,7 @@ fun TrainlogDeleteButton(
|
|||
fun TrainlogPrimaryAction(label: String, description: String, onClick: () -> Unit) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
Column(Modifier.fillMaxWidth().padding(bottom = 12.dp)) {
|
||||
Button(onClick = onClick, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) {
|
||||
Text(label)
|
||||
}
|
||||
TrainlogButton(label, onClick, Modifier.fillMaxWidth())
|
||||
if (description.isNotBlank()) {
|
||||
BasicText(
|
||||
description,
|
||||
|
|
@ -270,7 +312,7 @@ fun TrainlogActionTile(
|
|||
Column(
|
||||
modifier
|
||||
.heightIn(min = 112.dp)
|
||||
.background(colors.surface, RoundedCornerShape(12.dp))
|
||||
.background(colors.surface, MaterialTheme.shapes.medium)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
|
|
@ -291,13 +333,33 @@ fun TrainlogButton(
|
|||
containerColor: Color? = null,
|
||||
maxLines: Int = 1,
|
||||
enabled: Boolean = true,
|
||||
style: TrainlogButtonStyle = TrainlogButtonStyle.PRIMARY,
|
||||
) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
val resolvedContainer = containerColor ?: when (style) {
|
||||
TrainlogButtonStyle.PRIMARY -> colors.accent
|
||||
TrainlogButtonStyle.SECONDARY -> colors.surfaceAlt
|
||||
TrainlogButtonStyle.SUCCESS -> colors.success
|
||||
TrainlogButtonStyle.DESTRUCTIVE -> colors.error
|
||||
TrainlogButtonStyle.GHOST -> Color.Transparent
|
||||
}
|
||||
val resolvedContent = when (style) {
|
||||
TrainlogButtonStyle.PRIMARY, TrainlogButtonStyle.SUCCESS,
|
||||
TrainlogButtonStyle.DESTRUCTIVE -> colors.crust
|
||||
TrainlogButtonStyle.SECONDARY, TrainlogButtonStyle.GHOST -> colors.text
|
||||
}
|
||||
Button(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = modifier.heightIn(min = 52.dp),
|
||||
colors = containerColor?.let { ButtonDefaults.buttonColors(containerColor = it) }
|
||||
?: ButtonDefaults.buttonColors(),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
border = if (style == TrainlogButtonStyle.GHOST) BorderStroke(1.dp, colors.surfaceAlt) else null,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = resolvedContainer,
|
||||
contentColor = resolvedContent,
|
||||
disabledContainerColor = colors.surfaceAlt,
|
||||
disabledContentColor = colors.muted,
|
||||
),
|
||||
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp),
|
||||
) {
|
||||
icon?.let {
|
||||
|
|
@ -334,12 +396,15 @@ fun TrainlogInputField(
|
|||
label: String,
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
keyboardOptions: KeyboardOptions =
|
||||
KeyboardOptions.Default,
|
||||
testTag: String? = null,
|
||||
singleLine: Boolean = true,
|
||||
minLines: Int = 1,
|
||||
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
|
||||
suffix: String? = null,
|
||||
compact: Boolean = false,
|
||||
) {
|
||||
val colors =
|
||||
LocalTrainlogColors.current
|
||||
|
|
@ -350,8 +415,7 @@ fun TrainlogInputField(
|
|||
}
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.padding(bottom = 10.dp)
|
||||
modifier = modifier.padding(bottom = 10.dp)
|
||||
) {
|
||||
BasicText(
|
||||
text = label.uppercase(),
|
||||
|
|
@ -395,7 +459,7 @@ fun TrainlogInputField(
|
|||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 48.dp)
|
||||
.heightIn(min = if (compact) 44.dp else 48.dp)
|
||||
.onFocusChanged {
|
||||
focused = it.isFocused
|
||||
}
|
||||
|
|
@ -406,11 +470,22 @@ fun TrainlogInputField(
|
|||
Modifier.testTag(testTag)
|
||||
}
|
||||
)
|
||||
.background(colors.surface)
|
||||
.background(colors.surface, MaterialTheme.shapes.medium)
|
||||
.padding(
|
||||
horizontal = 12.dp,
|
||||
vertical = 10.dp,
|
||||
horizontal = if (compact) 10.dp else 12.dp,
|
||||
vertical = if (compact) 8.dp else 10.dp,
|
||||
),
|
||||
decorationBox = { innerTextField ->
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Box(Modifier.weight(1f)) { innerTextField() }
|
||||
suffix?.let {
|
||||
BasicText(it, style = TrainlogTypography.small.copy(color = colors.muted))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Box(
|
||||
|
|
|
|||
|
|
@ -9,12 +9,15 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Shapes
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
data class TrainlogColors(
|
||||
val background: Color,
|
||||
|
|
@ -110,6 +113,19 @@ fun TrainlogTheme(
|
|||
error = TrainlogDarkColors.error,
|
||||
)
|
||||
CompositionLocalProvider(LocalTrainlogColors provides TrainlogDarkColors) {
|
||||
MaterialTheme(colorScheme = scheme, content = content)
|
||||
/* INVARIANT: all Material controls share the same restrained corner
|
||||
* language. Screen code may change size, but must not reintroduce the
|
||||
* former pill-shaped action hierarchy. */
|
||||
MaterialTheme(
|
||||
colorScheme = scheme,
|
||||
shapes = Shapes(
|
||||
extraSmall = RoundedCornerShape(6.dp),
|
||||
small = RoundedCornerShape(8.dp),
|
||||
medium = RoundedCornerShape(10.dp),
|
||||
large = RoundedCornerShape(12.dp),
|
||||
extraLarge = RoundedCornerShape(14.dp),
|
||||
),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,31 @@
|
|||
<string name="nav_statistics">Statistics</string>
|
||||
<string name="nav_sync">Synchronization</string>
|
||||
<string name="nav_settings">Settings</string>
|
||||
<string name="nav_sleep">Sleep</string>
|
||||
<string name="sleep_current_night">Current night</string>
|
||||
<string name="sleep_morning">Morning</string>
|
||||
<string name="sleep_day">Day</string>
|
||||
<string name="sleep_bed_time">Bedtime</string>
|
||||
<string name="sleep_final_get_up">Final get-up</string>
|
||||
<string name="sleep_night_get_up">Night get-up</string>
|
||||
<string name="sleep_sleep">Sleep</string>
|
||||
<string name="sleep_nap">Nap</string>
|
||||
<string name="sleep_long_awake">Long awakening</string>
|
||||
<string name="sleep_half_sleep">Half-sleep</string>
|
||||
<string name="sleep_sleepiness">Sleepiness</string>
|
||||
<string name="sleep_quality">Sleep quality</string>
|
||||
<string name="sleep_wake_quality">Wake quality</string>
|
||||
<string name="sleep_day_form">Day form</string>
|
||||
<string name="sleep_notes">Treatment and notes</string>
|
||||
<string name="sleep_add_event">Add event</string>
|
||||
<string name="sleep_save">Save</string>
|
||||
<string name="sleep_validate">Validate day</string>
|
||||
<string name="sleep_status_draft">Draft</string>
|
||||
<string name="sleep_status_ready">Ready to sync</string>
|
||||
<string name="sleep_status_synchronized">Synchronized</string>
|
||||
<string name="sleep_status_modified">Modified since synchronization</string>
|
||||
<string name="sleep_delete">Delete</string>
|
||||
<string name="sleep_choose_time">Choose time</string>
|
||||
<string name="route_session_editor">Current session</string>
|
||||
<string name="route_session_generator">Plan a session</string>
|
||||
<string name="route_ai_drafts">Drafts</string>
|
||||
|
|
@ -94,6 +119,7 @@
|
|||
<string name="sync_action">Synchronize</string>
|
||||
<string name="sync_snapshot_automatic">The Android snapshot is maintained automatically.</string>
|
||||
<string name="sync_running">Synchronization in progress…</string>
|
||||
<string name="sync_interrupted">Synchronization interrupted. You can start a new attempt.</string>
|
||||
<string name="sync_now">Synchronize now</string>
|
||||
<string name="background_sync_title">Background synchronization</string>
|
||||
<string name="background_sync_description">Allows Trainlog to receive PC synchronizations without opening the app.</string>
|
||||
|
|
@ -334,7 +360,8 @@
|
|||
<plurals name="repetition_count"><item quantity="one">%d repetition</item><item quantity="other">%d repetitions</item></plurals>
|
||||
<string name="start">Start</string><string name="start_proposal_description">Copy this proposal into the current session.</string>
|
||||
<string name="manual_preparation">Manual preparation</string>
|
||||
<string name="start_preparation_description">Start this preparation without replacing an active session.</string>
|
||||
<string name="start_preparation_description">Creates a protected active session.</string>
|
||||
<string name="draft_rest_short">rest %1$d s</string>
|
||||
<string name="draft_already_active">A session is already in progress. The imported draft was preserved.</string>
|
||||
<string name="delete_draft_detail">Draft “%1$s” will be deleted.\nThis action cannot be undone.</string>
|
||||
<string name="training">Training</string><string name="resume_current_session">Resume current session</string>
|
||||
|
|
@ -390,7 +417,7 @@
|
|||
<string name="regenerate_description">Run again with the same inputs and reference instant.</string><string name="accept_actual_description">Create the normal draft without prefilling performed sets.</string><string name="acknowledge_exposure_first">Confirm the recent-exposure warning first.</string>
|
||||
<string name="reason_observed_dose">observed repeated dose</string><string name="reason_max_no_prescription">observed maximum without numeric prescription</string><string name="reason_user_max">user-selected MAX percentage</string><string name="reason_max_unavailable">compatible MAX unavailable</string><string name="reason_assistance_omitted">assistance load omitted</string><string name="reason_load_absent">numeric load absent</string><string name="reason_manual_load">manually entered load</string><string name="reason_preferred">preferred exercise</string><string name="reason_primary_zone">requested primary zone</string><string name="reason_secondary_zone">requested secondary zone</string><string name="reason_distinct_movement">distinct movement</string><string name="reason_recent_exercise">same exercise observed recently</string><string name="reason_recent_pattern">similar movement observed recently</string>
|
||||
<string name="shortage_upper">missing upper region</string><string name="shortage_lower">missing lower region</string><string name="shortage_core">missing core</string><string name="shortage_push">missing upper-body push</string><string name="shortage_pull">missing upper-body pull</string><string name="shortage_lower_extension">missing lower-body extension</string><string name="shortage_lower_flexion">missing lower-body flexion</string><string name="shortage_exercises">fewer distinct exercises available</string>
|
||||
<string name="create_equipment">Create equipment</string><string name="create_personal_machine">Add a personal machine.</string><string name="search_equipment">Search by name, label, or alias</string>
|
||||
<string name="create_equipment">Create equipment</string><string name="create_personal_machine">Add a personal machine.</string><string name="create_equipment_from_session">Create it in the catalogue, then return to this exercise.</string><string name="search_equipment">Search by name, label, or alias</string>
|
||||
<string name="unknown_reference">Unknown reference · %1$s</string><string name="definition">Definition</string><string name="personal">Personal</string><string name="provided">Provided</string><string name="label_value">Label: %1$s</string>
|
||||
<string name="confidence_value">%1$s confidence</string><string name="verified_sources">Verified sources: %1$d</string><string name="personal_equipment">Personal equipment</string><string name="create">Create</string>
|
||||
<string name="create_machine_description">Create a personal machine with the current semantics.</string><string name="equipment_name_invalid">Enter a name of 1 to 120 characters.</string><string name="equipment_name_exists">Equipment already has this name.</string><string name="creation_failed">Creation failed: %1$s.</string>
|
||||
|
|
@ -446,4 +473,148 @@
|
|||
<string name="settings_backup_complete">Complete backup exported.</string>
|
||||
<string name="settings_backup_failed">Backup failed; any private staged copy was retained.</string>
|
||||
<string name="settings_restore_failed">The backup is invalid or could not be restored.</string>
|
||||
<string name="sleep_medications">Medications</string>
|
||||
<string name="sleep_add_medication">Add medication</string>
|
||||
<string name="sleep_add_intake">Add intake</string>
|
||||
<string name="sleep_dose">Dose</string>
|
||||
<string name="sleep_unit">Unit</string>
|
||||
<string name="sleep_deactivate">Deactivate</string>
|
||||
<string name="sleep_edit">Edit</string>
|
||||
<string name="yes">Yes</string>
|
||||
<string name="no">No</string>
|
||||
<string name="route_heart_rate_diagnostics">BLE heart-rate diagnostics</string>
|
||||
<string name="settings_cardio_section">Cardio</string>
|
||||
<string name="settings_cardio_diagnostics">Heart-rate sensor diagnostics</string>
|
||||
<string name="settings_cardio_diagnostics_description">Find and verify a Bluetooth Heart Rate Service sensor.</string>
|
||||
<string name="hr_diag_status">Sensor status</string>
|
||||
<string name="hr_diag_idle">Ready</string>
|
||||
<string name="hr_diag_scanning">Scanning…</string>
|
||||
<string name="hr_diag_connecting">Connecting…</string>
|
||||
<string name="hr_diag_discovering">Discovering services…</string>
|
||||
<string name="hr_diag_subscribing">Enabling measurements…</string>
|
||||
<string name="hr_diag_receiving">Measurements received</string>
|
||||
<string name="hr_diag_disconnected">Disconnected</string>
|
||||
<string name="hr_diag_error">BLE error</string>
|
||||
<string name="hr_diag_permission_denied">Bluetooth permission denied.</string>
|
||||
<string name="hr_diag_technical">Diagnostic: %1$s</string>
|
||||
<string name="hr_diag_services">Heart Rate Service: %1$s · measurement: %2$s · battery: %3$s</string>
|
||||
<string name="hr_diag_bpm">%1$d BPM</string>
|
||||
<string name="hr_diag_measurement_meta">%1$d RR · contact %2$s · energy %3$s</string>
|
||||
<string name="hr_diag_notifications">Notifications received: %1$d</string>
|
||||
<string name="hr_diag_scan_section">BLE scan</string>
|
||||
<string name="hr_diag_scan">Find heart-rate</string>
|
||||
<string name="hr_diag_scan_all">Expanded scan</string>
|
||||
<string name="hr_diag_scan_description">Heart-rate scan lasts at most 10 seconds. Expanded scan is for sensors that do not advertise their service in BLE advertisements.</string>
|
||||
<string name="hr_diag_devices">BLE devices</string>
|
||||
<string name="hr_diag_no_devices">No device found yet.</string>
|
||||
<string name="hr_diag_unnamed_device">Unnamed device</string>
|
||||
<string name="hr_diag_hrs_advertised">Heart Rate advertised</string>
|
||||
<string name="hr_diag_hrs_not_advertised">Heart Rate not advertised</string>
|
||||
<string name="hr_diag_device_description">%1$s · %2$d dBm · %3$s</string>
|
||||
<string name="hr_diag_use_sensor">Use this sensor</string>
|
||||
<string name="heart_rate_service_channel">Heart-rate sensor</string>
|
||||
<string name="heart_rate_service_title">Trainlog · cardio</string>
|
||||
<string name="heart_rate_service_connecting">Connecting to sensor…</string>
|
||||
<string name="heart_rate_service_reconnecting">Waiting for heart-rate signal…</string>
|
||||
<string name="heart_rate_service_disconnected">Heart-rate sensor disconnected</string>
|
||||
<string name="heart_rate_service_bpm">%1$d BPM</string>
|
||||
<string name="heart_rate_a11y_connected">Heart rate %1$d BPM, sensor connected</string>
|
||||
<string name="heart_rate_a11y_connecting">Heart-rate sensor connecting</string>
|
||||
<string name="heart_rate_a11y_reconnecting">Heart-rate signal temporarily lost, reconnecting</string>
|
||||
<string name="heart_rate_a11y_disconnected">Heart-rate sensor disconnected</string>
|
||||
<string name="settings_cardio_configured">Heart-rate sensor configured.</string>
|
||||
<string name="settings_cardio_forget">Forget sensor</string>
|
||||
<string name="settings_cardio_forget_description">Stop heart-rate connection and remove the remembered sensor.</string>
|
||||
<string name="exercise_timeline_start">Start exercise</string>
|
||||
<string name="exercise_timeline_finish">Finish exercise</string>
|
||||
<string name="exercise_timeline_completed">Exercise completed</string>
|
||||
<string name="exercise_timeline_started">%1$s · started</string>
|
||||
<string name="exercise_timeline_finished">%1$s · finished</string>
|
||||
<string name="exercise_timeline_switch_title">Switch exercise?</string>
|
||||
<string name="exercise_timeline_switch_detail">%1$s is still active. Finish it now and then start %2$s?</string>
|
||||
<string name="exercise_timeline_switch_confirm">Finish and start</string>
|
||||
<string name="exercise_timeline_finalize_title">An exercise is still active</string>
|
||||
<string name="exercise_timeline_finalize_detail">%1$s is still active. Finish it now and then save the session?</string>
|
||||
<string name="exercise_timeline_finalize_confirm">Finish and save</string>
|
||||
|
||||
<string name="sleep_night_capture_title">Night capture</string>
|
||||
<string name="sleep_no_night_active">No night in progress.</string>
|
||||
<string name="sleep_night_in_progress">Night in progress · %1$s → %2$s</string>
|
||||
<string name="sleep_quick_bed">🛏 BED</string>
|
||||
<string name="sleep_quick_wake">👁 WAKE</string>
|
||||
<string name="sleep_quick_get_up">☀ GET UP</string>
|
||||
<string name="sleep_bed_recorded">Bed time recorded.</string>
|
||||
<string name="sleep_wake_recorded">30-minute awakening recorded.</string>
|
||||
<string name="sleep_get_up_recorded">Get-up recorded.</string>
|
||||
<string name="sleep_medication_recorded">%1$s recorded.</string>
|
||||
<string name="sleep_undo">UNDO</string>
|
||||
<string name="sleep_action_undone">Last action undone.</string>
|
||||
<string name="sleep_undo_unavailable">Undo unavailable: the night has already changed.</string>
|
||||
<string name="sleep_correct_night">Correct the night</string>
|
||||
<string name="sleep_return_night_mode">Back to night capture</string>
|
||||
<string name="sleep_night_timeline">Night timeline</string>
|
||||
|
||||
<string name="sleep_quick_no_active">No night in progress.</string>
|
||||
<string name="sleep_quick_already_active">A night is already in progress.</string>
|
||||
<string name="sleep_quick_medication_unavailable">Medication unavailable.</string>
|
||||
<string name="sleep_quick_conflict">The night changed; reload before trying again.</string>
|
||||
<string name="sleep_quick_error">Unable to record the action.</string>
|
||||
<string name="session_cardio">Cardio</string>
|
||||
<string name="route_cardio_calibration">Cardio calibration</string>
|
||||
<string name="calibration_explanation">This session measures your current heart-rate response. Effort increases progressively and you can stop immediately at any time. The result is stored as an observed heart-rate peak, not as a medical maximum heart rate.</string>
|
||||
<string name="calibration_start">Start calibration</string>
|
||||
<string name="calibration_start_description">Create a dedicated cardio session, start heart-rate capture and begin with warm-up.</string>
|
||||
<string name="calibration_latest">Latest calibration</string>
|
||||
<string name="calibration_current">Calibration in progress</string>
|
||||
<string name="calibration_phase_value">Phase: %1$s</string>
|
||||
<string name="calibration_phase_warmup">Warm-up</string>
|
||||
<string name="calibration_phase_progressive">Progressive effort</string>
|
||||
<string name="calibration_phase_high_effort">High effort</string>
|
||||
<string name="calibration_phase_recovery">Recovery</string>
|
||||
<string name="calibration_phase_completed">Completed</string>
|
||||
<string name="calibration_phase_aborted">Stopped</string>
|
||||
<string name="calibration_peak_value">Observed peak: %1$d BPM</string>
|
||||
<string name="calibration_recovery_value">Recovery +%1$d min: %2$d BPM</string>
|
||||
<string name="calibration_to_progressive">Start progressive effort</string>
|
||||
<string name="calibration_progressive_description">Increase effort progressively without trying to beat a previous value.</string>
|
||||
<string name="calibration_to_high_effort">Move to high effort</string>
|
||||
<string name="calibration_high_effort_description">Continue only while you choose to tolerate the effort. The next button ends the effort phase immediately.</string>
|
||||
<string name="calibration_end_effort">Stop effort</string>
|
||||
<string name="calibration_end_effort_description">Mark the end of effort and begin measured recovery.</string>
|
||||
<string name="calibration_finish">Finish calibration</string>
|
||||
<string name="calibration_finish_description">Close recovery and save the calibration profile.</string>
|
||||
<string name="calibration_abort">Stop calibration</string>
|
||||
<string name="calibration_conflict">Another session or calibration is already active.</string>
|
||||
<string name="cardio_guidance_title">Cardio guidance</string>
|
||||
<string name="cardio_guidance_phase_value">Phase: %1$s</string>
|
||||
<string name="cardio_guidance_target_value">Target: %1$d–%2$d BPM</string>
|
||||
<string name="cardio_guidance_calibration_value">Calibration: observed peak %1$d BPM · %2$d–%3$d %%</string>
|
||||
<string name="cardio_guidance_latest_calibration">Latest calibration: observed peak %1$d BPM</string>
|
||||
<string name="cardio_guidance_resolved_target">Resolved target: %1$d–%2$d BPM</string>
|
||||
<string name="cardio_guidance_accelerate">SPEED UP</string>
|
||||
<string name="cardio_guidance_maintain">MAINTAIN</string>
|
||||
<string name="cardio_guidance_slow_down">SLOW DOWN</string>
|
||||
<string name="cardio_guidance_suspended">HEART-RATE SIGNAL UNAVAILABLE</string>
|
||||
<string name="cardio_guidance_bpm_value">%1$d BPM</string>
|
||||
<string name="cardio_guidance_bpm_missing">-- BPM</string>
|
||||
<string name="cardio_guidance_start_phase">Start phase</string>
|
||||
<string name="cardio_guidance_finish_phase">End phase</string>
|
||||
<string name="cardio_guidance_invalid_configuration">Complete the target and exit condition.</string>
|
||||
<string name="cardio_guidance_start_failed">Unable to start this phase.</string>
|
||||
<string name="cardio_phase_warmup">Warm-up</string>
|
||||
<string name="cardio_phase_work">Work</string>
|
||||
<string name="cardio_phase_recovery">Recovery</string>
|
||||
<string name="cardio_phase_cooldown">Cool-down</string>
|
||||
<string name="cardio_target_absolute">BPM</string>
|
||||
<string name="cardio_target_calibration">Calibration %%</string>
|
||||
<string name="cardio_target_min_bpm">Min BPM</string>
|
||||
<string name="cardio_target_max_bpm">Max BPM</string>
|
||||
<string name="cardio_target_min_percent">Min %%</string>
|
||||
<string name="cardio_target_max_percent">Max %%</string>
|
||||
<string name="cardio_exit_duration">Duration</string>
|
||||
<string name="cardio_exit_target">Enter target</string>
|
||||
<string name="cardio_exit_recover">Below threshold</string>
|
||||
<string name="cardio_exit_bounded">Duration or threshold</string>
|
||||
<string name="cardio_exit_seconds">Max duration</string>
|
||||
<string name="cardio_exit_recovery_bpm">Recovery threshold</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,31 @@
|
|||
<string name="nav_statistics">Statistiques</string>
|
||||
<string name="nav_sync">Synchronisation</string>
|
||||
<string name="nav_settings">Paramètres</string>
|
||||
<string name="nav_sleep">Sommeil</string>
|
||||
<string name="sleep_current_night">Nuit actuelle</string>
|
||||
<string name="sleep_morning">Matin</string>
|
||||
<string name="sleep_day">Journée</string>
|
||||
<string name="sleep_bed_time">Mise au lit</string>
|
||||
<string name="sleep_final_get_up">Lever</string>
|
||||
<string name="sleep_night_get_up">Lever nocturne</string>
|
||||
<string name="sleep_sleep">Sommeil</string>
|
||||
<string name="sleep_nap">Sieste</string>
|
||||
<string name="sleep_long_awake">Long réveil</string>
|
||||
<string name="sleep_half_sleep">Demi-sommeil</string>
|
||||
<string name="sleep_sleepiness">Somnolence</string>
|
||||
<string name="sleep_quality">Qualité du sommeil</string>
|
||||
<string name="sleep_wake_quality">Qualité du réveil</string>
|
||||
<string name="sleep_day_form">Forme de la journée</string>
|
||||
<string name="sleep_notes">Traitement et remarques</string>
|
||||
<string name="sleep_add_event">Ajouter un événement</string>
|
||||
<string name="sleep_save">Enregistrer</string>
|
||||
<string name="sleep_validate">Valider la journée</string>
|
||||
<string name="sleep_status_draft">Brouillon</string>
|
||||
<string name="sleep_status_ready">Prête à synchroniser</string>
|
||||
<string name="sleep_status_synchronized">Synchronisée</string>
|
||||
<string name="sleep_status_modified">Modifiée depuis la synchronisation</string>
|
||||
<string name="sleep_delete">Supprimer</string>
|
||||
<string name="sleep_choose_time">Choisir l’heure</string>
|
||||
<string name="route_session_editor">Séance en cours</string>
|
||||
<string name="route_session_generator">Programmer une séance</string>
|
||||
<string name="route_ai_drafts">Brouillons</string>
|
||||
|
|
@ -94,6 +119,7 @@
|
|||
<string name="sync_action">Synchroniser</string>
|
||||
<string name="sync_snapshot_automatic">Le snapshot Android est maintenu automatiquement.</string>
|
||||
<string name="sync_running">Synchronisation en cours…</string>
|
||||
<string name="sync_interrupted">Synchronisation interrompue. Une nouvelle tentative peut être lancée.</string>
|
||||
<string name="sync_now">Synchroniser maintenant</string>
|
||||
<string name="background_sync_title">Synchronisation en arrière-plan</string>
|
||||
<string name="background_sync_description">Permet à Trainlog de recevoir les synchronisations du PC sans ouvrir l’application.</string>
|
||||
|
|
@ -103,13 +129,23 @@
|
|||
<string name="background_sync_notification_title">Trainlog est prêt à synchroniser</string>
|
||||
<string name="background_sync_notification_text">Le service attend une demande du PC.</string>
|
||||
<string name="background_sync_notification_required">Autorisez les notifications pour activer la synchronisation en arrière-plan.</string>
|
||||
<string name="drive_sync_title">Google Drive privé</string>
|
||||
<string name="drive_sync_description">Sélectionnez le dossier privé Trainlog/Sync/v1. Trainlog n’accède qu’à ce dossier et n’y copie jamais SQLite.</string>
|
||||
<string name="drive_sync_connect">Connecter Google Drive</string>
|
||||
<string name="drive_sync_disconnect">Déconnecter Google Drive</string>
|
||||
<string name="drive_sync_latency">La vérification en arrière-plan est bornée par Android et peut prendre au moins 15 minutes.</string>
|
||||
<string name="drive_sync_connected">Dossier Drive connecté. Une vérification est planifiée.</string>
|
||||
<string name="drive_sync_connection_failed">Impossible de conserver l’accès au dossier Drive.</string>
|
||||
<string name="bluetooth_sync_title">Bluetooth automatique</string>
|
||||
<string name="bluetooth_sync_description">Trainlog se connecte automatiquement au PC appairé dès qu’il est à portée. MTP reste disponible en secours.</string>
|
||||
<string name="bluetooth_sync_permission">Autoriser l’accès Bluetooth</string>
|
||||
<string name="bluetooth_sync_permission_required">Autorisation Bluetooth requise pour la connexion automatique.</string>
|
||||
<string name="bluetooth_sync_permission_granted">Bluetooth autorisé. Sélectionnez le PC appairé.</string>
|
||||
<string name="bluetooth_sync_no_desktop">Aucun ordinateur appairé détecté.</string>
|
||||
<string name="bluetooth_sync_select">Utiliser %1$s</string>
|
||||
<string name="bluetooth_sync_selected">PC Bluetooth sélectionné : %1$s.</string>
|
||||
<string name="bluetooth_sync_disconnect">Oublier le PC Bluetooth</string>
|
||||
<string name="bluetooth_sync_address">%1$s</string>
|
||||
<string name="drive_sync_title">Export IA Google Drive</string>
|
||||
<string name="drive_sync_description">Sélectionnez une fois le dossier Trainlog/AI. Après chaque synchronisation, Android y publie automatiquement trainlog_ai_export_v1.json.</string>
|
||||
<string name="drive_sync_connect">Choisir Trainlog/AI</string>
|
||||
<string name="drive_sync_disconnect">Déconnecter l’export IA Drive</string>
|
||||
<string name="drive_sync_latency">L’échec de Drive ne bloque jamais la synchronisation. Android conserve l’export local et réessaie automatiquement.</string>
|
||||
<string name="drive_sync_connected">Dossier Trainlog/AI connecté. L’export sera publié automatiquement.</string>
|
||||
<string name="drive_sync_connection_failed">Impossible de conserver l’accès au dossier Trainlog/AI.</string>
|
||||
<string name="sync_flow_description">Android → PC puis PC → Android, en une seule opération.</string>
|
||||
<string name="sync_authorize_first">Accès fichiers requis. Autorisez d’abord Documents/Trainlog.</string>
|
||||
<string name="sync_request_sent">Demande envoyée · attente du PC…</string>
|
||||
|
|
@ -334,7 +370,8 @@
|
|||
<plurals name="repetition_count"><item quantity="one">%d répétition</item><item quantity="other">%d répétitions</item></plurals>
|
||||
<string name="start">Démarrer</string><string name="start_proposal_description">Copier cette proposition dans la séance en cours.</string>
|
||||
<string name="manual_preparation">Préparation manuelle</string>
|
||||
<string name="start_preparation_description">Démarrer cette préparation sans écraser une séance en cours.</string>
|
||||
<string name="start_preparation_description">Crée une séance en cours protégée.</string>
|
||||
<string name="draft_rest_short">repos %1$d s</string>
|
||||
<string name="draft_already_active">Une séance est déjà en cours. Le brouillon importé a été conservé.</string>
|
||||
<string name="delete_draft_detail">Le brouillon « %1$s » sera supprimé.\nCette action est irréversible.</string>
|
||||
<string name="training">Entraînement</string><string name="resume_current_session">Reprendre la séance en cours</string>
|
||||
|
|
@ -390,7 +427,7 @@
|
|||
<string name="regenerate_description">Relancer les mêmes entrées et le même instant de référence.</string><string name="accept_actual_description">Créer le brouillon normal sans préremplir les séries réalisées.</string><string name="acknowledge_exposure_first">Confirmez d’abord l’avertissement d’exposition récente.</string>
|
||||
<string name="reason_observed_dose">dose répétée observée</string><string name="reason_max_no_prescription">maximum observé sans prescription numérique</string><string name="reason_user_max">pourcentage de MAX choisi par l’utilisateur</string><string name="reason_max_unavailable">MAX compatible indisponible</string><string name="reason_assistance_omitted">charge d’assistance omise</string><string name="reason_load_absent">charge numérique absente</string><string name="reason_manual_load">charge saisie manuellement</string><string name="reason_preferred">exercice préféré</string><string name="reason_primary_zone">zone principale demandée</string><string name="reason_secondary_zone">zone secondaire demandée</string><string name="reason_distinct_movement">mouvement distinct</string><string name="reason_recent_exercise">même exercice observé récemment</string><string name="reason_recent_pattern">mouvement similaire observé récemment</string>
|
||||
<string name="shortage_upper">région supérieure manquante</string><string name="shortage_lower">région inférieure manquante</string><string name="shortage_core">tronc manquant</string><string name="shortage_push">poussée du haut du corps manquante</string><string name="shortage_pull">tirage du haut du corps manquant</string><string name="shortage_lower_extension">extension du bas du corps manquante</string><string name="shortage_lower_flexion">flexion du bas du corps manquante</string><string name="shortage_exercises">moins d’exercices distincts disponibles</string>
|
||||
<string name="create_equipment">Créer un équipement</string><string name="create_personal_machine">Ajouter une machine personnelle.</string><string name="search_equipment">Rechercher par nom, étiquette ou alias</string>
|
||||
<string name="create_equipment">Créer un équipement</string><string name="create_personal_machine">Ajouter une machine personnelle.</string><string name="create_equipment_from_session">Créer dans le catalogue puis revenir à cet exercice.</string><string name="search_equipment">Rechercher par nom, étiquette ou alias</string>
|
||||
<string name="unknown_reference">Référence inconnue · %1$s</string><string name="definition">Définition</string><string name="personal">Personnel</string><string name="provided">Fourni</string><string name="label_value">Étiquette : %1$s</string>
|
||||
<string name="confidence_value">Confiance %1$s</string><string name="verified_sources">Sources vérifiées : %1$d</string><string name="personal_equipment">Équipement personnel</string><string name="create">Créer</string>
|
||||
<string name="create_machine_description">Créer une machine personnelle avec la sémantique actuelle.</string><string name="equipment_name_invalid">Saisissez un nom de 1 à 120 caractères.</string><string name="equipment_name_exists">Un équipement porte déjà ce nom.</string><string name="creation_failed">Création impossible : %1$s.</string>
|
||||
|
|
@ -446,4 +483,148 @@
|
|||
<string name="settings_backup_complete">Sauvegarde complète exportée.</string>
|
||||
<string name="settings_backup_failed">Échec de la sauvegarde ; toute copie privée créée est conservée.</string>
|
||||
<string name="settings_restore_failed">Sauvegarde invalide ou restauration impossible.</string>
|
||||
<string name="sleep_medications">Médicaments</string>
|
||||
<string name="sleep_add_medication">Ajouter un médicament</string>
|
||||
<string name="sleep_add_intake">Ajouter une prise</string>
|
||||
<string name="sleep_dose">Dosage</string>
|
||||
<string name="sleep_unit">Unité</string>
|
||||
<string name="sleep_deactivate">Désactiver</string>
|
||||
<string name="sleep_edit">Modifier</string>
|
||||
<string name="yes">Oui</string>
|
||||
<string name="no">Non</string>
|
||||
<string name="route_heart_rate_diagnostics">Diagnostic cardio BLE</string>
|
||||
<string name="settings_cardio_section">Cardio</string>
|
||||
<string name="settings_cardio_diagnostics">Diagnostic du capteur cardio</string>
|
||||
<string name="settings_cardio_diagnostics_description">Rechercher et vérifier un capteur Bluetooth Heart Rate Service.</string>
|
||||
<string name="hr_diag_status">État du capteur</string>
|
||||
<string name="hr_diag_idle">Prêt</string>
|
||||
<string name="hr_diag_scanning">Recherche en cours…</string>
|
||||
<string name="hr_diag_connecting">Connexion…</string>
|
||||
<string name="hr_diag_discovering">Découverte des services…</string>
|
||||
<string name="hr_diag_subscribing">Activation des mesures…</string>
|
||||
<string name="hr_diag_receiving">Mesures reçues</string>
|
||||
<string name="hr_diag_disconnected">Déconnecté</string>
|
||||
<string name="hr_diag_error">Erreur BLE</string>
|
||||
<string name="hr_diag_permission_denied">Autorisation Bluetooth refusée.</string>
|
||||
<string name="hr_diag_technical">Diagnostic : %1$s</string>
|
||||
<string name="hr_diag_services">Heart Rate Service : %1$s · mesure : %2$s · batterie : %3$s</string>
|
||||
<string name="hr_diag_bpm">%1$d BPM</string>
|
||||
<string name="hr_diag_measurement_meta">%1$d RR · contact %2$s · énergie %3$s</string>
|
||||
<string name="hr_diag_notifications">Notifications reçues : %1$d</string>
|
||||
<string name="hr_diag_scan_section">Recherche BLE</string>
|
||||
<string name="hr_diag_scan">Rechercher cardio</string>
|
||||
<string name="hr_diag_scan_all">Recherche élargie</string>
|
||||
<string name="hr_diag_scan_description">La recherche cardio dure au maximum 10 secondes. La recherche élargie sert aux capteurs qui n’annoncent pas leur service dans la publicité BLE.</string>
|
||||
<string name="hr_diag_devices">Périphériques BLE</string>
|
||||
<string name="hr_diag_no_devices">Aucun périphérique trouvé pour l’instant.</string>
|
||||
<string name="hr_diag_unnamed_device">Périphérique sans nom</string>
|
||||
<string name="hr_diag_hrs_advertised">Heart Rate annoncé</string>
|
||||
<string name="hr_diag_hrs_not_advertised">Heart Rate non annoncé</string>
|
||||
<string name="hr_diag_device_description">%1$s · %2$d dBm · %3$s</string>
|
||||
<string name="hr_diag_use_sensor">Utiliser ce capteur</string>
|
||||
<string name="heart_rate_service_channel">Capteur cardio</string>
|
||||
<string name="heart_rate_service_title">Trainlog · cardio</string>
|
||||
<string name="heart_rate_service_connecting">Connexion au capteur…</string>
|
||||
<string name="heart_rate_service_reconnecting">Signal cardio en attente…</string>
|
||||
<string name="heart_rate_service_disconnected">Capteur cardio déconnecté</string>
|
||||
<string name="heart_rate_service_bpm">%1$d BPM</string>
|
||||
<string name="heart_rate_a11y_connected">Fréquence cardiaque %1$d BPM, capteur connecté</string>
|
||||
<string name="heart_rate_a11y_connecting">Capteur cardio en connexion</string>
|
||||
<string name="heart_rate_a11y_reconnecting">Signal cardio temporairement perdu, reconnexion en cours</string>
|
||||
<string name="heart_rate_a11y_disconnected">Capteur cardio déconnecté</string>
|
||||
<string name="settings_cardio_configured">Capteur cardio configuré.</string>
|
||||
<string name="settings_cardio_forget">Oublier le capteur</string>
|
||||
<string name="settings_cardio_forget_description">Arrêter la connexion cardio et supprimer le capteur mémorisé.</string>
|
||||
<string name="exercise_timeline_start">Démarrer l’exercice</string>
|
||||
<string name="exercise_timeline_finish">Terminer l’exercice</string>
|
||||
<string name="exercise_timeline_completed">Exercice terminé</string>
|
||||
<string name="exercise_timeline_started">%1$s · démarré</string>
|
||||
<string name="exercise_timeline_finished">%1$s · terminé</string>
|
||||
<string name="exercise_timeline_switch_title">Changer d’exercice ?</string>
|
||||
<string name="exercise_timeline_switch_detail">%1$s est encore en cours. Le terminer maintenant puis démarrer %2$s ?</string>
|
||||
<string name="exercise_timeline_switch_confirm">Terminer et démarrer</string>
|
||||
<string name="exercise_timeline_finalize_title">Un exercice est encore en cours</string>
|
||||
<string name="exercise_timeline_finalize_detail">%1$s est encore actif. Le terminer maintenant puis enregistrer la séance ?</string>
|
||||
<string name="exercise_timeline_finalize_confirm">Terminer et enregistrer</string>
|
||||
|
||||
<string name="sleep_night_capture_title">Saisie nocturne</string>
|
||||
<string name="sleep_no_night_active">Aucune nuit en cours.</string>
|
||||
<string name="sleep_night_in_progress">Nuit en cours · %1$s → %2$s</string>
|
||||
<string name="sleep_quick_bed">🛏 COUCHÉ</string>
|
||||
<string name="sleep_quick_wake">👁 RÉVEIL</string>
|
||||
<string name="sleep_quick_get_up">☀ LEVÉ</string>
|
||||
<string name="sleep_bed_recorded">Couché enregistré.</string>
|
||||
<string name="sleep_wake_recorded">Réveil de 30 minutes enregistré.</string>
|
||||
<string name="sleep_get_up_recorded">Levé enregistré.</string>
|
||||
<string name="sleep_medication_recorded">%1$s enregistré.</string>
|
||||
<string name="sleep_undo">ANNULER</string>
|
||||
<string name="sleep_action_undone">Dernière action annulée.</string>
|
||||
<string name="sleep_undo_unavailable">Annulation impossible : la nuit a déjà changé.</string>
|
||||
<string name="sleep_correct_night">Corriger la nuit</string>
|
||||
<string name="sleep_return_night_mode">Retour à la saisie nocturne</string>
|
||||
<string name="sleep_night_timeline">Chronologie de la nuit</string>
|
||||
|
||||
<string name="sleep_quick_no_active">Aucune nuit en cours.</string>
|
||||
<string name="sleep_quick_already_active">Une nuit est déjà en cours.</string>
|
||||
<string name="sleep_quick_medication_unavailable">Médicament indisponible.</string>
|
||||
<string name="sleep_quick_conflict">La nuit a changé ; rechargez avant de recommencer.</string>
|
||||
<string name="sleep_quick_error">Enregistrement impossible.</string>
|
||||
<string name="session_cardio">Cardio</string>
|
||||
<string name="route_cardio_calibration">Calibration cardio</string>
|
||||
<string name="calibration_explanation">Cette séance mesure votre réponse cardiaque actuelle. L’effort augmente progressivement et vous pouvez arrêter immédiatement à tout moment. Le résultat est enregistré comme un pic de fréquence cardiaque observé, pas comme une FC max médicale.</string>
|
||||
<string name="calibration_start">Démarrer la calibration</string>
|
||||
<string name="calibration_start_description">Créer une séance cardio dédiée, démarrer la capture cardiaque et commencer par l’échauffement.</string>
|
||||
<string name="calibration_latest">Dernière calibration</string>
|
||||
<string name="calibration_current">Calibration en cours</string>
|
||||
<string name="calibration_phase_value">Phase : %1$s</string>
|
||||
<string name="calibration_phase_warmup">Échauffement</string>
|
||||
<string name="calibration_phase_progressive">Montée progressive</string>
|
||||
<string name="calibration_phase_high_effort">Effort élevé</string>
|
||||
<string name="calibration_phase_recovery">Récupération</string>
|
||||
<string name="calibration_phase_completed">Terminée</string>
|
||||
<string name="calibration_phase_aborted">Arrêtée</string>
|
||||
<string name="calibration_peak_value">Pic observé : %1$d BPM</string>
|
||||
<string name="calibration_recovery_value">Récupération +%1$d min : %2$d BPM</string>
|
||||
<string name="calibration_to_progressive">Commencer la montée progressive</string>
|
||||
<string name="calibration_progressive_description">Augmenter progressivement l’effort sans chercher à battre une ancienne valeur.</string>
|
||||
<string name="calibration_to_high_effort">Passer à l’effort élevé</string>
|
||||
<string name="calibration_high_effort_description">Continuer seulement tant que vous choisissez de tolérer l’effort. Le bouton suivant arrête immédiatement la phase d’effort.</string>
|
||||
<string name="calibration_end_effort">Arrêter l’effort</string>
|
||||
<string name="calibration_end_effort_description">Marquer la fin de l’effort et commencer la récupération mesurée.</string>
|
||||
<string name="calibration_finish">Terminer la calibration</string>
|
||||
<string name="calibration_finish_description">Fermer la récupération et enregistrer le profil de calibration.</string>
|
||||
<string name="calibration_abort">Arrêter la calibration</string>
|
||||
<string name="calibration_conflict">Une autre séance ou calibration est déjà en cours.</string>
|
||||
<string name="cardio_guidance_title">Guidage cardio</string>
|
||||
<string name="cardio_guidance_phase_value">Phase : %1$s</string>
|
||||
<string name="cardio_guidance_target_value">Cible : %1$d–%2$d BPM</string>
|
||||
<string name="cardio_guidance_calibration_value">Calibration : pic observé %1$d BPM · %2$d–%3$d %%</string>
|
||||
<string name="cardio_guidance_latest_calibration">Dernière calibration : pic observé %1$d BPM</string>
|
||||
<string name="cardio_guidance_resolved_target">Cible utilisée : %1$d–%2$d BPM</string>
|
||||
<string name="cardio_guidance_accelerate">ACCÉLÈRE</string>
|
||||
<string name="cardio_guidance_maintain">MAINTIENS</string>
|
||||
<string name="cardio_guidance_slow_down">RALENTIS</string>
|
||||
<string name="cardio_guidance_suspended">SIGNAL CARDIO INDISPONIBLE</string>
|
||||
<string name="cardio_guidance_bpm_value">%1$d BPM</string>
|
||||
<string name="cardio_guidance_bpm_missing">-- BPM</string>
|
||||
<string name="cardio_guidance_start_phase">Démarrer la phase</string>
|
||||
<string name="cardio_guidance_finish_phase">Terminer la phase</string>
|
||||
<string name="cardio_guidance_invalid_configuration">Complète la cible et la condition de fin.</string>
|
||||
<string name="cardio_guidance_start_failed">Impossible de démarrer cette phase.</string>
|
||||
<string name="cardio_phase_warmup">Échauffement</string>
|
||||
<string name="cardio_phase_work">Travail</string>
|
||||
<string name="cardio_phase_recovery">Récupération</string>
|
||||
<string name="cardio_phase_cooldown">Retour au calme</string>
|
||||
<string name="cardio_target_absolute">BPM</string>
|
||||
<string name="cardio_target_calibration">%% calibration</string>
|
||||
<string name="cardio_target_min_bpm">BPM min</string>
|
||||
<string name="cardio_target_max_bpm">BPM max</string>
|
||||
<string name="cardio_target_min_percent">%% min</string>
|
||||
<string name="cardio_target_max_percent">%% max</string>
|
||||
<string name="cardio_exit_duration">Durée</string>
|
||||
<string name="cardio_exit_target">Entrée dans la cible</string>
|
||||
<string name="cardio_exit_recover">Sous un seuil</string>
|
||||
<string name="cardio_exit_bounded">Durée ou seuil</string>
|
||||
<string name="cardio_exit_seconds">Durée max</string>
|
||||
<string name="cardio_exit_recovery_bpm">Seuil de récupération</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import com.labfytools.trainlog.model.TrackingMode
|
|||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.UUID
|
||||
import java.util.zip.ZipInputStream
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
|
@ -24,6 +26,30 @@ import org.robolectric.annotation.Config
|
|||
class AndroidBackupServiceTest {
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
|
||||
@Test
|
||||
fun manifestUsesInjectedCanonicalProductVersion() {
|
||||
val name = "backup-version-${UUID.randomUUID()}.db"
|
||||
val repository = TrainlogRepository(context, name)
|
||||
try {
|
||||
val output = ByteArrayOutputStream()
|
||||
val service = AndroidBackupService(context, "0.1.3-test")
|
||||
assertTrue(service.create(repository, output) is AndroidBackupResult.Success)
|
||||
var manifest: JSONObject? = null
|
||||
ZipInputStream(ByteArrayInputStream(output.toByteArray())).use { zip ->
|
||||
while (true) {
|
||||
val entry = zip.nextEntry ?: break
|
||||
if (entry.name == "manifest.json") {
|
||||
manifest = JSONObject(zip.readBytes().toString(Charsets.UTF_8))
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals("0.1.3-test", checkNotNull(manifest).getString("product_version"))
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completeBackupRestoresDraftPeerLedgersAndPreferences() {
|
||||
val name = "backup-${UUID.randomUUID()}.db"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,251 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.time.Duration
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
class SyncConversationOwnershipTest {
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
|
||||
private fun request(root: File, runId: String, androidPeer: String, desktopPeer: String) {
|
||||
File(root, "request-v1.json").writeText(
|
||||
JSONObject()
|
||||
.put("format", "trainlog-sync-generation-request")
|
||||
.put("version", 1)
|
||||
.put("run_id", runId)
|
||||
.put("android_peer_id", androidPeer)
|
||||
.put("desktop_peer_id", desktopPeer)
|
||||
.toString()
|
||||
)
|
||||
}
|
||||
|
||||
private fun archiveAcknowledgements(
|
||||
root: File,
|
||||
runId: String,
|
||||
androidPeer: String,
|
||||
desktopPeer: String,
|
||||
) {
|
||||
File(root, "desktop-archive-acknowledgements-v1.json").writeText(
|
||||
JSONObject()
|
||||
.put("format", "trainlog-sync-archive-acknowledgements")
|
||||
.put("version", 1)
|
||||
.put("run_id", runId)
|
||||
.put("android_peer_id", androidPeer)
|
||||
.put("desktop_peer_id", desktopPeer)
|
||||
.put("acknowledgements", JSONArray())
|
||||
.toString()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun explicitClickYieldsOldBackgroundAndPublishesFreshTriggerForNewRun() {
|
||||
val databaseName = "generation-ownership-${UUID.randomUUID()}.db"
|
||||
val root = Files.createTempDirectory("trainlog-generation-ownership-").toFile()
|
||||
val repository = TrainlogRepository(context, databaseName)
|
||||
val arbiter = SyncConversationArbiter()
|
||||
val oldObserved = CountDownLatch(1)
|
||||
val capturedRuns = mutableListOf<String>()
|
||||
val desktopPeer = "peer_${UUID.randomUUID()}"
|
||||
val oldRun = "sy_${UUID.randomUUID()}"
|
||||
val newRun = "sy_${UUID.randomUUID()}"
|
||||
val service = SyncGenerationService(repository)
|
||||
val background =
|
||||
SyncGenerationCoordinator(
|
||||
repository,
|
||||
arbiter = arbiter,
|
||||
trace = SyncGenerationTrace { runId, phase, _, _, _ ->
|
||||
if (phase == "generation_request_observed" && runId == oldRun) oldObserved.countDown()
|
||||
},
|
||||
)
|
||||
val foreground =
|
||||
SyncGenerationCoordinator(
|
||||
repository,
|
||||
arbiter = arbiter,
|
||||
trace = SyncGenerationTrace { runId, phase, _, _, _ ->
|
||||
if (phase == "generation_captured") synchronized(capturedRuns) {
|
||||
capturedRuns += checkNotNull(runId)
|
||||
}
|
||||
},
|
||||
)
|
||||
val executor = java.util.concurrent.Executors.newFixedThreadPool(2)
|
||||
try {
|
||||
request(root, oldRun, service.peerId(), desktopPeer)
|
||||
File(root, "trainlog-sync-request-v1.json").writeText(
|
||||
"""{"format":"trainlog-sync-request","version":1,"request_id":"sr_old"}"""
|
||||
)
|
||||
val old = executor.submit<ForegroundGenerationResult> {
|
||||
background.run(root, Duration.ofSeconds(3))
|
||||
}
|
||||
assertTrue(oldObserved.await(2, TimeUnit.SECONDS))
|
||||
|
||||
val claim = arbiter.announceExplicit()
|
||||
val snapshot =
|
||||
(DirectExchangePublisher { directExchangeDirectoryForPath(root) }.snapshot()
|
||||
as DirectExchangeSnapshotResult.Ready).snapshot
|
||||
val outbox = SyncRequestOutbox { directExchangeDirectoryForPath(root) }
|
||||
var freshRequestId = ""
|
||||
val explicit = executor.submit<ForegroundGenerationResult> {
|
||||
foreground.run(
|
||||
root,
|
||||
Duration.ofMillis(700),
|
||||
afterPeerPublication = {
|
||||
freshRequestId =
|
||||
(outbox.requestSync(snapshot) as SyncRequestResult.Requested).requestId
|
||||
request(root, newRun, service.peerId(), desktopPeer)
|
||||
archiveAcknowledgements(root, newRun, service.peerId(), desktopPeer)
|
||||
},
|
||||
intent = SyncConversationIntent.NEW_EXPLICIT,
|
||||
explicitClaim = claim,
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue(old.get(2, TimeUnit.SECONDS) is ForegroundGenerationResult.Superseded)
|
||||
assertTrue(explicit.get(3, TimeUnit.SECONDS) is ForegroundGenerationResult.Failed)
|
||||
assertNotEquals("sr_old", freshRequestId)
|
||||
assertEquals(
|
||||
freshRequestId,
|
||||
JSONObject(File(root, "trainlog-sync-request-v1.json").readText())
|
||||
.getString("request_id"),
|
||||
)
|
||||
assertEquals(listOf(newRun), capturedRuns)
|
||||
repository.inSyncGenerationTransaction { database ->
|
||||
database.rawQuery("SELECT run_id FROM sync_generations", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(newRun, cursor.getString(0))
|
||||
assertEquals(false, cursor.moveToNext())
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
executor.shutdownNow()
|
||||
repository.close()
|
||||
context.deleteDatabase(databaseName)
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun terminalBackgroundRunStaysSuppressedUntilNewRequestOrRemoteEvidence() {
|
||||
val databaseName = "generation-suppression-${UUID.randomUUID()}.db"
|
||||
val root = Files.createTempDirectory("trainlog-generation-suppression-").toFile()
|
||||
val repository = TrainlogRepository(context, databaseName)
|
||||
try {
|
||||
val service = SyncGenerationService(repository)
|
||||
val coordinator = SyncGenerationCoordinator(repository, arbiter = SyncConversationArbiter())
|
||||
val desktopPeer = "peer_${UUID.randomUUID()}"
|
||||
val runId = "sy_${UUID.randomUUID()}"
|
||||
request(root, runId, service.peerId(), desktopPeer)
|
||||
service.capture(root, desktopPeer, runId)
|
||||
val terminal = BackgroundTerminalRun(runId, coordinator.remoteEvidence(root, runId))
|
||||
|
||||
repeat(5) {
|
||||
assertEquals(
|
||||
BackgroundRequestActionability.RESUMABLE_BUT_STALE,
|
||||
coordinator.classifyBackgroundRequest(root, terminal),
|
||||
)
|
||||
}
|
||||
|
||||
File(root, "desktop-consumption-ack-v1.json").writeText(
|
||||
JSONObject()
|
||||
.put("format", "trainlog-sync-ack")
|
||||
.put("version", 1)
|
||||
.put("run_id", runId)
|
||||
.put("proof", "new")
|
||||
.toString()
|
||||
)
|
||||
assertEquals(
|
||||
BackgroundRequestActionability.NEW_REMOTE_EVIDENCE,
|
||||
coordinator.classifyBackgroundRequest(root, terminal),
|
||||
)
|
||||
|
||||
request(root, "sy_${UUID.randomUUID()}", service.peerId(), desktopPeer)
|
||||
assertEquals(
|
||||
BackgroundRequestActionability.NEW_REQUEST,
|
||||
coordinator.classifyBackgroundRequest(root, terminal),
|
||||
)
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(databaseName)
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cancelledExplicitConversationHandsSameGenerationToBackgroundWithoutDuplication() {
|
||||
val databaseName = "generation-handoff-${UUID.randomUUID()}.db"
|
||||
val root = Files.createTempDirectory("trainlog-generation-handoff-").toFile()
|
||||
val repository = TrainlogRepository(context, databaseName)
|
||||
val arbiter = SyncConversationArbiter()
|
||||
val published = CountDownLatch(1)
|
||||
val result = AtomicReference<ForegroundGenerationResult>()
|
||||
val desktopPeer = "peer_${UUID.randomUUID()}"
|
||||
val oldRunId = "sy_${UUID.randomUUID()}"
|
||||
val runId = "sy_${UUID.randomUUID()}"
|
||||
try {
|
||||
val service = SyncGenerationService(repository)
|
||||
request(root, oldRunId, service.peerId(), desktopPeer)
|
||||
val coordinator =
|
||||
SyncGenerationCoordinator(
|
||||
repository,
|
||||
arbiter = arbiter,
|
||||
trace = SyncGenerationTrace { _, phase, _, _, _ ->
|
||||
if (phase == "generation_reference_published") published.countDown()
|
||||
},
|
||||
)
|
||||
val claim = arbiter.announceExplicit()
|
||||
val thread = Thread {
|
||||
result.set(
|
||||
coordinator.run(
|
||||
root,
|
||||
Duration.ofSeconds(5),
|
||||
afterPeerPublication = {
|
||||
request(root, runId, service.peerId(), desktopPeer)
|
||||
archiveAcknowledgements(root, runId, service.peerId(), desktopPeer)
|
||||
},
|
||||
intent = SyncConversationIntent.NEW_EXPLICIT,
|
||||
explicitClaim = claim,
|
||||
)
|
||||
)
|
||||
}
|
||||
thread.start()
|
||||
assertTrue(published.await(3, TimeUnit.SECONDS))
|
||||
thread.interrupt()
|
||||
thread.join(3_000)
|
||||
assertTrue(result.get() is ForegroundGenerationResult.Cancelled)
|
||||
assertEquals(
|
||||
BackgroundRequestActionability.ACTIVE_HANDOFF,
|
||||
coordinator.classifyBackgroundRequest(root, null),
|
||||
)
|
||||
|
||||
val resumed = coordinator.run(root, Duration.ofMillis(250))
|
||||
assertTrue(resumed is ForegroundGenerationResult.Failed)
|
||||
repository.inSyncGenerationTransaction { database ->
|
||||
database.rawQuery("SELECT COUNT(*) FROM sync_generations WHERE run_id=?", arrayOf(runId))
|
||||
.use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(1, cursor.getInt(0))
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(databaseName)
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -56,6 +56,7 @@ class SyncDeploymentConversationTest {
|
|||
val databaseName = "deployment-peer-${UUID.randomUUID()}.db"
|
||||
var repository = TrainlogRepository(context, databaseName)
|
||||
var localWorker: Process? = null
|
||||
var lostDesktopAckGeneration: String? = null
|
||||
try {
|
||||
transport.mkdirs()
|
||||
// A previous interrupted exchange leaves durable coordination
|
||||
|
|
@ -101,7 +102,15 @@ class SyncDeploymentConversationTest {
|
|||
}
|
||||
val conversations = if (suppliedTransport == null) 24 else 1
|
||||
repeat(conversations) {
|
||||
val coordinator = SyncGenerationForegroundCoordinator(repository)
|
||||
val visibilityEvents = mutableListOf<List<String>>()
|
||||
val tracePhases = mutableListOf<String>()
|
||||
val coordinator = SyncGenerationForegroundCoordinator(
|
||||
repository,
|
||||
MtpPublicationVisibility { files ->
|
||||
visibilityEvents += files.map(File::getName)
|
||||
},
|
||||
SyncGenerationTrace { _, phase, _, _, _ -> tracePhases += phase },
|
||||
)
|
||||
val executor = java.util.concurrent.Executors.newSingleThreadExecutor()
|
||||
var requestSignals = 0
|
||||
var refreshedAfterDesktopReference = false
|
||||
|
|
@ -156,6 +165,35 @@ class SyncDeploymentConversationTest {
|
|||
"transport is refreshed after the correlated desktop reference",
|
||||
refreshedAfterDesktopReference,
|
||||
)
|
||||
val referenceVisibility = visibilityEvents.indexOfFirst {
|
||||
it == listOf("android-generation-v1.json")
|
||||
}
|
||||
val objectVisibility = visibilityEvents.indexOfFirst { "manifest.json" in it }
|
||||
assertTrue("generation objects must become visible", objectVisibility >= 0)
|
||||
assertTrue(
|
||||
"reference must follow complete object visibility",
|
||||
referenceVisibility > objectVisibility,
|
||||
)
|
||||
assertTrue(
|
||||
tracePhases.containsAll(
|
||||
listOf(
|
||||
"peer_published",
|
||||
"full_generation_trigger_published",
|
||||
"generation_request_observed",
|
||||
"archive_ack_observed",
|
||||
"android_archive_ack_published",
|
||||
"generation_capture_started",
|
||||
"generation_captured",
|
||||
"generation_objects_published",
|
||||
"generation_reference_published",
|
||||
"desktop_ack_observed",
|
||||
"desktop_generation_observed",
|
||||
"desktop_generation_consumed",
|
||||
"android_ack_published",
|
||||
"completed",
|
||||
)
|
||||
)
|
||||
)
|
||||
var workerOutput = ""
|
||||
localWorker?.let { worker ->
|
||||
workerOutput = worker.inputStream.bufferedReader().readText()
|
||||
|
|
@ -179,6 +217,71 @@ class SyncDeploymentConversationTest {
|
|||
}
|
||||
}
|
||||
}
|
||||
if (suppliedTransport == null && it == 11) {
|
||||
/* Reproduce the inverse deployed failure: Android has
|
||||
* durably consumed the newest desktop generation, but its
|
||||
* ACK was lost before the desktop producer committed it.
|
||||
* The next conversation must recover that exact Android
|
||||
* evidence before capturing another desktop generation. */
|
||||
SQLiteDatabase.openDatabase(
|
||||
desktop.absolutePath,
|
||||
null,
|
||||
SQLiteDatabase.OPEN_READWRITE,
|
||||
).use { database ->
|
||||
val generation =
|
||||
database.rawQuery(
|
||||
"SELECT generation_id FROM sync_generations " +
|
||||
"WHERE status='acknowledged' ORDER BY generated_at DESC LIMIT 1",
|
||||
null,
|
||||
).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
cursor.getString(0)
|
||||
}
|
||||
database.delete(
|
||||
"sync_acknowledgements",
|
||||
"generation_id=?",
|
||||
arrayOf(generation),
|
||||
)
|
||||
database.execSQL(
|
||||
"UPDATE sync_generations SET status='waiting_acknowledgement'," +
|
||||
"acknowledged_at=NULL WHERE generation_id=?",
|
||||
arrayOf(generation),
|
||||
)
|
||||
database.rawQuery(
|
||||
"SELECT status FROM sync_generations WHERE generation_id=?",
|
||||
arrayOf(generation),
|
||||
).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals("waiting_acknowledgement", cursor.getString(0))
|
||||
}
|
||||
lostDesktopAckGeneration = generation
|
||||
}
|
||||
}
|
||||
if (suppliedTransport == null && it == 12) {
|
||||
val recovered = checkNotNull(lostDesktopAckGeneration)
|
||||
SQLiteDatabase.openDatabase(
|
||||
desktop.absolutePath,
|
||||
null,
|
||||
SQLiteDatabase.OPEN_READONLY,
|
||||
).use { database ->
|
||||
database.rawQuery(
|
||||
"SELECT status FROM sync_generations WHERE generation_id=?",
|
||||
arrayOf(recovered),
|
||||
).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals("acknowledged", cursor.getString(0))
|
||||
}
|
||||
database.rawQuery(
|
||||
"SELECT parent_generation_id,status FROM sync_generations " +
|
||||
"ORDER BY generated_at DESC LIMIT 1",
|
||||
null,
|
||||
).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(recovered, cursor.getString(0))
|
||||
assertEquals("acknowledged", cursor.getString(1))
|
||||
}
|
||||
}
|
||||
}
|
||||
localWorker = null
|
||||
}
|
||||
repository.close()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import java.nio.file.Files
|
||||
import java.time.Duration
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
class SyncGenerationConversationLockTest {
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
|
||||
@Test
|
||||
fun foregroundConversationCannotBeStolenByBackgroundCoordinator() {
|
||||
val databaseName = "generation-lock-${UUID.randomUUID()}.db"
|
||||
val root = Files.createTempDirectory("trainlog-generation-lock-").toFile()
|
||||
val repository = TrainlogRepository(context, databaseName)
|
||||
val entered = CountDownLatch(1)
|
||||
val executor = java.util.concurrent.Executors.newSingleThreadExecutor()
|
||||
try {
|
||||
val foreground = SyncGenerationCoordinator(repository)
|
||||
val background = SyncGenerationCoordinator(repository)
|
||||
val active = executor.submit<ForegroundGenerationResult> {
|
||||
foreground.run(
|
||||
root,
|
||||
Duration.ofMillis(300),
|
||||
afterPeerPublication = { entered.countDown() },
|
||||
)
|
||||
}
|
||||
assertTrue(entered.await(2, TimeUnit.SECONDS))
|
||||
val duplicate = background.run(root, Duration.ofMillis(10))
|
||||
assertEquals(ForegroundGenerationResult.Busy, duplicate)
|
||||
assertTrue(active.get(2, TimeUnit.SECONDS) is ForegroundGenerationResult.Failed)
|
||||
repository.inSyncGenerationTransaction { database ->
|
||||
database.rawQuery("SELECT COUNT(*) FROM sync_generations", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(0, cursor.getInt(0))
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
executor.shutdownNow()
|
||||
repository.close()
|
||||
context.deleteDatabase(databaseName)
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,11 +3,15 @@ package com.labfytools.trainlog.data
|
|||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.labfytools.trainlog.model.HeartRateContextKind
|
||||
import com.labfytools.trainlog.model.HeartRateRrInterval
|
||||
import com.labfytools.trainlog.model.NewExerciseProfile
|
||||
import com.labfytools.trainlog.model.RecordingMode
|
||||
import com.labfytools.trainlog.model.SessionDraft
|
||||
import com.labfytools.trainlog.model.SessionExerciseDraft
|
||||
import com.labfytools.trainlog.model.SessionSetDraft
|
||||
import com.labfytools.trainlog.model.SleepDiaryDraft
|
||||
import com.labfytools.trainlog.model.SleepMedication
|
||||
import com.labfytools.trainlog.model.TrackingMode
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
|
|
@ -27,6 +31,299 @@ import org.robolectric.annotation.Config
|
|||
class SyncGenerationServiceTest {
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
|
||||
@Test
|
||||
fun sleepOnlyNightCrossesDesktopAckReplayAndSecondRevisionWithoutSession() {
|
||||
val sourceName = "sleep-only-generation-${UUID.randomUUID()}.db"
|
||||
val root = Files.createTempDirectory("trainlog-sleep-only-generation-").toFile()
|
||||
val desktopDb = java.io.File(root, "desktop.sqlite")
|
||||
val repositoryRoot =
|
||||
generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))) {
|
||||
it.parentFile
|
||||
}
|
||||
.first { java.io.File(it, "tools/sync_generation_exchange.py").isFile }
|
||||
val generationTool =
|
||||
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath
|
||||
var source = TrainlogRepository(context, sourceName)
|
||||
|
||||
fun run(vararg command: String): String {
|
||||
val process =
|
||||
ProcessBuilder(*command)
|
||||
.directory(repositoryRoot)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
val output = process.inputStream.bufferedReader().readText()
|
||||
assertEquals(
|
||||
"command failed: ${command.joinToString(" ")}\n$output",
|
||||
0,
|
||||
process.waitFor(),
|
||||
)
|
||||
return output
|
||||
}
|
||||
|
||||
try {
|
||||
run(
|
||||
java.io.File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
|
||||
desktopDb.absolutePath,
|
||||
)
|
||||
val desktopPeer =
|
||||
Regex("PEER_ID=(peer_[^\\s]+)")
|
||||
.find(
|
||||
run(
|
||||
"python3",
|
||||
generationTool,
|
||||
"peer-id",
|
||||
"--database",
|
||||
desktopDb.absolutePath,
|
||||
)
|
||||
)!!
|
||||
.groupValues[1]
|
||||
val medicationCreated =
|
||||
source.saveSleepMedication(
|
||||
SleepMedication(
|
||||
medicationId = "",
|
||||
revisionId = "",
|
||||
createdAt = "2026-09-23T20:00:00+02:00",
|
||||
updatedAt = "2026-09-23T20:00:00+02:00",
|
||||
name = "Synthetic sleep-only medication",
|
||||
defaultDoseValue = 5.0,
|
||||
defaultDoseUnit = "mg",
|
||||
form = "tablet",
|
||||
note = "",
|
||||
active = true,
|
||||
)
|
||||
) as TrainlogRepository.SaveSleepDiaryResult.Saved
|
||||
val pendingRoot =
|
||||
source.saveSleepDiary(
|
||||
SleepDiaryDraft(
|
||||
nightStartDate = "2026-09-23",
|
||||
nightEndDate = "2026-09-24",
|
||||
createdAt = "2026-09-23T22:00:00+02:00",
|
||||
updatedAt = "2026-09-23T22:00:00+02:00",
|
||||
sleepQuality = null,
|
||||
wakeQuality = null,
|
||||
dayForm = null,
|
||||
treatmentAndNotes = "",
|
||||
events = emptyList(),
|
||||
intakes = emptyList(),
|
||||
)
|
||||
) as TrainlogRepository.SaveSleepDiaryResult.Saved
|
||||
val preBed =
|
||||
source.quickSleepMedication(
|
||||
medicationCreated.entryId,
|
||||
"2026-09-23T22:20:00+02:00",
|
||||
quantity = 2,
|
||||
) as TrainlogRepository.SleepQuickActionResult.Applied
|
||||
assertEquals(pendingRoot.entryId, preBed.receipt.entryId)
|
||||
source.quickSleepMedication(
|
||||
medicationCreated.entryId,
|
||||
"2026-09-23T22:21:00+02:00",
|
||||
) as TrainlogRepository.SleepQuickActionResult.Applied
|
||||
source.inSyncGenerationTransaction { db ->
|
||||
assertEquals(
|
||||
2,
|
||||
db.rawQuery(
|
||||
"SELECT COUNT(*) FROM sleep_medication_intakes WHERE revision_id=" +
|
||||
"(SELECT current_revision_id FROM sleep_diary_entries WHERE entry_id=?)",
|
||||
arrayOf(preBed.receipt.entryId),
|
||||
).use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
cursor.getInt(0)
|
||||
},
|
||||
)
|
||||
}
|
||||
source.close()
|
||||
source = TrainlogRepository(context, sourceName)
|
||||
assertEquals(listOf(2, 1), source.listSleepDiary().single().intakes.map { it.quantity })
|
||||
val bed =
|
||||
source.quickSleepBedTime("2026-09-23T22:30:00+02:00")
|
||||
as TrainlogRepository.SleepQuickActionResult.Applied
|
||||
assertEquals(preBed.receipt.entryId, bed.receipt.entryId)
|
||||
assertEquals(2, source.listSleepDiary().single().intakes.size)
|
||||
source.close()
|
||||
source = TrainlogRepository(context, sourceName)
|
||||
val firstMeasurement =
|
||||
ParsedHeartRateMeasurement(
|
||||
bpm = 61,
|
||||
sensorContactDetected = true,
|
||||
energyExpended = 7,
|
||||
rrIntervals1024 = listOf(1000),
|
||||
)
|
||||
assertTrue(
|
||||
source.recordLiveHeartRateForActiveSleep(
|
||||
"Synthetic HR",
|
||||
"2026-09-23T22:30:01+02:00",
|
||||
firstMeasurement,
|
||||
) is TrainlogRepository.HeartRateMutationResult.Applied
|
||||
)
|
||||
assertTrue(
|
||||
source.quickSleepWake("2026-09-24T02:17:00+02:00")
|
||||
is TrainlogRepository.SleepQuickActionResult.Applied
|
||||
)
|
||||
assertEquals(2, source.listSleepDiary().single().intakes.size)
|
||||
source.close()
|
||||
source = TrainlogRepository(context, sourceName)
|
||||
assertTrue(
|
||||
source.recordLiveHeartRateForActiveSleep(
|
||||
"Synthetic HR",
|
||||
"2026-09-24T02:17:01+02:00",
|
||||
firstMeasurement.copy(bpm = 65, rrIntervals1024 = listOf(980)),
|
||||
) is TrainlogRepository.HeartRateMutationResult.Applied
|
||||
)
|
||||
assertTrue(
|
||||
source.quickSleepFinalGetUp("2026-09-24T06:31:00+02:00")
|
||||
is TrainlogRepository.SleepQuickActionResult.Applied
|
||||
)
|
||||
source.close()
|
||||
source = TrainlogRepository(context, sourceName)
|
||||
assertTrue(source.listSessions().isEmpty())
|
||||
|
||||
val night = source.listSleepDiary().single()
|
||||
val capture =
|
||||
JSONObject(source.buildHeartRateV1Json())
|
||||
.getJSONArray("captures")
|
||||
.getJSONObject(0)
|
||||
assertEquals(night.entryId, capture.getString("context_id"))
|
||||
assertEquals(2, capture.getJSONArray("samples").length())
|
||||
assertEquals(listOf(2, 1), night.intakes.map { it.quantity })
|
||||
|
||||
val service = SyncGenerationService(source)
|
||||
val captured = service.capture(root, desktopPeer)
|
||||
val diaryArtifact =
|
||||
JSONObject(java.io.File(captured.stagingDirectory, "sleep-diary-v2.json").readText())
|
||||
val heartArtifact =
|
||||
JSONObject(java.io.File(captured.stagingDirectory, "heart-rate-v1.json").readText())
|
||||
val historyArtifact =
|
||||
JSONObject(java.io.File(captured.stagingDirectory, "history-v4.json").readText())
|
||||
assertEquals(0, historyArtifact.getJSONArray("sessions").length())
|
||||
val publishedNight = diaryArtifact.getJSONArray("entries").getJSONObject(0)
|
||||
val publishedCapture = heartArtifact.getJSONArray("captures").getJSONObject(0)
|
||||
assertEquals(night.entryId, publishedNight.getString("entry_id"))
|
||||
assertEquals(2, publishedNight.getJSONArray("intakes").length())
|
||||
assertEquals(
|
||||
listOf(1, 2),
|
||||
(0 until publishedNight.getJSONArray("intakes").length()).map { index ->
|
||||
publishedNight.getJSONArray("intakes").getJSONObject(index).getInt("quantity")
|
||||
}.sorted(),
|
||||
)
|
||||
assertEquals(capture.getString("capture_id"), publishedCapture.getString("capture_id"))
|
||||
|
||||
val published = service.publish(captured, java.io.File(root, "android-objects"))
|
||||
val ack = java.io.File(root, "sleep-only-ack.json")
|
||||
run(
|
||||
"python3",
|
||||
generationTool,
|
||||
"consume-desktop",
|
||||
published.absolutePath,
|
||||
"--database",
|
||||
desktopDb.absolutePath,
|
||||
"--ack-output",
|
||||
ack.absolutePath,
|
||||
)
|
||||
val ackDocument = JSONObject(ack.readText())
|
||||
assertEquals(
|
||||
"desktop ACK diagnostic: ${ackDocument.getString("diagnostic")}",
|
||||
"consumed",
|
||||
ackDocument.getString("result"),
|
||||
)
|
||||
assertEquals("acknowledged", service.acceptAcknowledgement(ack.readBytes()))
|
||||
assertEquals("unchanged", service.acceptAcknowledgement(ack.readBytes()))
|
||||
run(
|
||||
"python3",
|
||||
generationTool,
|
||||
"consume-desktop",
|
||||
published.absolutePath,
|
||||
"--database",
|
||||
desktopDb.absolutePath,
|
||||
"--ack-output",
|
||||
java.io.File(root, "sleep-only-replay-ack.json").absolutePath,
|
||||
)
|
||||
assertEquals(
|
||||
// The desktop fixture owns one reserved calibration session;
|
||||
// the empty Android history must not add a workout.
|
||||
"1|2|2,1|2|2|1",
|
||||
run(
|
||||
"sqlite3",
|
||||
desktopDb.absolutePath,
|
||||
"SELECT (SELECT count(*) FROM sleep_diary_entries WHERE entry_id='${night.entryId}') || '|' || " +
|
||||
"(SELECT count(*) FROM sleep_medication_intakes WHERE revision_id='${night.revisionId}') || '|' || " +
|
||||
"(SELECT group_concat(quantity,',') FROM (SELECT quantity FROM sleep_medication_intakes WHERE revision_id='${night.revisionId}' ORDER BY taken_at,intake_id)) || '|' || " +
|
||||
"(SELECT count(*) FROM heart_rate_samples WHERE capture_id='${capture.getString("capture_id")}') || '|' || " +
|
||||
"(SELECT count(*) FROM heart_rate_rr_intervals WHERE capture_id='${capture.getString("capture_id")}') || '|' || " +
|
||||
"(SELECT count(*) FROM sessions);",
|
||||
)
|
||||
.trim(),
|
||||
)
|
||||
|
||||
val staleWithoutIntakes = JSONObject(source.buildSleepDiaryV1Json())
|
||||
val staleEntry = staleWithoutIntakes.getJSONArray("entries").getJSONObject(0)
|
||||
staleEntry.put("revision_id", pendingRoot.revisionId)
|
||||
staleEntry.put("parent_revision_id", JSONObject.NULL)
|
||||
staleEntry.put("ancestry", org.json.JSONArray().put(pendingRoot.revisionId))
|
||||
staleEntry.put("updated_at", "2026-09-23T22:00:00+02:00")
|
||||
staleEntry.put("events", org.json.JSONArray())
|
||||
staleEntry.put("intakes", org.json.JSONArray())
|
||||
val staleResult = source.applySleepDiaryV1Json(staleWithoutIntakes.toString())
|
||||
assertTrue(staleResult is TrainlogRepository.SleepDiaryImportResult.Applied)
|
||||
assertEquals(1, (staleResult as TrainlogRepository.SleepDiaryImportResult.Applied).unchanged)
|
||||
assertEquals(listOf(2, 1), source.listSleepDiary().single().intakes.map { it.quantity })
|
||||
|
||||
val revised =
|
||||
source.saveSleepDiary(
|
||||
SleepDiaryDraft(
|
||||
entryId = night.entryId,
|
||||
expectedRevision = night.revisionId,
|
||||
nightStartDate = night.nightStartDate,
|
||||
nightEndDate = night.nightEndDate,
|
||||
createdAt = night.createdAt,
|
||||
updatedAt = "2026-09-24T07:00:00+02:00",
|
||||
sleepQuality = night.sleepQuality,
|
||||
wakeQuality = night.wakeQuality,
|
||||
dayForm = night.dayForm,
|
||||
treatmentAndNotes = "Sleep-only revision two",
|
||||
events = night.events,
|
||||
intakes = night.intakes,
|
||||
)
|
||||
) as TrainlogRepository.SaveSleepDiaryResult.Saved
|
||||
assertTrue(
|
||||
source.validateSleepDiary(
|
||||
revised.entryId,
|
||||
revised.revisionId,
|
||||
"2026-09-24T07:01:00+02:00",
|
||||
) is TrainlogRepository.SaveSleepDiaryResult.Saved
|
||||
)
|
||||
val second = service.capture(root, desktopPeer)
|
||||
val secondPublished =
|
||||
service.publish(second, java.io.File(root, "android-objects-second"))
|
||||
val secondAck = java.io.File(root, "sleep-only-second-ack.json")
|
||||
run(
|
||||
"python3",
|
||||
generationTool,
|
||||
"consume-desktop",
|
||||
secondPublished.absolutePath,
|
||||
"--database",
|
||||
desktopDb.absolutePath,
|
||||
"--ack-output",
|
||||
secondAck.absolutePath,
|
||||
)
|
||||
assertEquals("acknowledged", service.acceptAcknowledgement(secondAck.readBytes()))
|
||||
assertEquals(
|
||||
revised.revisionId,
|
||||
run(
|
||||
"sqlite3",
|
||||
desktopDb.absolutePath,
|
||||
"SELECT current_revision_id FROM sleep_diary_entries WHERE entry_id='${night.entryId}';",
|
||||
)
|
||||
.trim(),
|
||||
)
|
||||
assertEquals(2, source.listSleepDiary().single().intakes.size)
|
||||
assertTrue(source.listSessions().isEmpty())
|
||||
} finally {
|
||||
source.close()
|
||||
context.deleteDatabase(sourceName)
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun desktopProgramDeletionGenerationIsAckedAndCannotResurrect() {
|
||||
val repositoryRoot =
|
||||
|
|
@ -319,6 +616,13 @@ class SyncGenerationServiceTest {
|
|||
destinationService.peerId(),
|
||||
runId,
|
||||
)
|
||||
val retryRun = "sy_${UUID.randomUUID()}"
|
||||
val crossRunResume =
|
||||
SyncGenerationCoordinator(source)
|
||||
.resumableGeneration(retryRun, destinationService.peerId())
|
||||
assertEquals(outbound.generationId, crossRunResume?.generationId)
|
||||
assertEquals(retryRun, crossRunResume?.runId)
|
||||
assertEquals(outbound.manifestSha256, crossRunResume?.manifestSha256)
|
||||
val outboundDirectory =
|
||||
sourceService.publish(outbound, java.io.File(root, "source-objects"))
|
||||
sourceService.acceptAcknowledgement(
|
||||
|
|
@ -350,6 +654,74 @@ class SyncGenerationServiceTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun crossRunResumeAcceptsExactImmutableAcknowledgementFromOriginalRun() {
|
||||
val generationId = "gen_${UUID.randomUUID()}"
|
||||
val originalRunId = "sy_${UUID.randomUUID()}"
|
||||
val retryRunId = "sy_${UUID.randomUUID()}"
|
||||
val digest = "a".repeat(64)
|
||||
val captured =
|
||||
CapturedSyncGeneration(generationId, retryRunId, digest, java.io.File("unused"))
|
||||
val acknowledgement =
|
||||
JSONObject()
|
||||
.put("run_id", originalRunId)
|
||||
.put("generation_id", generationId)
|
||||
.put("manifest_sha256", digest)
|
||||
|
||||
assertTrue(acknowledgementMatchesCapturedGeneration(acknowledgement, captured))
|
||||
acknowledgement.put("manifest_sha256", "b".repeat(64))
|
||||
assertTrue(!acknowledgementMatchesCapturedGeneration(acknowledgement, captured))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ledgerBackedSiblingMakesRetainedWaitingBranchesCapacityTerminal() {
|
||||
val name = "generation-superseded-capacity-${UUID.randomUUID()}.db"
|
||||
val root = Files.createTempDirectory("trainlog-generation-superseded-").toFile()
|
||||
val repository = TrainlogRepository(context, name)
|
||||
try {
|
||||
val service = SyncGenerationService(repository)
|
||||
val peer = "peer_11111111-1111-4111-8111-111111111111"
|
||||
val branches = (0 until 4).map { service.capture(root, peer) }
|
||||
val acknowledged = branches.last()
|
||||
repository.inSyncGenerationTransaction { db ->
|
||||
db.execSQL(
|
||||
"UPDATE sync_generations SET parent_generation_id=? WHERE consumer_peer_id=?",
|
||||
arrayOf("gen_22222222-2222-4222-8222-222222222222", peer),
|
||||
)
|
||||
db.execSQL(
|
||||
"UPDATE sync_generations SET status='acknowledged',acknowledged_at=? " +
|
||||
"WHERE generation_id=?",
|
||||
arrayOf("2026-09-24T07:00:00+02:00", acknowledged.generationId),
|
||||
)
|
||||
db.execSQL(
|
||||
"INSERT INTO sync_acknowledgements VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
||||
arrayOf(
|
||||
"ack_${UUID.randomUUID()}",
|
||||
acknowledged.generationId,
|
||||
acknowledged.runId,
|
||||
service.peerId(),
|
||||
peer,
|
||||
acknowledged.manifestSha256,
|
||||
"consumed",
|
||||
"sqlite-commit-full",
|
||||
"2026-09-24T07:00:00+02:00",
|
||||
"",
|
||||
"a".repeat(64),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// The three sibling branches remain durable, but the ledger-backed
|
||||
// acknowledged sibling proves that none can be the causal tip.
|
||||
assertEquals(3, branches.dropLast(1).size)
|
||||
assertTrue(service.capture(root, peer).generationId.startsWith("gen_"))
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun coordinatorOnlyTreatsCorrelatedFreshRequestAsActionable() {
|
||||
val databaseName = "generation-actionable-${UUID.randomUUID()}.db"
|
||||
|
|
@ -357,9 +729,15 @@ class SyncGenerationServiceTest {
|
|||
val repository = TrainlogRepository(context, databaseName)
|
||||
try {
|
||||
val coordinator = SyncGenerationCoordinator(repository)
|
||||
assertEquals(false, coordinator.hasActionableRequest(root))
|
||||
assertEquals(
|
||||
BackgroundRequestActionability.NOT_ACTIONABLE,
|
||||
coordinator.classifyBackgroundRequest(root, null),
|
||||
)
|
||||
File(root, "request-v1.json").writeText("not-json")
|
||||
assertEquals(false, coordinator.hasActionableRequest(root))
|
||||
assertEquals(
|
||||
BackgroundRequestActionability.NOT_ACTIONABLE,
|
||||
coordinator.classifyBackgroundRequest(root, null),
|
||||
)
|
||||
|
||||
val peer = SyncGenerationService(repository).peerId()
|
||||
val request =
|
||||
|
|
@ -370,11 +748,17 @@ class SyncGenerationServiceTest {
|
|||
.put("android_peer_id", "peer_${UUID.randomUUID()}")
|
||||
.put("desktop_peer_id", "peer_${UUID.randomUUID()}")
|
||||
File(root, "request-v1.json").writeText(request.toString())
|
||||
assertEquals(false, coordinator.hasActionableRequest(root))
|
||||
assertEquals(
|
||||
BackgroundRequestActionability.NOT_ACTIONABLE,
|
||||
coordinator.classifyBackgroundRequest(root, null),
|
||||
)
|
||||
|
||||
request.put("android_peer_id", peer)
|
||||
File(root, "request-v1.json").writeText(request.toString())
|
||||
assertEquals(true, coordinator.hasActionableRequest(root))
|
||||
assertEquals(
|
||||
BackgroundRequestActionability.NEW_REQUEST,
|
||||
coordinator.classifyBackgroundRequest(root, null),
|
||||
)
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(databaseName)
|
||||
|
|
@ -494,7 +878,7 @@ class SyncGenerationServiceTest {
|
|||
.use { db ->
|
||||
db.rawQuery("PRAGMA user_version", null).use {
|
||||
assertTrue(it.moveToFirst())
|
||||
assertEquals(25, it.getInt(0))
|
||||
assertEquals(33, it.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
|
||||
assertTrue(it.moveToFirst())
|
||||
|
|
@ -843,7 +1227,73 @@ class SyncGenerationServiceTest {
|
|||
)
|
||||
)!!
|
||||
.groupValues[1]
|
||||
val sleepEntry =
|
||||
source.saveSleepDiary(
|
||||
SleepDiaryDraft(
|
||||
nightStartDate = "2026-09-22",
|
||||
nightEndDate = "2026-09-23",
|
||||
createdAt = "2026-09-22T22:00:00+02:00",
|
||||
updatedAt = "2026-09-22T22:00:00+02:00",
|
||||
sleepQuality = null,
|
||||
wakeQuality = null,
|
||||
dayForm = null,
|
||||
treatmentAndNotes = "",
|
||||
events = emptyList(),
|
||||
),
|
||||
) as TrainlogRepository.SaveSleepDiaryResult.Saved
|
||||
assertTrue(
|
||||
source.validateSleepDiary(
|
||||
sleepEntry.entryId,
|
||||
sleepEntry.revisionId,
|
||||
"2026-09-23T07:00:00+02:00",
|
||||
) is TrainlogRepository.SaveSleepDiaryResult.Saved,
|
||||
)
|
||||
val heartCapture =
|
||||
source.startHeartRateCapture(
|
||||
HeartRateContextKind.SLEEP,
|
||||
sleepEntry.entryId,
|
||||
"2026-09-22T22:30:00+02:00",
|
||||
"Synthetic HR",
|
||||
) as TrainlogRepository.StartHeartRateCaptureResult.Started
|
||||
assertTrue(
|
||||
source.appendHeartRateSample(
|
||||
heartCapture.captureId,
|
||||
"2026-09-22T22:30:01+02:00",
|
||||
61,
|
||||
true,
|
||||
7,
|
||||
listOf(HeartRateRrInterval(0, 1024)),
|
||||
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||
)
|
||||
assertTrue(
|
||||
source.stopHeartRateCapture(
|
||||
heartCapture.captureId,
|
||||
"2026-09-22T22:31:00+02:00",
|
||||
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||
)
|
||||
|
||||
val androidToDesktop = sourceService.capture(root, desktopPeer)
|
||||
assertEquals(
|
||||
1,
|
||||
JSONObject(
|
||||
java.io.File(androidToDesktop.stagingDirectory, "heart-rate-v1.json")
|
||||
.readText(),
|
||||
)
|
||||
.getJSONArray("captures")
|
||||
.length(),
|
||||
)
|
||||
val heartRateCorrections =
|
||||
JSONObject(
|
||||
java.io.File(
|
||||
androidToDesktop.stagingDirectory,
|
||||
"heart-rate-corrections-v1.json",
|
||||
).readText(),
|
||||
)
|
||||
assertEquals(
|
||||
"trainlog-heart-rate-corrections",
|
||||
heartRateCorrections.getString("format"),
|
||||
)
|
||||
assertEquals(0, heartRateCorrections.getJSONArray("corrections").length())
|
||||
val androidPublished =
|
||||
sourceService.publish(
|
||||
androidToDesktop,
|
||||
|
|
@ -860,10 +1310,35 @@ class SyncGenerationServiceTest {
|
|||
"--ack-output",
|
||||
androidAckFile.absolutePath,
|
||||
)
|
||||
val androidAck = JSONObject(androidAckFile.readText())
|
||||
assertEquals(
|
||||
"desktop ACK diagnostic: ${androidAck.getString("diagnostic")}",
|
||||
"consumed",
|
||||
androidAck.getString("result"),
|
||||
)
|
||||
assertEquals(
|
||||
"acknowledged",
|
||||
sourceService.acceptAcknowledgement(androidAckFile.readBytes()),
|
||||
)
|
||||
assertEquals(
|
||||
"unchanged",
|
||||
sourceService.acceptAcknowledgement(androidAckFile.readBytes()),
|
||||
)
|
||||
assertEquals(
|
||||
0,
|
||||
JSONObject(source.buildHeartRateV1Json()).getJSONArray("captures").length(),
|
||||
)
|
||||
assertEquals(
|
||||
"1|1|1",
|
||||
run(
|
||||
"sqlite3",
|
||||
desktopConsumerDb.absolutePath,
|
||||
"SELECT (SELECT count(*) FROM heart_rate_captures) || '|' || " +
|
||||
"(SELECT count(*) FROM heart_rate_samples) || '|' || " +
|
||||
"(SELECT count(*) FROM heart_rate_rr_intervals);",
|
||||
)
|
||||
.trim(),
|
||||
)
|
||||
val androidSessionId = source.listSessions().single().sessionId
|
||||
assertEquals(
|
||||
"1",
|
||||
|
|
@ -875,6 +1350,69 @@ class SyncGenerationServiceTest {
|
|||
.trim(),
|
||||
)
|
||||
|
||||
/* A second Sleep-only mutation must advance the same diary entry
|
||||
* after the first correlated ACK. No new workout is created, and
|
||||
* replaying either generation remains strictly idempotent. */
|
||||
val revisedSleep =
|
||||
source.saveSleepDiary(
|
||||
SleepDiaryDraft(
|
||||
entryId = sleepEntry.entryId,
|
||||
expectedRevision = sleepEntry.revisionId,
|
||||
nightStartDate = "2026-09-22",
|
||||
nightEndDate = "2026-09-23",
|
||||
createdAt = "2026-09-22T22:00:00+02:00",
|
||||
updatedAt = "2026-09-23T08:00:00+02:00",
|
||||
sleepQuality = null,
|
||||
wakeQuality = null,
|
||||
dayForm = null,
|
||||
treatmentAndNotes = "second sleep-only revision",
|
||||
events = emptyList(),
|
||||
),
|
||||
) as TrainlogRepository.SaveSleepDiaryResult.Saved
|
||||
assertTrue(
|
||||
source.validateSleepDiary(
|
||||
revisedSleep.entryId,
|
||||
revisedSleep.revisionId,
|
||||
"2026-09-23T08:01:00+02:00",
|
||||
) is TrainlogRepository.SaveSleepDiaryResult.Saved,
|
||||
)
|
||||
val secondSleepGeneration = sourceService.capture(root, desktopPeer)
|
||||
val secondSleepPublished =
|
||||
sourceService.publish(
|
||||
secondSleepGeneration,
|
||||
java.io.File(root, "android-desktop-objects"),
|
||||
)
|
||||
val secondAckFile = java.io.File(root, "android-desktop-second-ack.json")
|
||||
run(
|
||||
"python3",
|
||||
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath,
|
||||
"consume-desktop",
|
||||
secondSleepPublished.absolutePath,
|
||||
"--database",
|
||||
desktopConsumerDb.absolutePath,
|
||||
"--ack-output",
|
||||
secondAckFile.absolutePath,
|
||||
)
|
||||
assertEquals(
|
||||
"acknowledged",
|
||||
sourceService.acceptAcknowledgement(secondAckFile.readBytes()),
|
||||
)
|
||||
assertEquals(
|
||||
"unchanged",
|
||||
sourceService.acceptAcknowledgement(secondAckFile.readBytes()),
|
||||
)
|
||||
assertEquals(
|
||||
"1|${revisedSleep.revisionId}|1",
|
||||
run(
|
||||
"sqlite3",
|
||||
desktopConsumerDb.absolutePath,
|
||||
"SELECT count(*) || '|' || current_revision_id || '|' || " +
|
||||
"(SELECT count(*) FROM sessions WHERE session_id='$androidSessionId') " +
|
||||
"FROM sleep_diary_entries WHERE entry_id='${sleepEntry.entryId}';",
|
||||
)
|
||||
.trim(),
|
||||
)
|
||||
|
||||
run(
|
||||
java.io.File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
|
||||
desktopDb.absolutePath,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import java.nio.file.Files
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
class SyncRequestOutboxTest {
|
||||
@Test
|
||||
fun distinctExplicitRequestsAlwaysPublishFreshIdentities() {
|
||||
val directory = Files.createTempDirectory("trainlog-request-outbox-").toFile()
|
||||
try {
|
||||
val outbox = SyncRequestOutbox { directExchangeDirectoryForPath(directory) }
|
||||
val snapshot =
|
||||
(DirectExchangePublisher { directExchangeDirectoryForPath(directory) }.snapshot()
|
||||
as DirectExchangeSnapshotResult.Ready).snapshot
|
||||
val first = outbox.requestSync(snapshot) as SyncRequestResult.Requested
|
||||
val firstBytes = java.io.File(directory, "trainlog-sync-request-v1.json").readText()
|
||||
val second = outbox.requestSync(snapshot) as SyncRequestResult.Requested
|
||||
val secondBytes = java.io.File(directory, "trainlog-sync-request-v1.json").readText()
|
||||
|
||||
assertNotEquals(first.requestId, second.requestId)
|
||||
assertNotEquals(firstBytes, secondBytes)
|
||||
assertTrue(JSONObject(secondBytes).getString("request_id") == second.requestId)
|
||||
} finally {
|
||||
directory.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun oneIntentUsesSameIdentityAcrossFullGenerationAndLegacyChannels() {
|
||||
val directory = Files.createTempDirectory("trainlog-dual-request-").toFile()
|
||||
try {
|
||||
val outbox = SyncRequestOutbox { directExchangeDirectoryForPath(directory) }
|
||||
val snapshot =
|
||||
(DirectExchangePublisher { directExchangeDirectoryForPath(directory) }.snapshot()
|
||||
as DirectExchangeSnapshotResult.Ready).snapshot
|
||||
val intent = outbox.newIntent()
|
||||
|
||||
assertTrue(outbox.publishFullGeneration(snapshot, intent) is SyncRequestResult.Requested)
|
||||
assertTrue(outbox.publishLegacy(snapshot, intent) is SyncRequestResult.Requested)
|
||||
|
||||
val full = JSONObject(java.io.File(directory, FULL_GENERATION_REQUEST_NAME).readText())
|
||||
val legacy = JSONObject(java.io.File(directory, LEGACY_SYNC_REQUEST_NAME).readText())
|
||||
assertEquals(intent.requestId, full.getString("request_id"))
|
||||
assertEquals(intent.requestId, legacy.getString("request_id"))
|
||||
assertEquals(full.toString(), legacy.toString())
|
||||
} finally {
|
||||
directory.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -139,8 +222,8 @@ class AiSessionDraftUiWiringTest {
|
|||
} }
|
||||
|
||||
compose.onNodeWithText("Dos + biceps").assertIsDisplayed()
|
||||
compose.onNodeWithText("Tractions supination · 3 × 10 répétitions · repos 90 s")
|
||||
.assertIsDisplayed()
|
||||
compose.onNodeWithText("Tractions supination").assertIsDisplayed()
|
||||
compose.onNodeWithText("3 × 10 · repos 90 s").assertIsDisplayed()
|
||||
compose.onNodeWithText("Accent sur le dos et les biceps.").assertIsDisplayed()
|
||||
}
|
||||
|
||||
|
|
@ -299,6 +382,15 @@ class AiSessionDraftUiWiringTest {
|
|||
return payload
|
||||
}
|
||||
|
||||
private fun createExercise(
|
||||
name: String,
|
||||
recordingMode: RecordingMode,
|
||||
trackingMode: TrackingMode,
|
||||
) =
|
||||
(repository.createExercise(
|
||||
NewExerciseProfile(name, recordingMode, trackingMode, 0),
|
||||
) as CreateExerciseResult.Created).exercise
|
||||
|
||||
private fun artifact(exerciseId: String): String {
|
||||
val target = JSONObject().put("sets", 3).put("reps", 10)
|
||||
.put("duration_seconds", JSONObject.NULL).put("weight_kg", JSONObject.NULL)
|
||||
|
|
|
|||
|
|
@ -36,14 +36,10 @@ class AndroidUiRedesignTest {
|
|||
HomeScreen(
|
||||
activeDraft = null,
|
||||
draftError = null,
|
||||
latestSession = null,
|
||||
latestMaximum = null,
|
||||
bodyZoneOverview = BodyZoneHomeOverview(emptyList(), emptyList(), false, false),
|
||||
onSession = {},
|
||||
onOpenExercises = {},
|
||||
onOpenStatistics = {},
|
||||
onBody = { bodyCalls++ },
|
||||
onOpenLatestSession = {},
|
||||
onSync = { syncCalls++ },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,13 +36,14 @@ class AppNavigationStateTest {
|
|||
|
||||
@After fun tearDown() { context.deleteDatabase(databaseName) }
|
||||
|
||||
@Test fun sevenSectionsHaveTypedRootsAndSelectionComesFromRoute() {
|
||||
@Test fun captureSectionsHaveTypedRootsWithoutMobileStatistics() {
|
||||
assertEquals(7, AppSection.entries.size)
|
||||
assertFalse(AppSection.entries.any { it.name == "STATISTICS" })
|
||||
AppSection.entries.forEach { assertEquals(it, it.rootRoute().section) }
|
||||
assertEquals(AppSection.SESSIONS, AppRoute.SessionDetail("se_1").section)
|
||||
assertEquals(AppSection.EXERCISES, AppRoute.ExerciseDetail("ex_1").section)
|
||||
assertEquals(AppSection.EQUIPMENT, AppRoute.EquipmentDetail("eq_1").section)
|
||||
assertEquals(AppSection.STATISTICS, AppRoute.LatestMaxima.section)
|
||||
assertEquals(AppSection.EXERCISES, AppRoute.LatestMaxima.section)
|
||||
}
|
||||
|
||||
@Test fun catalogueDetailsAndCreatorsReturnToTheirDeclaredCaller() {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
package com.labfytools.trainlog.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.labfytools.trainlog.ui.theme.TrainlogTheme
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
class BodyMetricGridTest {
|
||||
@get:Rule val compose = createComposeRule()
|
||||
|
||||
private val items = listOf(
|
||||
BodyMetricItem("POIDS", "kg", "", {}),
|
||||
BodyMetricItem("COU", "cm", "", {}),
|
||||
BodyMetricItem("TOUR DE TAILLE", "cm", "", {}),
|
||||
BodyMetricItem("HANCHES", "cm", "", {}),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun deployedPhoneContentWidthUsesTwoColumns() {
|
||||
// 1080 px / 480 dpi = 360 dp; the screen frame leaves about 312 dp.
|
||||
assertEquals(2, bodyMetricColumnCount(312f, 0.9f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun genuinelyNarrowWidthStacksFields() {
|
||||
assertEquals(1, bodyMetricColumnCount(280f, 1f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun largeAccessibilityTextStacksFields() {
|
||||
assertEquals(1, bodyMetricColumnCount(312f, 1.5f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deployedPhoneWidthRendersPairedFieldsWithoutClippingUnits() {
|
||||
compose.setContent {
|
||||
TrainlogTheme {
|
||||
Column(Modifier.width(312.dp)) { BodyMetricGrid(items) }
|
||||
}
|
||||
}
|
||||
val weight = compose.onNodeWithText("POIDS").fetchSemanticsNode().boundsInRoot
|
||||
val neck = compose.onNodeWithText("COU").fetchSemanticsNode().boundsInRoot
|
||||
assertTrue(neck.left > weight.left)
|
||||
assertTrue(kotlin.math.abs(neck.top - weight.top) < 2f)
|
||||
compose.onNodeWithText("kg").fetchSemanticsNode()
|
||||
assertTrue(compose.onAllNodesWithText("cm", useUnmergedTree = true).fetchSemanticsNodes().isNotEmpty())
|
||||
compose.onNodeWithText("TOUR DE TAILLE").fetchSemanticsNode()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trulyNarrowLayoutStacksPairedFields() {
|
||||
compose.setContent {
|
||||
TrainlogTheme {
|
||||
Column(Modifier.width(280.dp)) { BodyMetricGrid(items.take(2)) }
|
||||
}
|
||||
}
|
||||
val weight = compose.onNodeWithText("POIDS").fetchSemanticsNode().boundsInRoot
|
||||
val neck = compose.onNodeWithText("COU").fetchSemanticsNode().boundsInRoot
|
||||
assertTrue(neck.top > weight.top)
|
||||
}
|
||||
}
|
||||
|
|
@ -110,12 +110,10 @@ class BodyZoneHomeSemanticsTest {
|
|||
|
||||
@Test fun practicalWidthMapSelectionFeedsExistingNavigationActions() {
|
||||
var openedZone: String? = null
|
||||
var statisticsOpened = false
|
||||
setSection(
|
||||
widthDp = 380,
|
||||
fontScale = 1f,
|
||||
onOpenExercises = { openedZone = it },
|
||||
onOpenStatistics = { statisticsOpened = true },
|
||||
)
|
||||
|
||||
assertEquals("core", bodyZoneAtPoint("front", 180f, 236f, Offset(90f, 113.28f), allZoneIds.toSet()))
|
||||
|
|
@ -126,16 +124,12 @@ class BodyZoneHomeSemanticsTest {
|
|||
compose.onNodeWithText("Voir les exercices").assertHasClickAction()
|
||||
.performSemanticsAction(SemanticsActions.OnClick)
|
||||
assertEquals("core", openedZone)
|
||||
compose.onNodeWithText("Voir les statistiques").assertHasClickAction()
|
||||
.performSemanticsAction(SemanticsActions.OnClick)
|
||||
assertTrue(statisticsOpened)
|
||||
}
|
||||
|
||||
private fun setSection(
|
||||
widthDp: Int,
|
||||
fontScale: Float,
|
||||
onOpenExercises: (String) -> Unit = {},
|
||||
onOpenStatistics: () -> Unit = {},
|
||||
) {
|
||||
val zones = allZoneIds.map { id ->
|
||||
status(id, if (id == "chest") BodyZoneHomeState.PRIORITIZE else BodyZoneHomeState.RECENT_WORK)
|
||||
|
|
@ -147,7 +141,6 @@ class BodyZoneHomeSemanticsTest {
|
|||
BodyZoneHomeSection(
|
||||
BodyZoneHomeOverview(zones, listOf(zones.first()), true, false),
|
||||
onOpenExercises,
|
||||
onOpenStatistics,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ class SyncBundlePublicationTest {
|
|||
assertTrue(feedback is SaveFeedbackResult.Saved)
|
||||
|
||||
val writesBeforeFirstClick = directory.writeCounts.toMap()
|
||||
assertTrue(publishBundleAndRequest(repository, exporter, outbox) is SyncRequestResult.Requested)
|
||||
assertTrue(publishLegacyBundleAndRequest(exporter, outbox) is SyncRequestResult.Requested)
|
||||
assertEquals(2, directory.enumerationCount)
|
||||
val required = listOf(
|
||||
"trainlog-mobile-export-v3.json",
|
||||
|
|
@ -138,7 +138,7 @@ class SyncBundlePublicationTest {
|
|||
ShadowLog.clear()
|
||||
val bodyCreatesBefore = directory.createCount("trainlog-exercise-body-zones-v1.json")
|
||||
val writesBeforeSecondClick = directory.writeCounts.toMap()
|
||||
assertTrue(publishBundleAndRequest(repository, exporter, outbox) is SyncRequestResult.Requested)
|
||||
assertTrue(publishLegacyBundleAndRequest(exporter, outbox) is SyncRequestResult.Requested)
|
||||
assertEquals(3, directory.enumerationCount)
|
||||
val phases = ShadowLog.getLogsForTag(DIRECT_EXCHANGE_LOG_TAG).map { it.msg }
|
||||
assertEquals(1, phases.count { it.startsWith("SYNC_BUNDLE coordinator.export.begin") })
|
||||
|
|
|
|||
|
|
@ -7,15 +7,22 @@ package com.labfytools.trainlog.ui
|
|||
|
||||
import android.content.Context
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.hasClickAction
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.compose.ui.test.performSemanticsAction
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.performScrollToNode
|
||||
import androidx.compose.ui.semantics.SemanticsActions
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.labfytools.trainlog.data.ActiveDraftMutationResult
|
||||
import com.labfytools.trainlog.data.ActiveDraftLoadResult
|
||||
import com.labfytools.trainlog.data.BodyZoneRecentExposure
|
||||
import com.labfytools.trainlog.data.ExposureWindowSummary
|
||||
import com.labfytools.trainlog.data.CreateExerciseResult
|
||||
import com.labfytools.trainlog.data.GenerationWarningLevel
|
||||
import com.labfytools.trainlog.data.SessionGenerationPreview
|
||||
import com.labfytools.trainlog.data.SessionGenerationPreviewExercise
|
||||
|
|
@ -26,6 +33,13 @@ import com.labfytools.trainlog.data.SyncExporter
|
|||
import com.labfytools.trainlog.data.SyncRequestOutbox
|
||||
import com.labfytools.trainlog.data.TrainlogRepository
|
||||
import com.labfytools.trainlog.model.SessionExercisePlan
|
||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||
import com.labfytools.trainlog.model.NewExerciseProfile
|
||||
import com.labfytools.trainlog.model.RecordingMode
|
||||
import com.labfytools.trainlog.model.SessionExerciseDraft
|
||||
import com.labfytools.trainlog.model.SessionLoadMode
|
||||
import com.labfytools.trainlog.model.SessionSetDraft
|
||||
import com.labfytools.trainlog.model.TrackingMode
|
||||
import com.labfytools.trainlog.ui.theme.TrainlogTheme
|
||||
import java.security.MessageDigest
|
||||
import java.util.UUID
|
||||
|
|
@ -109,6 +123,106 @@ class TrainlogAppNavigationCallbackTest {
|
|||
assertArrayEquals(before, databaseDigest())
|
||||
}
|
||||
|
||||
@Test fun activeExerciseEditorCreatesEquipmentReturnsSelectsAndSaves() {
|
||||
val exercise = (repository.createExercise(
|
||||
NewExerciseProfile(
|
||||
"Goblet squat haltère",
|
||||
RecordingMode.SETS,
|
||||
TrackingMode.REPS,
|
||||
0,
|
||||
),
|
||||
) as CreateExerciseResult.Created).exercise
|
||||
val occurrence = SessionExerciseDraft(
|
||||
exercise = exercise,
|
||||
plan = SessionExercisePlan(
|
||||
sets = 3,
|
||||
reps = 10,
|
||||
weightKg = 8.0,
|
||||
loadMode = SessionLoadMode.EXTERNAL,
|
||||
restSeconds = 90,
|
||||
),
|
||||
)
|
||||
val before = SessionExerciseDraft(
|
||||
exercise = (repository.createExercise(
|
||||
NewExerciseProfile("Marche témoin", RecordingMode.CONTINUOUS, TrackingMode.DURATION, 0),
|
||||
) as CreateExerciseResult.Created).exercise,
|
||||
continuousDurationSeconds = 600,
|
||||
)
|
||||
val after = SessionExerciseDraft(
|
||||
exercise = (repository.createExercise(
|
||||
NewExerciseProfile("Presse témoin", RecordingMode.SETS, TrackingMode.REPS, 0),
|
||||
) as CreateExerciseResult.Created).exercise,
|
||||
sets = listOf(SessionSetDraft(reps = 12)),
|
||||
)
|
||||
repository.createCustomEquipment("ZZZ équipement témoin")
|
||||
assertEquals(
|
||||
ActiveDraftMutationResult.Saved,
|
||||
repository.saveActiveSessionDraft(
|
||||
ActiveSessionDraft(exercises = listOf(before, occurrence, after)),
|
||||
),
|
||||
)
|
||||
val editorState = TrainlogAppState().also {
|
||||
it.navigation.open(AppRoute.SessionEditor)
|
||||
}
|
||||
compose.setContent {
|
||||
TrainlogTheme {
|
||||
TrainlogApp(
|
||||
repository,
|
||||
SyncExporter(context, repository),
|
||||
SyncCatalogInbox(context, repository),
|
||||
SyncRequestOutbox(context),
|
||||
editorState,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithTag("edit-draft-exercise-${occurrence.entryId}")
|
||||
.performSemanticsAction(SemanticsActions.OnClick)
|
||||
compose.onNodeWithText("SAISIE — Goblet squat haltère")
|
||||
.performScrollTo()
|
||||
.assertIsDisplayed()
|
||||
compose.onNodeWithTag("session-equipment-results")
|
||||
.performScrollToNode(hasText("ZZZ équipement témoin"))
|
||||
compose.onNodeWithTag("session-create-equipment")
|
||||
.performSemanticsAction(SemanticsActions.OnClick)
|
||||
compose.waitForIdle()
|
||||
assertEquals(AppRoute.EquipmentCreate(AppRoute.SessionEditor), editorState.navigation.route)
|
||||
assertEquals(occurrence.entryId, editorState.pendingSessionEquipmentEntryId)
|
||||
|
||||
compose.onNodeWithTag("equipment-create-name").performTextInput("Haltère")
|
||||
compose.onNodeWithText("Créer")
|
||||
.performSemanticsAction(SemanticsActions.OnClick)
|
||||
compose.waitForIdle()
|
||||
|
||||
assertEquals(AppRoute.SessionEditor, editorState.navigation.route)
|
||||
compose.onNodeWithText("SAISIE — Goblet squat haltère")
|
||||
.performScrollTo()
|
||||
.assertIsDisplayed()
|
||||
compose.onNode(
|
||||
hasText("✓ Haltère") and hasText("Équipement sélectionné."),
|
||||
).performScrollTo().assertIsDisplayed()
|
||||
compose.onNodeWithTag("add-to-session")
|
||||
.performSemanticsAction(SemanticsActions.OnClick)
|
||||
compose.runOnIdle {
|
||||
val loaded = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
|
||||
assertEquals(3, loaded.draft.exercises.size)
|
||||
assertEquals(before, loaded.draft.exercises[0])
|
||||
assertEquals(occurrence.entryId, loaded.draft.exercises[1].entryId)
|
||||
assertTrue(loaded.draft.exercises[1].sets.isEmpty())
|
||||
assertEquals(3, loaded.draft.exercises[1].plan?.sets)
|
||||
assertEquals(10, loaded.draft.exercises[1].plan?.reps)
|
||||
assertEquals(90, loaded.draft.exercises[1].plan?.restSeconds)
|
||||
assertEquals(8.0, loaded.draft.exercises[1].plan?.weightKg)
|
||||
assertEquals(after, loaded.draft.exercises[2])
|
||||
assertEquals(
|
||||
"Haltère",
|
||||
repository.listEquipment().single {
|
||||
it.equipmentId == loaded.draft.exercises[1].equipmentId
|
||||
}.displayName,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun databaseDigest(): ByteArray = MessageDigest.getInstance("SHA-256")
|
||||
.digest(context.getDatabasePath(databaseName).readBytes())
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue