Compare commits
No commits in common. "main" and "v0.1.3" have entirely different histories.
265 changed files with 1890 additions and 44364 deletions
347
CHANGELOG.md
347
CHANGELOG.md
|
|
@ -1,352 +1,5 @@
|
||||||
# Changelog
|
# 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
|
## 0.1.3 — 2026-09-20
|
||||||
|
|
||||||
- Decoupled the Android full-generation request from the strict legacy V3
|
- Decoupled the Android full-generation request from the strict legacy V3
|
||||||
|
|
|
||||||
568
README.md
568
README.md
|
|
@ -1,124 +1,372 @@
|
||||||
<p align="center">
|
# Trainlog
|
||||||
<img src="docs/assets/trainlog-banner.svg" alt="Trainlog — local-first training data system" width="100%">
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
Synchronization uses one generation/ACK engine over USB/MTP first and an
|
||||||
<img src="docs/assets/trainlog-badges.svg" alt="Trainlog badges: local-first, C17, Android, local Web, GPLv3" width="720">
|
optional private Google Drive mirror/fallback. Drive contains only versioned
|
||||||
</p>
|
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.
|
||||||
|
|
||||||
Trainlog is a **local-first system for recording, synchronizing, and examining
|
Trainlog is a local-first workout and body-data system. Trainlog Core owns
|
||||||
training and body data**. Its Android companion handles capture in the field;
|
business truth and canonical desktop persistence. Its native Android app is the
|
||||||
the C17 desktop core keeps the canonical long-term history; a keyboard-first
|
field companion, its C17/Notcurses desktop TUI is the administration and
|
||||||
Notcurses TUI and a loopback-only Web application provide complementary ways
|
technical surface, and its local browser Web sibling provides the implemented
|
||||||
to inspect and manage it.
|
Dashboard, Sessions, Programmes, and Exercises surfaces while reserving Analyse
|
||||||
|
for separately contracted work.
|
||||||
|
|
||||||
The system is built around explicit facts, stable identities, versioned JSON
|
Android and desktop each own a local SQLite database. The desktop database is
|
||||||
exchanges, and user-controlled storage. Plans never become performed work until
|
the canonical long-term history. Trainlog synchronizes versioned JSON artifacts
|
||||||
they are actually recorded, continuous activities are not disguised as sets,
|
over direct MTP; it never copies SQLite database files between devices.
|
||||||
and estimates are kept distinct from measurements.
|
|
||||||
|
|
||||||
> **Release line:** The latest stable release is
|
## Product split
|
||||||
> [`v0.1.6`](https://github.com/labfytools/trainlog/releases/tag/v0.1.6).
|
|
||||||
|
|
||||||
## ✦ Showcase
|
| Surface | Primary responsibility |
|
||||||
|
|---|---|
|
||||||
|
| Android | Capture and quickly correct sets, repetitions, loads, durations, and continuous activities; reorder active and completed-session occurrences; capture feedback, J+1 follow-ups, body measurements, and AI proposals; trigger sync; show quick summaries. |
|
||||||
|
| Desktop TUI | Administer, inspect, maintain, import/export, correct canonical history, and provide technical tools. |
|
||||||
|
| Local Web (0.1.3 development) | Display the Dashboard, the operational Programmes calendar, Sessions administration, and the desktop exercise catalogue through typed Trainlog Core/API boundaries. Exercises supports search/filter, detail, creation, editing and causal retirement; equipment is read-only. Analyse remains a placeholder. |
|
||||||
|
|
||||||
<p align="center">
|
No interface reconstructs business truth from SQLite tables. The local Web is
|
||||||
<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%">
|
a sibling adapter, not an extension of the TUI. On `main`, its loopback-only
|
||||||
</p>
|
CLI/HTTP adapter, embedded frontend, read-only Dashboard API, factual tiles,
|
||||||
|
visualizations, and private layout persistence are implemented. This does not
|
||||||
|
make the remaining placeholder routes functional.
|
||||||
|
The top-level `/programmes` route is an operational daily calendar for active
|
||||||
|
Programs. Sessions → Programmes remains the technical administration/import,
|
||||||
|
list, detail, archive, and delete surface.
|
||||||
|
Web Exercises administration is implemented for 0.1.3 development. The next
|
||||||
|
separately contracted priority is the Android UX redesign.
|
||||||
|
|
||||||
<p align="center"><sub>Web Analyse in Trainlog 0.1.6 — synchronized Sleep facts and measured heart-rate data on one timeline.</sub></p>
|
## Releases
|
||||||
|
|
||||||
The repository currently tracks real-browser evidence for the Web interface.
|
When a Trainlog release is published, its prebuilt assets are available from
|
||||||
Its Android overview asset is a clearly labelled static design study with
|
both official mirrors:
|
||||||
fictional data, not a runtime screenshot; no current TUI screenshot is tracked.
|
|
||||||
Those assets are therefore not presented here as product captures.
|
|
||||||
|
|
||||||
## ✦ Features
|
- [GitHub Releases](https://github.com/labfytools/trainlog/releases)
|
||||||
|
- [Forgejo Releases](https://git.labfytools.com/fy59/trainlog/releases)
|
||||||
|
|
||||||
| Area | Available in the current development tree |
|
Each published stable release provides:
|
||||||
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| **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. |
|
|
||||||
|
|
||||||
### Boundaries and work in progress
|
- a signed Android APK;
|
||||||
|
- a Linux x86-64 desktop runtime bundle;
|
||||||
|
- SHA-256 checksums.
|
||||||
|
|
||||||
- `TRAINLOG_FORMAT_V1` remains frozen. The active mobile snapshot is V3; V1
|
Both mirrors publish the same Trainlog product version and release assets. The
|
||||||
and V2 remain readable, while V4 codecs are staged but not selected by the
|
latest stable version is **0.1.3** on Android and desktop; this is one shared
|
||||||
active transport.
|
Trainlog version, not separate interface versions. Version 0.1.2 is the
|
||||||
- Full-generation synchronization is deployed only behind a trusted local
|
previous stable release.
|
||||||
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.
|
|
||||||
|
|
||||||
The canonical status and limitations live in
|
Trainlog presents French by default, with English selectable
|
||||||
[`docs/current_state.md`](docs/current_state.md); future work belongs in the
|
from **Settings → Language** on Android and the desktop TUI. The selection is
|
||||||
[`roadmap`](docs/roadmap.md).
|
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.
|
||||||
|
|
||||||
## ✦ Architecture
|
See the [documentation](docs/README.md) and [changelog](CHANGELOG.md) for more
|
||||||
|
details.
|
||||||
|
|
||||||
```text
|
## Installation
|
||||||
┌──────────────────────────────┐
|
|
||||||
│ Android field companion │
|
### Download a prebuilt release
|
||||||
│ private SQLite capture store │
|
|
||||||
└──────────────┬───────────────┘
|
For a published release, download Trainlog from either official mirror:
|
||||||
│ versioned JSON generations + acknowledgements
|
|
||||||
│ Bluetooth RFCOMM primary · direct USB/MTP recovery
|
- [GitHub](https://github.com/labfytools/trainlog/releases)
|
||||||
▼
|
- [Forgejo](https://git.labfytools.com/fy59/trainlog/releases)
|
||||||
┌──────────────────────────────┐ optional, separately configured
|
|
||||||
│ Synchronization orchestration│◄───── private Drive workflow
|
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
|
||||||
│ typed imports, commands and read models
|
against `SHA256SUMS` before installing them.
|
||||||
▼
|
|
||||||
┌──────────────────────────────┐
|
The Linux artifact is an architecture-specific tar archive whose published
|
||||||
│ Trainlog Core · C17 │
|
filename intentionally remains `trainlog-tui-linux-x86_64-v<version>`. It
|
||||||
│ desktop SQLite = canonical │
|
contains the TUI/Web executable, synchronization daemon and helpers, catalogs,
|
||||||
│ long-term history │
|
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>
|
||||||
│ Notcurses TUI│ │ 127.0.0.1 Web adapter│──► browser
|
tar -xf trainlog-tui-linux-x86_64-v<version> -C trainlog-v<version>
|
||||||
└──────────────┘ └──────────────────────┘
|
./trainlog-v<version>/bin/trainlog
|
||||||
```
|
```
|
||||||
|
|
||||||
The TUI, Web application, and Android client are sibling adapters with
|
The extracted bundle can remain in a user-managed installation directory.
|
||||||
different responsibilities. Business rules, calculations, persistence, and
|
Linking its launchers from `bin/` into `~/.local/bin` is optional.
|
||||||
typed read models belong to Core; presentation code does not reconstruct them
|
|
||||||
from raw database tables. See the full
|
|
||||||
[`architecture contract`](docs/architecture.md).
|
|
||||||
|
|
||||||
## ✦ Your data, your system
|
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.
|
||||||
|
|
||||||
- Trainlog runs on the user's own Android and Linux systems; no hosted Trainlog
|
Android and the TUI in each stable release share the same Trainlog product
|
||||||
account or proprietary cloud service is required for core capture, history,
|
version on both mirrors. Database schemas and JSON protocol versions are
|
||||||
analytics, or direct synchronization.
|
independent.
|
||||||
- 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.
|
|
||||||
|
|
||||||
## ✦ Build and run
|
### Daily synchronization on an opted-in installation
|
||||||
|
|
||||||
### Desktop and TUI
|
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.
|
||||||
|
|
||||||
The desktop build requires Meson, Ninja, a C17 toolchain, `pkg-config`, Python
|
### Build from source
|
||||||
3, and development files for SQLite, libuuid, utf8proc, libudev, libmtp,
|
|
||||||
Notcurses Core, GNU libmicrohttpd, and yyjson.
|
Desktop:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
meson setup build
|
||||||
|
meson compile -C build
|
||||||
|
meson test -C build --print-errorlogs
|
||||||
|
```
|
||||||
|
|
||||||
|
Android:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd android
|
||||||
|
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew test
|
||||||
|
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew assembleRelease
|
||||||
|
```
|
||||||
|
|
||||||
|
`assembleRelease` produces a distributable stable APK only when release signing
|
||||||
|
is configured by the release operator. The repository does not contain signing
|
||||||
|
credentials and does not silently generate them.
|
||||||
|
|
||||||
|
Release hosts provide `TRAINLOG_RELEASE_STORE_FILE`,
|
||||||
|
`TRAINLOG_RELEASE_STORE_PASSWORD`, `TRAINLOG_RELEASE_KEY_ALIAS`, and
|
||||||
|
`TRAINLOG_RELEASE_KEY_PASSWORD`, or a private
|
||||||
|
`~/.config/trainlog/release-signing.properties` file containing the equivalent
|
||||||
|
`storeFile`, `storePassword`, `keyAlias`, and `keyPassword` keys. Environment
|
||||||
|
variables take precedence. Keystores and local credential files are ignored by
|
||||||
|
Git; neither belongs in source control, build logs, or release notes.
|
||||||
|
|
||||||
|
The Android release key is the permanent update identity for the application.
|
||||||
|
The release operator must preserve an encrypted external backup of both the
|
||||||
|
keystore and its credentials. Losing either prevents future compatible Android
|
||||||
|
updates; generating a replacement key is not a normal release procedure.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### Linux runtime dependencies
|
||||||
|
|
||||||
|
The published v0.1.3 x86-64 desktop runtime is dynamically linked. Its tagged
|
||||||
|
source directly requires compatible versions of:
|
||||||
|
|
||||||
|
- glibc and the GCC support runtime;
|
||||||
|
- SQLite 3;
|
||||||
|
- libuuid;
|
||||||
|
- utf8proc;
|
||||||
|
- libudev;
|
||||||
|
- libmtp;
|
||||||
|
- Notcurses Core;
|
||||||
|
- the standard math library.
|
||||||
|
|
||||||
|
Version 0.1.3 additionally links GNU libmicrohttpd and yyjson for the local Web
|
||||||
|
adapter and Dashboard layout configuration. Those are not retroactive
|
||||||
|
requirements of the pre-Web v0.1.1 tagged source.
|
||||||
|
|
||||||
|
Distribution packages may pull additional transitive libraries, including
|
||||||
|
libusb, ncursesw, unistring, gpm, libgcrypt, libgpg-error, and libdeflate.
|
||||||
|
Package names and ABI versions vary by distribution; inspect the release's
|
||||||
|
`ldd` evidence and install packages from the target distribution rather than
|
||||||
|
assuming one universal package command.
|
||||||
|
|
||||||
|
### Optional runtime and integration dependencies
|
||||||
|
|
||||||
|
- Android is optional when using the desktop TUI alone. USB/MTP synchronization
|
||||||
|
requires the Android companion, libudev, libmtp, and an unlocked connected
|
||||||
|
device.
|
||||||
|
- `trainlog-syncd` installation expects a systemd user session. Manual TUI
|
||||||
|
synchronization does not require the user service.
|
||||||
|
- `rclone` is required for desktop Google Drive synchronization and automatic
|
||||||
|
AI Drive workflows. Core capture, local history, analytics, and direct MTP
|
||||||
|
synchronization continue to work without `rclone`.
|
||||||
|
|
||||||
|
### Build dependencies
|
||||||
|
|
||||||
|
Desktop builds require:
|
||||||
|
|
||||||
|
- Meson and Ninja;
|
||||||
|
- a C17 compiler toolchain;
|
||||||
|
- `pkg-config`;
|
||||||
|
- Notcurses Core development headers;
|
||||||
|
- SQLite, libuuid, utf8proc, libudev, libmtp, GNU libmicrohttpd, and yyjson
|
||||||
|
development headers for the current development source;
|
||||||
|
- Python 3 for generated sources, validators, import/export helpers, and tests.
|
||||||
|
|
||||||
|
The embedded Web frontend additionally uses Node and npm at build time only.
|
||||||
|
Prepare its exact locked dependencies explicitly, then enable the release
|
||||||
|
frontend build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
npm ci
|
||||||
|
npm run typecheck
|
||||||
|
npm test
|
||||||
|
cd ..
|
||||||
|
meson setup build -Dweb=enabled
|
||||||
|
meson compile -C build
|
||||||
|
```
|
||||||
|
|
||||||
|
Meson never runs `npm install` or downloads packages. Run `npm ci` explicitly
|
||||||
|
for the exact lockfile before configuring a Web-enabled build. The default `web=auto`
|
||||||
|
embeds the frontend when Node, npm, and the prepared `web/node_modules` are
|
||||||
|
available; otherwise it preserves a desktop-only build and `trainlog -w`
|
||||||
|
reports that frontend support is absent. Release builds must use
|
||||||
|
`-Dweb=enabled`. `-Dweb=disabled` is the explicit desktop-only choice. Node,
|
||||||
|
npm, `node_modules`, and `web/dist` are not runtime dependencies.
|
||||||
|
|
||||||
|
For an existing build directory, change the option with `meson configure
|
||||||
|
build -Dweb=enabled` (or the required value) instead of running `meson setup`
|
||||||
|
as though the directory were new.
|
||||||
|
|
||||||
|
### Optional systemd user service for local Web development
|
||||||
|
|
||||||
|
The versioned `systemd/trainlog-web.service` unit can keep `trainlog -w`
|
||||||
|
available on `127.0.0.1:8080` during an interactive user session. Without
|
||||||
|
systemd user lingering, enabling it does not guarantee startup before login.
|
||||||
|
This is a development convenience, not a requirement of the final Trainlog
|
||||||
|
distribution.
|
||||||
|
It intentionally runs the canonical user entry point
|
||||||
|
`~/.local/bin/trainlog`; in a source checkout that entry point may be a symlink
|
||||||
|
to the current `build/tui/trainlog` binary, but the symlink must resolve to an
|
||||||
|
executable before the service is started.
|
||||||
|
|
||||||
|
The unit prevents privilege acquisition, gives the process a private temporary
|
||||||
|
directory, makes system paths read-only, and applies kernel/control-group and
|
||||||
|
setuid restrictions that are compatible with the local HTTP server. It does
|
||||||
|
not protect or hide the home directory because SQLite and Trainlog
|
||||||
|
configuration must remain writable under `~/.local/share/trainlog` and
|
||||||
|
`~/.config/trainlog`. Failed starts use a ten-second restart delay and are
|
||||||
|
limited to three attempts per minute, preventing a persistent port-8080
|
||||||
|
conflict from producing an uncontrolled loop.
|
||||||
|
|
||||||
|
Install the development unit as a symlink so repository updates are picked up
|
||||||
|
after `daemon-reload`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.config/systemd/user
|
||||||
|
ln -s "$(pwd)/systemd/trainlog-web.service" ~/.config/systemd/user/trainlog-web.service
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable --now trainlog-web.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Useful lifecycle and diagnostic commands are:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl --user status trainlog-web
|
||||||
|
systemctl --user restart trainlog-web
|
||||||
|
systemctl --user stop trainlog-web
|
||||||
|
systemctl --user start trainlog-web
|
||||||
|
journalctl --user -u trainlog-web
|
||||||
|
```
|
||||||
|
|
||||||
|
An operator may separately configure a local nginx proxy such as
|
||||||
|
`http://trainlog.perf`. That hostname and proxy are machine-local conveniences,
|
||||||
|
not a public Trainlog domain or a runtime dependency, and Trainlog does not
|
||||||
|
configure them automatically.
|
||||||
|
|
||||||
|
Uninstalling the integration disables the unit before removing only its user
|
||||||
|
configuration symlink:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl --user disable --now trainlog-web.service
|
||||||
|
rm ~/.config/systemd/user/trainlog-web.service
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
```
|
||||||
|
|
||||||
|
Android builds require JDK 17, an Android SDK supporting the configured API
|
||||||
|
levels, and the Gradle wrapper committed in this repository. People installing
|
||||||
|
the APK do not need Java, Gradle, or the Android SDK.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
| Topic | Document | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| Documentation map | [docs/README.md](docs/README.md) | Canonical index and ownership map. |
|
||||||
|
| Current state | [docs/current_state.md](docs/current_state.md) | Current implemented schemas, capabilities, validation, and limitations. |
|
||||||
|
| Architecture | [docs/architecture.md](docs/architecture.md) | Component, storage, process, and ownership boundaries. |
|
||||||
|
| Android | [docs/android.md](docs/android.md) | Field-companion behavior and local persistence. |
|
||||||
|
| Desktop TUI | [docs/tui.md](docs/tui.md) | Notcurses workflows, correction, and analytics. |
|
||||||
|
| Database | [docs/database.md](docs/database.md) | Desktop persistence schema and migration contracts. |
|
||||||
|
| Exercise model | [docs/exercise_data_model.md](docs/exercise_data_model.md) | Exercise, occurrence, load, and MAX semantics. |
|
||||||
|
| Synchronization | [docs/sync_exchange.md](docs/sync_exchange.md) | Active MTP flow and companion artifacts. |
|
||||||
|
| Exchange formats | [docs/exchange_format.md](docs/exchange_format.md) | Frozen and separately versioned JSON contracts. |
|
||||||
|
| Training feedback | [docs/training_feedback.md](docs/training_feedback.md) | Immediate feedback, revisions, and J+1 follow-ups. |
|
||||||
|
| Tests | [docs/tests.md](docs/tests.md) | Durable validation commands and coverage strategy. |
|
||||||
|
| Roadmap | [docs/roadmap.md](docs/roadmap.md) | Current cursor and future work only. |
|
||||||
|
| Development contract | [AGENTS.md](AGENTS.md) | Repository invariants and contribution rules. |
|
||||||
|
| Change history | [CHANGELOG.md](CHANGELOG.md) | Chronological implementation history. |
|
||||||
|
|
||||||
|
## Architecture overview
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
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`.
|
||||||
|
|
||||||
|
## Current status
|
||||||
|
|
||||||
|
As of 2026-09-18:
|
||||||
|
|
||||||
|
- `TRAINLOG_FORMAT_V1=PASS/FROZEN`;
|
||||||
|
- desktop SQLite schema v28 and Android SQLite schema v25;
|
||||||
|
- Notcurses is the only active desktop terminal backend;
|
||||||
|
- direct storage is `/storage/emulated/0/Documents/Trainlog` under Android's
|
||||||
|
all-files access setting;
|
||||||
|
- mobile export V3 is active; V1/V2 remain readable legacy inputs; explicit V4
|
||||||
|
history and execution-draft codecs are staged but not selected by transport;
|
||||||
|
- `STATS_V1=IMPLEMENTED`, `TRAINING_KNOWLEDGE_V1=PASS`, and
|
||||||
|
`SESSION_GENERATOR_V1=PASS` (the generator is hidden pending V2);
|
||||||
|
- `APP_SHELL_V1=IMPLEMENTED_AWAITING_VISUAL_REVIEW_2`;
|
||||||
|
- `TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING` pending a real Drive plus
|
||||||
|
Android-triggered bidirectional smoke test.
|
||||||
|
- `WEB_FRONTEND_SHELL_V1=PASS/FROZEN` and
|
||||||
|
`WEB_DASHBOARD_V1=PASS/FROZEN` and `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN`;
|
||||||
|
`/programmes` is an operational active-Program calendar, while
|
||||||
|
Sessions → Programmes remains the administration surface;
|
||||||
|
`TRAINLOG_WEB_EXERCISES_V1=PASS`; Analyse remains a placeholder;
|
||||||
|
- `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=PASS`: the coordinated
|
||||||
|
private desktop/Android deployment, real Firefox presentation, correlated
|
||||||
|
`programs-v1` deletion ACK, restart/replay, and non-resurrection checks pass;
|
||||||
|
- `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`;
|
||||||
|
- `TRAINLOG_SYNC_GENERATION_ACK_V1=PASS/FROZEN` through explicit staged
|
||||||
|
services; automatic synchronization still selects V3. The trusted local
|
||||||
|
opt-in path is deployed on the paired private installation and has completed
|
||||||
|
real direct-MTP restart/replay validation; it is not a public rollout.
|
||||||
|
|
||||||
|
The latest executable result belongs in
|
||||||
|
[current state](docs/current_state.md), not in multiple README narratives.
|
||||||
|
|
||||||
|
## Desktop development quick start
|
||||||
|
|
||||||
|
Requirements include Meson, Ninja, SQLite3, utf8proc, libuuid, libudev,
|
||||||
|
libmtp, and Notcurses.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
meson setup build
|
meson setup build
|
||||||
|
|
@ -127,36 +375,13 @@ meson test -C build --print-errorlogs
|
||||||
./build/tui/trainlog
|
./build/tui/trainlog
|
||||||
```
|
```
|
||||||
|
|
||||||
The desktop database is stored at
|
The database path is `$XDG_DATA_HOME/trainlog/trainlog.db`, or
|
||||||
`$XDG_DATA_HOME/trainlog/trainlog.db`, or
|
|
||||||
`~/.local/share/trainlog/trainlog.db` when `XDG_DATA_HOME` is unset.
|
`~/.local/share/trainlog/trainlog.db` when `XDG_DATA_HOME` is unset.
|
||||||
|
|
||||||
### Embedded local Web application
|
## Android development quick start
|
||||||
|
|
||||||
Node and npm are build-time dependencies only. Meson does not download npm
|
Use Java 17 and provide the Android SDK through `ANDROID_HOME` or the untracked
|
||||||
packages.
|
`android/local.properties` file.
|
||||||
|
|
||||||
```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
|
```bash
|
||||||
cd android
|
cd android
|
||||||
|
|
@ -164,81 +389,38 @@ JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew test
|
||||||
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew assembleDebug
|
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./gradlew assembleDebug
|
||||||
```
|
```
|
||||||
|
|
||||||
Instrumented tests belong on an emulator; the primary personal phone is
|
Instrumented tests belong on an emulator. Do not run
|
||||||
reserved for manual, non-destructive installation and synchronization checks.
|
`connectedDebugAndroidTest` on the primary personal phone.
|
||||||
Release APK signing is intentionally operator-local and is not configured with
|
|
||||||
credentials from this repository.
|
|
||||||
|
|
||||||
<details>
|
## Synchronization quick start
|
||||||
<summary>Optional user-session services</summary>
|
|
||||||
|
|
||||||
After a desktop build, the repository provides an installer for the private
|
|
||||||
automatic synchronization services:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
meson compile -C build
|
||||||
bash tools/install_syncd_user.sh
|
bash tools/install_syncd_user.sh
|
||||||
systemctl --user is-active trainlog-syncd.service
|
systemctl --user is-active trainlog-syncd.service
|
||||||
```
|
```
|
||||||
|
|
||||||
This modifies the current user's `~/.local/bin` and systemd user configuration.
|
On Android, grant all-files access for the fixed `Documents/Trainlog`
|
||||||
Bluetooth generation transport starts only when its separate Trainlog
|
directory, then use **Synchronisation → Synchroniser maintenant**. Hardware MTP
|
||||||
configuration explicitly enables it. The loopback Web development unit in
|
validation requires an unlocked connected device and remains an explicit manual
|
||||||
`systemd/trainlog-web.service` is another optional integration; neither service
|
check.
|
||||||
is required for manual TUI use.
|
|
||||||
|
|
||||||
</details>
|
## Development principles
|
||||||
|
|
||||||
Prebuilt stable APKs and Linux x86-64 runtime bundles are published on both
|
- Preserve frozen, versioned compatibility boundaries.
|
||||||
official mirrors: [Forgejo Releases](https://git.labfytools.com/fy59/trainlog/releases)
|
- Use stable IDs; display names are not identities.
|
||||||
and [GitHub Releases](https://github.com/labfytools/trainlog/releases). Verify
|
- Keep performed work separate from plans and continuous activity separate
|
||||||
downloaded artifacts against the accompanying `SHA256SUMS`.
|
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.
|
||||||
|
|
||||||
## ✦ Repository layout
|
## License and project notes
|
||||||
|
|
||||||
```text
|
See [LICENSE](LICENSE). Forgejo is the primary repository; GitHub is a mirror.
|
||||||
trainlog/
|
Repository layout, detailed contracts, and historical review records are linked
|
||||||
├── android/ Kotlin + Jetpack Compose field companion
|
from [docs/README.md](docs/README.md).
|
||||||
├── 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
|
LOCAL_PERSISTENCE=IMPLEMENTED
|
||||||
DIRECT_STORAGE=IMPLEMENTED
|
DIRECT_STORAGE=IMPLEMENTED
|
||||||
MTP_SYNC=IMPLEMENTED
|
MTP_SYNC=IMPLEMENTED
|
||||||
ANDROID_SCHEMA=33
|
ANDROID_SCHEMA=17
|
||||||
```
|
```
|
||||||
|
|
||||||
## Visual contract
|
## Visual contract
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import java.time.OffsetDateTime
|
|
||||||
import java.util.Properties
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
|
|
@ -44,38 +43,7 @@ val trainlogVersionCode =
|
||||||
providers.environmentVariable("TRAINLOG_ANDROID_VERSION_CODE").orNull?.let { raw ->
|
providers.environmentVariable("TRAINLOG_ANDROID_VERSION_CODE").orNull?.let { raw ->
|
||||||
raw.toIntOrNull()?.takeIf { it in 1..Int.MAX_VALUE }
|
raw.toIntOrNull()?.takeIf { it in 1..Int.MAX_VALUE }
|
||||||
?: throw GradleException("TRAINLOG_ANDROID_VERSION_CODE must be a positive integer")
|
?: throw GradleException("TRAINLOG_ANDROID_VERSION_CODE must be a positive integer")
|
||||||
} ?: 7
|
} ?: 4
|
||||||
|
|
||||||
/*
|
|
||||||
* 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 {
|
android {
|
||||||
namespace = "com.labfytools.trainlog"
|
namespace = "com.labfytools.trainlog"
|
||||||
|
|
@ -87,9 +55,7 @@ android {
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
|
|
||||||
versionCode = trainlogVersionCode
|
versionCode = trainlogVersionCode
|
||||||
versionName = "0.1.6"
|
versionName = "0.1.3"
|
||||||
buildConfigField("String", "ACTIVE_SESSION_RECOVERY_ID", "\"$recoverySessionId\"")
|
|
||||||
buildConfigField("String", "ACTIVE_SESSION_RECOVERY_ENDED_AT", "\"$recoveryEndedAt\"")
|
|
||||||
testInstrumentationRunner =
|
testInstrumentationRunner =
|
||||||
"androidx.test.runner.AndroidJUnitRunner"
|
"androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
@ -120,7 +86,6 @@ android {
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
buildConfig = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,6 @@ class DraftUiTestActivity : ComponentActivity() {
|
||||||
sessionVisible = false
|
sessionVisible = false
|
||||||
},
|
},
|
||||||
onCreateExercise = {},
|
onCreateExercise = {},
|
||||||
onCreateEquipment = { _ -> },
|
|
||||||
/* CONTRACT: this host never writes shared export data. */
|
/* CONTRACT: this host never writes shared export data. */
|
||||||
onSessionSaved = {},
|
onSessionSaved = {},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -6,24 +6,8 @@
|
||||||
user-visible directory, Documents/Trainlog, without a SAF provider. -->
|
user-visible directory, Documents/Trainlog, without a SAF provider. -->
|
||||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<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" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
|
|
@ -49,10 +33,6 @@
|
||||||
<service
|
<service
|
||||||
android:name=".data.SyncBackgroundService"
|
android:name=".data.SyncBackgroundService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="connectedDevice|dataSync" />
|
android:foregroundServiceType="dataSync" />
|
||||||
<service
|
|
||||||
android:name=".data.HeartRateSensorService"
|
|
||||||
android:exported="false"
|
|
||||||
android:foregroundServiceType="connectedDevice" />
|
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,6 @@ import com.labfytools.trainlog.data.TrainlogRepository
|
||||||
import com.labfytools.trainlog.data.SyncExporter
|
import com.labfytools.trainlog.data.SyncExporter
|
||||||
import com.labfytools.trainlog.data.SyncCatalogInbox
|
import com.labfytools.trainlog.data.SyncCatalogInbox
|
||||||
import com.labfytools.trainlog.data.SyncRequestOutbox
|
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.TrainlogApp
|
||||||
import com.labfytools.trainlog.ui.TrainlogAppState
|
import com.labfytools.trainlog.ui.TrainlogAppState
|
||||||
import com.labfytools.trainlog.ui.LanguagePresentation
|
import com.labfytools.trainlog.ui.LanguagePresentation
|
||||||
|
|
@ -36,19 +30,6 @@ class MainActivity : ComponentActivity() {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
languageSettings = LanguageSettingsOwner(applicationContext)
|
languageSettings = LanguageSettingsOwner(applicationContext)
|
||||||
|
|
||||||
if (
|
|
||||||
BackgroundSyncSettings(applicationContext).enabled &&
|
|
||||||
hasBluetoothConnectPermission(applicationContext)
|
|
||||||
) {
|
|
||||||
SyncBackgroundService.start(applicationContext)
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
HeartRateSensorPreferences(applicationContext).selected() != null &&
|
|
||||||
hasHeartRateBleRuntimePermissions(applicationContext)
|
|
||||||
) {
|
|
||||||
HeartRateSensorService.start(applicationContext)
|
|
||||||
}
|
|
||||||
|
|
||||||
val repository =
|
val repository =
|
||||||
TrainlogRepository(
|
TrainlogRepository(
|
||||||
applicationContext
|
applicationContext
|
||||||
|
|
|
||||||
|
|
@ -1,235 +0,0 @@
|
||||||
package com.labfytools.trainlog.data
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.documentfile.provider.DocumentFile
|
|
||||||
import androidx.work.Constraints
|
|
||||||
import androidx.work.CoroutineWorker
|
|
||||||
import androidx.work.ExistingPeriodicWorkPolicy
|
|
||||||
import androidx.work.ExistingWorkPolicy
|
|
||||||
import androidx.work.NetworkType
|
|
||||||
import androidx.work.OneTimeWorkRequestBuilder
|
|
||||||
import androidx.work.PeriodicWorkRequestBuilder
|
|
||||||
import androidx.work.WorkManager
|
|
||||||
import androidx.work.WorkerParameters
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileOutputStream
|
|
||||||
import java.security.MessageDigest
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
import org.json.JSONObject
|
|
||||||
|
|
||||||
internal const val AI_EXPORT_FILENAME = "trainlog_ai_export_v1.json"
|
|
||||||
|
|
||||||
internal sealed interface AiExportDriveResult {
|
|
||||||
data object NotConfigured : AiExportDriveResult
|
|
||||||
data object Missing : AiExportDriveResult
|
|
||||||
data object Unchanged : AiExportDriveResult
|
|
||||||
data class Published(val sha256: String) : AiExportDriveResult
|
|
||||||
data class Error(val message: String) : AiExportDriveResult
|
|
||||||
}
|
|
||||||
|
|
||||||
internal class AiExportDriveSettings(private val context: Context) {
|
|
||||||
private val preferences =
|
|
||||||
context.applicationContext.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
|
||||||
|
|
||||||
val treeUri: Uri?
|
|
||||||
get() = preferences.getString(TREE, null)?.let(Uri::parse)
|
|
||||||
|
|
||||||
val connected: Boolean
|
|
||||||
get() = treeUri != null
|
|
||||||
|
|
||||||
fun connect(uri: Uri) {
|
|
||||||
context.contentResolver.takePersistableUriPermission(
|
|
||||||
uri,
|
|
||||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
|
|
||||||
)
|
|
||||||
preferences.edit().putString(TREE, uri.toString()).apply()
|
|
||||||
schedule(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun disconnect() {
|
|
||||||
treeUri?.let { uri ->
|
|
||||||
runCatching {
|
|
||||||
context.contentResolver.releasePersistableUriPermission(
|
|
||||||
uri,
|
|
||||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
preferences.edit()
|
|
||||||
.remove(TREE)
|
|
||||||
.remove(LAST_SHA256)
|
|
||||||
.remove(LAST_SIZE)
|
|
||||||
.remove(LAST_MODIFIED)
|
|
||||||
.apply()
|
|
||||||
WorkManager.getInstance(context).cancelUniqueWork(PERIODIC_WORK)
|
|
||||||
WorkManager.getInstance(context).cancelUniqueWork(IMMEDIATE_WORK)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun alreadyPublished(source: File, sha256: String): Boolean =
|
|
||||||
preferences.getString(LAST_SHA256, null) == sha256 &&
|
|
||||||
preferences.getLong(LAST_SIZE, -1L) == source.length() &&
|
|
||||||
preferences.getLong(LAST_MODIFIED, -1L) == source.lastModified()
|
|
||||||
|
|
||||||
fun needsPublication(source: File): Boolean =
|
|
||||||
connected &&
|
|
||||||
source.isFile &&
|
|
||||||
(
|
|
||||||
preferences.getString(LAST_SHA256, null) == null ||
|
|
||||||
preferences.getLong(LAST_SIZE, -1L) != source.length() ||
|
|
||||||
preferences.getLong(LAST_MODIFIED, -1L) != source.lastModified()
|
|
||||||
)
|
|
||||||
|
|
||||||
fun recordPublished(source: File, sha256: String) {
|
|
||||||
preferences.edit()
|
|
||||||
.putString(LAST_SHA256, sha256)
|
|
||||||
.putLong(LAST_SIZE, source.length())
|
|
||||||
.putLong(LAST_MODIFIED, source.lastModified())
|
|
||||||
.apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val PREFERENCES = "trainlog-ai-export-drive"
|
|
||||||
private const val TREE = "tree_uri"
|
|
||||||
private const val LAST_SHA256 = "last_sha256"
|
|
||||||
private const val LAST_SIZE = "last_size"
|
|
||||||
private const val LAST_MODIFIED = "last_modified"
|
|
||||||
private const val PERIODIC_WORK = "trainlog-ai-export-drive-periodic-v1"
|
|
||||||
private const val IMMEDIATE_WORK = "trainlog-ai-export-drive-now-v1"
|
|
||||||
|
|
||||||
fun sourceFile(): File = File(canonicalExchangeDirectory(), AI_EXPORT_FILENAME)
|
|
||||||
|
|
||||||
fun schedule(context: Context) {
|
|
||||||
val constraints =
|
|
||||||
Constraints.Builder()
|
|
||||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
|
||||||
.build()
|
|
||||||
val periodic =
|
|
||||||
PeriodicWorkRequestBuilder<AiExportDriveWorker>(15, TimeUnit.MINUTES)
|
|
||||||
.setConstraints(constraints)
|
|
||||||
.build()
|
|
||||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
|
||||||
PERIODIC_WORK,
|
|
||||||
ExistingPeriodicWorkPolicy.UPDATE,
|
|
||||||
periodic,
|
|
||||||
)
|
|
||||||
enqueueImmediate(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun enqueueImmediate(context: Context) {
|
|
||||||
val settings = AiExportDriveSettings(context)
|
|
||||||
val source = sourceFile()
|
|
||||||
if (!settings.needsPublication(source)) return
|
|
||||||
val request =
|
|
||||||
OneTimeWorkRequestBuilder<AiExportDriveWorker>()
|
|
||||||
.setConstraints(
|
|
||||||
Constraints.Builder()
|
|
||||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
WorkManager.getInstance(context).enqueueUniqueWork(
|
|
||||||
IMMEDIATE_WORK,
|
|
||||||
ExistingWorkPolicy.KEEP,
|
|
||||||
request,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal class AiExportDrivePublisher(private val context: Context) {
|
|
||||||
private val settings = AiExportDriveSettings(context)
|
|
||||||
|
|
||||||
private fun sha256(file: File): String {
|
|
||||||
val digest = MessageDigest.getInstance("SHA-256")
|
|
||||||
file.inputStream().use { input ->
|
|
||||||
val buffer = ByteArray(64 * 1024)
|
|
||||||
while (true) {
|
|
||||||
val count = input.read(buffer)
|
|
||||||
if (count < 0) break
|
|
||||||
digest.update(buffer, 0, count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return digest.digest().joinToString("") { "%02x".format(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun validateSource(source: File) {
|
|
||||||
require(source.isFile && !java.nio.file.Files.isSymbolicLink(source.toPath())) {
|
|
||||||
"Export IA local indisponible."
|
|
||||||
}
|
|
||||||
require(source.length() in 1..64L * 1024L * 1024L) {
|
|
||||||
"Export IA local invalide."
|
|
||||||
}
|
|
||||||
val root =
|
|
||||||
source.inputStream().bufferedReader(Charsets.UTF_8).use { reader ->
|
|
||||||
JSONObject(reader.readText())
|
|
||||||
}
|
|
||||||
require(
|
|
||||||
root.optString("format") == "TRAINLOG_AI_EXPORT" &&
|
|
||||||
root.optInt("version", -1) == 1
|
|
||||||
) { "Format d’export IA invalide." }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun publishPending(): AiExportDriveResult {
|
|
||||||
val uri = settings.treeUri ?: return AiExportDriveResult.NotConfigured
|
|
||||||
val source = AiExportDriveSettings.sourceFile()
|
|
||||||
if (!source.isFile) return AiExportDriveResult.Missing
|
|
||||||
return try {
|
|
||||||
validateSource(source)
|
|
||||||
val sourceSha = sha256(source)
|
|
||||||
if (settings.alreadyPublished(source, sourceSha)) {
|
|
||||||
return AiExportDriveResult.Unchanged
|
|
||||||
}
|
|
||||||
val root =
|
|
||||||
DocumentFile.fromTreeUri(context, uri)
|
|
||||||
?: return AiExportDriveResult.Error("Dossier Drive IA indisponible.")
|
|
||||||
require(root.isDirectory) { "Dossier Drive IA invalide." }
|
|
||||||
val destination =
|
|
||||||
root.findFile(AI_EXPORT_FILENAME)
|
|
||||||
?: root.createFile("application/json", AI_EXPORT_FILENAME)
|
|
||||||
?: throw IllegalStateException("Impossible de créer l’export IA sur Drive.")
|
|
||||||
require(destination.isFile) { "Destination Drive IA invalide." }
|
|
||||||
|
|
||||||
context.contentResolver.openOutputStream(destination.uri, "wt")?.use { output ->
|
|
||||||
source.inputStream().use { input -> input.copyTo(output, 64 * 1024) }
|
|
||||||
output.flush()
|
|
||||||
} ?: throw IllegalStateException("Impossible d’écrire l’export IA sur Drive.")
|
|
||||||
|
|
||||||
val verification =
|
|
||||||
File.createTempFile("trainlog-ai-drive-verify-", ".json", context.cacheDir)
|
|
||||||
try {
|
|
||||||
context.contentResolver.openInputStream(destination.uri)?.use { input ->
|
|
||||||
FileOutputStream(verification).use { output ->
|
|
||||||
input.copyTo(output, 64 * 1024)
|
|
||||||
output.fd.sync()
|
|
||||||
}
|
|
||||||
} ?: throw IllegalStateException("Impossible de relire l’export IA Drive.")
|
|
||||||
require(
|
|
||||||
verification.length() == source.length() &&
|
|
||||||
sha256(verification) == sourceSha
|
|
||||||
) { "Vérification de l’export IA Drive échouée." }
|
|
||||||
} finally {
|
|
||||||
verification.delete()
|
|
||||||
}
|
|
||||||
settings.recordPublished(source, sourceSha)
|
|
||||||
AiExportDriveResult.Published(sourceSha)
|
|
||||||
} catch (error: Exception) {
|
|
||||||
AiExportDriveResult.Error(error.message ?: error.javaClass.simpleName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class AiExportDriveWorker(
|
|
||||||
context: Context,
|
|
||||||
parameters: WorkerParameters,
|
|
||||||
) : CoroutineWorker(context, parameters) {
|
|
||||||
override suspend fun doWork(): Result =
|
|
||||||
when (AiExportDrivePublisher(applicationContext).publishPending()) {
|
|
||||||
AiExportDriveResult.NotConfigured,
|
|
||||||
AiExportDriveResult.Missing,
|
|
||||||
AiExportDriveResult.Unchanged,
|
|
||||||
is AiExportDriveResult.Published -> Result.success()
|
|
||||||
is AiExportDriveResult.Error -> Result.retry()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -225,7 +225,7 @@ internal class AndroidBackupService(
|
||||||
manifest.getInt("version") == VERSION &&
|
manifest.getInt("version") == VERSION &&
|
||||||
manifest.getString("package") == context.packageName &&
|
manifest.getString("package") == context.packageName &&
|
||||||
manifest.getBoolean("plaintext") &&
|
manifest.getBoolean("plaintext") &&
|
||||||
manifest.getInt("schema") in 17..33
|
manifest.getInt("schema") in 17..25
|
||||||
) {
|
) {
|
||||||
"Unsupported backup."
|
"Unsupported backup."
|
||||||
}
|
}
|
||||||
|
|
@ -319,7 +319,7 @@ internal class AndroidBackupService(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() })
|
require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() })
|
||||||
require(db.version in 17..33) { "Unsupported Android schema ${db.version}." }
|
require(db.version in 17..25) { "Unsupported Android schema ${db.version}." }
|
||||||
return db.version
|
return db.version
|
||||||
} finally {
|
} finally {
|
||||||
db.close()
|
db.close()
|
||||||
|
|
|
||||||
|
|
@ -1,562 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,190 +0,0 @@
|
||||||
package com.labfytools.trainlog.data
|
|
||||||
|
|
||||||
import com.labfytools.trainlog.model.CardioGuidanceInstruction
|
|
||||||
import com.labfytools.trainlog.model.CardioGuidanceOutput
|
|
||||||
import com.labfytools.trainlog.model.CardioTargetDefinition
|
|
||||||
import com.labfytools.trainlog.model.CardioTargetSnapshot
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
internal object CardioTargetResolver {
|
|
||||||
fun resolve(definition: CardioTargetDefinition): CardioTargetSnapshot? =
|
|
||||||
when (definition) {
|
|
||||||
is CardioTargetDefinition.AbsoluteBpm -> {
|
|
||||||
if (!validBounds(definition.minimumBpm, definition.maximumBpm)) return null
|
|
||||||
CardioTargetSnapshot(
|
|
||||||
minimumBpm = definition.minimumBpm,
|
|
||||||
maximumBpm = definition.maximumBpm,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is CardioTargetDefinition.CalibrationPercent -> {
|
|
||||||
if (
|
|
||||||
definition.calibrationId.isBlank() ||
|
|
||||||
definition.observedPeakBpm !in 1..65535 ||
|
|
||||||
definition.minimumPercent !in 1..100 ||
|
|
||||||
definition.maximumPercent !in 1..100 ||
|
|
||||||
definition.minimumPercent > definition.maximumPercent
|
|
||||||
) return null
|
|
||||||
val minimum =
|
|
||||||
(definition.observedPeakBpm * definition.minimumPercent / 100.0)
|
|
||||||
.roundToInt()
|
|
||||||
val maximum =
|
|
||||||
(definition.observedPeakBpm * definition.maximumPercent / 100.0)
|
|
||||||
.roundToInt()
|
|
||||||
if (!validBounds(minimum, maximum)) return null
|
|
||||||
CardioTargetSnapshot(
|
|
||||||
minimumBpm = minimum,
|
|
||||||
maximumBpm = maximum,
|
|
||||||
calibrationId = definition.calibrationId,
|
|
||||||
calibrationObservedPeakBpm = definition.observedPeakBpm,
|
|
||||||
minimumPercent = definition.minimumPercent,
|
|
||||||
maximumPercent = definition.maximumPercent,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun validBounds(minimum: Int, maximum: Int): Boolean =
|
|
||||||
minimum in 1..65535 && maximum in 1..65535 && minimum <= maximum
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure state machine for BPM guidance.
|
|
||||||
*
|
|
||||||
* Hysteresis and confirmation time are UI/control stability parameters, not
|
|
||||||
* physiological zones. The engine never extrapolates a stale measurement.
|
|
||||||
*/
|
|
||||||
internal object CardioPhaseCompletion {
|
|
||||||
fun isComplete(
|
|
||||||
phase: com.labfytools.trainlog.model.CardioGuidedPhase,
|
|
||||||
elapsedSeconds: Int,
|
|
||||||
bpm: Int?,
|
|
||||||
fresh: Boolean,
|
|
||||||
): Boolean {
|
|
||||||
if (elapsedSeconds < 0) return false
|
|
||||||
return when (val exit = phase.exitCondition) {
|
|
||||||
is com.labfytools.trainlog.model.CardioPhaseExitCondition.FixedDuration ->
|
|
||||||
elapsedSeconds >= exit.seconds
|
|
||||||
|
|
||||||
com.labfytools.trainlog.model.CardioPhaseExitCondition.EnterTarget -> {
|
|
||||||
val target = phase.target ?: return false
|
|
||||||
fresh && bpm != null && bpm in target.minimumBpm..target.maximumBpm
|
|
||||||
}
|
|
||||||
|
|
||||||
is com.labfytools.trainlog.model.CardioPhaseExitCondition.RecoverBelow ->
|
|
||||||
fresh && bpm != null && bpm <= exit.bpm
|
|
||||||
|
|
||||||
is com.labfytools.trainlog.model.CardioPhaseExitCondition.DurationOrRecoverBelow ->
|
|
||||||
elapsedSeconds >= exit.maximumSeconds ||
|
|
||||||
(fresh && bpm != null && bpm <= exit.bpm)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal class CardioGuidanceEngine(
|
|
||||||
private val hysteresisBpm: Int = DEFAULT_HYSTERESIS_BPM,
|
|
||||||
private val confirmationMs: Long = DEFAULT_CONFIRMATION_MS,
|
|
||||||
initialInstruction: CardioGuidanceInstruction = CardioGuidanceInstruction.MAINTAIN,
|
|
||||||
) {
|
|
||||||
private var current =
|
|
||||||
initialInstruction.takeUnless { it == CardioGuidanceInstruction.SUSPENDED }
|
|
||||||
?: CardioGuidanceInstruction.MAINTAIN
|
|
||||||
private var candidate: CardioGuidanceInstruction? = null
|
|
||||||
private var candidateSinceMs: Long? = null
|
|
||||||
|
|
||||||
init {
|
|
||||||
require(hysteresisBpm in 0..20)
|
|
||||||
require(confirmationMs in 0L..30_000L)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun reset() {
|
|
||||||
current = CardioGuidanceInstruction.MAINTAIN
|
|
||||||
candidate = null
|
|
||||||
candidateSinceMs = null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun evaluate(
|
|
||||||
target: CardioTargetSnapshot?,
|
|
||||||
bpm: Int?,
|
|
||||||
fresh: Boolean,
|
|
||||||
nowMs: Long,
|
|
||||||
): CardioGuidanceOutput {
|
|
||||||
require(nowMs >= 0L)
|
|
||||||
if (!fresh || bpm == null || bpm !in 1..65535 || target == null) {
|
|
||||||
candidate = null
|
|
||||||
candidateSinceMs = null
|
|
||||||
return CardioGuidanceOutput(
|
|
||||||
instruction = CardioGuidanceInstruction.SUSPENDED,
|
|
||||||
bpm = null,
|
|
||||||
target = target,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
require(target.minimumBpm in 1..65535)
|
|
||||||
require(target.maximumBpm in target.minimumBpm..65535)
|
|
||||||
|
|
||||||
val desired = desiredInstruction(target, bpm)
|
|
||||||
if (desired == current) {
|
|
||||||
candidate = null
|
|
||||||
candidateSinceMs = null
|
|
||||||
return CardioGuidanceOutput(current, bpm, target)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (candidate != desired) {
|
|
||||||
candidate = desired
|
|
||||||
candidateSinceMs = nowMs
|
|
||||||
}
|
|
||||||
val since = checkNotNull(candidateSinceMs)
|
|
||||||
if (nowMs - since >= confirmationMs) {
|
|
||||||
current = desired
|
|
||||||
candidate = null
|
|
||||||
candidateSinceMs = null
|
|
||||||
}
|
|
||||||
|
|
||||||
return CardioGuidanceOutput(
|
|
||||||
instruction = current,
|
|
||||||
bpm = bpm,
|
|
||||||
target = target,
|
|
||||||
pendingInstruction = candidate,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun desiredInstruction(
|
|
||||||
target: CardioTargetSnapshot,
|
|
||||||
bpm: Int,
|
|
||||||
): CardioGuidanceInstruction {
|
|
||||||
// A target entered from either side immediately trends toward MAINTAIN.
|
|
||||||
if (bpm in target.minimumBpm..target.maximumBpm) {
|
|
||||||
return CardioGuidanceInstruction.MAINTAIN
|
|
||||||
}
|
|
||||||
|
|
||||||
return when (current) {
|
|
||||||
CardioGuidanceInstruction.ACCELERATE ->
|
|
||||||
if (bpm >= target.minimumBpm + hysteresisBpm) {
|
|
||||||
CardioGuidanceInstruction.MAINTAIN
|
|
||||||
} else {
|
|
||||||
CardioGuidanceInstruction.ACCELERATE
|
|
||||||
}
|
|
||||||
|
|
||||||
CardioGuidanceInstruction.SLOW_DOWN ->
|
|
||||||
if (bpm <= target.maximumBpm - hysteresisBpm) {
|
|
||||||
CardioGuidanceInstruction.MAINTAIN
|
|
||||||
} else {
|
|
||||||
CardioGuidanceInstruction.SLOW_DOWN
|
|
||||||
}
|
|
||||||
|
|
||||||
CardioGuidanceInstruction.MAINTAIN,
|
|
||||||
CardioGuidanceInstruction.SUSPENDED ->
|
|
||||||
when {
|
|
||||||
bpm < target.minimumBpm - hysteresisBpm ->
|
|
||||||
CardioGuidanceInstruction.ACCELERATE
|
|
||||||
bpm > target.maximumBpm + hysteresisBpm ->
|
|
||||||
CardioGuidanceInstruction.SLOW_DOWN
|
|
||||||
else -> CardioGuidanceInstruction.MAINTAIN
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val DEFAULT_HYSTERESIS_BPM = 2
|
|
||||||
const val DEFAULT_CONFIRMATION_MS = 3_000L
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
package com.labfytools.trainlog.data
|
|
||||||
|
|
||||||
import com.labfytools.trainlog.model.CardioGuidanceInstruction
|
|
||||||
import com.labfytools.trainlog.model.CardioPhaseKind
|
|
||||||
import com.labfytools.trainlog.model.CardioTargetSnapshot
|
|
||||||
import java.util.concurrent.CopyOnWriteArraySet
|
|
||||||
|
|
||||||
internal data class CardioGuidanceLiveSnapshot(
|
|
||||||
val phaseId: String? = null,
|
|
||||||
val phaseKind: CardioPhaseKind? = null,
|
|
||||||
val instruction: CardioGuidanceInstruction = CardioGuidanceInstruction.SUSPENDED,
|
|
||||||
val bpm: Int? = null,
|
|
||||||
val target: CardioTargetSnapshot? = null,
|
|
||||||
val startedAt: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
internal object CardioGuidanceLiveState {
|
|
||||||
private val listeners =
|
|
||||||
CopyOnWriteArraySet<(CardioGuidanceLiveSnapshot) -> Unit>()
|
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var snapshot = CardioGuidanceLiveSnapshot()
|
|
||||||
|
|
||||||
fun current(): CardioGuidanceLiveSnapshot = snapshot
|
|
||||||
|
|
||||||
fun publish(next: CardioGuidanceLiveSnapshot) {
|
|
||||||
snapshot = next
|
|
||||||
listeners.forEach { it(next) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clear() {
|
|
||||||
publish(CardioGuidanceLiveSnapshot())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun subscribe(listener: (CardioGuidanceLiveSnapshot) -> Unit): AutoCloseable {
|
|
||||||
listeners += listener
|
|
||||||
listener(snapshot)
|
|
||||||
return AutoCloseable { listeners -= listener }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -195,8 +195,7 @@ internal class DriveDocumentTransport(private val context: Context, treeUri: Uri
|
||||||
localRoot.mkdirs()
|
localRoot.mkdirs()
|
||||||
val coordination = listOf(
|
val coordination = listOf(
|
||||||
"android-peer-v1.json", "android-generation-v1.json", "android-consumption-ack-v1.json",
|
"android-peer-v1.json", "android-generation-v1.json", "android-consumption-ack-v1.json",
|
||||||
"android-archive-acknowledgements-v1.json", "android-generation-error-v1.json",
|
"android-generation-error-v1.json", "request-v1.json",
|
||||||
"request-v1.json",
|
|
||||||
"desktop-archive-acknowledgements-v1.json", "desktop-consumption-ack-v1.json",
|
"desktop-archive-acknowledgements-v1.json", "desktop-consumption-ack-v1.json",
|
||||||
"desktop-generation-v1.json",
|
"desktop-generation-v1.json",
|
||||||
)
|
)
|
||||||
|
|
@ -262,13 +261,6 @@ internal class DriveDocumentTransport(private val context: Context, treeUri: Uri
|
||||||
upload("android-generation-v1.json", reference)
|
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) {
|
fun pushAcknowledgement(localRoot: File) {
|
||||||
upload(
|
upload(
|
||||||
"android-consumption-ack-v1.json",
|
"android-consumption-ack-v1.json",
|
||||||
|
|
@ -326,10 +318,6 @@ class DriveSyncWorker(context: Context, parameters: WorkerParameters) :
|
||||||
local,
|
local,
|
||||||
Duration.ofMinutes(10),
|
Duration.ofMinutes(10),
|
||||||
afterPeerPublication = { transport.pushPeer(local) },
|
afterPeerPublication = { transport.pushPeer(local) },
|
||||||
afterRecoveryAcknowledgements = {
|
|
||||||
stage = "recovery-acknowledgement"
|
|
||||||
transport.pushRecoveryAcknowledgements(local)
|
|
||||||
},
|
|
||||||
afterPublication = {
|
afterPublication = {
|
||||||
stage = "generation-publication"
|
stage = "generation-publication"
|
||||||
transport.pushGeneration(local)
|
transport.pushGeneration(local)
|
||||||
|
|
|
||||||
|
|
@ -1,482 +0,0 @@
|
||||||
/*
|
|
||||||
* Android-only BLE Heart Rate diagnostics.
|
|
||||||
*
|
|
||||||
* This owner is intentionally bounded and screen-scoped. Long-running capture
|
|
||||||
* belongs to the later foreground acquisition service, not this diagnostic.
|
|
||||||
*/
|
|
||||||
package com.labfytools.trainlog.data
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.bluetooth.BluetoothDevice
|
|
||||||
import android.bluetooth.BluetoothGatt
|
|
||||||
import android.bluetooth.BluetoothGattCallback
|
|
||||||
import android.bluetooth.BluetoothGattCharacteristic
|
|
||||||
import android.bluetooth.BluetoothGattDescriptor
|
|
||||||
import android.bluetooth.BluetoothManager
|
|
||||||
import android.bluetooth.BluetoothProfile
|
|
||||||
import android.bluetooth.BluetoothStatusCodes
|
|
||||||
import android.bluetooth.le.ScanCallback
|
|
||||||
import android.bluetooth.le.ScanFilter
|
|
||||||
import android.bluetooth.le.ScanResult
|
|
||||||
import android.bluetooth.le.ScanSettings
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import android.os.ParcelUuid
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
internal val HEART_RATE_SERVICE_UUID: UUID =
|
|
||||||
UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb")
|
|
||||||
internal val HEART_RATE_MEASUREMENT_UUID: UUID =
|
|
||||||
UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb")
|
|
||||||
internal val BATTERY_SERVICE_UUID: UUID =
|
|
||||||
UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")
|
|
||||||
internal val CLIENT_CHARACTERISTIC_CONFIG_UUID: UUID =
|
|
||||||
UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
|
||||||
|
|
||||||
internal enum class HeartRateDiagnosticPhase {
|
|
||||||
IDLE,
|
|
||||||
SCANNING,
|
|
||||||
CONNECTING,
|
|
||||||
DISCOVERING,
|
|
||||||
SUBSCRIBING,
|
|
||||||
RECEIVING,
|
|
||||||
DISCONNECTED,
|
|
||||||
ERROR,
|
|
||||||
}
|
|
||||||
|
|
||||||
internal data class HeartRateDiagnosticDevice(
|
|
||||||
val address: String,
|
|
||||||
val name: String?,
|
|
||||||
val rssi: Int,
|
|
||||||
val advertisesHeartRate: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
internal data class HeartRateDiagnosticSnapshot(
|
|
||||||
val phase: HeartRateDiagnosticPhase = HeartRateDiagnosticPhase.IDLE,
|
|
||||||
val devices: List<HeartRateDiagnosticDevice> = emptyList(),
|
|
||||||
val selectedAddress: String? = null,
|
|
||||||
val serviceFound: Boolean = false,
|
|
||||||
val measurementFound: Boolean = false,
|
|
||||||
val batteryServiceFound: Boolean = false,
|
|
||||||
val notificationCount: Int = 0,
|
|
||||||
val latestMeasurement: ParsedHeartRateMeasurement? = null,
|
|
||||||
val diagnostic: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
internal fun heartRateBleRuntimePermissions(): Array<String> =
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
||||||
arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
|
|
||||||
} else {
|
|
||||||
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun hasHeartRateBleRuntimePermissions(context: Context): Boolean =
|
|
||||||
heartRateBleRuntimePermissions().all {
|
|
||||||
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
|
|
||||||
}
|
|
||||||
|
|
||||||
internal class HeartRateBleDiagnostics(
|
|
||||||
context: Context,
|
|
||||||
private val onSnapshot: (HeartRateDiagnosticSnapshot) -> Unit,
|
|
||||||
) : AutoCloseable {
|
|
||||||
private val appContext = context.applicationContext
|
|
||||||
private val main = Handler(Looper.getMainLooper())
|
|
||||||
private val bluetoothManager =
|
|
||||||
appContext.getSystemService(BluetoothManager::class.java)
|
|
||||||
private val adapter get() = bluetoothManager?.adapter
|
|
||||||
private val devices = linkedMapOf<String, BluetoothDevice>()
|
|
||||||
private var snapshot = HeartRateDiagnosticSnapshot()
|
|
||||||
private var scannerCallback: ScanCallback? = null
|
|
||||||
private var scanStop: Runnable? = null
|
|
||||||
private var gatt: BluetoothGatt? = null
|
|
||||||
private var closed = false
|
|
||||||
|
|
||||||
private fun update(transform: (HeartRateDiagnosticSnapshot) -> HeartRateDiagnosticSnapshot) {
|
|
||||||
if (closed) return
|
|
||||||
if (Looper.myLooper() != Looper.getMainLooper()) {
|
|
||||||
main.post { update(transform) }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
snapshot = transform(snapshot)
|
|
||||||
onSnapshot(snapshot)
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
|
||||||
fun startScan(includeAllBle: Boolean = false) {
|
|
||||||
if (!hasHeartRateBleRuntimePermissions(appContext)) {
|
|
||||||
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "permission") }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val currentAdapter = adapter
|
|
||||||
if (currentAdapter == null) {
|
|
||||||
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "bluetooth_unavailable") }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!currentAdapter.isEnabled) {
|
|
||||||
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "bluetooth_disabled") }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val scanner = currentAdapter.bluetoothLeScanner
|
|
||||||
if (scanner == null) {
|
|
||||||
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "scanner_unavailable") }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
stopScan()
|
|
||||||
devices.clear()
|
|
||||||
update {
|
|
||||||
HeartRateDiagnosticSnapshot(
|
|
||||||
phase = HeartRateDiagnosticPhase.SCANNING,
|
|
||||||
diagnostic = if (includeAllBle) "scan_all" else "scan_heart_rate",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val callback =
|
|
||||||
object : ScanCallback() {
|
|
||||||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
|
||||||
handleScanResult(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBatchScanResults(results: MutableList<ScanResult>) {
|
|
||||||
results.forEach(::handleScanResult)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onScanFailed(errorCode: Int) {
|
|
||||||
update {
|
|
||||||
it.copy(
|
|
||||||
phase = HeartRateDiagnosticPhase.ERROR,
|
|
||||||
diagnostic = "scan_failed:" + errorCode,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
scannerCallback = callback
|
|
||||||
val filters =
|
|
||||||
if (includeAllBle) {
|
|
||||||
emptyList()
|
|
||||||
} else {
|
|
||||||
listOf(
|
|
||||||
ScanFilter.Builder()
|
|
||||||
.setServiceUuid(ParcelUuid(HEART_RATE_SERVICE_UUID))
|
|
||||||
.build(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
scanner.startScan(
|
|
||||||
filters,
|
|
||||||
ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build(),
|
|
||||||
callback,
|
|
||||||
)
|
|
||||||
val stopper =
|
|
||||||
Runnable {
|
|
||||||
if (snapshot.phase == HeartRateDiagnosticPhase.SCANNING) {
|
|
||||||
stopScan()
|
|
||||||
update { it.copy(phase = HeartRateDiagnosticPhase.IDLE, diagnostic = "scan_complete") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
scanStop = stopper
|
|
||||||
main.postDelayed(stopper, SCAN_DURATION_MS)
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
|
||||||
private fun handleScanResult(result: ScanResult) {
|
|
||||||
val record = result.scanRecord
|
|
||||||
val advertised =
|
|
||||||
record?.serviceUuids?.any { it.uuid == HEART_RATE_SERVICE_UUID } == true
|
|
||||||
val device = result.device
|
|
||||||
devices[device.address] = device
|
|
||||||
val row =
|
|
||||||
HeartRateDiagnosticDevice(
|
|
||||||
address = device.address,
|
|
||||||
name = record?.deviceName,
|
|
||||||
rssi = result.rssi,
|
|
||||||
advertisesHeartRate = advertised,
|
|
||||||
)
|
|
||||||
update { old ->
|
|
||||||
val rows =
|
|
||||||
(old.devices.filterNot { it.address == row.address } + row)
|
|
||||||
.sortedWith(
|
|
||||||
compareByDescending<HeartRateDiagnosticDevice> { it.advertisesHeartRate }
|
|
||||||
.thenByDescending { it.rssi },
|
|
||||||
)
|
|
||||||
old.copy(devices = rows)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
|
||||||
fun connect(address: String) {
|
|
||||||
if (!hasHeartRateBleRuntimePermissions(appContext)) {
|
|
||||||
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "permission") }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val device =
|
|
||||||
devices[address]
|
|
||||||
?: runCatching { adapter?.getRemoteDevice(address) }.getOrNull()
|
|
||||||
?: run {
|
|
||||||
update { it.copy(phase = HeartRateDiagnosticPhase.ERROR, diagnostic = "device_missing") }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
stopScan()
|
|
||||||
closeGatt()
|
|
||||||
update {
|
|
||||||
it.copy(
|
|
||||||
phase = HeartRateDiagnosticPhase.CONNECTING,
|
|
||||||
selectedAddress = address,
|
|
||||||
serviceFound = false,
|
|
||||||
measurementFound = false,
|
|
||||||
batteryServiceFound = false,
|
|
||||||
notificationCount = 0,
|
|
||||||
latestMeasurement = null,
|
|
||||||
diagnostic = "connecting",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
runCatching {
|
|
||||||
device.connectGatt(
|
|
||||||
appContext,
|
|
||||||
false,
|
|
||||||
gattCallback,
|
|
||||||
BluetoothDevice.TRANSPORT_LE,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.onSuccess { gatt = it }
|
|
||||||
.onFailure { error ->
|
|
||||||
update {
|
|
||||||
it.copy(
|
|
||||||
phase = HeartRateDiagnosticPhase.ERROR,
|
|
||||||
diagnostic = "connect_failed:" + error.javaClass.simpleName,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val gattCallback =
|
|
||||||
object : BluetoothGattCallback() {
|
|
||||||
@SuppressLint("MissingPermission")
|
|
||||||
override fun onConnectionStateChange(
|
|
||||||
callbackGatt: BluetoothGatt,
|
|
||||||
status: Int,
|
|
||||||
newState: Int,
|
|
||||||
) {
|
|
||||||
main.post {
|
|
||||||
if (closed || callbackGatt !== gatt) return@post
|
|
||||||
if (status == BluetoothGatt.GATT_SUCCESS &&
|
|
||||||
newState == BluetoothProfile.STATE_CONNECTED
|
|
||||||
) {
|
|
||||||
update {
|
|
||||||
it.copy(
|
|
||||||
phase = HeartRateDiagnosticPhase.DISCOVERING,
|
|
||||||
diagnostic = "connected",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (!callbackGatt.discoverServices()) {
|
|
||||||
update {
|
|
||||||
it.copy(
|
|
||||||
phase = HeartRateDiagnosticPhase.ERROR,
|
|
||||||
diagnostic = "discover_start_failed",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
|
|
||||||
update {
|
|
||||||
it.copy(
|
|
||||||
phase = HeartRateDiagnosticPhase.DISCONNECTED,
|
|
||||||
diagnostic = "disconnected:" + status,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
closeGatt()
|
|
||||||
} else if (status != BluetoothGatt.GATT_SUCCESS) {
|
|
||||||
update {
|
|
||||||
it.copy(
|
|
||||||
phase = HeartRateDiagnosticPhase.ERROR,
|
|
||||||
diagnostic = "gatt_status:" + status,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
closeGatt()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
|
||||||
override fun onServicesDiscovered(callbackGatt: BluetoothGatt, status: Int) {
|
|
||||||
main.post {
|
|
||||||
if (closed || callbackGatt !== gatt) return@post
|
|
||||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
|
||||||
update {
|
|
||||||
it.copy(
|
|
||||||
phase = HeartRateDiagnosticPhase.ERROR,
|
|
||||||
diagnostic = "service_discovery:" + status,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return@post
|
|
||||||
}
|
|
||||||
val service = callbackGatt.getService(HEART_RATE_SERVICE_UUID)
|
|
||||||
val measurement = service?.getCharacteristic(HEART_RATE_MEASUREMENT_UUID)
|
|
||||||
val battery = callbackGatt.getService(BATTERY_SERVICE_UUID) != null
|
|
||||||
update {
|
|
||||||
it.copy(
|
|
||||||
serviceFound = service != null,
|
|
||||||
measurementFound = measurement != null,
|
|
||||||
batteryServiceFound = battery,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (measurement == null) {
|
|
||||||
update {
|
|
||||||
it.copy(
|
|
||||||
phase = HeartRateDiagnosticPhase.ERROR,
|
|
||||||
diagnostic = "heart_rate_measurement_missing",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return@post
|
|
||||||
}
|
|
||||||
subscribe(callbackGatt, measurement)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDescriptorWrite(
|
|
||||||
callbackGatt: BluetoothGatt,
|
|
||||||
descriptor: BluetoothGattDescriptor,
|
|
||||||
status: Int,
|
|
||||||
) {
|
|
||||||
if (descriptor.uuid != CLIENT_CHARACTERISTIC_CONFIG_UUID) return
|
|
||||||
update {
|
|
||||||
if (status == BluetoothGatt.GATT_SUCCESS) {
|
|
||||||
it.copy(
|
|
||||||
phase = HeartRateDiagnosticPhase.RECEIVING,
|
|
||||||
diagnostic = "notifications_enabled",
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
it.copy(
|
|
||||||
phase = HeartRateDiagnosticPhase.ERROR,
|
|
||||||
diagnostic = "descriptor_write:" + status,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Deprecated("Deprecated by Android API 33 callback with explicit value")
|
|
||||||
override fun onCharacteristicChanged(
|
|
||||||
callbackGatt: BluetoothGatt,
|
|
||||||
characteristic: BluetoothGattCharacteristic,
|
|
||||||
) {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
val value = characteristic.value ?: return
|
|
||||||
handleMeasurement(characteristic.uuid, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCharacteristicChanged(
|
|
||||||
callbackGatt: BluetoothGatt,
|
|
||||||
characteristic: BluetoothGattCharacteristic,
|
|
||||||
value: ByteArray,
|
|
||||||
) {
|
|
||||||
handleMeasurement(characteristic.uuid, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
|
||||||
private fun subscribe(
|
|
||||||
callbackGatt: BluetoothGatt,
|
|
||||||
measurement: BluetoothGattCharacteristic,
|
|
||||||
) {
|
|
||||||
if (!callbackGatt.setCharacteristicNotification(measurement, true)) {
|
|
||||||
update {
|
|
||||||
it.copy(
|
|
||||||
phase = HeartRateDiagnosticPhase.ERROR,
|
|
||||||
diagnostic = "notification_enable_failed",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val descriptor = measurement.getDescriptor(CLIENT_CHARACTERISTIC_CONFIG_UUID)
|
|
||||||
if (descriptor == null) {
|
|
||||||
update {
|
|
||||||
it.copy(
|
|
||||||
phase = HeartRateDiagnosticPhase.ERROR,
|
|
||||||
diagnostic = "ccc_descriptor_missing",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
update {
|
|
||||||
it.copy(
|
|
||||||
phase = HeartRateDiagnosticPhase.SUBSCRIBING,
|
|
||||||
diagnostic = "enabling_notifications",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val started =
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
callbackGatt.writeDescriptor(
|
|
||||||
descriptor,
|
|
||||||
BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE,
|
|
||||||
) == BluetoothStatusCodes.SUCCESS
|
|
||||||
} else {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
run {
|
|
||||||
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
|
||||||
callbackGatt.writeDescriptor(descriptor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!started) {
|
|
||||||
update {
|
|
||||||
it.copy(
|
|
||||||
phase = HeartRateDiagnosticPhase.ERROR,
|
|
||||||
diagnostic = "descriptor_write_start_failed",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleMeasurement(uuid: UUID, value: ByteArray) {
|
|
||||||
if (uuid != HEART_RATE_MEASUREMENT_UUID) return
|
|
||||||
when (val parsed = HeartRateMeasurementParser.parse(value)) {
|
|
||||||
is HeartRateMeasurementParseResult.Invalid ->
|
|
||||||
update {
|
|
||||||
it.copy(
|
|
||||||
phase = HeartRateDiagnosticPhase.ERROR,
|
|
||||||
diagnostic = "measurement:" + parsed.reason,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
is HeartRateMeasurementParseResult.Parsed ->
|
|
||||||
update {
|
|
||||||
it.copy(
|
|
||||||
phase = HeartRateDiagnosticPhase.RECEIVING,
|
|
||||||
notificationCount = it.notificationCount + 1,
|
|
||||||
latestMeasurement = parsed.measurement,
|
|
||||||
diagnostic = "measurement",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
|
||||||
fun stopScan() {
|
|
||||||
val callback = scannerCallback ?: return
|
|
||||||
scanStop?.let(main::removeCallbacks)
|
|
||||||
scanStop = null
|
|
||||||
runCatching { adapter?.bluetoothLeScanner?.stopScan(callback) }
|
|
||||||
scannerCallback = null
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
|
||||||
private fun closeGatt() {
|
|
||||||
val current = gatt
|
|
||||||
gatt = null
|
|
||||||
runCatching { current?.disconnect() }
|
|
||||||
runCatching { current?.close() }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun close() {
|
|
||||||
if (closed) return
|
|
||||||
stopScan()
|
|
||||||
closeGatt()
|
|
||||||
closed = true
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val SCAN_DURATION_MS = 10_000L
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
package com.labfytools.trainlog.data
|
|
||||||
|
|
||||||
import java.util.concurrent.CopyOnWriteArraySet
|
|
||||||
|
|
||||||
internal enum class HeartRateLivePhase {
|
|
||||||
DISCONNECTED,
|
|
||||||
CONNECTING,
|
|
||||||
CONNECTED,
|
|
||||||
STALE,
|
|
||||||
}
|
|
||||||
|
|
||||||
internal data class HeartRateLiveSnapshot(
|
|
||||||
val phase: HeartRateLivePhase = HeartRateLivePhase.DISCONNECTED,
|
|
||||||
val bpm: Int? = null,
|
|
||||||
val rrIntervals1024: List<Int> = emptyList(),
|
|
||||||
val sensorContactDetected: Boolean? = null,
|
|
||||||
val energyExpended: Int? = null,
|
|
||||||
val notificationCount: Long = 0,
|
|
||||||
)
|
|
||||||
|
|
||||||
internal object HeartRateLiveState {
|
|
||||||
private val listeners =
|
|
||||||
CopyOnWriteArraySet<(HeartRateLiveSnapshot) -> Unit>()
|
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var snapshot = HeartRateLiveSnapshot()
|
|
||||||
|
|
||||||
fun current(): HeartRateLiveSnapshot = snapshot
|
|
||||||
|
|
||||||
fun publish(next: HeartRateLiveSnapshot) {
|
|
||||||
snapshot = next
|
|
||||||
listeners.forEach { it(next) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun subscribe(listener: (HeartRateLiveSnapshot) -> Unit): AutoCloseable {
|
|
||||||
listeners += listener
|
|
||||||
listener(snapshot)
|
|
||||||
return AutoCloseable { listeners -= listener }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun disconnected() {
|
|
||||||
publish(HeartRateLiveSnapshot(phase = HeartRateLivePhase.DISCONNECTED))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun connecting() {
|
|
||||||
publish(
|
|
||||||
snapshot.copy(
|
|
||||||
phase = HeartRateLivePhase.CONNECTING,
|
|
||||||
bpm = null,
|
|
||||||
rrIntervals1024 = emptyList(),
|
|
||||||
sensorContactDetected = null,
|
|
||||||
energyExpended = null,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun measurement(measurement: ParsedHeartRateMeasurement) {
|
|
||||||
publish(
|
|
||||||
HeartRateLiveSnapshot(
|
|
||||||
phase = HeartRateLivePhase.CONNECTED,
|
|
||||||
bpm = measurement.bpm,
|
|
||||||
rrIntervals1024 = measurement.rrIntervals1024,
|
|
||||||
sensorContactDetected = measurement.sensorContactDetected,
|
|
||||||
energyExpended = measurement.energyExpended,
|
|
||||||
notificationCount = snapshot.notificationCount + 1,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stale() {
|
|
||||||
if (snapshot.phase == HeartRateLivePhase.CONNECTED) {
|
|
||||||
publish(
|
|
||||||
snapshot.copy(
|
|
||||||
phase = HeartRateLivePhase.STALE,
|
|
||||||
bpm = null,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
/*
|
|
||||||
* Bluetooth Heart Rate Measurement parser.
|
|
||||||
*
|
|
||||||
* This file deliberately has no Android Bluetooth dependency so protocol
|
|
||||||
* decoding can be exhaustively unit-tested independently of GATT transport.
|
|
||||||
*/
|
|
||||||
package com.labfytools.trainlog.data
|
|
||||||
|
|
||||||
internal data class ParsedHeartRateMeasurement(
|
|
||||||
val bpm: Int,
|
|
||||||
val sensorContactDetected: Boolean?,
|
|
||||||
val energyExpended: Int?,
|
|
||||||
/** Raw Bluetooth Heart Rate Service units: 1/1024 second. */
|
|
||||||
val rrIntervals1024: List<Int>,
|
|
||||||
)
|
|
||||||
|
|
||||||
internal sealed interface HeartRateMeasurementParseResult {
|
|
||||||
data class Parsed(val measurement: ParsedHeartRateMeasurement) :
|
|
||||||
HeartRateMeasurementParseResult
|
|
||||||
data class Invalid(val reason: String) : HeartRateMeasurementParseResult
|
|
||||||
}
|
|
||||||
|
|
||||||
internal object HeartRateMeasurementParser {
|
|
||||||
private const val FLAG_HEART_RATE_16_BIT = 1 shl 0
|
|
||||||
private const val FLAG_CONTACT_DETECTED = 1 shl 1
|
|
||||||
private const val FLAG_CONTACT_SUPPORTED = 1 shl 2
|
|
||||||
private const val FLAG_ENERGY_PRESENT = 1 shl 3
|
|
||||||
private const val FLAG_RR_PRESENT = 1 shl 4
|
|
||||||
private const val KNOWN_FLAGS =
|
|
||||||
FLAG_HEART_RATE_16_BIT or FLAG_CONTACT_DETECTED or FLAG_CONTACT_SUPPORTED or
|
|
||||||
FLAG_ENERGY_PRESENT or FLAG_RR_PRESENT
|
|
||||||
|
|
||||||
fun parse(payload: ByteArray): HeartRateMeasurementParseResult {
|
|
||||||
if (payload.isEmpty()) return HeartRateMeasurementParseResult.Invalid("missing flags")
|
|
||||||
val flags = payload[0].toInt() and 0xff
|
|
||||||
if ((flags and KNOWN_FLAGS.inv()) != 0) {
|
|
||||||
return HeartRateMeasurementParseResult.Invalid("reserved flags are set")
|
|
||||||
}
|
|
||||||
|
|
||||||
var offset = 1
|
|
||||||
fun uint8(): Int? =
|
|
||||||
payload.getOrNull(offset)?.let {
|
|
||||||
offset += 1
|
|
||||||
it.toInt() and 0xff
|
|
||||||
}
|
|
||||||
fun uint16(): Int? {
|
|
||||||
if (offset + 2 > payload.size) return null
|
|
||||||
val value =
|
|
||||||
(payload[offset].toInt() and 0xff) or
|
|
||||||
((payload[offset + 1].toInt() and 0xff) shl 8)
|
|
||||||
offset += 2
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
val bpm =
|
|
||||||
if ((flags and FLAG_HEART_RATE_16_BIT) != 0) {
|
|
||||||
uint16() ?: return HeartRateMeasurementParseResult.Invalid("truncated 16-bit BPM")
|
|
||||||
} else {
|
|
||||||
uint8() ?: return HeartRateMeasurementParseResult.Invalid("missing BPM")
|
|
||||||
}
|
|
||||||
|
|
||||||
val contact =
|
|
||||||
if ((flags and FLAG_CONTACT_SUPPORTED) == 0) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
(flags and FLAG_CONTACT_DETECTED) != 0
|
|
||||||
}
|
|
||||||
|
|
||||||
val energy =
|
|
||||||
if ((flags and FLAG_ENERGY_PRESENT) != 0) {
|
|
||||||
uint16()
|
|
||||||
?: return HeartRateMeasurementParseResult.Invalid(
|
|
||||||
"truncated energy-expended value",
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
val rr = mutableListOf<Int>()
|
|
||||||
if ((flags and FLAG_RR_PRESENT) != 0) {
|
|
||||||
if (offset == payload.size) {
|
|
||||||
return HeartRateMeasurementParseResult.Invalid("RR flag has no interval")
|
|
||||||
}
|
|
||||||
if ((payload.size - offset) % 2 != 0) {
|
|
||||||
return HeartRateMeasurementParseResult.Invalid("truncated RR interval")
|
|
||||||
}
|
|
||||||
while (offset < payload.size) {
|
|
||||||
rr += checkNotNull(uint16())
|
|
||||||
}
|
|
||||||
} else if (offset != payload.size) {
|
|
||||||
return HeartRateMeasurementParseResult.Invalid("unexpected trailing bytes")
|
|
||||||
}
|
|
||||||
|
|
||||||
return HeartRateMeasurementParseResult.Parsed(
|
|
||||||
ParsedHeartRateMeasurement(
|
|
||||||
bpm = bpm,
|
|
||||||
sensorContactDetected = contact,
|
|
||||||
energyExpended = energy,
|
|
||||||
rrIntervals1024 = rr,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
package com.labfytools.trainlog.data
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
|
|
||||||
internal data class SelectedHeartRateSensor(
|
|
||||||
val address: String,
|
|
||||||
val name: String?,
|
|
||||||
)
|
|
||||||
|
|
||||||
internal class HeartRateSensorPreferences(context: Context) {
|
|
||||||
private val preferences =
|
|
||||||
context.applicationContext.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
|
||||||
|
|
||||||
fun selected(): SelectedHeartRateSensor? {
|
|
||||||
if (!preferences.getBoolean(ENABLED, false)) return null
|
|
||||||
val address = preferences.getString(ADDRESS, null)?.takeIf { it.isNotBlank() } ?: return null
|
|
||||||
return SelectedHeartRateSensor(
|
|
||||||
address = address,
|
|
||||||
name = preferences.getString(NAME, null)?.takeIf { it.isNotBlank() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun select(address: String, name: String?) {
|
|
||||||
require(address.isNotBlank())
|
|
||||||
preferences.edit()
|
|
||||||
.putBoolean(ENABLED, true)
|
|
||||||
.putString(ADDRESS, address)
|
|
||||||
.putString(NAME, name?.takeIf { it.isNotBlank() })
|
|
||||||
.apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clear() {
|
|
||||||
preferences.edit()
|
|
||||||
.putBoolean(ENABLED, false)
|
|
||||||
.remove(ADDRESS)
|
|
||||||
.remove(NAME)
|
|
||||||
.apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val PREFERENCES = "trainlog-heart-rate-sensor"
|
|
||||||
private const val ENABLED = "enabled"
|
|
||||||
private const val ADDRESS = "address"
|
|
||||||
private const val NAME = "name"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,561 +0,0 @@
|
||||||
package com.labfytools.trainlog.data
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.app.NotificationChannel
|
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.app.Service
|
|
||||||
import android.bluetooth.BluetoothDevice
|
|
||||||
import android.bluetooth.BluetoothGatt
|
|
||||||
import android.bluetooth.BluetoothGattCallback
|
|
||||||
import android.bluetooth.BluetoothGattCharacteristic
|
|
||||||
import android.bluetooth.BluetoothGattDescriptor
|
|
||||||
import android.bluetooth.BluetoothManager
|
|
||||||
import android.bluetooth.BluetoothProfile
|
|
||||||
import android.bluetooth.BluetoothStatusCodes
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.ServiceInfo
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.IBinder
|
|
||||||
import android.os.Looper
|
|
||||||
import android.os.SystemClock
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import com.labfytools.trainlog.R
|
|
||||||
import com.labfytools.trainlog.model.CardioGuidanceInstruction
|
|
||||||
import java.time.Duration
|
|
||||||
import java.time.OffsetDateTime
|
|
||||||
|
|
||||||
class HeartRateSensorService : Service() {
|
|
||||||
private val main = Handler(Looper.getMainLooper())
|
|
||||||
private val preferences by lazy { HeartRateSensorPreferences(applicationContext) }
|
|
||||||
private val bluetoothManager by lazy {
|
|
||||||
applicationContext.getSystemService(BluetoothManager::class.java)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var selected: SelectedHeartRateSensor? = null
|
|
||||||
private var gatt: BluetoothGatt? = null
|
|
||||||
private var reconnectAttempt = 0
|
|
||||||
private var lastMeasurementElapsed = 0L
|
|
||||||
private var lastNotificationBpm: Int? = null
|
|
||||||
private var repository: TrainlogRepository? = null
|
|
||||||
private var guidancePhaseId: String? = null
|
|
||||||
private var guidanceEngine: CardioGuidanceEngine? = null
|
|
||||||
|
|
||||||
private fun repository(): TrainlogRepository =
|
|
||||||
repository ?: TrainlogRepository(applicationContext).also { repository = it }
|
|
||||||
|
|
||||||
private val reconnectRunnable = Runnable { connectSelected() }
|
|
||||||
private val connectionTimeoutRunnable =
|
|
||||||
Runnable {
|
|
||||||
if (HeartRateLiveState.current().phase == HeartRateLivePhase.CONNECTING) {
|
|
||||||
closeGatt()
|
|
||||||
scheduleReconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private val staleRunnable =
|
|
||||||
object : Runnable {
|
|
||||||
override fun run() {
|
|
||||||
val last = lastMeasurementElapsed
|
|
||||||
if (last > 0L) {
|
|
||||||
val age = SystemClock.elapsedRealtime() - last
|
|
||||||
if (
|
|
||||||
age >= STALE_AFTER_MS &&
|
|
||||||
HeartRateLiveState.current().phase == HeartRateLivePhase.CONNECTED
|
|
||||||
) {
|
|
||||||
HeartRateLiveState.stale()
|
|
||||||
suspendCardioGuidance()
|
|
||||||
updateForegroundNotification()
|
|
||||||
}
|
|
||||||
if (age >= STALE_RECONNECT_AFTER_MS && gatt != null) {
|
|
||||||
lastMeasurementElapsed = 0L
|
|
||||||
closeGatt()
|
|
||||||
scheduleReconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
main.postDelayed(this, STALE_CHECK_MS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
createChannel()
|
|
||||||
promoteForeground()
|
|
||||||
main.post(staleRunnable)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
||||||
if (intent?.action == ACTION_STOP) {
|
|
||||||
stopSelf()
|
|
||||||
return START_NOT_STICKY
|
|
||||||
}
|
|
||||||
|
|
||||||
selected = preferences.selected()
|
|
||||||
if (selected == null || !hasHeartRateBleRuntimePermissions(this)) {
|
|
||||||
HeartRateLiveState.disconnected()
|
|
||||||
updateForegroundNotification()
|
|
||||||
stopSelf()
|
|
||||||
return START_NOT_STICKY
|
|
||||||
}
|
|
||||||
if (bluetoothManager.adapter?.isEnabled != true) {
|
|
||||||
scheduleReconnect()
|
|
||||||
return START_STICKY
|
|
||||||
}
|
|
||||||
|
|
||||||
promoteForeground()
|
|
||||||
if (gatt == null) connectSelected()
|
|
||||||
return START_STICKY
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
private fun connectSelected() {
|
|
||||||
main.removeCallbacks(reconnectRunnable)
|
|
||||||
main.removeCallbacks(connectionTimeoutRunnable)
|
|
||||||
|
|
||||||
val sensor = preferences.selected()
|
|
||||||
selected = sensor
|
|
||||||
val adapter = bluetoothManager.adapter
|
|
||||||
if (sensor == null || !hasHeartRateBleRuntimePermissions(this)) {
|
|
||||||
HeartRateLiveState.disconnected()
|
|
||||||
updateForegroundNotification()
|
|
||||||
stopSelf()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (adapter?.isEnabled != true) {
|
|
||||||
scheduleReconnect()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
closeGatt()
|
|
||||||
lastMeasurementElapsed = 0L
|
|
||||||
HeartRateLiveState.connecting()
|
|
||||||
updateForegroundNotification()
|
|
||||||
|
|
||||||
val device =
|
|
||||||
runCatching { adapter.getRemoteDevice(sensor.address) }.getOrNull()
|
|
||||||
?: run {
|
|
||||||
HeartRateLiveState.disconnected()
|
|
||||||
scheduleReconnect()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
runCatching {
|
|
||||||
device.connectGatt(
|
|
||||||
applicationContext,
|
|
||||||
false,
|
|
||||||
gattCallback,
|
|
||||||
BluetoothDevice.TRANSPORT_LE,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.onSuccess {
|
|
||||||
gatt = it
|
|
||||||
main.postDelayed(connectionTimeoutRunnable, CONNECTION_TIMEOUT_MS)
|
|
||||||
}
|
|
||||||
.onFailure {
|
|
||||||
scheduleReconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val gattCallback =
|
|
||||||
object : BluetoothGattCallback() {
|
|
||||||
@SuppressLint("MissingPermission")
|
|
||||||
override fun onConnectionStateChange(
|
|
||||||
callbackGatt: BluetoothGatt,
|
|
||||||
status: Int,
|
|
||||||
newState: Int,
|
|
||||||
) {
|
|
||||||
main.post {
|
|
||||||
if (callbackGatt !== gatt) return@post
|
|
||||||
if (
|
|
||||||
status == BluetoothGatt.GATT_SUCCESS &&
|
|
||||||
newState == BluetoothProfile.STATE_CONNECTED
|
|
||||||
) {
|
|
||||||
reconnectAttempt = 0
|
|
||||||
main.removeCallbacks(connectionTimeoutRunnable)
|
|
||||||
HeartRateLiveState.connecting()
|
|
||||||
updateForegroundNotification()
|
|
||||||
if (!callbackGatt.discoverServices()) {
|
|
||||||
failConnection()
|
|
||||||
} else {
|
|
||||||
main.postDelayed(connectionTimeoutRunnable, CONNECTION_TIMEOUT_MS)
|
|
||||||
}
|
|
||||||
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
|
|
||||||
failConnection()
|
|
||||||
} else if (status != BluetoothGatt.GATT_SUCCESS) {
|
|
||||||
failConnection()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
|
||||||
override fun onServicesDiscovered(callbackGatt: BluetoothGatt, status: Int) {
|
|
||||||
main.post {
|
|
||||||
if (callbackGatt !== gatt) return@post
|
|
||||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
|
||||||
failConnection()
|
|
||||||
return@post
|
|
||||||
}
|
|
||||||
val measurement =
|
|
||||||
callbackGatt
|
|
||||||
.getService(HEART_RATE_SERVICE_UUID)
|
|
||||||
?.getCharacteristic(HEART_RATE_MEASUREMENT_UUID)
|
|
||||||
if (measurement == null) {
|
|
||||||
failConnection()
|
|
||||||
return@post
|
|
||||||
}
|
|
||||||
main.removeCallbacks(connectionTimeoutRunnable)
|
|
||||||
if (!subscribe(callbackGatt, measurement)) {
|
|
||||||
failConnection()
|
|
||||||
} else {
|
|
||||||
main.postDelayed(connectionTimeoutRunnable, CONNECTION_TIMEOUT_MS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDescriptorWrite(
|
|
||||||
callbackGatt: BluetoothGatt,
|
|
||||||
descriptor: BluetoothGattDescriptor,
|
|
||||||
status: Int,
|
|
||||||
) {
|
|
||||||
if (descriptor.uuid != CLIENT_CHARACTERISTIC_CONFIG_UUID) return
|
|
||||||
main.post {
|
|
||||||
if (callbackGatt !== gatt) return@post
|
|
||||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
|
||||||
failConnection()
|
|
||||||
} else {
|
|
||||||
main.removeCallbacks(connectionTimeoutRunnable)
|
|
||||||
HeartRateLiveState.connecting()
|
|
||||||
updateForegroundNotification()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Deprecated("Deprecated by Android API 33 callback with explicit value")
|
|
||||||
override fun onCharacteristicChanged(
|
|
||||||
callbackGatt: BluetoothGatt,
|
|
||||||
characteristic: BluetoothGattCharacteristic,
|
|
||||||
) {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
val value = characteristic.value ?: return
|
|
||||||
onHeartRateNotification(callbackGatt, characteristic.uuid, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCharacteristicChanged(
|
|
||||||
callbackGatt: BluetoothGatt,
|
|
||||||
characteristic: BluetoothGattCharacteristic,
|
|
||||||
value: ByteArray,
|
|
||||||
) {
|
|
||||||
onHeartRateNotification(callbackGatt, characteristic.uuid, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
|
||||||
private fun subscribe(
|
|
||||||
callbackGatt: BluetoothGatt,
|
|
||||||
measurement: BluetoothGattCharacteristic,
|
|
||||||
): Boolean {
|
|
||||||
if (!callbackGatt.setCharacteristicNotification(measurement, true)) return false
|
|
||||||
val descriptor = measurement.getDescriptor(CLIENT_CHARACTERISTIC_CONFIG_UUID) ?: return false
|
|
||||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
callbackGatt.writeDescriptor(
|
|
||||||
descriptor,
|
|
||||||
BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE,
|
|
||||||
) == BluetoothStatusCodes.SUCCESS
|
|
||||||
} else {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
run {
|
|
||||||
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
|
||||||
callbackGatt.writeDescriptor(descriptor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onHeartRateNotification(
|
|
||||||
callbackGatt: BluetoothGatt,
|
|
||||||
uuid: java.util.UUID,
|
|
||||||
value: ByteArray,
|
|
||||||
) {
|
|
||||||
if (callbackGatt !== gatt || uuid != HEART_RATE_MEASUREMENT_UUID) return
|
|
||||||
val parsed = HeartRateMeasurementParser.parse(value)
|
|
||||||
if (parsed !is HeartRateMeasurementParseResult.Parsed) return
|
|
||||||
lastMeasurementElapsed = SystemClock.elapsedRealtime()
|
|
||||||
HeartRateLiveState.measurement(parsed.measurement)
|
|
||||||
val observedAt = OffsetDateTime.now().toString()
|
|
||||||
val repository = repository()
|
|
||||||
val sessionRecorded =
|
|
||||||
repository.recordLiveHeartRateForActiveSession(
|
|
||||||
sensorName = selected?.name,
|
|
||||||
observedAt = observedAt,
|
|
||||||
measurement = parsed.measurement,
|
|
||||||
)
|
|
||||||
if (sessionRecorded != null) {
|
|
||||||
processCardioGuidance(repository, observedAt, parsed.measurement.bpm)
|
|
||||||
} else {
|
|
||||||
repository.recordLiveHeartRateForActiveSleep(
|
|
||||||
sensorName = selected?.name,
|
|
||||||
observedAt = observedAt,
|
|
||||||
measurement = parsed.measurement,
|
|
||||||
)
|
|
||||||
clearCardioGuidanceEngine()
|
|
||||||
}
|
|
||||||
if (lastNotificationBpm != parsed.measurement.bpm) {
|
|
||||||
lastNotificationBpm = parsed.measurement.bpm
|
|
||||||
updateForegroundNotification()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private fun processCardioGuidance(
|
|
||||||
repository: TrainlogRepository,
|
|
||||||
observedAt: String,
|
|
||||||
bpm: Int,
|
|
||||||
) {
|
|
||||||
val phase = repository.activeCardioGuidancePhase()
|
|
||||||
if (phase == null) {
|
|
||||||
clearCardioGuidanceEngine()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (guidancePhaseId != phase.phase.phaseId || guidanceEngine == null) {
|
|
||||||
guidancePhaseId = phase.phase.phaseId
|
|
||||||
guidanceEngine =
|
|
||||||
CardioGuidanceEngine(initialInstruction = phase.currentInstruction)
|
|
||||||
}
|
|
||||||
val engine = checkNotNull(guidanceEngine)
|
|
||||||
val output =
|
|
||||||
engine.evaluate(
|
|
||||||
target = phase.phase.target,
|
|
||||||
bpm = bpm,
|
|
||||||
fresh = true,
|
|
||||||
nowMs = SystemClock.elapsedRealtime(),
|
|
||||||
)
|
|
||||||
repository.recordCardioGuidanceOutput(
|
|
||||||
phase.phase.phaseId,
|
|
||||||
observedAt,
|
|
||||||
output,
|
|
||||||
)
|
|
||||||
CardioGuidanceLiveState.publish(
|
|
||||||
CardioGuidanceLiveSnapshot(
|
|
||||||
phaseId = phase.phase.phaseId,
|
|
||||||
phaseKind = phase.phase.kind,
|
|
||||||
instruction = output.instruction,
|
|
||||||
bpm = output.bpm,
|
|
||||||
target = phase.phase.target,
|
|
||||||
startedAt = phase.startedAt,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
maybeFinishCardioGuidancePhase(repository, phase, observedAt, bpm, fresh = true)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun suspendCardioGuidance() {
|
|
||||||
val repository = repository ?: return
|
|
||||||
val phase = repository.activeCardioGuidancePhase() ?: run {
|
|
||||||
clearCardioGuidanceEngine()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (guidancePhaseId != phase.phase.phaseId || guidanceEngine == null) {
|
|
||||||
guidancePhaseId = phase.phase.phaseId
|
|
||||||
guidanceEngine =
|
|
||||||
CardioGuidanceEngine(initialInstruction = phase.currentInstruction)
|
|
||||||
}
|
|
||||||
val output =
|
|
||||||
checkNotNull(guidanceEngine).evaluate(
|
|
||||||
target = phase.phase.target,
|
|
||||||
bpm = null,
|
|
||||||
fresh = false,
|
|
||||||
nowMs = SystemClock.elapsedRealtime(),
|
|
||||||
)
|
|
||||||
val observedAt = OffsetDateTime.now().toString()
|
|
||||||
repository.recordCardioGuidanceOutput(
|
|
||||||
phase.phase.phaseId,
|
|
||||||
observedAt,
|
|
||||||
output,
|
|
||||||
)
|
|
||||||
CardioGuidanceLiveState.publish(
|
|
||||||
CardioGuidanceLiveSnapshot(
|
|
||||||
phaseId = phase.phase.phaseId,
|
|
||||||
phaseKind = phase.phase.kind,
|
|
||||||
instruction = CardioGuidanceInstruction.SUSPENDED,
|
|
||||||
bpm = null,
|
|
||||||
target = phase.phase.target,
|
|
||||||
startedAt = phase.startedAt,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
maybeFinishCardioGuidancePhase(repository, phase, observedAt, null, fresh = false)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun maybeFinishCardioGuidancePhase(
|
|
||||||
repository: TrainlogRepository,
|
|
||||||
phase: com.labfytools.trainlog.model.CardioGuidancePhaseRecord,
|
|
||||||
observedAt: String,
|
|
||||||
bpm: Int?,
|
|
||||||
fresh: Boolean,
|
|
||||||
) {
|
|
||||||
val elapsedSeconds =
|
|
||||||
runCatching {
|
|
||||||
Duration.between(
|
|
||||||
OffsetDateTime.parse(phase.startedAt),
|
|
||||||
OffsetDateTime.parse(observedAt),
|
|
||||||
).seconds
|
|
||||||
}
|
|
||||||
.getOrNull()
|
|
||||||
?.coerceIn(0L, Int.MAX_VALUE.toLong())
|
|
||||||
?.toInt()
|
|
||||||
?: return
|
|
||||||
if (
|
|
||||||
CardioPhaseCompletion.isComplete(
|
|
||||||
phase.phase,
|
|
||||||
elapsedSeconds,
|
|
||||||
bpm,
|
|
||||||
fresh,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
repository.finishCardioGuidancePhase(phase.phase.phaseId, observedAt)
|
|
||||||
clearCardioGuidanceEngine()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun clearCardioGuidanceEngine() {
|
|
||||||
guidancePhaseId = null
|
|
||||||
guidanceEngine = null
|
|
||||||
CardioGuidanceLiveState.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun failConnection() {
|
|
||||||
main.removeCallbacks(connectionTimeoutRunnable)
|
|
||||||
closeGatt()
|
|
||||||
lastMeasurementElapsed = 0L
|
|
||||||
lastNotificationBpm = null
|
|
||||||
scheduleReconnect()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun scheduleReconnect() {
|
|
||||||
if (preferences.selected() == null) {
|
|
||||||
HeartRateLiveState.disconnected()
|
|
||||||
updateForegroundNotification()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
HeartRateLiveState.connecting()
|
|
||||||
updateForegroundNotification()
|
|
||||||
reconnectAttempt = (reconnectAttempt + 1).coerceAtMost(6)
|
|
||||||
val delay =
|
|
||||||
(RECONNECT_BASE_MS * reconnectAttempt.toLong()).coerceAtMost(RECONNECT_MAX_MS)
|
|
||||||
main.removeCallbacks(reconnectRunnable)
|
|
||||||
main.postDelayed(reconnectRunnable, delay)
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
|
||||||
private fun closeGatt() {
|
|
||||||
val current = gatt
|
|
||||||
gatt = null
|
|
||||||
runCatching { current?.disconnect() }
|
|
||||||
runCatching { current?.close() }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
main.removeCallbacks(reconnectRunnable)
|
|
||||||
main.removeCallbacks(connectionTimeoutRunnable)
|
|
||||||
main.removeCallbacks(staleRunnable)
|
|
||||||
closeGatt()
|
|
||||||
repository?.close()
|
|
||||||
repository = null
|
|
||||||
HeartRateLiveState.disconnected()
|
|
||||||
super.onDestroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
|
||||||
|
|
||||||
private fun createChannel() {
|
|
||||||
getSystemService(NotificationManager::class.java)
|
|
||||||
.createNotificationChannel(
|
|
||||||
NotificationChannel(
|
|
||||||
CHANNEL,
|
|
||||||
getString(R.string.heart_rate_service_channel),
|
|
||||||
NotificationManager.IMPORTANCE_LOW,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun promoteForeground() {
|
|
||||||
val notification = buildNotification()
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
startForeground(
|
|
||||||
NOTIFICATION_ID,
|
|
||||||
notification,
|
|
||||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
startForeground(NOTIFICATION_ID, notification)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateForegroundNotification() {
|
|
||||||
runCatching {
|
|
||||||
getSystemService(NotificationManager::class.java)
|
|
||||||
.notify(NOTIFICATION_ID, buildNotification())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildNotification(): android.app.Notification {
|
|
||||||
val live = HeartRateLiveState.current()
|
|
||||||
val text =
|
|
||||||
when (live.phase) {
|
|
||||||
HeartRateLivePhase.CONNECTED ->
|
|
||||||
live.bpm?.let { getString(R.string.heart_rate_service_bpm, it) }
|
|
||||||
?: getString(R.string.heart_rate_service_connecting)
|
|
||||||
HeartRateLivePhase.CONNECTING ->
|
|
||||||
getString(R.string.heart_rate_service_connecting)
|
|
||||||
HeartRateLivePhase.STALE ->
|
|
||||||
getString(R.string.heart_rate_service_reconnecting)
|
|
||||||
HeartRateLivePhase.DISCONNECTED ->
|
|
||||||
getString(R.string.heart_rate_service_disconnected)
|
|
||||||
}
|
|
||||||
return NotificationCompat.Builder(this, CHANNEL)
|
|
||||||
.setSmallIcon(R.mipmap.ic_launcher)
|
|
||||||
.setContentTitle(getString(R.string.heart_rate_service_title))
|
|
||||||
.setContentText(text)
|
|
||||||
.setOngoing(true)
|
|
||||||
.setOnlyAlertOnce(true)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val CHANNEL = "trainlog_heart_rate"
|
|
||||||
private const val NOTIFICATION_ID = 1402
|
|
||||||
private const val ACTION_START = "com.labfytools.trainlog.heart_rate.START"
|
|
||||||
private const val ACTION_STOP = "com.labfytools.trainlog.heart_rate.STOP"
|
|
||||||
private const val CONNECTION_TIMEOUT_MS = 12_000L
|
|
||||||
private const val STALE_AFTER_MS = 3_000L
|
|
||||||
private const val STALE_RECONNECT_AFTER_MS = 8_000L
|
|
||||||
private const val STALE_CHECK_MS = 1_000L
|
|
||||||
private const val RECONNECT_BASE_MS = 2_000L
|
|
||||||
private const val RECONNECT_MAX_MS = 15_000L
|
|
||||||
|
|
||||||
fun start(context: Context) {
|
|
||||||
if (
|
|
||||||
HeartRateSensorPreferences(context).selected() == null ||
|
|
||||||
!hasHeartRateBleRuntimePermissions(context)
|
|
||||||
) {
|
|
||||||
HeartRateLiveState.disconnected()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ContextCompat.startForegroundService(
|
|
||||||
context,
|
|
||||||
Intent(context, HeartRateSensorService::class.java).setAction(ACTION_START),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stop(context: Context) {
|
|
||||||
context.startService(
|
|
||||||
Intent(context, HeartRateSensorService::class.java).setAction(ACTION_STOP),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun forget(context: Context) {
|
|
||||||
HeartRateSensorPreferences(context).clear()
|
|
||||||
stop(context)
|
|
||||||
HeartRateLiveState.disconnected()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -32,29 +32,37 @@ internal class BackgroundSyncSettings(context: Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User-enabled foreground listener for automatic Bluetooth and MTP synchronization.
|
* User-enabled foreground listener for PC-initiated USB generation requests.
|
||||||
*
|
*
|
||||||
* WHY: the phone must reconnect to the paired desktop while Compose is not
|
* WHY: a Web request must be consumed while Compose is not visible, but modern
|
||||||
* visible, but modern Android does not permit an invisible permanent loop.
|
* Android does not permit an invisible permanent background loop.
|
||||||
* CONTRACT: the service shows a mandatory notification, owns the RFCOMM client,
|
* CONTRACT: the service shows a mandatory notification, invokes the same
|
||||||
* invokes the same generation coordinator as SyncScreen, and never starts an
|
* generation coordinator as SyncScreen, and never starts an exercise.
|
||||||
* exercise. MTP remains a filesystem fallback.
|
* INVARIANT: filesystem visibility only triggers protocol validation; stable
|
||||||
* INVARIANT: transport visibility only triggers protocol validation; stable
|
|
||||||
* run/generation/ACK identities remain the sole business authority.
|
* run/generation/ACK identities remain the sole business authority.
|
||||||
*/
|
*/
|
||||||
class SyncBackgroundService : Service() {
|
class SyncBackgroundService : Service() {
|
||||||
private val stopped = AtomicBoolean(false)
|
private val stopped = AtomicBoolean(false)
|
||||||
private val launched = AtomicBoolean(false)
|
private val launched = AtomicBoolean(false)
|
||||||
private val executor = Executors.newFixedThreadPool(2)
|
private val executor = Executors.newSingleThreadExecutor()
|
||||||
private var repository: TrainlogRepository? = null
|
private var repository: TrainlogRepository? = null
|
||||||
private var bluetoothClient: BluetoothSyncClient? = null
|
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
createChannel()
|
createChannel()
|
||||||
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) {
|
||||||
|
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||||
|
} else {
|
||||||
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
repository = TrainlogRepository(applicationContext)
|
repository = TrainlogRepository(applicationContext)
|
||||||
bluetoothClient = BluetoothSyncClient(applicationContext, requireNotNull(repository))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
|
@ -62,11 +70,7 @@ class SyncBackgroundService : Service() {
|
||||||
stopSelf()
|
stopSelf()
|
||||||
return START_NOT_STICKY
|
return START_NOT_STICKY
|
||||||
}
|
}
|
||||||
promoteForeground()
|
|
||||||
if (!launched.compareAndSet(false, true)) return START_NOT_STICKY
|
if (!launched.compareAndSet(false, true)) return START_NOT_STICKY
|
||||||
executor.execute {
|
|
||||||
bluetoothClient?.run(stopped)
|
|
||||||
}
|
|
||||||
executor.execute {
|
executor.execute {
|
||||||
val coordinator = SyncGenerationCoordinator(
|
val coordinator = SyncGenerationCoordinator(
|
||||||
requireNotNull(repository),
|
requireNotNull(repository),
|
||||||
|
|
@ -112,7 +116,6 @@ class SyncBackgroundService : Service() {
|
||||||
is ForegroundGenerationResult.Cancelled -> Unit
|
is ForegroundGenerationResult.Cancelled -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AiExportDriveSettings.enqueueImmediate(applicationContext)
|
|
||||||
if (!stopped.get()) runCatching { Thread.sleep(5_000) }
|
if (!stopped.get()) runCatching { Thread.sleep(5_000) }
|
||||||
}
|
}
|
||||||
stopSelf()
|
stopSelf()
|
||||||
|
|
@ -122,8 +125,6 @@ class SyncBackgroundService : Service() {
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
stopped.set(true)
|
stopped.set(true)
|
||||||
bluetoothClient?.shutdown()
|
|
||||||
bluetoothClient = null
|
|
||||||
executor.shutdownNow()
|
executor.shutdownNow()
|
||||||
repository?.close()
|
repository?.close()
|
||||||
repository = null
|
repository = null
|
||||||
|
|
@ -140,28 +141,6 @@ class SyncBackgroundService : Service() {
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
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() {
|
private fun createChannel() {
|
||||||
val manager = getSystemService(NotificationManager::class.java)
|
val manager = getSystemService(NotificationManager::class.java)
|
||||||
manager.createNotificationChannel(
|
manager.createNotificationChannel(
|
||||||
|
|
|
||||||
|
|
@ -45,13 +45,6 @@ internal object AndroidSyncGenerationTrace : SyncGenerationTrace {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun acknowledgementMatchesCapturedGeneration(
|
|
||||||
acknowledgement: JSONObject,
|
|
||||||
captured: CapturedSyncGeneration,
|
|
||||||
): Boolean =
|
|
||||||
acknowledgement.optString("generation_id") == captured.generationId &&
|
|
||||||
acknowledgement.optString("manifest_sha256") == captured.manifestSha256
|
|
||||||
|
|
||||||
/** Owns the bounded, user-initiated Android side of a generation conversation. */
|
/** Owns the bounded, user-initiated Android side of a generation conversation. */
|
||||||
internal class SyncGenerationCoordinator(
|
internal class SyncGenerationCoordinator(
|
||||||
private val repository: TrainlogRepository,
|
private val repository: TrainlogRepository,
|
||||||
|
|
@ -61,11 +54,6 @@ internal class SyncGenerationCoordinator(
|
||||||
) {
|
) {
|
||||||
private val service = SyncGenerationService(repository)
|
private val service = SyncGenerationService(repository)
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val ACK_RECOVERY_CAPABILITY = "generation-ack-recovery-v1"
|
|
||||||
const val MAX_COORDINATION_BYTES = 65_536
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun phase(
|
private fun phase(
|
||||||
runId: String?,
|
runId: String?,
|
||||||
phase: String,
|
phase: String,
|
||||||
|
|
@ -76,9 +64,15 @@ internal class SyncGenerationCoordinator(
|
||||||
|
|
||||||
internal fun resumableGeneration(runId: String, consumerPeerId: String? = null): CapturedSyncGeneration? =
|
internal fun resumableGeneration(runId: String, consumerPeerId: String? = null): CapturedSyncGeneration? =
|
||||||
repository.inSyncGenerationTransaction { db ->
|
repository.inSyncGenerationTransaction { db ->
|
||||||
fun query(sql: String, arguments: Array<String>): CapturedSyncGeneration? = db.rawQuery(
|
db.rawQuery(
|
||||||
sql,
|
"SELECT generation_id,manifest_sha256,staging_path FROM sync_generations " +
|
||||||
arguments,
|
"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),
|
||||||
).use { cursor ->
|
).use { cursor ->
|
||||||
if (!cursor.moveToFirst()) null
|
if (!cursor.moveToFirst()) null
|
||||||
else {
|
else {
|
||||||
|
|
@ -94,29 +88,6 @@ internal class SyncGenerationCoordinator(
|
||||||
captured
|
captured
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
query(
|
|
||||||
"SELECT generation_id,manifest_sha256,staging_path FROM sync_generations " +
|
|
||||||
"WHERE run_id=? AND (status IN('captured','published','waiting_acknowledgement') " +
|
|
||||||
"OR (status='acknowledged' AND NOT EXISTS(" +
|
|
||||||
"SELECT 1 FROM sync_consumed_generations c WHERE c.run_id=sync_generations.run_id " +
|
|
||||||
"AND c.result IN('consumed','rejected')))) " +
|
|
||||||
(if (consumerPeerId == null) "" else "AND consumer_peer_id=? ") +
|
|
||||||
"LIMIT 2",
|
|
||||||
if (consumerPeerId == null) arrayOf(runId) else arrayOf(runId, consumerPeerId),
|
|
||||||
) ?: if (consumerPeerId == null) null else query(
|
|
||||||
/* WHY: an RFCOMM reset creates a new conversation run while
|
|
||||||
* the immutable generation still awaits its correlated ACK.
|
|
||||||
* CONTRACT: resume only the unsuperseded generation for the
|
|
||||||
* same peer; the reference adopts the new run while manifest
|
|
||||||
* and generation identity remain unchanged. */
|
|
||||||
"SELECT g.generation_id,g.manifest_sha256,g.staging_path FROM sync_generations g " +
|
|
||||||
"WHERE g.consumer_peer_id=? AND g.status IN('captured','published','waiting_acknowledgement') " +
|
|
||||||
"AND NOT EXISTS(SELECT 1 FROM sync_generations sibling JOIN sync_acknowledgements a " +
|
|
||||||
"ON a.generation_id=sibling.generation_id WHERE sibling.parent_generation_id=g.parent_generation_id " +
|
|
||||||
"AND sibling.generation_id<>g.generation_id AND sibling.status='acknowledged') " +
|
|
||||||
"ORDER BY g.generated_at DESC LIMIT 2",
|
|
||||||
arrayOf(consumerPeerId),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun request(directory: File): JSONObject? {
|
private fun request(directory: File): JSONObject? {
|
||||||
|
|
@ -225,45 +196,6 @@ internal class SyncGenerationCoordinator(
|
||||||
if (actual != expected) throw SyncGenerationException("invalid $label fields")
|
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(
|
private fun reconcileArchiveAcknowledgements(
|
||||||
raw: ByteArray,
|
raw: ByteArray,
|
||||||
runId: String,
|
runId: String,
|
||||||
|
|
@ -367,7 +299,6 @@ internal class SyncGenerationCoordinator(
|
||||||
"mobile-history-v4",
|
"mobile-history-v4",
|
||||||
"execution-draft-v1",
|
"execution-draft-v1",
|
||||||
"generation-archive-v1",
|
"generation-archive-v1",
|
||||||
ACK_RECOVERY_CAPABILITY,
|
|
||||||
"session-preparations-v2",
|
"session-preparations-v2",
|
||||||
"programs-v1",
|
"programs-v1",
|
||||||
)
|
)
|
||||||
|
|
@ -428,7 +359,6 @@ internal class SyncGenerationCoordinator(
|
||||||
directory: File,
|
directory: File,
|
||||||
timeout: Duration = Duration.ofMinutes(5),
|
timeout: Duration = Duration.ofMinutes(5),
|
||||||
afterPeerPublication: (() -> Unit)? = null,
|
afterPeerPublication: (() -> Unit)? = null,
|
||||||
afterRecoveryAcknowledgements: (() -> Unit)? = null,
|
|
||||||
afterPublication: (() -> Unit)? = null,
|
afterPublication: (() -> Unit)? = null,
|
||||||
pollTransport: (() -> Unit)? = null,
|
pollTransport: (() -> Unit)? = null,
|
||||||
afterAcknowledgement: (() -> Unit)? = null,
|
afterAcknowledgement: (() -> Unit)? = null,
|
||||||
|
|
@ -503,44 +433,6 @@ internal class SyncGenerationCoordinator(
|
||||||
request.getString("desktop_peer_id"),
|
request.getString("desktop_peer_id"),
|
||||||
)
|
)
|
||||||
phase(runId, "archive_ack_observed", "desktop-archive-acknowledgements-v1.json")
|
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.
|
/* WHY: transport can fail after immutable generation publication.
|
||||||
* CONTRACT: an explicit retry for the same run republishes that
|
* CONTRACT: an explicit retry for the same run republishes that
|
||||||
* exact generation and resumes ACK handling; it never recaptures
|
* exact generation and resumes ACK handling; it never recaptures
|
||||||
|
|
@ -594,15 +486,12 @@ internal class SyncGenerationCoordinator(
|
||||||
afterPublication?.invoke()
|
afterPublication?.invoke()
|
||||||
lease.throwIfYieldRequested()
|
lease.throwIfYieldRequested()
|
||||||
// CONTRACT: durable objects from earlier conversations remain in
|
// CONTRACT: durable objects from earlier conversations remain in
|
||||||
// the exchange directory. A cross-run retry preserves the
|
// the exchange directory. Only this run's exact generation ACK
|
||||||
// immutable manifest's original run_id, so correlate its ACK by
|
// can advance its lineage; stale bytes are retained and ignored.
|
||||||
// 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 =
|
val desktopAck =
|
||||||
awaitCorrelated(File(directory, "desktop-consumption-ack-v1.json"), deadline, {
|
awaitCorrelated(File(directory, "desktop-consumption-ack-v1.json"), deadline, {
|
||||||
acknowledgementMatchesCapturedGeneration(it, captured)
|
it.optString("run_id") == captured.runId &&
|
||||||
|
it.optString("generation_id") == captured.generationId
|
||||||
}, pollTransport, lease)
|
}, pollTransport, lease)
|
||||||
service.acceptAcknowledgement(desktopAck)
|
service.acceptAcknowledgement(desktopAck)
|
||||||
phase(runId, "desktop_ack_observed", "desktop-consumption-ack-v1.json", captured.generationId)
|
phase(runId, "desktop_ack_observed", "desktop-consumption-ack-v1.json", captured.generationId)
|
||||||
|
|
@ -652,12 +541,6 @@ internal class SyncGenerationCoordinator(
|
||||||
phase(currentRunId, "cancelled", result = "interrupted")
|
phase(currentRunId, "cancelled", result = "interrupted")
|
||||||
ForegroundGenerationResult.Cancelled(currentRunId)
|
ForegroundGenerationResult.Cancelled(currentRunId)
|
||||||
} catch (error: Exception) {
|
} 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") {
|
if (error.message == "generation retention capacity exhausted") {
|
||||||
try {
|
try {
|
||||||
val request = JSONObject(File(directory, "request-v1.json").readText())
|
val request = JSONObject(File(directory, "request-v1.json").readText())
|
||||||
|
|
|
||||||
|
|
@ -52,8 +52,6 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
||||||
linkedMapOf(
|
linkedMapOf(
|
||||||
"catalog" to Kind("trainlog-pc-catalog", 1, "catalog-v1.json"),
|
"catalog" to Kind("trainlog-pc-catalog", 1, "catalog-v1.json"),
|
||||||
"history" to Kind("trainlog-mobile-export", 4, "history-v4.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
|
"execution-drafts" to
|
||||||
Kind("trainlog-execution-drafts", 1, "execution-drafts-v1.json"),
|
Kind("trainlog-execution-drafts", 1, "execution-drafts-v1.json"),
|
||||||
"exercise-aliases" to
|
"exercise-aliases" to
|
||||||
|
|
@ -70,17 +68,6 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
||||||
Kind("trainlog-causal-deletions", 1, "causal-deletions-v1.json"),
|
Kind("trainlog-causal-deletions", 1, "causal-deletions-v1.json"),
|
||||||
"program-executions" to
|
"program-executions" to
|
||||||
Kind("trainlog-program-executions", 1, "program-executions-v1.json", false),
|
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 =
|
private val SUPPORTED =
|
||||||
(ANDROID_KINDS.values +
|
(ANDROID_KINDS.values +
|
||||||
|
|
@ -297,26 +284,7 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
||||||
repository.inSyncGenerationTransaction { db ->
|
repository.inSyncGenerationTransaction { db ->
|
||||||
val retained =
|
val retained =
|
||||||
db.rawQuery(
|
db.rawQuery(
|
||||||
/* WHY: early deployed generation builds could advance a
|
"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)",
|
||||||
* 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),
|
arrayOf(consumerPeerId),
|
||||||
)
|
)
|
||||||
.use {
|
.use {
|
||||||
|
|
@ -362,35 +330,10 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val generatedAt = OffsetDateTime.now().toString()
|
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 =
|
val parent =
|
||||||
repository.inSyncGenerationTransaction { db ->
|
repository.inSyncGenerationTransaction { db ->
|
||||||
db.rawQuery(
|
db.rawQuery(
|
||||||
"SELECT g.generation_id FROM sync_generations g WHERE " +
|
"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",
|
||||||
"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),
|
arrayOf(producer, consumerPeerId),
|
||||||
)
|
)
|
||||||
.use {
|
.use {
|
||||||
|
|
@ -468,76 +411,6 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
||||||
arrayOf(operationId, generationId, if (existed) 0 else 1),
|
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)
|
return CapturedSyncGeneration(generationId, runId, digest, stage)
|
||||||
} catch (error: Exception) {
|
} catch (error: Exception) {
|
||||||
|
|
@ -791,7 +664,6 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
||||||
is ProgramsImportResult.Applied,
|
is ProgramsImportResult.Applied,
|
||||||
is CausalDeleteResult.Applied,
|
is CausalDeleteResult.Applied,
|
||||||
is CausalDeleteResult.Unchanged -> null
|
is CausalDeleteResult.Unchanged -> null
|
||||||
is TrainlogRepository.SleepDiaryImportResult.Applied -> null
|
|
||||||
else -> result.toString()
|
else -> result.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -899,38 +771,26 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val producerPeer = manifest.getJSONObject("producer").getString("peer_id")
|
val producerPeer = manifest.getJSONObject("producer").getString("peer_id")
|
||||||
/* WHY: the producer can archive one transient fork while this
|
val expectedParent =
|
||||||
* 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(
|
db.rawQuery(
|
||||||
"SELECT 1 FROM sync_consumed_generations g WHERE " +
|
"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",
|
||||||
tipPredicate + " LIMIT 1",
|
|
||||||
arrayOf(producerPeer),
|
arrayOf(producerPeer),
|
||||||
)
|
)
|
||||||
.use { it.moveToFirst() }
|
.use {
|
||||||
|
if (!it.moveToFirst()) null
|
||||||
|
else {
|
||||||
|
val value = it.getString(0)
|
||||||
|
if (it.moveToNext())
|
||||||
|
throw SyncGenerationException(
|
||||||
|
"consumed generation lineage has multiple tips"
|
||||||
|
)
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
val suppliedParent =
|
val suppliedParent =
|
||||||
if (manifest.isNull("parent_generation_id")) null
|
if (manifest.isNull("parent_generation_id")) null
|
||||||
else manifest.getString("parent_generation_id")
|
else manifest.getString("parent_generation_id")
|
||||||
val suppliedParentIsTip =
|
if (suppliedParent != expectedParent)
|
||||||
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")
|
throw SyncGenerationException("generation lineage is stale or unrelated")
|
||||||
val a = frozen.artifacts
|
val a = frozen.artifacts
|
||||||
fun apply(name: String, result: () -> Any) {
|
fun apply(name: String, result: () -> Any) {
|
||||||
|
|
@ -980,10 +840,6 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
||||||
apply("execution-drafts") {
|
apply("execution-drafts") {
|
||||||
repository.applyExecutionDraftExportV1Json(checkNotNull(a["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)
|
if ("ai-proposals" in a)
|
||||||
apply("ai-proposals") {
|
apply("ai-proposals") {
|
||||||
repository.applyAiSessionDraftsJson(checkNotNull(a["ai-proposals"]))
|
repository.applyAiSessionDraftsJson(checkNotNull(a["ai-proposals"]))
|
||||||
|
|
@ -1177,49 +1033,6 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
||||||
ack.getString("generation_id"),
|
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
|
target
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,16 +69,6 @@ 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 {
|
internal fun requestSync(snapshot: DirectExchangeSnapshot): SyncRequestResult {
|
||||||
if (
|
if (
|
||||||
Build.VERSION.SDK_INT <
|
Build.VERSION.SDK_INT <
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,37 +0,0 @@
|
||||||
package com.labfytools.trainlog.model
|
|
||||||
|
|
||||||
enum class CardioCalibrationPhase(val wireValue: String) {
|
|
||||||
WARMUP("warmup"),
|
|
||||||
PROGRESSIVE("progressive"),
|
|
||||||
HIGH_EFFORT("high_effort"),
|
|
||||||
RECOVERY("recovery"),
|
|
||||||
COMPLETED("completed"),
|
|
||||||
ABORTED("aborted");
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun fromWire(value: String): CardioCalibrationPhase =
|
|
||||||
entries.firstOrNull { it.wireValue == value }
|
|
||||||
?: error("Unknown cardio calibration phase: $value")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class CardioCalibrationRecoveryPoint(
|
|
||||||
val targetOffsetSeconds: Int,
|
|
||||||
val observedAt: String,
|
|
||||||
val bpm: Int,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CardioCalibrationProfile(
|
|
||||||
val calibrationId: String,
|
|
||||||
val protocolVersion: Int,
|
|
||||||
val sessionId: String,
|
|
||||||
val entryId: String,
|
|
||||||
val phase: CardioCalibrationPhase,
|
|
||||||
val startedAt: String,
|
|
||||||
val phaseStartedAt: String,
|
|
||||||
val effortEndAt: String?,
|
|
||||||
val endedAt: String?,
|
|
||||||
val heartRateCaptureId: String?,
|
|
||||||
val observedPeakBpm: Int?,
|
|
||||||
val recovery: List<CardioCalibrationRecoveryPoint>,
|
|
||||||
)
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
package com.labfytools.trainlog.model
|
|
||||||
|
|
||||||
enum class CardioPhaseKind(val wireValue: String) {
|
|
||||||
WARMUP("warmup"),
|
|
||||||
WORK("work"),
|
|
||||||
RECOVERY("recovery"),
|
|
||||||
COOLDOWN("cooldown"),
|
|
||||||
}
|
|
||||||
|
|
||||||
sealed interface CardioTargetDefinition {
|
|
||||||
data class AbsoluteBpm(
|
|
||||||
val minimumBpm: Int,
|
|
||||||
val maximumBpm: Int,
|
|
||||||
) : CardioTargetDefinition
|
|
||||||
|
|
||||||
data class CalibrationPercent(
|
|
||||||
val calibrationId: String,
|
|
||||||
val observedPeakBpm: Int,
|
|
||||||
val minimumPercent: Int,
|
|
||||||
val maximumPercent: Int,
|
|
||||||
) : CardioTargetDefinition
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Immutable target actually used by one phase.
|
|
||||||
*
|
|
||||||
* The calibration identity and percentages are provenance only. Runtime
|
|
||||||
* guidance consumes the resolved BPM bounds so later calibration changes
|
|
||||||
* cannot rewrite an already-started session.
|
|
||||||
*/
|
|
||||||
data class CardioTargetSnapshot(
|
|
||||||
val minimumBpm: Int,
|
|
||||||
val maximumBpm: Int,
|
|
||||||
val calibrationId: String? = null,
|
|
||||||
val calibrationObservedPeakBpm: Int? = null,
|
|
||||||
val minimumPercent: Int? = null,
|
|
||||||
val maximumPercent: Int? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
sealed interface CardioPhaseExitCondition {
|
|
||||||
data class FixedDuration(val seconds: Int) : CardioPhaseExitCondition
|
|
||||||
data object EnterTarget : CardioPhaseExitCondition
|
|
||||||
data class RecoverBelow(val bpm: Int) : CardioPhaseExitCondition
|
|
||||||
|
|
||||||
data class DurationOrRecoverBelow(
|
|
||||||
val maximumSeconds: Int,
|
|
||||||
val bpm: Int,
|
|
||||||
) : CardioPhaseExitCondition
|
|
||||||
}
|
|
||||||
|
|
||||||
data class CardioGuidedPhase(
|
|
||||||
val phaseId: String,
|
|
||||||
val kind: CardioPhaseKind,
|
|
||||||
val target: CardioTargetSnapshot?,
|
|
||||||
val exitCondition: CardioPhaseExitCondition,
|
|
||||||
)
|
|
||||||
|
|
||||||
enum class CardioGuidanceInstruction {
|
|
||||||
ACCELERATE,
|
|
||||||
MAINTAIN,
|
|
||||||
SLOW_DOWN,
|
|
||||||
SUSPENDED,
|
|
||||||
}
|
|
||||||
|
|
||||||
data class CardioGuidanceOutput(
|
|
||||||
val instruction: CardioGuidanceInstruction,
|
|
||||||
val bpm: Int?,
|
|
||||||
val target: CardioTargetSnapshot?,
|
|
||||||
val pendingInstruction: CardioGuidanceInstruction? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CardioGuidancePhaseRecord(
|
|
||||||
val runId: String,
|
|
||||||
val sessionId: String,
|
|
||||||
val entryId: String,
|
|
||||||
val phase: CardioGuidedPhase,
|
|
||||||
val position: Int,
|
|
||||||
val startedAt: String,
|
|
||||||
val endedAt: String?,
|
|
||||||
val currentInstruction: CardioGuidanceInstruction,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CardioGuidanceEvent(
|
|
||||||
val sequence: Long,
|
|
||||||
val observedAt: String,
|
|
||||||
val instruction: CardioGuidanceInstruction,
|
|
||||||
val bpm: Int?,
|
|
||||||
val targetMinimumBpm: Int?,
|
|
||||||
val targetMaximumBpm: Int?,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CardioGuidanceRun(
|
|
||||||
val runId: String,
|
|
||||||
val sessionId: String,
|
|
||||||
val startedAt: String,
|
|
||||||
val endedAt: String?,
|
|
||||||
val phases: List<CardioGuidancePhaseRecord>,
|
|
||||||
)
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
/*
|
|
||||||
* Android HeartRateModels model.
|
|
||||||
*
|
|
||||||
* Android owns sensor acquisition. Desktop/Web only consume synchronized
|
|
||||||
* measured facts and never connect to the heart-rate sensor.
|
|
||||||
*/
|
|
||||||
package com.labfytools.trainlog.model
|
|
||||||
|
|
||||||
enum class HeartRateContextKind(val wireValue: String) {
|
|
||||||
SESSION("session"),
|
|
||||||
CARDIO("cardio"),
|
|
||||||
SLEEP("sleep");
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun fromWire(value: String): HeartRateContextKind =
|
|
||||||
entries.firstOrNull { it.wireValue == value }
|
|
||||||
?: error("Unknown heart-rate context: $value")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class HeartRateRrInterval(
|
|
||||||
val index: Int,
|
|
||||||
/** Raw Bluetooth Heart Rate Service unit: 1/1024 second. */
|
|
||||||
val value1024: Int,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class HeartRateSample(
|
|
||||||
val sequence: Long,
|
|
||||||
val observedAt: String,
|
|
||||||
val bpm: Int,
|
|
||||||
val exerciseEntryId: String? = null,
|
|
||||||
val sensorContactDetected: Boolean? = null,
|
|
||||||
val energyExpended: Int? = null,
|
|
||||||
val rrIntervals: List<HeartRateRrInterval> = emptyList(),
|
|
||||||
)
|
|
||||||
|
|
||||||
data class HeartRateCapture(
|
|
||||||
val captureId: String,
|
|
||||||
val contextKind: HeartRateContextKind,
|
|
||||||
val contextId: String,
|
|
||||||
val startedAt: String,
|
|
||||||
val endedAt: String?,
|
|
||||||
val activeExerciseEntryId: String?,
|
|
||||||
val sensorName: String?,
|
|
||||||
val samples: List<HeartRateSample> = emptyList(),
|
|
||||||
)
|
|
||||||
|
|
@ -9,14 +9,17 @@ enum class SessionType(
|
||||||
val wireValue: String,
|
val wireValue: String,
|
||||||
) {
|
) {
|
||||||
TRAINING("training"),
|
TRAINING("training"),
|
||||||
MAX_TEST("max_test"),
|
MAX_TEST("max_test");
|
||||||
CARDIO("cardio");
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromWire(
|
fun fromWire(
|
||||||
value: String,
|
value: String,
|
||||||
): SessionType =
|
): SessionType =
|
||||||
entries.firstOrNull { it.wireValue == value } ?: TRAINING
|
if (value == "max_test") {
|
||||||
|
MAX_TEST
|
||||||
|
} else {
|
||||||
|
TRAINING
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,24 +79,6 @@ data class SessionDraft(
|
||||||
val sessionType: SessionType = SessionType.TRAINING,
|
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(
|
data class SessionDraftForm(
|
||||||
val selectedExercise: ExerciseProfile? = null,
|
val selectedExercise: ExerciseProfile? = null,
|
||||||
/** Null means creation; otherwise replace this durable draft entry. */
|
/** Null means creation; otherwise replace this durable draft entry. */
|
||||||
|
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
package com.labfytools.trainlog.model
|
|
||||||
|
|
||||||
enum class SleepQuality(val wireValue: String) { TB("TB"), B("B"), MOY("Moy"), M("M"), TM("TM") }
|
|
||||||
enum class SleepEventType(val wireValue: String) {
|
|
||||||
BED_TIME("bed_time"), FINAL_GET_UP("final_get_up"), NIGHT_GET_UP("night_get_up"),
|
|
||||||
SLEEP("sleep"), NAP("nap"), LONG_AWAKE("long_awake"), HALF_SLEEP("half_sleep"),
|
|
||||||
DAYTIME_SLEEPINESS("daytime_sleepiness"),
|
|
||||||
}
|
|
||||||
enum class SleepPublicationStatus { DRAFT, READY, SYNCHRONIZED, MODIFIED }
|
|
||||||
data class SleepDiaryEvent(val eventId: String, val type: SleepEventType, val startAt: String, val endAt: String?)
|
|
||||||
data class SleepMedication(
|
|
||||||
val medicationId: String, val revisionId: String, val createdAt: String, val updatedAt: String,
|
|
||||||
val name: String, val defaultDoseValue: Double?, val defaultDoseUnit: String?,
|
|
||||||
val form: String, val note: String, val active: Boolean,
|
|
||||||
)
|
|
||||||
data class MedicationIntake(
|
|
||||||
val intakeId: String, val medicationId: String, val medicationName: String,
|
|
||||||
val takenAt: String, val doseValue: Double?, val doseUnit: String?, val note: String,
|
|
||||||
val createdAt: String, val quantity: Int = 1,
|
|
||||||
)
|
|
||||||
data class SleepDiaryEntry(
|
|
||||||
val entryId: String, val nightStartDate: String, val nightEndDate: String,
|
|
||||||
val createdAt: String, val updatedAt: String, val revisionId: String,
|
|
||||||
val sleepQuality: SleepQuality?, val wakeQuality: SleepQuality?, val dayForm: SleepQuality?,
|
|
||||||
val treatmentAndNotes: String, val events: List<SleepDiaryEvent>, val intakes: List<MedicationIntake>,
|
|
||||||
val publicationStatus: SleepPublicationStatus,
|
|
||||||
)
|
|
||||||
data class SleepDiaryDraft(
|
|
||||||
val entryId: String? = null, val expectedRevision: String? = null,
|
|
||||||
val nightStartDate: String, val nightEndDate: String, val createdAt: String,
|
|
||||||
val updatedAt: String, val sleepQuality: SleepQuality?, val wakeQuality: SleepQuality?,
|
|
||||||
val dayForm: SleepQuality?, val treatmentAndNotes: String, val events: List<SleepDiaryEvent>,
|
|
||||||
val intakes: List<MedicationIntake> = emptyList(),
|
|
||||||
)
|
|
||||||
|
|
@ -43,7 +43,6 @@ private val drawerIcons = mapOf(
|
||||||
AppSection.SESSIONS to R.drawable.ic_sessions,
|
AppSection.SESSIONS to R.drawable.ic_sessions,
|
||||||
AppSection.EXERCISES to R.drawable.ic_exercises,
|
AppSection.EXERCISES to R.drawable.ic_exercises,
|
||||||
AppSection.EQUIPMENT to R.drawable.ic_equipment,
|
AppSection.EQUIPMENT to R.drawable.ic_equipment,
|
||||||
AppSection.SLEEP to R.drawable.ic_statistics,
|
|
||||||
AppSection.SYNC to R.drawable.ic_sync,
|
AppSection.SYNC to R.drawable.ic_sync,
|
||||||
AppSection.SETTINGS to R.drawable.ic_settings,
|
AppSection.SETTINGS to R.drawable.ic_settings,
|
||||||
)
|
)
|
||||||
|
|
@ -61,7 +60,6 @@ fun AndroidAppShell(
|
||||||
fun sectionLabel(section: AppSection): String = strings.getString(when (section) {
|
fun sectionLabel(section: AppSection): String = strings.getString(when (section) {
|
||||||
AppSection.HOME -> R.string.nav_home; AppSection.SESSIONS -> R.string.nav_sessions
|
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.EXERCISES -> R.string.nav_exercises; AppSection.EQUIPMENT -> R.string.nav_equipment
|
||||||
AppSection.SLEEP -> R.string.nav_sleep
|
|
||||||
AppSection.SYNC -> R.string.nav_sync; AppSection.SETTINGS -> R.string.nav_settings
|
AppSection.SYNC -> R.string.nav_sync; AppSection.SETTINGS -> R.string.nav_settings
|
||||||
})
|
})
|
||||||
fun title(): String = strings.getString(when (route) {
|
fun title(): String = strings.getString(when (route) {
|
||||||
|
|
@ -69,16 +67,13 @@ fun AndroidAppShell(
|
||||||
AppRoute.SessionEditor -> R.string.route_session_editor; AppRoute.SessionGenerator -> R.string.route_session_generator
|
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.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.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
|
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
|
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
|
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
|
AppRoute.Equipment -> R.string.nav_equipment; is AppRoute.EquipmentDetail -> R.string.route_equipment_detail
|
||||||
is AppRoute.EquipmentCreate -> R.string.route_equipment_create
|
is AppRoute.EquipmentCreate -> R.string.route_equipment_create
|
||||||
AppRoute.BodyMeasurements -> R.string.route_body_measurements; AppRoute.LatestMaxima -> R.string.route_latest_maxima
|
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.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 drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
@ -147,7 +142,6 @@ fun AndroidAppShell(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
HeartRateIndicator()
|
|
||||||
if (!root) {
|
if (!root) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { scope.launch { drawerState.open() } },
|
onClick = { scope.launch { drawerState.open() } },
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import androidx.lifecycle.ViewModel
|
||||||
|
|
||||||
enum class AppSection {
|
enum class AppSection {
|
||||||
HOME, SESSIONS, EXERCISES,
|
HOME, SESSIONS, EXERCISES,
|
||||||
EQUIPMENT, SLEEP, SYNC, SETTINGS,
|
EQUIPMENT, SYNC, SETTINGS,
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed interface AppRoute {
|
sealed interface AppRoute {
|
||||||
|
|
@ -24,7 +24,6 @@ sealed interface AppRoute {
|
||||||
data object AiSessionDrafts : AppRoute { override val section = AppSection.SESSIONS }
|
data object AiSessionDrafts : AppRoute { override val section = AppSection.SESSIONS }
|
||||||
data object CompletedSessions : 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 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 ProgramDetail(val programId: String) : AppRoute { override val section = AppSection.SESSIONS }
|
||||||
data class SessionDetail(val sessionId: 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 }
|
data class SessionCorrection(val sessionId: String) : AppRoute { override val section = AppSection.SESSIONS }
|
||||||
|
|
@ -36,11 +35,9 @@ sealed interface AppRoute {
|
||||||
data class EquipmentDetail(val equipmentId: String) : 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 class EquipmentCreate(val caller: AppRoute) : AppRoute { override val section = caller.section }
|
||||||
data object BodyMeasurements : AppRoute { override val section = AppSection.HOME }
|
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 LatestMaxima : AppRoute { override val section = AppSection.EXERCISES }
|
||||||
data object Sync : AppRoute { override val section = AppSection.SYNC }
|
data object Sync : AppRoute { override val section = AppSection.SYNC }
|
||||||
data object Settings : AppRoute { override val section = AppSection.SETTINGS }
|
data object Settings : AppRoute { override val section = AppSection.SETTINGS }
|
||||||
data object HeartRateDiagnostics : AppRoute { override val section = AppSection.SETTINGS }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun AppSection.rootRoute(): AppRoute = when (this) {
|
fun AppSection.rootRoute(): AppRoute = when (this) {
|
||||||
|
|
@ -48,7 +45,6 @@ fun AppSection.rootRoute(): AppRoute = when (this) {
|
||||||
AppSection.SESSIONS -> AppRoute.Sessions
|
AppSection.SESSIONS -> AppRoute.Sessions
|
||||||
AppSection.EXERCISES -> AppRoute.Exercises
|
AppSection.EXERCISES -> AppRoute.Exercises
|
||||||
AppSection.EQUIPMENT -> AppRoute.Equipment
|
AppSection.EQUIPMENT -> AppRoute.Equipment
|
||||||
AppSection.SLEEP -> AppRoute.SleepDiary
|
|
||||||
AppSection.SYNC -> AppRoute.Sync
|
AppSection.SYNC -> AppRoute.Sync
|
||||||
AppSection.SETTINGS -> AppRoute.Settings
|
AppSection.SETTINGS -> AppRoute.Settings
|
||||||
}
|
}
|
||||||
|
|
@ -173,11 +169,5 @@ class TrainlogAppState : ViewModel() {
|
||||||
val equipment = EquipmentScreenState()
|
val equipment = EquipmentScreenState()
|
||||||
val exercise = ExerciseScreenState()
|
val exercise = ExerciseScreenState()
|
||||||
val body = BodyScreenState()
|
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)
|
val navigationController = AppNavigationController(navigation, generator, equipment, exercise, body)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,172 +0,0 @@
|
||||||
package com.labfytools.trainlog.ui
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import com.labfytools.trainlog.R
|
|
||||||
import com.labfytools.trainlog.data.TrainlogRepository
|
|
||||||
import com.labfytools.trainlog.model.CardioCalibrationPhase
|
|
||||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun CardioCalibrationScreen(
|
|
||||||
repository: TrainlogRepository,
|
|
||||||
onChanged: () -> Unit,
|
|
||||||
) {
|
|
||||||
val strings = localizedContext()
|
|
||||||
val colors = LocalTrainlogColors.current
|
|
||||||
var revision by remember { mutableIntStateOf(0) }
|
|
||||||
var message by remember { mutableStateOf<String?>(null) }
|
|
||||||
val active = remember(revision) { repository.activeCardioCalibration() }
|
|
||||||
val latest = remember(revision) {
|
|
||||||
repository.listCompletedCardioCalibrations(1).firstOrNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun apply(result: TrainlogRepository.CardioCalibrationResult) {
|
|
||||||
message =
|
|
||||||
when (result) {
|
|
||||||
is TrainlogRepository.CardioCalibrationResult.Applied -> {
|
|
||||||
revision++
|
|
||||||
onChanged()
|
|
||||||
null
|
|
||||||
}
|
|
||||||
is TrainlogRepository.CardioCalibrationResult.Invalid -> result.message
|
|
||||||
TrainlogRepository.CardioCalibrationResult.Conflict ->
|
|
||||||
strings.getString(R.string.calibration_conflict)
|
|
||||||
is TrainlogRepository.CardioCalibrationResult.DatabaseError ->
|
|
||||||
result.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun phaseLabel(phase: CardioCalibrationPhase): String =
|
|
||||||
strings.getString(
|
|
||||||
when (phase) {
|
|
||||||
CardioCalibrationPhase.WARMUP -> R.string.calibration_phase_warmup
|
|
||||||
CardioCalibrationPhase.PROGRESSIVE -> R.string.calibration_phase_progressive
|
|
||||||
CardioCalibrationPhase.HIGH_EFFORT -> R.string.calibration_phase_high_effort
|
|
||||||
CardioCalibrationPhase.RECOVERY -> R.string.calibration_phase_recovery
|
|
||||||
CardioCalibrationPhase.COMPLETED -> R.string.calibration_phase_completed
|
|
||||||
CardioCalibrationPhase.ABORTED -> R.string.calibration_phase_aborted
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
TrainlogScreen(
|
|
||||||
strings.getString(R.string.route_cardio_calibration),
|
|
||||||
scrollKey = "cardio-calibration",
|
|
||||||
) {
|
|
||||||
TrainlogInfo(strings.getString(R.string.calibration_explanation), colors.muted)
|
|
||||||
|
|
||||||
if (active == null) {
|
|
||||||
TrainlogPrimaryAction(
|
|
||||||
strings.getString(R.string.calibration_start),
|
|
||||||
strings.getString(R.string.calibration_start_description),
|
|
||||||
) {
|
|
||||||
apply(repository.startCardioCalibration())
|
|
||||||
}
|
|
||||||
latest?.let { profile ->
|
|
||||||
TrainlogFrame(strings.getString(R.string.calibration_latest)) {
|
|
||||||
TrainlogInfo(
|
|
||||||
strings.getString(
|
|
||||||
R.string.calibration_peak_value,
|
|
||||||
profile.observedPeakBpm ?: 0,
|
|
||||||
),
|
|
||||||
colors.accent,
|
|
||||||
)
|
|
||||||
profile.recovery.forEach { point ->
|
|
||||||
TrainlogInfo(
|
|
||||||
strings.getString(
|
|
||||||
R.string.calibration_recovery_value,
|
|
||||||
point.targetOffsetSeconds / 60,
|
|
||||||
point.bpm,
|
|
||||||
),
|
|
||||||
colors.muted,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
TrainlogFrame(strings.getString(R.string.calibration_current)) {
|
|
||||||
TrainlogInfo(
|
|
||||||
strings.getString(
|
|
||||||
R.string.calibration_phase_value,
|
|
||||||
phaseLabel(active.phase),
|
|
||||||
),
|
|
||||||
colors.accent,
|
|
||||||
)
|
|
||||||
active.observedPeakBpm?.let { bpm ->
|
|
||||||
TrainlogInfo(
|
|
||||||
strings.getString(R.string.calibration_peak_value, bpm),
|
|
||||||
colors.warning,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
active.recovery.forEach { point ->
|
|
||||||
TrainlogInfo(
|
|
||||||
strings.getString(
|
|
||||||
R.string.calibration_recovery_value,
|
|
||||||
point.targetOffsetSeconds / 60,
|
|
||||||
point.bpm,
|
|
||||||
),
|
|
||||||
colors.muted,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
when (active.phase) {
|
|
||||||
CardioCalibrationPhase.WARMUP -> {
|
|
||||||
TrainlogPrimaryAction(
|
|
||||||
strings.getString(R.string.calibration_to_progressive),
|
|
||||||
strings.getString(R.string.calibration_progressive_description),
|
|
||||||
) {
|
|
||||||
apply(repository.advanceCardioCalibrationPhase())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
CardioCalibrationPhase.PROGRESSIVE -> {
|
|
||||||
TrainlogPrimaryAction(
|
|
||||||
strings.getString(R.string.calibration_to_high_effort),
|
|
||||||
strings.getString(R.string.calibration_high_effort_description),
|
|
||||||
) {
|
|
||||||
apply(repository.advanceCardioCalibrationPhase())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
CardioCalibrationPhase.HIGH_EFFORT -> {
|
|
||||||
TrainlogPrimaryAction(
|
|
||||||
strings.getString(R.string.calibration_end_effort),
|
|
||||||
strings.getString(R.string.calibration_end_effort_description),
|
|
||||||
) {
|
|
||||||
apply(repository.markCardioCalibrationEffortEnd())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
CardioCalibrationPhase.RECOVERY -> {
|
|
||||||
TrainlogPrimaryAction(
|
|
||||||
strings.getString(R.string.calibration_finish),
|
|
||||||
strings.getString(R.string.calibration_finish_description),
|
|
||||||
) {
|
|
||||||
apply(repository.finishCardioCalibration())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
CardioCalibrationPhase.COMPLETED,
|
|
||||||
CardioCalibrationPhase.ABORTED -> Unit
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
|
||||||
Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.End,
|
|
||||||
) {
|
|
||||||
TrainlogButton(
|
|
||||||
strings.getString(R.string.calibration_abort),
|
|
||||||
{ apply(repository.abortCardioCalibration()) },
|
|
||||||
style = TrainlogButtonStyle.DESTRUCTIVE,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
message?.let { TrainlogInfo(it, colors.error) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,385 +0,0 @@
|
||||||
package com.labfytools.trainlog.ui
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.labfytools.trainlog.R
|
|
||||||
import com.labfytools.trainlog.data.CardioGuidanceLiveState
|
|
||||||
import com.labfytools.trainlog.data.CardioTargetResolver
|
|
||||||
import com.labfytools.trainlog.data.TrainlogRepository
|
|
||||||
import com.labfytools.trainlog.model.CardioGuidanceInstruction
|
|
||||||
import com.labfytools.trainlog.model.CardioGuidedPhase
|
|
||||||
import com.labfytools.trainlog.model.CardioPhaseExitCondition
|
|
||||||
import com.labfytools.trainlog.model.CardioPhaseKind
|
|
||||||
import com.labfytools.trainlog.model.CardioTargetDefinition
|
|
||||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun CardioGuidancePanel(
|
|
||||||
repository: TrainlogRepository,
|
|
||||||
entryId: String,
|
|
||||||
) {
|
|
||||||
val strings = localizedContext()
|
|
||||||
val colors = LocalTrainlogColors.current
|
|
||||||
var revision by remember(entryId) { mutableStateOf(0) }
|
|
||||||
val active =
|
|
||||||
remember(entryId, revision) {
|
|
||||||
repository.activeCardioGuidancePhase()?.takeIf { it.entryId == entryId }
|
|
||||||
}
|
|
||||||
val calibration =
|
|
||||||
remember(revision) {
|
|
||||||
repository.listCompletedCardioCalibrations(1).firstOrNull()
|
|
||||||
}
|
|
||||||
var live by remember { mutableStateOf(CardioGuidanceLiveState.current()) }
|
|
||||||
|
|
||||||
DisposableEffect(entryId) {
|
|
||||||
val subscription = CardioGuidanceLiveState.subscribe { live = it }
|
|
||||||
onDispose { subscription.close() }
|
|
||||||
}
|
|
||||||
|
|
||||||
TrainlogFrame(
|
|
||||||
title = strings.getString(R.string.cardio_guidance_title),
|
|
||||||
active = active != null,
|
|
||||||
) {
|
|
||||||
if (active != null) {
|
|
||||||
val phase = active.phase
|
|
||||||
val current = live.takeIf { it.phaseId == phase.phaseId }
|
|
||||||
val instruction = current?.instruction ?: active.currentInstruction
|
|
||||||
val bpm = current?.bpm
|
|
||||||
|
|
||||||
TrainlogInfo(
|
|
||||||
strings.getString(
|
|
||||||
R.string.cardio_guidance_phase_value,
|
|
||||||
phaseKindLabel(strings, phase.kind),
|
|
||||||
),
|
|
||||||
colors.muted,
|
|
||||||
)
|
|
||||||
phase.target?.let { target ->
|
|
||||||
TrainlogInfo(
|
|
||||||
strings.getString(
|
|
||||||
R.string.cardio_guidance_target_value,
|
|
||||||
target.minimumBpm,
|
|
||||||
target.maximumBpm,
|
|
||||||
),
|
|
||||||
colors.accent,
|
|
||||||
)
|
|
||||||
if (target.calibrationId != null) {
|
|
||||||
TrainlogInfo(
|
|
||||||
strings.getString(
|
|
||||||
R.string.cardio_guidance_calibration_value,
|
|
||||||
target.calibrationObservedPeakBpm ?: 0,
|
|
||||||
target.minimumPercent ?: 0,
|
|
||||||
target.maximumPercent ?: 0,
|
|
||||||
),
|
|
||||||
colors.muted,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TrainlogInfo(
|
|
||||||
guidanceInstructionLabel(strings, instruction),
|
|
||||||
when (instruction) {
|
|
||||||
CardioGuidanceInstruction.MAINTAIN -> colors.success
|
|
||||||
CardioGuidanceInstruction.ACCELERATE -> colors.accent
|
|
||||||
CardioGuidanceInstruction.SLOW_DOWN,
|
|
||||||
CardioGuidanceInstruction.SUSPENDED -> colors.warning
|
|
||||||
},
|
|
||||||
)
|
|
||||||
TrainlogInfo(
|
|
||||||
if (bpm == null) {
|
|
||||||
strings.getString(R.string.cardio_guidance_bpm_missing)
|
|
||||||
} else {
|
|
||||||
strings.getString(R.string.cardio_guidance_bpm_value, bpm)
|
|
||||||
},
|
|
||||||
if (bpm == null) colors.warning else colors.text,
|
|
||||||
)
|
|
||||||
TrainlogButton(
|
|
||||||
label = strings.getString(R.string.cardio_guidance_finish_phase),
|
|
||||||
onClick = {
|
|
||||||
if (
|
|
||||||
repository.finishCardioGuidancePhase(phase.phaseId) is
|
|
||||||
TrainlogRepository.CardioGuidanceMutationResult.Applied
|
|
||||||
) {
|
|
||||||
CardioGuidanceLiveState.clear()
|
|
||||||
revision++
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
style = TrainlogButtonStyle.SECONDARY,
|
|
||||||
)
|
|
||||||
return@TrainlogFrame
|
|
||||||
}
|
|
||||||
|
|
||||||
var kind by remember(entryId) { mutableStateOf(CardioPhaseKind.WARMUP) }
|
|
||||||
var targetMode by
|
|
||||||
remember(entryId, calibration?.calibrationId) {
|
|
||||||
mutableStateOf(if (calibration == null) "bpm" else "calibration")
|
|
||||||
}
|
|
||||||
var minimumBpm by remember(entryId) { mutableStateOf("") }
|
|
||||||
var maximumBpm by remember(entryId) { mutableStateOf("") }
|
|
||||||
var minimumPercent by remember(entryId) { mutableStateOf("") }
|
|
||||||
var maximumPercent by remember(entryId) { mutableStateOf("") }
|
|
||||||
var exitMode by remember(entryId) { mutableStateOf("duration") }
|
|
||||||
var durationSeconds by remember(entryId) { mutableStateOf("") }
|
|
||||||
var recoveryBpm by remember(entryId) { mutableStateOf("") }
|
|
||||||
var message by remember(entryId) { mutableStateOf<String?>(null) }
|
|
||||||
|
|
||||||
TrainlogChoiceChips(
|
|
||||||
choices =
|
|
||||||
listOf(
|
|
||||||
"warmup" to strings.getString(R.string.cardio_phase_warmup),
|
|
||||||
"work" to strings.getString(R.string.cardio_phase_work),
|
|
||||||
"recovery" to strings.getString(R.string.cardio_phase_recovery),
|
|
||||||
"cooldown" to strings.getString(R.string.cardio_phase_cooldown),
|
|
||||||
),
|
|
||||||
selectedId = kind.wireValue,
|
|
||||||
onSelected = { selected ->
|
|
||||||
kind = CardioPhaseKind.entries.first { it.wireValue == selected }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
TrainlogChoiceChips(
|
|
||||||
choices =
|
|
||||||
buildList {
|
|
||||||
add("bpm" to strings.getString(R.string.cardio_target_absolute))
|
|
||||||
if (calibration?.observedPeakBpm != null) {
|
|
||||||
add(
|
|
||||||
"calibration" to
|
|
||||||
strings.getString(R.string.cardio_target_calibration),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
selectedId = targetMode,
|
|
||||||
onSelected = { targetMode = it },
|
|
||||||
)
|
|
||||||
|
|
||||||
if (targetMode == "calibration") {
|
|
||||||
calibration?.observedPeakBpm?.let { peak ->
|
|
||||||
TrainlogInfo(
|
|
||||||
strings.getString(R.string.cardio_guidance_latest_calibration, peak),
|
|
||||||
colors.muted,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
NumberPair(
|
|
||||||
leftLabel = strings.getString(R.string.cardio_target_min_percent),
|
|
||||||
left = minimumPercent,
|
|
||||||
onLeft = { minimumPercent = digits(it, 3) },
|
|
||||||
rightLabel = strings.getString(R.string.cardio_target_max_percent),
|
|
||||||
right = maximumPercent,
|
|
||||||
onRight = { maximumPercent = digits(it, 3) },
|
|
||||||
suffix = "%",
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
NumberPair(
|
|
||||||
leftLabel = strings.getString(R.string.cardio_target_min_bpm),
|
|
||||||
left = minimumBpm,
|
|
||||||
onLeft = { minimumBpm = digits(it, 5) },
|
|
||||||
rightLabel = strings.getString(R.string.cardio_target_max_bpm),
|
|
||||||
right = maximumBpm,
|
|
||||||
onRight = { maximumBpm = digits(it, 5) },
|
|
||||||
suffix = "BPM",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
TrainlogChoiceChips(
|
|
||||||
choices =
|
|
||||||
listOf(
|
|
||||||
"duration" to strings.getString(R.string.cardio_exit_duration),
|
|
||||||
"target" to strings.getString(R.string.cardio_exit_target),
|
|
||||||
"recover" to strings.getString(R.string.cardio_exit_recover),
|
|
||||||
"bounded" to strings.getString(R.string.cardio_exit_bounded),
|
|
||||||
),
|
|
||||||
selectedId = exitMode,
|
|
||||||
onSelected = { exitMode = it },
|
|
||||||
)
|
|
||||||
|
|
||||||
if (exitMode == "duration" || exitMode == "bounded") {
|
|
||||||
TrainlogInputField(
|
|
||||||
label = strings.getString(R.string.cardio_exit_seconds),
|
|
||||||
value = durationSeconds,
|
|
||||||
onValueChange = { durationSeconds = digits(it, 5) },
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
|
||||||
suffix = "s",
|
|
||||||
compact = true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (exitMode == "recover" || exitMode == "bounded") {
|
|
||||||
TrainlogInputField(
|
|
||||||
label = strings.getString(R.string.cardio_exit_recovery_bpm),
|
|
||||||
value = recoveryBpm,
|
|
||||||
onValueChange = { recoveryBpm = digits(it, 5) },
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
|
||||||
suffix = "BPM",
|
|
||||||
compact = true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val target =
|
|
||||||
if (targetMode == "calibration") {
|
|
||||||
val peak = calibration?.observedPeakBpm
|
|
||||||
val min = minimumPercent.toIntOrNull()
|
|
||||||
val max = maximumPercent.toIntOrNull()
|
|
||||||
if (calibration == null || peak == null || min == null || max == null) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
CardioTargetResolver.resolve(
|
|
||||||
CardioTargetDefinition.CalibrationPercent(
|
|
||||||
calibration.calibrationId,
|
|
||||||
peak,
|
|
||||||
min,
|
|
||||||
max,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val min = minimumBpm.toIntOrNull()
|
|
||||||
val max = maximumBpm.toIntOrNull()
|
|
||||||
if (min == null || max == null) null
|
|
||||||
else CardioTargetResolver.resolve(
|
|
||||||
CardioTargetDefinition.AbsoluteBpm(min, max),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
target?.let {
|
|
||||||
TrainlogInfo(
|
|
||||||
strings.getString(
|
|
||||||
R.string.cardio_guidance_resolved_target,
|
|
||||||
it.minimumBpm,
|
|
||||||
it.maximumBpm,
|
|
||||||
),
|
|
||||||
colors.accent,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val exit =
|
|
||||||
when (exitMode) {
|
|
||||||
"duration" ->
|
|
||||||
durationSeconds.toIntOrNull()
|
|
||||||
?.takeIf { it in 1..86400 }
|
|
||||||
?.let(CardioPhaseExitCondition::FixedDuration)
|
|
||||||
"target" -> CardioPhaseExitCondition.EnterTarget
|
|
||||||
"recover" ->
|
|
||||||
recoveryBpm.toIntOrNull()
|
|
||||||
?.takeIf { it in 1..65535 }
|
|
||||||
?.let(CardioPhaseExitCondition::RecoverBelow)
|
|
||||||
"bounded" -> {
|
|
||||||
val seconds = durationSeconds.toIntOrNull()
|
|
||||||
val bpm = recoveryBpm.toIntOrNull()
|
|
||||||
if (seconds != null && bpm != null &&
|
|
||||||
seconds in 1..86400 && bpm in 1..65535
|
|
||||||
) {
|
|
||||||
CardioPhaseExitCondition.DurationOrRecoverBelow(seconds, bpm)
|
|
||||||
} else null
|
|
||||||
}
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
message?.let { TrainlogInfo(it, colors.warning) }
|
|
||||||
|
|
||||||
TrainlogButton(
|
|
||||||
label = strings.getString(R.string.cardio_guidance_start_phase),
|
|
||||||
onClick = {
|
|
||||||
val actualTarget = target
|
|
||||||
val actualExit = exit
|
|
||||||
if (actualTarget == null || actualExit == null) {
|
|
||||||
message =
|
|
||||||
strings.getString(R.string.cardio_guidance_invalid_configuration)
|
|
||||||
return@TrainlogButton
|
|
||||||
}
|
|
||||||
val result =
|
|
||||||
repository.startCardioGuidancePhase(
|
|
||||||
entryId,
|
|
||||||
CardioGuidedPhase(
|
|
||||||
phaseId = "cgp_" + UUID.randomUUID(),
|
|
||||||
kind = kind,
|
|
||||||
target = actualTarget,
|
|
||||||
exitCondition = actualExit,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (result is TrainlogRepository.CardioGuidanceMutationResult.Applied) {
|
|
||||||
message = null
|
|
||||||
revision++
|
|
||||||
} else {
|
|
||||||
message = strings.getString(R.string.cardio_guidance_start_failed)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
enabled = target != null && exit != null,
|
|
||||||
style = TrainlogButtonStyle.SUCCESS,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun NumberPair(
|
|
||||||
leftLabel: String,
|
|
||||||
left: String,
|
|
||||||
onLeft: (String) -> Unit,
|
|
||||||
rightLabel: String,
|
|
||||||
right: String,
|
|
||||||
onRight: (String) -> Unit,
|
|
||||||
suffix: String,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
TrainlogInputField(
|
|
||||||
label = leftLabel,
|
|
||||||
value = left,
|
|
||||||
onValueChange = onLeft,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
|
||||||
suffix = suffix,
|
|
||||||
compact = true,
|
|
||||||
)
|
|
||||||
TrainlogInputField(
|
|
||||||
label = rightLabel,
|
|
||||||
value = right,
|
|
||||||
onValueChange = onRight,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
|
||||||
suffix = suffix,
|
|
||||||
compact = true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun phaseKindLabel(
|
|
||||||
strings: android.content.Context,
|
|
||||||
kind: CardioPhaseKind,
|
|
||||||
): String =
|
|
||||||
strings.getString(
|
|
||||||
when (kind) {
|
|
||||||
CardioPhaseKind.WARMUP -> R.string.cardio_phase_warmup
|
|
||||||
CardioPhaseKind.WORK -> R.string.cardio_phase_work
|
|
||||||
CardioPhaseKind.RECOVERY -> R.string.cardio_phase_recovery
|
|
||||||
CardioPhaseKind.COOLDOWN -> R.string.cardio_phase_cooldown
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun guidanceInstructionLabel(
|
|
||||||
strings: android.content.Context,
|
|
||||||
instruction: CardioGuidanceInstruction,
|
|
||||||
): String =
|
|
||||||
strings.getString(
|
|
||||||
when (instruction) {
|
|
||||||
CardioGuidanceInstruction.ACCELERATE -> R.string.cardio_guidance_accelerate
|
|
||||||
CardioGuidanceInstruction.MAINTAIN -> R.string.cardio_guidance_maintain
|
|
||||||
CardioGuidanceInstruction.SLOW_DOWN -> R.string.cardio_guidance_slow_down
|
|
||||||
CardioGuidanceInstruction.SUSPENDED -> R.string.cardio_guidance_suspended
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun digits(value: String, maximum: Int): String =
|
|
||||||
value.filter(Char::isDigit).take(maximum)
|
|
||||||
|
|
@ -1,203 +0,0 @@
|
||||||
package com.labfytools.trainlog.ui
|
|
||||||
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.labfytools.trainlog.R
|
|
||||||
import com.labfytools.trainlog.data.HeartRateBleDiagnostics
|
|
||||||
import com.labfytools.trainlog.data.HeartRateDiagnosticPhase
|
|
||||||
import com.labfytools.trainlog.data.HeartRateDiagnosticSnapshot
|
|
||||||
import com.labfytools.trainlog.data.HeartRateSensorPreferences
|
|
||||||
import com.labfytools.trainlog.data.HeartRateSensorService
|
|
||||||
import com.labfytools.trainlog.data.hasHeartRateBleRuntimePermissions
|
|
||||||
import com.labfytools.trainlog.data.heartRateBleRuntimePermissions
|
|
||||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun HeartRateDiagnosticsScreen(onSelected: () -> Unit) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val strings = localizedContext()
|
|
||||||
val colors = LocalTrainlogColors.current
|
|
||||||
var snapshot by remember { mutableStateOf(HeartRateDiagnosticSnapshot()) }
|
|
||||||
var pendingExpandedScan by remember { mutableStateOf(false) }
|
|
||||||
var permissionDenied by remember { mutableStateOf(false) }
|
|
||||||
val diagnostics =
|
|
||||||
remember(context) {
|
|
||||||
HeartRateBleDiagnostics(context) { next -> snapshot = next }
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(diagnostics) {
|
|
||||||
onDispose { diagnostics.close() }
|
|
||||||
}
|
|
||||||
|
|
||||||
val permissionLauncher =
|
|
||||||
rememberLauncherForActivityResult(
|
|
||||||
ActivityResultContracts.RequestMultiplePermissions(),
|
|
||||||
) { results ->
|
|
||||||
if (heartRateBleRuntimePermissions().all { results[it] == true }) {
|
|
||||||
permissionDenied = false
|
|
||||||
diagnostics.startScan(pendingExpandedScan)
|
|
||||||
} else {
|
|
||||||
permissionDenied = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun scan(expanded: Boolean) {
|
|
||||||
pendingExpandedScan = expanded
|
|
||||||
if (hasHeartRateBleRuntimePermissions(context)) {
|
|
||||||
permissionDenied = false
|
|
||||||
diagnostics.startScan(expanded)
|
|
||||||
} else {
|
|
||||||
permissionLauncher.launch(heartRateBleRuntimePermissions())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun phaseLabel(): String =
|
|
||||||
strings.getString(
|
|
||||||
when (snapshot.phase) {
|
|
||||||
HeartRateDiagnosticPhase.IDLE -> R.string.hr_diag_idle
|
|
||||||
HeartRateDiagnosticPhase.SCANNING -> R.string.hr_diag_scanning
|
|
||||||
HeartRateDiagnosticPhase.CONNECTING -> R.string.hr_diag_connecting
|
|
||||||
HeartRateDiagnosticPhase.DISCOVERING -> R.string.hr_diag_discovering
|
|
||||||
HeartRateDiagnosticPhase.SUBSCRIBING -> R.string.hr_diag_subscribing
|
|
||||||
HeartRateDiagnosticPhase.RECEIVING -> R.string.hr_diag_receiving
|
|
||||||
HeartRateDiagnosticPhase.DISCONNECTED -> R.string.hr_diag_disconnected
|
|
||||||
HeartRateDiagnosticPhase.ERROR -> R.string.hr_diag_error
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
TrainlogScreen(strings.getString(R.string.route_heart_rate_diagnostics)) {
|
|
||||||
TrainlogFrame(strings.getString(R.string.hr_diag_status)) {
|
|
||||||
TrainlogInfo(
|
|
||||||
phaseLabel(),
|
|
||||||
when (snapshot.phase) {
|
|
||||||
HeartRateDiagnosticPhase.RECEIVING -> colors.success
|
|
||||||
HeartRateDiagnosticPhase.ERROR -> colors.error
|
|
||||||
HeartRateDiagnosticPhase.CONNECTING,
|
|
||||||
HeartRateDiagnosticPhase.DISCOVERING,
|
|
||||||
HeartRateDiagnosticPhase.SUBSCRIBING,
|
|
||||||
HeartRateDiagnosticPhase.SCANNING -> colors.warning
|
|
||||||
else -> colors.muted
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (permissionDenied) {
|
|
||||||
TrainlogInfo(strings.getString(R.string.hr_diag_permission_denied), colors.error)
|
|
||||||
}
|
|
||||||
snapshot.diagnostic?.let {
|
|
||||||
TrainlogInfo(strings.getString(R.string.hr_diag_technical, it), colors.muted)
|
|
||||||
}
|
|
||||||
TrainlogInfo(
|
|
||||||
strings.getString(
|
|
||||||
R.string.hr_diag_services,
|
|
||||||
if (snapshot.serviceFound) strings.getString(R.string.yes) else strings.getString(R.string.no),
|
|
||||||
if (snapshot.measurementFound) strings.getString(R.string.yes) else strings.getString(R.string.no),
|
|
||||||
if (snapshot.batteryServiceFound) strings.getString(R.string.yes) else strings.getString(R.string.no),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
snapshot.latestMeasurement?.let { measurement ->
|
|
||||||
TrainlogInfo(
|
|
||||||
strings.getString(R.string.hr_diag_bpm, measurement.bpm),
|
|
||||||
colors.success,
|
|
||||||
)
|
|
||||||
TrainlogInfo(
|
|
||||||
strings.getString(
|
|
||||||
R.string.hr_diag_measurement_meta,
|
|
||||||
measurement.rrIntervals1024.size,
|
|
||||||
measurement.sensorContactDetected?.let { if (it) strings.getString(R.string.yes) else strings.getString(R.string.no) } ?: "—",
|
|
||||||
measurement.energyExpended?.toString() ?: "—",
|
|
||||||
),
|
|
||||||
colors.muted,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
TrainlogInfo(
|
|
||||||
strings.getString(R.string.hr_diag_notifications, snapshot.notificationCount),
|
|
||||||
colors.muted,
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
snapshot.phase == HeartRateDiagnosticPhase.RECEIVING &&
|
|
||||||
snapshot.selectedAddress != null
|
|
||||||
) {
|
|
||||||
TrainlogButton(
|
|
||||||
strings.getString(R.string.hr_diag_use_sensor),
|
|
||||||
{
|
|
||||||
val address = checkNotNull(snapshot.selectedAddress)
|
|
||||||
val name =
|
|
||||||
snapshot.devices
|
|
||||||
.firstOrNull { it.address == address }
|
|
||||||
?.name
|
|
||||||
diagnostics.close()
|
|
||||||
HeartRateSensorPreferences(context).select(address, name)
|
|
||||||
HeartRateSensorService.start(context)
|
|
||||||
onSelected()
|
|
||||||
},
|
|
||||||
Modifier.fillMaxWidth(),
|
|
||||||
style = TrainlogButtonStyle.SUCCESS,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TrainlogFrame(strings.getString(R.string.hr_diag_scan_section)) {
|
|
||||||
Row(
|
|
||||||
Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
TrainlogButton(
|
|
||||||
strings.getString(R.string.hr_diag_scan),
|
|
||||||
{ scan(false) },
|
|
||||||
Modifier.weight(1f),
|
|
||||||
enabled = snapshot.phase != HeartRateDiagnosticPhase.SCANNING,
|
|
||||||
)
|
|
||||||
TrainlogButton(
|
|
||||||
strings.getString(R.string.hr_diag_scan_all),
|
|
||||||
{ scan(true) },
|
|
||||||
Modifier.weight(1f),
|
|
||||||
enabled = snapshot.phase != HeartRateDiagnosticPhase.SCANNING,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
TrainlogInfo(strings.getString(R.string.hr_diag_scan_description), colors.muted)
|
|
||||||
}
|
|
||||||
|
|
||||||
TrainlogFrame(
|
|
||||||
strings.getString(R.string.hr_diag_devices),
|
|
||||||
active = snapshot.devices.isNotEmpty(),
|
|
||||||
) {
|
|
||||||
if (snapshot.devices.isEmpty()) {
|
|
||||||
TrainlogInfo(strings.getString(R.string.hr_diag_no_devices), colors.muted)
|
|
||||||
}
|
|
||||||
snapshot.devices.forEach { device ->
|
|
||||||
val title =
|
|
||||||
device.name?.takeIf { it.isNotBlank() }
|
|
||||||
?: strings.getString(R.string.hr_diag_unnamed_device)
|
|
||||||
val advertised =
|
|
||||||
strings.getString(
|
|
||||||
if (device.advertisesHeartRate) {
|
|
||||||
R.string.hr_diag_hrs_advertised
|
|
||||||
} else {
|
|
||||||
R.string.hr_diag_hrs_not_advertised
|
|
||||||
},
|
|
||||||
)
|
|
||||||
TrainlogAction(
|
|
||||||
title,
|
|
||||||
strings.getString(
|
|
||||||
R.string.hr_diag_device_description,
|
|
||||||
device.address,
|
|
||||||
device.rssi,
|
|
||||||
advertised,
|
|
||||||
),
|
|
||||||
{ diagnostics.connect(device.address) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
package com.labfytools.trainlog.ui
|
|
||||||
|
|
||||||
import androidx.compose.animation.core.RepeatMode
|
|
||||||
import androidx.compose.animation.core.animateFloat
|
|
||||||
import androidx.compose.animation.core.infiniteRepeatable
|
|
||||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.scale
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import com.labfytools.trainlog.R
|
|
||||||
import com.labfytools.trainlog.data.HeartRateLivePhase
|
|
||||||
import com.labfytools.trainlog.data.HeartRateLiveState
|
|
||||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun HeartRateIndicator() {
|
|
||||||
var live by remember { mutableStateOf(HeartRateLiveState.current()) }
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
val subscription = HeartRateLiveState.subscribe { live = it }
|
|
||||||
onDispose { subscription.close() }
|
|
||||||
}
|
|
||||||
|
|
||||||
val colors = LocalTrainlogColors.current
|
|
||||||
val strings = localizedContext()
|
|
||||||
val bpm =
|
|
||||||
live.bpm?.takeIf {
|
|
||||||
live.phase == HeartRateLivePhase.CONNECTED
|
|
||||||
}
|
|
||||||
val color =
|
|
||||||
when (live.phase) {
|
|
||||||
HeartRateLivePhase.CONNECTED -> colors.success
|
|
||||||
HeartRateLivePhase.CONNECTING,
|
|
||||||
HeartRateLivePhase.STALE -> colors.warning
|
|
||||||
HeartRateLivePhase.DISCONNECTED -> colors.muted
|
|
||||||
}
|
|
||||||
|
|
||||||
val beatPeriodMs =
|
|
||||||
bpm?.takeIf { it > 0 }
|
|
||||||
?.let { (60_000 / it).coerceIn(300, 2_000) }
|
|
||||||
?: 1_000
|
|
||||||
val transition = rememberInfiniteTransition(label = "heart-rate-pulse")
|
|
||||||
val scale by
|
|
||||||
transition.animateFloat(
|
|
||||||
initialValue = 1f,
|
|
||||||
targetValue = if (bpm != null) 1.14f else 1f,
|
|
||||||
animationSpec =
|
|
||||||
infiniteRepeatable(
|
|
||||||
animation = tween(durationMillis = beatPeriodMs / 2),
|
|
||||||
repeatMode = RepeatMode.Reverse,
|
|
||||||
),
|
|
||||||
label = "heart-rate-scale",
|
|
||||||
)
|
|
||||||
|
|
||||||
val description =
|
|
||||||
when (live.phase) {
|
|
||||||
HeartRateLivePhase.CONNECTED ->
|
|
||||||
strings.getString(R.string.heart_rate_a11y_connected, bpm ?: 0)
|
|
||||||
HeartRateLivePhase.CONNECTING ->
|
|
||||||
strings.getString(R.string.heart_rate_a11y_connecting)
|
|
||||||
HeartRateLivePhase.STALE ->
|
|
||||||
strings.getString(R.string.heart_rate_a11y_reconnecting)
|
|
||||||
HeartRateLivePhase.DISCONNECTED ->
|
|
||||||
strings.getString(R.string.heart_rate_a11y_disconnected)
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.padding(horizontal = 4.dp)
|
|
||||||
.semantics { contentDescription = description },
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(5.dp),
|
|
||||||
) {
|
|
||||||
androidx.compose.material3.Text(
|
|
||||||
"♥",
|
|
||||||
color = color,
|
|
||||||
fontSize = 20.sp,
|
|
||||||
modifier = Modifier.scale(scale),
|
|
||||||
)
|
|
||||||
androidx.compose.material3.Text(
|
|
||||||
bpm?.let { "$it BPM" } ?: "-- BPM",
|
|
||||||
color = color,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -9,7 +9,6 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
|
@ -40,8 +39,6 @@ import com.labfytools.trainlog.data.StartAiSessionDraftResult
|
||||||
import com.labfytools.trainlog.data.TrainlogRepository
|
import com.labfytools.trainlog.data.TrainlogRepository
|
||||||
import com.labfytools.trainlog.data.AndroidBackupService
|
import com.labfytools.trainlog.data.AndroidBackupService
|
||||||
import com.labfytools.trainlog.data.AndroidBackupResult
|
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.data.directStoragePermissionIntent
|
||||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||||
import com.labfytools.trainlog.model.SessionExerciseDraft
|
import com.labfytools.trainlog.model.SessionExerciseDraft
|
||||||
|
|
@ -58,7 +55,6 @@ fun SessionsHub(
|
||||||
onDrafts: () -> Unit,
|
onDrafts: () -> Unit,
|
||||||
onHistory: () -> Unit,
|
onHistory: () -> Unit,
|
||||||
onPrograms: () -> Unit = {},
|
onPrograms: () -> Unit = {},
|
||||||
onCardioCalibration: () -> Unit = {},
|
|
||||||
) {
|
) {
|
||||||
val colors = LocalTrainlogColors.current
|
val colors = LocalTrainlogColors.current
|
||||||
val strings = localizedContext()
|
val strings = localizedContext()
|
||||||
|
|
@ -78,13 +74,6 @@ fun SessionsHub(
|
||||||
Modifier.weight(1f).fillMaxHeight().testTag("sessions-drafts-action"))
|
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,
|
TrainlogButton(strings.getString(R.string.route_completed_sessions), onHistory,
|
||||||
Modifier.fillMaxWidth().testTag("sessions-history-action"))
|
Modifier.fillMaxWidth().testTag("sessions-history-action"))
|
||||||
Spacer(Modifier.height(16.dp).testTag("sessions-programs-spacing"))
|
Spacer(Modifier.height(16.dp).testTag("sessions-programs-spacing"))
|
||||||
|
|
@ -434,29 +423,17 @@ fun EquipmentDetailScreen(repository: TrainlogRepository, equipmentId: String, o
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun EquipmentCreateScreen(
|
fun EquipmentCreateScreen(repository: TrainlogRepository, state: EquipmentScreenState, onCreated: () -> Unit) {
|
||||||
repository: TrainlogRepository,
|
|
||||||
state: EquipmentScreenState,
|
|
||||||
onCreated: (EquipmentCatalogEntry) -> Unit,
|
|
||||||
) {
|
|
||||||
val colors = LocalTrainlogColors.current
|
val colors = LocalTrainlogColors.current
|
||||||
val strings = localizedContext()
|
val strings = localizedContext()
|
||||||
TrainlogScreen(strings.getString(R.string.create_equipment), scrollKey = "equipment-create") {
|
TrainlogScreen(strings.getString(R.string.create_equipment), scrollKey = "equipment-create") {
|
||||||
TrainlogFrame(strings.getString(R.string.personal_equipment)) {
|
TrainlogFrame(strings.getString(R.string.personal_equipment)) {
|
||||||
TrainlogInputField(
|
TrainlogInputField(strings.getString(R.string.name), state.customName, { state.customName = it; state.message = null })
|
||||||
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)) {
|
TrainlogPrimaryAction(strings.getString(R.string.create), strings.getString(R.string.create_machine_description)) {
|
||||||
when (val result = repository.createCustomEquipment(state.customName)) {
|
when (val result = repository.createCustomEquipment(state.customName)) {
|
||||||
is CreateEquipmentResult.Created -> {
|
is CreateEquipmentResult.Created -> {
|
||||||
state.selectedEquipmentId = result.equipment.equipmentId
|
state.selectedEquipmentId = result.equipment.equipmentId
|
||||||
state.customName = ""
|
state.customName = ""; state.message = null; state.revision++; onCreated()
|
||||||
state.message = null
|
|
||||||
state.revision++
|
|
||||||
onCreated(result.equipment)
|
|
||||||
}
|
}
|
||||||
CreateEquipmentResult.Invalid -> state.message = strings.getString(R.string.equipment_name_invalid)
|
CreateEquipmentResult.Invalid -> state.message = strings.getString(R.string.equipment_name_invalid)
|
||||||
CreateEquipmentResult.Conflict -> state.message = strings.getString(R.string.equipment_name_exists)
|
CreateEquipmentResult.Conflict -> state.message = strings.getString(R.string.equipment_name_exists)
|
||||||
|
|
@ -483,22 +460,13 @@ private fun catalogCountsText(context: android.content.Context, imported: Int, r
|
||||||
).joinToString(", ")
|
).joinToString(", ")
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(
|
fun SettingsScreen(repository: TrainlogRepository, inbox: SyncCatalogInbox, onCatalogChanged: () -> Unit) {
|
||||||
repository: TrainlogRepository,
|
|
||||||
inbox: SyncCatalogInbox,
|
|
||||||
onHeartRateDiagnostics: () -> Unit,
|
|
||||||
onCatalogChanged: () -> Unit,
|
|
||||||
) {
|
|
||||||
val colors = LocalTrainlogColors.current
|
val colors = LocalTrainlogColors.current
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val language = LocalLanguagePresentation.current
|
val language = LocalLanguagePresentation.current
|
||||||
val strings = localizedContext()
|
val strings = localizedContext()
|
||||||
var authorized by remember { mutableStateOf(inbox.hasFolderAccess()) }
|
var authorized by remember { mutableStateOf(inbox.hasFolderAccess()) }
|
||||||
var message by remember { mutableStateOf<String?>(null) }
|
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()) {
|
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||||
authorized = inbox.hasFolderAccess()
|
authorized = inbox.hasFolderAccess()
|
||||||
message = strings.getString(if (authorized) R.string.settings_access_granted else R.string.settings_access_required)
|
message = strings.getString(if (authorized) R.string.settings_access_granted else R.string.settings_access_required)
|
||||||
|
|
@ -553,29 +521,6 @@ fun SettingsScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TrainlogFrame(strings.getString(R.string.settings_cardio_section)) {
|
|
||||||
if (selectedHeartRateSensor != null) {
|
|
||||||
TrainlogInfo(
|
|
||||||
strings.getString(R.string.settings_cardio_configured),
|
|
||||||
colors.success,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
TrainlogAction(
|
|
||||||
strings.getString(R.string.settings_cardio_diagnostics),
|
|
||||||
strings.getString(R.string.settings_cardio_diagnostics_description),
|
|
||||||
onHeartRateDiagnostics,
|
|
||||||
)
|
|
||||||
if (selectedHeartRateSensor != null) {
|
|
||||||
TrainlogAction(
|
|
||||||
strings.getString(R.string.settings_cardio_forget),
|
|
||||||
strings.getString(R.string.settings_cardio_forget_description),
|
|
||||||
{
|
|
||||||
HeartRateSensorService.forget(context)
|
|
||||||
heartRateRevision++
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TrainlogFrame(strings.getString(R.string.settings_exchange_folder)) {
|
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)
|
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)) })
|
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,16 +25,10 @@ import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
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.BasicText
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.key
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
|
@ -52,16 +46,14 @@ import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
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.ActiveDraftLoadResult
|
||||||
import com.labfytools.trainlog.data.ActiveDraftMutationResult
|
import com.labfytools.trainlog.data.ActiveDraftMutationResult
|
||||||
|
import com.labfytools.trainlog.data.CreateEquipmentResult
|
||||||
import com.labfytools.trainlog.data.EquipmentLoadSemantics
|
import com.labfytools.trainlog.data.EquipmentLoadSemantics
|
||||||
import com.labfytools.trainlog.data.FinalizeActiveDraftResult
|
import com.labfytools.trainlog.data.FinalizeActiveDraftResult
|
||||||
import com.labfytools.trainlog.data.ManualPercentMaxResult
|
import com.labfytools.trainlog.data.ManualPercentMaxResult
|
||||||
import com.labfytools.trainlog.data.SaveFeedbackResult
|
import com.labfytools.trainlog.data.SaveFeedbackResult
|
||||||
import com.labfytools.trainlog.data.TrainlogRepository
|
import com.labfytools.trainlog.data.TrainlogRepository
|
||||||
import com.labfytools.trainlog.data.TrainlogRepository.SessionExerciseTimingResult
|
|
||||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||||
import com.labfytools.trainlog.model.ExerciseDataFields
|
import com.labfytools.trainlog.model.ExerciseDataFields
|
||||||
import com.labfytools.trainlog.model.ExerciseProfile
|
import com.labfytools.trainlog.model.ExerciseProfile
|
||||||
|
|
@ -77,18 +69,7 @@ import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||||
import com.labfytools.trainlog.ui.theme.TrainlogTypography
|
import com.labfytools.trainlog.ui.theme.TrainlogTypography
|
||||||
import java.text.Normalizer
|
import java.text.Normalizer
|
||||||
import java.util.Locale
|
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
|
@Composable
|
||||||
fun SessionScreen(
|
fun SessionScreen(
|
||||||
|
|
@ -96,10 +77,7 @@ fun SessionScreen(
|
||||||
catalogRevision: Int,
|
catalogRevision: Int,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onCreateExercise: () -> Unit,
|
onCreateExercise: () -> Unit,
|
||||||
onCreateEquipment: (editingEntryId: String?) -> Unit,
|
|
||||||
onSessionSaved: () -> Unit,
|
onSessionSaved: () -> Unit,
|
||||||
restoreEditingEntryId: String? = null,
|
|
||||||
onEditingContextRestored: () -> Unit = {},
|
|
||||||
) {
|
) {
|
||||||
val colors =
|
val colors =
|
||||||
LocalTrainlogColors.current
|
LocalTrainlogColors.current
|
||||||
|
|
@ -119,27 +97,13 @@ fun SessionScreen(
|
||||||
|
|
||||||
var activeDraft by
|
var activeDraft by
|
||||||
remember(catalogRevision) {
|
remember(catalogRevision) {
|
||||||
val loadedDraft = (initialLoad as? ActiveDraftLoadResult.Loaded)?.draft
|
|
||||||
val restoredIndex = restoreEditingEntryId?.let { entryId ->
|
|
||||||
loadedDraft?.exercises?.indexOfFirst { it.entryId == entryId }
|
|
||||||
?.takeIf { it >= 0 }
|
|
||||||
}
|
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
if (restoredIndex == null) loadedDraft else loadedDraft?.let { draft ->
|
(initialLoad as?
|
||||||
draft.copy(
|
ActiveDraftLoadResult.Loaded)
|
||||||
form = draft.form.copy(
|
?.draft
|
||||||
editingExerciseIndex = restoredIndex,
|
|
||||||
editingEntryId = restoreEditingEntryId,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(restoreEditingEntryId) {
|
|
||||||
if (restoreEditingEntryId != null) onEditingContextRestored()
|
|
||||||
}
|
|
||||||
|
|
||||||
var message by
|
var message by
|
||||||
remember(catalogRevision) {
|
remember(catalogRevision) {
|
||||||
mutableStateOf<
|
mutableStateOf<
|
||||||
|
|
@ -169,9 +133,6 @@ fun SessionScreen(
|
||||||
var pendingDestructiveEdit by remember { mutableStateOf<Pair<Int, SessionExerciseDraft>?>(null) }
|
var pendingDestructiveEdit by remember { mutableStateOf<Pair<Int, SessionExerciseDraft>?>(null) }
|
||||||
var feedbackRevision by remember { mutableStateOf(0) }
|
var feedbackRevision by remember { mutableStateOf(0) }
|
||||||
var reorderedDuringGesture by remember { mutableStateOf(false) }
|
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:
|
val persistDraft:
|
||||||
(ActiveSessionDraft, String?) -> Unit =
|
(ActiveSessionDraft, String?) -> Unit =
|
||||||
|
|
@ -211,12 +172,6 @@ fun SessionScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
val currentDraft = activeDraft!!
|
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 =
|
val lastWriteFailed =
|
||||||
message?.startsWith(
|
message?.startsWith(
|
||||||
|
|
@ -269,19 +224,6 @@ fun SessionScreen(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
val cardio = currentDraft.sessionType == SessionType.CARDIO
|
|
||||||
TrainlogButton(
|
|
||||||
label = strings.getString(R.string.session_cardio),
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
style = if (cardio) TrainlogButtonStyle.SUCCESS else TrainlogButtonStyle.SECONDARY,
|
|
||||||
maxLines = 2,
|
|
||||||
onClick = {
|
|
||||||
persistDraft(
|
|
||||||
currentDraft.copy(sessionType = SessionType.CARDIO),
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -305,19 +247,23 @@ fun SessionScreen(
|
||||||
) {
|
) {
|
||||||
TrainlogInfo(
|
TrainlogInfo(
|
||||||
text =
|
text =
|
||||||
strings.getString(
|
strings.getString(R.string.session_type_value,
|
||||||
R.string.session_type_value,
|
if (
|
||||||
when (currentDraft.sessionType) {
|
currentDraft.sessionType ==
|
||||||
SessionType.TRAINING -> strings.getString(R.string.session_training)
|
SessionType.MAX_TEST
|
||||||
SessionType.MAX_TEST -> strings.getString(R.string.session_max_test)
|
) {
|
||||||
SessionType.CARDIO -> strings.getString(R.string.session_cardio)
|
strings.getString(R.string.session_max_test)
|
||||||
},
|
} else {
|
||||||
),
|
strings.getString(R.string.session_training)
|
||||||
|
}),
|
||||||
color =
|
color =
|
||||||
when (currentDraft.sessionType) {
|
if (
|
||||||
SessionType.MAX_TEST -> colors.warning
|
currentDraft.sessionType ==
|
||||||
SessionType.CARDIO -> colors.success
|
SessionType.MAX_TEST
|
||||||
SessionType.TRAINING -> colors.accent
|
) {
|
||||||
|
colors.warning
|
||||||
|
} else {
|
||||||
|
colors.accent
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -392,110 +338,18 @@ 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(
|
TrainlogAction(
|
||||||
label = strings.getString(R.string.modify_name, draft.exercise.name),
|
label = strings.getString(R.string.modify_name, draft.exercise.name),
|
||||||
description = strings.getString(R.string.edit_exercise_description),
|
description = strings.getString(R.string.edit_exercise_description),
|
||||||
accent = colors.accent,
|
accent = colors.accent,
|
||||||
modifier = Modifier.testTag("edit-draft-exercise-${draft.entryId}"),
|
|
||||||
onClick = {
|
onClick = {
|
||||||
persistDraft(
|
persistDraft(
|
||||||
currentDraft.copy(
|
currentDraft.copy(
|
||||||
form = formForExistingExercise(draft, index),
|
form = formForExistingExercise(draft, index),
|
||||||
),
|
),
|
||||||
strings.getString(R.string.editor_opened, draft.exercise.name),
|
null,
|
||||||
)
|
)
|
||||||
|
message = strings.getString(R.string.editor_opened, draft.exercise.name)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if (currentDraft.form.editingEntryId == draft.entryId) {
|
if (currentDraft.form.editingEntryId == draft.entryId) {
|
||||||
|
|
@ -579,11 +433,8 @@ fun SessionScreen(
|
||||||
sessionType = currentDraft.sessionType,
|
sessionType = currentDraft.sessionType,
|
||||||
initialForm =
|
initialForm =
|
||||||
currentDraft.form,
|
currentDraft.form,
|
||||||
initialOccurrence = currentDraft.form.editingExerciseIndex?.let {
|
initialPlan = currentDraft.form.editingExerciseIndex?.let {
|
||||||
currentDraft.exercises.getOrNull(it)
|
currentDraft.exercises.getOrNull(it)?.plan
|
||||||
},
|
|
||||||
onCreateEquipment = {
|
|
||||||
onCreateEquipment(currentDraft.form.editingEntryId)
|
|
||||||
},
|
},
|
||||||
onFormChanged = {
|
onFormChanged = {
|
||||||
form ->
|
form ->
|
||||||
|
|
@ -645,32 +496,22 @@ fun SessionScreen(
|
||||||
Modifier.weight(1f).fillMaxHeight().testTag("active-session-create-exercise"),
|
Modifier.weight(1f).fillMaxHeight().testTag("active-session-create-exercise"),
|
||||||
containerColor = colors.surfaceAlt, maxLines = 2)
|
containerColor = colors.surfaceAlt, maxLines = 2)
|
||||||
TrainlogButton(strings.getString(R.string.session_save), onClick = {
|
TrainlogButton(strings.getString(R.string.session_save), onClick = {
|
||||||
val activeEntryId = repository.activeSessionTimelineContext()?.activeEntryId
|
when (
|
||||||
if (activeEntryId != null) {
|
val result =
|
||||||
pendingFinalizeActiveEntryId = activeEntryId
|
repository.finalizeActiveSessionDraft()
|
||||||
} else {
|
) {
|
||||||
when (
|
is FinalizeActiveDraftResult.Saved -> {
|
||||||
val result =
|
onSessionSaved()
|
||||||
repository.finalizeActiveSessionDraft(
|
onBack()
|
||||||
recoveryEndedAt(currentDraft),
|
}
|
||||||
)
|
|
||||||
) {
|
|
||||||
is FinalizeActiveDraftResult.Saved -> {
|
|
||||||
onSessionSaved()
|
|
||||||
onBack()
|
|
||||||
}
|
|
||||||
|
|
||||||
is FinalizeActiveDraftResult.Invalid -> {
|
is FinalizeActiveDraftResult.Invalid -> {
|
||||||
message = localizedRepositoryMessage(strings, result.message)
|
message = localizedRepositoryMessage(strings, result.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
is FinalizeActiveDraftResult.DatabaseError -> {
|
is FinalizeActiveDraftResult.DatabaseError -> {
|
||||||
message =
|
message =
|
||||||
strings.getString(
|
strings.getString(R.string.finalize_failed, localizedRepositoryMessage(strings, result.message))
|
||||||
R.string.finalize_failed,
|
|
||||||
localizedRepositoryMessage(strings, result.message),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, modifier = Modifier.weight(1f).fillMaxHeight().testTag("active-session-save"),
|
}, modifier = Modifier.weight(1f).fillMaxHeight().testTag("active-session-save"),
|
||||||
|
|
@ -726,19 +567,7 @@ fun SessionScreen(
|
||||||
strings.getString(R.string.exercise_added) ||
|
strings.getString(R.string.exercise_added) ||
|
||||||
message ==
|
message ==
|
||||||
strings.getString(R.string.exercise_removed) ||
|
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
|
colors.success
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -789,142 +618,6 @@ fun SessionScreen(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pendingExerciseSwitch?.let { (activeEntryId, targetEntryId) ->
|
|
||||||
val activeName =
|
|
||||||
currentDraft.exercises.firstOrNull { it.entryId == activeEntryId }
|
|
||||||
?.exercise?.name
|
|
||||||
?: strings.getString(R.string.exercise_title)
|
|
||||||
val targetName =
|
|
||||||
currentDraft.exercises.firstOrNull { it.entryId == targetEntryId }
|
|
||||||
?.exercise?.name
|
|
||||||
?: strings.getString(R.string.exercise_title)
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { pendingExerciseSwitch = null },
|
|
||||||
title = { Text(strings.getString(R.string.exercise_timeline_switch_title)) },
|
|
||||||
text = {
|
|
||||||
Text(
|
|
||||||
strings.getString(
|
|
||||||
R.string.exercise_timeline_switch_detail,
|
|
||||||
activeName,
|
|
||||||
targetName,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
when (
|
|
||||||
val finished =
|
|
||||||
repository.finishActiveSessionExercise(activeEntryId)
|
|
||||||
) {
|
|
||||||
is SessionExerciseTimingResult.Finished -> {
|
|
||||||
when (
|
|
||||||
val started =
|
|
||||||
repository.startActiveSessionExercise(targetEntryId)
|
|
||||||
) {
|
|
||||||
is SessionExerciseTimingResult.Started -> {
|
|
||||||
timelineRevision++
|
|
||||||
message =
|
|
||||||
strings.getString(
|
|
||||||
R.string.exercise_timeline_started,
|
|
||||||
targetName,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
is SessionExerciseTimingResult.Invalid ->
|
|
||||||
message = started.message
|
|
||||||
is SessionExerciseTimingResult.DatabaseError ->
|
|
||||||
message = started.message
|
|
||||||
is SessionExerciseTimingResult.ActiveConflict,
|
|
||||||
is SessionExerciseTimingResult.Finished -> Unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is SessionExerciseTimingResult.Invalid ->
|
|
||||||
message = finished.message
|
|
||||||
is SessionExerciseTimingResult.DatabaseError ->
|
|
||||||
message = finished.message
|
|
||||||
is SessionExerciseTimingResult.Started,
|
|
||||||
is SessionExerciseTimingResult.ActiveConflict -> Unit
|
|
||||||
}
|
|
||||||
pendingExerciseSwitch = null
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Text(strings.getString(R.string.exercise_timeline_switch_confirm))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { pendingExerciseSwitch = null }) {
|
|
||||||
Text(strings.getString(R.string.dialog_cancel))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pendingFinalizeActiveEntryId?.let { activeEntryId ->
|
|
||||||
val activeName =
|
|
||||||
currentDraft.exercises.firstOrNull { it.entryId == activeEntryId }
|
|
||||||
?.exercise?.name
|
|
||||||
?: strings.getString(R.string.exercise_title)
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { pendingFinalizeActiveEntryId = null },
|
|
||||||
title = { Text(strings.getString(R.string.exercise_timeline_finalize_title)) },
|
|
||||||
text = {
|
|
||||||
Text(
|
|
||||||
strings.getString(
|
|
||||||
R.string.exercise_timeline_finalize_detail,
|
|
||||||
activeName,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
when (
|
|
||||||
val finished =
|
|
||||||
repository.finishActiveSessionExercise(activeEntryId)
|
|
||||||
) {
|
|
||||||
is SessionExerciseTimingResult.Finished -> {
|
|
||||||
timelineRevision++
|
|
||||||
when (
|
|
||||||
val result = repository.finalizeActiveSessionDraft(
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1167,8 +860,7 @@ private fun SessionExerciseForm(
|
||||||
exercise: ExerciseProfile,
|
exercise: ExerciseProfile,
|
||||||
sessionType: SessionType,
|
sessionType: SessionType,
|
||||||
initialForm: SessionDraftForm,
|
initialForm: SessionDraftForm,
|
||||||
initialOccurrence: SessionExerciseDraft?,
|
initialPlan: SessionExercisePlan?,
|
||||||
onCreateEquipment: () -> Unit,
|
|
||||||
onFormChanged: (SessionDraftForm) -> Unit,
|
onFormChanged: (SessionDraftForm) -> Unit,
|
||||||
onCancel: () -> Unit,
|
onCancel: () -> Unit,
|
||||||
onAdd:
|
onAdd:
|
||||||
|
|
@ -1178,7 +870,6 @@ private fun SessionExerciseForm(
|
||||||
val colors =
|
val colors =
|
||||||
LocalTrainlogColors.current
|
LocalTrainlogColors.current
|
||||||
val strings = localizedContext()
|
val strings = localizedContext()
|
||||||
val initialPlan = initialOccurrence?.plan
|
|
||||||
|
|
||||||
var setCountText by
|
var setCountText by
|
||||||
remember(key) {
|
remember(key) {
|
||||||
|
|
@ -1244,11 +935,13 @@ private fun SessionExerciseForm(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val equipmentEntries = remember(key) { repository.listEquipment() }
|
var equipmentRevision by remember(key) { mutableStateOf(0) }
|
||||||
|
val equipmentEntries = remember(equipmentRevision) { repository.listEquipment() }
|
||||||
val fixedEquipmentId = remember(exercise.exerciseId) {
|
val fixedEquipmentId = remember(exercise.exerciseId) {
|
||||||
repository.machineExerciseLegacyEquipmentId(exercise.exerciseId)
|
repository.machineExerciseLegacyEquipmentId(exercise.exerciseId)
|
||||||
}
|
}
|
||||||
var equipmentSearch by remember(key) { mutableStateOf("") }
|
var equipmentSearch by remember(key) { mutableStateOf("") }
|
||||||
|
var customEquipmentName by remember(key) { mutableStateOf("") }
|
||||||
var selectedEquipmentId by remember(key) {
|
var selectedEquipmentId by remember(key) {
|
||||||
mutableStateOf(initialForm.selectedEquipmentId ?: fixedEquipmentId)
|
mutableStateOf(initialForm.selectedEquipmentId ?: fixedEquipmentId)
|
||||||
}
|
}
|
||||||
|
|
@ -1285,18 +978,34 @@ private fun SessionExerciseForm(
|
||||||
value = equipmentSearch,
|
value = equipmentSearch,
|
||||||
onValueChange = { equipmentSearch = it },
|
onValueChange = { equipmentSearch = it },
|
||||||
)
|
)
|
||||||
|
TrainlogInputField(
|
||||||
|
label = strings.getString(R.string.new_historical_context),
|
||||||
|
value = customEquipmentName,
|
||||||
|
onValueChange = { customEquipmentName = it },
|
||||||
|
)
|
||||||
TrainlogAction(
|
TrainlogAction(
|
||||||
label = strings.getString(R.string.create_equipment),
|
label = strings.getString(R.string.create_context),
|
||||||
description = strings.getString(R.string.create_equipment_from_session),
|
description = strings.getString(R.string.context_compatibility),
|
||||||
accent = colors.success,
|
accent = colors.success,
|
||||||
modifier = Modifier.testTag("session-create-equipment"),
|
onClick = {
|
||||||
onClick = onCreateEquipment,
|
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))
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val selectedEquipment = equipmentEntries.firstOrNull { it.equipmentId == selectedEquipmentId }
|
val selectedEquipment = equipmentEntries.firstOrNull { it.equipmentId == selectedEquipmentId }
|
||||||
val percentResult = remember(
|
val percentResult = remember(
|
||||||
exercise.exerciseId, selectedEquipmentId, targetPercentText,
|
exercise.exerciseId, selectedEquipmentId, targetPercentText,
|
||||||
targetChoice,
|
targetChoice, equipmentRevision,
|
||||||
) {
|
) {
|
||||||
if (targetChoice == ManualTargetChoice.PERCENT_MAX)
|
if (targetChoice == ManualTargetChoice.PERCENT_MAX)
|
||||||
repository.calculateManualPercentMaxTarget(
|
repository.calculateManualPercentMaxTarget(
|
||||||
|
|
@ -1316,29 +1025,16 @@ private fun SessionExerciseForm(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (fixedEquipmentId == null) {
|
if (fixedEquipmentId == null) repository.searchEquipment(equipmentSearch).take(8).forEach { equipment ->
|
||||||
/* WHY: alphabetical truncation made valid equipment beyond the
|
TrainlogAction(
|
||||||
* eighth row impossible to select. CONTRACT: the bounded nested
|
label = if (equipment.equipmentId == selectedEquipmentId) "✓ ${equipment.displayName}" else equipment.displayName,
|
||||||
* viewport exposes every search result while the surrounding
|
description = equipment.labelName.ifBlank { equipment.type },
|
||||||
* exercise editor remains independently scrollable. */
|
accent = if (equipment.equipmentId == selectedEquipmentId) colors.success else colors.muted,
|
||||||
Column(
|
onClick = {
|
||||||
Modifier
|
selectedEquipmentId = equipment.equipmentId
|
||||||
.heightIn(max = 320.dp)
|
onFormChanged(currentForm(exercise, setCountText, repsText, durationText, speedText, distanceText, equipment.equipmentId, weightText, maxWeightText))
|
||||||
.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) {
|
if (sessionType == SessionType.MAX_TEST) {
|
||||||
|
|
@ -1569,7 +1265,6 @@ private fun SessionExerciseForm(
|
||||||
SessionNumberField(
|
SessionNumberField(
|
||||||
label =
|
label =
|
||||||
strings.getString(R.string.duration_minutes),
|
strings.getString(R.string.duration_minutes),
|
||||||
testTag = "session-continuous-duration",
|
|
||||||
value =
|
value =
|
||||||
durationText,
|
durationText,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
|
|
@ -1656,7 +1351,7 @@ private fun SessionExerciseForm(
|
||||||
modifier = Modifier.fillMaxWidth().testTag("add-to-session"),
|
modifier = Modifier.fillMaxWidth().testTag("add-to-session"),
|
||||||
containerColor = colors.success,
|
containerColor = colors.success,
|
||||||
onClick = {
|
onClick = {
|
||||||
val capturedDraft =
|
val draft =
|
||||||
buildSessionExerciseDraft(
|
buildSessionExerciseDraft(
|
||||||
exercise =
|
exercise =
|
||||||
exercise,
|
exercise,
|
||||||
|
|
@ -1678,26 +1373,6 @@ private fun SessionExerciseForm(
|
||||||
rawSetRows = setRows,
|
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) {
|
if (draft == null) {
|
||||||
error =
|
error =
|
||||||
if (sessionType == SessionType.MAX_TEST) {
|
if (sessionType == SessionType.MAX_TEST) {
|
||||||
|
|
@ -1721,9 +1396,7 @@ private fun SessionExerciseForm(
|
||||||
onFailure = { error = localizedTargetError(it.message, strings) },
|
onFailure = { error = localizedTargetError(it.message, strings) },
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
/* CONTRACT: editing performed continuous facts must not
|
onAdd(draft.copy(plan = null))
|
||||||
* erase the independent target copied from preparation. */
|
|
||||||
onAdd(draft.copy(plan = initialPlan))
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -1735,7 +1408,6 @@ private fun SessionExerciseForm(
|
||||||
strings.getString(R.string.return_catalog),
|
strings.getString(R.string.return_catalog),
|
||||||
accent =
|
accent =
|
||||||
colors.muted,
|
colors.muted,
|
||||||
modifier = Modifier.testTag("cancel-session-exercise-editor"),
|
|
||||||
onClick =
|
onClick =
|
||||||
onCancel,
|
onCancel,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,537 +0,0 @@
|
||||||
package com.labfytools.trainlog.ui
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.FilterChip
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.labfytools.trainlog.R
|
|
||||||
import com.labfytools.trainlog.data.TrainlogRepository
|
|
||||||
import com.labfytools.trainlog.model.SleepDiaryDraft
|
|
||||||
import com.labfytools.trainlog.model.SleepDiaryEntry
|
|
||||||
import com.labfytools.trainlog.model.SleepDiaryEvent
|
|
||||||
import com.labfytools.trainlog.model.SleepEventType
|
|
||||||
import com.labfytools.trainlog.model.SleepQuality
|
|
||||||
import com.labfytools.trainlog.model.SleepPublicationStatus
|
|
||||||
import com.labfytools.trainlog.model.MedicationIntake
|
|
||||||
import com.labfytools.trainlog.model.SleepMedication
|
|
||||||
import java.time.LocalDate
|
|
||||||
import java.time.OffsetDateTime
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SleepDiaryScreen(repository: TrainlogRepository) {
|
|
||||||
var correctionMode by remember { mutableStateOf(false) }
|
|
||||||
if (correctionMode) {
|
|
||||||
Column(Modifier.fillMaxSize()) {
|
|
||||||
Button(
|
|
||||||
onClick = { correctionMode = false },
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.sleep_return_night_mode))
|
|
||||||
}
|
|
||||||
SleepDiaryEditorScreen(repository)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
SleepNightCaptureScreen(repository) { correctionMode = true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SleepNightCaptureScreen(
|
|
||||||
repository: TrainlogRepository,
|
|
||||||
onCorrection: () -> Unit,
|
|
||||||
) {
|
|
||||||
var revision by remember { mutableStateOf(0) }
|
|
||||||
var undoReceipt by remember {
|
|
||||||
mutableStateOf<TrainlogRepository.SleepQuickActionReceipt?>(null)
|
|
||||||
}
|
|
||||||
var message by remember { mutableStateOf<String?>(null) }
|
|
||||||
val strings = localizedContext()
|
|
||||||
val entries = remember(revision) { repository.listSleepDiary() }
|
|
||||||
val active =
|
|
||||||
entries.firstOrNull { entry ->
|
|
||||||
entry.events.any { it.type == SleepEventType.BED_TIME } &&
|
|
||||||
entry.events.none { it.type == SleepEventType.FINAL_GET_UP }
|
|
||||||
}
|
|
||||||
val pending =
|
|
||||||
entries.firstOrNull { entry ->
|
|
||||||
entry.events.none { it.type == SleepEventType.BED_TIME } &&
|
|
||||||
entry.events.none { it.type == SleepEventType.FINAL_GET_UP }
|
|
||||||
}
|
|
||||||
val visibleNight = active ?: pending
|
|
||||||
val medications =
|
|
||||||
remember(revision) {
|
|
||||||
repository.listSleepMedications(includeInactive = false)
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(undoReceipt?.appliedRevisionId) {
|
|
||||||
if (undoReceipt != null) {
|
|
||||||
delay(10_000)
|
|
||||||
undoReceipt = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun apply(
|
|
||||||
successMessage: String,
|
|
||||||
result: TrainlogRepository.SleepQuickActionResult,
|
|
||||||
) {
|
|
||||||
when (result) {
|
|
||||||
is TrainlogRepository.SleepQuickActionResult.Applied -> {
|
|
||||||
undoReceipt = result.receipt
|
|
||||||
message = successMessage
|
|
||||||
revision++
|
|
||||||
}
|
|
||||||
TrainlogRepository.SleepQuickActionResult.NoActiveNight ->
|
|
||||||
message = strings.getString(R.string.sleep_quick_no_active)
|
|
||||||
TrainlogRepository.SleepQuickActionResult.AlreadyActive ->
|
|
||||||
message = strings.getString(R.string.sleep_quick_already_active)
|
|
||||||
TrainlogRepository.SleepQuickActionResult.InvalidMedication ->
|
|
||||||
message = strings.getString(R.string.sleep_quick_medication_unavailable)
|
|
||||||
TrainlogRepository.SleepQuickActionResult.Conflict ->
|
|
||||||
message = strings.getString(R.string.sleep_quick_conflict)
|
|
||||||
TrainlogRepository.SleepQuickActionResult.Error ->
|
|
||||||
message = strings.getString(R.string.sleep_quick_error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier.fillMaxSize().padding(12.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
item {
|
|
||||||
Text(stringResource(R.string.sleep_night_capture_title))
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
visibleNight?.let { night ->
|
|
||||||
Text(
|
|
||||||
stringResource(
|
|
||||||
R.string.sleep_night_in_progress,
|
|
||||||
night.nightStartDate,
|
|
||||||
night.nightEndDate,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} ?: Text(stringResource(R.string.sleep_no_night_active))
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
apply(
|
|
||||||
strings.getString(R.string.sleep_bed_recorded),
|
|
||||||
repository.quickSleepBedTime(),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
enabled = active == null,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.sleep_quick_bed))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (medications.isNotEmpty()) {
|
|
||||||
item { Text(stringResource(R.string.sleep_medications)) }
|
|
||||||
items(medications, key = { it.medicationId }) { medication ->
|
|
||||||
var quantity by remember(medication.medicationId) { mutableStateOf(1) }
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
Button(onClick = { if (quantity > 1) quantity-- }) { Text("−") }
|
|
||||||
Text("×$quantity", modifier = Modifier.padding(top = 12.dp))
|
|
||||||
Button(onClick = { if (quantity < 99) quantity++ }) { Text("+") }
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
val selectedQuantity = quantity
|
|
||||||
val result =
|
|
||||||
repository.quickSleepMedication(
|
|
||||||
medication.medicationId,
|
|
||||||
quantity = selectedQuantity,
|
|
||||||
)
|
|
||||||
apply(
|
|
||||||
strings.getString(
|
|
||||||
R.string.sleep_medication_recorded,
|
|
||||||
medication.name +
|
|
||||||
if (selectedQuantity > 1) " ×$selectedQuantity" else "",
|
|
||||||
),
|
|
||||||
result,
|
|
||||||
)
|
|
||||||
if (result is TrainlogRepository.SleepQuickActionResult.Applied) {
|
|
||||||
quantity = 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
) {
|
|
||||||
val dose =
|
|
||||||
medication.defaultDoseValue?.let { doseValue ->
|
|
||||||
" · " + doseValue.toString().trimEnd('0').trimEnd('.') +
|
|
||||||
" " + medication.defaultDoseUnit.orEmpty()
|
|
||||||
}.orEmpty()
|
|
||||||
Text("💊 " + medication.name + dose)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
apply(
|
|
||||||
strings.getString(R.string.sleep_wake_recorded),
|
|
||||||
repository.quickSleepWake(),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
enabled = active != null,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.sleep_quick_wake))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
apply(
|
|
||||||
strings.getString(R.string.sleep_get_up_recorded),
|
|
||||||
repository.quickSleepFinalGetUp(),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
enabled = active != null,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.sleep_quick_get_up))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
message?.let { text ->
|
|
||||||
item {
|
|
||||||
Card {
|
|
||||||
Column(
|
|
||||||
Modifier.fillMaxWidth().padding(12.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
Text(text)
|
|
||||||
undoReceipt?.let { receipt ->
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
when (repository.undoSleepQuickAction(receipt)) {
|
|
||||||
is TrainlogRepository.SleepQuickActionResult.Applied -> {
|
|
||||||
message = strings.getString(R.string.sleep_action_undone)
|
|
||||||
undoReceipt = null
|
|
||||||
revision++
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
message = strings.getString(R.string.sleep_undo_unavailable)
|
|
||||||
undoReceipt = null
|
|
||||||
revision++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.sleep_undo))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
visibleNight?.let { night ->
|
|
||||||
item {
|
|
||||||
Card {
|
|
||||||
Column(
|
|
||||||
Modifier.fillMaxWidth().padding(12.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(5.dp),
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.sleep_night_timeline))
|
|
||||||
night.events.sortedBy { it.startAt }.forEach { event ->
|
|
||||||
Text(eventLabelPlain(event.type, strings) + " · " + shortTime(event.startAt))
|
|
||||||
}
|
|
||||||
night.intakes.sortedBy { it.takenAt }.forEach { intake ->
|
|
||||||
Text(
|
|
||||||
"💊 " + intake.medicationName +
|
|
||||||
(if (intake.quantity > 1) " ×${intake.quantity}" else "") +
|
|
||||||
" · " + shortTime(intake.takenAt),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
Button(
|
|
||||||
onClick = onCorrection,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.sleep_correct_night))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun shortTime(value: String): String =
|
|
||||||
runCatching { OffsetDateTime.parse(value).toLocalTime().withSecond(0).withNano(0).toString() }
|
|
||||||
.getOrDefault(value)
|
|
||||||
|
|
||||||
private fun eventLabelPlain(type: SleepEventType, context: android.content.Context): String =
|
|
||||||
context.getString(
|
|
||||||
when (type) {
|
|
||||||
SleepEventType.BED_TIME -> R.string.sleep_bed_time
|
|
||||||
SleepEventType.FINAL_GET_UP -> R.string.sleep_final_get_up
|
|
||||||
SleepEventType.NIGHT_GET_UP -> R.string.sleep_night_get_up
|
|
||||||
SleepEventType.SLEEP -> R.string.sleep_sleep
|
|
||||||
SleepEventType.NAP -> R.string.sleep_nap
|
|
||||||
SleepEventType.LONG_AWAKE -> R.string.sleep_long_awake
|
|
||||||
SleepEventType.HALF_SLEEP -> R.string.sleep_half_sleep
|
|
||||||
SleepEventType.DAYTIME_SLEEPINESS -> R.string.sleep_sleepiness
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SleepDiaryEditorScreen(repository: TrainlogRepository) {
|
|
||||||
var revision by remember { mutableStateOf(0) }
|
|
||||||
var selected by remember(revision) { mutableStateOf<SleepDiaryEntry?>(repository.listSleepDiary().firstOrNull()) }
|
|
||||||
val today = LocalDate.now()
|
|
||||||
var startDate by remember(selected) { mutableStateOf(selected?.nightStartDate ?: today.minusDays(1).toString()) }
|
|
||||||
var endDate by remember(selected) { mutableStateOf(selected?.nightEndDate ?: today.toString()) }
|
|
||||||
var sleepQuality by remember(selected) { mutableStateOf(selected?.sleepQuality) }
|
|
||||||
var wakeQuality by remember(selected) { mutableStateOf(selected?.wakeQuality) }
|
|
||||||
var dayForm by remember(selected) { mutableStateOf(selected?.dayForm) }
|
|
||||||
var notes by remember(selected) { mutableStateOf(selected?.treatmentAndNotes.orEmpty()) }
|
|
||||||
var events by remember(selected) { mutableStateOf(selected?.events.orEmpty()) }
|
|
||||||
var intakes by remember(selected) { mutableStateOf(selected?.intakes.orEmpty()) }
|
|
||||||
val catalog = remember(revision) { repository.listSleepMedications() }
|
|
||||||
val medications = catalog.filter { it.active }
|
|
||||||
var intakeMedication by remember(medications) { mutableStateOf(medications.firstOrNull()) }
|
|
||||||
var intakeDose by remember(intakeMedication) { mutableStateOf(intakeMedication?.defaultDoseValue?.toString().orEmpty()) }
|
|
||||||
var intakeUnit by remember(intakeMedication) { mutableStateOf(intakeMedication?.defaultDoseUnit.orEmpty()) }
|
|
||||||
var medicationName by remember { mutableStateOf("") }
|
|
||||||
var medicationDose by remember { mutableStateOf("") }
|
|
||||||
var medicationUnit by remember { mutableStateOf("mg") }
|
|
||||||
var editingMedication by remember { mutableStateOf<SleepMedication?>(null) }
|
|
||||||
var editSerial by remember { mutableStateOf(0) }
|
|
||||||
val entries = repository.listSleepDiary()
|
|
||||||
fun changed() { editSerial++ }
|
|
||||||
|
|
||||||
LaunchedEffect(editSerial) {
|
|
||||||
if (editSerial > 0) {
|
|
||||||
val now = OffsetDateTime.now().toString()
|
|
||||||
val current = selected
|
|
||||||
val result = repository.saveSleepDiary(SleepDiaryDraft(current?.entryId,
|
|
||||||
current?.revisionId,startDate,endDate,current?.createdAt ?: now,now,sleepQuality,
|
|
||||||
wakeQuality,dayForm,notes,events,intakes))
|
|
||||||
if (result is TrainlogRepository.SaveSleepDiaryResult.Saved) {
|
|
||||||
selected = repository.listSleepDiary().firstOrNull { it.entryId == result.entryId }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier.fillMaxSize().padding(12.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
|
||||||
) {
|
|
||||||
item { Text(stringResource(R.string.sleep_current_night)) }
|
|
||||||
items(entries, key = { it.entryId }) { entry ->
|
|
||||||
Card(onClick = { selected = entry }) {
|
|
||||||
Column(Modifier.fillMaxWidth().padding(12.dp)) {
|
|
||||||
Text("${entry.nightStartDate} → ${entry.nightEndDate}")
|
|
||||||
Text(stringResource(when (entry.publicationStatus) {
|
|
||||||
SleepPublicationStatus.DRAFT -> R.string.sleep_status_draft
|
|
||||||
SleepPublicationStatus.READY -> R.string.sleep_status_ready
|
|
||||||
SleepPublicationStatus.SYNCHRONIZED -> R.string.sleep_status_synchronized
|
|
||||||
SleepPublicationStatus.MODIFIED -> R.string.sleep_status_modified
|
|
||||||
}))
|
|
||||||
Text("${entry.events.size} · ${entry.sleepQuality?.wireValue ?: "—"} / ${entry.wakeQuality?.wireValue ?: "—"}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
Card {
|
|
||||||
Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
Text(stringResource(R.string.sleep_morning))
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
OutlinedTextField(startDate, { startDate = it; changed() }, label = { Text("YYYY-MM-DD") }, modifier = Modifier.weight(1f))
|
|
||||||
OutlinedTextField(endDate, { endDate = it; changed() }, label = { Text("YYYY-MM-DD") }, modifier = Modifier.weight(1f))
|
|
||||||
}
|
|
||||||
QualityRow(stringResource(R.string.sleep_quality), sleepQuality) { sleepQuality = it; changed() }
|
|
||||||
QualityRow(stringResource(R.string.sleep_wake_quality), wakeQuality) { wakeQuality = it; changed() }
|
|
||||||
Text(stringResource(R.string.sleep_day))
|
|
||||||
QualityRow(stringResource(R.string.sleep_day_form), dayForm) { dayForm = it; changed() }
|
|
||||||
SleepEventType.entries.forEach { type ->
|
|
||||||
Button(onClick = {
|
|
||||||
val start = OffsetDateTime.now()
|
|
||||||
events = events + SleepDiaryEvent("sle_${UUID.randomUUID()}", type, start.toString(),
|
|
||||||
if (type in pointTypes) null else start.plusHours(1).toString())
|
|
||||||
changed()
|
|
||||||
}) { Text(eventLabel(type)) }
|
|
||||||
}
|
|
||||||
events.forEachIndexed { index, event ->
|
|
||||||
Card {
|
|
||||||
Column(Modifier.padding(8.dp)) {
|
|
||||||
Text(eventLabel(event.type))
|
|
||||||
TimeButton(event.startAt) { value ->
|
|
||||||
events = events.mapIndexed { item, current ->
|
|
||||||
if (item == index) current.copy(startAt = value) else current
|
|
||||||
}
|
|
||||||
changed()
|
|
||||||
}
|
|
||||||
OutlinedTextField(event.startAt, { value -> events = events.mapIndexed { item, current -> if (item == index) current.copy(startAt = value) else current }; changed() }, label = { Text("ISO-8601") })
|
|
||||||
event.endAt?.let { end ->
|
|
||||||
TimeButton(end) { value ->
|
|
||||||
events = events.mapIndexed { item, current ->
|
|
||||||
if (item == index) current.copy(endAt = value) else current
|
|
||||||
}
|
|
||||||
changed()
|
|
||||||
}
|
|
||||||
OutlinedTextField(end, { value -> events = events.mapIndexed { item, current -> if (item == index) current.copy(endAt = value) else current }; changed() }, label = { Text("ISO-8601 →") })
|
|
||||||
}
|
|
||||||
Button(onClick = { events = events.filterIndexed { item, _ -> item != index }; changed() }) { Text(stringResource(R.string.sleep_delete)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text(stringResource(R.string.sleep_medications))
|
|
||||||
OutlinedTextField(medicationName, { medicationName = it },
|
|
||||||
label = { Text(stringResource(R.string.sleep_medications)) }, modifier = Modifier.fillMaxWidth())
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
OutlinedTextField(medicationDose, { medicationDose = it },
|
|
||||||
label = { Text(stringResource(R.string.sleep_dose)) }, modifier = Modifier.weight(1f))
|
|
||||||
OutlinedTextField(medicationUnit, { medicationUnit = it },
|
|
||||||
label = { Text(stringResource(R.string.sleep_unit)) }, modifier = Modifier.weight(1f))
|
|
||||||
}
|
|
||||||
Button(onClick = {
|
|
||||||
val now = OffsetDateTime.now().toString()
|
|
||||||
val dose = medicationDose.toDoubleOrNull()
|
|
||||||
if (medicationName.isNotBlank() && (medicationDose.isEmpty() || dose != null && dose > 0.0)) {
|
|
||||||
val current = editingMedication
|
|
||||||
repository.saveSleepMedication(SleepMedication(current?.medicationId.orEmpty(),
|
|
||||||
current?.revisionId.orEmpty(), current?.createdAt ?: now, now,
|
|
||||||
medicationName.trim(), dose, if (dose == null) null else medicationUnit,
|
|
||||||
current?.form.orEmpty(), current?.note.orEmpty(), current?.active ?: true))
|
|
||||||
medicationName = ""; editingMedication = null; revision++
|
|
||||||
}
|
|
||||||
}) { Text(stringResource(R.string.sleep_add_medication)) }
|
|
||||||
medications.forEach { medication ->
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
FilterChip(selected = intakeMedication?.medicationId == medication.medicationId,
|
|
||||||
onClick = { intakeMedication = medication
|
|
||||||
intakeDose = medication.defaultDoseValue?.toString().orEmpty()
|
|
||||||
intakeUnit = medication.defaultDoseUnit.orEmpty() },
|
|
||||||
label = { Text(medication.name) })
|
|
||||||
Button(onClick = { editingMedication = medication; medicationName = medication.name
|
|
||||||
medicationDose = medication.defaultDoseValue?.toString().orEmpty()
|
|
||||||
medicationUnit = medication.defaultDoseUnit.orEmpty() }) {
|
|
||||||
Text(stringResource(R.string.sleep_edit))
|
|
||||||
}
|
|
||||||
Button(onClick = { repository.saveSleepMedication(medication.copy(
|
|
||||||
updatedAt = OffsetDateTime.now().toString(), active = false)); revision++ }) {
|
|
||||||
Text(stringResource(R.string.sleep_deactivate))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
OutlinedTextField(intakeDose, { intakeDose = it },
|
|
||||||
label = { Text(stringResource(R.string.sleep_dose)) }, modifier = Modifier.weight(1f))
|
|
||||||
OutlinedTextField(intakeUnit, { intakeUnit = it },
|
|
||||||
label = { Text(stringResource(R.string.sleep_unit)) }, modifier = Modifier.weight(1f))
|
|
||||||
}
|
|
||||||
Button(onClick = {
|
|
||||||
intakeMedication?.let { medication ->
|
|
||||||
val dose = intakeDose.toDoubleOrNull()
|
|
||||||
if (intakeDose.isEmpty() || dose != null && dose > 0.0) {
|
|
||||||
val now = OffsetDateTime.now().toString()
|
|
||||||
intakes = intakes + MedicationIntake("mdi_${UUID.randomUUID()}", medication.medicationId,
|
|
||||||
medication.name, now, dose, if (dose == null) null else intakeUnit,
|
|
||||||
"", now)
|
|
||||||
changed()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}) { Text(stringResource(R.string.sleep_add_intake)) }
|
|
||||||
intakes.forEachIndexed { index, intake -> Card {
|
|
||||||
Column(Modifier.fillMaxWidth().padding(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
|
||||||
Text(intake.medicationName)
|
|
||||||
TimeButton(intake.takenAt) { value -> intakes = intakes.mapIndexed { item, current ->
|
|
||||||
if (item == index) current.copy(takenAt = value) else current }; changed() }
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
OutlinedTextField(intake.doseValue?.toString().orEmpty(), { value ->
|
|
||||||
intakes = intakes.mapIndexed { item, current -> if (item == index) current.copy(
|
|
||||||
doseValue = value.toDoubleOrNull(), doseUnit = if (value.isEmpty()) null else current.doseUnit ?: "mg") else current }
|
|
||||||
changed()
|
|
||||||
}, label = { Text(stringResource(R.string.sleep_dose)) }, modifier = Modifier.weight(1f))
|
|
||||||
OutlinedTextField(intake.doseUnit.orEmpty(), { value -> intakes = intakes.mapIndexed { item, current ->
|
|
||||||
if (item == index) current.copy(doseUnit = value) else current }
|
|
||||||
changed()
|
|
||||||
}, label = { Text(stringResource(R.string.sleep_unit)) }, modifier = Modifier.weight(1f))
|
|
||||||
}
|
|
||||||
Button(onClick = { intakes = intakes.filterIndexed { item, _ -> item != index }; changed() }) {
|
|
||||||
Text(stringResource(R.string.sleep_delete))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} }
|
|
||||||
OutlinedTextField(notes, { notes = it; changed() }, label = { Text(stringResource(R.string.sleep_notes)) }, modifier = Modifier.fillMaxWidth(), minLines = 3)
|
|
||||||
Button(onClick = {
|
|
||||||
val now = OffsetDateTime.now().toString()
|
|
||||||
val current = selected
|
|
||||||
val result = repository.saveSleepDiary(SleepDiaryDraft(current?.entryId,current?.revisionId,startDate,endDate,current?.createdAt ?: now,now,sleepQuality,wakeQuality,dayForm,notes,events,intakes))
|
|
||||||
if (result is TrainlogRepository.SaveSleepDiaryResult.Saved) { revision++; selected = repository.listSleepDiary().firstOrNull { it.entryId == result.entryId } }
|
|
||||||
}) { Text(stringResource(R.string.sleep_save)) }
|
|
||||||
selected?.let { entry -> Button(onClick = {
|
|
||||||
repository.validateSleepDiary(entry.entryId, entry.revisionId,
|
|
||||||
OffsetDateTime.now().toString())
|
|
||||||
selected = repository.listSleepDiary().firstOrNull { it.entryId == entry.entryId }
|
|
||||||
}) { Text(stringResource(R.string.sleep_validate)) } }
|
|
||||||
selected?.let { entry -> Button(onClick = { repository.deleteSleepDiary(entry.entryId,entry.revisionId,OffsetDateTime.now().toString()); selected = null; revision++ }) { Text(stringResource(R.string.sleep_delete)) } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TimeButton(value: String, change: (String) -> Unit) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val parsed = runCatching { OffsetDateTime.parse(value) }.getOrNull()
|
|
||||||
Button(onClick = {
|
|
||||||
val current = parsed ?: OffsetDateTime.now()
|
|
||||||
android.app.TimePickerDialog(context, { _, hour, minute ->
|
|
||||||
change(current.withHour(hour).withMinute(minute).withSecond(0).withNano(0).toString())
|
|
||||||
}, current.hour, current.minute, true).show()
|
|
||||||
}) {
|
|
||||||
Text(stringResource(R.string.sleep_choose_time) + " " + (parsed?.toLocalTime()?.toString() ?: "—"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val pointTypes = setOf(SleepEventType.BED_TIME, SleepEventType.FINAL_GET_UP,
|
|
||||||
SleepEventType.NIGHT_GET_UP, SleepEventType.DAYTIME_SLEEPINESS)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun QualityRow(label: String, selected: SleepQuality?, change: (SleepQuality?) -> Unit) {
|
|
||||||
Column { Text(label); Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
|
||||||
SleepQuality.entries.forEach { value -> FilterChip(selected == value, { change(if (selected == value) null else value) }, { Text(value.wireValue) }) }
|
|
||||||
} }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun eventLabel(type: SleepEventType): String = stringResource(when (type) {
|
|
||||||
SleepEventType.BED_TIME -> R.string.sleep_bed_time
|
|
||||||
SleepEventType.FINAL_GET_UP -> R.string.sleep_final_get_up
|
|
||||||
SleepEventType.NIGHT_GET_UP -> R.string.sleep_night_get_up
|
|
||||||
SleepEventType.SLEEP -> R.string.sleep_sleep
|
|
||||||
SleepEventType.NAP -> R.string.sleep_nap
|
|
||||||
SleepEventType.LONG_AWAKE -> R.string.sleep_long_awake
|
|
||||||
SleepEventType.HALF_SLEEP -> R.string.sleep_half_sleep
|
|
||||||
SleepEventType.DAYTIME_SLEEPINESS -> R.string.sleep_sleepiness
|
|
||||||
})
|
|
||||||
|
|
@ -35,10 +35,7 @@ import com.labfytools.trainlog.data.SyncConversationIntent
|
||||||
import com.labfytools.trainlog.data.SyncGenerationForegroundCoordinator
|
import com.labfytools.trainlog.data.SyncGenerationForegroundCoordinator
|
||||||
import com.labfytools.trainlog.data.BackgroundSyncSettings
|
import com.labfytools.trainlog.data.BackgroundSyncSettings
|
||||||
import com.labfytools.trainlog.data.SyncBackgroundService
|
import com.labfytools.trainlog.data.SyncBackgroundService
|
||||||
import com.labfytools.trainlog.data.AiExportDriveSettings
|
import com.labfytools.trainlog.data.DriveSyncSettings
|
||||||
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.canonicalExchangeDirectory
|
||||||
import com.labfytools.trainlog.data.AndroidMtpPublicationVisibility
|
import com.labfytools.trainlog.data.AndroidMtpPublicationVisibility
|
||||||
import com.labfytools.trainlog.data.MtpPublicationVisibility
|
import com.labfytools.trainlog.data.MtpPublicationVisibility
|
||||||
|
|
@ -243,20 +240,8 @@ fun SyncScreen(
|
||||||
var syncRunning by remember { mutableStateOf(false) }
|
var syncRunning by remember { mutableStateOf(false) }
|
||||||
val backgroundSettings = remember { BackgroundSyncSettings(context) }
|
val backgroundSettings = remember { BackgroundSyncSettings(context) }
|
||||||
var backgroundEnabled by remember { mutableStateOf(backgroundSettings.enabled) }
|
var backgroundEnabled by remember { mutableStateOf(backgroundSettings.enabled) }
|
||||||
val driveSettings = remember { AiExportDriveSettings(context) }
|
val driveSettings = remember { DriveSyncSettings(context) }
|
||||||
var driveConnected by remember { mutableStateOf(driveSettings.connected) }
|
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(
|
val driveFolderLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.OpenDocumentTree(),
|
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||||
|
|
@ -271,23 +256,6 @@ 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(
|
val notificationPermissionLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.RequestPermission(),
|
contract = ActivityResultContracts.RequestPermission(),
|
||||||
) { granted ->
|
) { granted ->
|
||||||
|
|
@ -605,69 +573,6 @@ 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)) {
|
TrainlogFrame(title = strings.getString(R.string.background_sync_title)) {
|
||||||
TrainlogInfo(
|
TrainlogInfo(
|
||||||
text = strings.getString(R.string.background_sync_description),
|
text = strings.getString(R.string.background_sync_description),
|
||||||
|
|
|
||||||
|
|
@ -103,31 +103,9 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
||||||
},
|
},
|
||||||
{ open(AppRoute.BodyMeasurements) }, { open(AppRoute.Sync) },
|
{ open(AppRoute.BodyMeasurements) }, { open(AppRoute.Sync) },
|
||||||
)
|
)
|
||||||
AppRoute.Sessions -> SessionsHub(
|
AppRoute.Sessions -> SessionsHub(activeDraft, pendingAiDraftCount, { open(AppRoute.SessionEditor) }, ::openManualSession,
|
||||||
activeDraft,
|
{ open(AppRoute.AiSessionDrafts) }, { open(AppRoute.CompletedSessions) }, { open(AppRoute.Programs) })
|
||||||
pendingAiDraftCount,
|
AppRoute.SessionEditor -> SessionScreen(repository, catalogRevision, { draftRevision++; back() }, { open(AppRoute.ExerciseCreate(AppRoute.SessionEditor)) }, { exportSnapshot(); draftRevision++ })
|
||||||
{ open(AppRoute.SessionEditor) },
|
|
||||||
::openManualSession,
|
|
||||||
{ open(AppRoute.AiSessionDrafts) },
|
|
||||||
{ open(AppRoute.CompletedSessions) },
|
|
||||||
{ open(AppRoute.Programs) },
|
|
||||||
{ open(AppRoute.CardioCalibration) },
|
|
||||||
)
|
|
||||||
AppRoute.SessionEditor -> SessionScreen(
|
|
||||||
repository,
|
|
||||||
catalogRevision,
|
|
||||||
{ draftRevision++; back() },
|
|
||||||
{ open(AppRoute.ExerciseCreate(AppRoute.SessionEditor)) },
|
|
||||||
{ editingEntryId ->
|
|
||||||
appState.pendingSessionEquipmentEntryId = editingEntryId
|
|
||||||
open(AppRoute.EquipmentCreate(AppRoute.SessionEditor))
|
|
||||||
},
|
|
||||||
{ exportSnapshot(); draftRevision++ },
|
|
||||||
restoreEditingEntryId = appState.pendingSessionEquipmentEntryId,
|
|
||||||
onEditingContextRestored = {
|
|
||||||
appState.pendingSessionEquipmentEntryId = null
|
|
||||||
},
|
|
||||||
)
|
|
||||||
AppRoute.SessionGenerator -> SessionGeneratorScreen(repository, generatorState, { back() }, { generatorState.abandon(); draftRevision++; open(AppRoute.SessionEditor) }, {
|
AppRoute.SessionGenerator -> SessionGeneratorScreen(repository, generatorState, { back() }, { generatorState.abandon(); draftRevision++; open(AppRoute.SessionEditor) }, {
|
||||||
draftMessage = strings.getString(R.string.existing_draft_warning)
|
draftMessage = strings.getString(R.string.existing_draft_warning)
|
||||||
draftRevision++
|
draftRevision++
|
||||||
|
|
@ -145,10 +123,6 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
||||||
)
|
)
|
||||||
AppRoute.CompletedSessions -> HistoryScreen(repository, { back() }) { open(AppRoute.SessionDetail(it)) }
|
AppRoute.CompletedSessions -> HistoryScreen(repository, { back() }) { open(AppRoute.SessionDetail(it)) }
|
||||||
AppRoute.Programs -> ProgramsScreen(repository) { open(AppRoute.ProgramDetail(it)) }
|
AppRoute.Programs -> ProgramsScreen(repository) { open(AppRoute.ProgramDetail(it)) }
|
||||||
AppRoute.CardioCalibration -> CardioCalibrationScreen(repository) {
|
|
||||||
draftRevision++
|
|
||||||
exportSnapshot()
|
|
||||||
}
|
|
||||||
is AppRoute.ProgramDetail -> ProgramDetailScreen(repository, route.programId) {
|
is AppRoute.ProgramDetail -> ProgramDetailScreen(repository, route.programId) {
|
||||||
draftRevision++
|
draftRevision++
|
||||||
open(AppRoute.SessionEditor)
|
open(AppRoute.SessionEditor)
|
||||||
|
|
@ -164,51 +138,11 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
||||||
is AppRoute.ExerciseEdit -> ExerciseEditorRoute(repository, exerciseState, route.exerciseId, false) { exportSnapshot(); catalogRevision++; back() }
|
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)) })
|
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.EquipmentDetail -> EquipmentDetailScreen(repository, route.equipmentId) { open(AppRoute.ExerciseDetail(it)) }
|
||||||
is AppRoute.EquipmentCreate -> EquipmentCreateScreen(repository, equipmentState) { equipment ->
|
is AppRoute.EquipmentCreate -> EquipmentCreateScreen(repository, equipmentState) { exportSnapshot(); catalogRevision++; back() }
|
||||||
/* 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.BodyMeasurements -> BodyScreen(repository, bodyState, { exportSnapshot() }, { back() })
|
||||||
AppRoute.SleepDiary -> SleepDiaryScreen(repository)
|
|
||||||
AppRoute.LatestMaxima -> LatestMaximaScreen(repository)
|
AppRoute.LatestMaxima -> LatestMaximaScreen(repository)
|
||||||
AppRoute.Sync -> SyncScreen(repository, inbox, exporter, requestOutbox, { exportSnapshot(); catalogRevision++ }, { back() })
|
AppRoute.Sync -> SyncScreen(repository, inbox, exporter, requestOutbox, { exportSnapshot(); catalogRevision++ }, { back() })
|
||||||
AppRoute.Settings -> SettingsScreen(repository, inbox, { open(AppRoute.HeartRateDiagnostics) }) { exportSnapshot(); catalogRevision++ }
|
AppRoute.Settings -> SettingsScreen(repository, inbox) { exportSnapshot(); catalogRevision++ }
|
||||||
AppRoute.HeartRateDiagnostics -> HeartRateDiagnosticsScreen { back() }
|
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,31 +8,6 @@
|
||||||
<string name="nav_statistics">Statistics</string>
|
<string name="nav_statistics">Statistics</string>
|
||||||
<string name="nav_sync">Synchronization</string>
|
<string name="nav_sync">Synchronization</string>
|
||||||
<string name="nav_settings">Settings</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_editor">Current session</string>
|
||||||
<string name="route_session_generator">Plan a session</string>
|
<string name="route_session_generator">Plan a session</string>
|
||||||
<string name="route_ai_drafts">Drafts</string>
|
<string name="route_ai_drafts">Drafts</string>
|
||||||
|
|
@ -417,7 +392,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="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="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="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="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="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="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="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="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>
|
<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>
|
||||||
|
|
@ -473,148 +448,4 @@
|
||||||
<string name="settings_backup_complete">Complete backup exported.</string>
|
<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_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="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>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -8,31 +8,6 @@
|
||||||
<string name="nav_statistics">Statistiques</string>
|
<string name="nav_statistics">Statistiques</string>
|
||||||
<string name="nav_sync">Synchronisation</string>
|
<string name="nav_sync">Synchronisation</string>
|
||||||
<string name="nav_settings">Paramètres</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_editor">Séance en cours</string>
|
||||||
<string name="route_session_generator">Programmer une séance</string>
|
<string name="route_session_generator">Programmer une séance</string>
|
||||||
<string name="route_ai_drafts">Brouillons</string>
|
<string name="route_ai_drafts">Brouillons</string>
|
||||||
|
|
@ -129,23 +104,13 @@
|
||||||
<string name="background_sync_notification_title">Trainlog est prêt à synchroniser</string>
|
<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_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="background_sync_notification_required">Autorisez les notifications pour activer la synchronisation en arrière-plan.</string>
|
||||||
<string name="bluetooth_sync_title">Bluetooth automatique</string>
|
<string name="drive_sync_title">Google Drive privé</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="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="bluetooth_sync_permission">Autoriser l’accès Bluetooth</string>
|
<string name="drive_sync_connect">Connecter Google Drive</string>
|
||||||
<string name="bluetooth_sync_permission_required">Autorisation Bluetooth requise pour la connexion automatique.</string>
|
<string name="drive_sync_disconnect">Déconnecter Google Drive</string>
|
||||||
<string name="bluetooth_sync_permission_granted">Bluetooth autorisé. Sélectionnez le PC appairé.</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="bluetooth_sync_no_desktop">Aucun ordinateur appairé détecté.</string>
|
<string name="drive_sync_connected">Dossier Drive connecté. Une vérification est planifiée.</string>
|
||||||
<string name="bluetooth_sync_select">Utiliser %1$s</string>
|
<string name="drive_sync_connection_failed">Impossible de conserver l’accès au dossier Drive.</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_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_authorize_first">Accès fichiers requis. Autorisez d’abord Documents/Trainlog.</string>
|
||||||
<string name="sync_request_sent">Demande envoyée · attente du PC…</string>
|
<string name="sync_request_sent">Demande envoyée · attente du PC…</string>
|
||||||
|
|
@ -427,7 +392,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="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="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="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="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="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="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="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="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>
|
<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>
|
||||||
|
|
@ -483,148 +448,4 @@
|
||||||
<string name="settings_backup_complete">Sauvegarde complète exportée.</string>
|
<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_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="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>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest {
|
||||||
|
|
||||||
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
||||||
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
assertEquals(33, scalar(db, "PRAGMA user_version"))
|
assertEquals(25, scalar(db, "PRAGMA user_version"))
|
||||||
for (table in listOf("session_exercises", "draft_session_exercises")) {
|
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(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"))
|
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()
|
migrated.close()
|
||||||
}
|
}
|
||||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
assertEquals(33, scalar(db, "PRAGMA user_version"))
|
assertEquals(25, scalar(db, "PRAGMA user_version"))
|
||||||
/* v13 intentionally adds the frozen machine identities and
|
/* v13 intentionally adds the frozen machine identities and
|
||||||
* exercise-owned metadata; all non-exercise facts stay exact. */
|
* 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") }
|
val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") }
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest {
|
||||||
migrated.close()
|
migrated.close()
|
||||||
}
|
}
|
||||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
assertEquals(33, scalar(db, "PRAGMA user_version"))
|
assertEquals(25, scalar(db, "PRAGMA user_version"))
|
||||||
assertEquals(before, listOf(
|
assertEquals(before, listOf(
|
||||||
scalar(db, "SELECT COUNT(*) FROM sessions"),
|
scalar(db, "SELECT COUNT(*) FROM sessions"),
|
||||||
scalar(db, "SELECT COUNT(*) FROM performed_sets"),
|
scalar(db, "SELECT COUNT(*) FROM performed_sets"),
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ class AndroidV25ProgramExecutionMigrationTest {
|
||||||
reopened.close()
|
reopened.close()
|
||||||
}
|
}
|
||||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
assertEquals(33, scalar(db, "PRAGMA user_version"))
|
assertEquals(25, scalar(db, "PRAGMA user_version"))
|
||||||
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM sessions WHERE source_program_id IS NOT NULL"))
|
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM sessions WHERE source_program_id IS NOT NULL"))
|
||||||
assertEquals("ok", text(db, "PRAGMA integrity_check"))
|
assertEquals("ok", text(db, "PRAGMA integrity_check"))
|
||||||
db.rawQuery("PRAGMA foreign_key_check", null).use { cursor ->
|
db.rawQuery("PRAGMA foreign_key_check", null).use { cursor ->
|
||||||
|
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
package com.labfytools.trainlog.data
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.database.sqlite.SQLiteDatabase
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import java.util.UUID
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.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() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
package com.labfytools.trainlog.data
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.database.sqlite.SQLiteDatabase
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import java.util.UUID
|
|
||||||
import org.json.JSONObject
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.robolectric.RobolectricTestRunner
|
|
||||||
import org.robolectric.annotation.Config
|
|
||||||
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
|
||||||
@Config(sdk = [35])
|
|
||||||
class AndroidV27HeartRateMigrationTest {
|
|
||||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun v26AddsEmptyHeartRateDomainAndReopens() {
|
|
||||||
val name = "heart-rate-v26-${UUID.randomUUID()}.db"
|
|
||||||
TrainlogRepository(context, name).also {
|
|
||||||
JSONObject(it.buildHeartRateV1Json())
|
|
||||||
it.close()
|
|
||||||
}
|
|
||||||
SQLiteDatabase.openDatabase(
|
|
||||||
context.getDatabasePath(name).path,
|
|
||||||
null,
|
|
||||||
SQLiteDatabase.OPEN_READWRITE,
|
|
||||||
).use { db ->
|
|
||||||
db.execSQL("DROP TABLE heart_rate_generation_captures")
|
|
||||||
db.execSQL("DROP TABLE heart_rate_rr_intervals")
|
|
||||||
db.execSQL("DROP TABLE heart_rate_samples")
|
|
||||||
db.execSQL("DROP TABLE heart_rate_captures")
|
|
||||||
db.version = 26
|
|
||||||
}
|
|
||||||
|
|
||||||
var repository = TrainlogRepository(context, name)
|
|
||||||
try {
|
|
||||||
assertEquals(0, JSONObject(repository.buildHeartRateV1Json())
|
|
||||||
.getJSONArray("captures").length())
|
|
||||||
repository.close()
|
|
||||||
repository = TrainlogRepository(context, name)
|
|
||||||
SQLiteDatabase.openDatabase(
|
|
||||||
context.getDatabasePath(name).path,
|
|
||||||
null,
|
|
||||||
SQLiteDatabase.OPEN_READONLY,
|
|
||||||
).use { assertEquals(33, it.version) }
|
|
||||||
} finally {
|
|
||||||
repository.close()
|
|
||||||
context.deleteDatabase(name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
package com.labfytools.trainlog.data
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.database.sqlite.SQLiteDatabase
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import com.labfytools.trainlog.model.NewExerciseProfile
|
|
||||||
import com.labfytools.trainlog.model.RecordingMode
|
|
||||||
import com.labfytools.trainlog.model.SessionExerciseDraft
|
|
||||||
import com.labfytools.trainlog.model.SessionSetDraft
|
|
||||||
import com.labfytools.trainlog.model.TrackingMode
|
|
||||||
import java.util.UUID
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNotNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.robolectric.RobolectricTestRunner
|
|
||||||
import org.robolectric.annotation.Config
|
|
||||||
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
|
||||||
@Config(sdk = [35])
|
|
||||||
class AndroidV28SessionTimelineMigrationTest {
|
|
||||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun v27SeedsOnlyAuthoritativeActiveSessionBoundary() {
|
|
||||||
val name = "timeline-v27-${UUID.randomUUID()}.db"
|
|
||||||
var repository = TrainlogRepository(context, name)
|
|
||||||
try {
|
|
||||||
assertEquals(
|
|
||||||
ActiveDraftMutationResult.Saved,
|
|
||||||
repository.startActiveSessionDraft(),
|
|
||||||
)
|
|
||||||
val exercise =
|
|
||||||
(repository.createExercise(
|
|
||||||
NewExerciseProfile(
|
|
||||||
"Timeline migration exercise",
|
|
||||||
RecordingMode.SETS,
|
|
||||||
TrackingMode.REPS,
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
) as CreateExerciseResult.Created).exercise
|
|
||||||
val loaded =
|
|
||||||
repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
|
|
||||||
val sessionId = checkNotNull(loaded.draft.sessionId)
|
|
||||||
val startedAt = checkNotNull(loaded.draft.startedAt)
|
|
||||||
assertEquals(
|
|
||||||
ActiveDraftMutationResult.Saved,
|
|
||||||
repository.saveActiveSessionDraft(
|
|
||||||
loaded.draft.copy(
|
|
||||||
exercises =
|
|
||||||
listOf(
|
|
||||||
SessionExerciseDraft(
|
|
||||||
exercise = exercise,
|
|
||||||
sets = listOf(SessionSetDraft(reps = 1)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
repository.close()
|
|
||||||
|
|
||||||
SQLiteDatabase.openDatabase(
|
|
||||||
context.getDatabasePath(name).path,
|
|
||||||
null,
|
|
||||||
SQLiteDatabase.OPEN_READWRITE,
|
|
||||||
).use { db ->
|
|
||||||
db.execSQL("DROP TABLE session_timeline_generation_sessions")
|
|
||||||
db.execSQL("DROP TABLE session_timeline_exercises")
|
|
||||||
db.execSQL("DROP TABLE session_timeline_sessions")
|
|
||||||
db.version = 27
|
|
||||||
}
|
|
||||||
|
|
||||||
repository = TrainlogRepository(context, name)
|
|
||||||
assertNotNull(repository.activeSessionTimelineContext())
|
|
||||||
SQLiteDatabase.openDatabase(
|
|
||||||
context.getDatabasePath(name).path,
|
|
||||||
null,
|
|
||||||
SQLiteDatabase.OPEN_READONLY,
|
|
||||||
).use { db ->
|
|
||||||
assertEquals(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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
package com.labfytools.trainlog.data
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.database.sqlite.SQLiteDatabase
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
|
||||||
import com.labfytools.trainlog.model.NewExerciseProfile
|
|
||||||
import com.labfytools.trainlog.model.RecordingMode
|
|
||||||
import com.labfytools.trainlog.model.SessionDraft
|
|
||||||
import com.labfytools.trainlog.model.SessionExerciseDraft
|
|
||||||
import com.labfytools.trainlog.model.SessionSetDraft
|
|
||||||
import com.labfytools.trainlog.model.TrackingMode
|
|
||||||
import java.util.UUID
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.robolectric.RobolectricTestRunner
|
|
||||||
import org.robolectric.annotation.Config
|
|
||||||
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
|
||||||
@Config(sdk = [35])
|
|
||||||
class AndroidV29CardioSessionMigrationTest {
|
|
||||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun v28BackfillsCanonicalSessionKindFromFrozenLegacyType() {
|
|
||||||
val name = "cardio-v28-" + UUID.randomUUID() + ".db"
|
|
||||||
var repository = TrainlogRepository(context, name)
|
|
||||||
try {
|
|
||||||
val exercise =
|
|
||||||
(repository.createExercise(
|
|
||||||
NewExerciseProfile(
|
|
||||||
"Cardio migration exercise",
|
|
||||||
RecordingMode.SETS,
|
|
||||||
TrackingMode.REPS,
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
) as CreateExerciseResult.Created).exercise
|
|
||||||
assertTrue(
|
|
||||||
repository.saveSession(
|
|
||||||
SessionDraft(
|
|
||||||
listOf(
|
|
||||||
SessionExerciseDraft(
|
|
||||||
exercise = exercise,
|
|
||||||
sets = listOf(SessionSetDraft(reps = 5)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
) is SaveSessionResult.Saved,
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
ActiveDraftMutationResult.Saved,
|
|
||||||
repository.saveActiveSessionDraft(
|
|
||||||
ActiveSessionDraft(
|
|
||||||
exercises =
|
|
||||||
listOf(
|
|
||||||
SessionExerciseDraft(
|
|
||||||
exercise = exercise,
|
|
||||||
sets = listOf(SessionSetDraft(reps = 6)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
repository.close()
|
|
||||||
|
|
||||||
SQLiteDatabase.openDatabase(
|
|
||||||
context.getDatabasePath(name).path,
|
|
||||||
null,
|
|
||||||
SQLiteDatabase.OPEN_READWRITE,
|
|
||||||
).use { db ->
|
|
||||||
db.execSQL("UPDATE sessions SET session_kind='cardio'")
|
|
||||||
db.execSQL("UPDATE active_session_draft SET session_kind='cardio'")
|
|
||||||
db.version = 28
|
|
||||||
}
|
|
||||||
|
|
||||||
repository = TrainlogRepository(context, name)
|
|
||||||
assertEquals(1, repository.listSessions().size)
|
|
||||||
SQLiteDatabase.openDatabase(
|
|
||||||
context.getDatabasePath(name).path,
|
|
||||||
null,
|
|
||||||
SQLiteDatabase.OPEN_READONLY,
|
|
||||||
).use { db ->
|
|
||||||
assertEquals(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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
package com.labfytools.trainlog.data
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.database.sqlite.SQLiteDatabase
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import java.util.UUID
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.robolectric.RobolectricTestRunner
|
|
||||||
import org.robolectric.annotation.Config
|
|
||||||
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
|
||||||
@Config(sdk = [35])
|
|
||||||
class AndroidV30CardioCalibrationMigrationTest {
|
|
||||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun v29AddsEmptyCalibrationDomainAndReservedExercise() {
|
|
||||||
val name = "calibration-v29-" + UUID.randomUUID() + ".db"
|
|
||||||
var repository = TrainlogRepository(context, name)
|
|
||||||
try {
|
|
||||||
repository.listExercises()
|
|
||||||
repository.close()
|
|
||||||
SQLiteDatabase.openDatabase(
|
|
||||||
context.getDatabasePath(name).path,
|
|
||||||
null,
|
|
||||||
SQLiteDatabase.OPEN_READWRITE,
|
|
||||||
).use { db ->
|
|
||||||
db.execSQL("DROP TABLE cardio_calibration_generation")
|
|
||||||
db.execSQL("DROP TABLE cardio_calibration_recovery")
|
|
||||||
db.execSQL("DROP TABLE cardio_calibrations")
|
|
||||||
db.version = 29
|
|
||||||
}
|
|
||||||
|
|
||||||
repository = TrainlogRepository(context, name)
|
|
||||||
assertTrue(repository.listExercises().none { it.name == "Calibration cardio" })
|
|
||||||
SQLiteDatabase.openDatabase(
|
|
||||||
context.getDatabasePath(name).path,
|
|
||||||
null,
|
|
||||||
SQLiteDatabase.OPEN_READONLY,
|
|
||||||
).use { db ->
|
|
||||||
assertEquals(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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
package com.labfytools.trainlog.data
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.database.sqlite.SQLiteDatabase
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import java.util.UUID
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.robolectric.RobolectricTestRunner
|
|
||||||
import org.robolectric.annotation.Config
|
|
||||||
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
|
||||||
@Config(sdk = [35])
|
|
||||||
class AndroidV31CardioGuidanceMigrationTest {
|
|
||||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun v30AddsEmptyGuidanceDomainAndReopens() {
|
|
||||||
val name = "guidance-v30-" + UUID.randomUUID() + ".db"
|
|
||||||
var repository = TrainlogRepository(context, name)
|
|
||||||
try {
|
|
||||||
repository.listSessions()
|
|
||||||
repository.close()
|
|
||||||
SQLiteDatabase.openDatabase(
|
|
||||||
context.getDatabasePath(name).path,
|
|
||||||
null,
|
|
||||||
SQLiteDatabase.OPEN_READWRITE,
|
|
||||||
).use { db ->
|
|
||||||
db.execSQL("DROP TABLE cardio_guidance_generation")
|
|
||||||
db.execSQL("DROP TABLE cardio_guidance_events")
|
|
||||||
db.execSQL("DROP TABLE cardio_guidance_phases")
|
|
||||||
db.execSQL("DROP TABLE cardio_guidance_runs")
|
|
||||||
db.version = 30
|
|
||||||
}
|
|
||||||
|
|
||||||
repository = TrainlogRepository(context, name)
|
|
||||||
assertTrue(repository.activeCardioGuidancePhase() == null)
|
|
||||||
SQLiteDatabase.openDatabase(
|
|
||||||
context.getDatabasePath(name).path,
|
|
||||||
null,
|
|
||||||
SQLiteDatabase.OPEN_READONLY,
|
|
||||||
).use { db ->
|
|
||||||
assertEquals(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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
package com.labfytools.trainlog.data
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.database.sqlite.SQLiteDatabase
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import com.labfytools.trainlog.model.SleepMedication
|
|
||||||
import java.util.UUID
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.robolectric.RobolectricTestRunner
|
|
||||||
import org.robolectric.annotation.Config
|
|
||||||
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
|
||||||
@Config(sdk = [35])
|
|
||||||
class AndroidV32SleepQuantityMigrationTest {
|
|
||||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun v31IntakesHydrateToOneAndQuickPublicationStartsEmpty() {
|
|
||||||
val name = "sleep-quantity-v31-${UUID.randomUUID()}.db"
|
|
||||||
val initial = TrainlogRepository(context, name)
|
|
||||||
try {
|
|
||||||
val saved = initial.saveSleepMedication(
|
|
||||||
SleepMedication(
|
|
||||||
"", "", "2026-09-23T20:00:00+02:00", "2026-09-23T20:00:00+02:00",
|
|
||||||
"Migration medication", 75.0, "mg", "", "", true,
|
|
||||||
),
|
|
||||||
) as TrainlogRepository.SaveSleepDiaryResult.Saved
|
|
||||||
initial.quickSleepMedication(saved.entryId, "2026-09-23T21:00:00+02:00")
|
|
||||||
} finally {
|
|
||||||
initial.close()
|
|
||||||
}
|
|
||||||
val path = context.getDatabasePath(name).path
|
|
||||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READWRITE).use { db ->
|
|
||||||
db.execSQL("PRAGMA foreign_keys=OFF")
|
|
||||||
db.execSQL("DROP TABLE sleep_diary_quick_publication_state")
|
|
||||||
db.execSQL("ALTER TABLE sleep_medication_intakes RENAME TO sleep_medication_intakes_v32")
|
|
||||||
db.execSQL(
|
|
||||||
"""CREATE TABLE sleep_medication_intakes(
|
|
||||||
revision_id TEXT NOT NULL, intake_id TEXT NOT NULL,
|
|
||||||
medication_id TEXT NOT NULL, medication_name TEXT NOT NULL,
|
|
||||||
taken_at TEXT NOT NULL, dose_value REAL, dose_unit TEXT,
|
|
||||||
note TEXT, created_at TEXT NOT NULL,
|
|
||||||
PRIMARY KEY(revision_id,intake_id)
|
|
||||||
)""".trimIndent(),
|
|
||||||
)
|
|
||||||
db.execSQL(
|
|
||||||
"INSERT INTO sleep_medication_intakes SELECT revision_id,intake_id,medication_id," +
|
|
||||||
"medication_name,taken_at,dose_value,dose_unit,note,created_at " +
|
|
||||||
"FROM sleep_medication_intakes_v32",
|
|
||||||
)
|
|
||||||
db.execSQL("DROP TABLE sleep_medication_intakes_v32")
|
|
||||||
db.execSQL("PRAGMA user_version=31")
|
|
||||||
}
|
|
||||||
|
|
||||||
val migrated = TrainlogRepository(context, name)
|
|
||||||
migrated.listSleepDiary()
|
|
||||||
migrated.close()
|
|
||||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
|
||||||
assertEquals(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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
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.openDatabase(context.getDatabasePath(name).path, null,
|
||||||
SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version", null).use { cursor ->
|
db.rawQuery("PRAGMA user_version", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst()); assertEquals(33, cursor.getInt(0))
|
assertTrue(cursor.moveToFirst()); assertEquals(25, cursor.getInt(0))
|
||||||
}
|
}
|
||||||
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
|
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ class BridgeBackupMigrationTest {
|
||||||
null,
|
null,
|
||||||
android.database.sqlite.SQLiteDatabase.OPEN_READONLY,
|
android.database.sqlite.SQLiteDatabase.OPEN_READONLY,
|
||||||
)
|
)
|
||||||
.use { assertEquals(31, it.version) }
|
.use { assertEquals(25, it.version) }
|
||||||
} finally {
|
} finally {
|
||||||
repository.close()
|
repository.close()
|
||||||
context.deleteDatabase(name)
|
context.deleteDatabase(name)
|
||||||
|
|
|
||||||
|
|
@ -1,187 +0,0 @@
|
||||||
package com.labfytools.trainlog.data
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import com.labfytools.trainlog.model.CardioCalibrationPhase
|
|
||||||
import com.labfytools.trainlog.model.SessionType
|
|
||||||
import java.util.UUID
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.robolectric.RobolectricTestRunner
|
|
||||||
import org.robolectric.annotation.Config
|
|
||||||
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
|
||||||
@Config(sdk = [35])
|
|
||||||
class CardioCalibrationRepositoryTest {
|
|
||||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
|
||||||
|
|
||||||
private fun measurement(bpm: Int) =
|
|
||||||
ParsedHeartRateMeasurement(
|
|
||||||
bpm = bpm,
|
|
||||||
sensorContactDetected = null,
|
|
||||||
energyExpended = null,
|
|
||||||
rrIntervals1024 = listOf(1024),
|
|
||||||
)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun calibrationTracksObservedPeakRecoveryAndFinalizesRealCardioSession() {
|
|
||||||
val name = "calibration-" + UUID.randomUUID() + ".db"
|
|
||||||
val repository = TrainlogRepository(context, name)
|
|
||||||
try {
|
|
||||||
val started =
|
|
||||||
repository.startCardioCalibration("2026-09-23T10:00:00+02:00")
|
|
||||||
as TrainlogRepository.CardioCalibrationResult.Applied
|
|
||||||
assertEquals(CardioCalibrationPhase.WARMUP, started.profile.phase)
|
|
||||||
|
|
||||||
repository.recordLiveHeartRateForActiveSession(
|
|
||||||
"CYCPLUS H2",
|
|
||||||
"2026-09-23T10:00:10+02:00",
|
|
||||||
measurement(80),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
CardioCalibrationPhase.PROGRESSIVE,
|
|
||||||
(repository.advanceCardioCalibrationPhase("2026-09-23T10:01:00+02:00")
|
|
||||||
as TrainlogRepository.CardioCalibrationResult.Applied).profile.phase,
|
|
||||||
)
|
|
||||||
repository.recordLiveHeartRateForActiveSession(
|
|
||||||
"CYCPLUS H2",
|
|
||||||
"2026-09-23T10:01:20+02:00",
|
|
||||||
measurement(112),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
CardioCalibrationPhase.HIGH_EFFORT,
|
|
||||||
(repository.advanceCardioCalibrationPhase("2026-09-23T10:02:00+02:00")
|
|
||||||
as TrainlogRepository.CardioCalibrationResult.Applied).profile.phase,
|
|
||||||
)
|
|
||||||
repository.recordLiveHeartRateForActiveSession(
|
|
||||||
"CYCPLUS H2",
|
|
||||||
"2026-09-23T10:02:30+02:00",
|
|
||||||
measurement(150),
|
|
||||||
)
|
|
||||||
repository.recordLiveHeartRateForActiveSession(
|
|
||||||
"CYCPLUS H2",
|
|
||||||
"2026-09-23T10:03:00+02:00",
|
|
||||||
measurement(160),
|
|
||||||
)
|
|
||||||
|
|
||||||
val recovery =
|
|
||||||
repository.markCardioCalibrationEffortEnd("2026-09-23T10:03:01+02:00")
|
|
||||||
as TrainlogRepository.CardioCalibrationResult.Applied
|
|
||||||
assertEquals(CardioCalibrationPhase.RECOVERY, recovery.profile.phase)
|
|
||||||
assertEquals(160, recovery.profile.observedPeakBpm)
|
|
||||||
assertTrue(recovery.profile.heartRateCaptureId != null)
|
|
||||||
|
|
||||||
repository.recordLiveHeartRateForActiveSession(
|
|
||||||
"CYCPLUS H2",
|
|
||||||
"2026-09-23T10:04:01+02:00",
|
|
||||||
measurement(140),
|
|
||||||
)
|
|
||||||
repository.recordLiveHeartRateForActiveSession(
|
|
||||||
"CYCPLUS H2",
|
|
||||||
"2026-09-23T10:05:02+02:00",
|
|
||||||
measurement(120),
|
|
||||||
)
|
|
||||||
repository.recordLiveHeartRateForActiveSession(
|
|
||||||
"CYCPLUS H2",
|
|
||||||
"2026-09-23T10:06:01+02:00",
|
|
||||||
measurement(100),
|
|
||||||
)
|
|
||||||
|
|
||||||
val completed =
|
|
||||||
repository.finishCardioCalibration("2026-09-23T10:06:10+02:00")
|
|
||||||
as TrainlogRepository.CardioCalibrationResult.Applied
|
|
||||||
assertEquals(CardioCalibrationPhase.COMPLETED, completed.profile.phase)
|
|
||||||
assertEquals(
|
|
||||||
listOf(60 to 140, 120 to 120, 180 to 100),
|
|
||||||
completed.profile.recovery.map { it.targetOffsetSeconds to it.bpm },
|
|
||||||
)
|
|
||||||
assertNull(repository.activeCardioCalibration())
|
|
||||||
assertNull(repository.activeHeartRateCapture())
|
|
||||||
|
|
||||||
val session = repository.listSessions().single()
|
|
||||||
assertEquals(SessionType.CARDIO, session.sessionType)
|
|
||||||
val detail = checkNotNull(repository.getSessionDetail(session.sessionId))
|
|
||||||
assertEquals(370, detail.exercises.single().continuousDurationSeconds)
|
|
||||||
assertEquals(1, repository.listCompletedCardioCalibrations().size)
|
|
||||||
} finally {
|
|
||||||
repository.close()
|
|
||||||
context.deleteDatabase(name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun recoveryDoesNotInventLateReferencePointsAndCannotFinishTooEarly() {
|
|
||||||
val name = "calibration-recovery-" + UUID.randomUUID() + ".db"
|
|
||||||
val repository = TrainlogRepository(context, name)
|
|
||||||
try {
|
|
||||||
repository.startCardioCalibration("2026-09-23T12:00:00+02:00")
|
|
||||||
repository.recordLiveHeartRateForActiveSession(
|
|
||||||
"CYCPLUS H2",
|
|
||||||
"2026-09-23T12:00:10+02:00",
|
|
||||||
measurement(80),
|
|
||||||
)
|
|
||||||
repository.advanceCardioCalibrationPhase("2026-09-23T12:01:00+02:00")
|
|
||||||
repository.advanceCardioCalibrationPhase("2026-09-23T12:02:00+02:00")
|
|
||||||
repository.recordLiveHeartRateForActiveSession(
|
|
||||||
"CYCPLUS H2",
|
|
||||||
"2026-09-23T12:02:30+02:00",
|
|
||||||
measurement(155),
|
|
||||||
)
|
|
||||||
repository.markCardioCalibrationEffortEnd("2026-09-23T12:03:00+02:00")
|
|
||||||
|
|
||||||
repository.recordLiveHeartRateForActiveSession(
|
|
||||||
"CYCPLUS H2",
|
|
||||||
"2026-09-23T12:05:05+02:00",
|
|
||||||
measurement(118),
|
|
||||||
)
|
|
||||||
val duringRecovery = checkNotNull(repository.activeCardioCalibration())
|
|
||||||
assertEquals(
|
|
||||||
listOf(120 to 118),
|
|
||||||
duringRecovery.recovery.map { it.targetOffsetSeconds to it.bpm },
|
|
||||||
)
|
|
||||||
|
|
||||||
val tooEarly =
|
|
||||||
repository.finishCardioCalibration("2026-09-23T12:05:59+02:00")
|
|
||||||
assertTrue(tooEarly is TrainlogRepository.CardioCalibrationResult.Invalid)
|
|
||||||
assertTrue(repository.activeCardioCalibration() != null)
|
|
||||||
|
|
||||||
repository.recordLiveHeartRateForActiveSession(
|
|
||||||
"CYCPLUS H2",
|
|
||||||
"2026-09-23T12:06:03+02:00",
|
|
||||||
measurement(105),
|
|
||||||
)
|
|
||||||
val completed =
|
|
||||||
repository.finishCardioCalibration("2026-09-23T12:06:05+02:00")
|
|
||||||
as TrainlogRepository.CardioCalibrationResult.Applied
|
|
||||||
assertEquals(
|
|
||||||
listOf(120 to 118, 180 to 105),
|
|
||||||
completed.profile.recovery.map { it.targetOffsetSeconds to it.bpm },
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
repository.close()
|
|
||||||
context.deleteDatabase(name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun abortBeforeEffortEndNeverCreatesCompletedReference() {
|
|
||||||
val name = "calibration-abort-" + UUID.randomUUID() + ".db"
|
|
||||||
val repository = TrainlogRepository(context, name)
|
|
||||||
try {
|
|
||||||
repository.startCardioCalibration("2026-09-23T11:00:00+02:00")
|
|
||||||
val aborted =
|
|
||||||
repository.abortCardioCalibration("2026-09-23T11:01:00+02:00")
|
|
||||||
as TrainlogRepository.CardioCalibrationResult.Applied
|
|
||||||
assertEquals(CardioCalibrationPhase.ABORTED, aborted.profile.phase)
|
|
||||||
assertNull(aborted.profile.effortEndAt)
|
|
||||||
assertTrue(repository.loadActiveSessionDraft() is ActiveDraftLoadResult.None)
|
|
||||||
assertTrue(repository.listCompletedCardioCalibrations().isEmpty())
|
|
||||||
} finally {
|
|
||||||
repository.close()
|
|
||||||
context.deleteDatabase(name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,147 +0,0 @@
|
||||||
package com.labfytools.trainlog.data
|
|
||||||
|
|
||||||
import com.labfytools.trainlog.model.CardioGuidanceInstruction
|
|
||||||
import com.labfytools.trainlog.model.CardioTargetDefinition
|
|
||||||
import com.labfytools.trainlog.model.CardioTargetSnapshot
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class CardioGuidanceEngineTest {
|
|
||||||
private val target = CardioTargetSnapshot(120, 140)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun absoluteAndCalibrationTargetsResolveToImmutableBpmSnapshot() {
|
|
||||||
assertEquals(
|
|
||||||
CardioTargetSnapshot(120, 140),
|
|
||||||
CardioTargetResolver.resolve(CardioTargetDefinition.AbsoluteBpm(120, 140)),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
CardioTargetSnapshot(
|
|
||||||
minimumBpm = 100,
|
|
||||||
maximumBpm = 125,
|
|
||||||
calibrationId = "cal_11111111-1111-4111-8111-111111111111",
|
|
||||||
calibrationObservedPeakBpm = 167,
|
|
||||||
minimumPercent = 60,
|
|
||||||
maximumPercent = 75,
|
|
||||||
),
|
|
||||||
CardioTargetResolver.resolve(
|
|
||||||
CardioTargetDefinition.CalibrationPercent(
|
|
||||||
calibrationId = "cal_11111111-1111-4111-8111-111111111111",
|
|
||||||
observedPeakBpm = 167,
|
|
||||||
minimumPercent = 60,
|
|
||||||
maximumPercent = 75,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun invalidTargetDefinitionsAreRejectedInsteadOfClamped() {
|
|
||||||
assertNull(CardioTargetResolver.resolve(CardioTargetDefinition.AbsoluteBpm(140, 120)))
|
|
||||||
assertNull(
|
|
||||||
CardioTargetResolver.resolve(
|
|
||||||
CardioTargetDefinition.CalibrationPercent("cal_x", 0, 60, 70),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assertNull(
|
|
||||||
CardioTargetResolver.resolve(
|
|
||||||
CardioTargetDefinition.CalibrationPercent("cal_x", 160, 80, 60),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun lowBpmMustPersistBeforeAccelerateInstructionChanges() {
|
|
||||||
val engine = CardioGuidanceEngine(hysteresisBpm = 2, confirmationMs = 3_000)
|
|
||||||
assertEquals(
|
|
||||||
CardioGuidanceInstruction.MAINTAIN,
|
|
||||||
engine.evaluate(target, 110, true, 1_000).instruction,
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
CardioGuidanceInstruction.MAINTAIN,
|
|
||||||
engine.evaluate(target, 111, true, 3_999).instruction,
|
|
||||||
)
|
|
||||||
val changed = engine.evaluate(target, 111, true, 4_000)
|
|
||||||
assertEquals(CardioGuidanceInstruction.ACCELERATE, changed.instruction)
|
|
||||||
assertNull(changed.pendingInstruction)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun highBpmMustPersistBeforeSlowDownInstructionChanges() {
|
|
||||||
val engine = CardioGuidanceEngine(hysteresisBpm = 2, confirmationMs = 2_000)
|
|
||||||
engine.evaluate(target, 150, true, 0)
|
|
||||||
assertEquals(
|
|
||||||
CardioGuidanceInstruction.SLOW_DOWN,
|
|
||||||
engine.evaluate(target, 150, true, 2_000).instruction,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun hysteresisPreventsBoundaryYoyo() {
|
|
||||||
val engine = CardioGuidanceEngine(hysteresisBpm = 2, confirmationMs = 0)
|
|
||||||
assertEquals(
|
|
||||||
CardioGuidanceInstruction.ACCELERATE,
|
|
||||||
engine.evaluate(target, 117, true, 0).instruction,
|
|
||||||
)
|
|
||||||
// 119 is still below target, but not enough to leave ACCELERATE.
|
|
||||||
assertEquals(
|
|
||||||
CardioGuidanceInstruction.ACCELERATE,
|
|
||||||
engine.evaluate(target, 119, true, 1).instruction,
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
CardioGuidanceInstruction.MAINTAIN,
|
|
||||||
engine.evaluate(target, 122, true, 2).instruction,
|
|
||||||
)
|
|
||||||
// 141 is just above the zone and remains MAINTAIN within hysteresis.
|
|
||||||
assertEquals(
|
|
||||||
CardioGuidanceInstruction.MAINTAIN,
|
|
||||||
engine.evaluate(target, 141, true, 3).instruction,
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
CardioGuidanceInstruction.SLOW_DOWN,
|
|
||||||
engine.evaluate(target, 143, true, 4).instruction,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun isolatedOppositeSampleDoesNotReplaceCurrentInstruction() {
|
|
||||||
val engine = CardioGuidanceEngine(hysteresisBpm = 2, confirmationMs = 3_000)
|
|
||||||
engine.evaluate(target, 110, true, 0)
|
|
||||||
engine.evaluate(target, 110, true, 3_000)
|
|
||||||
assertEquals(
|
|
||||||
CardioGuidanceInstruction.ACCELERATE,
|
|
||||||
engine.evaluate(target, 150, true, 3_100).instruction,
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
CardioGuidanceInstruction.ACCELERATE,
|
|
||||||
engine.evaluate(target, 112, true, 3_200).instruction,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun staleOrMissingSignalSuspendsImmediatelyAndNeverUsesOldBpm() {
|
|
||||||
val engine = CardioGuidanceEngine(hysteresisBpm = 2, confirmationMs = 0)
|
|
||||||
assertEquals(
|
|
||||||
CardioGuidanceInstruction.ACCELERATE,
|
|
||||||
engine.evaluate(target, 110, true, 0).instruction,
|
|
||||||
)
|
|
||||||
val stale = engine.evaluate(target, 110, false, 1_000)
|
|
||||||
assertEquals(CardioGuidanceInstruction.SUSPENDED, stale.instruction)
|
|
||||||
assertNull(stale.bpm)
|
|
||||||
|
|
||||||
// A fresh sample must re-establish a candidate from real current data.
|
|
||||||
val resumed = engine.evaluate(target, 130, true, 2_000)
|
|
||||||
assertTrue(resumed.instruction != CardioGuidanceInstruction.SUSPENDED)
|
|
||||||
assertEquals(130, resumed.bpm)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun noTargetSuspendsGuidance() {
|
|
||||||
val engine = CardioGuidanceEngine()
|
|
||||||
val output = engine.evaluate(null, 130, true, 0)
|
|
||||||
assertEquals(CardioGuidanceInstruction.SUSPENDED, output.instruction)
|
|
||||||
assertNull(output.bpm)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,260 +0,0 @@
|
||||||
package com.labfytools.trainlog.data
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
|
||||||
import com.labfytools.trainlog.model.CardioGuidanceInstruction
|
|
||||||
import com.labfytools.trainlog.model.CardioGuidanceOutput
|
|
||||||
import com.labfytools.trainlog.model.CardioGuidedPhase
|
|
||||||
import com.labfytools.trainlog.model.CardioPhaseExitCondition
|
|
||||||
import com.labfytools.trainlog.model.CardioPhaseKind
|
|
||||||
import com.labfytools.trainlog.model.CardioTargetSnapshot
|
|
||||||
import com.labfytools.trainlog.model.NewExerciseProfile
|
|
||||||
import com.labfytools.trainlog.model.RecordingMode
|
|
||||||
import com.labfytools.trainlog.model.SessionExerciseDraft
|
|
||||||
import com.labfytools.trainlog.model.SessionExercisePlan
|
|
||||||
import com.labfytools.trainlog.model.SessionType
|
|
||||||
import com.labfytools.trainlog.model.TrackingMode
|
|
||||||
import java.nio.file.Files
|
|
||||||
import java.time.OffsetDateTime
|
|
||||||
import java.util.UUID
|
|
||||||
import org.json.JSONObject
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.robolectric.RobolectricTestRunner
|
|
||||||
import org.robolectric.annotation.Config
|
|
||||||
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
|
||||||
@Config(sdk = [35])
|
|
||||||
class CardioGuidanceGenerationTest {
|
|
||||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
|
||||||
|
|
||||||
private fun measurement(bpm: Int) =
|
|
||||||
ParsedHeartRateMeasurement(
|
|
||||||
bpm = bpm,
|
|
||||||
sensorContactDetected = null,
|
|
||||||
energyExpended = null,
|
|
||||||
rrIntervals1024 = listOf(1024),
|
|
||||||
)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun completedGuidanceSynchronizesOnceAndAckStopsRepublication() {
|
|
||||||
val name = "guidance-generation-" + UUID.randomUUID() + ".db"
|
|
||||||
val repository = TrainlogRepository(context, name)
|
|
||||||
val root = Files.createTempDirectory("trainlog-guidance-generation-").toFile()
|
|
||||||
try {
|
|
||||||
val exercise =
|
|
||||||
(repository.createExercise(
|
|
||||||
NewExerciseProfile(
|
|
||||||
"Vélo guidé sync",
|
|
||||||
RecordingMode.CONTINUOUS,
|
|
||||||
TrackingMode.DURATION,
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
) as CreateExerciseResult.Created).exercise
|
|
||||||
|
|
||||||
val entry =
|
|
||||||
SessionExerciseDraft(
|
|
||||||
exercise = exercise,
|
|
||||||
plan = SessionExercisePlan(sets = 0, durationSeconds = 30),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
ActiveDraftMutationResult.Saved,
|
|
||||||
repository.saveActiveSessionDraft(
|
|
||||||
ActiveSessionDraft(
|
|
||||||
startedAt = OffsetDateTime.now().minusMinutes(1).toString(),
|
|
||||||
sessionType = SessionType.CARDIO,
|
|
||||||
exercises = listOf(entry),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val active =
|
|
||||||
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
|
||||||
val base = OffsetDateTime.parse(checkNotNull(active.startedAt))
|
|
||||||
val exerciseStart = base.plusSeconds(1).toString()
|
|
||||||
assertTrue(
|
|
||||||
repository.startActiveSessionExercise(entry.entryId, exerciseStart) is
|
|
||||||
TrainlogRepository.SessionExerciseTimingResult.Started,
|
|
||||||
)
|
|
||||||
|
|
||||||
val target = CardioTargetSnapshot(120, 140)
|
|
||||||
val phase =
|
|
||||||
CardioGuidedPhase(
|
|
||||||
phaseId = "cgp_" + UUID.randomUUID(),
|
|
||||||
kind = CardioPhaseKind.WORK,
|
|
||||||
target = target,
|
|
||||||
exitCondition = CardioPhaseExitCondition.FixedDuration(30),
|
|
||||||
)
|
|
||||||
assertTrue(
|
|
||||||
repository.startCardioGuidancePhase(
|
|
||||||
entry.entryId,
|
|
||||||
phase,
|
|
||||||
base.plusSeconds(2).toString(),
|
|
||||||
) is TrainlogRepository.CardioGuidanceMutationResult.Applied,
|
|
||||||
)
|
|
||||||
|
|
||||||
val lowAt = base.plusSeconds(3).toString()
|
|
||||||
repository.recordLiveHeartRateForActiveSession(
|
|
||||||
"Synthetic HR",
|
|
||||||
lowAt,
|
|
||||||
measurement(110),
|
|
||||||
)
|
|
||||||
repository.recordCardioGuidanceOutput(
|
|
||||||
phase.phaseId,
|
|
||||||
lowAt,
|
|
||||||
CardioGuidanceOutput(
|
|
||||||
CardioGuidanceInstruction.ACCELERATE,
|
|
||||||
110,
|
|
||||||
target,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val targetAt = base.plusSeconds(4).toString()
|
|
||||||
repository.recordLiveHeartRateForActiveSession(
|
|
||||||
"Synthetic HR",
|
|
||||||
targetAt,
|
|
||||||
measurement(130),
|
|
||||||
)
|
|
||||||
repository.recordCardioGuidanceOutput(
|
|
||||||
phase.phaseId,
|
|
||||||
targetAt,
|
|
||||||
CardioGuidanceOutput(
|
|
||||||
CardioGuidanceInstruction.MAINTAIN,
|
|
||||||
130,
|
|
||||||
target,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
repository.finishCardioGuidancePhase(
|
|
||||||
phase.phaseId,
|
|
||||||
base.plusSeconds(5).toString(),
|
|
||||||
)
|
|
||||||
repository.finishActiveSessionExercise(
|
|
||||||
entry.entryId,
|
|
||||||
base.plusSeconds(6).toString(),
|
|
||||||
)
|
|
||||||
val completedDraft =
|
|
||||||
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
|
||||||
assertEquals(
|
|
||||||
ActiveDraftMutationResult.Saved,
|
|
||||||
repository.saveActiveSessionDraft(
|
|
||||||
completedDraft.copy(
|
|
||||||
exercises =
|
|
||||||
completedDraft.exercises.map {
|
|
||||||
if (it.entryId == entry.entryId) {
|
|
||||||
it.copy(continuousDurationSeconds = 5)
|
|
||||||
} else {
|
|
||||||
it
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assertTrue(repository.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
|
|
||||||
assertEquals(
|
|
||||||
1,
|
|
||||||
JSONObject(repository.buildCardioGuidanceV1Json())
|
|
||||||
.getJSONArray("runs")
|
|
||||||
.length(),
|
|
||||||
)
|
|
||||||
|
|
||||||
val repositoryRoot =
|
|
||||||
generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))) {
|
|
||||||
it.parentFile
|
|
||||||
}
|
|
||||||
.first { java.io.File(it, "tools/sync_generation_exchange.py").isFile }
|
|
||||||
fun run(vararg command: String): String {
|
|
||||||
val process =
|
|
||||||
ProcessBuilder(*command)
|
|
||||||
.directory(repositoryRoot)
|
|
||||||
.redirectErrorStream(true)
|
|
||||||
.start()
|
|
||||||
val output = process.inputStream.bufferedReader().readText()
|
|
||||||
assertEquals(
|
|
||||||
"command failed: ${command.joinToString(" ")}\n$output",
|
|
||||||
0,
|
|
||||||
process.waitFor(),
|
|
||||||
)
|
|
||||||
return output
|
|
||||||
}
|
|
||||||
|
|
||||||
val desktopDb = java.io.File(root, "desktop.sqlite")
|
|
||||||
run(
|
|
||||||
java.io.File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
|
|
||||||
desktopDb.absolutePath,
|
|
||||||
)
|
|
||||||
val desktopPeer =
|
|
||||||
Regex("PEER_ID=(peer_[^\\s]+)")
|
|
||||||
.find(
|
|
||||||
run(
|
|
||||||
"python3",
|
|
||||||
java.io.File(
|
|
||||||
repositoryRoot,
|
|
||||||
"tools/sync_generation_exchange.py",
|
|
||||||
).absolutePath,
|
|
||||||
"peer-id",
|
|
||||||
"--database",
|
|
||||||
desktopDb.absolutePath,
|
|
||||||
),
|
|
||||||
)!!
|
|
||||||
.groupValues[1]
|
|
||||||
|
|
||||||
val service = SyncGenerationService(repository)
|
|
||||||
val captured = service.capture(root, desktopPeer)
|
|
||||||
assertEquals(
|
|
||||||
1,
|
|
||||||
JSONObject(
|
|
||||||
java.io.File(
|
|
||||||
captured.stagingDirectory,
|
|
||||||
"cardio-guidance-v1.json",
|
|
||||||
).readText(),
|
|
||||||
)
|
|
||||||
.getJSONArray("runs")
|
|
||||||
.length(),
|
|
||||||
)
|
|
||||||
val published =
|
|
||||||
service.publish(captured, java.io.File(root, "objects"))
|
|
||||||
val ackFile = java.io.File(root, "ack.json")
|
|
||||||
run(
|
|
||||||
"python3",
|
|
||||||
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath,
|
|
||||||
"consume-desktop",
|
|
||||||
published.absolutePath,
|
|
||||||
"--database",
|
|
||||||
desktopDb.absolutePath,
|
|
||||||
"--ack-output",
|
|
||||||
ackFile.absolutePath,
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
"consumed",
|
|
||||||
JSONObject(ackFile.readText()).getString("result"),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
"acknowledged",
|
|
||||||
service.acceptAcknowledgement(ackFile.readBytes()),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
0,
|
|
||||||
JSONObject(repository.buildCardioGuidanceV1Json())
|
|
||||||
.getJSONArray("runs")
|
|
||||||
.length(),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
"1|1|2",
|
|
||||||
run(
|
|
||||||
"sqlite3",
|
|
||||||
desktopDb.absolutePath,
|
|
||||||
"SELECT (SELECT count(*) FROM cardio_guidance_runs) || '|' || " +
|
|
||||||
"(SELECT count(*) FROM cardio_guidance_phases) || '|' || " +
|
|
||||||
"(SELECT count(*) FROM cardio_guidance_events);",
|
|
||||||
)
|
|
||||||
.trim(),
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
repository.close()
|
|
||||||
context.deleteDatabase(name)
|
|
||||||
root.deleteRecursively()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,428 +0,0 @@
|
||||||
package com.labfytools.trainlog.data
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.database.sqlite.SQLiteDatabase
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
|
||||||
import com.labfytools.trainlog.model.CardioGuidanceInstruction
|
|
||||||
import com.labfytools.trainlog.model.CardioGuidanceOutput
|
|
||||||
import com.labfytools.trainlog.model.CardioGuidedPhase
|
|
||||||
import com.labfytools.trainlog.model.CardioPhaseExitCondition
|
|
||||||
import com.labfytools.trainlog.model.CardioPhaseKind
|
|
||||||
import com.labfytools.trainlog.model.CardioTargetSnapshot
|
|
||||||
import com.labfytools.trainlog.model.HeartRateContextKind
|
|
||||||
import com.labfytools.trainlog.model.NewExerciseProfile
|
|
||||||
import com.labfytools.trainlog.model.RecordingMode
|
|
||||||
import com.labfytools.trainlog.model.SessionExerciseDraft
|
|
||||||
import com.labfytools.trainlog.model.SessionExercisePlan
|
|
||||||
import com.labfytools.trainlog.model.SessionType
|
|
||||||
import com.labfytools.trainlog.model.TrackingMode
|
|
||||||
import java.time.OffsetDateTime
|
|
||||||
import java.util.UUID
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.robolectric.RobolectricTestRunner
|
|
||||||
import org.robolectric.annotation.Config
|
|
||||||
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
|
||||||
@Config(sdk = [35])
|
|
||||||
class CardioGuidanceRepositoryTest {
|
|
||||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
|
||||||
|
|
||||||
private data class ActiveCardio(
|
|
||||||
val repository: TrainlogRepository,
|
|
||||||
val databaseName: String,
|
|
||||||
val entryId: String,
|
|
||||||
val exerciseStartedAt: String,
|
|
||||||
) {
|
|
||||||
fun at(seconds: Long): String =
|
|
||||||
OffsetDateTime.parse(exerciseStartedAt).plusSeconds(seconds).toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun activeCardio(): ActiveCardio {
|
|
||||||
val name = "guidance-" + UUID.randomUUID() + ".db"
|
|
||||||
val repository = TrainlogRepository(context, name)
|
|
||||||
val exercise =
|
|
||||||
(repository.createExercise(
|
|
||||||
NewExerciseProfile(
|
|
||||||
"Tapis guidé",
|
|
||||||
RecordingMode.CONTINUOUS,
|
|
||||||
TrackingMode.DURATION,
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
) as CreateExerciseResult.Created).exercise
|
|
||||||
val entry =
|
|
||||||
SessionExerciseDraft(
|
|
||||||
exercise = exercise,
|
|
||||||
plan = SessionExercisePlan(sets = 0, durationSeconds = 600),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
ActiveDraftMutationResult.Saved,
|
|
||||||
repository.saveActiveSessionDraft(
|
|
||||||
ActiveSessionDraft(
|
|
||||||
startedAt = OffsetDateTime.now().minusMinutes(20).toString(),
|
|
||||||
sessionType = SessionType.CARDIO,
|
|
||||||
exercises = listOf(entry),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val loaded = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
|
|
||||||
val exerciseStartedAt =
|
|
||||||
OffsetDateTime.parse(checkNotNull(loaded.draft.startedAt))
|
|
||||||
.plusSeconds(10)
|
|
||||||
.toString()
|
|
||||||
val started =
|
|
||||||
repository.startActiveSessionExercise(
|
|
||||||
entry.entryId,
|
|
||||||
exerciseStartedAt,
|
|
||||||
)
|
|
||||||
assertTrue(started is TrainlogRepository.SessionExerciseTimingResult.Started)
|
|
||||||
return ActiveCardio(repository, name, entry.entryId, exerciseStartedAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun trainingCaptureTransitionsToCardioWithoutStarvingGuidance() {
|
|
||||||
val name = "guidance-transition-" + UUID.randomUUID() + ".db"
|
|
||||||
val repository = TrainlogRepository(context, name)
|
|
||||||
try {
|
|
||||||
val exercise =
|
|
||||||
(repository.createExercise(
|
|
||||||
NewExerciseProfile(
|
|
||||||
"Vélo guidé après transition",
|
|
||||||
RecordingMode.CONTINUOUS,
|
|
||||||
TrackingMode.DURATION,
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
) as CreateExerciseResult.Created).exercise
|
|
||||||
assertEquals(ActiveDraftMutationResult.Saved, repository.startActiveSessionDraft())
|
|
||||||
val training =
|
|
||||||
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
|
||||||
val base = OffsetDateTime.parse(checkNotNull(training.startedAt))
|
|
||||||
val firstMeasurement =
|
|
||||||
ParsedHeartRateMeasurement(
|
|
||||||
bpm = 101,
|
|
||||||
sensorContactDetected = true,
|
|
||||||
energyExpended = 12,
|
|
||||||
rrIntervals1024 = listOf(611),
|
|
||||||
)
|
|
||||||
assertTrue(
|
|
||||||
repository.recordLiveHeartRateForActiveSession(
|
|
||||||
"Synthetic HR",
|
|
||||||
base.plusSeconds(1).toString(),
|
|
||||||
firstMeasurement,
|
|
||||||
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
|
||||||
)
|
|
||||||
val originalCapture = checkNotNull(repository.activeHeartRateCapture())
|
|
||||||
assertEquals(HeartRateContextKind.SESSION, originalCapture.contextKind)
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
ActiveDraftMutationResult.Saved,
|
|
||||||
repository.saveActiveSessionDraft(training.copy(sessionType = SessionType.CARDIO)),
|
|
||||||
)
|
|
||||||
val transitionedCapture = checkNotNull(repository.activeHeartRateCapture())
|
|
||||||
assertEquals(originalCapture.captureId, transitionedCapture.captureId)
|
|
||||||
assertEquals(HeartRateContextKind.CARDIO, transitionedCapture.contextKind)
|
|
||||||
|
|
||||||
val entry =
|
|
||||||
SessionExerciseDraft(
|
|
||||||
exercise = exercise,
|
|
||||||
plan = SessionExercisePlan(sets = 0, durationSeconds = 60),
|
|
||||||
)
|
|
||||||
val cardio =
|
|
||||||
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
|
||||||
assertEquals(
|
|
||||||
ActiveDraftMutationResult.Saved,
|
|
||||||
repository.saveActiveSessionDraft(cardio.copy(exercises = listOf(entry))),
|
|
||||||
)
|
|
||||||
assertTrue(
|
|
||||||
repository.startActiveSessionExercise(entry.entryId, base.plusSeconds(2).toString()) is
|
|
||||||
TrainlogRepository.SessionExerciseTimingResult.Started,
|
|
||||||
)
|
|
||||||
val target = CardioTargetSnapshot(100, 110)
|
|
||||||
val phase =
|
|
||||||
CardioGuidedPhase(
|
|
||||||
phaseId = "cgp_" + UUID.randomUUID(),
|
|
||||||
kind = CardioPhaseKind.WARMUP,
|
|
||||||
target = target,
|
|
||||||
exitCondition = CardioPhaseExitCondition.FixedDuration(60),
|
|
||||||
)
|
|
||||||
assertTrue(
|
|
||||||
repository.startCardioGuidancePhase(
|
|
||||||
entry.entryId,
|
|
||||||
phase,
|
|
||||||
base.plusSeconds(3).toString(),
|
|
||||||
) is TrainlogRepository.CardioGuidanceMutationResult.Applied,
|
|
||||||
)
|
|
||||||
val secondAt = base.plusSeconds(4).toString()
|
|
||||||
assertTrue(
|
|
||||||
repository.recordLiveHeartRateForActiveSession(
|
|
||||||
"Synthetic HR",
|
|
||||||
secondAt,
|
|
||||||
firstMeasurement.copy(bpm = 99),
|
|
||||||
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
|
||||||
)
|
|
||||||
assertTrue(
|
|
||||||
repository.recordCardioGuidanceOutput(
|
|
||||||
phase.phaseId,
|
|
||||||
secondAt,
|
|
||||||
CardioGuidanceOutput(CardioGuidanceInstruction.ACCELERATE, 99, target),
|
|
||||||
) is TrainlogRepository.CardioGuidanceMutationResult.Applied,
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
listOf(CardioGuidanceInstruction.ACCELERATE),
|
|
||||||
repository.listCardioGuidanceEvents(phase.phaseId).map { it.instruction },
|
|
||||||
)
|
|
||||||
|
|
||||||
val lateReverse =
|
|
||||||
repository.saveActiveSessionDraft(
|
|
||||||
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded)
|
|
||||||
.draft
|
|
||||||
.copy(sessionType = SessionType.TRAINING),
|
|
||||||
)
|
|
||||||
assertTrue(lateReverse is ActiveDraftMutationResult.Error)
|
|
||||||
assertEquals(
|
|
||||||
SessionType.CARDIO,
|
|
||||||
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded)
|
|
||||||
.draft
|
|
||||||
.sessionType,
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
HeartRateContextKind.CARDIO,
|
|
||||||
checkNotNull(repository.activeHeartRateCapture()).contextKind,
|
|
||||||
)
|
|
||||||
|
|
||||||
SQLiteDatabase.openDatabase(
|
|
||||||
context.getDatabasePath(name).path,
|
|
||||||
null,
|
|
||||||
SQLiteDatabase.OPEN_READONLY,
|
|
||||||
).use { db ->
|
|
||||||
db.rawQuery(
|
|
||||||
"SELECT sequence,bpm,exercise_entry_id FROM heart_rate_samples " +
|
|
||||||
"WHERE capture_id=? ORDER BY sequence",
|
|
||||||
arrayOf(originalCapture.captureId),
|
|
||||||
).use { cursor ->
|
|
||||||
assertTrue(cursor.moveToFirst())
|
|
||||||
assertEquals(0L, cursor.getLong(0))
|
|
||||||
assertEquals(101, cursor.getInt(1))
|
|
||||||
assertTrue(cursor.isNull(2))
|
|
||||||
assertTrue(cursor.moveToNext())
|
|
||||||
assertEquals(1L, cursor.getLong(0))
|
|
||||||
assertEquals(99, cursor.getInt(1))
|
|
||||||
assertEquals(entry.entryId, cursor.getString(2))
|
|
||||||
assertTrue(!cursor.moveToNext())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals(ActiveDraftMutationResult.Saved, repository.discardActiveSessionDraft())
|
|
||||||
assertNull(repository.activeHeartRateCapture())
|
|
||||||
assertNull(repository.activeCardioGuidancePhase())
|
|
||||||
assertTrue(repository.listActiveSessionExerciseTimings().isEmpty())
|
|
||||||
} finally {
|
|
||||||
repository.close()
|
|
||||||
context.deleteDatabase(name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun cardioCaptureCanReturnToTrainingBeforeCardioOnlyFactsExist() {
|
|
||||||
val name = "guidance-early-reverse-" + UUID.randomUUID() + ".db"
|
|
||||||
val repository = TrainlogRepository(context, name)
|
|
||||||
try {
|
|
||||||
assertEquals(ActiveDraftMutationResult.Saved, repository.startActiveSessionDraft())
|
|
||||||
val training =
|
|
||||||
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
|
||||||
val observedAt =
|
|
||||||
OffsetDateTime.parse(checkNotNull(training.startedAt)).plusSeconds(1).toString()
|
|
||||||
assertTrue(
|
|
||||||
repository.recordLiveHeartRateForActiveSession(
|
|
||||||
"Synthetic HR",
|
|
||||||
observedAt,
|
|
||||||
ParsedHeartRateMeasurement(103, null, null, listOf(600)),
|
|
||||||
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
|
||||||
)
|
|
||||||
val captureId = checkNotNull(repository.activeHeartRateCapture()).captureId
|
|
||||||
assertEquals(
|
|
||||||
ActiveDraftMutationResult.Saved,
|
|
||||||
repository.saveActiveSessionDraft(training.copy(sessionType = SessionType.CARDIO)),
|
|
||||||
)
|
|
||||||
val cardio =
|
|
||||||
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
|
||||||
assertEquals(
|
|
||||||
ActiveDraftMutationResult.Saved,
|
|
||||||
repository.saveActiveSessionDraft(cardio.copy(sessionType = SessionType.TRAINING)),
|
|
||||||
)
|
|
||||||
val reversed = checkNotNull(repository.activeHeartRateCapture())
|
|
||||||
assertEquals(captureId, reversed.captureId)
|
|
||||||
assertEquals(HeartRateContextKind.SESSION, reversed.contextKind)
|
|
||||||
|
|
||||||
SQLiteDatabase.openDatabase(
|
|
||||||
context.getDatabasePath(name).path,
|
|
||||||
null,
|
|
||||||
SQLiteDatabase.OPEN_READONLY,
|
|
||||||
).use { db ->
|
|
||||||
db.rawQuery(
|
|
||||||
"SELECT COUNT(*),MIN(bpm) FROM heart_rate_samples WHERE capture_id=?",
|
|
||||||
arrayOf(captureId),
|
|
||||||
).use { cursor ->
|
|
||||||
assertTrue(cursor.moveToFirst())
|
|
||||||
assertEquals(1, cursor.getInt(0))
|
|
||||||
assertEquals(103, cursor.getInt(1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
repository.close()
|
|
||||||
context.deleteDatabase(name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun phaseRequiresActiveCardioExerciseAndRecordsOnlyInstructionChanges() {
|
|
||||||
val active = activeCardio()
|
|
||||||
val repository = active.repository
|
|
||||||
try {
|
|
||||||
val target = CardioTargetSnapshot(120, 140)
|
|
||||||
val phase =
|
|
||||||
CardioGuidedPhase(
|
|
||||||
phaseId = "cgp_" + UUID.randomUUID(),
|
|
||||||
kind = CardioPhaseKind.WORK,
|
|
||||||
target = target,
|
|
||||||
exitCondition = CardioPhaseExitCondition.FixedDuration(180),
|
|
||||||
)
|
|
||||||
val started =
|
|
||||||
repository.startCardioGuidancePhase(
|
|
||||||
active.entryId,
|
|
||||||
phase,
|
|
||||||
active.at(10),
|
|
||||||
) as TrainlogRepository.CardioGuidanceMutationResult.Applied
|
|
||||||
assertEquals(CardioGuidanceInstruction.MAINTAIN, started.phase!!.currentInstruction)
|
|
||||||
|
|
||||||
repository.recordCardioGuidanceOutput(
|
|
||||||
phase.phaseId,
|
|
||||||
active.at(13),
|
|
||||||
CardioGuidanceOutput(
|
|
||||||
CardioGuidanceInstruction.ACCELERATE,
|
|
||||||
110,
|
|
||||||
target,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
repository.recordCardioGuidanceOutput(
|
|
||||||
phase.phaseId,
|
|
||||||
active.at(14),
|
|
||||||
CardioGuidanceOutput(
|
|
||||||
CardioGuidanceInstruction.ACCELERATE,
|
|
||||||
111,
|
|
||||||
target,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
repository.recordCardioGuidanceOutput(
|
|
||||||
phase.phaseId,
|
|
||||||
active.at(30),
|
|
||||||
CardioGuidanceOutput(
|
|
||||||
CardioGuidanceInstruction.MAINTAIN,
|
|
||||||
130,
|
|
||||||
target,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
listOf(
|
|
||||||
CardioGuidanceInstruction.ACCELERATE,
|
|
||||||
CardioGuidanceInstruction.MAINTAIN,
|
|
||||||
),
|
|
||||||
repository.listCardioGuidanceEvents(phase.phaseId).map { it.instruction },
|
|
||||||
)
|
|
||||||
|
|
||||||
repository.finishActiveSessionExercise(
|
|
||||||
active.entryId,
|
|
||||||
active.at(600),
|
|
||||||
)
|
|
||||||
assertNull(repository.activeCardioGuidancePhase())
|
|
||||||
val completedDraft =
|
|
||||||
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
|
||||||
assertEquals(
|
|
||||||
ActiveDraftMutationResult.Saved,
|
|
||||||
repository.saveActiveSessionDraft(
|
|
||||||
completedDraft.copy(
|
|
||||||
exercises =
|
|
||||||
completedDraft.exercises.map {
|
|
||||||
if (it.entryId == active.entryId) {
|
|
||||||
it.copy(continuousDurationSeconds = 600)
|
|
||||||
} else {
|
|
||||||
it
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val finalized = repository.finalizeActiveSessionDraft()
|
|
||||||
assertTrue(finalized is FinalizeActiveDraftResult.Saved)
|
|
||||||
} finally {
|
|
||||||
repository.close()
|
|
||||||
context.deleteDatabase(active.databaseName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun guidanceCannotStartBeforeExerciseTimingIsActive() {
|
|
||||||
val name = "guidance-inactive-" + UUID.randomUUID() + ".db"
|
|
||||||
val repository = TrainlogRepository(context, name)
|
|
||||||
try {
|
|
||||||
val exercise =
|
|
||||||
(repository.createExercise(
|
|
||||||
NewExerciseProfile(
|
|
||||||
"Vélo guidé",
|
|
||||||
RecordingMode.CONTINUOUS,
|
|
||||||
TrackingMode.DURATION,
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
) as CreateExerciseResult.Created).exercise
|
|
||||||
repository.startActiveSessionDraft()
|
|
||||||
val loaded = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
|
|
||||||
val entry = SessionExerciseDraft(exercise = exercise, continuousDurationSeconds = 60)
|
|
||||||
repository.saveActiveSessionDraft(
|
|
||||||
loaded.draft.copy(sessionType = SessionType.CARDIO, exercises = listOf(entry)),
|
|
||||||
)
|
|
||||||
val result =
|
|
||||||
repository.startCardioGuidancePhase(
|
|
||||||
entry.entryId,
|
|
||||||
CardioGuidedPhase(
|
|
||||||
"cgp_" + UUID.randomUUID(),
|
|
||||||
CardioPhaseKind.WARMUP,
|
|
||||||
CardioTargetSnapshot(100, 115),
|
|
||||||
CardioPhaseExitCondition.FixedDuration(60),
|
|
||||||
),
|
|
||||||
"2026-09-23T13:00:00+02:00",
|
|
||||||
)
|
|
||||||
assertTrue(result is TrainlogRepository.CardioGuidanceMutationResult.Invalid)
|
|
||||||
} finally {
|
|
||||||
repository.close()
|
|
||||||
context.deleteDatabase(name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun abandonedDraftDeletesOnlyUnfinishedGuidanceRun() {
|
|
||||||
val active = activeCardio()
|
|
||||||
val repository = active.repository
|
|
||||||
try {
|
|
||||||
repository.startCardioGuidancePhase(
|
|
||||||
active.entryId,
|
|
||||||
CardioGuidedPhase(
|
|
||||||
"cgp_" + UUID.randomUUID(),
|
|
||||||
CardioPhaseKind.WORK,
|
|
||||||
CardioTargetSnapshot(120, 140),
|
|
||||||
CardioPhaseExitCondition.FixedDuration(120),
|
|
||||||
),
|
|
||||||
active.at(10),
|
|
||||||
)
|
|
||||||
assertTrue(repository.activeCardioGuidancePhase() != null)
|
|
||||||
repository.discardActiveSessionDraft()
|
|
||||||
assertNull(repository.activeCardioGuidancePhase())
|
|
||||||
} finally {
|
|
||||||
repository.close()
|
|
||||||
context.deleteDatabase(active.databaseName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
package com.labfytools.trainlog.data
|
|
||||||
|
|
||||||
import com.labfytools.trainlog.model.CardioGuidedPhase
|
|
||||||
import com.labfytools.trainlog.model.CardioPhaseExitCondition
|
|
||||||
import com.labfytools.trainlog.model.CardioPhaseKind
|
|
||||||
import com.labfytools.trainlog.model.CardioTargetSnapshot
|
|
||||||
import org.junit.Assert.assertFalse
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class CardioPhaseCompletionTest {
|
|
||||||
private val target = CardioTargetSnapshot(120, 140)
|
|
||||||
|
|
||||||
private fun phase(
|
|
||||||
exit: CardioPhaseExitCondition,
|
|
||||||
targetOverride: CardioTargetSnapshot? = target,
|
|
||||||
) = CardioGuidedPhase(
|
|
||||||
phaseId = "phase_test",
|
|
||||||
kind = CardioPhaseKind.WORK,
|
|
||||||
target = targetOverride,
|
|
||||||
exitCondition = exit,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun fixedDurationUsesElapsedTimeOnly() {
|
|
||||||
val p = phase(CardioPhaseExitCondition.FixedDuration(180))
|
|
||||||
assertFalse(CardioPhaseCompletion.isComplete(p, 179, null, false))
|
|
||||||
assertTrue(CardioPhaseCompletion.isComplete(p, 180, null, false))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun enterTargetRequiresFreshMeasurementInsideSnapshot() {
|
|
||||||
val p = phase(CardioPhaseExitCondition.EnterTarget)
|
|
||||||
assertFalse(CardioPhaseCompletion.isComplete(p, 10, 130, false))
|
|
||||||
assertFalse(CardioPhaseCompletion.isComplete(p, 10, 119, true))
|
|
||||||
assertTrue(CardioPhaseCompletion.isComplete(p, 10, 120, true))
|
|
||||||
assertTrue(CardioPhaseCompletion.isComplete(p, 10, 140, true))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun enterTargetWithoutTargetNeverCompletes() {
|
|
||||||
val p = phase(CardioPhaseExitCondition.EnterTarget, null)
|
|
||||||
assertFalse(CardioPhaseCompletion.isComplete(p, 100, 130, true))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun recoveryThresholdRequiresFreshMeasurement() {
|
|
||||||
val p = phase(CardioPhaseExitCondition.RecoverBelow(110), null)
|
|
||||||
assertFalse(CardioPhaseCompletion.isComplete(p, 30, 105, false))
|
|
||||||
assertFalse(CardioPhaseCompletion.isComplete(p, 30, 111, true))
|
|
||||||
assertTrue(CardioPhaseCompletion.isComplete(p, 30, 110, true))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun durationOrRecoveryCanFinishFromEitherRealCondition() {
|
|
||||||
val p = phase(CardioPhaseExitCondition.DurationOrRecoverBelow(300, 110), null)
|
|
||||||
assertFalse(CardioPhaseCompletion.isComplete(p, 299, 120, true))
|
|
||||||
assertTrue(CardioPhaseCompletion.isComplete(p, 100, 108, true))
|
|
||||||
assertTrue(CardioPhaseCompletion.isComplete(p, 300, null, false))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun negativeElapsedTimeCannotCompleteAnything() {
|
|
||||||
val p = phase(CardioPhaseExitCondition.FixedDuration(0))
|
|
||||||
assertFalse(CardioPhaseCompletion.isComplete(p, -1, 130, true))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,194 +0,0 @@
|
||||||
package com.labfytools.trainlog.data
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
|
||||||
import com.labfytools.trainlog.model.NewExerciseProfile
|
|
||||||
import com.labfytools.trainlog.model.RecordingMode
|
|
||||||
import com.labfytools.trainlog.model.SessionExerciseDraft
|
|
||||||
import com.labfytools.trainlog.model.SessionSetDraft
|
|
||||||
import com.labfytools.trainlog.model.SessionType
|
|
||||||
import com.labfytools.trainlog.model.TrackingMode
|
|
||||||
import java.nio.file.Files
|
|
||||||
import java.util.UUID
|
|
||||||
import org.json.JSONObject
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.robolectric.RobolectricTestRunner
|
|
||||||
import org.robolectric.annotation.Config
|
|
||||||
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
|
||||||
@Config(sdk = [35])
|
|
||||||
class CardioSessionSyncGenerationTest {
|
|
||||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun dedicatedCardioCompanionImportsWithoutWideningFrozenHistory() {
|
|
||||||
val sourceName = "cardio-sync-" + UUID.randomUUID() + ".db"
|
|
||||||
val root = Files.createTempDirectory("trainlog-cardio-sync-").toFile()
|
|
||||||
val source = TrainlogRepository(context, sourceName)
|
|
||||||
try {
|
|
||||||
val exercise =
|
|
||||||
(source.createExercise(
|
|
||||||
NewExerciseProfile(
|
|
||||||
"Cardio sync exercise",
|
|
||||||
RecordingMode.SETS,
|
|
||||||
TrackingMode.REPS,
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
) as CreateExerciseResult.Created).exercise
|
|
||||||
val entry =
|
|
||||||
SessionExerciseDraft(
|
|
||||||
exercise = exercise,
|
|
||||||
sets = listOf(SessionSetDraft(reps = 15)),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
ActiveDraftMutationResult.Saved,
|
|
||||||
source.saveActiveSessionDraft(
|
|
||||||
ActiveSessionDraft(
|
|
||||||
sessionType = SessionType.CARDIO,
|
|
||||||
exercises = listOf(entry),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assertTrue(
|
|
||||||
source.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved,
|
|
||||||
)
|
|
||||||
val sessionId = source.listSessions().single().sessionId
|
|
||||||
|
|
||||||
val repositoryRoot =
|
|
||||||
generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))) {
|
|
||||||
it.parentFile
|
|
||||||
}.first { java.io.File(it, "tools/sync_generation_exchange.py").isFile }
|
|
||||||
|
|
||||||
fun run(vararg command: String): String {
|
|
||||||
val process =
|
|
||||||
ProcessBuilder(*command)
|
|
||||||
.directory(repositoryRoot)
|
|
||||||
.redirectErrorStream(true)
|
|
||||||
.start()
|
|
||||||
val output = process.inputStream.bufferedReader().readText()
|
|
||||||
assertEquals(
|
|
||||||
"command failed: " + command.joinToString(" ") + "\n" + output,
|
|
||||||
0,
|
|
||||||
process.waitFor(),
|
|
||||||
)
|
|
||||||
return output
|
|
||||||
}
|
|
||||||
|
|
||||||
val desktopDb = java.io.File(root, "desktop.sqlite")
|
|
||||||
run(
|
|
||||||
java.io.File(
|
|
||||||
repositoryRoot,
|
|
||||||
"build/tui/sync-generation-fixture",
|
|
||||||
).absolutePath,
|
|
||||||
desktopDb.absolutePath,
|
|
||||||
)
|
|
||||||
val desktopPeer =
|
|
||||||
Regex("PEER_ID=(peer_[^\\s]+)")
|
|
||||||
.find(
|
|
||||||
run(
|
|
||||||
"python3",
|
|
||||||
java.io.File(
|
|
||||||
repositoryRoot,
|
|
||||||
"tools/sync_generation_exchange.py",
|
|
||||||
).absolutePath,
|
|
||||||
"peer-id",
|
|
||||||
"--database",
|
|
||||||
desktopDb.absolutePath,
|
|
||||||
),
|
|
||||||
)!!
|
|
||||||
.groupValues[1]
|
|
||||||
|
|
||||||
val service = SyncGenerationService(source)
|
|
||||||
val captured = service.capture(root, desktopPeer)
|
|
||||||
val history =
|
|
||||||
JSONObject(
|
|
||||||
java.io.File(
|
|
||||||
captured.stagingDirectory,
|
|
||||||
"history-v4.json",
|
|
||||||
).readText(),
|
|
||||||
)
|
|
||||||
assertEquals(0, history.getJSONArray("sessions").length())
|
|
||||||
|
|
||||||
val cardio =
|
|
||||||
JSONObject(
|
|
||||||
java.io.File(
|
|
||||||
captured.stagingDirectory,
|
|
||||||
"cardio-sessions-v1.json",
|
|
||||||
).readText(),
|
|
||||||
)
|
|
||||||
assertEquals("trainlog-cardio-sessions", cardio.getString("format"))
|
|
||||||
assertEquals(1, cardio.getJSONArray("sessions").length())
|
|
||||||
assertEquals(
|
|
||||||
"cardio",
|
|
||||||
cardio
|
|
||||||
.getJSONArray("sessions")
|
|
||||||
.getJSONObject(0)
|
|
||||||
.getString("session_type"),
|
|
||||||
)
|
|
||||||
|
|
||||||
val published = service.publish(captured, java.io.File(root, "objects"))
|
|
||||||
val ackFile = java.io.File(root, "ack.json")
|
|
||||||
run(
|
|
||||||
"python3",
|
|
||||||
java.io.File(
|
|
||||||
repositoryRoot,
|
|
||||||
"tools/sync_generation_exchange.py",
|
|
||||||
).absolutePath,
|
|
||||||
"consume-desktop",
|
|
||||||
published.absolutePath,
|
|
||||||
"--database",
|
|
||||||
desktopDb.absolutePath,
|
|
||||||
"--ack-output",
|
|
||||||
ackFile.absolutePath,
|
|
||||||
)
|
|
||||||
val ack = JSONObject(ackFile.readText())
|
|
||||||
assertEquals(
|
|
||||||
"desktop ACK diagnostic: " + ack.getString("diagnostic"),
|
|
||||||
"consumed",
|
|
||||||
ack.getString("result"),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
"training|cardio|15",
|
|
||||||
run(
|
|
||||||
"sqlite3",
|
|
||||||
desktopDb.absolutePath,
|
|
||||||
"SELECT s.session_type || '|' || s.session_kind || '|' || ps.reps " +
|
|
||||||
"FROM sessions s JOIN session_exercises se ON se.session_row_id=s.id " +
|
|
||||||
"JOIN performed_sets ps ON ps.session_exercise_row_id=se.id " +
|
|
||||||
"WHERE s.session_id='$sessionId';",
|
|
||||||
).trim(),
|
|
||||||
)
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
"acknowledged",
|
|
||||||
service.acceptAcknowledgement(ackFile.readBytes()),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
ack.toString(),
|
|
||||||
JSONObject(
|
|
||||||
run(
|
|
||||||
"python3",
|
|
||||||
java.io.File(
|
|
||||||
repositoryRoot,
|
|
||||||
"tools/sync_generation_exchange.py",
|
|
||||||
).absolutePath,
|
|
||||||
"consume-desktop",
|
|
||||||
published.absolutePath,
|
|
||||||
"--database",
|
|
||||||
desktopDb.absolutePath,
|
|
||||||
"--ack-output",
|
|
||||||
java.io.File(root, "replay-ack.json").absolutePath,
|
|
||||||
).let { java.io.File(root, "replay-ack.json").readText() },
|
|
||||||
).toString(),
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
source.close()
|
|
||||||
context.deleteDatabase(sourceName)
|
|
||||||
root.deleteRecursively()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
package com.labfytools.trainlog.data
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
|
||||||
import com.labfytools.trainlog.model.NewExerciseProfile
|
|
||||||
import com.labfytools.trainlog.model.RecordingMode
|
|
||||||
import com.labfytools.trainlog.model.SessionExerciseDraft
|
|
||||||
import com.labfytools.trainlog.model.SessionSetDraft
|
|
||||||
import com.labfytools.trainlog.model.SessionType
|
|
||||||
import com.labfytools.trainlog.model.TrackingMode
|
|
||||||
import java.util.UUID
|
|
||||||
import org.json.JSONObject
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.robolectric.RobolectricTestRunner
|
|
||||||
import org.robolectric.annotation.Config
|
|
||||||
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
|
||||||
@Config(sdk = [35])
|
|
||||||
class CardioSessionTypeTest {
|
|
||||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun cardioIsCanonicalLocallyAndExcludedFromFrozenMobileHistory() {
|
|
||||||
val name = "cardio-kind-" + UUID.randomUUID() + ".db"
|
|
||||||
val repository = TrainlogRepository(context, name)
|
|
||||||
try {
|
|
||||||
val exercise =
|
|
||||||
(repository.createExercise(
|
|
||||||
NewExerciseProfile(
|
|
||||||
"Cardio kind exercise",
|
|
||||||
RecordingMode.SETS,
|
|
||||||
TrackingMode.REPS,
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
) as CreateExerciseResult.Created).exercise
|
|
||||||
val occurrence =
|
|
||||||
SessionExerciseDraft(
|
|
||||||
exercise = exercise,
|
|
||||||
sets = listOf(SessionSetDraft(reps = 1)),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
ActiveDraftMutationResult.Saved,
|
|
||||||
repository.saveActiveSessionDraft(
|
|
||||||
ActiveSessionDraft(
|
|
||||||
sessionType = SessionType.CARDIO,
|
|
||||||
exercises = listOf(occurrence),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val loaded =
|
|
||||||
repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
|
|
||||||
assertEquals(SessionType.CARDIO, loaded.draft.sessionType)
|
|
||||||
|
|
||||||
val db = context.openOrCreateDatabase(name, Context.MODE_PRIVATE, null)
|
|
||||||
db.rawQuery(
|
|
||||||
"SELECT session_type,session_kind FROM active_session_draft WHERE id=1",
|
|
||||||
null,
|
|
||||||
).use { cursor ->
|
|
||||||
assertTrue(cursor.moveToFirst())
|
|
||||||
assertEquals("training", cursor.getString(0))
|
|
||||||
assertEquals("cardio", cursor.getString(1))
|
|
||||||
}
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
assertTrue(
|
|
||||||
repository.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved,
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
SessionType.CARDIO,
|
|
||||||
repository.listSessions().single().sessionType,
|
|
||||||
)
|
|
||||||
|
|
||||||
val frozen = JSONObject(repository.buildMobileExportV4Json())
|
|
||||||
assertEquals(0, frozen.getJSONArray("sessions").length())
|
|
||||||
|
|
||||||
val verify = context.openOrCreateDatabase(name, Context.MODE_PRIVATE, null)
|
|
||||||
verify.rawQuery(
|
|
||||||
"SELECT session_type,session_kind FROM sessions",
|
|
||||||
null,
|
|
||||||
).use { cursor ->
|
|
||||||
assertTrue(cursor.moveToFirst())
|
|
||||||
assertEquals("training", cursor.getString(0))
|
|
||||||
assertEquals("cardio", cursor.getString(1))
|
|
||||||
}
|
|
||||||
verify.close()
|
|
||||||
} finally {
|
|
||||||
repository.close()
|
|
||||||
context.deleteDatabase(name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
package com.labfytools.trainlog.data
|
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class HeartRateLiveStateTest {
|
|
||||||
@Test
|
|
||||||
fun liveStateDropsStaleBpmWithoutInventingMeasurement() {
|
|
||||||
HeartRateLiveState.disconnected()
|
|
||||||
val observed = mutableListOf<HeartRateLiveSnapshot>()
|
|
||||||
val subscription = HeartRateLiveState.subscribe { observed += it }
|
|
||||||
try {
|
|
||||||
HeartRateLiveState.connecting()
|
|
||||||
HeartRateLiveState.measurement(
|
|
||||||
ParsedHeartRateMeasurement(
|
|
||||||
bpm = 82,
|
|
||||||
sensorContactDetected = null,
|
|
||||||
energyExpended = null,
|
|
||||||
rrIntervals1024 = listOf(768),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assertEquals(HeartRateLivePhase.CONNECTED, HeartRateLiveState.current().phase)
|
|
||||||
assertEquals(82, HeartRateLiveState.current().bpm)
|
|
||||||
assertEquals(listOf(768), HeartRateLiveState.current().rrIntervals1024)
|
|
||||||
assertEquals(1L, HeartRateLiveState.current().notificationCount)
|
|
||||||
|
|
||||||
HeartRateLiveState.stale()
|
|
||||||
assertEquals(HeartRateLivePhase.STALE, HeartRateLiveState.current().phase)
|
|
||||||
assertNull(HeartRateLiveState.current().bpm)
|
|
||||||
assertEquals(listOf(768), HeartRateLiveState.current().rrIntervals1024)
|
|
||||||
|
|
||||||
HeartRateLiveState.disconnected()
|
|
||||||
assertEquals(HeartRateLivePhase.DISCONNECTED, HeartRateLiveState.current().phase)
|
|
||||||
assertNull(HeartRateLiveState.current().bpm)
|
|
||||||
assertEquals(5, observed.size)
|
|
||||||
} finally {
|
|
||||||
subscription.close()
|
|
||||||
HeartRateLiveState.disconnected()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
package com.labfytools.trainlog.data
|
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class HeartRateMeasurementParserTest {
|
|
||||||
private fun parsed(vararg bytes: Int): ParsedHeartRateMeasurement {
|
|
||||||
val result =
|
|
||||||
HeartRateMeasurementParser.parse(
|
|
||||||
ByteArray(bytes.size) { index -> bytes[index].toByte() },
|
|
||||||
)
|
|
||||||
return (result as HeartRateMeasurementParseResult.Parsed).measurement
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun invalid(vararg bytes: Int): HeartRateMeasurementParseResult.Invalid =
|
|
||||||
HeartRateMeasurementParser.parse(
|
|
||||||
ByteArray(bytes.size) { index -> bytes[index].toByte() },
|
|
||||||
) as HeartRateMeasurementParseResult.Invalid
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parsesEightBitBpm() {
|
|
||||||
val value = parsed(0x00, 72)
|
|
||||||
assertEquals(72, value.bpm)
|
|
||||||
assertNull(value.sensorContactDetected)
|
|
||||||
assertNull(value.energyExpended)
|
|
||||||
assertTrue(value.rrIntervals1024.isEmpty())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parsesSixteenBitBpmLittleEndian() {
|
|
||||||
assertEquals(300, parsed(0x01, 0x2c, 0x01).bpm)
|
|
||||||
assertEquals(65535, parsed(0x01, 0xff, 0xff).bpm)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun contactIsNullableAndOnlyMeaningfulWhenSupported() {
|
|
||||||
assertNull(parsed(0x02, 80).sensorContactDetected)
|
|
||||||
assertEquals(false, parsed(0x04, 80).sensorContactDetected)
|
|
||||||
assertEquals(true, parsed(0x06, 80).sensorContactDetected)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parsesEnergyExpended() {
|
|
||||||
val value = parsed(0x08, 90, 0x34, 0x12)
|
|
||||||
assertEquals(0x1234, value.energyExpended)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parsesOneAndMultipleRawRrIntervals() {
|
|
||||||
assertEquals(listOf(1024), parsed(0x10, 60, 0x00, 0x04).rrIntervals1024)
|
|
||||||
assertEquals(
|
|
||||||
listOf(1024, 1000, 65535),
|
|
||||||
parsed(0x10, 60, 0x00, 0x04, 0xe8, 0x03, 0xff, 0xff).rrIntervals1024,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parsesCombinedKnownFlagsInWireOrder() {
|
|
||||||
val value =
|
|
||||||
parsed(
|
|
||||||
0x1f,
|
|
||||||
0x96,
|
|
||||||
0x00,
|
|
||||||
0x07,
|
|
||||||
0x00,
|
|
||||||
0xf0,
|
|
||||||
0x03,
|
|
||||||
0x00,
|
|
||||||
0x04,
|
|
||||||
)
|
|
||||||
assertEquals(150, value.bpm)
|
|
||||||
assertEquals(true, value.sensorContactDetected)
|
|
||||||
assertEquals(7, value.energyExpended)
|
|
||||||
assertEquals(listOf(1008, 1024), value.rrIntervals1024)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun rejectsTruncatedAndIncoherentPayloads() {
|
|
||||||
assertEquals("missing flags", invalid().reason)
|
|
||||||
assertEquals("truncated 16-bit BPM", invalid(0x01, 0x2c).reason)
|
|
||||||
assertEquals("truncated energy-expended value", invalid(0x08, 70, 0x01).reason)
|
|
||||||
assertEquals("RR flag has no interval", invalid(0x10, 70).reason)
|
|
||||||
assertEquals("truncated RR interval", invalid(0x10, 70, 0x01).reason)
|
|
||||||
assertEquals("unexpected trailing bytes", invalid(0x00, 70, 0x01).reason)
|
|
||||||
assertEquals("reserved flags are set", invalid(0x20, 70).reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun preservesBoundaryBpmValues() {
|
|
||||||
assertEquals(0, parsed(0x00, 0).bpm)
|
|
||||||
assertEquals(255, parsed(0x00, 255).bpm)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
package com.labfytools.trainlog.data
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import com.labfytools.trainlog.model.HeartRateContextKind
|
|
||||||
import com.labfytools.trainlog.model.HeartRateRrInterval
|
|
||||||
import com.labfytools.trainlog.model.SleepDiaryDraft
|
|
||||||
import java.util.UUID
|
|
||||||
import org.json.JSONObject
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.robolectric.RobolectricTestRunner
|
|
||||||
import org.robolectric.annotation.Config
|
|
||||||
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
|
||||||
@Config(sdk = [35])
|
|
||||||
class HeartRateRepositoryTest {
|
|
||||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun sleepCaptureStoresRawSamplesAndExportsOnlyAfterStop() {
|
|
||||||
val name = "heart-rate-${UUID.randomUUID()}.db"
|
|
||||||
val repository = TrainlogRepository(context, name)
|
|
||||||
try {
|
|
||||||
val created =
|
|
||||||
repository.saveSleepDiary(
|
|
||||||
SleepDiaryDraft(
|
|
||||||
nightStartDate = "2026-09-22",
|
|
||||||
nightEndDate = "2026-09-23",
|
|
||||||
createdAt = "2026-09-22T22:00:00+02:00",
|
|
||||||
updatedAt = "2026-09-22T22:00:00+02:00",
|
|
||||||
sleepQuality = null,
|
|
||||||
wakeQuality = null,
|
|
||||||
dayForm = null,
|
|
||||||
treatmentAndNotes = "",
|
|
||||||
events = emptyList(),
|
|
||||||
),
|
|
||||||
) as TrainlogRepository.SaveSleepDiaryResult.Saved
|
|
||||||
|
|
||||||
val started =
|
|
||||||
repository.startHeartRateCapture(
|
|
||||||
HeartRateContextKind.SLEEP,
|
|
||||||
created.entryId,
|
|
||||||
"2026-09-22T22:30:00+02:00",
|
|
||||||
"Synthetic HR sensor",
|
|
||||||
) as TrainlogRepository.StartHeartRateCaptureResult.Started
|
|
||||||
assertEquals(started.captureId, repository.activeHeartRateCapture()?.captureId)
|
|
||||||
|
|
||||||
val sample =
|
|
||||||
repository.appendHeartRateSample(
|
|
||||||
captureId = started.captureId,
|
|
||||||
observedAt = "2026-09-22T22:30:01+02:00",
|
|
||||||
bpm = 61,
|
|
||||||
sensorContactDetected = true,
|
|
||||||
energyExpended = 7,
|
|
||||||
rrIntervals = listOf(
|
|
||||||
HeartRateRrInterval(0, 1024),
|
|
||||||
HeartRateRrInterval(1, 1000),
|
|
||||||
),
|
|
||||||
) as TrainlogRepository.HeartRateMutationResult.Applied
|
|
||||||
assertEquals(0L, sample.sequence)
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
0,
|
|
||||||
JSONObject(repository.buildHeartRateV1Json()).getJSONArray("captures").length(),
|
|
||||||
)
|
|
||||||
assertTrue(
|
|
||||||
repository.stopHeartRateCapture(
|
|
||||||
started.captureId,
|
|
||||||
"2026-09-23T06:30:00+02:00",
|
|
||||||
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
|
||||||
)
|
|
||||||
assertNull(repository.activeHeartRateCapture())
|
|
||||||
|
|
||||||
val root = JSONObject(repository.buildHeartRateV1Json())
|
|
||||||
assertEquals("trainlog-heart-rate", root.getString("format"))
|
|
||||||
assertEquals(1, root.getInt("version"))
|
|
||||||
val capture = root.getJSONArray("captures").getJSONObject(0)
|
|
||||||
assertEquals(created.entryId, capture.getString("context_id"))
|
|
||||||
assertEquals("sleep", capture.getString("context_kind"))
|
|
||||||
assertEquals("Synthetic HR sensor", capture.getString("sensor_name"))
|
|
||||||
val stored = capture.getJSONArray("samples").getJSONObject(0)
|
|
||||||
assertEquals(61, stored.getInt("bpm"))
|
|
||||||
assertTrue(stored.getBoolean("sensor_contact_detected"))
|
|
||||||
assertEquals(7, stored.getInt("energy_expended"))
|
|
||||||
assertEquals(1024, stored.getJSONArray("rr_intervals_1024").getInt(0))
|
|
||||||
assertEquals(1000, stored.getJSONArray("rr_intervals_1024").getInt(1))
|
|
||||||
} finally {
|
|
||||||
repository.close()
|
|
||||||
context.deleteDatabase(name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
package com.labfytools.trainlog.data
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.robolectric.RobolectricTestRunner
|
|
||||||
import org.robolectric.annotation.Config
|
|
||||||
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
|
||||||
@Config(sdk = [35])
|
|
||||||
class HeartRateSensorPreferencesTest {
|
|
||||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun selectionIsDurableAndClearable() {
|
|
||||||
val preferences = HeartRateSensorPreferences(context)
|
|
||||||
preferences.clear()
|
|
||||||
assertNull(preferences.selected())
|
|
||||||
|
|
||||||
preferences.select("AA:BB:CC:DD:EE:FF", "Synthetic HR")
|
|
||||||
assertEquals(
|
|
||||||
SelectedHeartRateSensor("AA:BB:CC:DD:EE:FF", "Synthetic HR"),
|
|
||||||
HeartRateSensorPreferences(context).selected(),
|
|
||||||
)
|
|
||||||
|
|
||||||
preferences.clear()
|
|
||||||
assertNull(HeartRateSensorPreferences(context).selected())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,490 +0,0 @@
|
||||||
package com.labfytools.trainlog.data
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.database.sqlite.SQLiteDatabase
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import com.labfytools.trainlog.model.HeartRateContextKind
|
|
||||||
import com.labfytools.trainlog.model.SessionSetDraft
|
|
||||||
import java.nio.file.Files
|
|
||||||
import java.time.OffsetDateTime
|
|
||||||
import java.util.UUID
|
|
||||||
import org.json.JSONArray
|
|
||||||
import org.json.JSONObject
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.robolectric.RobolectricTestRunner
|
|
||||||
import org.robolectric.annotation.Config
|
|
||||||
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
|
||||||
@Config(sdk = [35])
|
|
||||||
class ProgramSessionCardioReadinessTest {
|
|
||||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun programSessionPreservesTimelineAndHeartRateOwnershipThroughDesktopReplay() {
|
|
||||||
val androidName = "program-cardio-readiness-${UUID.randomUUID()}.db"
|
|
||||||
val android = TrainlogRepository(context, androidName)
|
|
||||||
val root = Files.createTempDirectory("trainlog-program-cardio-readiness-").toFile()
|
|
||||||
val repositoryRoot =
|
|
||||||
generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))) {
|
|
||||||
it.parentFile
|
|
||||||
}.first { java.io.File(it, "tools/sync_generation_exchange.py").isFile }
|
|
||||||
val desktopDb = java.io.File(root, "desktop.sqlite")
|
|
||||||
val fixture = java.io.File(repositoryRoot, "build/tui/sync-generation-fixture")
|
|
||||||
val generationTool =
|
|
||||||
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath
|
|
||||||
|
|
||||||
fun run(vararg command: String): String {
|
|
||||||
val process =
|
|
||||||
ProcessBuilder(*command)
|
|
||||||
.directory(repositoryRoot)
|
|
||||||
.redirectErrorStream(true)
|
|
||||||
.start()
|
|
||||||
val output = process.inputStream.bufferedReader().readText()
|
|
||||||
assertEquals(
|
|
||||||
"command failed: ${command.joinToString(" ")}\n$output",
|
|
||||||
0,
|
|
||||||
process.waitFor(),
|
|
||||||
)
|
|
||||||
return output
|
|
||||||
}
|
|
||||||
|
|
||||||
fun measurement(bpm: Int, rr: Int) =
|
|
||||||
ParsedHeartRateMeasurement(
|
|
||||||
bpm = bpm,
|
|
||||||
sensorContactDetected = true,
|
|
||||||
energyExpended = null,
|
|
||||||
rrIntervals1024 = listOf(rr),
|
|
||||||
)
|
|
||||||
|
|
||||||
try {
|
|
||||||
val programId = "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
|
||||||
val programSessionId = "pgs_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
|
|
||||||
val programExerciseAId = "ex_dddddddd-dddd-4ddd-8ddd-dddddddddddd"
|
|
||||||
val programExerciseBId = "ex_11111111-1111-4111-8111-111111111111"
|
|
||||||
val catalog =
|
|
||||||
JSONObject()
|
|
||||||
.put("format", "trainlog-pc-catalog")
|
|
||||||
.put("version", 1)
|
|
||||||
.put(
|
|
||||||
"exercises",
|
|
||||||
JSONArray()
|
|
||||||
.put(
|
|
||||||
JSONObject()
|
|
||||||
.put("exercise_id", programExerciseAId)
|
|
||||||
.put("name", "Program exercise fixture A")
|
|
||||||
.put("recording_mode", "sets")
|
|
||||||
.put("tracking_mode", "reps")
|
|
||||||
.put("data_fields", 0),
|
|
||||||
)
|
|
||||||
.put(
|
|
||||||
JSONObject()
|
|
||||||
.put("exercise_id", programExerciseBId)
|
|
||||||
.put("name", "Program exercise fixture B")
|
|
||||||
.put("recording_mode", "sets")
|
|
||||||
.put("tracking_mode", "reps")
|
|
||||||
.put("data_fields", 0),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toString()
|
|
||||||
assertTrue(android.applyPcCatalogJson(catalog) is PcCatalogImportResult.Applied)
|
|
||||||
val program =
|
|
||||||
JSONObject()
|
|
||||||
.put("program_id", programId)
|
|
||||||
.put("revision_id", "pgr_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
|
|
||||||
.put("title", "Tomorrow readiness")
|
|
||||||
.put("note", JSONObject.NULL)
|
|
||||||
.put("state", "active")
|
|
||||||
.put("start_date", "2026-09-24")
|
|
||||||
.put("end_date", JSONObject.NULL)
|
|
||||||
.put("created_at", "2026-09-23T16:00:00Z")
|
|
||||||
.put("updated_at", "2026-09-23T16:00:00Z")
|
|
||||||
.put("source_format", "trainlog-program")
|
|
||||||
.put("source_version", 1)
|
|
||||||
.put("source_payload_sha256", "a".repeat(64))
|
|
||||||
.put(
|
|
||||||
"sessions",
|
|
||||||
JSONArray().put(
|
|
||||||
JSONObject()
|
|
||||||
.put("program_session_id", programSessionId)
|
|
||||||
.put("title", "Session A")
|
|
||||||
.put("session_type", "training")
|
|
||||||
.put("planned_for", "2026-09-24")
|
|
||||||
.put("note", JSONObject.NULL)
|
|
||||||
.put(
|
|
||||||
"occurrences",
|
|
||||||
JSONArray()
|
|
||||||
.put(
|
|
||||||
JSONObject()
|
|
||||||
.put(
|
|
||||||
"entry_id",
|
|
||||||
"pge_cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
|
||||||
)
|
|
||||||
.put("exercise_id", programExerciseAId)
|
|
||||||
.put("equipment_id", JSONObject.NULL)
|
|
||||||
.put("load_mode", "external")
|
|
||||||
.put("rest_seconds", 90)
|
|
||||||
.put("target_sets", 1)
|
|
||||||
.put("target_reps", 8)
|
|
||||||
.put("target_duration_seconds", JSONObject.NULL)
|
|
||||||
.put("target_weight_kg", 40.0)
|
|
||||||
.put("notes", JSONObject.NULL),
|
|
||||||
)
|
|
||||||
.put(
|
|
||||||
JSONObject()
|
|
||||||
.put(
|
|
||||||
"entry_id",
|
|
||||||
"pge_22222222-2222-4222-8222-222222222222",
|
|
||||||
)
|
|
||||||
.put("exercise_id", programExerciseBId)
|
|
||||||
.put("equipment_id", JSONObject.NULL)
|
|
||||||
.put("load_mode", "external")
|
|
||||||
.put("rest_seconds", 60)
|
|
||||||
.put("target_sets", 1)
|
|
||||||
.put("target_reps", 10)
|
|
||||||
.put("target_duration_seconds", JSONObject.NULL)
|
|
||||||
.put("target_weight_kg", 25.0)
|
|
||||||
.put("notes", JSONObject.NULL),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val programs =
|
|
||||||
JSONObject()
|
|
||||||
.put("format", "trainlog-programs")
|
|
||||||
.put("version", 1)
|
|
||||||
.put("generated_at", "2026-09-23T16:01:00Z")
|
|
||||||
.put("programs", JSONArray().put(program))
|
|
||||||
.put("deletions", JSONArray())
|
|
||||||
.toString()
|
|
||||||
assertEquals(
|
|
||||||
ProgramsImportResult.Applied(1, 0, 0),
|
|
||||||
android.applyProgramsV1Json(programs),
|
|
||||||
)
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
StartProgramSessionResult.Started,
|
|
||||||
android.startSyncedProgramSession(programId, programSessionId),
|
|
||||||
)
|
|
||||||
val opened = android.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
|
|
||||||
val sessionId = checkNotNull(opened.draft.sessionId)
|
|
||||||
val sessionStartedAt = checkNotNull(opened.draft.startedAt)
|
|
||||||
assertEquals(programId, opened.draft.sourceProgramId)
|
|
||||||
assertEquals(programSessionId, opened.draft.sourceProgramSessionId)
|
|
||||||
assertEquals(2, opened.draft.exercises.size)
|
|
||||||
val exerciseA = opened.draft.exercises[0]
|
|
||||||
val exerciseBEntry = opened.draft.exercises[1]
|
|
||||||
assertEquals(programExerciseAId, exerciseA.exercise.exerciseId)
|
|
||||||
assertEquals(programExerciseBId, exerciseBEntry.exercise.exerciseId)
|
|
||||||
assertTrue(exerciseA.entryId != exerciseBEntry.entryId)
|
|
||||||
assertEquals(
|
|
||||||
ActiveDraftMutationResult.Saved,
|
|
||||||
android.saveActiveSessionDraft(
|
|
||||||
opened.draft.copy(
|
|
||||||
exercises =
|
|
||||||
listOf(
|
|
||||||
exerciseA.copy(
|
|
||||||
sets = listOf(SessionSetDraft(reps = 8, weightKg = 40.0)),
|
|
||||||
),
|
|
||||||
exerciseBEntry.copy(
|
|
||||||
sets = listOf(SessionSetDraft(reps = 10, weightKg = 25.0)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val edited = android.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
|
|
||||||
assertEquals(sessionId, edited.draft.sessionId)
|
|
||||||
assertEquals(sessionStartedAt, edited.draft.startedAt)
|
|
||||||
assertEquals(programId, edited.draft.sourceProgramId)
|
|
||||||
assertEquals(programSessionId, edited.draft.sourceProgramSessionId)
|
|
||||||
|
|
||||||
val preExerciseAt = OffsetDateTime.now().toString()
|
|
||||||
assertTrue(
|
|
||||||
android.recordLiveHeartRateForActiveSession(
|
|
||||||
"Synthetic CYCPLUS",
|
|
||||||
preExerciseAt,
|
|
||||||
measurement(91, 675),
|
|
||||||
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
|
||||||
)
|
|
||||||
val capture = checkNotNull(android.activeHeartRateCapture())
|
|
||||||
assertEquals(HeartRateContextKind.SESSION, capture.contextKind)
|
|
||||||
assertEquals(sessionId, capture.contextId)
|
|
||||||
assertEquals(sessionStartedAt, capture.startedAt)
|
|
||||||
|
|
||||||
val exerciseAStartedAt = OffsetDateTime.now().toString()
|
|
||||||
assertTrue(
|
|
||||||
android.startActiveSessionExercise(exerciseA.entryId, exerciseAStartedAt) is
|
|
||||||
TrainlogRepository.SessionExerciseTimingResult.Started,
|
|
||||||
)
|
|
||||||
val exerciseASampleAt = OffsetDateTime.now().toString()
|
|
||||||
assertTrue(
|
|
||||||
android.recordLiveHeartRateForActiveSession(
|
|
||||||
"Synthetic CYCPLUS",
|
|
||||||
exerciseASampleAt,
|
|
||||||
measurement(112, 548),
|
|
||||||
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
|
||||||
)
|
|
||||||
val exerciseAEndedAt = OffsetDateTime.now().toString()
|
|
||||||
assertTrue(
|
|
||||||
android.finishActiveSessionExercise(exerciseA.entryId, exerciseAEndedAt) is
|
|
||||||
TrainlogRepository.SessionExerciseTimingResult.Finished,
|
|
||||||
)
|
|
||||||
|
|
||||||
val betweenExercisesAt = OffsetDateTime.now().toString()
|
|
||||||
assertTrue(
|
|
||||||
android.recordLiveHeartRateForActiveSession(
|
|
||||||
"Synthetic CYCPLUS",
|
|
||||||
betweenExercisesAt,
|
|
||||||
measurement(98, 620),
|
|
||||||
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
|
||||||
)
|
|
||||||
val exerciseBStartedAt = OffsetDateTime.now().toString()
|
|
||||||
assertTrue(
|
|
||||||
android.startActiveSessionExercise(
|
|
||||||
exerciseBEntry.entryId,
|
|
||||||
exerciseBStartedAt,
|
|
||||||
) is TrainlogRepository.SessionExerciseTimingResult.Started,
|
|
||||||
)
|
|
||||||
val exerciseBSampleAt = OffsetDateTime.now().toString()
|
|
||||||
assertTrue(
|
|
||||||
android.recordLiveHeartRateForActiveSession(
|
|
||||||
"Synthetic CYCPLUS",
|
|
||||||
exerciseBSampleAt,
|
|
||||||
measurement(119, 525),
|
|
||||||
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
|
||||||
)
|
|
||||||
val activeFinalize = android.finalizeActiveSessionDraft()
|
|
||||||
assertTrue(activeFinalize is FinalizeActiveDraftResult.Invalid)
|
|
||||||
assertTrue(
|
|
||||||
(activeFinalize as FinalizeActiveDraftResult.Invalid)
|
|
||||||
.message
|
|
||||||
.startsWith("ACTIVE_EXERCISE:"),
|
|
||||||
)
|
|
||||||
val exerciseBEndedAt = OffsetDateTime.now().toString()
|
|
||||||
assertTrue(
|
|
||||||
android.finishActiveSessionExercise(
|
|
||||||
exerciseBEntry.entryId,
|
|
||||||
exerciseBEndedAt,
|
|
||||||
) is TrainlogRepository.SessionExerciseTimingResult.Finished,
|
|
||||||
)
|
|
||||||
val finalized = android.finalizeActiveSessionDraft()
|
|
||||||
assertTrue(finalized is FinalizeActiveDraftResult.Saved)
|
|
||||||
assertEquals(sessionId, (finalized as FinalizeActiveDraftResult.Saved).sessionId)
|
|
||||||
assertNull(android.activeHeartRateCapture())
|
|
||||||
|
|
||||||
val timeline = JSONObject(android.buildSessionTimelineV1Json())
|
|
||||||
val timelineSession = timeline.getJSONArray("sessions").getJSONObject(0)
|
|
||||||
val sessionEndedAt = timelineSession.getString("ended_at")
|
|
||||||
assertEquals(sessionId, timelineSession.getString("session_id"))
|
|
||||||
assertEquals(sessionStartedAt, timelineSession.getString("started_at"))
|
|
||||||
assertTrue(OffsetDateTime.parse(sessionEndedAt) > OffsetDateTime.parse(sessionStartedAt))
|
|
||||||
val timelineExercises = timelineSession.getJSONArray("exercises")
|
|
||||||
assertEquals(2, timelineExercises.length())
|
|
||||||
assertEquals(exerciseA.entryId, timelineExercises.getJSONObject(0).getString("entry_id"))
|
|
||||||
assertEquals(exerciseAStartedAt, timelineExercises.getJSONObject(0).getString("started_at"))
|
|
||||||
assertEquals(exerciseAEndedAt, timelineExercises.getJSONObject(0).getString("ended_at"))
|
|
||||||
assertEquals(
|
|
||||||
exerciseBEntry.entryId,
|
|
||||||
timelineExercises.getJSONObject(1).getString("entry_id"),
|
|
||||||
)
|
|
||||||
assertEquals(exerciseBStartedAt, timelineExercises.getJSONObject(1).getString("started_at"))
|
|
||||||
assertEquals(exerciseBEndedAt, timelineExercises.getJSONObject(1).getString("ended_at"))
|
|
||||||
|
|
||||||
val heart = JSONObject(android.buildHeartRateV1Json())
|
|
||||||
val heartCapture = heart.getJSONArray("captures").getJSONObject(0)
|
|
||||||
assertEquals(capture.captureId, heartCapture.getString("capture_id"))
|
|
||||||
assertEquals("session", heartCapture.getString("context_kind"))
|
|
||||||
assertEquals(sessionId, heartCapture.getString("context_id"))
|
|
||||||
assertEquals(sessionStartedAt, heartCapture.getString("started_at"))
|
|
||||||
assertEquals(sessionEndedAt, heartCapture.getString("ended_at"))
|
|
||||||
val samples = heartCapture.getJSONArray("samples")
|
|
||||||
assertEquals(4, samples.length())
|
|
||||||
val expectedOwnership =
|
|
||||||
listOf(null, exerciseA.entryId, null, exerciseBEntry.entryId)
|
|
||||||
val expectedTimes =
|
|
||||||
listOf(preExerciseAt, exerciseASampleAt, betweenExercisesAt, exerciseBSampleAt)
|
|
||||||
val expectedRr = listOf(675, 548, 620, 525)
|
|
||||||
for (index in 0 until samples.length()) {
|
|
||||||
val sample = samples.getJSONObject(index)
|
|
||||||
assertEquals(index.toLong(), sample.getLong("sequence"))
|
|
||||||
assertEquals(expectedTimes[index], sample.getString("observed_at"))
|
|
||||||
if (expectedOwnership[index] == null) {
|
|
||||||
assertTrue(sample.isNull("exercise_entry_id"))
|
|
||||||
} else {
|
|
||||||
assertEquals(expectedOwnership[index], sample.getString("exercise_entry_id"))
|
|
||||||
}
|
|
||||||
assertEquals(expectedRr[index], sample.getJSONArray("rr_intervals_1024").getInt(0))
|
|
||||||
}
|
|
||||||
|
|
||||||
SQLiteDatabase.openDatabase(
|
|
||||||
context.getDatabasePath(androidName).absolutePath,
|
|
||||||
null,
|
|
||||||
SQLiteDatabase.OPEN_READONLY,
|
|
||||||
).use { db ->
|
|
||||||
db.rawQuery(
|
|
||||||
"SELECT source_program_id,source_program_session_id FROM sessions " +
|
|
||||||
"WHERE session_id=?",
|
|
||||||
arrayOf(sessionId),
|
|
||||||
).use { cursor ->
|
|
||||||
assertTrue(cursor.moveToFirst())
|
|
||||||
assertEquals(programId, cursor.getString(0))
|
|
||||||
assertEquals(programSessionId, cursor.getString(1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
run(fixture.absolutePath, desktopDb.absolutePath, "program-create")
|
|
||||||
val desktopPeer =
|
|
||||||
Regex("PEER_ID=(peer_[^\\s]+)")
|
|
||||||
.find(
|
|
||||||
run(
|
|
||||||
"python3",
|
|
||||||
generationTool,
|
|
||||||
"peer-id",
|
|
||||||
"--database",
|
|
||||||
desktopDb.absolutePath,
|
|
||||||
),
|
|
||||||
)!!
|
|
||||||
.groupValues[1]
|
|
||||||
val service = SyncGenerationService(android)
|
|
||||||
val captured = service.capture(root, desktopPeer)
|
|
||||||
val published = service.publish(captured, java.io.File(root, "objects"))
|
|
||||||
val publishedTimeline =
|
|
||||||
JSONObject(java.io.File(published, "session-timeline-v1.json").readText())
|
|
||||||
val publishedHeart =
|
|
||||||
JSONObject(java.io.File(published, "heart-rate-v1.json").readText())
|
|
||||||
assertEquals(sessionId, publishedTimeline.getJSONArray("sessions").getJSONObject(0).getString("session_id"))
|
|
||||||
assertEquals(capture.captureId, publishedHeart.getJSONArray("captures").getJSONObject(0).getString("capture_id"))
|
|
||||||
|
|
||||||
val firstAck = java.io.File(root, "desktop-ack-first.json")
|
|
||||||
run(
|
|
||||||
"python3",
|
|
||||||
generationTool,
|
|
||||||
"consume-desktop",
|
|
||||||
published.absolutePath,
|
|
||||||
"--database",
|
|
||||||
desktopDb.absolutePath,
|
|
||||||
"--ack-output",
|
|
||||||
firstAck.absolutePath,
|
|
||||||
)
|
|
||||||
val countsBeforeReplay =
|
|
||||||
run(
|
|
||||||
"sqlite3",
|
|
||||||
desktopDb.absolutePath,
|
|
||||||
"SELECT (SELECT count(*) FROM session_exercise_timeline WHERE session_id='$sessionId') || '|' || " +
|
|
||||||
"(SELECT count(*) FROM heart_rate_samples WHERE capture_id='${capture.captureId}') || '|' || " +
|
|
||||||
"(SELECT count(*) FROM heart_rate_rr_intervals WHERE capture_id='${capture.captureId}');",
|
|
||||||
).trim()
|
|
||||||
assertEquals("2|4|4", countsBeforeReplay)
|
|
||||||
|
|
||||||
val replayAck = java.io.File(root, "desktop-ack-replay.json")
|
|
||||||
run(
|
|
||||||
"python3",
|
|
||||||
generationTool,
|
|
||||||
"consume-desktop",
|
|
||||||
published.absolutePath,
|
|
||||||
"--database",
|
|
||||||
desktopDb.absolutePath,
|
|
||||||
"--ack-output",
|
|
||||||
replayAck.absolutePath,
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
countsBeforeReplay,
|
|
||||||
run(
|
|
||||||
"sqlite3",
|
|
||||||
desktopDb.absolutePath,
|
|
||||||
"SELECT (SELECT count(*) FROM session_exercise_timeline WHERE session_id='$sessionId') || '|' || " +
|
|
||||||
"(SELECT count(*) FROM heart_rate_samples WHERE capture_id='${capture.captureId}') || '|' || " +
|
|
||||||
"(SELECT count(*) FROM heart_rate_rr_intervals WHERE capture_id='${capture.captureId}');",
|
|
||||||
).trim(),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
"acknowledged",
|
|
||||||
service.acceptAcknowledgement(firstAck.readBytes()),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
0,
|
|
||||||
JSONObject(android.buildSessionTimelineV1Json()).getJSONArray("sessions").length(),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
0,
|
|
||||||
JSONObject(android.buildHeartRateV1Json()).getJSONArray("captures").length(),
|
|
||||||
)
|
|
||||||
|
|
||||||
val desktopFacts =
|
|
||||||
JSONObject(
|
|
||||||
run(
|
|
||||||
"sqlite3",
|
|
||||||
desktopDb.absolutePath,
|
|
||||||
"SELECT json_object(" +
|
|
||||||
"'program_id',x.program_id,'program_session_id',x.program_session_id," +
|
|
||||||
"'started_at',s.started_at,'ended_at',s.ended_at) FROM sessions s " +
|
|
||||||
"JOIN program_session_executions x ON x.session_id=s.session_id " +
|
|
||||||
"WHERE s.session_id='$sessionId' AND x.state='completed';",
|
|
||||||
).trim(),
|
|
||||||
)
|
|
||||||
assertEquals(programId, desktopFacts.getString("program_id"))
|
|
||||||
assertEquals(programSessionId, desktopFacts.getString("program_session_id"))
|
|
||||||
assertEquals(sessionStartedAt, desktopFacts.getString("started_at"))
|
|
||||||
assertEquals(sessionEndedAt, desktopFacts.getString("ended_at"))
|
|
||||||
assertEquals(
|
|
||||||
listOf(
|
|
||||||
"$exerciseAStartedAt|$exerciseAEndedAt|${exerciseA.entryId}",
|
|
||||||
"$exerciseBStartedAt|$exerciseBEndedAt|${exerciseBEntry.entryId}",
|
|
||||||
),
|
|
||||||
run(
|
|
||||||
"sqlite3",
|
|
||||||
"-separator",
|
|
||||||
"|",
|
|
||||||
desktopDb.absolutePath,
|
|
||||||
"SELECT started_at,ended_at,entry_id FROM session_exercise_timeline " +
|
|
||||||
"WHERE session_id='$sessionId' ORDER BY started_at,entry_id;",
|
|
||||||
).lineSequence().filter { it.isNotBlank() }.toList(),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
"session|$sessionId|$sessionStartedAt|$sessionEndedAt",
|
|
||||||
run(
|
|
||||||
"sqlite3",
|
|
||||||
"-separator",
|
|
||||||
"|",
|
|
||||||
desktopDb.absolutePath,
|
|
||||||
"SELECT context_kind,context_id,started_at,ended_at " +
|
|
||||||
"FROM heart_rate_captures WHERE capture_id='${capture.captureId}';",
|
|
||||||
).trim(),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
listOf(
|
|
||||||
"0|$preExerciseAt|",
|
|
||||||
"1|$exerciseASampleAt|${exerciseA.entryId}",
|
|
||||||
"2|$betweenExercisesAt|",
|
|
||||||
"3|$exerciseBSampleAt|${exerciseBEntry.entryId}",
|
|
||||||
),
|
|
||||||
run(
|
|
||||||
"sqlite3",
|
|
||||||
"-separator",
|
|
||||||
"|",
|
|
||||||
desktopDb.absolutePath,
|
|
||||||
"SELECT sequence,observed_at,COALESCE(exercise_entry_id,'') " +
|
|
||||||
"FROM heart_rate_samples " +
|
|
||||||
"WHERE capture_id='${capture.captureId}' ORDER BY sequence;",
|
|
||||||
).lineSequence().filter { it.isNotBlank() }.toList(),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
expectedRr,
|
|
||||||
run(
|
|
||||||
"sqlite3",
|
|
||||||
desktopDb.absolutePath,
|
|
||||||
"SELECT value_1024 FROM heart_rate_rr_intervals " +
|
|
||||||
"WHERE capture_id='${capture.captureId}' ORDER BY sample_sequence,rr_index;",
|
|
||||||
).lineSequence().filter { it.isNotBlank() }.map { it.toInt() }.toList(),
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
android.close()
|
|
||||||
context.deleteDatabase(androidName)
|
|
||||||
root.deleteRecursively()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -70,57 +70,6 @@ class ProgramsProjectionRepositoryTest {
|
||||||
.put("deletions", deletions)
|
.put("deletions", deletions)
|
||||||
.toString()
|
.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 =
|
private fun deletion(): JSONObject =
|
||||||
JSONObject()
|
JSONObject()
|
||||||
.put("deletion_id", "pgd_ffffffff-ffff-4fff-8fff-ffffffffffff")
|
.put("deletion_id", "pgd_ffffffff-ffff-4fff-8fff-ffffffffffff")
|
||||||
|
|
@ -245,103 +194,4 @@ class ProgramsProjectionRepositoryTest {
|
||||||
context.deleteDatabase(name)
|
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,106 +105,6 @@ 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
|
@Test
|
||||||
fun deliveryReplaysStartsExplicitlyAndNeverOverwritesActiveDraft() {
|
fun deliveryReplaysStartsExplicitlyAndNeverOverwritesActiveDraft() {
|
||||||
val name = "prepared-${System.nanoTime()}.db"
|
val name = "prepared-${System.nanoTime()}.db"
|
||||||
|
|
@ -360,7 +260,7 @@ class SessionPreparationExchangeTest {
|
||||||
repository = TrainlogRepository(context, name)
|
repository = TrainlogRepository(context, name)
|
||||||
assertEquals(1, repository.listPreparedSessions().size)
|
assertEquals(1, repository.listPreparedSessions().size)
|
||||||
repository.close()
|
repository.close()
|
||||||
assertEquals("33", scalarText(name, "PRAGMA user_version"))
|
assertEquals("25", scalarText(name, "PRAGMA user_version"))
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"0",
|
"0",
|
||||||
scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"),
|
scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"),
|
||||||
|
|
|
||||||
|
|
@ -1,458 +0,0 @@
|
||||||
package com.labfytools.trainlog.data
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import com.labfytools.trainlog.model.NewExerciseProfile
|
|
||||||
import com.labfytools.trainlog.model.RecordingMode
|
|
||||||
import com.labfytools.trainlog.model.SessionExerciseDraft
|
|
||||||
import com.labfytools.trainlog.model.SessionSetDraft
|
|
||||||
import com.labfytools.trainlog.model.TrackingMode
|
|
||||||
import java.time.OffsetDateTime
|
|
||||||
import java.util.UUID
|
|
||||||
import org.json.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,209 +0,0 @@
|
||||||
package com.labfytools.trainlog.data
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import com.labfytools.trainlog.model.NewExerciseProfile
|
|
||||||
import com.labfytools.trainlog.model.RecordingMode
|
|
||||||
import com.labfytools.trainlog.model.SessionExerciseDraft
|
|
||||||
import com.labfytools.trainlog.model.SessionSetDraft
|
|
||||||
import com.labfytools.trainlog.model.TrackingMode
|
|
||||||
import java.nio.file.Files
|
|
||||||
import java.time.OffsetDateTime
|
|
||||||
import java.util.UUID
|
|
||||||
import org.json.JSONObject
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.robolectric.RobolectricTestRunner
|
|
||||||
import org.robolectric.annotation.Config
|
|
||||||
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
|
||||||
@Config(sdk = [35])
|
|
||||||
class SessionTimelineSyncGenerationTest {
|
|
||||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun timelineAndHeartRateArriveTogetherAndStopPublishingAfterAck() {
|
|
||||||
val sourceName = "timeline-sync-" + UUID.randomUUID() + ".db"
|
|
||||||
val root = Files.createTempDirectory("trainlog-timeline-sync-").toFile()
|
|
||||||
val source = TrainlogRepository(context, sourceName)
|
|
||||||
try {
|
|
||||||
fun exercise(label: String) =
|
|
||||||
(source.createExercise(
|
|
||||||
NewExerciseProfile(label, RecordingMode.SETS, TrackingMode.REPS, 0),
|
|
||||||
) as CreateExerciseResult.Created).exercise
|
|
||||||
val a = SessionExerciseDraft(
|
|
||||||
exercise = exercise("Sync timeline A"),
|
|
||||||
sets = listOf(SessionSetDraft(reps = 8)),
|
|
||||||
)
|
|
||||||
val b = SessionExerciseDraft(
|
|
||||||
exercise = exercise("Sync timeline B"),
|
|
||||||
sets = listOf(SessionSetDraft(reps = 9)),
|
|
||||||
)
|
|
||||||
assertEquals(ActiveDraftMutationResult.Saved, source.startActiveSessionDraft())
|
|
||||||
val opened = source.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
|
|
||||||
assertEquals(
|
|
||||||
ActiveDraftMutationResult.Saved,
|
|
||||||
source.saveActiveSessionDraft(opened.draft.copy(exercises = listOf(a, b))),
|
|
||||||
)
|
|
||||||
assertTrue(
|
|
||||||
source.startActiveSessionExercise(a.entryId) is
|
|
||||||
TrainlogRepository.SessionExerciseTimingResult.Started,
|
|
||||||
)
|
|
||||||
val base = ParsedHeartRateMeasurement(96, null, null, listOf(640))
|
|
||||||
assertTrue(
|
|
||||||
source.recordLiveHeartRateForActiveSession(
|
|
||||||
"Synthetic HR",
|
|
||||||
OffsetDateTime.now().toString(),
|
|
||||||
base,
|
|
||||||
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
|
||||||
)
|
|
||||||
assertTrue(
|
|
||||||
source.finishActiveSessionExercise(a.entryId) is
|
|
||||||
TrainlogRepository.SessionExerciseTimingResult.Finished,
|
|
||||||
)
|
|
||||||
assertTrue(
|
|
||||||
source.recordLiveHeartRateForActiveSession(
|
|
||||||
"Synthetic HR",
|
|
||||||
OffsetDateTime.now().toString(),
|
|
||||||
base.copy(bpm = 84),
|
|
||||||
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
|
||||||
)
|
|
||||||
assertTrue(
|
|
||||||
source.startActiveSessionExercise(b.entryId) is
|
|
||||||
TrainlogRepository.SessionExerciseTimingResult.Started,
|
|
||||||
)
|
|
||||||
assertTrue(
|
|
||||||
source.recordLiveHeartRateForActiveSession(
|
|
||||||
"Synthetic HR",
|
|
||||||
OffsetDateTime.now().toString(),
|
|
||||||
base.copy(bpm = 105),
|
|
||||||
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
|
||||||
)
|
|
||||||
assertTrue(
|
|
||||||
source.finishActiveSessionExercise(b.entryId) is
|
|
||||||
TrainlogRepository.SessionExerciseTimingResult.Finished,
|
|
||||||
)
|
|
||||||
assertTrue(source.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
|
|
||||||
|
|
||||||
val repositoryRoot =
|
|
||||||
generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))) {
|
|
||||||
it.parentFile
|
|
||||||
}.first { java.io.File(it, "tools/sync_generation_exchange.py").isFile }
|
|
||||||
fun run(vararg command: String): String {
|
|
||||||
val process =
|
|
||||||
ProcessBuilder(*command)
|
|
||||||
.directory(repositoryRoot)
|
|
||||||
.redirectErrorStream(true)
|
|
||||||
.start()
|
|
||||||
val output = process.inputStream.bufferedReader().readText()
|
|
||||||
assertEquals(
|
|
||||||
"command failed: " + command.joinToString(" ") + "\n" + output,
|
|
||||||
0,
|
|
||||||
process.waitFor(),
|
|
||||||
)
|
|
||||||
return output
|
|
||||||
}
|
|
||||||
|
|
||||||
val desktopDb = java.io.File(root, "desktop.sqlite")
|
|
||||||
run(
|
|
||||||
java.io.File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
|
|
||||||
desktopDb.absolutePath,
|
|
||||||
)
|
|
||||||
val desktopPeer =
|
|
||||||
Regex("PEER_ID=(peer_[^\\s]+)")
|
|
||||||
.find(
|
|
||||||
run(
|
|
||||||
"python3",
|
|
||||||
java.io.File(
|
|
||||||
repositoryRoot,
|
|
||||||
"tools/sync_generation_exchange.py",
|
|
||||||
).absolutePath,
|
|
||||||
"peer-id",
|
|
||||||
"--database",
|
|
||||||
desktopDb.absolutePath,
|
|
||||||
),
|
|
||||||
)!!
|
|
||||||
.groupValues[1]
|
|
||||||
|
|
||||||
val service = SyncGenerationService(source)
|
|
||||||
val captured = service.capture(root, desktopPeer)
|
|
||||||
val timelineJson =
|
|
||||||
JSONObject(
|
|
||||||
java.io.File(
|
|
||||||
captured.stagingDirectory,
|
|
||||||
"session-timeline-v1.json",
|
|
||||||
).readText(),
|
|
||||||
)
|
|
||||||
assertEquals(1, timelineJson.getJSONArray("sessions").length())
|
|
||||||
assertEquals(
|
|
||||||
2,
|
|
||||||
timelineJson
|
|
||||||
.getJSONArray("sessions")
|
|
||||||
.getJSONObject(0)
|
|
||||||
.getJSONArray("exercises")
|
|
||||||
.length(),
|
|
||||||
)
|
|
||||||
val heartJson =
|
|
||||||
JSONObject(
|
|
||||||
java.io.File(captured.stagingDirectory, "heart-rate-v1.json").readText(),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
3,
|
|
||||||
heartJson
|
|
||||||
.getJSONArray("captures")
|
|
||||||
.getJSONObject(0)
|
|
||||||
.getJSONArray("samples")
|
|
||||||
.length(),
|
|
||||||
)
|
|
||||||
|
|
||||||
val published = service.publish(captured, java.io.File(root, "objects"))
|
|
||||||
val ackFile = java.io.File(root, "ack.json")
|
|
||||||
run(
|
|
||||||
"python3",
|
|
||||||
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath,
|
|
||||||
"consume-desktop",
|
|
||||||
published.absolutePath,
|
|
||||||
"--database",
|
|
||||||
desktopDb.absolutePath,
|
|
||||||
"--ack-output",
|
|
||||||
ackFile.absolutePath,
|
|
||||||
)
|
|
||||||
val ack = JSONObject(ackFile.readText())
|
|
||||||
assertEquals(
|
|
||||||
"desktop ACK diagnostic: " + ack.getString("diagnostic"),
|
|
||||||
"consumed",
|
|
||||||
ack.getString("result"),
|
|
||||||
)
|
|
||||||
assertEquals("acknowledged", service.acceptAcknowledgement(ackFile.readBytes()))
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
"2|3|3",
|
|
||||||
run(
|
|
||||||
"sqlite3",
|
|
||||||
desktopDb.absolutePath,
|
|
||||||
"SELECT (SELECT count(*) FROM session_exercise_timeline) || '|' || " +
|
|
||||||
"(SELECT count(*) FROM heart_rate_samples) || '|' || " +
|
|
||||||
"(SELECT count(*) FROM heart_rate_rr_intervals);",
|
|
||||||
).trim(),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
0,
|
|
||||||
JSONObject(source.buildSessionTimelineV1Json())
|
|
||||||
.getJSONArray("sessions")
|
|
||||||
.length(),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
0,
|
|
||||||
JSONObject(source.buildHeartRateV1Json())
|
|
||||||
.getJSONArray("captures")
|
|
||||||
.length(),
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
source.close()
|
|
||||||
context.deleteDatabase(sourceName)
|
|
||||||
root.deleteRecursively()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,434 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,287 +0,0 @@
|
||||||
package com.labfytools.trainlog.data
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import com.labfytools.trainlog.model.SleepEventType
|
|
||||||
import com.labfytools.trainlog.model.SleepMedication
|
|
||||||
import java.util.UUID
|
|
||||||
import org.json.JSONObject
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.robolectric.RobolectricTestRunner
|
|
||||||
import org.robolectric.annotation.Config
|
|
||||||
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
|
||||||
@Config(sdk = [35])
|
|
||||||
class SleepQuickCaptureTest {
|
|
||||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun pendingMedicationSurvivesReopenAndBedTimeKeepsEntryIdentity() {
|
|
||||||
val name = "sleep-reopen-" + UUID.randomUUID()
|
|
||||||
var repository = TrainlogRepository(context, name)
|
|
||||||
try {
|
|
||||||
val medication =
|
|
||||||
repository.saveSleepMedication(
|
|
||||||
SleepMedication(
|
|
||||||
medicationId = "",
|
|
||||||
revisionId = "",
|
|
||||||
createdAt = "2026-09-23T20:00:00+02:00",
|
|
||||||
updatedAt = "2026-09-23T20:00:00+02:00",
|
|
||||||
name = "Test medication",
|
|
||||||
defaultDoseValue = 5.0,
|
|
||||||
defaultDoseUnit = "mg",
|
|
||||||
form = "",
|
|
||||||
note = "",
|
|
||||||
active = true,
|
|
||||||
),
|
|
||||||
) as TrainlogRepository.SaveSleepDiaryResult.Saved
|
|
||||||
val pending =
|
|
||||||
repository.quickSleepMedication(
|
|
||||||
medication.entryId,
|
|
||||||
"2026-09-23T22:20:00+02:00",
|
|
||||||
quantity = 2,
|
|
||||||
) as TrainlogRepository.SleepQuickActionResult.Applied
|
|
||||||
|
|
||||||
repository.close()
|
|
||||||
repository = TrainlogRepository(context, name)
|
|
||||||
|
|
||||||
val bed =
|
|
||||||
repository.quickSleepBedTime("2026-09-23T22:30:00+02:00")
|
|
||||||
as TrainlogRepository.SleepQuickActionResult.Applied
|
|
||||||
assertEquals(pending.receipt.entryId, bed.receipt.entryId)
|
|
||||||
assertEquals(2, repository.listSleepDiary().single().intakes.single().quantity)
|
|
||||||
} finally {
|
|
||||||
repository.close()
|
|
||||||
context.deleteDatabase(name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun oneTapNightRecordsMedicationWakesAndSleepHeartRate() {
|
|
||||||
val name = "sleep-quick-" + UUID.randomUUID()
|
|
||||||
val repository = TrainlogRepository(context, name)
|
|
||||||
try {
|
|
||||||
val medicationCreated =
|
|
||||||
repository.saveSleepMedication(
|
|
||||||
SleepMedication(
|
|
||||||
medicationId = "",
|
|
||||||
revisionId = "",
|
|
||||||
createdAt = "2026-09-23T20:00:00+02:00",
|
|
||||||
updatedAt = "2026-09-23T20:00:00+02:00",
|
|
||||||
name = "Test medication",
|
|
||||||
defaultDoseValue = 5.0,
|
|
||||||
defaultDoseUnit = "mg",
|
|
||||||
form = "",
|
|
||||||
note = "",
|
|
||||||
active = true,
|
|
||||||
),
|
|
||||||
) as TrainlogRepository.SaveSleepDiaryResult.Saved
|
|
||||||
val medication =
|
|
||||||
repository.listSleepMedications(false).single {
|
|
||||||
it.medicationId == medicationCreated.entryId
|
|
||||||
}
|
|
||||||
|
|
||||||
val preBed = repository.quickSleepMedication(
|
|
||||||
medication.medicationId,
|
|
||||||
"2026-09-23T22:20:00+02:00",
|
|
||||||
quantity = 2,
|
|
||||||
) as TrainlogRepository.SleepQuickActionResult.Applied
|
|
||||||
assertNull(repository.activeHeartRateCapture())
|
|
||||||
val pending = repository.listSleepDiary().single()
|
|
||||||
assertTrue(pending.events.none { it.type == SleepEventType.BED_TIME })
|
|
||||||
assertEquals(2, pending.intakes.single().quantity)
|
|
||||||
val preBedExport = JSONObject(repository.buildSleepDiaryV1Json())
|
|
||||||
val exportedPending = preBedExport.getJSONArray("entries").getJSONObject(0)
|
|
||||||
assertEquals(preBed.receipt.entryId, exportedPending.getString("entry_id"))
|
|
||||||
assertEquals(preBed.receipt.appliedRevisionId, exportedPending.getString("revision_id"))
|
|
||||||
assertEquals(0, exportedPending.getJSONArray("events").length())
|
|
||||||
assertEquals(1, exportedPending.getJSONArray("intakes").length())
|
|
||||||
|
|
||||||
val bed =
|
|
||||||
repository.quickSleepBedTime("2026-09-23T22:30:00+02:00")
|
|
||||||
as TrainlogRepository.SleepQuickActionResult.Applied
|
|
||||||
assertEquals(preBed.receipt.entryId, bed.receipt.entryId)
|
|
||||||
assertEquals("2026-09-23", repository.listSleepDiary().single().nightStartDate)
|
|
||||||
|
|
||||||
val measurement =
|
|
||||||
ParsedHeartRateMeasurement(
|
|
||||||
bpm = 62,
|
|
||||||
sensorContactDetected = null,
|
|
||||||
energyExpended = null,
|
|
||||||
rrIntervals1024 = listOf(1000),
|
|
||||||
)
|
|
||||||
assertTrue(
|
|
||||||
repository.recordLiveHeartRateForActiveSleep(
|
|
||||||
"CYCPLUS H2",
|
|
||||||
"2026-09-23T22:30:01+02:00",
|
|
||||||
measurement,
|
|
||||||
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
|
||||||
)
|
|
||||||
val captureId = checkNotNull(repository.activeHeartRateCapture()).captureId
|
|
||||||
|
|
||||||
assertTrue(
|
|
||||||
repository.quickSleepMedication(
|
|
||||||
medication.medicationId,
|
|
||||||
"2026-09-23T22:35:00+02:00",
|
|
||||||
) is TrainlogRepository.SleepQuickActionResult.Applied,
|
|
||||||
)
|
|
||||||
assertTrue(
|
|
||||||
repository.quickSleepWake("2026-09-24T02:17:00+02:00")
|
|
||||||
is TrainlogRepository.SleepQuickActionResult.Applied,
|
|
||||||
)
|
|
||||||
assertTrue(
|
|
||||||
repository.quickSleepWake("2026-09-24T04:42:00+02:00")
|
|
||||||
is TrainlogRepository.SleepQuickActionResult.Applied,
|
|
||||||
)
|
|
||||||
|
|
||||||
val beforeGetUp = repository.listSleepDiary().single()
|
|
||||||
assertEquals(bed.receipt.entryId, beforeGetUp.entryId)
|
|
||||||
assertEquals(
|
|
||||||
2,
|
|
||||||
beforeGetUp.events.count { it.type == SleepEventType.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") {
|
private fun session(db: SQLiteDatabase, id: String, timestamp: String, type: String = "training") {
|
||||||
db.execSQL("INSERT INTO sessions(session_id,started_at,session_type,session_kind) VALUES(?,?,?,?)", arrayOf(id, timestamp, type, type))
|
db.execSQL("INSERT INTO sessions(session_id,started_at,session_type) VALUES(?,?,?)", arrayOf(id, timestamp, type))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun occurrence(
|
private fun occurrence(
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,6 @@ class SyncDeploymentConversationTest {
|
||||||
val databaseName = "deployment-peer-${UUID.randomUUID()}.db"
|
val databaseName = "deployment-peer-${UUID.randomUUID()}.db"
|
||||||
var repository = TrainlogRepository(context, databaseName)
|
var repository = TrainlogRepository(context, databaseName)
|
||||||
var localWorker: Process? = null
|
var localWorker: Process? = null
|
||||||
var lostDesktopAckGeneration: String? = null
|
|
||||||
try {
|
try {
|
||||||
transport.mkdirs()
|
transport.mkdirs()
|
||||||
// A previous interrupted exchange leaves durable coordination
|
// A previous interrupted exchange leaves durable coordination
|
||||||
|
|
@ -181,7 +180,6 @@ class SyncDeploymentConversationTest {
|
||||||
"full_generation_trigger_published",
|
"full_generation_trigger_published",
|
||||||
"generation_request_observed",
|
"generation_request_observed",
|
||||||
"archive_ack_observed",
|
"archive_ack_observed",
|
||||||
"android_archive_ack_published",
|
|
||||||
"generation_capture_started",
|
"generation_capture_started",
|
||||||
"generation_captured",
|
"generation_captured",
|
||||||
"generation_objects_published",
|
"generation_objects_published",
|
||||||
|
|
@ -217,71 +215,6 @@ class SyncDeploymentConversationTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (suppliedTransport == null && it == 11) {
|
|
||||||
/* Reproduce the inverse deployed failure: Android has
|
|
||||||
* durably consumed the newest desktop generation, but its
|
|
||||||
* ACK was lost before the desktop producer committed it.
|
|
||||||
* The next conversation must recover that exact Android
|
|
||||||
* evidence before capturing another desktop generation. */
|
|
||||||
SQLiteDatabase.openDatabase(
|
|
||||||
desktop.absolutePath,
|
|
||||||
null,
|
|
||||||
SQLiteDatabase.OPEN_READWRITE,
|
|
||||||
).use { database ->
|
|
||||||
val generation =
|
|
||||||
database.rawQuery(
|
|
||||||
"SELECT generation_id FROM sync_generations " +
|
|
||||||
"WHERE status='acknowledged' ORDER BY generated_at DESC LIMIT 1",
|
|
||||||
null,
|
|
||||||
).use { cursor ->
|
|
||||||
assertTrue(cursor.moveToFirst())
|
|
||||||
cursor.getString(0)
|
|
||||||
}
|
|
||||||
database.delete(
|
|
||||||
"sync_acknowledgements",
|
|
||||||
"generation_id=?",
|
|
||||||
arrayOf(generation),
|
|
||||||
)
|
|
||||||
database.execSQL(
|
|
||||||
"UPDATE sync_generations SET status='waiting_acknowledgement'," +
|
|
||||||
"acknowledged_at=NULL WHERE generation_id=?",
|
|
||||||
arrayOf(generation),
|
|
||||||
)
|
|
||||||
database.rawQuery(
|
|
||||||
"SELECT status FROM sync_generations WHERE generation_id=?",
|
|
||||||
arrayOf(generation),
|
|
||||||
).use { cursor ->
|
|
||||||
assertTrue(cursor.moveToFirst())
|
|
||||||
assertEquals("waiting_acknowledgement", cursor.getString(0))
|
|
||||||
}
|
|
||||||
lostDesktopAckGeneration = generation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (suppliedTransport == null && it == 12) {
|
|
||||||
val recovered = checkNotNull(lostDesktopAckGeneration)
|
|
||||||
SQLiteDatabase.openDatabase(
|
|
||||||
desktop.absolutePath,
|
|
||||||
null,
|
|
||||||
SQLiteDatabase.OPEN_READONLY,
|
|
||||||
).use { database ->
|
|
||||||
database.rawQuery(
|
|
||||||
"SELECT status FROM sync_generations WHERE generation_id=?",
|
|
||||||
arrayOf(recovered),
|
|
||||||
).use { cursor ->
|
|
||||||
assertTrue(cursor.moveToFirst())
|
|
||||||
assertEquals("acknowledged", cursor.getString(0))
|
|
||||||
}
|
|
||||||
database.rawQuery(
|
|
||||||
"SELECT parent_generation_id,status FROM sync_generations " +
|
|
||||||
"ORDER BY generated_at DESC LIMIT 1",
|
|
||||||
null,
|
|
||||||
).use { cursor ->
|
|
||||||
assertTrue(cursor.moveToFirst())
|
|
||||||
assertEquals(recovered, cursor.getString(0))
|
|
||||||
assertEquals("acknowledged", cursor.getString(1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
localWorker = null
|
localWorker = null
|
||||||
}
|
}
|
||||||
repository.close()
|
repository.close()
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,11 @@ package com.labfytools.trainlog.data
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.database.sqlite.SQLiteDatabase
|
import android.database.sqlite.SQLiteDatabase
|
||||||
import androidx.test.core.app.ApplicationProvider
|
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.NewExerciseProfile
|
||||||
import com.labfytools.trainlog.model.RecordingMode
|
import com.labfytools.trainlog.model.RecordingMode
|
||||||
import com.labfytools.trainlog.model.SessionDraft
|
import com.labfytools.trainlog.model.SessionDraft
|
||||||
import com.labfytools.trainlog.model.SessionExerciseDraft
|
import com.labfytools.trainlog.model.SessionExerciseDraft
|
||||||
import com.labfytools.trainlog.model.SessionSetDraft
|
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 com.labfytools.trainlog.model.TrackingMode
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
|
|
@ -31,299 +27,6 @@ import org.robolectric.annotation.Config
|
||||||
class SyncGenerationServiceTest {
|
class SyncGenerationServiceTest {
|
||||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
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
|
@Test
|
||||||
fun desktopProgramDeletionGenerationIsAckedAndCannotResurrect() {
|
fun desktopProgramDeletionGenerationIsAckedAndCannotResurrect() {
|
||||||
val repositoryRoot =
|
val repositoryRoot =
|
||||||
|
|
@ -616,13 +319,6 @@ class SyncGenerationServiceTest {
|
||||||
destinationService.peerId(),
|
destinationService.peerId(),
|
||||||
runId,
|
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 =
|
val outboundDirectory =
|
||||||
sourceService.publish(outbound, java.io.File(root, "source-objects"))
|
sourceService.publish(outbound, java.io.File(root, "source-objects"))
|
||||||
sourceService.acceptAcknowledgement(
|
sourceService.acceptAcknowledgement(
|
||||||
|
|
@ -654,74 +350,6 @@ 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
|
@Test
|
||||||
fun coordinatorOnlyTreatsCorrelatedFreshRequestAsActionable() {
|
fun coordinatorOnlyTreatsCorrelatedFreshRequestAsActionable() {
|
||||||
val databaseName = "generation-actionable-${UUID.randomUUID()}.db"
|
val databaseName = "generation-actionable-${UUID.randomUUID()}.db"
|
||||||
|
|
@ -878,7 +506,7 @@ class SyncGenerationServiceTest {
|
||||||
.use { db ->
|
.use { db ->
|
||||||
db.rawQuery("PRAGMA user_version", null).use {
|
db.rawQuery("PRAGMA user_version", null).use {
|
||||||
assertTrue(it.moveToFirst())
|
assertTrue(it.moveToFirst())
|
||||||
assertEquals(33, it.getInt(0))
|
assertEquals(25, it.getInt(0))
|
||||||
}
|
}
|
||||||
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
|
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
|
||||||
assertTrue(it.moveToFirst())
|
assertTrue(it.moveToFirst())
|
||||||
|
|
@ -1227,73 +855,7 @@ class SyncGenerationServiceTest {
|
||||||
)
|
)
|
||||||
)!!
|
)!!
|
||||||
.groupValues[1]
|
.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)
|
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 =
|
val androidPublished =
|
||||||
sourceService.publish(
|
sourceService.publish(
|
||||||
androidToDesktop,
|
androidToDesktop,
|
||||||
|
|
@ -1310,35 +872,10 @@ class SyncGenerationServiceTest {
|
||||||
"--ack-output",
|
"--ack-output",
|
||||||
androidAckFile.absolutePath,
|
androidAckFile.absolutePath,
|
||||||
)
|
)
|
||||||
val androidAck = JSONObject(androidAckFile.readText())
|
|
||||||
assertEquals(
|
|
||||||
"desktop ACK diagnostic: ${androidAck.getString("diagnostic")}",
|
|
||||||
"consumed",
|
|
||||||
androidAck.getString("result"),
|
|
||||||
)
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"acknowledged",
|
"acknowledged",
|
||||||
sourceService.acceptAcknowledgement(androidAckFile.readBytes()),
|
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
|
val androidSessionId = source.listSessions().single().sessionId
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"1",
|
"1",
|
||||||
|
|
@ -1350,69 +887,6 @@ class SyncGenerationServiceTest {
|
||||||
.trim(),
|
.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(
|
run(
|
||||||
java.io.File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
|
java.io.File(repositoryRoot, "build/tui/sync-generation-fixture").absolutePath,
|
||||||
desktopDb.absolutePath,
|
desktopDb.absolutePath,
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,6 @@ import com.labfytools.trainlog.model.BodyObservationDraft
|
||||||
import com.labfytools.trainlog.model.ExerciseDataFields
|
import com.labfytools.trainlog.model.ExerciseDataFields
|
||||||
import com.labfytools.trainlog.model.ExerciseEditInput
|
import com.labfytools.trainlog.model.ExerciseEditInput
|
||||||
import com.labfytools.trainlog.model.ExerciseProfile
|
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.NewExerciseProfile
|
||||||
import com.labfytools.trainlog.model.RecordingMode
|
import com.labfytools.trainlog.model.RecordingMode
|
||||||
import com.labfytools.trainlog.model.SessionDraft
|
import com.labfytools.trainlog.model.SessionDraft
|
||||||
|
|
@ -58,126 +56,6 @@ class TrainlogRepositoryDraftTest {
|
||||||
databaseName = "draft-test-${UUID.randomUUID()}.db"
|
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
|
@After
|
||||||
fun tearDown() {
|
fun tearDown() {
|
||||||
repository?.close()
|
repository?.close()
|
||||||
|
|
@ -2382,7 +2260,7 @@ class TrainlogRepositoryDraftTest {
|
||||||
).use { db ->
|
).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
assertTrue(cursor.moveToFirst())
|
||||||
assertEquals(33, cursor.getInt(0))
|
assertEquals(25, cursor.getInt(0))
|
||||||
}
|
}
|
||||||
db.rawQuery(
|
db.rawQuery(
|
||||||
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
||||||
|
|
@ -2509,7 +2387,7 @@ class TrainlogRepositoryDraftTest {
|
||||||
).use { db ->
|
).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
assertTrue(cursor.moveToFirst())
|
||||||
assertEquals(33, cursor.getInt(0))
|
assertEquals(25, cursor.getInt(0))
|
||||||
}
|
}
|
||||||
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
|
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
assertTrue(cursor.moveToFirst())
|
||||||
|
|
@ -2817,46 +2695,6 @@ class TrainlogRepositoryDraftTest {
|
||||||
} catch (_: IllegalStateException) { }
|
} 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
|
@Test
|
||||||
fun targetOnlyDraftPersistsButCannotFinalizeWithoutActualWork() {
|
fun targetOnlyDraftPersistsButCannotFinalizeWithoutActualWork() {
|
||||||
val repo = openRepository()
|
val repo = openRepository()
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,8 @@ import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.performClick
|
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.test.performSemanticsAction
|
||||||
import androidx.compose.ui.semantics.SemanticsActions
|
import androidx.compose.ui.semantics.SemanticsActions
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
|
|
@ -29,12 +28,8 @@ import com.labfytools.trainlog.data.ActiveDraftMutationResult
|
||||||
import com.labfytools.trainlog.data.AiSessionDraftImportResult
|
import com.labfytools.trainlog.data.AiSessionDraftImportResult
|
||||||
import com.labfytools.trainlog.data.CreateExerciseResult
|
import com.labfytools.trainlog.data.CreateExerciseResult
|
||||||
import com.labfytools.trainlog.data.TrainlogRepository
|
import com.labfytools.trainlog.data.TrainlogRepository
|
||||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
|
||||||
import com.labfytools.trainlog.model.NewExerciseProfile
|
import com.labfytools.trainlog.model.NewExerciseProfile
|
||||||
import com.labfytools.trainlog.model.RecordingMode
|
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.model.TrackingMode
|
||||||
import com.labfytools.trainlog.ui.theme.TrainlogTheme
|
import com.labfytools.trainlog.ui.theme.TrainlogTheme
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
@ -120,7 +115,7 @@ class AiSessionDraftUiWiringTest {
|
||||||
assertEquals(ActiveDraftMutationResult.Saved, repository.startActiveSessionDraft())
|
assertEquals(ActiveDraftMutationResult.Saved, repository.startActiveSessionDraft())
|
||||||
var createCalls = 0
|
var createCalls = 0
|
||||||
compose.setContent { TrainlogTheme {
|
compose.setContent { TrainlogTheme {
|
||||||
SessionScreen(repository, 0, {}, { createCalls++ }, { _ -> }, {})
|
SessionScreen(repository, 0, {}, { createCalls++ }, {})
|
||||||
} }
|
} }
|
||||||
|
|
||||||
val create = compose.onNodeWithTag("active-session-create-exercise")
|
val create = compose.onNodeWithTag("active-session-create-exercise")
|
||||||
|
|
@ -135,84 +130,6 @@ class AiSessionDraftUiWiringTest {
|
||||||
compose.runOnIdle { assertEquals(1, createCalls) }
|
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
|
@Test
|
||||||
fun draftScreenShowsDosBicepsAndTargetDetails() {
|
fun draftScreenShowsDosBicepsAndTargetDetails() {
|
||||||
importDraft()
|
importDraft()
|
||||||
|
|
@ -382,15 +299,6 @@ class AiSessionDraftUiWiringTest {
|
||||||
return payload
|
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 {
|
private fun artifact(exerciseId: String): String {
|
||||||
val target = JSONObject().put("sets", 3).put("reps", 10)
|
val target = JSONObject().put("sets", 3).put("reps", 10)
|
||||||
.put("duration_seconds", JSONObject.NULL).put("weight_kg", JSONObject.NULL)
|
.put("duration_seconds", JSONObject.NULL).put("weight_kg", JSONObject.NULL)
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ class AppNavigationStateTest {
|
||||||
@After fun tearDown() { context.deleteDatabase(databaseName) }
|
@After fun tearDown() { context.deleteDatabase(databaseName) }
|
||||||
|
|
||||||
@Test fun captureSectionsHaveTypedRootsWithoutMobileStatistics() {
|
@Test fun captureSectionsHaveTypedRootsWithoutMobileStatistics() {
|
||||||
assertEquals(7, AppSection.entries.size)
|
assertEquals(6, AppSection.entries.size)
|
||||||
assertFalse(AppSection.entries.any { it.name == "STATISTICS" })
|
assertFalse(AppSection.entries.any { it.name == "STATISTICS" })
|
||||||
AppSection.entries.forEach { assertEquals(it, it.rootRoute().section) }
|
AppSection.entries.forEach { assertEquals(it, it.rootRoute().section) }
|
||||||
assertEquals(AppSection.SESSIONS, AppRoute.SessionDetail("se_1").section)
|
assertEquals(AppSection.SESSIONS, AppRoute.SessionDetail("se_1").section)
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ class DraftFeedbackUiReadbackTest {
|
||||||
val completedSessionId = mutableStateOf<String?>(null)
|
val completedSessionId = mutableStateOf<String?>(null)
|
||||||
compose.setContent { TrainlogTheme {
|
compose.setContent { TrainlogTheme {
|
||||||
completedSessionId.value?.let { SessionDetailScreen(repository, it, {}, {}) }
|
completedSessionId.value?.let { SessionDetailScreen(repository, it, {}, {}) }
|
||||||
?: SessionScreen(repository, 0, {}, {}, { _ -> }, {})
|
?: SessionScreen(repository, 0, {}, {}, {})
|
||||||
} }
|
} }
|
||||||
compose.onNodeWithTag("draft-feedback-${entry.entryId}").fetchSemanticsNode()
|
compose.onNodeWithTag("draft-feedback-${entry.entryId}").fetchSemanticsNode()
|
||||||
compose.onNodeWithText("Ressenti · texte UI durable").fetchSemanticsNode()
|
compose.onNodeWithText("Ressenti · texte UI durable").fetchSemanticsNode()
|
||||||
|
|
|
||||||
|
|
@ -7,22 +7,15 @@ package com.labfytools.trainlog.ui
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.compose.ui.test.junit4.createComposeRule
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
|
||||||
import androidx.compose.ui.test.hasClickAction
|
import androidx.compose.ui.test.hasClickAction
|
||||||
import androidx.compose.ui.test.hasText
|
import androidx.compose.ui.test.hasText
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
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.performSemanticsAction
|
||||||
import androidx.compose.ui.test.performScrollTo
|
|
||||||
import androidx.compose.ui.test.performScrollToNode
|
|
||||||
import androidx.compose.ui.semantics.SemanticsActions
|
import androidx.compose.ui.semantics.SemanticsActions
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import com.labfytools.trainlog.data.ActiveDraftMutationResult
|
import com.labfytools.trainlog.data.ActiveDraftMutationResult
|
||||||
import com.labfytools.trainlog.data.ActiveDraftLoadResult
|
|
||||||
import com.labfytools.trainlog.data.BodyZoneRecentExposure
|
import com.labfytools.trainlog.data.BodyZoneRecentExposure
|
||||||
import com.labfytools.trainlog.data.ExposureWindowSummary
|
import com.labfytools.trainlog.data.ExposureWindowSummary
|
||||||
import com.labfytools.trainlog.data.CreateExerciseResult
|
|
||||||
import com.labfytools.trainlog.data.GenerationWarningLevel
|
import com.labfytools.trainlog.data.GenerationWarningLevel
|
||||||
import com.labfytools.trainlog.data.SessionGenerationPreview
|
import com.labfytools.trainlog.data.SessionGenerationPreview
|
||||||
import com.labfytools.trainlog.data.SessionGenerationPreviewExercise
|
import com.labfytools.trainlog.data.SessionGenerationPreviewExercise
|
||||||
|
|
@ -33,13 +26,6 @@ import com.labfytools.trainlog.data.SyncExporter
|
||||||
import com.labfytools.trainlog.data.SyncRequestOutbox
|
import com.labfytools.trainlog.data.SyncRequestOutbox
|
||||||
import com.labfytools.trainlog.data.TrainlogRepository
|
import com.labfytools.trainlog.data.TrainlogRepository
|
||||||
import com.labfytools.trainlog.model.SessionExercisePlan
|
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 com.labfytools.trainlog.ui.theme.TrainlogTheme
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
@ -123,106 +109,6 @@ class TrainlogAppNavigationCallbackTest {
|
||||||
assertArrayEquals(before, databaseDigest())
|
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")
|
private fun databaseDigest(): ByteArray = MessageDigest.getInstance("SHA-256")
|
||||||
.digest(context.getDatabasePath(databaseName).readBytes())
|
.digest(context.getDatabasePath(databaseName).readBytes())
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,12 @@
|
||||||
"columns": 12,
|
"columns": 12,
|
||||||
"max_y": 200,
|
"max_y": 200,
|
||||||
"tiles": [
|
"tiles": [
|
||||||
{ "id": "next-session", "min_width": 4, "max_width": 12, "min_height": 3, "max_height": 10, "default": { "x": 0, "y": 0, "width": 5, "height": 4 } },
|
{ "id": "next-session", "min_width": 4, "max_width": 8, "min_height": 3, "max_height": 7, "default": { "x": 0, "y": 0, "width": 5, "height": 4 } },
|
||||||
{ "id": "activity", "min_width": 5, "max_width": 12, "min_height": 3, "max_height": 12, "default": { "x": 5, "y": 0, "width": 7, "height": 4 } },
|
{ "id": "activity", "min_width": 5, "max_width": 12, "min_height": 3, "max_height": 7, "default": { "x": 5, "y": 0, "width": 7, "height": 4 } },
|
||||||
{ "id": "progression", "min_width": 6, "max_width": 12, "min_height": 4, "max_height": 12, "default": { "x": 0, "y": 4, "width": 8, "height": 5 } },
|
{ "id": "progression", "min_width": 6, "max_width": 12, "min_height": 4, "max_height": 9, "default": { "x": 0, "y": 4, "width": 8, "height": 5 } },
|
||||||
{ "id": "last-session", "min_width": 3, "max_width": 12, "min_height": 3, "max_height": 10, "default": { "x": 8, "y": 4, "width": 4, "height": 3 } },
|
{ "id": "last-session", "min_width": 3, "max_width": 7, "min_height": 3, "max_height": 6, "default": { "x": 8, "y": 4, "width": 4, "height": 3 } },
|
||||||
{ "id": "max-records", "min_width": 3, "max_width": 12, "min_height": 3, "max_height": 10, "default": { "x": 8, "y": 7, "width": 4, "height": 3 } },
|
{ "id": "max-records", "min_width": 3, "max_width": 8, "min_height": 3, "max_height": 7, "default": { "x": 8, "y": 7, "width": 4, "height": 3 } },
|
||||||
{ "id": "muscle-distribution", "min_width": 5, "max_width": 12, "min_height": 4, "max_height": 12, "default": { "x": 0, "y": 9, "width": 6, "height": 5 } },
|
{ "id": "muscle-distribution", "min_width": 5, "max_width": 12, "min_height": 4, "max_height": 9, "default": { "x": 0, "y": 9, "width": 6, "height": 5 } },
|
||||||
{ "id": "cardio-recovery", "min_width": 5, "max_width": 12, "min_height": 4, "max_height": 12, "default": { "x": 6, "y": 10, "width": 6, "height": 4 } }
|
{ "id": "cardio-recovery", "min_width": 5, "max_width": 12, "min_height": 4, "max_height": 8, "default": { "x": 6, "y": 10, "width": 6, "height": 4 } }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
112
docs/android.md
112
docs/android.md
|
|
@ -1,73 +1,5 @@
|
||||||
# Android application
|
# Android application
|
||||||
|
|
||||||
## Sleep Diary V1 and active V2 companion
|
|
||||||
|
|
||||||
Android schema v26 additively mirrors the Sleep Diary revision/event model for
|
|
||||||
local-first capture. The unreleased schema-v26 contract also includes the
|
|
||||||
revisioned medication catalog and entry-revision-owned intake snapshots. A
|
|
||||||
catalog default dose is only a form default; each intake retains its actual
|
|
||||||
optional positive dose and unit. Existing development databases already at
|
|
||||||
v26 receive the missing additive tables during the normal open transaction.
|
|
||||||
local-first field capture. Its compact Morning and Day sections edit one
|
|
||||||
durable night/day entry without reproducing the desktop/PDF grid. Creation,
|
|
||||||
correction and logical deletion are revision guarded and survive repository
|
|
||||||
reopen. Full-generation exchange transports the companion, never SQLite.
|
|
||||||
Each event, intake, appreciation, date or note edit is saved locally at once;
|
|
||||||
closing the screen or killing the process does not require validation or sync.
|
|
||||||
The screen resumes the most recent entry and distinguishes draft, ready,
|
|
||||||
synchronized and modified tips. “Validate day” marks the current revision for
|
|
||||||
the existing global synchronization action and never makes it immutable.
|
|
||||||
|
|
||||||
Android schema v32 additively stores each immutable medication-intake
|
|
||||||
`quantity` as an integer in `1..99`, with `1` backfilled for historical rows.
|
|
||||||
It also adds a separate quick-publication marker for the exact current
|
|
||||||
revision. Quick publication makes a current fact eligible for generation; it
|
|
||||||
does not validate a day and does not change the existing validation/ACK
|
|
||||||
lifecycle.
|
|
||||||
|
|
||||||
Android schema v33 enforces that same immutable identity boundary inside
|
|
||||||
SQLite. Eight append-only triggers reject update or deletion of Sleep diary
|
|
||||||
revisions, events, medication intakes, and medication revisions. Repository
|
|
||||||
imports compare a known ancestor's complete canonical payload before the stale
|
|
||||||
ancestor shortcut, so a reused ID with a different parent or content cannot be
|
|
||||||
hidden below a later local tip. All user edits continue to create a fresh
|
|
||||||
UUIDv4 revision and preserve the previous payload and causal parent.
|
|
||||||
Medication-intake writes name all columns explicitly, so the additive
|
|
||||||
`quantity` column cannot reorder note or creation-time values in upgraded
|
|
||||||
schema-v33 databases.
|
|
||||||
|
|
||||||
The quick Sleep capture derives one night identity from the existing
|
|
||||||
18:00-to-18:00 date rule. A medication action can create that pending entry
|
|
||||||
before `BED_TIME`, later medication actions reuse it, and Couché appends
|
|
||||||
`BED_TIME` to the same stable `entry_id`. Réveil and Levé remain unavailable
|
|
||||||
until bedtime. The compact quantity control defaults to one and resets after a
|
|
||||||
successful intake; the per-unit dose and unit remain independent structured
|
|
||||||
facts. Pending entries persist through restart and can synchronize without
|
|
||||||
inventing bedtime.
|
|
||||||
|
|
||||||
The persistence regression begins from an empty pending revision, records two
|
|
||||||
pre-bed intakes (one with quantity two), and reopens the repository after
|
|
||||||
intake, Couché, Réveil and Levé. It then proves the same stable entry and intake
|
|
||||||
snapshots through generation, desktop import/ACK, exact replay, rejection of a
|
|
||||||
stale intake-free ancestor and a later current revision. This is the executable
|
|
||||||
contract for lifecycle and synchronization retention; it does not infer facts
|
|
||||||
that are absent from a device database.
|
|
||||||
|
|
||||||
The one-tap Réveil action records `long_awake` rather than a point
|
|
||||||
`night_get_up`: its factual start is the tap timestamp and its policy end is 30
|
|
||||||
minutes later. Another tap inside that window extends the same interval to 30
|
|
||||||
minutes after the latest tap; Levé clamps an overlapping interval to the exact
|
|
||||||
final-get-up timestamp. This explicit default requires no Rendormi interaction,
|
|
||||||
remains revisioned/undoable, and is not derived from heart-rate measurements.
|
|
||||||
|
|
||||||
Sleep-owned HR capture remains sample-driven. A pending medication alone opens
|
|
||||||
no capture. After Couché, the first fresh real measurement creates or resumes
|
|
||||||
one `context_kind=sleep` capture for that entry, with `started_at` equal to the
|
|
||||||
factual bedtime and each measurement retaining its observed timestamp, BPM and
|
|
||||||
raw RR values. Réveil neither stops nor splits it; Levé closes it at the exact
|
|
||||||
final-get-up timestamp. No sensor measurement means no fabricated capture or
|
|
||||||
sample.
|
|
||||||
|
|
||||||
## Background USB and private Drive synchronization
|
## Background USB and private Drive synchronization
|
||||||
|
|
||||||
SyncScreen and the explicitly enabled background service invoke one
|
SyncScreen and the explicitly enabled background service invoke one
|
||||||
|
|
@ -91,16 +23,7 @@ Android schema v22 adds a pending manual-preparation store which is separate
|
||||||
from AI proposals and the exactly-one active-session singleton. A received
|
from AI proposals and the exactly-one active-session singleton. A received
|
||||||
preparation is inert until explicit start; an occupied singleton is never
|
preparation is inert until explicit start; an occupied singleton is never
|
||||||
overwritten. Starting preserves the desktop-reserved execution `session_id`,
|
overwritten. Starting preserves the desktop-reserved execution `session_id`,
|
||||||
copies targets only and creates no performed facts. A Program-owned preparation
|
copies targets only and creates no performed facts.
|
||||||
also preserves `source_program_id` and `source_program_session_id` through the
|
|
||||||
active draft and completed session. A legacy stored delivery may receive those
|
|
||||||
two omitted fields later only when its revision, complete prior payload and
|
|
||||||
reserved execution identity match exactly; this repairs provenance without
|
|
||||||
altering performed workout facts.
|
|
||||||
Acknowledged historical deliveries use the same exact-identity recovery. A
|
|
||||||
local continuous duration target is validated during generation capture while
|
|
||||||
the completed duration, speed and distance remain represented by the existing
|
|
||||||
`continuous` history payload rather than an invented set target.
|
|
||||||
|
|
||||||
Android schema v23 adds the durable manual-preparation withdrawal ledger. The
|
Android schema v23 adds the durable manual-preparation withdrawal ledger. The
|
||||||
schema is unchanged by Web proposal withdrawal: the existing durable AI draft
|
schema is unchanged by Web proposal withdrawal: the existing durable AI draft
|
||||||
|
|
@ -689,24 +612,7 @@ resume action after relaunch.
|
||||||
Final save validates the durable draft, inserts the completed session and actual
|
Final save validates the durable draft, inserts the completed session and actual
|
||||||
values, and removes the draft in one SQLite transaction. Failure rolls back and
|
values, and removes the draft in one SQLite transaction. Failure rolls back and
|
||||||
retains the draft for retry; repeated completion does not create duplicates.
|
retains the draft for retry; repeated completion does not create duplicates.
|
||||||
Planned targets remain prescriptions: finalization accepts fewer or more valid
|
The existing completed-session save-time timestamp behavior is unchanged.
|
||||||
performed sets and actual repetitions or loads that differ from their targets.
|
|
||||||
Finishing a target-only continuous occurrence with no supplemental fields
|
|
||||||
materializes its performed duration from that occurrence's already persisted
|
|
||||||
timeline start/end interval. This also repairs a preserved draft whose interval
|
|
||||||
was closed by an older build without creating the performed row. Existing
|
|
||||||
performed values are never overwritten, and continuous occurrences that require
|
|
||||||
speed or distance still require those measured values explicitly.
|
|
||||||
|
|
||||||
Ordinary completion uses the save-time timestamp. A private recovery build may
|
|
||||||
embed one exact session identity and one explicit offset-aware factual end time;
|
|
||||||
only that matching draft uses the supplied time. The repository rejects an end
|
|
||||||
before the session start or latest exercise marker. When heart-rate samples
|
|
||||||
arrived after a failed factual session end, delayed finalization keeps samples
|
|
||||||
at or before that inclusive end and transactionally deletes the strict tail.
|
|
||||||
RR children follow their BPM sample through the declared foreign-key cascade,
|
|
||||||
and the stopped capture uses the exact factual end. Retained sample identities
|
|
||||||
and values are never retimed or renumbered.
|
|
||||||
|
|
||||||
PC catalog reconciliation preserves draft references through catalog row
|
PC catalog reconciliation preserves draft references through catalog row
|
||||||
ownership. If an editing selection no longer resolves, only the selection is
|
ownership. If an editing selection no longer resolves, only the selection is
|
||||||
|
|
@ -960,18 +866,8 @@ decimal input is accepted (`12,5`); one value applies to all sets or values may
|
||||||
be separated with `;`. Assisted equipment is explicitly labelled
|
be separated with `;`. Assisted equipment is explicitly labelled
|
||||||
`Assistance (kg)`. Empty load remains distinct from an entered zero.
|
`Assistance (kg)`. Empty load remains distinct from an entered zero.
|
||||||
|
|
||||||
`Créer un équipement` in that selector opens the existing catalogue creation
|
`Nouvelle machine` in that same selector creates a persistent local custom
|
||||||
route. An explicit successful creation returns automatically to the same
|
equipment entry with a generated stable `eq_…` ID and selects it immediately.
|
||||||
exercise editor, restores the stable occurrence being edited, and selects the
|
|
||||||
new persistent `eq_…` ID; the draft's other raw fields and target remain
|
|
||||||
unchanged. No equipment is synthesized merely by opening the route.
|
|
||||||
The selector presents every matching catalogue entry inside a bounded,
|
|
||||||
independently scrollable result list; it does not truncate the selectable
|
|
||||||
catalogue to its first rows. An existing target-only prepared occurrence may
|
|
||||||
therefore change or create its equipment association and save in place while
|
|
||||||
retaining its `entry_id`, plan and sibling occurrences. This operation does not
|
|
||||||
fabricate a performed set; a partially entered performed row still follows the
|
|
||||||
normal validation path and is never silently discarded.
|
|
||||||
The shared bundled catalogue uses reserved canonical IDs. User-created IDs are
|
The shared bundled catalogue uses reserved canonical IDs. User-created IDs are
|
||||||
exchanged first by definitions V1, so a V2 association can resolve them on the
|
exchanged first by definitions V1, so a V2 association can resolve them on the
|
||||||
receiving side; conflicts remain explicit and are never converted to null.
|
receiving side; conflicts remain explicit and are never converted to null.
|
||||||
|
|
|
||||||
|
|
@ -61,14 +61,6 @@ long-term history. Core owns data, persistence, business logic, calculations,
|
||||||
read models, command services, and versioned contracts. An interface must not
|
read models, command services, and versioned contracts. An interface must not
|
||||||
query tables directly to reconstruct its own business rules.
|
query tables directly to reconstruct its own business rules.
|
||||||
|
|
||||||
The Web Dashboard and Analyse workspace share `GET /api/v1/analysis`, backed by
|
|
||||||
one C17 read model. Period filtering, active-Program progress, compatible
|
|
||||||
exercise metrics, explicit MAX results, primary BODY ZONE exposures, and body
|
|
||||||
measurement first/last/delta calculations occur in Core. React validates the
|
|
||||||
bounded response and presents it; it does not load raw history or issue SQL.
|
|
||||||
Absent values remain null, and the model never estimates physiological or
|
|
||||||
performance scores.
|
|
||||||
|
|
||||||
Android local SQLite is a capture store, not a synchronization format.
|
Android local SQLite is a capture store, not a synchronization format.
|
||||||
|
|
||||||
## 2. Components
|
## 2. Components
|
||||||
|
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="52" viewBox="0 0 960 52" role="img" aria-labelledby="title desc">
|
|
||||||
<title id="title">Trainlog technology badges</title>
|
|
||||||
<desc id="desc">Local-first, C17, Android, local Web, GPLv3, and development version 0.1.6.</desc>
|
|
||||||
<style>
|
|
||||||
.label { font: 600 16px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; fill: #cdd6f4; dominant-baseline: middle; text-anchor: middle; }
|
|
||||||
</style>
|
|
||||||
<g>
|
|
||||||
<rect x="0" y="4" width="172" height="44" rx="12" fill="#1e1e2e" stroke="#b4befe" stroke-width="2"/>
|
|
||||||
<circle cx="24" cy="26" r="6" fill="#b4befe"/>
|
|
||||||
<text class="label" x="100" y="27">LOCAL-FIRST</text>
|
|
||||||
</g>
|
|
||||||
<g>
|
|
||||||
<rect x="184" y="4" width="116" height="44" rx="12" fill="#1e1e2e" stroke="#89b4fa" stroke-width="2"/>
|
|
||||||
<circle cx="208" cy="26" r="6" fill="#89b4fa"/>
|
|
||||||
<text class="label" x="258" y="27">C17</text>
|
|
||||||
</g>
|
|
||||||
<g>
|
|
||||||
<rect x="312" y="4" width="160" height="44" rx="12" fill="#1e1e2e" stroke="#a6e3a1" stroke-width="2"/>
|
|
||||||
<circle cx="336" cy="26" r="6" fill="#a6e3a1"/>
|
|
||||||
<text class="label" x="405" y="27">ANDROID</text>
|
|
||||||
</g>
|
|
||||||
<g>
|
|
||||||
<rect x="484" y="4" width="176" height="44" rx="12" fill="#1e1e2e" stroke="#74c7ec" stroke-width="2"/>
|
|
||||||
<circle cx="508" cy="26" r="6" fill="#74c7ec"/>
|
|
||||||
<text class="label" x="584" y="27">LOCAL WEB</text>
|
|
||||||
</g>
|
|
||||||
<g>
|
|
||||||
<rect x="672" y="4" width="132" height="44" rx="12" fill="#1e1e2e" stroke="#cba6f7" stroke-width="2"/>
|
|
||||||
<circle cx="696" cy="26" r="6" fill="#cba6f7"/>
|
|
||||||
<text class="label" x="753" y="27">GPLv3</text>
|
|
||||||
</g>
|
|
||||||
<g>
|
|
||||||
<rect x="816" y="4" width="144" height="44" rx="12" fill="#1e1e2e" stroke="#fab387" stroke-width="2"/>
|
|
||||||
<circle cx="840" cy="26" r="6" fill="#fab387"/>
|
|
||||||
<text class="label" x="896" y="27">v0.1.6</text>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.8 KiB |
|
|
@ -1,66 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="480" viewBox="0 0 1440 480" role="img" aria-labelledby="title desc">
|
|
||||||
<title id="title">Trainlog</title>
|
|
||||||
<desc id="desc">A Catppuccin Mocha banner for a local-first training data system.</desc>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
|
||||||
<stop offset="0" stop-color="#11111b"/>
|
|
||||||
<stop offset="0.58" stop-color="#181825"/>
|
|
||||||
<stop offset="1" stop-color="#1e1e2e"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="lavender" x1="0" y1="0" x2="1" y2="0">
|
|
||||||
<stop offset="0" stop-color="#b4befe"/>
|
|
||||||
<stop offset="1" stop-color="#cba6f7"/>
|
|
||||||
</linearGradient>
|
|
||||||
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
|
|
||||||
<path d="M40 0H0V40" fill="none" stroke="#45475a" stroke-width="1" opacity="0.22"/>
|
|
||||||
</pattern>
|
|
||||||
<filter id="glow" x="-30%" y="-30%" width="160%" height="160%">
|
|
||||||
<feGaussianBlur stdDeviation="7" result="blur"/>
|
|
||||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
|
||||||
</filter>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<rect width="1440" height="480" rx="28" fill="url(#bg)"/>
|
|
||||||
<rect width="1440" height="480" rx="28" fill="url(#grid)"/>
|
|
||||||
<path d="M0 25Q0 0 25 0H1415Q1440 0 1440 25" fill="none" stroke="#b4befe" stroke-width="4"/>
|
|
||||||
|
|
||||||
<g transform="translate(72 64)">
|
|
||||||
<circle cx="12" cy="12" r="7" fill="#f38ba8"/>
|
|
||||||
<circle cx="36" cy="12" r="7" fill="#f9e2af"/>
|
|
||||||
<circle cx="60" cy="12" r="7" fill="#a6e3a1"/>
|
|
||||||
<text x="90" y="19" fill="#6c7086" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="21">~/trainlog</text>
|
|
||||||
|
|
||||||
<text x="0" y="164" fill="#cdd6f4" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="92" font-weight="700" letter-spacing="10">TRAINLOG</text>
|
|
||||||
<rect x="2" y="190" width="610" height="5" rx="2.5" fill="url(#lavender)"/>
|
|
||||||
<text x="0" y="243" fill="#b4befe" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="27" letter-spacing="1">capture · synchronize · inspect · understand</text>
|
|
||||||
<text x="0" y="293" fill="#a6adc8" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="21">Android / C17 + Notcurses / Local Web / SQLite</text>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<g transform="translate(890 72)">
|
|
||||||
<rect width="470" height="336" rx="22" fill="#11111b" stroke="#45475a" stroke-width="2"/>
|
|
||||||
<path d="M0 58H470" stroke="#45475a" stroke-width="2"/>
|
|
||||||
<circle cx="28" cy="29" r="6" fill="#f38ba8"/>
|
|
||||||
<circle cx="50" cy="29" r="6" fill="#f9e2af"/>
|
|
||||||
<circle cx="72" cy="29" r="6" fill="#a6e3a1"/>
|
|
||||||
<text x="96" y="36" fill="#6c7086" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="17">facts.timeline</text>
|
|
||||||
|
|
||||||
<path d="M34 222H436" stroke="#313244" stroke-width="2"/>
|
|
||||||
<path d="M34 118H436M34 170H436M34 274H436" stroke="#313244" stroke-width="1" stroke-dasharray="5 8"/>
|
|
||||||
<path d="M34 221H78L94 219L111 145L128 263L147 212H188L207 194L226 205L245 114L264 248L286 218H327L345 176L364 203L384 202L402 155L420 221H436" fill="none" stroke="#b4befe" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" filter="url(#glow)"/>
|
|
||||||
<circle cx="111" cy="145" r="6" fill="#f38ba8"/>
|
|
||||||
<circle cx="245" cy="114" r="6" fill="#f38ba8"/>
|
|
||||||
<circle cx="402" cy="155" r="6" fill="#a6e3a1"/>
|
|
||||||
|
|
||||||
<rect x="34" y="292" width="112" height="24" rx="12" fill="#313244"/>
|
|
||||||
<circle cx="50" cy="304" r="5" fill="#a6e3a1"/>
|
|
||||||
<text x="63" y="310" fill="#cdd6f4" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="14">local</text>
|
|
||||||
<rect x="158" y="292" width="136" height="24" rx="12" fill="#313244"/>
|
|
||||||
<circle cx="174" cy="304" r="5" fill="#89b4fa"/>
|
|
||||||
<text x="187" y="310" fill="#cdd6f4" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="14">versioned</text>
|
|
||||||
<rect x="306" y="292" width="130" height="24" rx="12" fill="#313244"/>
|
|
||||||
<circle cx="322" cy="304" r="5" fill="#b4befe"/>
|
|
||||||
<text x="335" y="310" fill="#cdd6f4" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="14">factual</text>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<rect x="1" y="1" width="1438" height="478" rx="27" fill="none" stroke="#313244" stroke-width="2"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.2 KiB |
|
|
@ -1,31 +1,20 @@
|
||||||
# Current implementation state
|
# Current implementation state
|
||||||
|
|
||||||
Full-generation synchronization uses one generation/ACK business engine across
|
Full-generation Web synchronization now selects an actually usable MTP peer
|
||||||
versioned transports. On the paired 0.1.4 deployment, automatic local selection
|
before USB, falls back to a separately configured private Drive generation
|
||||||
is Bluetooth Classic RFCOMM first and direct USB/MTP second. The desktop BlueZ
|
namespace, and mirrors successful USB conversations to Drive on a best-effort
|
||||||
service accepts only the configured bonded Android address, Android proves the
|
basis without rolling back USB. Both transports move the same generation/ACK
|
||||||
persisted Trainlog peer identity in the first bounded frame, and reconnect noise
|
bytes; neither transports SQLite. Android has an explicitly enabled foreground
|
||||||
is rate-limited so proximity does not create repeated generations. MTP remains
|
USB listener with mandatory notification and a private-folder Drive grant with
|
||||||
the wired recovery path. The separately configured private Drive transport
|
bounded WorkManager checks. Both use the same concurrency-guarded generation
|
||||||
remains available to its legacy mirror/fallback workflow. No transport copies
|
|
||||||
SQLite.
|
|
||||||
|
|
||||||
Automatic Bluetooth restart recovery preserves the immutable generation
|
|
||||||
identity across conversation-run changes and recognizes the exact original-run
|
|
||||||
ACK by immutable generation ID and manifest digest before full SQLite
|
|
||||||
validation. An unacknowledged branch is excluded
|
|
||||||
from the bounded admission count only when a different sibling has a durable,
|
|
||||||
ledger-backed consumed acknowledgement; the superseded bytes and row remain
|
|
||||||
retained. The desktop service installer retires the exact obsolete private
|
|
||||||
ExecStart override that omitted required runtime paths and installs both
|
|
||||||
`trainlog-syncd` and the BlueZ RFCOMM agent.
|
|
||||||
|
|
||||||
Android synchronization paths share the same concurrency-guarded generation
|
|
||||||
coordinator as SyncScreen. That coordinator has one process-wide ownership
|
coordinator as SyncScreen. That coordinator has one process-wide ownership
|
||||||
arbiter: explicit foreground intent has priority over automatic background
|
arbiter: explicit foreground intent has priority over automatic background
|
||||||
resumption, while a foreground service may remain alive without owning the
|
resumption, while the foreground service may remain alive without owning the
|
||||||
conversation. An automatic run yields cooperatively at safe boundaries; its
|
conversation. An automatic run yields cooperatively at safe boundaries; its
|
||||||
durable generation is retained for a correlated handoff rather than deleted.
|
durable generation is retained for a correlated handoff rather than deleted.
|
||||||
|
Background actionability distinguishes a new request or new correlated remote
|
||||||
|
evidence from a merely stale resumable generation, preventing five-second
|
||||||
|
terminal retry loops.
|
||||||
|
|
||||||
Direct-MTP publication now has an explicit Android visibility boundary.
|
Direct-MTP publication now has an explicit Android visibility boundary.
|
||||||
Durable immutable generation files are submitted to MediaProvider and all scan
|
Durable immutable generation files are submitted to MediaProvider and all scan
|
||||||
|
|
@ -56,33 +45,23 @@ and an accessible send action. Draft/local advances the same preparation to a
|
||||||
ready revision before delivery; ready/local delivers the current revision.
|
ready revision before delivery; ready/local delivers the current revision.
|
||||||
Completed history hides the active preparation exclusively through the
|
Completed history hides the active preparation exclusively through the
|
||||||
delivery's stable execution-session identity and retains all provenance.
|
delivery's stable execution-session identity and retains all provenance.
|
||||||
Starting a delivered mixed preparation preserves continuous duration targets
|
|
||||||
and set/repetition/load/rest targets in the active singleton without creating
|
|
||||||
performed continuous activity, performed sets, MAX results, history, or
|
|
||||||
feedback before explicit user capture.
|
|
||||||
The active-session editor accepts target-only continuous and set-based
|
|
||||||
occurrences: opening an existing stable entry renders its prefilled form,
|
|
||||||
keeps the independent target intact on save, and leaves sibling occurrences
|
|
||||||
unchanged on save or cancellation. A set-based target-only occurrence can add
|
|
||||||
or change equipment without fabricating a performed set.
|
|
||||||
Session completion now also turns a completed target-only continuous timeline
|
|
||||||
into its measured performed duration when no supplemental field is required.
|
|
||||||
Program targets remain independent from facts, so a valid execution may contain
|
|
||||||
fewer or more sets and different repetitions or loads. A strictly session-bound
|
|
||||||
private recovery build can supply a past factual end without retiming exercise
|
|
||||||
markers or immutable heart-rate samples.
|
|
||||||
|
|
||||||
The full-generation path is operational on the paired daily installation.
|
The opt-in full-generation path is operational on the private daily
|
||||||
Bluetooth Classic is the primary local byte transport, MTP remains the wired
|
installation. Android explicit synchronization uses a dedicated coordination
|
||||||
recovery transport, and the existing private Drive namespace remains separately
|
filename carrying the unchanged request-v1 payload. Legacy V3 signalling is
|
||||||
configured for its supported mirror/fallback path. All carry the same bounded
|
additive and occurs only after a fresh strict V3 snapshot succeeds; daemon
|
||||||
generation/ACK artifacts and persistent peer identities; cross-transport replay
|
request identities are deduplicated across both bounded channels. The loopback
|
||||||
is idempotent and no transport carries SQLite. The Bluetooth transport does not
|
Web control and foreground Android coordinator use
|
||||||
yet ingest heart-rate sensor data.
|
the production direct-libmtp adapter, correlated manifest/ACK V1 objects, and
|
||||||
|
persistent peer identities. Controlled USB and private Drive validation proved
|
||||||
|
the complete Web-initiated request, generation, import, publication, and ACK
|
||||||
|
conversation without opening SyncScreen for each run. USB remains preferred;
|
||||||
|
the private `Trainlog/Sync/v1` namespace is configured as a verified mirror and
|
||||||
|
fallback, independently from `Trainlog/AI`. Cross-transport replay is
|
||||||
|
idempotent and neither transport carries SQLite.
|
||||||
|
|
||||||
The public Android release chain advances from v0.1.1 versionCode 2 through
|
The public Android release chain advances from v0.1.1 versionCode 2 through
|
||||||
v0.1.2 versionCode 3, v0.1.3 versionCode 4, v0.1.4 versionCode 5 and v0.1.5
|
v0.1.2 versionCode 3 to v0.1.3 versionCode 4 under certificate SHA-256
|
||||||
versionCode 6 to v0.1.6 versionCode 7 under certificate SHA-256
|
|
||||||
`5ef41117da107c9805ae3215c5ec823cff06216610e6e4f7542a754161c71bd2`.
|
`5ef41117da107c9805ae3215c5ec823cff06216610e6e4f7542a754161c71bd2`.
|
||||||
Higher-numbered private validation builds used a separate Android Debug
|
Higher-numbered private validation builds used a separate Android Debug
|
||||||
certificate and are not members of the public update-signature chain.
|
certificate and are not members of the public update-signature chain.
|
||||||
|
|
@ -97,7 +76,7 @@ The rollout packager now keeps the legacy request daemon and one-shot helper in
|
||||||
the same relocatable, hashed bundle as the Web generation coordinator, avoiding
|
the same relocatable, hashed bundle as the Web generation coordinator, avoiding
|
||||||
mixed-checkout user-service execution during the controlled rollout.
|
mixed-checkout user-service execution during the controlled rollout.
|
||||||
|
|
||||||
Snapshot date: **2026-09-25**.
|
Snapshot date: **2026-09-20**.
|
||||||
|
|
||||||
`TRAINLOG_CODE_READABILITY_V1` normalizes the recent generation-MTP,
|
`TRAINLOG_CODE_READABILITY_V1` normalizes the recent generation-MTP,
|
||||||
generation/ACK, Android-backup, Web Dashboard serialization, tests, doubles and
|
generation/ACK, Android-backup, Web Dashboard serialization, tests, doubles and
|
||||||
|
|
@ -115,9 +94,9 @@ Android is the field companion: it captures training and body data, preserves
|
||||||
the active draft, receives AI proposals, triggers synchronization, and shows
|
the active draft, receives AI proposals, triggers synchronization, and shows
|
||||||
quick summaries. The C17/Notcurses TUI is the administration, inspection,
|
quick summaries. The C17/Notcurses TUI is the administration, inspection,
|
||||||
maintenance, import/export, and technical-tooling surface. The local Web
|
maintenance, import/export, and technical-tooling surface. The local Web
|
||||||
sibling provides its embedded application shell, Dashboard, Sessions,
|
sibling now provides its embedded application shell and implemented Dashboard;
|
||||||
Programmes, Exercises, factual Analyse views, and Sleep Diary
|
its future business surfaces will own broader analysis, program/session
|
||||||
workflow through Trainlog Core.
|
preparation, and exercise workflows through Trainlog Core.
|
||||||
|
|
||||||
Trainlog Core owns business truth. The desktop SQLite database remains the
|
Trainlog Core owns business truth. The desktop SQLite database remains the
|
||||||
canonical local source of truth and long-term history; no interface owns a
|
canonical local source of truth and long-term history; no interface owns a
|
||||||
|
|
@ -128,11 +107,11 @@ parallel implementation of its rules.
|
||||||
| Boundary | Current state |
|
| Boundary | Current state |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
|
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
|
||||||
| Desktop SQLite | schema v36; v36 makes Sleep revision payload tables append-only; v35 gives every Sleep medication intake a structured `quantity` (1..99), defaulting historical rows to 1 |
|
| Desktop SQLite | schema v28 |
|
||||||
| Android SQLite | schema v33; v33 makes Sleep revision payload tables append-only; v32 gives every Sleep medication intake a structured `quantity` (1..99), defaults historical rows to 1, and records a distinct quick-publication tip |
|
| Android SQLite | schema v25 |
|
||||||
| Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport |
|
| Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport |
|
||||||
| Desktop terminal backend | Notcurses only |
|
| Desktop terminal backend | Notcurses only |
|
||||||
| Trainlog product version | `0.1.6` stable, synchronized across Android and desktop; latest stable release: `v0.1.6` |
|
| Trainlog product version | `0.1.3` stable, synchronized across Android and desktop; latest stable release: `v0.1.3` |
|
||||||
| Interface language | `TRAINLOG_I18N_V0_1_1=PASS`: French default; English selectable in Settings → Language on both surfaces |
|
| Interface language | `TRAINLOG_I18N_V0_1_1=PASS`: French default; English selectable in Settings → Language on both surfaces |
|
||||||
| AI history export | `TRAINLOG_AI_EXPORT_V1` active |
|
| AI history export | `TRAINLOG_AI_EXPORT_V1` active |
|
||||||
| AI session proposals | `TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING` |
|
| AI session proposals | `TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING` |
|
||||||
|
|
@ -150,7 +129,6 @@ parallel implementation of its rules.
|
||||||
| Web Dashboard tiles | `WEB_DASHBOARD_TILES_V1=PASS/FROZEN` |
|
| Web Dashboard tiles | `WEB_DASHBOARD_TILES_V1=PASS/FROZEN` |
|
||||||
| Web Dashboard visualizations | `WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` |
|
| Web Dashboard visualizations | `WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` |
|
||||||
| Web Dashboard V1 | `WEB_DASHBOARD_V1=PASS/FROZEN` |
|
| Web Dashboard V1 | `WEB_DASHBOARD_V1=PASS/FROZEN` |
|
||||||
| Sleep Diary V1/V2 | `SLEEP_DIARY_V1=PASS` remains frozen and readable. The active Sleep exchange requires `sleep-diary-v2` / `sleep-diary-v2.json`: structured per-intake quantity and quick-publishable current revisions supplement the existing causal diary model without changing V1. |
|
|
||||||
| Web Sessions V1 | `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN` (controlled desktop/Android deployment validated) |
|
| Web Sessions V1 | `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN` (controlled desktop/Android deployment validated) |
|
||||||
| Web Sessions deletion and Programs V1 | `TRAINLOG_WEB_SESSIONS_DELETE_AND_PROGRAMS_V1=PASS` (private grouped rollout validated) |
|
| Web Sessions deletion and Programs V1 | `TRAINLOG_WEB_SESSIONS_DELETE_AND_PROGRAMS_V1=PASS` (private grouped rollout validated) |
|
||||||
| Programs presentation, Android projection, and deletion | `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=PASS` (private coordinated deployment and restart/replay validated) |
|
| Programs presentation, Android projection, and deletion | `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=PASS` (private coordinated deployment and restart/replay validated) |
|
||||||
|
|
@ -160,67 +138,21 @@ parallel implementation of its rules.
|
||||||
| Web sync API V1 | `TRAINLOG_WEB_SYNC_API_V1=PASS/FROZEN` |
|
| Web sync API V1 | `TRAINLOG_WEB_SYNC_API_V1=PASS/FROZEN` |
|
||||||
| Web sync button V1 | `TRAINLOG_WEB_SYNC_BUTTON_V1=PASS/FROZEN` |
|
| Web sync button V1 | `TRAINLOG_WEB_SYNC_BUTTON_V1=PASS/FROZEN` |
|
||||||
| Sync Web end-to-end wrapper | `TRAINLOG_SYNC_WEB_END_TO_END_V1=PASS/FROZEN` (isolated) |
|
| Sync Web end-to-end wrapper | `TRAINLOG_SYNC_WEB_END_TO_END_V1=PASS/FROZEN` (isolated) |
|
||||||
| Private full-generation rollout | `TRAINLOG_SYNC_FINALIZATION_AND_ROLLOUT_V1=PASS` (authenticated Bluetooth primary transport with direct MTP recovery on one authorized phone) |
|
| Private full-generation rollout | `TRAINLOG_SYNC_FINALIZATION_AND_ROLLOUT_V1=PASS` (direct MTP, one authorized phone) |
|
||||||
| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` |
|
| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` |
|
||||||
| Web Exercises V1 | `TRAINLOG_WEB_EXERCISES_V1=PASS` |
|
| Web Exercises V1 | `TRAINLOG_WEB_EXERCISES_V1=PASS` |
|
||||||
| Cardio persistence/sync | `TRAINLOG_CARDIO_PERSISTENCE_SYNC_V1=PASS`: Android v27 + desktop v30, strict optional `trainlog-heart-rate` V1 Android→desktop companion, generation relation and correlated ACK |
|
| Current operational cursor | `TRAINLOG_ANDROID_UI_REDESIGN_V1_CONTRACT` |
|
||||||
| Recovered session HR tail | `TRAINLOG_HR_TRUNCATE_AFTER_SESSION_END_V1=PASS`: delayed Android finalization truncates BPM/RR strictly after the inclusive factual end, and the separate `trainlog-heart-rate-corrections` V1 companion reuses causal state without widening frozen causal-deletions V1. The paired-device recovery for `se_fa845379-794d-4bb6-8cd3-48187ebbd134` retained 3,429 BPM/RR facts through 11:16; two successive full MTP exchanges received consumed ACKs, and replay of the original 4,919-sample Heart Rate V1 artifact did not resurrect the tail. |
|
|
||||||
| Cardio BLE diagnostics | `TRAINLOG_CARDIO_BLE_DIAGNOSTICS_V1=PASS`: real CYCPLUS H2 validated on Android; `0x180D` + `0x2A37`, Battery Service, approximately 1 Hz notifications and one raw RR interval per observed notification; contact and energy fields absent in the observed payloads |
|
|
||||||
| Cardio persistent acquisition | `TRAINLOG_CARDIO_PERSISTENT_ACQUISITION_V1=PASS`: selected sensor persists, dedicated connected-device foreground service reconnects, stale BPM is cleared, and the global Android `♥ BPM` indicator is grey/orange/green by connection state; real CYCPLUS restart and Bluetooth-off/on recovery validated on private vc40 |
|
|
||||||
| Training timeline | `TRAINLOG_CARDIO_TRAINING_TIMELINE_V1=PASS`: real session bounds reuse canonical lifecycle timestamps; exercise start/end are explicit user actions; live BPM samples snapshot the active stable `entry_id`; session finalization refuses an unclosed exercise; timeline sync is Android→desktop idempotent |
|
|
||||||
| Sleep Android one-tap | `TRAINLOG_SLEEP_ANDROID_ONE_TAP_V1=PASS`: one-tap Couché / medication / Réveil / Levé, guarded 10 s undo, correction route, and sleep-entry-owned cardio capture remain intact. Réveil now creates a structured 30-minute `long_awake` window, repeated taps extend it, and Levé clamps it. The validated V2 development delta additionally creates/reuses one 18:00-to-18:00 pending entry for pre-bed medication, reuses its stable `entry_id` at Couché, and exposes a compact quantity control. |
|
|
||||||
| Cardio session type | `TRAINLOG_CARDIO_SESSION_TYPE_V1=PASS`: Android v29 + desktop v32 use canonical `session_kind=cardio`; frozen mobile/history and draft formats keep legacy training/max_test; dedicated `trainlog-cardio-sessions` V1 syncs completed cardio sessions Android→desktop idempotently |
|
|
||||||
| Cardio calibration | `TRAINLOG_CARDIO_CALIBRATION_V1=PASS`: reserved system Calibration cardio exercise, real measured observed peak, ≥3 min recovery with bounded +1/+2/+3 min factual samples, immutable profile and Android→desktop calibration companion |
|
|
||||||
| Cardio BPM guidance | `TRAINLOG_CARDIO_BPM_GUIDANCE_V1=PASS`: absolute/calibration-derived immutable BPM targets, 2 BPM hysteresis, 3 s confirmation, stale-signal suspension, durable phase/condition history, Android live guidance UI and strict Android→desktop `trainlog-cardio-guidance` V1 sync |
|
|
||||||
| Cardio Web visualization | `TRAINLOG_CARDIO_WEB_VISUALIZATION_V1=PASS`: one bounded factual heart-rate timeline read model/HTTP endpoint powers Web History and Sleep with raw BPM/RR, real session/exercise/Sleep markers, guided targets/instructions and calibration facts; no sleep-stage or medical inference |
|
|
||||||
| Sleep tonight readiness delta | `TRAINLOG_SLEEP_TONIGHT_CLOSEOUT_V1=PASS`: Meson 111/111, import checks 6/6, Android `testDebugUnitTest` plus `assembleDebug`, Web 191/191 plus typecheck/build, exact V2 quantity replay, private vc47 schema-v32 migration, fresh CYCPLUS BPM and non-mutating Android/Web visual review. |
|
|
||||||
| 0.1.5 validation | `TRAINLOG_0_1_5_VALIDATION_V1=PASS`: native 111/111; Android unit-test task and APK build; Web 191/191 plus typecheck/build; JSON/import, C-format and diff checks pass. Private vc47 preserves real data through schema v32, renders pre-bed medication quantity controls, reconnects the CYCPLUS and presents the multi-night Web Sleep agenda. |
|
|
||||||
| Tomorrow session readiness | `TRAINLOG_TOMORROW_SESSION_CARDIO_READINESS_V1=PASS`: the private vc46 is clean on schema v31 with fresh CYCPLUS BPM and the next Program session is unambiguous; one combined regression proves two ordered occurrences loaded from the synchronized Program, Program provenance, factual timeline/HR ownership, finalization, full generation, desktop import, replay and correlated ACK |
|
|
||||||
| 0.1.6 validation | `TRAINLOG_0_1_6_VALIDATION_V1=PASS`: native Meson 114/114 and Clang ASan/UBSan 114/114; Android 317 tests (312 passed, five documented optional skips), debug APK and signed release APK; Web 32 files / 209 tests plus typecheck/build; JSON/import, changed-C formatting and diff checks pass. |
|
|
||||||
| Current operational cursor | Stable v0.1.6 closes the Web refinement, Sleep timeline, synchronization recovery and factual session-finalization cycle; subsequent stabilization opens as 0.1.7 after the immutable release tag. |
|
|
||||||
| Sleep awake/selection/PDF refinement | `TRAINLOG_SLEEP_AWAKE_SELECTION_PDF_V1=PASS`: one temporal projection drives agenda and selected HR detail; a separate inclusive night-start date range drives preview/export from the loaded period. The agenda axis and rows share 24 exact hour columns; estimated sleep duration is consistent and remains non-persistent. |
|
|
||||||
| Sleep Réveil interval policy | Android Réveil creates a revisioned 30-minute `long_awake` window; repeated taps extend it and Levé clamps it. The real 2026-09-23 night is causally repaired on Android/desktop and renders two orange intervals in agenda, HR and PDF; Android 307 passed plus five optional skips, and Web 208/208 passed. |
|
|
||||||
| Sleep revision collision recovery | `TRAINLOG_SLEEP_REVISION_COLLISION_RECOVERY_V1=PASS`: one unpublished divergent desktop tip was re-identified without payload loss, the immutable Android identity was restored exactly, and a fresh successor retained the non-conflicting union. The original generation was consumed with a full SQLite ACK; the real 24/09 session, Sleep facts, cardio samples, timeline, feedback, and Program provenance are visible on Desktop/Web. Schema v36/v33 and importer payload checks prevent recurrence. |
|
|
||||||
| Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered |
|
| Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered |
|
||||||
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
|
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
|
||||||
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |
|
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |
|
||||||
| Synchronization data/lifecycle slice | `TRAINLOG_SYNC_DATA_LIFECYCLE_V1=PASS/FROZEN` |
|
| Synchronization data/lifecycle slice | `TRAINLOG_SYNC_DATA_LIFECYCLE_V1=PASS/FROZEN` |
|
||||||
| Synchronization causal deletion slice | `TRAINLOG_SYNC_CAUSAL_DELETE_V1=PASS/FROZEN` |
|
| Synchronization causal deletion slice | `TRAINLOG_SYNC_CAUSAL_DELETE_V1=PASS/FROZEN` |
|
||||||
| Synchronization generation/ACK slice | `TRAINLOG_SYNC_GENERATION_ACK_V1=PASS/FROZEN` (explicit staged entry points; active transport remains V3) |
|
| Synchronization generation/ACK slice | `TRAINLOG_SYNC_GENERATION_ACK_V1=PASS/FROZEN` (explicit staged entry points; active transport remains V3) |
|
||||||
| Bluetooth synchronization transport | `TRAINLOG_SYNC_BLUETOOTH_TRANSPORT_V1=PASS/FROZEN`; independent from future heart-rate sensor ingestion |
|
| Generation MTP and Android backup | Software complete and validated in the authorized private physical-device rollout |
|
||||||
| Generation MTP and Android backup | Software complete and retained as the authorized wired recovery path |
|
|
||||||
|
|
||||||
Desktop and Android schema numbers are independent. Neither changes the frozen
|
Desktop and Android schema numbers are independent. Neither changes the frozen
|
||||||
Trainlog JSON V1 contract.
|
Trainlog JSON V1 contract.
|
||||||
|
|
||||||
The active Sleep V2 delta keeps the Web agenda period-owned: every returned
|
|
||||||
night has one row on the shared 18:00-to-18:00 geometry. Selecting a row is
|
|
||||||
presentation-only and atomically drives the detail dates, editor content, and
|
|
||||||
full-width heart-rate detail; it performs no database mutation. PDF preview
|
|
||||||
and download instead share an explicit inclusive range over night start dates,
|
|
||||||
initialized to the loaded agenda bounds and independent of detail selection. Its
|
|
||||||
rise/fall overlays are descriptive relative measured
|
|
||||||
variation only (five-minute trailing reference excluding the current 60-second
|
|
||||||
window; threshold `max(10 BPM, 15%)`; gaps over three seconds excluded). They
|
|
||||||
are not sleep events, sleep stages, causes, readiness, or medical inference.
|
|
||||||
The detail shares one normalized time projection with declared sleep and
|
|
||||||
awakening intervals, exact Couché/Réveil/Levé markers, medication markers, and
|
|
||||||
full-hour ticks between exact visible bounds. Medication presentation retains
|
|
||||||
unit dose and quantity as separate facts. A missing declared sleep interval
|
|
||||||
uses only a labeled, non-medical Web estimate from Couché plus 45 minutes to
|
|
||||||
Levé minus 10 minutes when both bounds exist; it never changes canonical Sleep
|
|
||||||
data. Agenda, selected-night graph, and ranged PDF consume the same projection of
|
|
||||||
sleep intervals, every structured long-awake interval, point events, medication
|
|
||||||
intakes, and bounds. The agenda renders an estimated interval with distinct
|
|
||||||
hatching, never substitutes it for a factual interval, and retains the exact
|
|
||||||
shared 18:00-to-18:00 geometry, reinforced by 24 equal hour columns shared by
|
|
||||||
every row, with each hour label centered inside the cell it names. Displayed
|
|
||||||
events use one factual night offset even when revision metadata is UTC, and
|
|
||||||
French agenda headings use `DD/MM/YYYY`. Displayed sleep duration is the sum of factual
|
|
||||||
sleep intervals or, in their absence, the displayed estimate minus overlapping
|
|
||||||
structured awakenings. The overlays remain available without an HR capture and
|
|
||||||
do not fabricate BPM.
|
|
||||||
|
|
||||||
Language is device-local presentation state. Android keeps it in dedicated
|
Language is device-local presentation state. Android keeps it in dedicated
|
||||||
SharedPreferences; desktop keeps it in the XDG configuration file
|
SharedPreferences; desktop keeps it in the XDG configuration file
|
||||||
`trainlog/presentation.conf`. A successful selection updates the UI
|
`trainlog/presentation.conf`. A successful selection updates the UI
|
||||||
|
|
@ -296,10 +228,6 @@ Android currently provides:
|
||||||
- exactly one durable active session draft with identity-preserving continuous
|
- exactly one durable active session draft with identity-preserving continuous
|
||||||
multi-position drag reorder,
|
multi-position drag reorder,
|
||||||
resume, confirmed discard, and atomic completion;
|
resume, confirmed discard, and atomic completion;
|
||||||
- complete scrollable equipment selection and caller-aware creation from an
|
|
||||||
active occurrence editor, with the explicit catalogue-created ID selected on
|
|
||||||
return and the occurrence identity, plan, raw form and siblings retained for
|
|
||||||
in-place save;
|
|
||||||
- completed-session consultation, transient drag reorder, and atomic factual or
|
- completed-session consultation, transient drag reorder, and atomic factual or
|
||||||
order correction while retaining session/occurrence/feedback identities for
|
order correction while retaining session/occurrence/feedback identities for
|
||||||
synchronization;
|
synchronization;
|
||||||
|
|
@ -347,53 +275,6 @@ the result. The Web aggregate now exposes the distinct factual snapshot through
|
||||||
|
|
||||||
## Local Web
|
## Local Web
|
||||||
|
|
||||||
The 0.1.6 refinement adds a persistent French/English preference and a dedicated
|
|
||||||
Settings route without moving the global synchronization action. Dashboard
|
|
||||||
activity is grouped by calendar month/ISO week, its progression tile is now a
|
|
||||||
global factual synthesis, and Analyse progresses from primary BODY ZONE to
|
|
||||||
exercise to individual detail. Exercise work time is derived exclusively from
|
|
||||||
`session_exercise_timeline` start/end facts; missing timelines remain unknown.
|
|
||||||
Measurements first expose unit-safe relative change cards before real-unit
|
|
||||||
detail. Dashboard and Analyse reuse the same human-proportioned BODY ZONES SVG.
|
|
||||||
|
|
||||||
The Dashboard layout keeps a 12-column canonical grid but permits every card
|
|
||||||
to reach full width and several useful heights; per-card minima, overlap
|
|
||||||
validation, exact save/cancel/reset semantics, and keyboard resizing remain
|
|
||||||
authoritative. Settings and synchronization form the rightmost Header action
|
|
||||||
cluster. Program progress uses the shared success color while retaining its
|
|
||||||
factual completed/total text. Responsive charts preserve their viewBox aspect
|
|
||||||
ratio, and Analyse explicitly distinguishes unavailable exercise timing from
|
|
||||||
partial measured coverage instead of deriving time from sets or repetitions.
|
|
||||||
The shared Sleep agenda owns one label/track/facts grid. Its 25 exact hourly
|
|
||||||
positions and every event, interval and medication marker use the same
|
|
||||||
coordinates: 18:00 at 0%, 00:00 at 25%, 06:00 at 50%, 12:00 at 75%, and 18:00
|
|
||||||
at 100%. Ordinary desktop widths show the 13 two-hour labels, wide layouts show
|
|
||||||
all hours, and narrow layouts retain the five six-hour anchors without changing
|
|
||||||
the underlying geometry.
|
|
||||||
|
|
||||||
The Equipment route projects supplied and custom definitions with exercise,
|
|
||||||
history, preparation and Program reference counts. Its guarded merge accepts a
|
|
||||||
custom duplicate only, transfers relational references in one SQLite IMMEDIATE
|
|
||||||
transaction, preserves occurrence/MAX facts indirectly through occurrence
|
|
||||||
identity, and writes the existing causal deletion boundary. An immutable
|
|
||||||
execution-draft JSON reference rejects the operation rather than being edited.
|
|
||||||
Browser mutation identities use the shared UUIDv4 helper backed by
|
|
||||||
`crypto.getRandomValues()`; no Web mutation depends on `crypto.randomUUID()`,
|
|
||||||
so `http://trainlog.perf` remains supported.
|
|
||||||
|
|
||||||
An `execution_drafts` row marked active but containing no `exercises` or
|
|
||||||
legacy `entries` is an orphaned projection, not a resumable workout. Web may
|
|
||||||
delete that exact row through the existing revision-guarded causal transaction;
|
|
||||||
the durable tombstone prevents synchronization replay from resurrecting it.
|
|
||||||
An active draft with any occurrence remains protected from Web deletion.
|
|
||||||
|
|
||||||
Generation admission archives only rows backed by an exact correlated consumed
|
|
||||||
ACK and retains the newest two acknowledged lineage members. Legacy terminal
|
|
||||||
Android rows that predate the durable ACK ledger are neither fabricated nor
|
|
||||||
deleted, but no longer consume the active admission window. Captured,
|
|
||||||
published, waiting-ACK, and current acknowledged rows continue to count until
|
|
||||||
safe archival, so capacity recovery never prunes unacknowledged evidence.
|
|
||||||
|
|
||||||
`TRAINLOG_WEB_V1` architecture, API independence, local-network boundary,
|
`TRAINLOG_WEB_V1` architecture, API independence, local-network boundary,
|
||||||
browser shell, Dashboard, layout ownership, build/runtime separation, and
|
browser shell, Dashboard, layout ownership, build/runtime separation, and
|
||||||
security invariants are canonical. The CLI/local HTTP infrastructure and
|
security invariants are canonical. The CLI/local HTTP infrastructure and
|
||||||
|
|
@ -410,24 +291,7 @@ explicit proposal derivation and generation-backed Android delivery. The
|
||||||
top-level `/programmes` route is an operational daily active-Program calendar;
|
top-level `/programmes` route is an operational daily active-Program calendar;
|
||||||
Sessions → Programmes remains the technical administration/import, list,
|
Sessions → Programmes remains the technical administration/import, list,
|
||||||
detail, archive, and delete surface. `/exercices` is the operational desktop
|
detail, archive, and delete surface. `/exercices` is the operational desktop
|
||||||
catalogue administration surface. `/analyse` is implemented with Overview,
|
catalogue administration surface; Analyse remains an explicit placeholder.
|
||||||
Exercises, Distribution, and Measurements sections over one bounded Core read
|
|
||||||
model. Its global 7/30/90-day/all filter is applied in the backend; Dashboard
|
|
||||||
uses the same 30-day calculations for activity, active-Program progress,
|
|
||||||
exercise progression, measurements, and BODY ZONE links.
|
|
||||||
|
|
||||||
The Analyse API validates period, exercise identity, measurement metric and
|
|
||||||
page, rejects unknown or duplicate parameters, and bounds exercises and chart
|
|
||||||
points. Missing facts remain null. Exercise output respects persisted
|
|
||||||
tracking/load modes, explicit MAX rows are never estimated, and duration,
|
|
||||||
distance, speed, external load, and external volume appear only when their
|
|
||||||
source observations support them. BODY ZONES are labelled as recorded primary
|
|
||||||
zone exposures/associated sets rather than physiological percentages. All
|
|
||||||
fourteen stored body metrics expose period-first, period-last, and an absolute
|
|
||||||
delta only with at least two points. The route and Dashboard deep links have
|
|
||||||
safe invalid-query fallbacks, French/English labels, keyboard controls, and
|
|
||||||
responsive no-overflow layouts. Visual inspection remains pending before any
|
|
||||||
freeze marker.
|
|
||||||
|
|
||||||
Exercises uses bounded Core-owned list/detail reads, canonical name
|
Exercises uses bounded Core-owned list/detail reads, canonical name
|
||||||
normalization, BODY ZONES taxonomy, profiled creation/update commands and
|
normalization, BODY ZONES taxonomy, profiled creation/update commands and
|
||||||
|
|
@ -513,10 +377,10 @@ The full Android lint comparison against an isolated clean `main` worktree is
|
||||||
unchanged at ten pre-existing errors: one `NewApi`, one
|
unchanged at ten pre-existing errors: one `NewApi`, one
|
||||||
`LocalContextConfigurationRead`, and eight bilingual `StringFormatMatches`
|
`LocalContextConfigurationRead`, and eight bilingual `StringFormatMatches`
|
||||||
diagnostics. This UI tranche adds no lint error and changes no lint policy.
|
diagnostics. This UI tranche adds no lint error and changes no lint policy.
|
||||||
The stable Android v0.1.6 tag uses `versionName=0.1.6` and public versionCode 7.
|
The next private daily Android candidate remains on development
|
||||||
Private validation builds may still supply only a strictly increasing
|
`versionName=0.1.3`; only its strictly increasing `versionCode` may be supplied
|
||||||
`versionCode` as a build-time override; that does not create a public release
|
as a build-time override. No public tag or release is created by that private
|
||||||
or change schemas and protocols.
|
candidate process.
|
||||||
|
|
||||||
The private Program execution rollout installed desktop schema v27 and the
|
The private Program execution rollout installed desktop schema v27 and the
|
||||||
signed nondebuggable Android versionCode 17/schema v25 update without
|
signed nondebuggable Android versionCode 17/schema v25 update without
|
||||||
|
|
@ -535,17 +399,6 @@ ACK without duplicating the execution or resurrecting the Program. A final
|
||||||
correlated exchange remained completed with no missing capability. The real
|
correlated exchange remained completed with no missing capability. The real
|
||||||
Program remains active and unique with 24 sessions and 164 ordered entries.
|
Program remains active and unique with 24 sessions and 164 ordered entries.
|
||||||
|
|
||||||
Program-owned manual preparations now carry their stable Program provenance to
|
|
||||||
Android. Starting and finalizing such a delivery retains both identities, and a
|
|
||||||
strict same-delivery enrichment repairs completed sessions produced from the
|
|
||||||
earlier omission without changing workout facts. Android and Web therefore
|
|
||||||
derive the completed count and per-session state from the same execution
|
|
||||||
identity after synchronization and restart.
|
|
||||||
The historical S1 A delivery was recovered onto its original completed
|
|
||||||
`session_id`; Android and Web now both report one completed session out of 24.
|
|
||||||
Generation capture also accepts its valid local continuous duration plans
|
|
||||||
without projecting them as unsupported set targets.
|
|
||||||
|
|
||||||
The private Programs rollout migrated desktop schema v25 to v26 and Android
|
The private Programs rollout migrated desktop schema v25 to v26 and Android
|
||||||
schema v23 to v24 after verified backups, then installed the signed
|
schema v23 to v24 after verified backups, then installed the signed
|
||||||
non-debuggable versionCode 16 APK without uninstalling or clearing data. The
|
non-debuggable versionCode 16 APK without uninstalling or clearing data. The
|
||||||
|
|
@ -734,8 +587,9 @@ tests, Android debug assembly, and 211 Android tests (207 passed, four optional
|
||||||
historical-fixture skips). Strict desktop builds use Clang 22.1.8; the compared
|
historical-fixture skips). Strict desktop builds use Clang 22.1.8; the compared
|
||||||
pre-existing GCC 16.2.1 Web warning gate remains documented rather than green.
|
pre-existing GCC 16.2.1 Web warning gate remains documented rather than green.
|
||||||
|
|
||||||
- `TRAINLOG_WEB_EXERCISES_V1=PASS`. `TRAINLOG_WEB_ANALYSIS_V1` is implemented
|
- `TRAINLOG_WEB_EXERCISES_V1=PASS`. The next planned work is
|
||||||
on the 0.1.4 branch and awaits the requested Dashboard/Analyse visual review.
|
`TRAINLOG_ANDROID_UI_REDESIGN_V1`; it remains unimplemented. Analyse remains
|
||||||
|
a placeholder and is not a v0.1.3 priority.
|
||||||
- `APP_SHELL_V1` retains a recorded legacy visual/accessibility review gate; its
|
- `APP_SHELL_V1` retains a recorded legacy visual/accessibility review gate; its
|
||||||
observations are inputs to, not decisions for, the new 0.1.3 Android contract.
|
observations are inputs to, not decisions for, the new 0.1.3 Android contract.
|
||||||
- Session Generator V1 is hidden while V2 planning semantics are developed.
|
- Session Generator V1 is hidden while V2 planning semantics are developed.
|
||||||
|
|
|
||||||
153
docs/database.md
153
docs/database.md
|
|
@ -1,158 +1,5 @@
|
||||||
# Desktop database
|
# Desktop database
|
||||||
|
|
||||||
## Causal heart-rate tail correction (schema v36, no migration)
|
|
||||||
|
|
||||||
The separate `trainlog-heart-rate-corrections` V1 companion reuses the existing
|
|
||||||
`sync_causal_operations` and `sync_causal_state` tables for the additive local
|
|
||||||
`heart_rate_tail` state. The frozen `trainlog-causal-deletions` V1 artifact
|
|
||||||
continues to emit and accept exactly its original seven target kinds. A heart-
|
|
||||||
rate correction's composite target contains the stable capture identity and an
|
|
||||||
inclusive offset-aware cutoff. Applying it deletes only BPM samples strictly
|
|
||||||
after the cutoff; declared RR foreign keys cascade those exact children, and
|
|
||||||
the capture end becomes the cutoff in the same transaction. The predecessor
|
|
||||||
revision hashes the original ordered raw capture, including RR values, so a
|
|
||||||
correction cannot be silently applied to a different measurement history.
|
|
||||||
|
|
||||||
The immutable Heart Rate V1 generation remains audit evidence. Import applies
|
|
||||||
known causal cutoffs before stable-identity comparison or insertion, which
|
|
||||||
makes old-generation replay idempotent without rewriting its manifest or ACK.
|
|
||||||
No schema or frozen `TRAINLOG_FORMAT_V1` change is involved.
|
|
||||||
|
|
||||||
## Schema v36: append-only Sleep revision payloads
|
|
||||||
|
|
||||||
Schema v36 adds database-level `BEFORE UPDATE` and `BEFORE DELETE` guards for
|
|
||||||
Sleep diary revisions, their events and medication intakes, and medication
|
|
||||||
catalog revisions. A stable revision identity therefore cannot be detached
|
|
||||||
from its canonical parent or payload even through a direct SQLite write.
|
|
||||||
Normal correction remains append-only: it inserts a fresh UUIDv4 revision and
|
|
||||||
advances the entry or medication current pointer transactionally. The migration
|
|
||||||
is additive and does not rewrite any existing Sleep identity, payload, causal
|
|
||||||
edge, generation membership, publication state, or acknowledgement.
|
|
||||||
|
|
||||||
Importers additionally compare the immutable payload of every already-known
|
|
||||||
Sleep revision before treating an incoming ancestor as stale/idempotent. Thus
|
|
||||||
an exact historical replay remains accepted, while reuse of the same identity
|
|
||||||
with a different parent or content is an explicit collision.
|
|
||||||
|
|
||||||
## Schema v35: Sleep Diary quantity V2
|
|
||||||
|
|
||||||
Schema v35 additively adds `sleep_medication_intakes.quantity`, a required
|
|
||||||
integer in `1..99` with default `1`. It is the count of the separately stored
|
|
||||||
per-unit dose, not a free-text convention and not a total-dose replacement.
|
|
||||||
Thus `75 mg` with quantity `2` retains `dose_value=75`, `dose_unit=mg`, and
|
|
||||||
`quantity=2`. Existing immutable intake rows read as one unit; the migration
|
|
||||||
does not alter their revision identity, timestamps, causal ancestry, events,
|
|
||||||
or medication snapshot.
|
|
||||||
|
|
||||||
Sleep Diary V1 remains a frozen historical input. Quantity is carried by the
|
|
||||||
separate V2 companion; desktop v35 validates the V2 bounded value while still
|
|
||||||
hydrating historical V1 input as quantity one.
|
|
||||||
|
|
||||||
## Schema v34: synchronized Cardio guidance V1
|
|
||||||
|
|
||||||
Schema v34 additively stores completed Android-owned guided-cardio runs. `cardio_guidance_runs` keeps the stable run/session identity and exact run bounds. `cardio_guidance_phases` stores the ordered `entry_id`-owned phase, its immutable resolved BPM target, optional exact calibration provenance, exit condition, phase bounds and final instruction. `cardio_guidance_events` stores only instruction changes, not another copy of the raw heart-rate curve.
|
|
||||||
|
|
||||||
The importer requires the referenced canonical session to be `cardio`, every phase `entry_id` to exist in that session, and every phase interval to remain inside the synchronized exercise timeline. Calibration-derived targets must reference an imported calibration with the same observed peak. Any guidance event carrying BPM must match an exact synchronized raw heart-rate sample at the same timestamp; suspended events intentionally carry no BPM.
|
|
||||||
|
|
||||||
No migration invents phases, instructions, BPM or calibration references.
|
|
||||||
|
|
||||||
## Schema v33: synchronized Cardio calibration V1
|
|
||||||
|
|
||||||
Schema v33 additively stores completed Android-owned Cardio calibration profiles. `cardio_calibrations` is keyed by stable `calibration_id` and retains protocol version, canonical cardio `session_id`, stable occurrence `entry_id`, exact calibration/effort/end timestamps, the synchronized heart-rate `capture_id`, observed peak BPM and import time.
|
|
||||||
|
|
||||||
`cardio_calibration_recovery` stores only factual recovery samples for the defined +60, +120 and +180 second references. The source curve remains Heart Rate V1; the calibration importer verifies that the observed peak and every recovery `(observed_at, bpm)` pair exist in that raw capture. It never derives, interpolates or rewrites HR samples.
|
|
||||||
|
|
||||||
Android owns the active calibration lifecycle. Desktop stores only completed profiles. A later calibration creates a new stable profile and never rewrites an older session or reference.
|
|
||||||
|
|
||||||
## Schema v32: canonical cardio session kind
|
|
||||||
|
|
||||||
Schema v32 adds the product-owned `sessions.session_kind` column with the
|
|
||||||
explicit domain `training | max_test | cardio`. The pre-existing
|
|
||||||
`sessions.session_type` column remains intentionally frozen to
|
|
||||||
`training | max_test` for compatibility with historical codecs and MAX
|
|
||||||
semantics. A cardio row therefore stores `session_type=training` only as a
|
|
||||||
legacy compatibility projection and `session_kind=cardio` as the canonical
|
|
||||||
product fact.
|
|
||||||
|
|
||||||
The v31→v32 migration introspects historical `sessions` layouts before
|
|
||||||
backfill. When the legacy `session_type` column exists it seeds
|
|
||||||
`session_kind` from it; very old historical fixtures that legitimately lack
|
|
||||||
that column keep the session kind produced by their earlier migration chain.
|
|
||||||
No existing session identity, occurrence, set, timeline, Program provenance or
|
|
||||||
heart-rate fact is rewritten.
|
|
||||||
|
|
||||||
Completed cardio sessions synchronize Android→desktop through the separate
|
|
||||||
optional `trainlog-cardio-sessions` V1 generation companion. The frozen
|
|
||||||
mobile/history V4 and execution-draft formats are not widened to a third
|
|
||||||
session type.
|
|
||||||
|
|
||||||
## Schema v31: synchronized session exercise timeline
|
|
||||||
|
|
||||||
Schema v31 additively owns Android-recorded execution timing for completed
|
|
||||||
session occurrences in `session_exercise_timeline`. Each row is keyed by the
|
|
||||||
stable `session_id + entry_id` pair and stores the matching catalogue
|
|
||||||
`exercise_id`, the real user-action `started_at` and `ended_at`, and import
|
|
||||||
time. The importer requires the existing canonical session and occurrence to
|
|
||||||
match; it never creates workout history or infers exercise timing from order,
|
|
||||||
sets, plans, or heart-rate samples.
|
|
||||||
|
|
||||||
The session bounds remain the existing canonical `sessions.started_at` and
|
|
||||||
`sessions.ended_at`. The separate `trainlog-session-timeline` V1 companion
|
|
||||||
must match those exact values before occurrence timing can be imported.
|
|
||||||
|
|
||||||
## Schema v30: synchronized Heart Rate V1
|
|
||||||
|
|
||||||
Schema v30 additively owns Android-recorded heart-rate captures after
|
|
||||||
full-generation synchronization. `heart_rate_captures` stores one immutable
|
|
||||||
stopped capture with its stable `hrc_` identity, owner kind (`session` or
|
|
||||||
`sleep`), stable owner identity, exact start/end timestamps, optional sensor
|
|
||||||
name, and import time.
|
|
||||||
|
|
||||||
`heart_rate_samples` stores the received timestamp, measured BPM, optional
|
|
||||||
exercise `entry_id` snapshot, optional sensor-contact state, and optional
|
|
||||||
cumulative energy value. `heart_rate_rr_intervals` preserves zero or more raw
|
|
||||||
Heart Rate Service RR values in their native 1/1024-second units. Desktop never
|
|
||||||
creates these measurements and never connects to the sensor.
|
|
||||||
|
|
||||||
The v29→v30 migration is additive and creates no cardio row. Heart-rate data
|
|
||||||
travels Android→desktop through the separate optional `trainlog-heart-rate`
|
|
||||||
V1 generation companion. Exact replay is idempotent; a stable capture/sample
|
|
||||||
identity with different measured content is rejected. Existing workout,
|
|
||||||
Sleep Diary, and frozen mobile formats are unchanged.
|
|
||||||
|
|
||||||
## Schema v29: Sleep Diary V1
|
|
||||||
|
|
||||||
Schema v29 additively owns `sleep_diary_entries`, immutable
|
|
||||||
`sleep_diary_revisions`, and revision-owned `sleep_diary_events`. An entry is
|
|
||||||
one night and its associated day. Point events have an offset-bearing
|
|
||||||
`start_at`; intervals additionally have an absolute `end_at` strictly after
|
|
||||||
their start. The 18:00-to-18:00 agenda is only a projection and is never stored
|
|
||||||
as cells.
|
|
||||||
|
|
||||||
The same stable v0.1.4 migration owns `sleep_medications`, immutable
|
|
||||||
`sleep_medication_revisions`, and revision-owned `sleep_medication_intakes`.
|
|
||||||
Catalog defaults assist capture but do not own historical facts: every intake
|
|
||||||
stores the displayed medication name and effective optional positive dose/unit
|
|
||||||
snapshot. Renaming or deactivating a catalog item therefore cannot rewrite a
|
|
||||||
past entry. Development databases that had already opened the earlier v29
|
|
||||||
draft are completed idempotently on open before the additive v30 migration;
|
|
||||||
no existing sleep row is rewritten.
|
|
||||||
|
|
||||||
Mutations advance `current_revision_id` only when the caller supplies the
|
|
||||||
current parent. Logical deletion advances the revision and retains the
|
|
||||||
tombstone. Qualities preserve `TB`, `B`, `Moy`, `M`, and `TM` exactly.
|
|
||||||
|
|
||||||
`sleep_diary_publication_state` separates the durable local tip from the tip
|
|
||||||
explicitly validated for publication and the exact revision covered by a
|
|
||||||
full-generation ACK. `sleep_diary_generation_entries` binds captured revisions
|
|
||||||
to immutable generation identities. Consequently a new entry is a draft, a
|
|
||||||
validated tip is ready, an ACKed tip is synchronized, and any later local tip
|
|
||||||
is modified without rewriting its acknowledged ancestor. One stable entry is
|
|
||||||
owned by each `night_start_date`; old drafts remain editable indefinitely.
|
|
||||||
|
|
||||||
The absolute timestamps are the future heart-rate correlation boundary. Sleep
|
|
||||||
Diary V1 does not store, infer, or simulate heart-rate data.
|
|
||||||
|
|
||||||
## Schema v28: terminal Program execution provenance
|
## Schema v28: terminal Program execution provenance
|
||||||
|
|
||||||
Schema v28 extends `program_session_executions.state` with the terminal
|
Schema v28 extends `program_session_executions.state` with the terminal
|
||||||
|
|
|
||||||
|
|
@ -1,413 +0,0 @@
|
||||||
# Cardio V1
|
|
||||||
|
|
||||||
Status: `TRAINLOG_CARDIO_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`
|
|
||||||
|
|
||||||
This document is the implementation contract for Trainlog 0.1.5 cardio,
|
|
||||||
training-timeline correlation, and Android one-tap sleep capture. It does not
|
|
||||||
declare the implementation complete.
|
|
||||||
|
|
||||||
## Ownership and hard boundaries
|
|
||||||
|
|
||||||
Android is the only heart-rate sensor client. It owns BLE discovery,
|
|
||||||
connection, reconnection, Heart Rate Measurement parsing, local persistence,
|
|
||||||
capture lifecycle, and the live BPM presentation.
|
|
||||||
|
|
||||||
Desktop, TUI and Web never scan, pair with, or connect directly to a
|
|
||||||
heart-rate sensor. They consume only measurements already recorded by Android
|
|
||||||
and synchronized through Trainlog.
|
|
||||||
|
|
||||||
Heart-rate BLE/GATT and Trainlog Bluetooth synchronization are independent:
|
|
||||||
|
|
||||||
```text
|
|
||||||
heart-rate sensor -- BLE/GATT --> Android
|
|
||||||
Android <-------- Trainlog full-generation --------> desktop
|
|
||||||
over
|
|
||||||
Bluetooth Classic RFCOMM or MTP recovery
|
|
||||||
```
|
|
||||||
|
|
||||||
The cardio implementation must not reuse the frozen Bluetooth Classic service
|
|
||||||
UUID, framing, peer identity, connection state, or transport protocol.
|
|
||||||
|
|
||||||
## Standard sensor target
|
|
||||||
|
|
||||||
Cardio V1 targets the Bluetooth LE Heart Rate Service:
|
|
||||||
|
|
||||||
- Heart Rate Service: `0x180D`;
|
|
||||||
- Heart Rate Measurement: `0x2A37`.
|
|
||||||
|
|
||||||
The parser must support the standard 8-bit and 16-bit BPM forms, optional
|
|
||||||
sensor-contact state, optional cumulative energy expended, and zero or more RR
|
|
||||||
intervals. RR intervals are preserved as raw integer units of 1/1024 second.
|
|
||||||
|
|
||||||
Battery Service may be observed as optional device metadata but is not required
|
|
||||||
to retain a heart-rate sample.
|
|
||||||
|
|
||||||
Malformed notifications are rejected. Trainlog never manufactures missing BPM
|
|
||||||
or RR values.
|
|
||||||
|
|
||||||
## Global Android heart-rate indicator
|
|
||||||
|
|
||||||
The Android application shell exposes one compact indicator at the top right:
|
|
||||||
|
|
||||||
```text
|
|
||||||
♥ 82 BPM
|
|
||||||
```
|
|
||||||
|
|
||||||
Only the heart and BPM are shown. Sensor name, address, and technical state are
|
|
||||||
not shown in the global header.
|
|
||||||
|
|
||||||
Heart colour describes connection freshness only:
|
|
||||||
|
|
||||||
- grey: known sensor unavailable/disconnected;
|
|
||||||
- orange: connecting, reconnecting, or the last measurement is stale;
|
|
||||||
- green: connected and receiving fresh valid measurements.
|
|
||||||
|
|
||||||
Colour never indicates a medical zone or whether the BPM is high or low.
|
|
||||||
|
|
||||||
When no fresh measurement exists, the value is `-- BPM`. A stale previous
|
|
||||||
number must never remain visible as if it were current.
|
|
||||||
|
|
||||||
With a fresh BPM the heart uses a restrained pulse animation derived from
|
|
||||||
`60 / bpm`. This is only a visual representation of the current measured
|
|
||||||
rate; it does not claim knowledge of the exact instant of each contraction.
|
|
||||||
|
|
||||||
## Capture identities and contexts
|
|
||||||
|
|
||||||
One capture has a stable `hrc_<uuid-v4>` identity and exactly one semantic
|
|
||||||
context:
|
|
||||||
|
|
||||||
- `session`: a normal training or MAX session identified by stable
|
|
||||||
`se_<uuid-v4>`;
|
|
||||||
- `cardio`: a dedicated cardio session identified by stable
|
|
||||||
`se_<uuid-v4>`;
|
|
||||||
- `sleep`: one Sleep Diary entry identified by stable `sl_<uuid-v4>`.
|
|
||||||
|
|
||||||
Context is never inferred from a title, clock date, exercise name, or display
|
|
||||||
position.
|
|
||||||
|
|
||||||
A workout/cardio sample may additionally snapshot the stable active occurrence
|
|
||||||
`sxe_<uuid-v4>` at the instant the sample is received.
|
|
||||||
|
|
||||||
## Raw measurement contract
|
|
||||||
|
|
||||||
Each received measurement stores at minimum:
|
|
||||||
|
|
||||||
- `capture_id`;
|
|
||||||
- monotonically increasing `sequence` within that capture;
|
|
||||||
- offset-bearing `observed_at` reception timestamp;
|
|
||||||
- integer `bpm`.
|
|
||||||
|
|
||||||
When actually supplied by the sensor it may also store:
|
|
||||||
|
|
||||||
- sensor-contact state;
|
|
||||||
- cumulative energy-expended value;
|
|
||||||
- zero or more RR intervals in 1/1024-second units.
|
|
||||||
|
|
||||||
For workout/cardio captures it may store the active `entry_id` snapshot.
|
|
||||||
|
|
||||||
Raw storage preserves signal gaps. It does not interpolate, smooth, estimate,
|
|
||||||
diagnose, infer sleep stages, infer a return to sleep, or synthesize missing
|
|
||||||
measurements.
|
|
||||||
|
|
||||||
## Android durability
|
|
||||||
|
|
||||||
Android SQLite owns authoritative field capture. An active capture remains
|
|
||||||
durable across application navigation and ordinary process/lifecycle
|
|
||||||
transitions supported by Android. Long-running acquisition uses a dedicated
|
|
||||||
foreground service distinct from synchronization.
|
|
||||||
|
|
||||||
Cardio V1 permits one active heart-rate capture at a time.
|
|
||||||
|
|
||||||
Sensor disconnect does not delete or close a capture. Reconnection resumes
|
|
||||||
future measurements and leaves the missing interval as a real gap.
|
|
||||||
|
|
||||||
Explicit stop closes the capture with its actual `ended_at`.
|
|
||||||
|
|
||||||
## Synchronization contract
|
|
||||||
|
|
||||||
Recorded cardio is synchronized Android -> desktop as the separate optional
|
|
||||||
full-generation companion:
|
|
||||||
|
|
||||||
```text
|
|
||||||
logical_name = heart-rate
|
|
||||||
format = trainlog-heart-rate
|
|
||||||
version = 1
|
|
||||||
filename = heart-rate-v1.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Desktop persists and presents these measurements but is not a sensor producer.
|
|
||||||
|
|
||||||
Normal publication contains completed/stopped, not-yet-acknowledged captures.
|
|
||||||
An active capture is retained locally and is not emitted as a mutable
|
|
||||||
full-generation snapshot.
|
|
||||||
|
|
||||||
Identity is idempotent:
|
|
||||||
|
|
||||||
- capture key: `capture_id`;
|
|
||||||
- sample key: `capture_id + sequence`;
|
|
||||||
- RR key: `capture_id + sequence + rr_index`.
|
|
||||||
|
|
||||||
Exact replay is a no-op. Reusing an identity with different measured content
|
|
||||||
is rejected.
|
|
||||||
|
|
||||||
The exact captured `capture_id -> generation_id` relation is durable. Only a
|
|
||||||
correlated consumed ACK marks those captures acknowledged. A rejected
|
|
||||||
generation does not acknowledge them. Acknowledged captures remain in Android
|
|
||||||
history but are excluded from ordinary future publication.
|
|
||||||
|
|
||||||
The companion does not alter `TRAINLOG_FORMAT_V1`, mobile-export V3/V4,
|
|
||||||
Sleep Diary V1, generation/ACK framing, or Bluetooth Classic sync V1.
|
|
||||||
|
|
||||||
## Training timeline correlation
|
|
||||||
|
|
||||||
A training session must expose real time boundaries, never reconstructed
|
|
||||||
timings.
|
|
||||||
|
|
||||||
Session start/end use the existing stable session identity and canonical
|
|
||||||
`started_at` / `ended_at` lifecycle when those values are already the
|
|
||||||
authoritative timestamps.
|
|
||||||
|
|
||||||
Each session occurrence has an explicit execution lifecycle:
|
|
||||||
|
|
||||||
```text
|
|
||||||
not_started -> in_progress -> completed
|
|
||||||
```
|
|
||||||
|
|
||||||
Starting an exercise records its actual start timestamp against
|
|
||||||
`session_id + entry_id`. Ending it records its actual end timestamp.
|
|
||||||
|
|
||||||
Only one occurrence may be active at a time. Starting another occurrence while
|
|
||||||
one remains active requires an explicit resolution rather than silently
|
|
||||||
inventing an end.
|
|
||||||
|
|
||||||
Samples received while an occurrence is active snapshot that `entry_id`.
|
|
||||||
Samples between occurrences remain valid session samples with no fabricated
|
|
||||||
exercise ownership and represent rest/transition.
|
|
||||||
|
|
||||||
Ending a session while an occurrence is active requires an explicit UI action
|
|
||||||
to close the occurrence at the validated finalization instant.
|
|
||||||
|
|
||||||
The synchronized history must permit factual curve markers for:
|
|
||||||
|
|
||||||
- session start;
|
|
||||||
- exercise start;
|
|
||||||
- exercise end;
|
|
||||||
- session end.
|
|
||||||
|
|
||||||
Future set-level `set_start` / `set_end` events may extend this model, but
|
|
||||||
Cardio V1 must not infer them from reps, duration targets, or form edits.
|
|
||||||
|
|
||||||
## Dedicated cardio session type
|
|
||||||
|
|
||||||
Trainlog 0.1.5 adds a real third session type:
|
|
||||||
|
|
||||||
```text
|
|
||||||
training
|
|
||||||
max_test
|
|
||||||
cardio
|
|
||||||
```
|
|
||||||
|
|
||||||
Every existing boundary that assumes only `training|max_test` must be audited.
|
|
||||||
A frozen artifact may not be silently widened. If an existing wire codec
|
|
||||||
cannot safely express `cardio`, add a separately versioned compatible
|
|
||||||
extension while retaining legacy readers.
|
|
||||||
|
|
||||||
A cardio session consists of cardio exercises and optional phases. Initial
|
|
||||||
exercise families include treadmill, bicycle, elliptical, rower, stepper,
|
|
||||||
walking, running, and custom cardio.
|
|
||||||
|
|
||||||
A phase may express a type, target, duration, and exit condition. Required
|
|
||||||
phase concepts include warm-up, work, recovery, and cool-down.
|
|
||||||
|
|
||||||
The motor must be able to represent fixed duration, reaching a BPM zone,
|
|
||||||
recovering below a threshold, and bounded combinations of time and
|
|
||||||
heart-rate conditions.
|
|
||||||
|
|
||||||
Machine speed, resistance, incline, and distance may be recorded, but BPM is
|
|
||||||
the primary guidance signal.
|
|
||||||
|
|
||||||
## BPM-guidance motor
|
|
||||||
|
|
||||||
Cardio V1 guidance outputs are:
|
|
||||||
|
|
||||||
```text
|
|
||||||
ACCÉLÈRE
|
|
||||||
MAINTIENS
|
|
||||||
RALENTIS
|
|
||||||
```
|
|
||||||
|
|
||||||
Guidance is based only on fresh measurements and the phase target.
|
|
||||||
|
|
||||||
Hysteresis and a minimum persistence interval prevent oscillation around a
|
|
||||||
boundary. One isolated sample must not cause rapid instruction changes.
|
|
||||||
|
|
||||||
When the signal becomes stale, guidance is suspended. The global heart becomes
|
|
||||||
orange and BPM becomes unavailable rather than continuing from an old value.
|
|
||||||
|
|
||||||
## Calibration cardio exercise
|
|
||||||
|
|
||||||
The first dedicated cardio exercise is `Calibration cardio`.
|
|
||||||
|
|
||||||
Its purpose is to establish a personal measured reference for later guided
|
|
||||||
exercise. Trainlog records an **observed peak BPM**, not an automatically
|
|
||||||
asserted physiological maximum.
|
|
||||||
|
|
||||||
The protocol is progressive:
|
|
||||||
|
|
||||||
```text
|
|
||||||
warm-up -> easy stage -> progressive stages -> user-chosen highest tolerated
|
|
||||||
effort for that day -> stop effort -> recovery observation
|
|
||||||
```
|
|
||||||
|
|
||||||
A manual stop is always immediately available. The motor never attempts to
|
|
||||||
beat the previous observed peak automatically.
|
|
||||||
|
|
||||||
Each calibration is immutable/versioned and retains at minimum:
|
|
||||||
|
|
||||||
- stable `calibration_id`;
|
|
||||||
- protocol version;
|
|
||||||
- performance timestamp;
|
|
||||||
- associated heart-rate capture;
|
|
||||||
- observed peak BPM;
|
|
||||||
- effort-end timestamp;
|
|
||||||
- recovery timeline.
|
|
||||||
|
|
||||||
Recovery projections may expose +1, +2 and +3 minute facts while the complete
|
|
||||||
raw curve remains authoritative.
|
|
||||||
|
|
||||||
A later calibration creates a new reference. It never rewrites an older
|
|
||||||
calibration or a prior guided session.
|
|
||||||
|
|
||||||
Every guided cardio session snapshots the exact `calibration_id` and target
|
|
||||||
thresholds used at that time.
|
|
||||||
|
|
||||||
Measured, user-configured, and calculated/estimated values remain explicitly
|
|
||||||
distinguishable.
|
|
||||||
|
|
||||||
## Android Sleep entry point
|
|
||||||
|
|
||||||
Android adds a first-class `Sommeil` menu entry backed by the existing Sleep
|
|
||||||
Diary domain. It must not create a parallel sleep database or incompatible
|
|
||||||
sleep truth.
|
|
||||||
|
|
||||||
Night-time interaction is deliberately minimal. The primary screen exposes:
|
|
||||||
|
|
||||||
```text
|
|
||||||
[ COUCHÉ ]
|
|
||||||
|
|
||||||
[ médicament 1 ] [ médicament 2 ]
|
|
||||||
[ médicament 3 ] [ + ]
|
|
||||||
|
|
||||||
[ RÉVEIL ]
|
|
||||||
|
|
||||||
[ LEVÉ ]
|
|
||||||
```
|
|
||||||
|
|
||||||
Buttons are large, visually consistent with Trainlog, and usable while only
|
|
||||||
partly awake.
|
|
||||||
|
|
||||||
### Couché
|
|
||||||
|
|
||||||
One tap records the actual `bed_time` timestamp in the correct stable Sleep
|
|
||||||
Diary entry. If heart-rate acquisition is available, the nocturnal capture is
|
|
||||||
linked directly to that `sleep entry_id`.
|
|
||||||
|
|
||||||
### Médicaments
|
|
||||||
|
|
||||||
Active configured medications are one-tap actions. A tap stores the actual
|
|
||||||
intake timestamp plus stable medication identity and the effective
|
|
||||||
name/dose/unit snapshot. Full editing remains available later.
|
|
||||||
|
|
||||||
### Réveil
|
|
||||||
|
|
||||||
There is no `Rendormi` button. One tap records a structured `long_awake`
|
|
||||||
interval beginning at the factual tap time and ending 30 minutes later under
|
|
||||||
the explicit capture policy. Nothing else is required before the user tries to
|
|
||||||
return to sleep. A second tap within that window resets its end to 30 minutes
|
|
||||||
after the latest tap. Levé truncates an overlapping interval to the factual
|
|
||||||
final-get-up timestamp.
|
|
||||||
|
|
||||||
The 30-minute end is user-approved product policy, not an inference from BPM or
|
|
||||||
RR data. Later correction remains available when the actual duration is known.
|
|
||||||
|
|
||||||
### Levé
|
|
||||||
|
|
||||||
One tap records the actual `final_get_up` timestamp, closes the night, and
|
|
||||||
stops the linked nocturnal heart-rate capture when one is active.
|
|
||||||
|
|
||||||
### Immediate undo and later correction
|
|
||||||
|
|
||||||
After Couché, medication, Réveil, or Levé, Android shows a short-lived action
|
|
||||||
such as:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Réveil enregistré à 02:17 [ ANNULER ]
|
|
||||||
```
|
|
||||||
|
|
||||||
Undo is scoped to that exact action and must not roll back unrelated newer
|
|
||||||
state.
|
|
||||||
|
|
||||||
`Corriger la nuit` provides later editing of timestamps, medication
|
|
||||||
intakes, awakening intervals, forgotten events, and existing subjective
|
|
||||||
Sleep Diary fields.
|
|
||||||
|
|
||||||
The night-time rule is: one tap, one 30-minute awakening window, then no further
|
|
||||||
required interaction.
|
|
||||||
|
|
||||||
## Sleep + cardio factual timeline
|
|
||||||
|
|
||||||
A nocturnal capture is owned by stable `sleep entry_id`, not only a date.
|
|
||||||
|
|
||||||
After synchronization, Analyse may superpose factual events and measurements:
|
|
||||||
|
|
||||||
- Couché;
|
|
||||||
- medication intakes;
|
|
||||||
- structured 30-minute awakening windows;
|
|
||||||
- Levé;
|
|
||||||
- BPM;
|
|
||||||
- RR when supplied;
|
|
||||||
- real sensor disconnect/reconnect gaps.
|
|
||||||
|
|
||||||
Trainlog does not diagnose sleep, invent sleep stages, or claim a certain
|
|
||||||
return-to-sleep instant from heart rate alone.
|
|
||||||
|
|
||||||
## Web/Analyse projection
|
|
||||||
|
|
||||||
Web and desktop consume synchronized facts only.
|
|
||||||
|
|
||||||
Training views may show the BPM curve, session bounds, exercise intervals,
|
|
||||||
rest/transition gaps, and factual min/max/mean projections.
|
|
||||||
|
|
||||||
Cardio views may additionally show phase targets, chronological guidance,
|
|
||||||
calibration identity, and measured response versus target.
|
|
||||||
|
|
||||||
Sleep views may overlay Sleep Diary events with BPM/RR measurements.
|
|
||||||
|
|
||||||
Graphs are projections. Persistent measurements, identities, and timestamps
|
|
||||||
remain the source of truth.
|
|
||||||
|
|
||||||
## Performance bounds
|
|
||||||
|
|
||||||
One night can contain tens of thousands of samples. Storage and exchange must
|
|
||||||
use bounded artifacts, indexed temporal queries, transactional import, and
|
|
||||||
avoid per-sample N+1 behavior on analysis paths.
|
|
||||||
|
|
||||||
Trainlog does not arbitrarily downsample the authoritative raw capture merely
|
|
||||||
for transport convenience. Presentation may derive bounded projections while
|
|
||||||
retaining the recorded source data.
|
|
||||||
|
|
||||||
## Implementation order
|
|
||||||
|
|
||||||
1. audit/freeze this contract;
|
|
||||||
2. Android + desktop persistence and strict heart-rate companion exchange;
|
|
||||||
3. standard Heart Rate Measurement parser and real-sensor diagnostics;
|
|
||||||
4. Android foreground acquisition service and global ♥ BPM indicator;
|
|
||||||
5. training start/end and exercise start/end timeline;
|
|
||||||
6. Android one-tap Sleep UI and sleep-linked capture;
|
|
||||||
7. true `cardio` session type;
|
|
||||||
8. versioned Calibration cardio exercise;
|
|
||||||
9. BPM-guided phase motor;
|
|
||||||
10. Web factual visualization;
|
|
||||||
11. complete regression, migration, hardware, replay, and restart validation.
|
|
||||||
|
|
||||||
No stage permits Web/TUI sensor connection.
|
|
||||||
|
|
@ -1,196 +0,0 @@
|
||||||
# Bluetooth synchronization transport V1
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
`TRAINLOG_SYNC_BLUETOOTH_TRANSPORT_V1=PASS/FROZEN`
|
|
||||||
|
|
||||||
This transport is additive. It does not change generation manifests, artifact
|
|
||||||
formats, causal deletion, revision ancestry, acknowledgement semantics, SQLite
|
|
||||||
schemas, or the frozen Trainlog session format.
|
|
||||||
|
|
||||||
## Product behavior
|
|
||||||
|
|
||||||
Normal synchronization is automatic when the authorized Android phone and
|
|
||||||
desktop are within Bluetooth range.
|
|
||||||
|
|
||||||
1. Android's already user-enabled sync foreground service maintains a bonded
|
|
||||||
Bluetooth Classic RFCOMM connection to the selected desktop.
|
|
||||||
2. The desktop advertises one Trainlog-specific RFCOMM profile.
|
|
||||||
3. On a fresh Bluetooth connection Android publishes one bounded
|
|
||||||
full-generation request. Reconnect noise is rate-limited.
|
|
||||||
4. The existing full-generation orchestrator performs the same bidirectional
|
|
||||||
generation/ACK conversation through the Bluetooth byte transport.
|
|
||||||
5. If Bluetooth is unavailable, the existing MTP transport remains the wired
|
|
||||||
fallback.
|
|
||||||
6. Google Drive is not a synchronization transport in the target configuration.
|
|
||||||
After convergence, the desktop AI export is transferred to Android and
|
|
||||||
Android publishes it to the user-selected `Trainlog/AI` Drive folder.
|
|
||||||
Drive publication failure never rolls back a completed sync.
|
|
||||||
|
|
||||||
The manual Web and Android Synchroniser actions remain as recovery/forced-sync
|
|
||||||
controls.
|
|
||||||
|
|
||||||
## Private live-device evidence — 2026-09-22
|
|
||||||
|
|
||||||
The authorized Samsung Android peer and ArchASP completed a physical Bluetooth
|
|
||||||
Classic/RFCOMM synchronization without MTP or Drive as a sync transport.
|
|
||||||
|
|
||||||
- request: `sr_86ccc07f-cc5b-4506-86c1-06c00bd8b06a`;
|
|
||||||
- run: `sy_df8f87fb-e4c1-44f5-b3f7-8fe87942d19e`;
|
|
||||||
- inbound Android generation:
|
|
||||||
`gen_9609d163-4a3a-4187-a806-84379a1bcf4c`;
|
|
||||||
- outbound desktop generation:
|
|
||||||
`gen_bc1d5e4b-4b16-49bc-b228-c3ff1f1a1705`;
|
|
||||||
- final transport: `bt`;
|
|
||||||
- final Bluetooth state: `success`;
|
|
||||||
- final phase/result: `completed/completed`;
|
|
||||||
- desktop SQLite integrity: `ok`;
|
|
||||||
- post-sync AI export: `delivered_to_android`;
|
|
||||||
- Android copy:
|
|
||||||
`Documents/Trainlog/trainlog_ai_export_v1.json`, 66,971 bytes,
|
|
||||||
SHA-256 `49f7f02fdeebd15699655e43fdb475bb20d9dd2d1bc59d2bdb4cd099ae50ecda`.
|
|
||||||
|
|
||||||
The initial legacy bond contained mismatched link-key state. The private device
|
|
||||||
was explicitly re-paired; Android then reported the bond ready and established
|
|
||||||
the Trainlog RFCOMM service. Android now also repairs a missing bond through the
|
|
||||||
normal platform pairing flow before attempting RFCOMM.
|
|
||||||
|
|
||||||
The Android SAF destination `Trainlog/AI` was then selected explicitly by
|
|
||||||
the user. A Web-triggered Bluetooth-only run completed immediately afterward:
|
|
||||||
|
|
||||||
- request: `sy_ab1869d3-7f25-497f-a338-97213ec08370`;
|
|
||||||
- run: `sy_f3ab061f-b4f8-44e9-9911-c0ad3283b457`;
|
|
||||||
- inbound Android generation:
|
|
||||||
`gen_c74705f9-04dc-4ffe-9e67-6ae5bf08d1a2`;
|
|
||||||
- outbound desktop generation:
|
|
||||||
`gen_0b33d209-35f1-41c0-9f98-6adf843b2a5f`;
|
|
||||||
- `transport=bt`, `bluetooth_state=success`;
|
|
||||||
- both directions are durable `sqlite-commit-full`;
|
|
||||||
- `ai_post_sync=delivered_to_android`;
|
|
||||||
- desktop SQLite integrity: `ok`.
|
|
||||||
|
|
||||||
The resulting local AI export was 66,971 bytes with SHA-256
|
|
||||||
`73ce8d3ca6cef3080ed2da77d58ef600bb79e6d2089f3472632b59aea76a8b90`.
|
|
||||||
The exact Google Drive object in `Trainlog/AI/trainlog_ai_export_v1.json` was
|
|
||||||
modified at `2026-09-22T14:50:11.875Z`, had the same 66,971-byte size, and
|
|
||||||
the downloaded Drive object had the exact same SHA-256. This closes the
|
|
||||||
Android-SAF publication proof without rclone.
|
|
||||||
|
|
||||||
## Trust and pairing
|
|
||||||
|
|
||||||
Bluetooth device names are presentation only and are never authority.
|
|
||||||
|
|
||||||
Desktop trusted configuration pins:
|
|
||||||
|
|
||||||
- Android sync `peer_id`;
|
|
||||||
- bonded Android Bluetooth device address;
|
|
||||||
- Trainlog RFCOMM service UUID.
|
|
||||||
|
|
||||||
Android persists the selected bonded desktop address after an explicit one-time
|
|
||||||
selection. The RFCOMM service uses normal bonded-device authentication.
|
|
||||||
|
|
||||||
Current private deployment values are installation configuration, not protocol
|
|
||||||
constants.
|
|
||||||
|
|
||||||
## RFCOMM service
|
|
||||||
|
|
||||||
Trainlog service UUID:
|
|
||||||
|
|
||||||
`f0d1c0de-7a11-4f62-9b7c-545241494e4c`
|
|
||||||
|
|
||||||
The desktop registers an `org.bluez.Profile1` server with BlueZ. Android is
|
|
||||||
the RFCOMM client. This direction lets Android resolve the advertised service
|
|
||||||
through SDP without hidden Android channel APIs.
|
|
||||||
|
|
||||||
The desktop Bluetooth agent is persistent and owns the BlueZ profile. Local
|
|
||||||
sync workers never talk to BlueZ directly; a bounded local adapter talks to the
|
|
||||||
agent over a mode-0600 Unix socket.
|
|
||||||
|
|
||||||
## Bluetooth byte envelope
|
|
||||||
|
|
||||||
Each Bluetooth frame is:
|
|
||||||
|
|
||||||
- 4-byte unsigned big-endian JSON header length;
|
|
||||||
- UTF-8 JSON header (maximum 64 KiB);
|
|
||||||
- optional payload whose length and SHA-256 are declared in the header.
|
|
||||||
|
|
||||||
Payload limit is 256 MiB and individual exchange files remain subject to their
|
|
||||||
existing generation bounds. A receiver validates length and SHA-256 before
|
|
||||||
making received bytes visible.
|
|
||||||
|
|
||||||
Initial frame:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"format": "trainlog-bt-frame",
|
|
||||||
"version": 1,
|
|
||||||
"type": "hello",
|
|
||||||
"peer_id": "peer_<uuid>",
|
|
||||||
"protocol": "trainlog-bt-files-v1"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The local adapter exposes only `pull` and `push`, matching the existing MTP
|
|
||||||
adapter boundary. Transfers use a bounded ZIP archive containing relative
|
|
||||||
exchange paths. Extraction rejects absolute paths, `..`, symbolic links,
|
|
||||||
duplicates and excess file/byte counts. Each destination is replaced from a
|
|
||||||
same-directory temporary after complete bytes are durable.
|
|
||||||
|
|
||||||
## Pull ownership
|
|
||||||
|
|
||||||
Android pull snapshots include only Android-owned/current coordination objects:
|
|
||||||
|
|
||||||
- `android-peer-v1.json`;
|
|
||||||
- `trainlog-sync-full-generation-request-v1.json`;
|
|
||||||
- `trainlog-sync-request-v1.json`;
|
|
||||||
- `android-generation-v1.json`;
|
|
||||||
- `android-consumption-ack-v1.json`;
|
|
||||||
- `android-generation-error-v1.json`;
|
|
||||||
- the immutable generation referenced by `android-generation-v1.json`.
|
|
||||||
|
|
||||||
Desktop-owned coordination files are never treated as Android source
|
|
||||||
authority.
|
|
||||||
|
|
||||||
## Push ownership
|
|
||||||
|
|
||||||
Desktop workers already construct phase-specific disposable outboxes. Bluetooth
|
|
||||||
push accepts exactly that bounded outbox and atomically publishes those files
|
|
||||||
under Android's canonical `Documents/Trainlog` directory.
|
|
||||||
|
|
||||||
## Concurrency
|
|
||||||
|
|
||||||
Bluetooth and MTP have independent physical transport locks. The common
|
|
||||||
business `sync.lock` still serializes synchronization conversations.
|
|
||||||
|
|
||||||
A background discovery probe must never own `sync.lock`. A Web-triggered
|
|
||||||
conversation must never fail merely because an idle transport probe is in
|
|
||||||
progress.
|
|
||||||
|
|
||||||
## Automatic arrival trigger
|
|
||||||
|
|
||||||
A successful RFCOMM connection is an arrival event. Android publishes at most
|
|
||||||
one automatic request per connection and persists a short reconnect cooldown.
|
|
||||||
The same connection is not a repeating timer; no connection means no automatic
|
|
||||||
request.
|
|
||||||
|
|
||||||
A later optimization may compare explicit dirty revisions before generating a
|
|
||||||
request, but V1 correctness does not depend on such an optimization.
|
|
||||||
|
|
||||||
## MTP fallback
|
|
||||||
|
|
||||||
MTP remains supported and keeps its current fail-closed semantics. Automatic
|
|
||||||
desktop selection is:
|
|
||||||
|
|
||||||
`Bluetooth -> MTP -> device_unavailable`
|
|
||||||
|
|
||||||
Drive is never an implicit fallback in the target configuration.
|
|
||||||
|
|
||||||
## AI export publication
|
|
||||||
|
|
||||||
The desktop remains the canonical producer of
|
|
||||||
`trainlog_ai_export_v1.json`. After a completed bidirectional sync it sends
|
|
||||||
the exact export bytes to Android. Android publishes/replaces the export in the
|
|
||||||
persisted SAF tree selected for `Trainlog/AI`.
|
|
||||||
|
|
||||||
The export has its own pending/success state. A Drive provider error is not a
|
|
||||||
sync error and is retried later by Android.
|
|
||||||
|
|
@ -691,30 +691,6 @@ enumerate at most 4096 operations plus one sentinel row, and a local deletion
|
||||||
is refused before its effect if the complete protection artifact would exceed
|
is refused before its effect if the complete protection artifact would exceed
|
||||||
either bound.
|
either bound.
|
||||||
|
|
||||||
The V1 `target_kind` set remains the frozen seven-kind set: `session`,
|
|
||||||
`execution_draft`, `exercise`, `body_observation`, `custom_equipment`,
|
|
||||||
`feedback`, and `body_zone_relation`. Heart-rate correction does not widen this
|
|
||||||
published contract.
|
|
||||||
|
|
||||||
## Heart-rate corrections V1
|
|
||||||
|
|
||||||
`trainlog-heart-rate-corrections` V1 is a separate optional Android→desktop
|
|
||||||
full-generation companion named `heart-rate-corrections`, stored as
|
|
||||||
`heart-rate-corrections-v1.json`. Its root contains `format`, integer `version`,
|
|
||||||
`generated_at`, and at most 256 `corrections`; the complete UTF-8 artifact is
|
|
||||||
limited to 4 MiB. Each correction contains a stable UUIDv4-based `operation_id`,
|
|
||||||
stable `capture_id`, inclusive offset-aware `cutoff`, `creator_id`, exact
|
|
||||||
`predecessor_revision_id`, `created_at`, and canonical `payload_sha256`.
|
|
||||||
|
|
||||||
The predecessor hashes the complete original capture metadata, ordered raw BPM
|
|
||||||
samples, and ordered RR children. The consumer requires the cutoff to equal the
|
|
||||||
canonical session end and not precede the session start or latest exercise end.
|
|
||||||
It deletes only samples strictly after the cutoff in one transaction, relies on
|
|
||||||
the declared RR cascade, records durable causal state, and applies that state
|
|
||||||
before any Heart Rate V1 snapshot in the same generation. Unknown fields,
|
|
||||||
duplicate keys/identities, digest mismatch, ancestry mismatch, and incompatible
|
|
||||||
capture/session ownership reject the containing generation atomically.
|
|
||||||
|
|
||||||
## Staged generation manifest and acknowledgement V1
|
## Staged generation manifest and acknowledgement V1
|
||||||
|
|
||||||
`trainlog-sync-manifest` V1 is a strict independent envelope. It contains
|
`trainlog-sync-manifest` V1 is a strict independent envelope. It contains
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,6 @@ than later development. GitHub and Forgejo publication must preserve the
|
||||||
existing release, tag, status, and assets while keeping their note bodies equal
|
existing release, tag, status, and assets while keeping their note bodies equal
|
||||||
to the corresponding source file.
|
to the corresponding source file.
|
||||||
|
|
||||||
- [Trainlog v0.1.6](v0.1.6.md)
|
|
||||||
- [Trainlog v0.1.5](v0.1.5.md)
|
|
||||||
- [Trainlog v0.1.4](v0.1.4.md)
|
|
||||||
- [Trainlog v0.1.3](v0.1.3.md)
|
- [Trainlog v0.1.3](v0.1.3.md)
|
||||||
- [Trainlog v0.1.2](v0.1.2.md)
|
- [Trainlog v0.1.2](v0.1.2.md)
|
||||||
- [Trainlog v0.1.1](v0.1.1.md)
|
- [Trainlog v0.1.1](v0.1.1.md)
|
||||||
|
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
Trainlog v0.1.4 extends the local-first training workflow with factual
|
|
||||||
analysis, a synchronized Sleep Diary, improved prepared-session execution, and
|
|
||||||
an authenticated Bluetooth synchronization transport.
|
|
||||||
|
|
||||||
## Analyse and Dashboard
|
|
||||||
|
|
||||||
- The local Web Analyse workspace exposes factual 7/30/90-day and all-history
|
|
||||||
views through Trainlog Core.
|
|
||||||
- Activity, active-Program progress, compatible exercise metrics, persisted MAX
|
|
||||||
results, BODY ZONE exposure, and body measurements share the bounded Core
|
|
||||||
read model used by Dashboard summaries.
|
|
||||||
- Body-measurement trends are charted from persisted observations, and the next
|
|
||||||
session projection refreshes after synchronization.
|
|
||||||
- Analyse deliberately does not invent readiness, fatigue, recovery, body-fat,
|
|
||||||
or estimated performance values.
|
|
||||||
|
|
||||||
## Sleep Diary
|
|
||||||
|
|
||||||
- Sleep Diary V1 is available across desktop schema v29 and Android schema v26.
|
|
||||||
- Entries use absolute timestamped events, causal revisions, appreciations,
|
|
||||||
notes, medication catalog entries, and timestamped intake snapshots.
|
|
||||||
- The Web agenda uses one exact 18:00-to-18:00 projection and follows the latest
|
|
||||||
locally persisted revision without leaking another selected night.
|
|
||||||
- Local vector PDF reports are available in French and English.
|
|
||||||
- Sleep Diary participates in the full-generation synchronization and
|
|
||||||
acknowledgement flow.
|
|
||||||
|
|
||||||
## Android session execution
|
|
||||||
|
|
||||||
- Prepared continuous-duration exercises remain planning-only until actual work
|
|
||||||
is explicitly recorded.
|
|
||||||
- Target-only continuous and set-based occurrences can be edited without
|
|
||||||
fabricating performed activity or duplicate occurrences.
|
|
||||||
- Equipment can be created and selected from the active-session editor while
|
|
||||||
preserving the current durable draft.
|
|
||||||
- Program provenance is preserved through preparation, Android execution,
|
|
||||||
completion, synchronization, and progress display, including safe recovery
|
|
||||||
of compatible historical deliveries.
|
|
||||||
|
|
||||||
## Bluetooth synchronization
|
|
||||||
|
|
||||||
- The paired deployment adds Bluetooth Classic RFCOMM as the primary local
|
|
||||||
Trainlog synchronization transport and retains direct MTP as wired recovery.
|
|
||||||
- The desktop accepts only the configured bonded Android device, and Android
|
|
||||||
proves the persisted Trainlog peer identity before exchanging bounded frames.
|
|
||||||
- Reconnect noise is rate-limited and does not create repeated generations.
|
|
||||||
- This Bluetooth feature transports Trainlog synchronization data only.
|
|
||||||
Heart-rate sensor ingestion is intentionally deferred to the 0.1.5 cardio
|
|
||||||
cycle.
|
|
||||||
|
|
||||||
## Assets
|
|
||||||
|
|
||||||
- `trainlog-android-v0.1.4.apk`: signed public Android APK.
|
|
||||||
- `trainlog-tui-linux-x86_64-v0.1.4`: Linux x86-64 runtime bundle containing
|
|
||||||
the TUI, local Web adapter, synchronization helpers, Bluetooth transport
|
|
||||||
helper, and required catalogs.
|
|
||||||
- `SHA256SUMS`: SHA-256 checksums for both artifacts.
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
Trainlog v0.1.5 completes the factual cardio capture cycle and makes the
|
|
||||||
Android-to-Web Sleep workflow ready for real nightly use without adding
|
|
||||||
medical, sleep-stage, or causal inference.
|
|
||||||
|
|
||||||
## Heart-rate capture and cardio sessions
|
|
||||||
|
|
||||||
- Android persistently acquires real CYCPLUS Heart Rate Service measurements,
|
|
||||||
retains factual observation timestamps, BPM and raw RR values, and exposes a
|
|
||||||
global fresh/stale/disconnected indicator.
|
|
||||||
- Training occurrences have explicit start/end timelines so synchronized
|
|
||||||
heart-rate samples retain their stable exercise ownership instead of
|
|
||||||
inferring it later from time overlap.
|
|
||||||
- Dedicated `cardio` sessions, measured calibration profiles and fresh-signal
|
|
||||||
BPM-guided phases use separate versioned companions and preserve the frozen
|
|
||||||
historical session formats.
|
|
||||||
- Guidance suspends on stale signal and records measured facts only; it does
|
|
||||||
not estimate fitness, recovery, readiness or medical state.
|
|
||||||
|
|
||||||
## Factual Web timelines
|
|
||||||
|
|
||||||
- History and Sleep display synchronized heart-rate samples through one
|
|
||||||
bounded native read model and responsive Web timeline.
|
|
||||||
- Full-source min/mean/max and RR counts remain exact while large visual
|
|
||||||
projections are bounded.
|
|
||||||
- Cardio timelines show factual occurrence bounds, resolved targets,
|
|
||||||
instruction changes and measured calibration points.
|
|
||||||
- Sleep highlights notable relative rises and falls with a deterministic local
|
|
||||||
median rule, explicitly without assigning a sleep stage or cause.
|
|
||||||
|
|
||||||
## Sleep capture and medication quantity
|
|
||||||
|
|
||||||
- Medication can be recorded before Couché. The first intake creates one
|
|
||||||
durable pending 18:00-to-18:00 night; Couché advances the same stable entry,
|
|
||||||
Réveil leaves heart-rate capture running, and Levé closes it at the factual
|
|
||||||
final-get-up timestamp.
|
|
||||||
- A missing sensor fabricates neither a capture nor a sample.
|
|
||||||
- Sleep Diary V2 carries structured per-intake quantity while published V1
|
|
||||||
remains strict and readable; historical intakes default to quantity one.
|
|
||||||
- The Web agenda renders every night in the selected period on one aligned
|
|
||||||
18:00-to-18:00 axis, and the selected night owns the full-width heart-rate
|
|
||||||
detail. PDF reports retain their multi-night behavior and display quantity
|
|
||||||
without replacing per-unit dose.
|
|
||||||
|
|
||||||
## Compatibility and validation
|
|
||||||
|
|
||||||
- Desktop schema v35 and Android schema v32 are additive migrations.
|
|
||||||
- `TRAINLOG_FORMAT_V1`, mobile export V3, stable identities, causal revision
|
|
||||||
lineage, generation/ACK semantics and Bluetooth/MTP transport ownership are
|
|
||||||
unchanged.
|
|
||||||
- Native tests, Android unit tests and APK assembly, Web tests/typecheck/build,
|
|
||||||
JSON/import validation, changed-C formatting and exact Sleep V2 replay all
|
|
||||||
pass at the release tag.
|
|
||||||
|
|
||||||
## Assets
|
|
||||||
|
|
||||||
- `trainlog-android-v0.1.5.apk`: signed public Android APK.
|
|
||||||
- `trainlog-tui-linux-x86_64-v0.1.5`: Linux x86-64 runtime bundle containing
|
|
||||||
the TUI, local Web adapter, synchronization helpers and required catalogs.
|
|
||||||
- `SHA256SUMS`: SHA-256 checksums for both artifacts.
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
Trainlog v0.1.6 closes the Web refinement and synchronization-recovery cycle.
|
|
||||||
It improves factual Sleep and training presentation, preserves immutable
|
|
||||||
history through difficult replay and recovery cases, and gives the repository
|
|
||||||
a durable Catppuccin-based project entry point without widening frozen product
|
|
||||||
contracts.
|
|
||||||
|
|
||||||
## Local Web refinement
|
|
||||||
|
|
||||||
- Dashboard activity uses calendar months and ISO weeks, progression is a
|
|
||||||
factual global synthesis, and Analyse drills from BODY ZONES to exercises and
|
|
||||||
measured detail without inventing missing exercise time.
|
|
||||||
- A persistent French/English preference, Settings route, denser shared
|
|
||||||
surfaces, responsive charts, and a shared BODY ZONES figure refine the local
|
|
||||||
interface while presentation state remains outside business SQLite.
|
|
||||||
- Equipment administration can transactionally merge a custom duplicate into
|
|
||||||
a canonical identity while preserving history, MAX context, preparations,
|
|
||||||
Programs, and causal deletion evidence.
|
|
||||||
|
|
||||||
## Sleep facts and heart-rate timelines
|
|
||||||
|
|
||||||
- The multi-night agenda, selected-night heart-rate detail, and vector PDF use
|
|
||||||
one temporal projection of factual sleep intervals, awakenings, point events,
|
|
||||||
medication occurrences, and exact bounds.
|
|
||||||
- The shared 18:00-to-18:00 agenda keeps exact hourly geometry at desktop and
|
|
||||||
narrow widths. A clearly labelled fallback estimate is presentation-only,
|
|
||||||
non-medical, and never written to Sleep data.
|
|
||||||
- Android wake capture records revisioned long-awake intervals, medication
|
|
||||||
quantity survives persistence and synchronization, and Sleep revision
|
|
||||||
payloads are append-only on both platforms.
|
|
||||||
|
|
||||||
## Synchronization and recovery
|
|
||||||
|
|
||||||
- Bluetooth restart recovery retains immutable generation identity and accepts
|
|
||||||
the exact original generation/digest acknowledgement across conversation
|
|
||||||
runs; direct MTP remains the wired recovery transport.
|
|
||||||
- Android foreground and automatic synchronization share one ownership arbiter,
|
|
||||||
and direct-MTP publication waits for MediaProvider visibility before exposing
|
|
||||||
a mutable generation reference.
|
|
||||||
- Divergent Sleep revision identities can be recovered without choosing an
|
|
||||||
arbitrary winner. Strict payload checks reject reuse of a historical revision
|
|
||||||
identity with different parentage or content.
|
|
||||||
- A separate heart-rate correction companion removes samples strictly after a
|
|
||||||
factual session end and prevents an older immutable generation from
|
|
||||||
resurrecting the removed tail.
|
|
||||||
|
|
||||||
## Compatibility and validation
|
|
||||||
|
|
||||||
- Desktop schema v36 and Android schema v33 are additive migrations.
|
|
||||||
- `TRAINLOG_FORMAT_V1`, mobile export V3, stable identities, generation/ACK
|
|
||||||
semantics, and Bluetooth/MTP transport ownership remain unchanged.
|
|
||||||
- The default automatic exchange remains V3; full-generation synchronization
|
|
||||||
remains behind its trusted local opt-in.
|
|
||||||
- Native Meson and Clang ASan/UBSan suites each pass 114/114. Android passes
|
|
||||||
317 tests (312 passed and five documented optional skips), debug assembly,
|
|
||||||
signed release assembly, and signature verification. Web passes 32 files / 209
|
|
||||||
tests plus typecheck/build; JSON/import validation, changed-C formatting, and
|
|
||||||
diff checks also pass at the release tag.
|
|
||||||
|
|
||||||
## Assets
|
|
||||||
|
|
||||||
- `trainlog-android-v0.1.6.apk`: signed public Android APK.
|
|
||||||
- `trainlog-tui-linux-x86_64-v0.1.6`: Linux x86-64 runtime bundle containing
|
|
||||||
the TUI, local Web adapter, synchronization helpers, and required catalogs.
|
|
||||||
- `SHA256SUMS`: SHA-256 checksums for both artifacts.
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 136 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 138 KiB |
|
|
@ -1,387 +0,0 @@
|
||||||
%PDF-1.4
|
|
||||||
%Trainlog
|
|
||||||
1 0 obj
|
|
||||||
<< /Type /Catalog /Pages 2 0 R >>
|
|
||||||
endobj
|
|
||||||
2 0 obj
|
|
||||||
<< /Type /Pages /Kids [4 0 R] /Count 1 >>
|
|
||||||
endobj
|
|
||||||
3 0 obj
|
|
||||||
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>
|
|
||||||
endobj
|
|
||||||
4 0 obj
|
|
||||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 842 595] /Resources << /Font << /F1 3 0 R >> >> /Contents 5 0 R >>
|
|
||||||
endobj
|
|
||||||
5 0 obj
|
|
||||||
<< /Length 17665 >>
|
|
||||||
stream
|
|
||||||
0 G 0 g
|
|
||||||
0 g BT /F1 14 Tf 30.0 565.0 Td (AGENDA TRAINLOG DE VIGILANCE ET DE SOMMEIL) Tj ET
|
|
||||||
0 g BT /F1 7 Tf 740.0 565.0 Td (1/1) Tj ET
|
|
||||||
0 g BT /F1 7 Tf 30.0 548.0 Td (DATE) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 112.0 548.0 Td (18) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 132.8 548.0 Td (19) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 153.7 548.0 Td (20) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 174.5 548.0 Td (21) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 195.3 548.0 Td (22) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 216.2 548.0 Td (23) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 237.0 548.0 Td (0) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 257.8 548.0 Td (1) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 278.7 548.0 Td (2) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 299.5 548.0 Td (3) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 320.3 548.0 Td (4) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 341.2 548.0 Td (5) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 362.0 548.0 Td (6) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 382.8 548.0 Td (7) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 403.7 548.0 Td (8) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 424.5 548.0 Td (9) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 445.3 548.0 Td (10) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 466.2 548.0 Td (11) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 487.0 548.0 Td (12) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 507.8 548.0 Td (13) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 528.7 548.0 Td (14) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 549.5 548.0 Td (15) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 570.3 548.0 Td (16) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 591.2 548.0 Td (17) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 612.0 548.0 Td (18) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 616.0 552.0 Td (QUALIT\311 DU) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 616.0 545.0 Td (SOMMEIL) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 654.0 552.0 Td (QUALIT\311 DU) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 654.0 545.0 Td (R\311VEIL) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 692.0 552.0 Td (FORME DE LA) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 692.0 545.0 Td (JOURN\311E) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 728.0 552.0 Td (TRAITEMENT ET) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 728.0 545.0 Td (REMARQUES) Tj ET
|
|
||||||
0.7 G 30 498 782 40 re S
|
|
||||||
0 g BT /F1 5 Tf 33.0 525.0 Td (23/09/2026) Tj ET
|
|
||||||
0 g BT /F1 5 Tf 33.0 515.0 Td (au 24/09/2026) Tj ET
|
|
||||||
0.9 G 112 498 m 112 538 l S
|
|
||||||
0.9 G 132.83333333333334 498 m 132.83333333333334 538 l S
|
|
||||||
0.9 G 153.66666666666666 498 m 153.66666666666666 538 l S
|
|
||||||
0.9 G 174.5 498 m 174.5 538 l S
|
|
||||||
0.9 G 195.33333333333331 498 m 195.33333333333331 538 l S
|
|
||||||
0.9 G 216.16666666666669 498 m 216.16666666666669 538 l S
|
|
||||||
0.9 G 237 498 m 237 538 l S
|
|
||||||
0.9 G 257.83333333333337 498 m 257.83333333333337 538 l S
|
|
||||||
0.9 G 278.66666666666663 498 m 278.66666666666663 538 l S
|
|
||||||
0.9 G 299.5 498 m 299.5 538 l S
|
|
||||||
0.9 G 320.33333333333337 498 m 320.33333333333337 538 l S
|
|
||||||
0.9 G 341.16666666666663 498 m 341.16666666666663 538 l S
|
|
||||||
0.9 G 362 498 m 362 538 l S
|
|
||||||
0.9 G 382.8333333333333 498 m 382.8333333333333 538 l S
|
|
||||||
0.9 G 403.6666666666667 498 m 403.6666666666667 538 l S
|
|
||||||
0.9 G 424.5 498 m 424.5 538 l S
|
|
||||||
0.9 G 445.3333333333333 498 m 445.3333333333333 538 l S
|
|
||||||
0.9 G 466.1666666666667 498 m 466.1666666666667 538 l S
|
|
||||||
0.9 G 487 498 m 487 538 l S
|
|
||||||
0.9 G 507.8333333333333 498 m 507.8333333333333 538 l S
|
|
||||||
0.9 G 528.6666666666667 498 m 528.6666666666667 538 l S
|
|
||||||
0.9 G 549.5 498 m 549.5 538 l S
|
|
||||||
0.9 G 570.3333333333333 498 m 570.3333333333333 538 l S
|
|
||||||
0.9 G 591.1666666666667 498 m 591.1666666666667 538 l S
|
|
||||||
0.9 G 612 498 m 612 538 l S
|
|
||||||
0 g BT /F1 8 Tf 211.0 513.0 Td (v) Tj ET
|
|
||||||
0.75 g 221.38 509.00 78.13 18.00 re f 0 G 221.38 509.00 78.13 18.00 re S
|
|
||||||
0 g BT /F1 5 Tf 223.4 515.0 Td (SOMMEIL) Tj ET
|
|
||||||
0.5 g 299.50 509.00 10.42 18.00 re f 0 G 299.50 509.00 10.42 18.00 re S
|
|
||||||
0.75 g 309.92 509.00 67.71 18.00 re f 0 G 309.92 509.00 67.71 18.00 re S
|
|
||||||
0 g BT /F1 5 Tf 311.9 515.0 Td (SOMMEIL) Tj ET
|
|
||||||
0 g BT /F1 8 Tf 386.3 513.0 Td (^) Tj ET
|
|
||||||
0 g BT /F1 6 Tf 195.3 502.0 Td (M) Tj ET
|
|
||||||
0 g BT /F1 6 Tf 195.3 502.0 Td (M) Tj ET
|
|
||||||
0 g BT /F1 7 Tf 620.0 522.0 Td (-) Tj ET
|
|
||||||
0 g BT /F1 7 Tf 658.0 522.0 Td (-) Tj ET
|
|
||||||
0 g BT /F1 7 Tf 696.0 522.0 Td (-) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 730.0 531.0 Td (22:00 \227 venlafaxine \227 37,5 mg ; 22:00 \227 ) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 730.0 522.0 Td (venlafaxine \227 75 mg) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 730.0 513.0 Td () Tj ET
|
|
||||||
0 g BT /F1 4 Tf 730.0 504.0 Td () Tj ET
|
|
||||||
0.7 G 30 458 782 40 re S
|
|
||||||
0 g BT /F1 5 Tf 33.0 485.0 Td (19/09/2026) Tj ET
|
|
||||||
0 g BT /F1 5 Tf 33.0 475.0 Td (au 20/09/2026) Tj ET
|
|
||||||
0.9 G 112 458 m 112 498 l S
|
|
||||||
0.9 G 132.83333333333334 458 m 132.83333333333334 498 l S
|
|
||||||
0.9 G 153.66666666666666 458 m 153.66666666666666 498 l S
|
|
||||||
0.9 G 174.5 458 m 174.5 498 l S
|
|
||||||
0.9 G 195.33333333333331 458 m 195.33333333333331 498 l S
|
|
||||||
0.9 G 216.16666666666669 458 m 216.16666666666669 498 l S
|
|
||||||
0.9 G 237 458 m 237 498 l S
|
|
||||||
0.9 G 257.83333333333337 458 m 257.83333333333337 498 l S
|
|
||||||
0.9 G 278.66666666666663 458 m 278.66666666666663 498 l S
|
|
||||||
0.9 G 299.5 458 m 299.5 498 l S
|
|
||||||
0.9 G 320.33333333333337 458 m 320.33333333333337 498 l S
|
|
||||||
0.9 G 341.16666666666663 458 m 341.16666666666663 498 l S
|
|
||||||
0.9 G 362 458 m 362 498 l S
|
|
||||||
0.9 G 382.8333333333333 458 m 382.8333333333333 498 l S
|
|
||||||
0.9 G 403.6666666666667 458 m 403.6666666666667 498 l S
|
|
||||||
0.9 G 424.5 458 m 424.5 498 l S
|
|
||||||
0.9 G 445.3333333333333 458 m 445.3333333333333 498 l S
|
|
||||||
0.9 G 466.1666666666667 458 m 466.1666666666667 498 l S
|
|
||||||
0.9 G 487 458 m 487 498 l S
|
|
||||||
0.9 G 507.8333333333333 458 m 507.8333333333333 498 l S
|
|
||||||
0.9 G 528.6666666666667 458 m 528.6666666666667 498 l S
|
|
||||||
0.9 G 549.5 458 m 549.5 498 l S
|
|
||||||
0.9 G 570.3333333333333 458 m 570.3333333333333 498 l S
|
|
||||||
0.9 G 591.1666666666667 458 m 591.1666666666667 498 l S
|
|
||||||
0.9 G 612 458 m 612 498 l S
|
|
||||||
0.75 g 205.75 469.00 104.17 18.00 re f 0 G 205.75 469.00 104.17 18.00 re S
|
|
||||||
0 g BT /F1 5 Tf 207.8 475.0 Td (SOMMEIL) Tj ET
|
|
||||||
0 g BT /F1 8 Tf 205.8 473.0 Td (v) Tj ET
|
|
||||||
0 g BT /F1 8 Tf 336.0 473.0 Td (^) Tj ET
|
|
||||||
0 g BT /F1 8 Tf 559.9 473.0 Td (S) Tj ET
|
|
||||||
0 g BT /F1 6 Tf 205.8 462.0 Td (M) Tj ET
|
|
||||||
0 g BT /F1 7 Tf 620.0 482.0 Td (B) Tj ET
|
|
||||||
0 g BT /F1 7 Tf 658.0 482.0 Td (TB) Tj ET
|
|
||||||
0 g BT /F1 7 Tf 696.0 482.0 Td (Moy) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 730.0 491.0 Td (22:30 \227 venlafaxine \227 75 mg \3272 | Nuit de) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 730.0 482.0 Td ( contr\364le g\351om\351trique.) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 730.0 473.0 Td () Tj ET
|
|
||||||
0 g BT /F1 4 Tf 730.0 464.0 Td () Tj ET
|
|
||||||
0.7 G 30 418 782 40 re S
|
|
||||||
0 g BT /F1 5 Tf 33.0 445.0 Td (18/09/2026) Tj ET
|
|
||||||
0 g BT /F1 5 Tf 33.0 435.0 Td (au 19/09/2026) Tj ET
|
|
||||||
0.9 G 112 418 m 112 458 l S
|
|
||||||
0.9 G 132.83333333333334 418 m 132.83333333333334 458 l S
|
|
||||||
0.9 G 153.66666666666666 418 m 153.66666666666666 458 l S
|
|
||||||
0.9 G 174.5 418 m 174.5 458 l S
|
|
||||||
0.9 G 195.33333333333331 418 m 195.33333333333331 458 l S
|
|
||||||
0.9 G 216.16666666666669 418 m 216.16666666666669 458 l S
|
|
||||||
0.9 G 237 418 m 237 458 l S
|
|
||||||
0.9 G 257.83333333333337 418 m 257.83333333333337 458 l S
|
|
||||||
0.9 G 278.66666666666663 418 m 278.66666666666663 458 l S
|
|
||||||
0.9 G 299.5 418 m 299.5 458 l S
|
|
||||||
0.9 G 320.33333333333337 418 m 320.33333333333337 458 l S
|
|
||||||
0.9 G 341.16666666666663 418 m 341.16666666666663 458 l S
|
|
||||||
0.9 G 362 418 m 362 458 l S
|
|
||||||
0.9 G 382.8333333333333 418 m 382.8333333333333 458 l S
|
|
||||||
0.9 G 403.6666666666667 418 m 403.6666666666667 458 l S
|
|
||||||
0.9 G 424.5 418 m 424.5 458 l S
|
|
||||||
0.9 G 445.3333333333333 418 m 445.3333333333333 458 l S
|
|
||||||
0.9 G 466.1666666666667 418 m 466.1666666666667 458 l S
|
|
||||||
0.9 G 487 418 m 487 458 l S
|
|
||||||
0.9 G 507.8333333333333 418 m 507.8333333333333 458 l S
|
|
||||||
0.9 G 528.6666666666667 418 m 528.6666666666667 458 l S
|
|
||||||
0.9 G 549.5 418 m 549.5 458 l S
|
|
||||||
0.9 G 570.3333333333333 418 m 570.3333333333333 458 l S
|
|
||||||
0.9 G 591.1666666666667 418 m 591.1666666666667 458 l S
|
|
||||||
0.9 G 612 418 m 612 458 l S
|
|
||||||
0 g BT /F1 8 Tf 205.8 433.0 Td (v) Tj ET
|
|
||||||
0.75 g 216.17 429.00 83.33 18.00 re f 0 G 216.17 429.00 83.33 18.00 re S
|
|
||||||
0 g BT /F1 5 Tf 218.2 435.0 Td (SOMMEIL) Tj ET
|
|
||||||
0.5 g 299.50 429.00 15.63 18.00 re f 0 G 299.50 429.00 15.63 18.00 re S
|
|
||||||
0.75 g 315.13 429.00 67.71 18.00 re f 0 G 315.13 429.00 67.71 18.00 re S
|
|
||||||
0 g BT /F1 5 Tf 317.1 435.0 Td (SOMMEIL) Tj ET
|
|
||||||
0.87 g 382.83 429.00 3.47 18.00 re f 0 G 382.83 429.00 3.47 18.00 re S
|
|
||||||
0 g BT /F1 8 Tf 388.0 433.0 Td (^) Tj ET
|
|
||||||
0.93 g 528.67 429.00 12.15 18.00 re f 0 G 528.67 429.00 12.15 18.00 re S
|
|
||||||
0 g BT /F1 8 Tf 549.5 433.0 Td (S) Tj ET
|
|
||||||
0 g BT /F1 7 Tf 620.0 442.0 Td (B) Tj ET
|
|
||||||
0 g BT /F1 7 Tf 658.0 442.0 Td (Moy) Tj ET
|
|
||||||
0 g BT /F1 7 Tf 696.0 442.0 Td (TB) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 730.0 451.0 Td (Remarque fran\347aise synth\351tique.) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 730.0 442.0 Td () Tj ET
|
|
||||||
0 g BT /F1 4 Tf 730.0 433.0 Td () Tj ET
|
|
||||||
0 g BT /F1 4 Tf 730.0 424.0 Td () Tj ET
|
|
||||||
0.7 G 30 378 782 40 re S
|
|
||||||
0 g BT /F1 5 Tf 33.0 405.0 Td (17/09/2026) Tj ET
|
|
||||||
0 g BT /F1 5 Tf 33.0 395.0 Td (au 18/09/2026) Tj ET
|
|
||||||
0.9 G 112 378 m 112 418 l S
|
|
||||||
0.9 G 132.83333333333334 378 m 132.83333333333334 418 l S
|
|
||||||
0.9 G 153.66666666666666 378 m 153.66666666666666 418 l S
|
|
||||||
0.9 G 174.5 378 m 174.5 418 l S
|
|
||||||
0.9 G 195.33333333333331 378 m 195.33333333333331 418 l S
|
|
||||||
0.9 G 216.16666666666669 378 m 216.16666666666669 418 l S
|
|
||||||
0.9 G 237 378 m 237 418 l S
|
|
||||||
0.9 G 257.83333333333337 378 m 257.83333333333337 418 l S
|
|
||||||
0.9 G 278.66666666666663 378 m 278.66666666666663 418 l S
|
|
||||||
0.9 G 299.5 378 m 299.5 418 l S
|
|
||||||
0.9 G 320.33333333333337 378 m 320.33333333333337 418 l S
|
|
||||||
0.9 G 341.16666666666663 378 m 341.16666666666663 418 l S
|
|
||||||
0.9 G 362 378 m 362 418 l S
|
|
||||||
0.9 G 382.8333333333333 378 m 382.8333333333333 418 l S
|
|
||||||
0.9 G 403.6666666666667 378 m 403.6666666666667 418 l S
|
|
||||||
0.9 G 424.5 378 m 424.5 418 l S
|
|
||||||
0.9 G 445.3333333333333 378 m 445.3333333333333 418 l S
|
|
||||||
0.9 G 466.1666666666667 378 m 466.1666666666667 418 l S
|
|
||||||
0.9 G 487 378 m 487 418 l S
|
|
||||||
0.9 G 507.8333333333333 378 m 507.8333333333333 418 l S
|
|
||||||
0.9 G 528.6666666666667 378 m 528.6666666666667 418 l S
|
|
||||||
0.9 G 549.5 378 m 549.5 418 l S
|
|
||||||
0.9 G 570.3333333333333 378 m 570.3333333333333 418 l S
|
|
||||||
0.9 G 591.1666666666667 378 m 591.1666666666667 418 l S
|
|
||||||
0.9 G 612 378 m 612 418 l S
|
|
||||||
0 g BT /F1 8 Tf 205.8 393.0 Td (v) Tj ET
|
|
||||||
0.75 g 216.17 389.00 83.33 18.00 re f 0 G 216.17 389.00 83.33 18.00 re S
|
|
||||||
0 g BT /F1 5 Tf 218.2 395.0 Td (SOMMEIL) Tj ET
|
|
||||||
0.5 g 299.50 389.00 15.63 18.00 re f 0 G 299.50 389.00 15.63 18.00 re S
|
|
||||||
0.75 g 315.13 389.00 67.71 18.00 re f 0 G 315.13 389.00 67.71 18.00 re S
|
|
||||||
0 g BT /F1 5 Tf 317.1 395.0 Td (SOMMEIL) Tj ET
|
|
||||||
0.87 g 382.83 389.00 3.47 18.00 re f 0 G 382.83 389.00 3.47 18.00 re S
|
|
||||||
0 g BT /F1 8 Tf 388.0 393.0 Td (^) Tj ET
|
|
||||||
0.93 g 528.67 389.00 12.15 18.00 re f 0 G 528.67 389.00 12.15 18.00 re S
|
|
||||||
0 g BT /F1 8 Tf 549.5 393.0 Td (S) Tj ET
|
|
||||||
0 g BT /F1 7 Tf 620.0 402.0 Td (B) Tj ET
|
|
||||||
0 g BT /F1 7 Tf 658.0 402.0 Td (Moy) Tj ET
|
|
||||||
0 g BT /F1 7 Tf 696.0 402.0 Td (TB) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 730.0 411.0 Td (Remarque fran\347aise synth\351tique.) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 730.0 402.0 Td () Tj ET
|
|
||||||
0 g BT /F1 4 Tf 730.0 393.0 Td () Tj ET
|
|
||||||
0 g BT /F1 4 Tf 730.0 384.0 Td () Tj ET
|
|
||||||
0.7 G 30 338 782 40 re S
|
|
||||||
0 g BT /F1 5 Tf 33.0 365.0 Td (16/09/2026) Tj ET
|
|
||||||
0 g BT /F1 5 Tf 33.0 355.0 Td (au 17/09/2026) Tj ET
|
|
||||||
0.9 G 112 338 m 112 378 l S
|
|
||||||
0.9 G 132.83333333333334 338 m 132.83333333333334 378 l S
|
|
||||||
0.9 G 153.66666666666666 338 m 153.66666666666666 378 l S
|
|
||||||
0.9 G 174.5 338 m 174.5 378 l S
|
|
||||||
0.9 G 195.33333333333331 338 m 195.33333333333331 378 l S
|
|
||||||
0.9 G 216.16666666666669 338 m 216.16666666666669 378 l S
|
|
||||||
0.9 G 237 338 m 237 378 l S
|
|
||||||
0.9 G 257.83333333333337 338 m 257.83333333333337 378 l S
|
|
||||||
0.9 G 278.66666666666663 338 m 278.66666666666663 378 l S
|
|
||||||
0.9 G 299.5 338 m 299.5 378 l S
|
|
||||||
0.9 G 320.33333333333337 338 m 320.33333333333337 378 l S
|
|
||||||
0.9 G 341.16666666666663 338 m 341.16666666666663 378 l S
|
|
||||||
0.9 G 362 338 m 362 378 l S
|
|
||||||
0.9 G 382.8333333333333 338 m 382.8333333333333 378 l S
|
|
||||||
0.9 G 403.6666666666667 338 m 403.6666666666667 378 l S
|
|
||||||
0.9 G 424.5 338 m 424.5 378 l S
|
|
||||||
0.9 G 445.3333333333333 338 m 445.3333333333333 378 l S
|
|
||||||
0.9 G 466.1666666666667 338 m 466.1666666666667 378 l S
|
|
||||||
0.9 G 487 338 m 487 378 l S
|
|
||||||
0.9 G 507.8333333333333 338 m 507.8333333333333 378 l S
|
|
||||||
0.9 G 528.6666666666667 338 m 528.6666666666667 378 l S
|
|
||||||
0.9 G 549.5 338 m 549.5 378 l S
|
|
||||||
0.9 G 570.3333333333333 338 m 570.3333333333333 378 l S
|
|
||||||
0.9 G 591.1666666666667 338 m 591.1666666666667 378 l S
|
|
||||||
0.9 G 612 338 m 612 378 l S
|
|
||||||
0 g BT /F1 8 Tf 205.8 353.0 Td (v) Tj ET
|
|
||||||
0.75 g 216.17 349.00 83.33 18.00 re f 0 G 216.17 349.00 83.33 18.00 re S
|
|
||||||
0 g BT /F1 5 Tf 218.2 355.0 Td (SOMMEIL) Tj ET
|
|
||||||
0.5 g 299.50 349.00 15.63 18.00 re f 0 G 299.50 349.00 15.63 18.00 re S
|
|
||||||
0.75 g 315.13 349.00 67.71 18.00 re f 0 G 315.13 349.00 67.71 18.00 re S
|
|
||||||
0 g BT /F1 5 Tf 317.1 355.0 Td (SOMMEIL) Tj ET
|
|
||||||
0.87 g 382.83 349.00 3.47 18.00 re f 0 G 382.83 349.00 3.47 18.00 re S
|
|
||||||
0 g BT /F1 8 Tf 388.0 353.0 Td (^) Tj ET
|
|
||||||
0.93 g 528.67 349.00 12.15 18.00 re f 0 G 528.67 349.00 12.15 18.00 re S
|
|
||||||
0 g BT /F1 8 Tf 549.5 353.0 Td (S) Tj ET
|
|
||||||
0 g BT /F1 7 Tf 620.0 362.0 Td (B) Tj ET
|
|
||||||
0 g BT /F1 7 Tf 658.0 362.0 Td (Moy) Tj ET
|
|
||||||
0 g BT /F1 7 Tf 696.0 362.0 Td (TB) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 730.0 371.0 Td (Remarque fran\347aise synth\351tique.) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 730.0 362.0 Td () Tj ET
|
|
||||||
0 g BT /F1 4 Tf 730.0 353.0 Td () Tj ET
|
|
||||||
0 g BT /F1 4 Tf 730.0 344.0 Td () Tj ET
|
|
||||||
0.7 G 30 298 782 40 re S
|
|
||||||
0 g BT /F1 5 Tf 33.0 325.0 Td (15/09/2026) Tj ET
|
|
||||||
0 g BT /F1 5 Tf 33.0 315.0 Td (au 16/09/2026) Tj ET
|
|
||||||
0.9 G 112 298 m 112 338 l S
|
|
||||||
0.9 G 132.83333333333334 298 m 132.83333333333334 338 l S
|
|
||||||
0.9 G 153.66666666666666 298 m 153.66666666666666 338 l S
|
|
||||||
0.9 G 174.5 298 m 174.5 338 l S
|
|
||||||
0.9 G 195.33333333333331 298 m 195.33333333333331 338 l S
|
|
||||||
0.9 G 216.16666666666669 298 m 216.16666666666669 338 l S
|
|
||||||
0.9 G 237 298 m 237 338 l S
|
|
||||||
0.9 G 257.83333333333337 298 m 257.83333333333337 338 l S
|
|
||||||
0.9 G 278.66666666666663 298 m 278.66666666666663 338 l S
|
|
||||||
0.9 G 299.5 298 m 299.5 338 l S
|
|
||||||
0.9 G 320.33333333333337 298 m 320.33333333333337 338 l S
|
|
||||||
0.9 G 341.16666666666663 298 m 341.16666666666663 338 l S
|
|
||||||
0.9 G 362 298 m 362 338 l S
|
|
||||||
0.9 G 382.8333333333333 298 m 382.8333333333333 338 l S
|
|
||||||
0.9 G 403.6666666666667 298 m 403.6666666666667 338 l S
|
|
||||||
0.9 G 424.5 298 m 424.5 338 l S
|
|
||||||
0.9 G 445.3333333333333 298 m 445.3333333333333 338 l S
|
|
||||||
0.9 G 466.1666666666667 298 m 466.1666666666667 338 l S
|
|
||||||
0.9 G 487 298 m 487 338 l S
|
|
||||||
0.9 G 507.8333333333333 298 m 507.8333333333333 338 l S
|
|
||||||
0.9 G 528.6666666666667 298 m 528.6666666666667 338 l S
|
|
||||||
0.9 G 549.5 298 m 549.5 338 l S
|
|
||||||
0.9 G 570.3333333333333 298 m 570.3333333333333 338 l S
|
|
||||||
0.9 G 591.1666666666667 298 m 591.1666666666667 338 l S
|
|
||||||
0.9 G 612 298 m 612 338 l S
|
|
||||||
0 g BT /F1 8 Tf 205.8 313.0 Td (v) Tj ET
|
|
||||||
0.75 g 216.17 309.00 83.33 18.00 re f 0 G 216.17 309.00 83.33 18.00 re S
|
|
||||||
0 g BT /F1 5 Tf 218.2 315.0 Td (SOMMEIL) Tj ET
|
|
||||||
0.5 g 299.50 309.00 15.63 18.00 re f 0 G 299.50 309.00 15.63 18.00 re S
|
|
||||||
0.75 g 315.13 309.00 67.71 18.00 re f 0 G 315.13 309.00 67.71 18.00 re S
|
|
||||||
0 g BT /F1 5 Tf 317.1 315.0 Td (SOMMEIL) Tj ET
|
|
||||||
0.87 g 382.83 309.00 3.47 18.00 re f 0 G 382.83 309.00 3.47 18.00 re S
|
|
||||||
0 g BT /F1 8 Tf 388.0 313.0 Td (^) Tj ET
|
|
||||||
0.93 g 528.67 309.00 12.15 18.00 re f 0 G 528.67 309.00 12.15 18.00 re S
|
|
||||||
0 g BT /F1 8 Tf 549.5 313.0 Td (S) Tj ET
|
|
||||||
0 g BT /F1 7 Tf 620.0 322.0 Td (B) Tj ET
|
|
||||||
0 g BT /F1 7 Tf 658.0 322.0 Td (Moy) Tj ET
|
|
||||||
0 g BT /F1 7 Tf 696.0 322.0 Td (TB) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 730.0 331.0 Td (Remarque fran\347aise synth\351tique.) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 730.0 322.0 Td () Tj ET
|
|
||||||
0 g BT /F1 4 Tf 730.0 313.0 Td () Tj ET
|
|
||||||
0 g BT /F1 4 Tf 730.0 304.0 Td () Tj ET
|
|
||||||
0.7 G 30 258 782 40 re S
|
|
||||||
0 g BT /F1 5 Tf 33.0 285.0 Td (14/09/2026) Tj ET
|
|
||||||
0 g BT /F1 5 Tf 33.0 275.0 Td (au 15/09/2026) Tj ET
|
|
||||||
0.9 G 112 258 m 112 298 l S
|
|
||||||
0.9 G 132.83333333333334 258 m 132.83333333333334 298 l S
|
|
||||||
0.9 G 153.66666666666666 258 m 153.66666666666666 298 l S
|
|
||||||
0.9 G 174.5 258 m 174.5 298 l S
|
|
||||||
0.9 G 195.33333333333331 258 m 195.33333333333331 298 l S
|
|
||||||
0.9 G 216.16666666666669 258 m 216.16666666666669 298 l S
|
|
||||||
0.9 G 237 258 m 237 298 l S
|
|
||||||
0.9 G 257.83333333333337 258 m 257.83333333333337 298 l S
|
|
||||||
0.9 G 278.66666666666663 258 m 278.66666666666663 298 l S
|
|
||||||
0.9 G 299.5 258 m 299.5 298 l S
|
|
||||||
0.9 G 320.33333333333337 258 m 320.33333333333337 298 l S
|
|
||||||
0.9 G 341.16666666666663 258 m 341.16666666666663 298 l S
|
|
||||||
0.9 G 362 258 m 362 298 l S
|
|
||||||
0.9 G 382.8333333333333 258 m 382.8333333333333 298 l S
|
|
||||||
0.9 G 403.6666666666667 258 m 403.6666666666667 298 l S
|
|
||||||
0.9 G 424.5 258 m 424.5 298 l S
|
|
||||||
0.9 G 445.3333333333333 258 m 445.3333333333333 298 l S
|
|
||||||
0.9 G 466.1666666666667 258 m 466.1666666666667 298 l S
|
|
||||||
0.9 G 487 258 m 487 298 l S
|
|
||||||
0.9 G 507.8333333333333 258 m 507.8333333333333 298 l S
|
|
||||||
0.9 G 528.6666666666667 258 m 528.6666666666667 298 l S
|
|
||||||
0.9 G 549.5 258 m 549.5 298 l S
|
|
||||||
0.9 G 570.3333333333333 258 m 570.3333333333333 298 l S
|
|
||||||
0.9 G 591.1666666666667 258 m 591.1666666666667 298 l S
|
|
||||||
0.9 G 612 258 m 612 298 l S
|
|
||||||
0 g BT /F1 8 Tf 205.8 273.0 Td (v) Tj ET
|
|
||||||
0.75 g 216.17 269.00 83.33 18.00 re f 0 G 216.17 269.00 83.33 18.00 re S
|
|
||||||
0 g BT /F1 5 Tf 218.2 275.0 Td (SOMMEIL) Tj ET
|
|
||||||
0.5 g 299.50 269.00 15.63 18.00 re f 0 G 299.50 269.00 15.63 18.00 re S
|
|
||||||
0.75 g 315.13 269.00 67.71 18.00 re f 0 G 315.13 269.00 67.71 18.00 re S
|
|
||||||
0 g BT /F1 5 Tf 317.1 275.0 Td (SOMMEIL) Tj ET
|
|
||||||
0.87 g 382.83 269.00 3.47 18.00 re f 0 G 382.83 269.00 3.47 18.00 re S
|
|
||||||
0 g BT /F1 8 Tf 388.0 273.0 Td (^) Tj ET
|
|
||||||
0.93 g 528.67 269.00 12.15 18.00 re f 0 G 528.67 269.00 12.15 18.00 re S
|
|
||||||
0 g BT /F1 8 Tf 549.5 273.0 Td (S) Tj ET
|
|
||||||
0 g BT /F1 7 Tf 620.0 282.0 Td (B) Tj ET
|
|
||||||
0 g BT /F1 7 Tf 658.0 282.0 Td (Moy) Tj ET
|
|
||||||
0 g BT /F1 7 Tf 696.0 282.0 Td (TB) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 730.0 291.0 Td (Remarque fran\347aise synth\351tique.) Tj ET
|
|
||||||
0 g BT /F1 4 Tf 730.0 282.0 Td () Tj ET
|
|
||||||
0 g BT /F1 4 Tf 730.0 273.0 Td () Tj ET
|
|
||||||
0 g BT /F1 4 Tf 730.0 264.0 Td () Tj ET
|
|
||||||
0 g BT /F1 9 Tf 30.0 240.0 Td (OBSERVATIONS) Tj ET
|
|
||||||
0 g BT /F1 6 Tf 30.0 226.0 Td (7 nuits \267 Sommeil d\351clar\351 : 48 h 15 min \267 Longs r\351veils : 6 \267 Siestes : 5 \267 Somnolences : 6 \267 Prises de m\351dicaments : 3) Tj ET
|
|
||||||
0 g BT /F1 6 Tf 30.0 216.0 Td (AVERTISSEMENT : 7 jours non valid\351s dans cet export.) Tj ET
|
|
||||||
0 g BT /F1 6 Tf 30.0 204.0 Td (19/09/2026 : Nuit de contr\364le g\351om\351trique.) Tj ET
|
|
||||||
0 g BT /F1 6 Tf 30.0 194.0 Td (18/09/2026 : Remarque fran\347aise synth\351tique.) Tj ET
|
|
||||||
0 g BT /F1 6 Tf 30.0 184.0 Td (17/09/2026 : Remarque fran\347aise synth\351tique.) Tj ET
|
|
||||||
0 g BT /F1 6 Tf 30.0 28.0 Td (L\351gende : v Mise au lit ; ^ Lever ; ^ Lever nocturne ; S Somnolence ; M Prise de m\351dicament.) Tj ET
|
|
||||||
0 g BT /F1 6 Tf 30.0 18.0 Td (Sommeil : plage de sommeil ; Sieste : plage de sieste ; Long r\351veil : interruption prolong\351e ; Demi-sommeil : plage de demi-sommeil.) Tj ET
|
|
||||||
endstream
|
|
||||||
endobj
|
|
||||||
xref
|
|
||||||
0 6
|
|
||||||
0000000000 65535 f
|
|
||||||
0000000019 00000 n
|
|
||||||
0000000068 00000 n
|
|
||||||
0000000125 00000 n
|
|
||||||
0000000222 00000 n
|
|
||||||
0000000348 00000 n
|
|
||||||
trailer
|
|
||||||
<< /Size 6 /Root 1 0 R >>
|
|
||||||
startxref
|
|
||||||
18065
|
|
||||||
%%EOF
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 124 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 140 KiB |
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue