Compare commits
40 commits
bedf392542
...
5d58071601
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d58071601 | ||
|
|
b4fd753ac5 | ||
|
|
8a334613f8 | ||
|
|
26d24d2ed7 | ||
|
|
1d8450ad1c | ||
|
|
d5b4584ae2 | ||
|
|
b580b63886 | ||
|
|
066a3a44a7 | ||
|
|
87931ca1ec | ||
|
|
c11ea07a19 | ||
|
|
3347de3dbb | ||
|
|
0a35ddacbf | ||
|
|
a88e85c235 | ||
|
|
0f7d793960 | ||
|
|
dd6d2a180d | ||
|
|
891c09179e | ||
|
|
e6e5e76f68 | ||
|
|
9652b35189 | ||
|
|
a04e41d252 | ||
|
|
20fe562cd8 | ||
|
|
a98a4440ed | ||
|
|
915728cec4 | ||
|
|
47ee128807 | ||
|
|
e320767516 | ||
|
|
c5e62c2e5e | ||
|
|
e2284be1e3 | ||
|
|
f48be35f3b | ||
|
|
0cf53228da | ||
|
|
afd1fcc92f | ||
|
|
53f15e1354 | ||
|
|
1096b13939 | ||
|
|
bf27e05e7a | ||
|
|
36a7e651ac | ||
|
|
30df3030e6 | ||
|
|
2565d0eafa | ||
|
|
6948e0bbd5 | ||
|
|
c31c394e4f | ||
|
|
109eb6fcd4 | ||
|
|
2bd68100e9 | ||
|
|
451d0a36a0 |
160 changed files with 20744 additions and 1287 deletions
104
CHANGELOG.md
104
CHANGELOG.md
|
|
@ -1,5 +1,109 @@
|
|||
# Changelog
|
||||
|
||||
## 0.1.4 — 2026-09-22
|
||||
|
||||
- Added the frozen Bluetooth Classic RFCOMM synchronization transport without
|
||||
changing full-generation business semantics. The paired deployment selects
|
||||
Bluetooth before MTP, authenticates the configured bonded Android peer with
|
||||
the persisted Trainlog peer identity, rate-limits reconnect noise, and keeps
|
||||
MTP as the wired recovery transport. This transport is for Trainlog data
|
||||
synchronization only; heart-rate sensor ingestion remains future 0.1.5 work.
|
||||
- Refreshed the Web next-session projection after synchronization and added
|
||||
factual body-measurement trend charts through the existing bounded analysis
|
||||
read model without introducing health interpretation or estimated metrics.
|
||||
- Fixed Program progress for sessions started from a delivered manual
|
||||
preparation. The preparation companion now carries its stable Program and
|
||||
Program-session provenance through Android start, finalization, restart and
|
||||
the existing execution companion; legacy stored deliveries are safely
|
||||
enriched only when every pre-existing field and execution identity matches.
|
||||
Android and Web consequently present the completed session and progress from
|
||||
the same persisted identity without fabricating history.
|
||||
Historical acknowledged deliveries now complete that recovery on the
|
||||
original session even when the preparation contained planned continuous
|
||||
activities, and current sleep snapshots can initialize a fresh peer from a
|
||||
non-root revision without discarding causal identity.
|
||||
- Aligned the Sleep Diary Agenda ticks, interval bands and point markers on one
|
||||
exact 18:00-to-18:00 timestamp projection. Selecting another night now clears
|
||||
the previous entry immediately, adopts only an exact-date result and rejects
|
||||
stale out-of-order reads; an absent night remains unpersisted and empty until
|
||||
the first real edit. The editor presents local completion as Draft,
|
||||
Day validated or Modified without exposing synchronization transport state.
|
||||
- Fully localized the French Sleep Diary PDF without changing its canonical
|
||||
data source or event semantics. Column headings, timeline labels, legend,
|
||||
factual summary, draft warning, dates and plurals are now French, accented
|
||||
text uses the PDF WinAnsi encoding, and durations use human-readable units.
|
||||
Long awakenings remain proportional intervals between independent sleep
|
||||
ranges, while medication intake snapshots retain localized dose and unit.
|
||||
The English report keeps an independent English vocabulary.
|
||||
- Made the Sleep Diary Agenda a live projection of the latest locally
|
||||
persisted entry revision. Event and intake mutations now re-read and adopt
|
||||
the canonical Core snapshot, serialized autosaves carry the returned
|
||||
revision forward, and stale asynchronous reads cannot regress the visible
|
||||
state. The Agenda shows factual bedtime, final get-up, declared-sleep and
|
||||
time-in-bed durations, long awakenings, naps, sleepiness and grouped
|
||||
medication markers without requiring reload or publication. Medication
|
||||
catalog entries and selectors now include their usual localized dose and
|
||||
unit, keep same-name/different-dose records distinct, and prefill editable
|
||||
intake doses. Schemas, domain semantics, synchronization and PDF generation
|
||||
are unchanged.
|
||||
- Fixed the Sleep Diary Web workspace becoming entirely blank after adding an
|
||||
event or medication intake on the loopback-routed HTTP origin. Sleep event
|
||||
and intake UUIDv4 identities now use secure random bytes without depending
|
||||
on the secure-context-only `crypto.randomUUID()` API, and a section-local
|
||||
error boundary preserves the Trainlog shell if an unrelated render failure
|
||||
occurs. The capture workspace now reuses Trainlog cards, controls, status,
|
||||
primary/secondary/danger actions and responsive spacing for a structured
|
||||
night header, appreciations, event timeline, medication catalog and intake
|
||||
editor. Domain data, schemas, revisions, synchronization and PDF semantics
|
||||
are unchanged.
|
||||
- Fixed equipment creation from the Android active-session exercise editor.
|
||||
The selector now exposes the complete catalogue in a bounded scrollable
|
||||
list, reuses the catalogue creation route, returns to the same stable
|
||||
occurrence with all draft fields intact, and selects the explicitly created
|
||||
equipment. Target-only prepared occurrences can save that equipment in
|
||||
place without fabricating performed sets or appending a duplicate occurrence.
|
||||
- Fixed the Android active-session exercise editor for target-only continuous
|
||||
preparation entries. Opening an occurrence now renders its prefilled form,
|
||||
preserves its independent plan while editing performed values, updates only
|
||||
the selected stable occurrence, and reports persistence failures instead of
|
||||
displaying a false editor-opened confirmation.
|
||||
- Fixed Android activation of mixed session preparations containing continuous
|
||||
duration targets and set-based exercises. Continuous target metadata now
|
||||
remains planning-only, SQL `NULL` set counts round-trip correctly, and no
|
||||
performed continuous activity or set is created before user capture.
|
||||
- Added Sleep Diary V1 across desktop schema v29, Android schema v26, Analyse,
|
||||
full-generation synchronization and a deterministic local vector PDF agenda.
|
||||
Entries retain causal revisions, absolute timestamped events, the five-value
|
||||
appreciation scale and user-authored notes; no medical interpretation or
|
||||
heart-rate data is introduced. Sleep Diary also includes a revisioned local
|
||||
medication catalog and timestamped intake snapshots with optional positive
|
||||
dose/unit, synchronized and rendered factually in the agenda and PDF without
|
||||
efficacy, interaction, causality, dosage, or clinical advice.
|
||||
Progressive daily capture now persists every edit locally while keeping
|
||||
draft, ready, synchronized, and post-sync modified states distinct. Explicit
|
||||
validation publishes only the guarded current revision through the existing
|
||||
full-generation/ACK path; validation never makes a day immutable.
|
||||
|
||||
Development opened after stable v0.1.3 release.
|
||||
|
||||
- Implemented the loopback-only Web Analyse V1 workspace and shared bounded
|
||||
Core read model. The 7/30/90-day and all-history filters, factual activity,
|
||||
active-Program progress, compatible exercise metrics, explicit persisted
|
||||
MAX results, primary BODY ZONE exposures, and fourteen body measurements are
|
||||
calculated by the C17 backend. Dashboard summaries consume the same model
|
||||
while preserving all existing tile identities and layout behavior. Deep
|
||||
links, safe query fallbacks, null/empty states, French/English presentation,
|
||||
keyboard focus, and responsive charts are covered without estimating 1RM,
|
||||
readiness, fatigue, recovery, body fat, or performance.
|
||||
- Refined the Analyse V1 and Dashboard presentation without changing their
|
||||
read models or API contracts. Period controls now share the section toolbar;
|
||||
durations use the shared human-readable formatter; incompatible exercise
|
||||
metrics remain hidden; activity, exercise, and measurement charts expose
|
||||
chronological dates, units, and factual tooltips; BODY ZONE silhouettes and
|
||||
exposure comparisons are more legible; and active-Program summaries use a
|
||||
compact structured progression layout. Persisted Dashboard tile identities
|
||||
and layout behavior remain unchanged.
|
||||
|
||||
## 0.1.3 — 2026-09-20
|
||||
|
||||
- Decoupled the Android full-generation request from the strict legacy V3
|
||||
|
|
|
|||
56
README.md
56
README.md
|
|
@ -1,22 +1,22 @@
|
|||
# Trainlog
|
||||
|
||||
Synchronization uses one generation/ACK engine over USB/MTP first and an
|
||||
optional private Google Drive mirror/fallback. Drive contains only versioned
|
||||
Trainlog artifacts under its separate Sync namespace; desktop and Android
|
||||
SQLite databases always remain local. Android can process PC USB requests from
|
||||
an explicitly enabled, visibly notified background service, while Drive checks
|
||||
follow Android's bounded background scheduling.
|
||||
Synchronization uses one generation/ACK business engine across versioned
|
||||
local transports. The paired 0.1.4 deployment prefers Bluetooth Classic RFCOMM
|
||||
and falls back to direct USB/MTP; the separately configured private Drive path
|
||||
remains available to the legacy mirror/fallback workflow. No transport copies
|
||||
SQLite databases between devices.
|
||||
|
||||
Trainlog is a local-first workout and body-data system. Trainlog Core owns
|
||||
business truth and canonical desktop persistence. Its native Android app is the
|
||||
field companion, its C17/Notcurses desktop TUI is the administration and
|
||||
technical surface, and its local browser Web sibling provides the implemented
|
||||
Dashboard, Sessions, Programmes, and Exercises surfaces while reserving Analyse
|
||||
for separately contracted work.
|
||||
Dashboard, Sessions, Programmes, Exercises, and factual Analyse/Sleep Diary
|
||||
surfaces.
|
||||
|
||||
Android and desktop each own a local SQLite database. The desktop database is
|
||||
the canonical long-term history. Trainlog synchronizes versioned JSON artifacts
|
||||
over direct MTP; it never copies SQLite database files between devices.
|
||||
over its authenticated local transports and optional configured Drive workflow;
|
||||
it never copies SQLite database files between devices.
|
||||
|
||||
## Product split
|
||||
|
||||
|
|
@ -24,7 +24,7 @@ over direct MTP; it never copies SQLite database files between devices.
|
|||
|---|---|
|
||||
| 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. |
|
||||
| Local Web (0.1.4) | Display Dashboard, Programmes, Sessions, Exercises and factual Analyse workspaces. Analyse includes the Sleep Diary editor, 18:00-to-18:00 agenda and local vector PDF export. |
|
||||
|
||||
No interface reconstructs business truth from SQLite tables. The local Web is
|
||||
a sibling adapter, not an extension of the TUI. On `main`, its loopback-only
|
||||
|
|
@ -34,8 +34,8 @@ 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.
|
||||
Web Exercises administration is implemented. Version 0.1.4 also includes the
|
||||
factual Dashboard/Analyse read model and the synchronized Sleep Diary workspace.
|
||||
|
||||
## Releases
|
||||
|
||||
|
|
@ -52,8 +52,8 @@ Each published stable release provides:
|
|||
- SHA-256 checksums.
|
||||
|
||||
Both mirrors publish the same Trainlog product version and release assets. The
|
||||
latest stable version is **0.1.3** on Android and desktop; this is one shared
|
||||
Trainlog version, not separate interface versions. Version 0.1.2 is the
|
||||
latest stable version is **0.1.4** on Android and desktop; this is one shared
|
||||
Trainlog version, not separate interface versions. Version 0.1.3 is the
|
||||
previous stable release.
|
||||
|
||||
Trainlog presents French by default, with English selectable
|
||||
|
|
@ -109,14 +109,14 @@ independent.
|
|||
|
||||
### Daily synchronization on an opted-in installation
|
||||
|
||||
After explicitly enabling Android background synchronization once, connect and
|
||||
unlock the paired phone and select **Synchronize** in the local Web Dashboard.
|
||||
USB/MTP is preferred when the Trainlog peer is usable; the separately selected
|
||||
private Drive folder is updated as a mirror and is the fallback transport. Wait
|
||||
until Web reports the correlated generation and acknowledgements. A failure
|
||||
remains durable and should be diagnosed before retrying; do not delete
|
||||
generation, ACK, database, or backup files. Full-generation mode and Drive
|
||||
remain trusted per-installation configuration.
|
||||
After explicitly enabling the paired local synchronization path, keep Bluetooth
|
||||
enabled on Android and the desktop and select **Synchronize** in Trainlog when
|
||||
an explicit run is wanted. The current paired deployment prefers authenticated
|
||||
Bluetooth Classic RFCOMM and uses direct USB/MTP as the wired recovery path.
|
||||
The existing private Drive workflow remains separately configured. Wait until
|
||||
the correlated generation and acknowledgements complete; a failure remains
|
||||
durable and should be diagnosed before retrying rather than deleting protocol,
|
||||
database, or backup files.
|
||||
|
||||
### Build from source
|
||||
|
||||
|
|
@ -157,7 +157,7 @@ updates; generating a replacement key is not a normal release procedure.
|
|||
|
||||
### Linux runtime dependencies
|
||||
|
||||
The published v0.1.3 x86-64 desktop runtime is dynamically linked. Its tagged
|
||||
The published v0.1.4 x86-64 desktop runtime is dynamically linked. Its tagged
|
||||
source directly requires compatible versions of:
|
||||
|
||||
- glibc and the GCC support runtime;
|
||||
|
|
@ -169,7 +169,7 @@ source directly requires compatible versions of:
|
|||
- Notcurses Core;
|
||||
- the standard math library.
|
||||
|
||||
Version 0.1.3 additionally links GNU libmicrohttpd and yyjson for the local Web
|
||||
Version 0.1.4 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.
|
||||
|
||||
|
|
@ -184,6 +184,9 @@ assuming one universal package command.
|
|||
- Android is optional when using the desktop TUI alone. USB/MTP synchronization
|
||||
requires the Android companion, libudev, libmtp, and an unlocked connected
|
||||
device.
|
||||
- Bluetooth synchronization requires BlueZ on Linux plus Python 3.11 or newer
|
||||
with D-Bus and GObject bindings. It is a Trainlog synchronization transport,
|
||||
not a heart-rate sensor implementation.
|
||||
- `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
|
||||
|
|
@ -335,7 +338,7 @@ not contain cloud credentials and does not run `rclone`.
|
|||
As of 2026-09-18:
|
||||
|
||||
- `TRAINLOG_FORMAT_V1=PASS/FROZEN`;
|
||||
- desktop SQLite schema v28 and Android SQLite schema v25;
|
||||
- desktop SQLite schema v29 and Android SQLite schema v26;
|
||||
- Notcurses is the only active desktop terminal backend;
|
||||
- direct storage is `/storage/emulated/0/Documents/Trainlog` under Android's
|
||||
all-files access setting;
|
||||
|
|
@ -350,7 +353,8 @@ As of 2026-09-18:
|
|||
`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_WEB_EXERCISES_V1=PASS`; `/analyse` now provides the 0.1.4 factual
|
||||
Dashboard/Analyse read-model foundation pending visual review;
|
||||
- `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;
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ val trainlogVersionCode =
|
|||
providers.environmentVariable("TRAINLOG_ANDROID_VERSION_CODE").orNull?.let { raw ->
|
||||
raw.toIntOrNull()?.takeIf { it in 1..Int.MAX_VALUE }
|
||||
?: throw GradleException("TRAINLOG_ANDROID_VERSION_CODE must be a positive integer")
|
||||
} ?: 4
|
||||
} ?: 5
|
||||
|
||||
android {
|
||||
namespace = "com.labfytools.trainlog"
|
||||
|
|
@ -55,7 +55,7 @@ android {
|
|||
targetSdk = 36
|
||||
|
||||
versionCode = trainlogVersionCode
|
||||
versionName = "0.1.3"
|
||||
versionName = "0.1.4"
|
||||
testInstrumentationRunner =
|
||||
"androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ class DraftUiTestActivity : ComponentActivity() {
|
|||
sessionVisible = false
|
||||
},
|
||||
onCreateExercise = {},
|
||||
onCreateEquipment = { _ -> },
|
||||
/* CONTRACT: this host never writes shared export data. */
|
||||
onSessionSaved = {},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,8 +6,16 @@
|
|||
user-visible directory, Documents/Trainlog, without a SAF provider. -->
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission
|
||||
android:name="android.permission.BLUETOOTH"
|
||||
android:maxSdkVersion="30" />
|
||||
<uses-permission
|
||||
android:name="android.permission.BLUETOOTH_ADMIN"
|
||||
android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
|
|
@ -33,6 +41,6 @@
|
|||
<service
|
||||
android:name=".data.SyncBackgroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
android:foregroundServiceType="connectedDevice|dataSync" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ import com.labfytools.trainlog.data.TrainlogRepository
|
|||
import com.labfytools.trainlog.data.SyncExporter
|
||||
import com.labfytools.trainlog.data.SyncCatalogInbox
|
||||
import com.labfytools.trainlog.data.SyncRequestOutbox
|
||||
import com.labfytools.trainlog.data.BackgroundSyncSettings
|
||||
import com.labfytools.trainlog.data.SyncBackgroundService
|
||||
import com.labfytools.trainlog.data.hasBluetoothConnectPermission
|
||||
import com.labfytools.trainlog.ui.TrainlogApp
|
||||
import com.labfytools.trainlog.ui.TrainlogAppState
|
||||
import com.labfytools.trainlog.ui.LanguagePresentation
|
||||
|
|
@ -30,6 +33,13 @@ class MainActivity : ComponentActivity() {
|
|||
super.onCreate(savedInstanceState)
|
||||
languageSettings = LanguageSettingsOwner(applicationContext)
|
||||
|
||||
if (
|
||||
BackgroundSyncSettings(applicationContext).enabled &&
|
||||
hasBluetoothConnectPermission(applicationContext)
|
||||
) {
|
||||
SyncBackgroundService.start(applicationContext)
|
||||
}
|
||||
|
||||
val repository =
|
||||
TrainlogRepository(
|
||||
applicationContext
|
||||
|
|
|
|||
|
|
@ -0,0 +1,235 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.TimeUnit
|
||||
import org.json.JSONObject
|
||||
|
||||
internal const val AI_EXPORT_FILENAME = "trainlog_ai_export_v1.json"
|
||||
|
||||
internal sealed interface AiExportDriveResult {
|
||||
data object NotConfigured : AiExportDriveResult
|
||||
data object Missing : AiExportDriveResult
|
||||
data object Unchanged : AiExportDriveResult
|
||||
data class Published(val sha256: String) : AiExportDriveResult
|
||||
data class Error(val message: String) : AiExportDriveResult
|
||||
}
|
||||
|
||||
internal class AiExportDriveSettings(private val context: Context) {
|
||||
private val preferences =
|
||||
context.applicationContext.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||
|
||||
val treeUri: Uri?
|
||||
get() = preferences.getString(TREE, null)?.let(Uri::parse)
|
||||
|
||||
val connected: Boolean
|
||||
get() = treeUri != null
|
||||
|
||||
fun connect(uri: Uri) {
|
||||
context.contentResolver.takePersistableUriPermission(
|
||||
uri,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
|
||||
)
|
||||
preferences.edit().putString(TREE, uri.toString()).apply()
|
||||
schedule(context)
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
treeUri?.let { uri ->
|
||||
runCatching {
|
||||
context.contentResolver.releasePersistableUriPermission(
|
||||
uri,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
|
||||
)
|
||||
}
|
||||
}
|
||||
preferences.edit()
|
||||
.remove(TREE)
|
||||
.remove(LAST_SHA256)
|
||||
.remove(LAST_SIZE)
|
||||
.remove(LAST_MODIFIED)
|
||||
.apply()
|
||||
WorkManager.getInstance(context).cancelUniqueWork(PERIODIC_WORK)
|
||||
WorkManager.getInstance(context).cancelUniqueWork(IMMEDIATE_WORK)
|
||||
}
|
||||
|
||||
fun alreadyPublished(source: File, sha256: String): Boolean =
|
||||
preferences.getString(LAST_SHA256, null) == sha256 &&
|
||||
preferences.getLong(LAST_SIZE, -1L) == source.length() &&
|
||||
preferences.getLong(LAST_MODIFIED, -1L) == source.lastModified()
|
||||
|
||||
fun needsPublication(source: File): Boolean =
|
||||
connected &&
|
||||
source.isFile &&
|
||||
(
|
||||
preferences.getString(LAST_SHA256, null) == null ||
|
||||
preferences.getLong(LAST_SIZE, -1L) != source.length() ||
|
||||
preferences.getLong(LAST_MODIFIED, -1L) != source.lastModified()
|
||||
)
|
||||
|
||||
fun recordPublished(source: File, sha256: String) {
|
||||
preferences.edit()
|
||||
.putString(LAST_SHA256, sha256)
|
||||
.putLong(LAST_SIZE, source.length())
|
||||
.putLong(LAST_MODIFIED, source.lastModified())
|
||||
.apply()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREFERENCES = "trainlog-ai-export-drive"
|
||||
private const val TREE = "tree_uri"
|
||||
private const val LAST_SHA256 = "last_sha256"
|
||||
private const val LAST_SIZE = "last_size"
|
||||
private const val LAST_MODIFIED = "last_modified"
|
||||
private const val PERIODIC_WORK = "trainlog-ai-export-drive-periodic-v1"
|
||||
private const val IMMEDIATE_WORK = "trainlog-ai-export-drive-now-v1"
|
||||
|
||||
fun sourceFile(): File = File(canonicalExchangeDirectory(), AI_EXPORT_FILENAME)
|
||||
|
||||
fun schedule(context: Context) {
|
||||
val constraints =
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
val periodic =
|
||||
PeriodicWorkRequestBuilder<AiExportDriveWorker>(15, TimeUnit.MINUTES)
|
||||
.setConstraints(constraints)
|
||||
.build()
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
PERIODIC_WORK,
|
||||
ExistingPeriodicWorkPolicy.UPDATE,
|
||||
periodic,
|
||||
)
|
||||
enqueueImmediate(context)
|
||||
}
|
||||
|
||||
fun enqueueImmediate(context: Context) {
|
||||
val settings = AiExportDriveSettings(context)
|
||||
val source = sourceFile()
|
||||
if (!settings.needsPublication(source)) return
|
||||
val request =
|
||||
OneTimeWorkRequestBuilder<AiExportDriveWorker>()
|
||||
.setConstraints(
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||
IMMEDIATE_WORK,
|
||||
ExistingWorkPolicy.KEEP,
|
||||
request,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class AiExportDrivePublisher(private val context: Context) {
|
||||
private val settings = AiExportDriveSettings(context)
|
||||
|
||||
private fun sha256(file: File): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
file.inputStream().use { input ->
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
while (true) {
|
||||
val count = input.read(buffer)
|
||||
if (count < 0) break
|
||||
digest.update(buffer, 0, count)
|
||||
}
|
||||
}
|
||||
return digest.digest().joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
private fun validateSource(source: File) {
|
||||
require(source.isFile && !java.nio.file.Files.isSymbolicLink(source.toPath())) {
|
||||
"Export IA local indisponible."
|
||||
}
|
||||
require(source.length() in 1..64L * 1024L * 1024L) {
|
||||
"Export IA local invalide."
|
||||
}
|
||||
val root =
|
||||
source.inputStream().bufferedReader(Charsets.UTF_8).use { reader ->
|
||||
JSONObject(reader.readText())
|
||||
}
|
||||
require(
|
||||
root.optString("format") == "TRAINLOG_AI_EXPORT" &&
|
||||
root.optInt("version", -1) == 1
|
||||
) { "Format d’export IA invalide." }
|
||||
}
|
||||
|
||||
fun publishPending(): AiExportDriveResult {
|
||||
val uri = settings.treeUri ?: return AiExportDriveResult.NotConfigured
|
||||
val source = AiExportDriveSettings.sourceFile()
|
||||
if (!source.isFile) return AiExportDriveResult.Missing
|
||||
return try {
|
||||
validateSource(source)
|
||||
val sourceSha = sha256(source)
|
||||
if (settings.alreadyPublished(source, sourceSha)) {
|
||||
return AiExportDriveResult.Unchanged
|
||||
}
|
||||
val root =
|
||||
DocumentFile.fromTreeUri(context, uri)
|
||||
?: return AiExportDriveResult.Error("Dossier Drive IA indisponible.")
|
||||
require(root.isDirectory) { "Dossier Drive IA invalide." }
|
||||
val destination =
|
||||
root.findFile(AI_EXPORT_FILENAME)
|
||||
?: root.createFile("application/json", AI_EXPORT_FILENAME)
|
||||
?: throw IllegalStateException("Impossible de créer l’export IA sur Drive.")
|
||||
require(destination.isFile) { "Destination Drive IA invalide." }
|
||||
|
||||
context.contentResolver.openOutputStream(destination.uri, "wt")?.use { output ->
|
||||
source.inputStream().use { input -> input.copyTo(output, 64 * 1024) }
|
||||
output.flush()
|
||||
} ?: throw IllegalStateException("Impossible d’écrire l’export IA sur Drive.")
|
||||
|
||||
val verification =
|
||||
File.createTempFile("trainlog-ai-drive-verify-", ".json", context.cacheDir)
|
||||
try {
|
||||
context.contentResolver.openInputStream(destination.uri)?.use { input ->
|
||||
FileOutputStream(verification).use { output ->
|
||||
input.copyTo(output, 64 * 1024)
|
||||
output.fd.sync()
|
||||
}
|
||||
} ?: throw IllegalStateException("Impossible de relire l’export IA Drive.")
|
||||
require(
|
||||
verification.length() == source.length() &&
|
||||
sha256(verification) == sourceSha
|
||||
) { "Vérification de l’export IA Drive échouée." }
|
||||
} finally {
|
||||
verification.delete()
|
||||
}
|
||||
settings.recordPublished(source, sourceSha)
|
||||
AiExportDriveResult.Published(sourceSha)
|
||||
} catch (error: Exception) {
|
||||
AiExportDriveResult.Error(error.message ?: error.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AiExportDriveWorker(
|
||||
context: Context,
|
||||
parameters: WorkerParameters,
|
||||
) : CoroutineWorker(context, parameters) {
|
||||
override suspend fun doWork(): Result =
|
||||
when (AiExportDrivePublisher(applicationContext).publishPending()) {
|
||||
AiExportDriveResult.NotConfigured,
|
||||
AiExportDriveResult.Missing,
|
||||
AiExportDriveResult.Unchanged,
|
||||
is AiExportDriveResult.Published -> Result.success()
|
||||
is AiExportDriveResult.Error -> Result.retry()
|
||||
}
|
||||
}
|
||||
|
|
@ -225,7 +225,7 @@ internal class AndroidBackupService(
|
|||
manifest.getInt("version") == VERSION &&
|
||||
manifest.getString("package") == context.packageName &&
|
||||
manifest.getBoolean("plaintext") &&
|
||||
manifest.getInt("schema") in 17..25
|
||||
manifest.getInt("schema") in 17..26
|
||||
) {
|
||||
"Unsupported backup."
|
||||
}
|
||||
|
|
@ -319,7 +319,7 @@ internal class AndroidBackupService(
|
|||
}
|
||||
)
|
||||
require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() })
|
||||
require(db.version in 17..25) { "Unsupported Android schema ${db.version}." }
|
||||
require(db.version in 17..26) { "Unsupported Android schema ${db.version}." }
|
||||
return db.version
|
||||
} finally {
|
||||
db.close()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,539 @@
|
|||
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 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
|
||||
|
||||
@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 ->
|
||||
listOf(
|
||||
"android-peer-v1.json",
|
||||
FULL_GENERATION_REQUEST_NAME,
|
||||
LEGACY_SYNC_REQUEST_NAME,
|
||||
"android-generation-v1.json",
|
||||
"android-consumption-ack-v1.json",
|
||||
"android-generation-error-v1.json",
|
||||
).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() {
|
||||
if (!BackgroundSyncSettings(context).enabled) return
|
||||
val now = System.currentTimeMillis()
|
||||
if (!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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -32,37 +32,29 @@ internal class BackgroundSyncSettings(context: Context) {
|
|||
}
|
||||
|
||||
/**
|
||||
* User-enabled foreground listener for PC-initiated USB generation requests.
|
||||
* User-enabled foreground listener for automatic Bluetooth and MTP synchronization.
|
||||
*
|
||||
* WHY: a Web request must be consumed while Compose is not visible, but modern
|
||||
* Android does not permit an invisible permanent background loop.
|
||||
* CONTRACT: the service shows a mandatory notification, invokes the same
|
||||
* generation coordinator as SyncScreen, and never starts an exercise.
|
||||
* INVARIANT: filesystem visibility only triggers protocol validation; stable
|
||||
* WHY: the phone must reconnect to the paired desktop while Compose is not
|
||||
* visible, but modern Android does not permit an invisible permanent loop.
|
||||
* CONTRACT: the service shows a mandatory notification, owns the RFCOMM client,
|
||||
* invokes the same generation coordinator as SyncScreen, and never starts an
|
||||
* exercise. MTP remains a filesystem fallback.
|
||||
* INVARIANT: transport visibility only triggers protocol validation; stable
|
||||
* run/generation/ACK identities remain the sole business authority.
|
||||
*/
|
||||
class SyncBackgroundService : Service() {
|
||||
private val stopped = AtomicBoolean(false)
|
||||
private val launched = AtomicBoolean(false)
|
||||
private val executor = Executors.newSingleThreadExecutor()
|
||||
private val executor = Executors.newFixedThreadPool(2)
|
||||
private var repository: TrainlogRepository? = null
|
||||
private var bluetoothClient: BluetoothSyncClient? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createChannel()
|
||||
val notification = NotificationCompat.Builder(this, CHANNEL)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(getString(R.string.background_sync_notification_title))
|
||||
.setContentText(getString(R.string.background_sync_notification_text))
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.build()
|
||||
if (Build.VERSION.SDK_INT >= 29) {
|
||||
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
promoteForeground()
|
||||
repository = TrainlogRepository(applicationContext)
|
||||
bluetoothClient = BluetoothSyncClient(applicationContext, requireNotNull(repository))
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
|
|
@ -70,7 +62,11 @@ class SyncBackgroundService : Service() {
|
|||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
promoteForeground()
|
||||
if (!launched.compareAndSet(false, true)) return START_NOT_STICKY
|
||||
executor.execute {
|
||||
bluetoothClient?.run(stopped)
|
||||
}
|
||||
executor.execute {
|
||||
val coordinator = SyncGenerationCoordinator(
|
||||
requireNotNull(repository),
|
||||
|
|
@ -116,6 +112,7 @@ class SyncBackgroundService : Service() {
|
|||
is ForegroundGenerationResult.Cancelled -> Unit
|
||||
}
|
||||
}
|
||||
AiExportDriveSettings.enqueueImmediate(applicationContext)
|
||||
if (!stopped.get()) runCatching { Thread.sleep(5_000) }
|
||||
}
|
||||
stopSelf()
|
||||
|
|
@ -125,6 +122,8 @@ class SyncBackgroundService : Service() {
|
|||
|
||||
override fun onDestroy() {
|
||||
stopped.set(true)
|
||||
bluetoothClient?.shutdown()
|
||||
bluetoothClient = null
|
||||
executor.shutdownNow()
|
||||
repository?.close()
|
||||
repository = null
|
||||
|
|
@ -141,6 +140,28 @@ class SyncBackgroundService : Service() {
|
|||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
private fun promoteForeground() {
|
||||
val notification =
|
||||
NotificationCompat.Builder(this, CHANNEL)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(getString(R.string.background_sync_notification_title))
|
||||
.setContentText(getString(R.string.background_sync_notification_text))
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.build()
|
||||
if (Build.VERSION.SDK_INT >= 29) {
|
||||
val serviceType =
|
||||
if (hasBluetoothConnectPermission(this)) {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
|
||||
} else {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||
}
|
||||
startForeground(NOTIFICATION_ID, notification, serviceType)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createChannel() {
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager.createNotificationChannel(
|
||||
|
|
|
|||
|
|
@ -541,6 +541,12 @@ internal class SyncGenerationCoordinator(
|
|||
phase(currentRunId, "cancelled", result = "interrupted")
|
||||
ForegroundGenerationResult.Cancelled(currentRunId)
|
||||
} catch (error: Exception) {
|
||||
Log.e(
|
||||
"TrainlogSyncGeneration",
|
||||
"run_id=${currentRunId ?: "pending"} phase=error " +
|
||||
"type=${error.javaClass.name} message=${error.message}",
|
||||
error,
|
||||
)
|
||||
if (error.message == "generation retention capacity exhausted") {
|
||||
try {
|
||||
val request = JSONObject(File(directory, "request-v1.json").readText())
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
|||
Kind("trainlog-causal-deletions", 1, "causal-deletions-v1.json"),
|
||||
"program-executions" to
|
||||
Kind("trainlog-program-executions", 1, "program-executions-v1.json", false),
|
||||
"sleep-diary" to Kind("trainlog-sleep-diary", 1, "sleep-diary-v1.json", false),
|
||||
)
|
||||
private val SUPPORTED =
|
||||
(ANDROID_KINDS.values +
|
||||
|
|
@ -411,6 +412,16 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
|||
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")),
|
||||
)
|
||||
}
|
||||
}
|
||||
return CapturedSyncGeneration(generationId, runId, digest, stage)
|
||||
} catch (error: Exception) {
|
||||
|
|
@ -664,6 +675,7 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
|||
is ProgramsImportResult.Applied,
|
||||
is CausalDeleteResult.Applied,
|
||||
is CausalDeleteResult.Unchanged -> null
|
||||
is TrainlogRepository.SleepDiaryImportResult.Applied -> null
|
||||
else -> result.toString()
|
||||
}
|
||||
|
||||
|
|
@ -840,6 +852,10 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
|||
apply("execution-drafts") {
|
||||
repository.applyExecutionDraftExportV1Json(checkNotNull(a["execution-drafts"]))
|
||||
}
|
||||
if ("sleep-diary" in a)
|
||||
apply("sleep-diary") {
|
||||
repository.applySleepDiaryV1Json(checkNotNull(a["sleep-diary"]))
|
||||
}
|
||||
if ("ai-proposals" in a)
|
||||
apply("ai-proposals") {
|
||||
repository.applyAiSessionDraftsJson(checkNotNull(a["ai-proposals"]))
|
||||
|
|
@ -1033,6 +1049,17 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
|
|||
ack.getString("generation_id"),
|
||||
),
|
||||
)
|
||||
if (target == "acknowledged") {
|
||||
db.execSQL(
|
||||
"UPDATE sleep_diary_publication_state SET acknowledged_revision_id=(SELECT " +
|
||||
"m.revision_id FROM sleep_diary_generation_entries m WHERE " +
|
||||
"m.generation_id=? AND m.entry_id=sleep_diary_publication_state.entry_id)," +
|
||||
"acknowledged_at=? WHERE EXISTS(SELECT 1 FROM sleep_diary_generation_entries m " +
|
||||
"WHERE m.generation_id=? AND m.entry_id=sleep_diary_publication_state.entry_id)",
|
||||
arrayOf(ack.getString("generation_id"), ack.getString("consumed_at"),
|
||||
ack.getString("generation_id")),
|
||||
)
|
||||
}
|
||||
target
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,16 @@ class SyncRequestOutbox private constructor(
|
|||
}
|
||||
}
|
||||
|
||||
internal fun requestFullGeneration(): SyncRequestResult {
|
||||
return when (val opened = publisher.snapshot()) {
|
||||
is DirectExchangeSnapshotResult.Ready -> {
|
||||
val intent = newIntent()
|
||||
publishFullGeneration(opened.snapshot, intent)
|
||||
}
|
||||
is DirectExchangeSnapshotResult.Error -> SyncRequestResult.Error(opened.message)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun requestSync(snapshot: DirectExchangeSnapshot): SyncRequestResult {
|
||||
if (
|
||||
Build.VERSION.SDK_INT <
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,34 @@
|
|||
package com.labfytools.trainlog.model
|
||||
|
||||
enum class SleepQuality(val wireValue: String) { TB("TB"), B("B"), MOY("Moy"), M("M"), TM("TM") }
|
||||
enum class SleepEventType(val wireValue: String) {
|
||||
BED_TIME("bed_time"), FINAL_GET_UP("final_get_up"), NIGHT_GET_UP("night_get_up"),
|
||||
SLEEP("sleep"), NAP("nap"), LONG_AWAKE("long_awake"), HALF_SLEEP("half_sleep"),
|
||||
DAYTIME_SLEEPINESS("daytime_sleepiness"),
|
||||
}
|
||||
enum class SleepPublicationStatus { DRAFT, READY, SYNCHRONIZED, MODIFIED }
|
||||
data class SleepDiaryEvent(val eventId: String, val type: SleepEventType, val startAt: String, val endAt: String?)
|
||||
data class SleepMedication(
|
||||
val medicationId: String, val revisionId: String, val createdAt: String, val updatedAt: String,
|
||||
val name: String, val defaultDoseValue: Double?, val defaultDoseUnit: String?,
|
||||
val form: String, val note: String, val active: Boolean,
|
||||
)
|
||||
data class MedicationIntake(
|
||||
val intakeId: String, val medicationId: String, val medicationName: String,
|
||||
val takenAt: String, val doseValue: Double?, val doseUnit: String?, val note: String,
|
||||
val createdAt: String,
|
||||
)
|
||||
data class SleepDiaryEntry(
|
||||
val entryId: String, val nightStartDate: String, val nightEndDate: String,
|
||||
val createdAt: String, val updatedAt: String, val revisionId: String,
|
||||
val sleepQuality: SleepQuality?, val wakeQuality: SleepQuality?, val dayForm: SleepQuality?,
|
||||
val treatmentAndNotes: String, val events: List<SleepDiaryEvent>, val intakes: List<MedicationIntake>,
|
||||
val publicationStatus: SleepPublicationStatus,
|
||||
)
|
||||
data class SleepDiaryDraft(
|
||||
val entryId: String? = null, val expectedRevision: String? = null,
|
||||
val nightStartDate: String, val nightEndDate: String, val createdAt: String,
|
||||
val updatedAt: String, val sleepQuality: SleepQuality?, val wakeQuality: SleepQuality?,
|
||||
val dayForm: SleepQuality?, val treatmentAndNotes: String, val events: List<SleepDiaryEvent>,
|
||||
val intakes: List<MedicationIntake> = emptyList(),
|
||||
)
|
||||
|
|
@ -43,6 +43,7 @@ private val drawerIcons = mapOf(
|
|||
AppSection.SESSIONS to R.drawable.ic_sessions,
|
||||
AppSection.EXERCISES to R.drawable.ic_exercises,
|
||||
AppSection.EQUIPMENT to R.drawable.ic_equipment,
|
||||
AppSection.SLEEP to R.drawable.ic_statistics,
|
||||
AppSection.SYNC to R.drawable.ic_sync,
|
||||
AppSection.SETTINGS to R.drawable.ic_settings,
|
||||
)
|
||||
|
|
@ -60,6 +61,7 @@ fun AndroidAppShell(
|
|||
fun sectionLabel(section: AppSection): String = strings.getString(when (section) {
|
||||
AppSection.HOME -> R.string.nav_home; AppSection.SESSIONS -> R.string.nav_sessions
|
||||
AppSection.EXERCISES -> R.string.nav_exercises; AppSection.EQUIPMENT -> R.string.nav_equipment
|
||||
AppSection.SLEEP -> R.string.nav_sleep
|
||||
AppSection.SYNC -> R.string.nav_sync; AppSection.SETTINGS -> R.string.nav_settings
|
||||
})
|
||||
fun title(): String = strings.getString(when (route) {
|
||||
|
|
@ -73,6 +75,7 @@ fun AndroidAppShell(
|
|||
AppRoute.Equipment -> R.string.nav_equipment; is AppRoute.EquipmentDetail -> R.string.route_equipment_detail
|
||||
is AppRoute.EquipmentCreate -> R.string.route_equipment_create
|
||||
AppRoute.BodyMeasurements -> R.string.route_body_measurements; AppRoute.LatestMaxima -> R.string.route_latest_maxima
|
||||
AppRoute.SleepDiary -> R.string.nav_sleep
|
||||
AppRoute.Sync -> R.string.nav_sync; AppRoute.Settings -> R.string.nav_settings
|
||||
})
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import androidx.lifecycle.ViewModel
|
|||
|
||||
enum class AppSection {
|
||||
HOME, SESSIONS, EXERCISES,
|
||||
EQUIPMENT, SYNC, SETTINGS,
|
||||
EQUIPMENT, SLEEP, SYNC, SETTINGS,
|
||||
}
|
||||
|
||||
sealed interface AppRoute {
|
||||
|
|
@ -35,6 +35,7 @@ sealed interface AppRoute {
|
|||
data class EquipmentDetail(val equipmentId: String) : AppRoute { override val section = AppSection.EQUIPMENT }
|
||||
data class EquipmentCreate(val caller: AppRoute) : AppRoute { override val section = caller.section }
|
||||
data object BodyMeasurements : AppRoute { override val section = AppSection.HOME }
|
||||
data object SleepDiary : AppRoute { override val section = AppSection.SLEEP }
|
||||
data object LatestMaxima : AppRoute { override val section = AppSection.EXERCISES }
|
||||
data object Sync : AppRoute { override val section = AppSection.SYNC }
|
||||
data object Settings : AppRoute { override val section = AppSection.SETTINGS }
|
||||
|
|
@ -45,6 +46,7 @@ fun AppSection.rootRoute(): AppRoute = when (this) {
|
|||
AppSection.SESSIONS -> AppRoute.Sessions
|
||||
AppSection.EXERCISES -> AppRoute.Exercises
|
||||
AppSection.EQUIPMENT -> AppRoute.Equipment
|
||||
AppSection.SLEEP -> AppRoute.SleepDiary
|
||||
AppSection.SYNC -> AppRoute.Sync
|
||||
AppSection.SETTINGS -> AppRoute.Settings
|
||||
}
|
||||
|
|
@ -169,5 +171,11 @@ class TrainlogAppState : ViewModel() {
|
|||
val equipment = EquipmentScreenState()
|
||||
val exercise = ExerciseScreenState()
|
||||
val body = BodyScreenState()
|
||||
/* WHY: the durable form intentionally does not serialize transient edit
|
||||
* position, but a catalogue detour must still return to the same row.
|
||||
* CONTRACT: this stable occurrence ID spans only that caller-aware detour;
|
||||
* all actual form fields remain repository-owned and durable.
|
||||
* INVARIANT: null means a new occurrence, never an inferred row. */
|
||||
var pendingSessionEquipmentEntryId: String? = null
|
||||
val navigationController = AppNavigationController(navigation, generator, equipment, exercise, body)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -423,17 +423,29 @@ fun EquipmentDetailScreen(repository: TrainlogRepository, equipmentId: String, o
|
|||
}
|
||||
|
||||
@Composable
|
||||
fun EquipmentCreateScreen(repository: TrainlogRepository, state: EquipmentScreenState, onCreated: () -> Unit) {
|
||||
fun EquipmentCreateScreen(
|
||||
repository: TrainlogRepository,
|
||||
state: EquipmentScreenState,
|
||||
onCreated: (EquipmentCatalogEntry) -> Unit,
|
||||
) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
val strings = localizedContext()
|
||||
TrainlogScreen(strings.getString(R.string.create_equipment), scrollKey = "equipment-create") {
|
||||
TrainlogFrame(strings.getString(R.string.personal_equipment)) {
|
||||
TrainlogInputField(strings.getString(R.string.name), state.customName, { state.customName = it; state.message = null })
|
||||
TrainlogInputField(
|
||||
strings.getString(R.string.name),
|
||||
state.customName,
|
||||
{ state.customName = it; state.message = null },
|
||||
testTag = "equipment-create-name",
|
||||
)
|
||||
TrainlogPrimaryAction(strings.getString(R.string.create), strings.getString(R.string.create_machine_description)) {
|
||||
when (val result = repository.createCustomEquipment(state.customName)) {
|
||||
is CreateEquipmentResult.Created -> {
|
||||
state.selectedEquipmentId = result.equipment.equipmentId
|
||||
state.customName = ""; state.message = null; state.revision++; onCreated()
|
||||
state.customName = ""
|
||||
state.message = null
|
||||
state.revision++
|
||||
onCreated(result.equipment)
|
||||
}
|
||||
CreateEquipmentResult.Invalid -> state.message = strings.getString(R.string.equipment_name_invalid)
|
||||
CreateEquipmentResult.Conflict -> state.message = strings.getString(R.string.equipment_name_exists)
|
||||
|
|
|
|||
|
|
@ -25,10 +25,13 @@ import androidx.compose.foundation.layout.IntrinsicSize
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
|
|
@ -48,7 +51,6 @@ import androidx.compose.ui.text.input.KeyboardType
|
|||
import androidx.compose.ui.unit.dp
|
||||
import com.labfytools.trainlog.data.ActiveDraftLoadResult
|
||||
import com.labfytools.trainlog.data.ActiveDraftMutationResult
|
||||
import com.labfytools.trainlog.data.CreateEquipmentResult
|
||||
import com.labfytools.trainlog.data.EquipmentLoadSemantics
|
||||
import com.labfytools.trainlog.data.FinalizeActiveDraftResult
|
||||
import com.labfytools.trainlog.data.ManualPercentMaxResult
|
||||
|
|
@ -77,7 +79,10 @@ fun SessionScreen(
|
|||
catalogRevision: Int,
|
||||
onBack: () -> Unit,
|
||||
onCreateExercise: () -> Unit,
|
||||
onCreateEquipment: (editingEntryId: String?) -> Unit,
|
||||
onSessionSaved: () -> Unit,
|
||||
restoreEditingEntryId: String? = null,
|
||||
onEditingContextRestored: () -> Unit = {},
|
||||
) {
|
||||
val colors =
|
||||
LocalTrainlogColors.current
|
||||
|
|
@ -97,13 +102,27 @@ fun SessionScreen(
|
|||
|
||||
var activeDraft by
|
||||
remember(catalogRevision) {
|
||||
val loadedDraft = (initialLoad as? ActiveDraftLoadResult.Loaded)?.draft
|
||||
val restoredIndex = restoreEditingEntryId?.let { entryId ->
|
||||
loadedDraft?.exercises?.indexOfFirst { it.entryId == entryId }
|
||||
?.takeIf { it >= 0 }
|
||||
}
|
||||
mutableStateOf(
|
||||
(initialLoad as?
|
||||
ActiveDraftLoadResult.Loaded)
|
||||
?.draft
|
||||
if (restoredIndex == null) loadedDraft else loadedDraft?.let { draft ->
|
||||
draft.copy(
|
||||
form = draft.form.copy(
|
||||
editingExerciseIndex = restoredIndex,
|
||||
editingEntryId = restoreEditingEntryId,
|
||||
),
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(restoreEditingEntryId) {
|
||||
if (restoreEditingEntryId != null) onEditingContextRestored()
|
||||
}
|
||||
|
||||
var message by
|
||||
remember(catalogRevision) {
|
||||
mutableStateOf<
|
||||
|
|
@ -342,14 +361,14 @@ fun SessionScreen(
|
|||
label = strings.getString(R.string.modify_name, draft.exercise.name),
|
||||
description = strings.getString(R.string.edit_exercise_description),
|
||||
accent = colors.accent,
|
||||
modifier = Modifier.testTag("edit-draft-exercise-${draft.entryId}"),
|
||||
onClick = {
|
||||
persistDraft(
|
||||
currentDraft.copy(
|
||||
form = formForExistingExercise(draft, index),
|
||||
),
|
||||
null,
|
||||
strings.getString(R.string.editor_opened, draft.exercise.name),
|
||||
)
|
||||
message = strings.getString(R.string.editor_opened, draft.exercise.name)
|
||||
},
|
||||
)
|
||||
if (currentDraft.form.editingEntryId == draft.entryId) {
|
||||
|
|
@ -433,8 +452,11 @@ fun SessionScreen(
|
|||
sessionType = currentDraft.sessionType,
|
||||
initialForm =
|
||||
currentDraft.form,
|
||||
initialPlan = currentDraft.form.editingExerciseIndex?.let {
|
||||
currentDraft.exercises.getOrNull(it)?.plan
|
||||
initialOccurrence = currentDraft.form.editingExerciseIndex?.let {
|
||||
currentDraft.exercises.getOrNull(it)
|
||||
},
|
||||
onCreateEquipment = {
|
||||
onCreateEquipment(currentDraft.form.editingEntryId)
|
||||
},
|
||||
onFormChanged = {
|
||||
form ->
|
||||
|
|
@ -860,7 +882,8 @@ private fun SessionExerciseForm(
|
|||
exercise: ExerciseProfile,
|
||||
sessionType: SessionType,
|
||||
initialForm: SessionDraftForm,
|
||||
initialPlan: SessionExercisePlan?,
|
||||
initialOccurrence: SessionExerciseDraft?,
|
||||
onCreateEquipment: () -> Unit,
|
||||
onFormChanged: (SessionDraftForm) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
onAdd:
|
||||
|
|
@ -870,6 +893,7 @@ private fun SessionExerciseForm(
|
|||
val colors =
|
||||
LocalTrainlogColors.current
|
||||
val strings = localizedContext()
|
||||
val initialPlan = initialOccurrence?.plan
|
||||
|
||||
var setCountText by
|
||||
remember(key) {
|
||||
|
|
@ -935,13 +959,11 @@ private fun SessionExerciseForm(
|
|||
)
|
||||
}
|
||||
|
||||
var equipmentRevision by remember(key) { mutableStateOf(0) }
|
||||
val equipmentEntries = remember(equipmentRevision) { repository.listEquipment() }
|
||||
val equipmentEntries = remember(key) { repository.listEquipment() }
|
||||
val fixedEquipmentId = remember(exercise.exerciseId) {
|
||||
repository.machineExerciseLegacyEquipmentId(exercise.exerciseId)
|
||||
}
|
||||
var equipmentSearch by remember(key) { mutableStateOf("") }
|
||||
var customEquipmentName by remember(key) { mutableStateOf("") }
|
||||
var selectedEquipmentId by remember(key) {
|
||||
mutableStateOf(initialForm.selectedEquipmentId ?: fixedEquipmentId)
|
||||
}
|
||||
|
|
@ -978,34 +1000,18 @@ private fun SessionExerciseForm(
|
|||
value = equipmentSearch,
|
||||
onValueChange = { equipmentSearch = it },
|
||||
)
|
||||
TrainlogInputField(
|
||||
label = strings.getString(R.string.new_historical_context),
|
||||
value = customEquipmentName,
|
||||
onValueChange = { customEquipmentName = it },
|
||||
)
|
||||
TrainlogAction(
|
||||
label = strings.getString(R.string.create_context),
|
||||
description = strings.getString(R.string.context_compatibility),
|
||||
label = strings.getString(R.string.create_equipment),
|
||||
description = strings.getString(R.string.create_equipment_from_session),
|
||||
accent = colors.success,
|
||||
onClick = {
|
||||
when (val result = repository.createCustomEquipment(customEquipmentName)) {
|
||||
is CreateEquipmentResult.Created -> {
|
||||
customEquipmentName = ""
|
||||
equipmentRevision += 1
|
||||
selectedEquipmentId = result.equipment.equipmentId
|
||||
onFormChanged(currentForm(exercise, setCountText, repsText, durationText, speedText, distanceText, selectedEquipmentId, weightText, maxWeightText))
|
||||
}
|
||||
CreateEquipmentResult.Invalid -> error = strings.getString(R.string.context_name_invalid)
|
||||
CreateEquipmentResult.Conflict -> error = strings.getString(R.string.context_exists)
|
||||
is CreateEquipmentResult.DatabaseError -> error = strings.getString(R.string.context_create_error, localizedRepositoryMessage(strings, result.message))
|
||||
}
|
||||
},
|
||||
modifier = Modifier.testTag("session-create-equipment"),
|
||||
onClick = onCreateEquipment,
|
||||
)
|
||||
}
|
||||
val selectedEquipment = equipmentEntries.firstOrNull { it.equipmentId == selectedEquipmentId }
|
||||
val percentResult = remember(
|
||||
exercise.exerciseId, selectedEquipmentId, targetPercentText,
|
||||
targetChoice, equipmentRevision,
|
||||
targetChoice,
|
||||
) {
|
||||
if (targetChoice == ManualTargetChoice.PERCENT_MAX)
|
||||
repository.calculateManualPercentMaxTarget(
|
||||
|
|
@ -1025,16 +1031,29 @@ private fun SessionExerciseForm(
|
|||
},
|
||||
)
|
||||
}
|
||||
if (fixedEquipmentId == null) repository.searchEquipment(equipmentSearch).take(8).forEach { equipment ->
|
||||
TrainlogAction(
|
||||
label = if (equipment.equipmentId == selectedEquipmentId) "✓ ${equipment.displayName}" else equipment.displayName,
|
||||
description = equipment.labelName.ifBlank { equipment.type },
|
||||
accent = if (equipment.equipmentId == selectedEquipmentId) colors.success else colors.muted,
|
||||
onClick = {
|
||||
selectedEquipmentId = equipment.equipmentId
|
||||
onFormChanged(currentForm(exercise, setCountText, repsText, durationText, speedText, distanceText, equipment.equipmentId, weightText, maxWeightText))
|
||||
},
|
||||
)
|
||||
if (fixedEquipmentId == null) {
|
||||
/* WHY: alphabetical truncation made valid equipment beyond the
|
||||
* eighth row impossible to select. CONTRACT: the bounded nested
|
||||
* viewport exposes every search result while the surrounding
|
||||
* exercise editor remains independently scrollable. */
|
||||
Column(
|
||||
Modifier
|
||||
.heightIn(max = 320.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.testTag("session-equipment-results"),
|
||||
) {
|
||||
repository.searchEquipment(equipmentSearch).forEach { equipment ->
|
||||
TrainlogAction(
|
||||
label = if (equipment.equipmentId == selectedEquipmentId) "✓ ${equipment.displayName}" else equipment.displayName,
|
||||
description = equipment.labelName.ifBlank { equipment.type },
|
||||
accent = if (equipment.equipmentId == selectedEquipmentId) colors.success else colors.muted,
|
||||
onClick = {
|
||||
selectedEquipmentId = equipment.equipmentId
|
||||
onFormChanged(currentForm(exercise, setCountText, repsText, durationText, speedText, distanceText, equipment.equipmentId, weightText, maxWeightText))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionType == SessionType.MAX_TEST) {
|
||||
|
|
@ -1265,6 +1284,7 @@ private fun SessionExerciseForm(
|
|||
SessionNumberField(
|
||||
label =
|
||||
strings.getString(R.string.duration_minutes),
|
||||
testTag = "session-continuous-duration",
|
||||
value =
|
||||
durationText,
|
||||
onValueChange = {
|
||||
|
|
@ -1351,7 +1371,7 @@ private fun SessionExerciseForm(
|
|||
modifier = Modifier.fillMaxWidth().testTag("add-to-session"),
|
||||
containerColor = colors.success,
|
||||
onClick = {
|
||||
val draft =
|
||||
val capturedDraft =
|
||||
buildSessionExerciseDraft(
|
||||
exercise =
|
||||
exercise,
|
||||
|
|
@ -1373,6 +1393,26 @@ private fun SessionExerciseForm(
|
|||
rawSetRows = setRows,
|
||||
)
|
||||
|
||||
/* WHY: a prepared occurrence may legitimately have a target
|
||||
* but no performed sets yet. CONTRACT: changing its equipment
|
||||
* edits planning metadata in place without fabricating actual
|
||||
* work. INVARIANT: any nonblank partial performed row still
|
||||
* follows normal validation and can never be discarded here. */
|
||||
val targetOnlyDraft = initialOccurrence?.takeIf {
|
||||
sessionType == SessionType.TRAINING &&
|
||||
it.plan != null &&
|
||||
it.sets.isEmpty() &&
|
||||
it.maxWeightKg == null &&
|
||||
it.continuousDurationSeconds == 0 &&
|
||||
setRows.all { row ->
|
||||
row.repsText.isBlank() && row.weightText.isBlank()
|
||||
}
|
||||
}?.copy(
|
||||
exercise = exercise,
|
||||
equipmentId = selectedEquipmentId,
|
||||
)
|
||||
val draft = capturedDraft ?: targetOnlyDraft
|
||||
|
||||
if (draft == null) {
|
||||
error =
|
||||
if (sessionType == SessionType.MAX_TEST) {
|
||||
|
|
@ -1396,7 +1436,9 @@ private fun SessionExerciseForm(
|
|||
onFailure = { error = localizedTargetError(it.message, strings) },
|
||||
)
|
||||
} else {
|
||||
onAdd(draft.copy(plan = null))
|
||||
/* CONTRACT: editing performed continuous facts must not
|
||||
* erase the independent target copied from preparation. */
|
||||
onAdd(draft.copy(plan = initialPlan))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
@ -1408,6 +1450,7 @@ private fun SessionExerciseForm(
|
|||
strings.getString(R.string.return_catalog),
|
||||
accent =
|
||||
colors.muted,
|
||||
modifier = Modifier.testTag("cancel-session-exercise-editor"),
|
||||
onClick =
|
||||
onCancel,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,273 @@
|
|||
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 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 revision by remember { mutableStateOf(0) }
|
||||
var selected by remember(revision) { mutableStateOf<SleepDiaryEntry?>(repository.listSleepDiary().firstOrNull()) }
|
||||
val today = LocalDate.now()
|
||||
var startDate by remember(selected) { mutableStateOf(selected?.nightStartDate ?: today.minusDays(1).toString()) }
|
||||
var endDate by remember(selected) { mutableStateOf(selected?.nightEndDate ?: today.toString()) }
|
||||
var sleepQuality by remember(selected) { mutableStateOf(selected?.sleepQuality) }
|
||||
var wakeQuality by remember(selected) { mutableStateOf(selected?.wakeQuality) }
|
||||
var dayForm by remember(selected) { mutableStateOf(selected?.dayForm) }
|
||||
var notes by remember(selected) { mutableStateOf(selected?.treatmentAndNotes.orEmpty()) }
|
||||
var events by remember(selected) { mutableStateOf(selected?.events.orEmpty()) }
|
||||
var intakes by remember(selected) { mutableStateOf(selected?.intakes.orEmpty()) }
|
||||
val catalog = remember(revision) { repository.listSleepMedications() }
|
||||
val medications = catalog.filter { it.active }
|
||||
var intakeMedication by remember(medications) { mutableStateOf(medications.firstOrNull()) }
|
||||
var intakeDose by remember(intakeMedication) { mutableStateOf(intakeMedication?.defaultDoseValue?.toString().orEmpty()) }
|
||||
var intakeUnit by remember(intakeMedication) { mutableStateOf(intakeMedication?.defaultDoseUnit.orEmpty()) }
|
||||
var medicationName by remember { mutableStateOf("") }
|
||||
var medicationDose by remember { mutableStateOf("") }
|
||||
var medicationUnit by remember { mutableStateOf("mg") }
|
||||
var editingMedication by remember { mutableStateOf<SleepMedication?>(null) }
|
||||
var editSerial by remember { mutableStateOf(0) }
|
||||
val entries = repository.listSleepDiary()
|
||||
fun changed() { editSerial++ }
|
||||
|
||||
LaunchedEffect(editSerial) {
|
||||
if (editSerial > 0) {
|
||||
val now = OffsetDateTime.now().toString()
|
||||
val current = selected
|
||||
val result = repository.saveSleepDiary(SleepDiaryDraft(current?.entryId,
|
||||
current?.revisionId,startDate,endDate,current?.createdAt ?: now,now,sleepQuality,
|
||||
wakeQuality,dayForm,notes,events,intakes))
|
||||
if (result is TrainlogRepository.SaveSleepDiaryResult.Saved) {
|
||||
selected = repository.listSleepDiary().firstOrNull { it.entryId == result.entryId }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
item { Text(stringResource(R.string.sleep_current_night)) }
|
||||
items(entries, key = { it.entryId }) { entry ->
|
||||
Card(onClick = { selected = entry }) {
|
||||
Column(Modifier.fillMaxWidth().padding(12.dp)) {
|
||||
Text("${entry.nightStartDate} → ${entry.nightEndDate}")
|
||||
Text(stringResource(when (entry.publicationStatus) {
|
||||
SleepPublicationStatus.DRAFT -> R.string.sleep_status_draft
|
||||
SleepPublicationStatus.READY -> R.string.sleep_status_ready
|
||||
SleepPublicationStatus.SYNCHRONIZED -> R.string.sleep_status_synchronized
|
||||
SleepPublicationStatus.MODIFIED -> R.string.sleep_status_modified
|
||||
}))
|
||||
Text("${entry.events.size} · ${entry.sleepQuality?.wireValue ?: "—"} / ${entry.wakeQuality?.wireValue ?: "—"}")
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Card {
|
||||
Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(stringResource(R.string.sleep_morning))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(startDate, { startDate = it; changed() }, label = { Text("YYYY-MM-DD") }, modifier = Modifier.weight(1f))
|
||||
OutlinedTextField(endDate, { endDate = it; changed() }, label = { Text("YYYY-MM-DD") }, modifier = Modifier.weight(1f))
|
||||
}
|
||||
QualityRow(stringResource(R.string.sleep_quality), sleepQuality) { sleepQuality = it; changed() }
|
||||
QualityRow(stringResource(R.string.sleep_wake_quality), wakeQuality) { wakeQuality = it; changed() }
|
||||
Text(stringResource(R.string.sleep_day))
|
||||
QualityRow(stringResource(R.string.sleep_day_form), dayForm) { dayForm = it; changed() }
|
||||
SleepEventType.entries.forEach { type ->
|
||||
Button(onClick = {
|
||||
val start = OffsetDateTime.now()
|
||||
events = events + SleepDiaryEvent("sle_${UUID.randomUUID()}", type, start.toString(),
|
||||
if (type in pointTypes) null else start.plusHours(1).toString())
|
||||
changed()
|
||||
}) { Text(eventLabel(type)) }
|
||||
}
|
||||
events.forEachIndexed { index, event ->
|
||||
Card {
|
||||
Column(Modifier.padding(8.dp)) {
|
||||
Text(eventLabel(event.type))
|
||||
TimeButton(event.startAt) { value ->
|
||||
events = events.mapIndexed { item, current ->
|
||||
if (item == index) current.copy(startAt = value) else current
|
||||
}
|
||||
changed()
|
||||
}
|
||||
OutlinedTextField(event.startAt, { value -> events = events.mapIndexed { item, current -> if (item == index) current.copy(startAt = value) else current }; changed() }, label = { Text("ISO-8601") })
|
||||
event.endAt?.let { end ->
|
||||
TimeButton(end) { value ->
|
||||
events = events.mapIndexed { item, current ->
|
||||
if (item == index) current.copy(endAt = value) else current
|
||||
}
|
||||
changed()
|
||||
}
|
||||
OutlinedTextField(end, { value -> events = events.mapIndexed { item, current -> if (item == index) current.copy(endAt = value) else current }; changed() }, label = { Text("ISO-8601 →") })
|
||||
}
|
||||
Button(onClick = { events = events.filterIndexed { item, _ -> item != index }; changed() }) { Text(stringResource(R.string.sleep_delete)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(stringResource(R.string.sleep_medications))
|
||||
OutlinedTextField(medicationName, { medicationName = it },
|
||||
label = { Text(stringResource(R.string.sleep_medications)) }, modifier = Modifier.fillMaxWidth())
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(medicationDose, { medicationDose = it },
|
||||
label = { Text(stringResource(R.string.sleep_dose)) }, modifier = Modifier.weight(1f))
|
||||
OutlinedTextField(medicationUnit, { medicationUnit = it },
|
||||
label = { Text(stringResource(R.string.sleep_unit)) }, modifier = Modifier.weight(1f))
|
||||
}
|
||||
Button(onClick = {
|
||||
val now = OffsetDateTime.now().toString()
|
||||
val dose = medicationDose.toDoubleOrNull()
|
||||
if (medicationName.isNotBlank() && (medicationDose.isEmpty() || dose != null && dose > 0.0)) {
|
||||
val current = editingMedication
|
||||
repository.saveSleepMedication(SleepMedication(current?.medicationId.orEmpty(),
|
||||
current?.revisionId.orEmpty(), current?.createdAt ?: now, now,
|
||||
medicationName.trim(), dose, if (dose == null) null else medicationUnit,
|
||||
current?.form.orEmpty(), current?.note.orEmpty(), current?.active ?: true))
|
||||
medicationName = ""; editingMedication = null; revision++
|
||||
}
|
||||
}) { Text(stringResource(R.string.sleep_add_medication)) }
|
||||
medications.forEach { medication ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(selected = intakeMedication?.medicationId == medication.medicationId,
|
||||
onClick = { intakeMedication = medication
|
||||
intakeDose = medication.defaultDoseValue?.toString().orEmpty()
|
||||
intakeUnit = medication.defaultDoseUnit.orEmpty() },
|
||||
label = { Text(medication.name) })
|
||||
Button(onClick = { editingMedication = medication; medicationName = medication.name
|
||||
medicationDose = medication.defaultDoseValue?.toString().orEmpty()
|
||||
medicationUnit = medication.defaultDoseUnit.orEmpty() }) {
|
||||
Text(stringResource(R.string.sleep_edit))
|
||||
}
|
||||
Button(onClick = { repository.saveSleepMedication(medication.copy(
|
||||
updatedAt = OffsetDateTime.now().toString(), active = false)); revision++ }) {
|
||||
Text(stringResource(R.string.sleep_deactivate))
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(intakeDose, { intakeDose = it },
|
||||
label = { Text(stringResource(R.string.sleep_dose)) }, modifier = Modifier.weight(1f))
|
||||
OutlinedTextField(intakeUnit, { intakeUnit = it },
|
||||
label = { Text(stringResource(R.string.sleep_unit)) }, modifier = Modifier.weight(1f))
|
||||
}
|
||||
Button(onClick = {
|
||||
intakeMedication?.let { medication ->
|
||||
val dose = intakeDose.toDoubleOrNull()
|
||||
if (intakeDose.isEmpty() || dose != null && dose > 0.0) {
|
||||
val now = OffsetDateTime.now().toString()
|
||||
intakes = intakes + MedicationIntake("mdi_${UUID.randomUUID()}", medication.medicationId,
|
||||
medication.name, now, dose, if (dose == null) null else intakeUnit,
|
||||
"", now)
|
||||
changed()
|
||||
}
|
||||
}
|
||||
}) { Text(stringResource(R.string.sleep_add_intake)) }
|
||||
intakes.forEachIndexed { index, intake -> Card {
|
||||
Column(Modifier.fillMaxWidth().padding(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(intake.medicationName)
|
||||
TimeButton(intake.takenAt) { value -> intakes = intakes.mapIndexed { item, current ->
|
||||
if (item == index) current.copy(takenAt = value) else current }; changed() }
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(intake.doseValue?.toString().orEmpty(), { value ->
|
||||
intakes = intakes.mapIndexed { item, current -> if (item == index) current.copy(
|
||||
doseValue = value.toDoubleOrNull(), doseUnit = if (value.isEmpty()) null else current.doseUnit ?: "mg") else current }
|
||||
changed()
|
||||
}, label = { Text(stringResource(R.string.sleep_dose)) }, modifier = Modifier.weight(1f))
|
||||
OutlinedTextField(intake.doseUnit.orEmpty(), { value -> intakes = intakes.mapIndexed { item, current ->
|
||||
if (item == index) current.copy(doseUnit = value) else current }
|
||||
changed()
|
||||
}, label = { Text(stringResource(R.string.sleep_unit)) }, modifier = Modifier.weight(1f))
|
||||
}
|
||||
Button(onClick = { intakes = intakes.filterIndexed { item, _ -> item != index }; changed() }) {
|
||||
Text(stringResource(R.string.sleep_delete))
|
||||
}
|
||||
}
|
||||
} }
|
||||
OutlinedTextField(notes, { notes = it; changed() }, label = { Text(stringResource(R.string.sleep_notes)) }, modifier = Modifier.fillMaxWidth(), minLines = 3)
|
||||
Button(onClick = {
|
||||
val now = OffsetDateTime.now().toString()
|
||||
val current = selected
|
||||
val result = repository.saveSleepDiary(SleepDiaryDraft(current?.entryId,current?.revisionId,startDate,endDate,current?.createdAt ?: now,now,sleepQuality,wakeQuality,dayForm,notes,events,intakes))
|
||||
if (result is TrainlogRepository.SaveSleepDiaryResult.Saved) { revision++; selected = repository.listSleepDiary().firstOrNull { it.entryId == result.entryId } }
|
||||
}) { Text(stringResource(R.string.sleep_save)) }
|
||||
selected?.let { entry -> Button(onClick = {
|
||||
repository.validateSleepDiary(entry.entryId, entry.revisionId,
|
||||
OffsetDateTime.now().toString())
|
||||
selected = repository.listSleepDiary().firstOrNull { it.entryId == entry.entryId }
|
||||
}) { Text(stringResource(R.string.sleep_validate)) } }
|
||||
selected?.let { entry -> Button(onClick = { repository.deleteSleepDiary(entry.entryId,entry.revisionId,OffsetDateTime.now().toString()); selected = null; revision++ }) { Text(stringResource(R.string.sleep_delete)) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TimeButton(value: String, change: (String) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val parsed = runCatching { OffsetDateTime.parse(value) }.getOrNull()
|
||||
Button(onClick = {
|
||||
val current = parsed ?: OffsetDateTime.now()
|
||||
android.app.TimePickerDialog(context, { _, hour, minute ->
|
||||
change(current.withHour(hour).withMinute(minute).withSecond(0).withNano(0).toString())
|
||||
}, current.hour, current.minute, true).show()
|
||||
}) {
|
||||
Text(stringResource(R.string.sleep_choose_time) + " " + (parsed?.toLocalTime()?.toString() ?: "—"))
|
||||
}
|
||||
}
|
||||
|
||||
private val pointTypes = setOf(SleepEventType.BED_TIME, SleepEventType.FINAL_GET_UP,
|
||||
SleepEventType.NIGHT_GET_UP, SleepEventType.DAYTIME_SLEEPINESS)
|
||||
|
||||
@Composable
|
||||
private fun QualityRow(label: String, selected: SleepQuality?, change: (SleepQuality?) -> Unit) {
|
||||
Column { Text(label); Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
SleepQuality.entries.forEach { value -> FilterChip(selected == value, { change(if (selected == value) null else value) }, { Text(value.wireValue) }) }
|
||||
} }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun eventLabel(type: SleepEventType): String = stringResource(when (type) {
|
||||
SleepEventType.BED_TIME -> R.string.sleep_bed_time
|
||||
SleepEventType.FINAL_GET_UP -> R.string.sleep_final_get_up
|
||||
SleepEventType.NIGHT_GET_UP -> R.string.sleep_night_get_up
|
||||
SleepEventType.SLEEP -> R.string.sleep_sleep
|
||||
SleepEventType.NAP -> R.string.sleep_nap
|
||||
SleepEventType.LONG_AWAKE -> R.string.sleep_long_awake
|
||||
SleepEventType.HALF_SLEEP -> R.string.sleep_half_sleep
|
||||
SleepEventType.DAYTIME_SLEEPINESS -> R.string.sleep_sleepiness
|
||||
})
|
||||
|
|
@ -35,7 +35,10 @@ import com.labfytools.trainlog.data.SyncConversationIntent
|
|||
import com.labfytools.trainlog.data.SyncGenerationForegroundCoordinator
|
||||
import com.labfytools.trainlog.data.BackgroundSyncSettings
|
||||
import com.labfytools.trainlog.data.SyncBackgroundService
|
||||
import com.labfytools.trainlog.data.DriveSyncSettings
|
||||
import com.labfytools.trainlog.data.AiExportDriveSettings
|
||||
import com.labfytools.trainlog.data.BluetoothSyncSettings
|
||||
import com.labfytools.trainlog.data.bondedBluetoothDesktops
|
||||
import com.labfytools.trainlog.data.hasBluetoothConnectPermission
|
||||
import com.labfytools.trainlog.data.canonicalExchangeDirectory
|
||||
import com.labfytools.trainlog.data.AndroidMtpPublicationVisibility
|
||||
import com.labfytools.trainlog.data.MtpPublicationVisibility
|
||||
|
|
@ -240,8 +243,20 @@ fun SyncScreen(
|
|||
var syncRunning by remember { mutableStateOf(false) }
|
||||
val backgroundSettings = remember { BackgroundSyncSettings(context) }
|
||||
var backgroundEnabled by remember { mutableStateOf(backgroundSettings.enabled) }
|
||||
val driveSettings = remember { DriveSyncSettings(context) }
|
||||
val driveSettings = remember { AiExportDriveSettings(context) }
|
||||
var driveConnected by remember { mutableStateOf(driveSettings.connected) }
|
||||
val bluetoothSettings = remember { BluetoothSyncSettings(context) }
|
||||
var bluetoothPermission by remember {
|
||||
mutableStateOf(hasBluetoothConnectPermission(context))
|
||||
}
|
||||
var bluetoothDesktop by remember {
|
||||
mutableStateOf(bluetoothSettings.desktopAddress)
|
||||
}
|
||||
var bondedDesktops by remember {
|
||||
mutableStateOf(
|
||||
if (bluetoothPermission) bondedBluetoothDesktops(context) else emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
val driveFolderLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||
|
|
@ -256,6 +271,23 @@ fun SyncScreen(
|
|||
}
|
||||
}
|
||||
|
||||
val bluetoothPermissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
bluetoothPermission = granted || Build.VERSION.SDK_INT < Build.VERSION_CODES.S
|
||||
bondedDesktops =
|
||||
if (bluetoothPermission) bondedBluetoothDesktops(context) else emptyList()
|
||||
if (bluetoothPermission && backgroundSettings.enabled) {
|
||||
SyncBackgroundService.start(context)
|
||||
}
|
||||
status =
|
||||
if (bluetoothPermission) {
|
||||
strings.getString(R.string.bluetooth_sync_permission_granted)
|
||||
} else {
|
||||
strings.getString(R.string.bluetooth_sync_permission_required)
|
||||
}
|
||||
}
|
||||
|
||||
val notificationPermissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
|
|
@ -573,6 +605,69 @@ fun SyncScreen(
|
|||
)
|
||||
}
|
||||
|
||||
TrainlogFrame(title = strings.getString(R.string.bluetooth_sync_title)) {
|
||||
TrainlogInfo(
|
||||
text = strings.getString(R.string.bluetooth_sync_description),
|
||||
color =
|
||||
if (bluetoothDesktop != null && bluetoothPermission) colors.success
|
||||
else colors.warning,
|
||||
)
|
||||
if (!bluetoothPermission) {
|
||||
TrainlogAction(
|
||||
label = strings.getString(R.string.bluetooth_sync_permission),
|
||||
description = strings.getString(R.string.bluetooth_sync_permission_required),
|
||||
accent = colors.success,
|
||||
onClick = {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
bluetoothPermissionLauncher.launch(Manifest.permission.BLUETOOTH_CONNECT)
|
||||
} else {
|
||||
bluetoothPermission = true
|
||||
bondedDesktops = bondedBluetoothDesktops(context)
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
if (bondedDesktops.isEmpty()) {
|
||||
TrainlogInfo(
|
||||
text = strings.getString(R.string.bluetooth_sync_no_desktop),
|
||||
color = colors.warning,
|
||||
)
|
||||
}
|
||||
bondedDesktops.forEach { desktop ->
|
||||
TrainlogAction(
|
||||
label = strings.getString(R.string.bluetooth_sync_select, desktop.name),
|
||||
description = strings.getString(R.string.bluetooth_sync_address, desktop.address),
|
||||
accent =
|
||||
if (bluetoothDesktop == desktop.address) colors.success else colors.accent,
|
||||
onClick = {
|
||||
bluetoothSettings.selectDesktop(desktop.address)
|
||||
bluetoothDesktop = desktop.address
|
||||
status =
|
||||
strings.getString(R.string.bluetooth_sync_selected, desktop.name)
|
||||
if (backgroundSettings.enabled) {
|
||||
SyncBackgroundService.start(context)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
if (bluetoothDesktop != null) {
|
||||
TrainlogAction(
|
||||
label = strings.getString(R.string.bluetooth_sync_disconnect),
|
||||
description =
|
||||
strings.getString(
|
||||
R.string.bluetooth_sync_address,
|
||||
bluetoothDesktop ?: "",
|
||||
),
|
||||
accent = colors.warning,
|
||||
onClick = {
|
||||
bluetoothSettings.disconnect()
|
||||
bluetoothDesktop = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TrainlogFrame(title = strings.getString(R.string.background_sync_title)) {
|
||||
TrainlogInfo(
|
||||
text = strings.getString(R.string.background_sync_description),
|
||||
|
|
|
|||
|
|
@ -105,7 +105,21 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
|||
)
|
||||
AppRoute.Sessions -> SessionsHub(activeDraft, pendingAiDraftCount, { open(AppRoute.SessionEditor) }, ::openManualSession,
|
||||
{ open(AppRoute.AiSessionDrafts) }, { open(AppRoute.CompletedSessions) }, { open(AppRoute.Programs) })
|
||||
AppRoute.SessionEditor -> SessionScreen(repository, catalogRevision, { draftRevision++; back() }, { open(AppRoute.ExerciseCreate(AppRoute.SessionEditor)) }, { exportSnapshot(); draftRevision++ })
|
||||
AppRoute.SessionEditor -> SessionScreen(
|
||||
repository,
|
||||
catalogRevision,
|
||||
{ draftRevision++; back() },
|
||||
{ open(AppRoute.ExerciseCreate(AppRoute.SessionEditor)) },
|
||||
{ editingEntryId ->
|
||||
appState.pendingSessionEquipmentEntryId = editingEntryId
|
||||
open(AppRoute.EquipmentCreate(AppRoute.SessionEditor))
|
||||
},
|
||||
{ exportSnapshot(); draftRevision++ },
|
||||
restoreEditingEntryId = appState.pendingSessionEquipmentEntryId,
|
||||
onEditingContextRestored = {
|
||||
appState.pendingSessionEquipmentEntryId = null
|
||||
},
|
||||
)
|
||||
AppRoute.SessionGenerator -> SessionGeneratorScreen(repository, generatorState, { back() }, { generatorState.abandon(); draftRevision++; open(AppRoute.SessionEditor) }, {
|
||||
draftMessage = strings.getString(R.string.existing_draft_warning)
|
||||
draftRevision++
|
||||
|
|
@ -138,8 +152,47 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
|||
is AppRoute.ExerciseEdit -> ExerciseEditorRoute(repository, exerciseState, route.exerciseId, false) { exportSnapshot(); catalogRevision++; back() }
|
||||
AppRoute.Equipment -> EquipmentScreen(repository, equipmentState, { open(AppRoute.EquipmentCreate(AppRoute.Equipment)) }, { open(AppRoute.EquipmentDetail(it)) })
|
||||
is AppRoute.EquipmentDetail -> EquipmentDetailScreen(repository, route.equipmentId) { open(AppRoute.ExerciseDetail(it)) }
|
||||
is AppRoute.EquipmentCreate -> EquipmentCreateScreen(repository, equipmentState) { exportSnapshot(); catalogRevision++; back() }
|
||||
is AppRoute.EquipmentCreate -> EquipmentCreateScreen(repository, equipmentState) { equipment ->
|
||||
/* WHY: equipment creation temporarily leaves the active-session editor.
|
||||
* CONTRACT: returning from that catalogue route preserves the durable
|
||||
* form and selects the exact stable ID that was just created.
|
||||
* INVARIANT: no equipment is synthesized; this runs only after the
|
||||
* explicit EquipmentCreateScreen action succeeded. */
|
||||
if (route.caller == AppRoute.SessionEditor) {
|
||||
when (val loaded = repository.loadActiveSessionDraft()) {
|
||||
is ActiveDraftLoadResult.Loaded -> when (
|
||||
val saved = repository.saveActiveSessionDraft(
|
||||
loaded.draft.copy(
|
||||
form = loaded.draft.form.copy(
|
||||
editingExerciseIndex = appState.pendingSessionEquipmentEntryId
|
||||
?.let { entryId ->
|
||||
loaded.draft.exercises.indexOfFirst {
|
||||
it.entryId == entryId
|
||||
}.takeIf { it >= 0 }
|
||||
},
|
||||
editingEntryId = appState.pendingSessionEquipmentEntryId,
|
||||
selectedEquipmentId = equipment.equipmentId,
|
||||
),
|
||||
),
|
||||
)
|
||||
) {
|
||||
ActiveDraftMutationResult.Saved -> draftRevision++
|
||||
is ActiveDraftMutationResult.Error -> {
|
||||
draftMessage = localizedRepositoryMessage(localized, saved.message)
|
||||
}
|
||||
}
|
||||
is ActiveDraftLoadResult.Error -> {
|
||||
draftMessage = localizedRepositoryMessage(localized, loaded.message)
|
||||
}
|
||||
ActiveDraftLoadResult.None -> Unit
|
||||
}
|
||||
}
|
||||
exportSnapshot()
|
||||
catalogRevision++
|
||||
back()
|
||||
}
|
||||
AppRoute.BodyMeasurements -> BodyScreen(repository, bodyState, { exportSnapshot() }, { back() })
|
||||
AppRoute.SleepDiary -> SleepDiaryScreen(repository)
|
||||
AppRoute.LatestMaxima -> LatestMaximaScreen(repository)
|
||||
AppRoute.Sync -> SyncScreen(repository, inbox, exporter, requestOutbox, { exportSnapshot(); catalogRevision++ }, { back() })
|
||||
AppRoute.Settings -> SettingsScreen(repository, inbox) { exportSnapshot(); catalogRevision++ }
|
||||
|
|
|
|||
|
|
@ -8,6 +8,31 @@
|
|||
<string name="nav_statistics">Statistics</string>
|
||||
<string name="nav_sync">Synchronization</string>
|
||||
<string name="nav_settings">Settings</string>
|
||||
<string name="nav_sleep">Sleep</string>
|
||||
<string name="sleep_current_night">Current night</string>
|
||||
<string name="sleep_morning">Morning</string>
|
||||
<string name="sleep_day">Day</string>
|
||||
<string name="sleep_bed_time">Bedtime</string>
|
||||
<string name="sleep_final_get_up">Final get-up</string>
|
||||
<string name="sleep_night_get_up">Night get-up</string>
|
||||
<string name="sleep_sleep">Sleep</string>
|
||||
<string name="sleep_nap">Nap</string>
|
||||
<string name="sleep_long_awake">Long awakening</string>
|
||||
<string name="sleep_half_sleep">Half-sleep</string>
|
||||
<string name="sleep_sleepiness">Sleepiness</string>
|
||||
<string name="sleep_quality">Sleep quality</string>
|
||||
<string name="sleep_wake_quality">Wake quality</string>
|
||||
<string name="sleep_day_form">Day form</string>
|
||||
<string name="sleep_notes">Treatment and notes</string>
|
||||
<string name="sleep_add_event">Add event</string>
|
||||
<string name="sleep_save">Save</string>
|
||||
<string name="sleep_validate">Validate day</string>
|
||||
<string name="sleep_status_draft">Draft</string>
|
||||
<string name="sleep_status_ready">Ready to sync</string>
|
||||
<string name="sleep_status_synchronized">Synchronized</string>
|
||||
<string name="sleep_status_modified">Modified since synchronization</string>
|
||||
<string name="sleep_delete">Delete</string>
|
||||
<string name="sleep_choose_time">Choose time</string>
|
||||
<string name="route_session_editor">Current session</string>
|
||||
<string name="route_session_generator">Plan a session</string>
|
||||
<string name="route_ai_drafts">Drafts</string>
|
||||
|
|
@ -392,7 +417,7 @@
|
|||
<string name="regenerate_description">Run again with the same inputs and reference instant.</string><string name="accept_actual_description">Create the normal draft without prefilling performed sets.</string><string name="acknowledge_exposure_first">Confirm the recent-exposure warning first.</string>
|
||||
<string name="reason_observed_dose">observed repeated dose</string><string name="reason_max_no_prescription">observed maximum without numeric prescription</string><string name="reason_user_max">user-selected MAX percentage</string><string name="reason_max_unavailable">compatible MAX unavailable</string><string name="reason_assistance_omitted">assistance load omitted</string><string name="reason_load_absent">numeric load absent</string><string name="reason_manual_load">manually entered load</string><string name="reason_preferred">preferred exercise</string><string name="reason_primary_zone">requested primary zone</string><string name="reason_secondary_zone">requested secondary zone</string><string name="reason_distinct_movement">distinct movement</string><string name="reason_recent_exercise">same exercise observed recently</string><string name="reason_recent_pattern">similar movement observed recently</string>
|
||||
<string name="shortage_upper">missing upper region</string><string name="shortage_lower">missing lower region</string><string name="shortage_core">missing core</string><string name="shortage_push">missing upper-body push</string><string name="shortage_pull">missing upper-body pull</string><string name="shortage_lower_extension">missing lower-body extension</string><string name="shortage_lower_flexion">missing lower-body flexion</string><string name="shortage_exercises">fewer distinct exercises available</string>
|
||||
<string name="create_equipment">Create equipment</string><string name="create_personal_machine">Add a personal machine.</string><string name="search_equipment">Search by name, label, or alias</string>
|
||||
<string name="create_equipment">Create equipment</string><string name="create_personal_machine">Add a personal machine.</string><string name="create_equipment_from_session">Create it in the catalogue, then return to this exercise.</string><string name="search_equipment">Search by name, label, or alias</string>
|
||||
<string name="unknown_reference">Unknown reference · %1$s</string><string name="definition">Definition</string><string name="personal">Personal</string><string name="provided">Provided</string><string name="label_value">Label: %1$s</string>
|
||||
<string name="confidence_value">%1$s confidence</string><string name="verified_sources">Verified sources: %1$d</string><string name="personal_equipment">Personal equipment</string><string name="create">Create</string>
|
||||
<string name="create_machine_description">Create a personal machine with the current semantics.</string><string name="equipment_name_invalid">Enter a name of 1 to 120 characters.</string><string name="equipment_name_exists">Equipment already has this name.</string><string name="creation_failed">Creation failed: %1$s.</string>
|
||||
|
|
@ -448,4 +473,11 @@
|
|||
<string name="settings_backup_complete">Complete backup exported.</string>
|
||||
<string name="settings_backup_failed">Backup failed; any private staged copy was retained.</string>
|
||||
<string name="settings_restore_failed">The backup is invalid or could not be restored.</string>
|
||||
<string name="sleep_medications">Medications</string>
|
||||
<string name="sleep_add_medication">Add medication</string>
|
||||
<string name="sleep_add_intake">Add intake</string>
|
||||
<string name="sleep_dose">Dose</string>
|
||||
<string name="sleep_unit">Unit</string>
|
||||
<string name="sleep_deactivate">Deactivate</string>
|
||||
<string name="sleep_edit">Edit</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,31 @@
|
|||
<string name="nav_statistics">Statistiques</string>
|
||||
<string name="nav_sync">Synchronisation</string>
|
||||
<string name="nav_settings">Paramètres</string>
|
||||
<string name="nav_sleep">Sommeil</string>
|
||||
<string name="sleep_current_night">Nuit actuelle</string>
|
||||
<string name="sleep_morning">Matin</string>
|
||||
<string name="sleep_day">Journée</string>
|
||||
<string name="sleep_bed_time">Mise au lit</string>
|
||||
<string name="sleep_final_get_up">Lever</string>
|
||||
<string name="sleep_night_get_up">Lever nocturne</string>
|
||||
<string name="sleep_sleep">Sommeil</string>
|
||||
<string name="sleep_nap">Sieste</string>
|
||||
<string name="sleep_long_awake">Long réveil</string>
|
||||
<string name="sleep_half_sleep">Demi-sommeil</string>
|
||||
<string name="sleep_sleepiness">Somnolence</string>
|
||||
<string name="sleep_quality">Qualité du sommeil</string>
|
||||
<string name="sleep_wake_quality">Qualité du réveil</string>
|
||||
<string name="sleep_day_form">Forme de la journée</string>
|
||||
<string name="sleep_notes">Traitement et remarques</string>
|
||||
<string name="sleep_add_event">Ajouter un événement</string>
|
||||
<string name="sleep_save">Enregistrer</string>
|
||||
<string name="sleep_validate">Valider la journée</string>
|
||||
<string name="sleep_status_draft">Brouillon</string>
|
||||
<string name="sleep_status_ready">Prête à synchroniser</string>
|
||||
<string name="sleep_status_synchronized">Synchronisée</string>
|
||||
<string name="sleep_status_modified">Modifiée depuis la synchronisation</string>
|
||||
<string name="sleep_delete">Supprimer</string>
|
||||
<string name="sleep_choose_time">Choisir l’heure</string>
|
||||
<string name="route_session_editor">Séance en cours</string>
|
||||
<string name="route_session_generator">Programmer une séance</string>
|
||||
<string name="route_ai_drafts">Brouillons</string>
|
||||
|
|
@ -104,13 +129,23 @@
|
|||
<string name="background_sync_notification_title">Trainlog est prêt à synchroniser</string>
|
||||
<string name="background_sync_notification_text">Le service attend une demande du PC.</string>
|
||||
<string name="background_sync_notification_required">Autorisez les notifications pour activer la synchronisation en arrière-plan.</string>
|
||||
<string name="drive_sync_title">Google Drive privé</string>
|
||||
<string name="drive_sync_description">Sélectionnez le dossier privé Trainlog/Sync/v1. Trainlog n’accède qu’à ce dossier et n’y copie jamais SQLite.</string>
|
||||
<string name="drive_sync_connect">Connecter Google Drive</string>
|
||||
<string name="drive_sync_disconnect">Déconnecter Google Drive</string>
|
||||
<string name="drive_sync_latency">La vérification en arrière-plan est bornée par Android et peut prendre au moins 15 minutes.</string>
|
||||
<string name="drive_sync_connected">Dossier Drive connecté. Une vérification est planifiée.</string>
|
||||
<string name="drive_sync_connection_failed">Impossible de conserver l’accès au dossier Drive.</string>
|
||||
<string name="bluetooth_sync_title">Bluetooth automatique</string>
|
||||
<string name="bluetooth_sync_description">Trainlog se connecte automatiquement au PC appairé dès qu’il est à portée. MTP reste disponible en secours.</string>
|
||||
<string name="bluetooth_sync_permission">Autoriser l’accès Bluetooth</string>
|
||||
<string name="bluetooth_sync_permission_required">Autorisation Bluetooth requise pour la connexion automatique.</string>
|
||||
<string name="bluetooth_sync_permission_granted">Bluetooth autorisé. Sélectionnez le PC appairé.</string>
|
||||
<string name="bluetooth_sync_no_desktop">Aucun ordinateur appairé détecté.</string>
|
||||
<string name="bluetooth_sync_select">Utiliser %1$s</string>
|
||||
<string name="bluetooth_sync_selected">PC Bluetooth sélectionné : %1$s.</string>
|
||||
<string name="bluetooth_sync_disconnect">Oublier le PC Bluetooth</string>
|
||||
<string name="bluetooth_sync_address">%1$s</string>
|
||||
<string name="drive_sync_title">Export IA Google Drive</string>
|
||||
<string name="drive_sync_description">Sélectionnez une fois le dossier Trainlog/AI. Après chaque synchronisation, Android y publie automatiquement trainlog_ai_export_v1.json.</string>
|
||||
<string name="drive_sync_connect">Choisir Trainlog/AI</string>
|
||||
<string name="drive_sync_disconnect">Déconnecter l’export IA Drive</string>
|
||||
<string name="drive_sync_latency">L’échec de Drive ne bloque jamais la synchronisation. Android conserve l’export local et réessaie automatiquement.</string>
|
||||
<string name="drive_sync_connected">Dossier Trainlog/AI connecté. L’export sera publié automatiquement.</string>
|
||||
<string name="drive_sync_connection_failed">Impossible de conserver l’accès au dossier Trainlog/AI.</string>
|
||||
<string name="sync_flow_description">Android → PC puis PC → Android, en une seule opération.</string>
|
||||
<string name="sync_authorize_first">Accès fichiers requis. Autorisez d’abord Documents/Trainlog.</string>
|
||||
<string name="sync_request_sent">Demande envoyée · attente du PC…</string>
|
||||
|
|
@ -392,7 +427,7 @@
|
|||
<string name="regenerate_description">Relancer les mêmes entrées et le même instant de référence.</string><string name="accept_actual_description">Créer le brouillon normal sans préremplir les séries réalisées.</string><string name="acknowledge_exposure_first">Confirmez d’abord l’avertissement d’exposition récente.</string>
|
||||
<string name="reason_observed_dose">dose répétée observée</string><string name="reason_max_no_prescription">maximum observé sans prescription numérique</string><string name="reason_user_max">pourcentage de MAX choisi par l’utilisateur</string><string name="reason_max_unavailable">MAX compatible indisponible</string><string name="reason_assistance_omitted">charge d’assistance omise</string><string name="reason_load_absent">charge numérique absente</string><string name="reason_manual_load">charge saisie manuellement</string><string name="reason_preferred">exercice préféré</string><string name="reason_primary_zone">zone principale demandée</string><string name="reason_secondary_zone">zone secondaire demandée</string><string name="reason_distinct_movement">mouvement distinct</string><string name="reason_recent_exercise">même exercice observé récemment</string><string name="reason_recent_pattern">mouvement similaire observé récemment</string>
|
||||
<string name="shortage_upper">région supérieure manquante</string><string name="shortage_lower">région inférieure manquante</string><string name="shortage_core">tronc manquant</string><string name="shortage_push">poussée du haut du corps manquante</string><string name="shortage_pull">tirage du haut du corps manquant</string><string name="shortage_lower_extension">extension du bas du corps manquante</string><string name="shortage_lower_flexion">flexion du bas du corps manquante</string><string name="shortage_exercises">moins d’exercices distincts disponibles</string>
|
||||
<string name="create_equipment">Créer un équipement</string><string name="create_personal_machine">Ajouter une machine personnelle.</string><string name="search_equipment">Rechercher par nom, étiquette ou alias</string>
|
||||
<string name="create_equipment">Créer un équipement</string><string name="create_personal_machine">Ajouter une machine personnelle.</string><string name="create_equipment_from_session">Créer dans le catalogue puis revenir à cet exercice.</string><string name="search_equipment">Rechercher par nom, étiquette ou alias</string>
|
||||
<string name="unknown_reference">Référence inconnue · %1$s</string><string name="definition">Définition</string><string name="personal">Personnel</string><string name="provided">Fourni</string><string name="label_value">Étiquette : %1$s</string>
|
||||
<string name="confidence_value">Confiance %1$s</string><string name="verified_sources">Sources vérifiées : %1$d</string><string name="personal_equipment">Équipement personnel</string><string name="create">Créer</string>
|
||||
<string name="create_machine_description">Créer une machine personnelle avec la sémantique actuelle.</string><string name="equipment_name_invalid">Saisissez un nom de 1 à 120 caractères.</string><string name="equipment_name_exists">Un équipement porte déjà ce nom.</string><string name="creation_failed">Création impossible : %1$s.</string>
|
||||
|
|
@ -448,4 +483,11 @@
|
|||
<string name="settings_backup_complete">Sauvegarde complète exportée.</string>
|
||||
<string name="settings_backup_failed">Échec de la sauvegarde ; toute copie privée créée est conservée.</string>
|
||||
<string name="settings_restore_failed">Sauvegarde invalide ou restauration impossible.</string>
|
||||
<string name="sleep_medications">Médicaments</string>
|
||||
<string name="sleep_add_medication">Ajouter un médicament</string>
|
||||
<string name="sleep_add_intake">Ajouter une prise</string>
|
||||
<string name="sleep_dose">Dosage</string>
|
||||
<string name="sleep_unit">Unité</string>
|
||||
<string name="sleep_deactivate">Désactiver</string>
|
||||
<string name="sleep_edit">Modifier</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest {
|
|||
|
||||
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
||||
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(25, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(26, scalar(db, "PRAGMA user_version"))
|
||||
for (table in listOf("session_exercises", "draft_session_exercises")) {
|
||||
assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')"))
|
||||
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL"))
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ class AndroidV11ExerciseAliasMigrationTest {
|
|||
migrated.close()
|
||||
}
|
||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(25, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(26, scalar(db, "PRAGMA user_version"))
|
||||
/* v13 intentionally adds the frozen machine identities and
|
||||
* exercise-owned metadata; all non-exercise facts stay exact. */
|
||||
val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") }
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest {
|
|||
migrated.close()
|
||||
}
|
||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(25, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(26, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(before, listOf(
|
||||
scalar(db, "SELECT COUNT(*) FROM sessions"),
|
||||
scalar(db, "SELECT COUNT(*) FROM performed_sets"),
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ class AndroidV25ProgramExecutionMigrationTest {
|
|||
reopened.close()
|
||||
}
|
||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
assertEquals(25, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(26, scalar(db, "PRAGMA user_version"))
|
||||
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM sessions WHERE source_program_id IS NOT NULL"))
|
||||
assertEquals("ok", text(db, "PRAGMA integrity_check"))
|
||||
db.rawQuery("PRAGMA foreign_key_check", null).use { cursor ->
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import java.util.UUID
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
class AndroidV26SleepMigrationTest {
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
|
||||
@Test
|
||||
fun v25AddsEmptySleepDomainAndReopens() {
|
||||
val name = "sleep-v25-${UUID.randomUUID()}.db"
|
||||
TrainlogRepository(context, name).useForTest { assertTrueEmpty(it) }
|
||||
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null, SQLiteDatabase.OPEN_READWRITE).use { db ->
|
||||
db.execSQL("DROP TABLE sleep_diary_events")
|
||||
db.execSQL("DROP TABLE sleep_diary_revisions")
|
||||
db.execSQL("DROP TABLE sleep_diary_entries")
|
||||
db.version = 25
|
||||
}
|
||||
var repository = TrainlogRepository(context, name)
|
||||
try {
|
||||
assertTrueEmpty(repository)
|
||||
repository.close()
|
||||
repository = TrainlogRepository(context, name)
|
||||
assertTrueEmpty(repository)
|
||||
SQLiteDatabase.openDatabase(
|
||||
context.getDatabasePath(name).path,
|
||||
null,
|
||||
SQLiteDatabase.OPEN_READONLY,
|
||||
).use { assertEquals(26, 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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -328,7 +328,7 @@ class BodyZonesTest {
|
|||
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
|
||||
SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||
db.rawQuery("PRAGMA user_version", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(25, cursor.getInt(0))
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(26, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ class BridgeBackupMigrationTest {
|
|||
null,
|
||||
android.database.sqlite.SQLiteDatabase.OPEN_READONLY,
|
||||
)
|
||||
.use { assertEquals(25, it.version) }
|
||||
.use { assertEquals(26, it.version) }
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
|
|
|
|||
|
|
@ -70,6 +70,57 @@ class ProgramsProjectionRepositoryTest {
|
|||
.put("deletions", deletions)
|
||||
.toString()
|
||||
|
||||
private fun preparationArtifact(
|
||||
includeProgramProvenance: Boolean = true,
|
||||
deliveryState: String = "pending",
|
||||
): String {
|
||||
val occurrence =
|
||||
JSONObject()
|
||||
.put("entry_id", "spe_11111111-1111-4111-8111-111111111111")
|
||||
.put("position", 0)
|
||||
.put("exercise_id", "ex_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee")
|
||||
.put("equipment_id", JSONObject.NULL)
|
||||
.put("recording_mode", "sets")
|
||||
.put("tracking_mode", "reps")
|
||||
.put("data_fields", 0)
|
||||
.put("load_mode", "external")
|
||||
.put("rest_seconds", 90)
|
||||
.put("target_sets", 3)
|
||||
.put("target_reps", 8)
|
||||
.put("target_duration_seconds", JSONObject.NULL)
|
||||
.put("target_weight_kg", 40.0)
|
||||
.put("notes", JSONObject.NULL)
|
||||
val delivery =
|
||||
JSONObject()
|
||||
.put("delivery_id", "spd_22222222-2222-4222-8222-222222222222")
|
||||
.put("preparation_id", "sp_33333333-3333-4333-8333-333333333333")
|
||||
.put("revision_id", "spr_44444444-4444-4444-8444-444444444444")
|
||||
.put("execution_session_id", "se_55555555-5555-4555-8555-555555555555")
|
||||
.put("state", deliveryState)
|
||||
.put("title", "Séance A")
|
||||
.put("session_type", "training")
|
||||
.put("planned_for", JSONObject.NULL)
|
||||
.put("notes", JSONObject.NULL)
|
||||
.put("source_proposal_id", JSONObject.NULL)
|
||||
.put("source_payload_sha256", JSONObject.NULL)
|
||||
.put("occurrences", JSONArray().put(occurrence))
|
||||
if (includeProgramProvenance) {
|
||||
delivery
|
||||
.put("source_program_id", "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
|
||||
.put(
|
||||
"source_program_session_id",
|
||||
"pgs_cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
||||
)
|
||||
}
|
||||
return JSONObject()
|
||||
.put("format", "trainlog-session-preparations")
|
||||
.put("version", 2)
|
||||
.put("generated_at", "2026-09-18T12:05:00Z")
|
||||
.put("deliveries", JSONArray().put(delivery))
|
||||
.put("withdrawals", JSONArray())
|
||||
.toString()
|
||||
}
|
||||
|
||||
private fun deletion(): JSONObject =
|
||||
JSONObject()
|
||||
.put("deletion_id", "pgd_ffffffff-ffff-4fff-8fff-ffffffffffff")
|
||||
|
|
@ -194,4 +245,103 @@ class ProgramsProjectionRepositoryTest {
|
|||
context.deleteDatabase(name)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deliveredProgramPreparationCompletesProgramProgressAcrossRestart() {
|
||||
val name = "program-delivery-${UUID.randomUUID()}.db"
|
||||
var repository = TrainlogRepository(context, name)
|
||||
try {
|
||||
val catalog =
|
||||
JSONObject()
|
||||
.put("format", "trainlog-pc-catalog")
|
||||
.put("version", 1)
|
||||
.put(
|
||||
"exercises",
|
||||
JSONArray().put(
|
||||
JSONObject()
|
||||
.put("exercise_id", "ex_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee")
|
||||
.put("name", "Exercise fixture")
|
||||
.put("recording_mode", "sets")
|
||||
.put("tracking_mode", "reps")
|
||||
.put("data_fields", 0),
|
||||
),
|
||||
)
|
||||
.toString()
|
||||
assertTrue(repository.applyPcCatalogJson(catalog) is PcCatalogImportResult.Applied)
|
||||
assertEquals(
|
||||
ProgramsImportResult.Applied(1, 0, 0),
|
||||
repository.applyProgramsV1Json(artifact(JSONArray().put(program()))),
|
||||
)
|
||||
assertEquals(
|
||||
AiSessionDraftImportResult.Applied(1, 0),
|
||||
repository.applySessionPreparationsJson(
|
||||
preparationArtifact(includeProgramProvenance = false),
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
StartAiSessionDraftResult.Started,
|
||||
repository.startPreparedSession("spd_22222222-2222-4222-8222-222222222222"),
|
||||
)
|
||||
val active =
|
||||
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
||||
assertEquals(null, active.sourceProgramId)
|
||||
assertEquals(null, active.sourceProgramSessionId)
|
||||
val performed =
|
||||
active.copy(
|
||||
exercises =
|
||||
active.exercises.map { occurrence ->
|
||||
occurrence.copy(
|
||||
sets = listOf(SessionSetDraft(reps = 8, weightKg = 40.0)),
|
||||
)
|
||||
},
|
||||
)
|
||||
assertEquals(
|
||||
ActiveDraftMutationResult.Saved,
|
||||
repository.saveActiveSessionDraft(performed),
|
||||
)
|
||||
assertTrue(repository.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
|
||||
assertEquals(
|
||||
ProgramSessionExecutionState.TODO,
|
||||
repository.getSyncedProgram("pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")!!
|
||||
.sessions.single().executionState,
|
||||
)
|
||||
assertEquals(
|
||||
AiSessionDraftImportResult.Applied(0, 1),
|
||||
repository.applySessionPreparationsJson(
|
||||
preparationArtifact(deliveryState = "remote_unknown"),
|
||||
),
|
||||
)
|
||||
val detail = repository.getSyncedProgram("pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")!!
|
||||
assertEquals(ProgramSessionExecutionState.COMPLETED, detail.sessions.single().executionState)
|
||||
val execution =
|
||||
JSONObject(repository.buildProgramExecutionsV1Json())
|
||||
.getJSONArray("executions")
|
||||
.getJSONObject(0)
|
||||
assertEquals("completed", execution.getString("state"))
|
||||
assertEquals(
|
||||
"pgs_cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
||||
execution.getString("program_session_id"),
|
||||
)
|
||||
} finally {
|
||||
repository.close()
|
||||
}
|
||||
|
||||
repository = TrainlogRepository(context, name)
|
||||
try {
|
||||
val detail = repository.getSyncedProgram("pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")!!
|
||||
assertEquals(1, detail.sessions.count {
|
||||
it.executionState == ProgramSessionExecutionState.COMPLETED
|
||||
})
|
||||
assertEquals(
|
||||
StartProgramSessionResult.AlreadyCompleted,
|
||||
repository.startSyncedProgramSession(
|
||||
"pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
"pgs_cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
||||
),
|
||||
)
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,6 +105,106 @@ class SessionPreparationExchangeTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun startingMixedPreparationCopiesTargetsWithoutPerformedRows() {
|
||||
val name = "prepared-mixed-${System.nanoTime()}.db"
|
||||
val repository = TrainlogRepository(context, name)
|
||||
try {
|
||||
assertTrue(
|
||||
repository.createExercise(
|
||||
NewExerciseProfile(
|
||||
name = "Test prepared walk",
|
||||
recordingMode = RecordingMode.CONTINUOUS,
|
||||
trackingMode = TrackingMode.DURATION,
|
||||
dataFields = 0,
|
||||
),
|
||||
) is CreateExerciseResult.Created,
|
||||
)
|
||||
assertTrue(
|
||||
repository.createExercise(
|
||||
NewExerciseProfile(
|
||||
name = "Test prepared press",
|
||||
recordingMode = RecordingMode.SETS,
|
||||
trackingMode = TrackingMode.REPS,
|
||||
dataFields = 0,
|
||||
),
|
||||
) is CreateExerciseResult.Created,
|
||||
)
|
||||
val continuous = repository.listExercises().single { it.name == "Test prepared walk" }
|
||||
val sets = repository.listExercises().single { it.name == "Test prepared press" }
|
||||
val payload = JSONObject(artifact(repository))
|
||||
val delivery = payload.getJSONArray("deliveries").getJSONObject(0)
|
||||
delivery.put(
|
||||
"occurrences",
|
||||
JSONArray()
|
||||
.put(
|
||||
JSONObject()
|
||||
.put("entry_id", "spe_11111111-1111-4111-8111-111111111111")
|
||||
.put("position", 0)
|
||||
.put("exercise_id", continuous.exerciseId)
|
||||
.put("equipment_id", JSONObject.NULL)
|
||||
.put("recording_mode", "continuous")
|
||||
.put("tracking_mode", "duration")
|
||||
.put("data_fields", continuous.dataFields)
|
||||
.put("load_mode", "none")
|
||||
.put("rest_seconds", 0)
|
||||
.put("target_sets", JSONObject.NULL)
|
||||
.put("target_reps", JSONObject.NULL)
|
||||
.put("target_duration_seconds", 600)
|
||||
.put("target_weight_kg", JSONObject.NULL)
|
||||
.put("notes", "Target only"),
|
||||
)
|
||||
.put(
|
||||
JSONObject()
|
||||
.put("entry_id", "spe_22222222-2222-4222-8222-222222222222")
|
||||
.put("position", 1)
|
||||
.put("exercise_id", sets.exerciseId)
|
||||
.put("equipment_id", JSONObject.NULL)
|
||||
.put("recording_mode", "sets")
|
||||
.put("tracking_mode", "reps")
|
||||
.put("data_fields", sets.dataFields)
|
||||
.put("load_mode", "external")
|
||||
.put("rest_seconds", 90)
|
||||
.put("target_sets", 3)
|
||||
.put("target_reps", 10)
|
||||
.put("target_duration_seconds", JSONObject.NULL)
|
||||
.put("target_weight_kg", 52.0)
|
||||
.put("notes", JSONObject.NULL),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
AiSessionDraftImportResult.Applied(1, 0),
|
||||
repository.applySessionPreparationsJson(payload.toString()),
|
||||
)
|
||||
assertEquals(
|
||||
StartAiSessionDraftResult.Started,
|
||||
repository.startPreparedSession("spd_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
|
||||
)
|
||||
val loadResult = repository.loadActiveSessionDraft()
|
||||
assertTrue(loadResult.toString(), loadResult is ActiveDraftLoadResult.Loaded)
|
||||
val active = loadResult as ActiveDraftLoadResult.Loaded
|
||||
assertEquals(2, active.draft.exercises.size)
|
||||
assertEquals(600, active.draft.exercises[0].plan?.durationSeconds)
|
||||
assertEquals(0, active.draft.exercises[0].continuousDurationSeconds)
|
||||
assertEquals(3, active.draft.exercises[1].plan?.sets)
|
||||
assertEquals(10, active.draft.exercises[1].plan?.reps)
|
||||
assertEquals(52.0, active.draft.exercises[1].plan?.weightKg)
|
||||
assertTrue(active.draft.exercises[1].sets.isEmpty())
|
||||
assertEquals(1, scalarInt(name, "SELECT COUNT(*) FROM active_session_draft"))
|
||||
assertEquals(0, scalarInt(name, "SELECT COUNT(*) FROM draft_continuous_activity"))
|
||||
assertEquals(0, scalarInt(name, "SELECT COUNT(*) FROM draft_performed_sets"))
|
||||
assertEquals(0, scalarInt(name, "SELECT COUNT(*) FROM draft_max_results"))
|
||||
assertEquals(0, scalarInt(name, "SELECT COUNT(*) FROM draft_exercise_feedback"))
|
||||
assertEquals(0, scalarInt(name, "SELECT COUNT(*) FROM sessions"))
|
||||
assertEquals("ok", scalarText(name, "PRAGMA integrity_check"))
|
||||
assertEquals(0, scalarInt(name, "SELECT COUNT(*) FROM pragma_foreign_key_check"))
|
||||
} finally {
|
||||
repository.close()
|
||||
context.deleteDatabase(name)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deliveryReplaysStartsExplicitlyAndNeverOverwritesActiveDraft() {
|
||||
val name = "prepared-${System.nanoTime()}.db"
|
||||
|
|
@ -260,7 +360,7 @@ class SessionPreparationExchangeTest {
|
|||
repository = TrainlogRepository(context, name)
|
||||
assertEquals(1, repository.listPreparedSessions().size)
|
||||
repository.close()
|
||||
assertEquals("25", scalarText(name, "PRAGMA user_version"))
|
||||
assertEquals("26", scalarText(name, "PRAGMA user_version"))
|
||||
assertEquals(
|
||||
"0",
|
||||
scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,287 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.content.Context
|
||||
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.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 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)
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -506,7 +506,7 @@ class SyncGenerationServiceTest {
|
|||
.use { db ->
|
||||
db.rawQuery("PRAGMA user_version", null).use {
|
||||
assertTrue(it.moveToFirst())
|
||||
assertEquals(25, it.getInt(0))
|
||||
assertEquals(26, it.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
|
||||
assertTrue(it.moveToFirst())
|
||||
|
|
|
|||
|
|
@ -2260,7 +2260,7 @@ class TrainlogRepositoryDraftTest {
|
|||
).use { db ->
|
||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(25, cursor.getInt(0))
|
||||
assertEquals(26, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery(
|
||||
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
||||
|
|
@ -2387,7 +2387,7 @@ class TrainlogRepositoryDraftTest {
|
|||
).use { db ->
|
||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(25, cursor.getInt(0))
|
||||
assertEquals(26, cursor.getInt(0))
|
||||
}
|
||||
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
|
|
@ -2695,6 +2695,46 @@ class TrainlogRepositoryDraftTest {
|
|||
} catch (_: IllegalStateException) { }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completedContinuousPlanKeepsPerformedFactAndExportsWithoutSetTarget() {
|
||||
val repo = openRepository()
|
||||
val exercise = createExercise(
|
||||
repo,
|
||||
"Marche planifiée",
|
||||
RecordingMode.CONTINUOUS,
|
||||
TrackingMode.DURATION,
|
||||
)
|
||||
val plan = SessionExercisePlan(sets = 0, durationSeconds = 600)
|
||||
assertEquals(
|
||||
ActiveDraftMutationResult.Saved,
|
||||
repo.saveActiveSessionDraft(
|
||||
ActiveSessionDraft(
|
||||
exercises = listOf(
|
||||
SessionExerciseDraft(
|
||||
exercise = exercise,
|
||||
plan = plan,
|
||||
continuousDurationSeconds = 540,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
val result = repo.finalizeActiveSessionDraft() as FinalizeActiveDraftResult.Saved
|
||||
|
||||
val detail = repo.getSessionDetail(result.sessionId)!!
|
||||
assertEquals(plan, detail.exercises.single().plan)
|
||||
val exported =
|
||||
JSONObject(repo.buildMobileExportV4Json())
|
||||
.getJSONArray("sessions")
|
||||
.getJSONObject(0)
|
||||
.getJSONArray("exercises")
|
||||
.getJSONObject(0)
|
||||
assertTrue(exported.isNull("target"))
|
||||
assertEquals(540, exported.getJSONObject("continuous").getInt("duration_seconds"))
|
||||
assertTrue(!exported.getJSONObject("continuous").has("speed_kmh"))
|
||||
assertTrue(!exported.getJSONObject("continuous").has("distance_km"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun targetOnlyDraftPersistsButCannotFinalizeWithoutActualWork() {
|
||||
val repo = openRepository()
|
||||
|
|
|
|||
|
|
@ -12,8 +12,9 @@ import androidx.compose.ui.test.junit4.createComposeRule
|
|||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTextClearance
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.compose.ui.test.performSemanticsAction
|
||||
import androidx.compose.ui.semantics.SemanticsActions
|
||||
import androidx.compose.ui.input.key.Key
|
||||
|
|
@ -28,8 +29,12 @@ import com.labfytools.trainlog.data.ActiveDraftMutationResult
|
|||
import com.labfytools.trainlog.data.AiSessionDraftImportResult
|
||||
import com.labfytools.trainlog.data.CreateExerciseResult
|
||||
import com.labfytools.trainlog.data.TrainlogRepository
|
||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||
import com.labfytools.trainlog.model.NewExerciseProfile
|
||||
import com.labfytools.trainlog.model.RecordingMode
|
||||
import com.labfytools.trainlog.model.SessionExerciseDraft
|
||||
import com.labfytools.trainlog.model.SessionExercisePlan
|
||||
import com.labfytools.trainlog.model.SessionLoadMode
|
||||
import com.labfytools.trainlog.model.TrackingMode
|
||||
import com.labfytools.trainlog.ui.theme.TrainlogTheme
|
||||
import java.util.UUID
|
||||
|
|
@ -115,7 +120,7 @@ class AiSessionDraftUiWiringTest {
|
|||
assertEquals(ActiveDraftMutationResult.Saved, repository.startActiveSessionDraft())
|
||||
var createCalls = 0
|
||||
compose.setContent { TrainlogTheme {
|
||||
SessionScreen(repository, 0, {}, { createCalls++ }, {})
|
||||
SessionScreen(repository, 0, {}, { createCalls++ }, { _ -> }, {})
|
||||
} }
|
||||
|
||||
val create = compose.onNodeWithTag("active-session-create-exercise")
|
||||
|
|
@ -130,6 +135,84 @@ class AiSessionDraftUiWiringTest {
|
|||
compose.runOnIdle { assertEquals(1, createCalls) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun modifyingTargetOnlyOccurrenceRendersItsPrefilledEditor() {
|
||||
val walk = createExercise("Marche ciblée", RecordingMode.CONTINUOUS, TrackingMode.DURATION)
|
||||
val press = createExercise("Presse ciblée", RecordingMode.SETS, TrackingMode.REPS)
|
||||
val walkEntry = SessionExerciseDraft(
|
||||
entryId = "spe_11111111-1111-4111-8111-111111111111",
|
||||
exercise = walk,
|
||||
plan = SessionExercisePlan(sets = 0, durationSeconds = 600),
|
||||
)
|
||||
val pressEntry = SessionExerciseDraft(
|
||||
entryId = "spe_22222222-2222-4222-8222-222222222222",
|
||||
exercise = press,
|
||||
plan = SessionExercisePlan(
|
||||
sets = 3,
|
||||
reps = 10,
|
||||
weightKg = 52.0,
|
||||
loadMode = SessionLoadMode.EXTERNAL,
|
||||
restSeconds = 90,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
ActiveDraftMutationResult.Saved,
|
||||
repository.saveActiveSessionDraft(
|
||||
ActiveSessionDraft(exercises = listOf(walkEntry, pressEntry)),
|
||||
),
|
||||
)
|
||||
val loaded = (repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
||||
assertEquals(
|
||||
ActiveDraftMutationResult.Saved,
|
||||
repository.saveActiveSessionDraft(
|
||||
loaded.copy(form = formForExistingExercise(loaded.exercises[0], 0)),
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
ActiveDraftMutationResult.Saved,
|
||||
repository.saveActiveSessionDraft(loaded),
|
||||
)
|
||||
compose.setContent {
|
||||
TrainlogTheme { SessionScreen(repository, 0, {}, {}, { _ -> }, {}) }
|
||||
}
|
||||
|
||||
compose.onNodeWithTag("edit-draft-exercise-${walkEntry.entryId}")
|
||||
.performSemanticsAction(SemanticsActions.OnClick)
|
||||
|
||||
compose.runOnIdle {
|
||||
val active = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
|
||||
assertEquals(walk.exerciseId, active.draft.form.selectedExercise?.exerciseId)
|
||||
}
|
||||
compose.onNodeWithText("SAISIE — Marche ciblée").assertExists()
|
||||
compose.onNodeWithTag("session-continuous-duration").assertExists()
|
||||
compose.onNodeWithText("Ajouter à la séance").assertExists()
|
||||
|
||||
compose.onNodeWithTag("session-continuous-duration").performTextClearance()
|
||||
compose.onNodeWithTag("session-continuous-duration").performTextInput("12")
|
||||
compose.onNodeWithTag("add-to-session")
|
||||
.performSemanticsAction(SemanticsActions.OnClick)
|
||||
compose.runOnIdle {
|
||||
val active = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
|
||||
assertEquals(2, active.draft.exercises.size)
|
||||
assertEquals(walkEntry.entryId, active.draft.exercises[0].entryId)
|
||||
assertEquals(720, active.draft.exercises[0].continuousDurationSeconds)
|
||||
assertEquals(600, active.draft.exercises[0].plan?.durationSeconds)
|
||||
assertEquals(pressEntry, active.draft.exercises[1])
|
||||
}
|
||||
|
||||
compose.onNodeWithTag("edit-draft-exercise-${pressEntry.entryId}")
|
||||
.performSemanticsAction(SemanticsActions.OnClick)
|
||||
compose.onNodeWithText("SAISIE — Presse ciblée").assertExists()
|
||||
compose.onNodeWithTag("cancel-session-exercise-editor")
|
||||
.performSemanticsAction(SemanticsActions.OnClick)
|
||||
compose.runOnIdle {
|
||||
val active = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
|
||||
assertEquals(720, active.draft.exercises[0].continuousDurationSeconds)
|
||||
assertEquals(pressEntry, active.draft.exercises[1])
|
||||
assertEquals(null, active.draft.form.selectedExercise)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun draftScreenShowsDosBicepsAndTargetDetails() {
|
||||
importDraft()
|
||||
|
|
@ -299,6 +382,15 @@ class AiSessionDraftUiWiringTest {
|
|||
return payload
|
||||
}
|
||||
|
||||
private fun createExercise(
|
||||
name: String,
|
||||
recordingMode: RecordingMode,
|
||||
trackingMode: TrackingMode,
|
||||
) =
|
||||
(repository.createExercise(
|
||||
NewExerciseProfile(name, recordingMode, trackingMode, 0),
|
||||
) as CreateExerciseResult.Created).exercise
|
||||
|
||||
private fun artifact(exerciseId: String): String {
|
||||
val target = JSONObject().put("sets", 3).put("reps", 10)
|
||||
.put("duration_seconds", JSONObject.NULL).put("weight_kg", JSONObject.NULL)
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ class AppNavigationStateTest {
|
|||
@After fun tearDown() { context.deleteDatabase(databaseName) }
|
||||
|
||||
@Test fun captureSectionsHaveTypedRootsWithoutMobileStatistics() {
|
||||
assertEquals(6, AppSection.entries.size)
|
||||
assertEquals(7, AppSection.entries.size)
|
||||
assertFalse(AppSection.entries.any { it.name == "STATISTICS" })
|
||||
AppSection.entries.forEach { assertEquals(it, it.rootRoute().section) }
|
||||
assertEquals(AppSection.SESSIONS, AppRoute.SessionDetail("se_1").section)
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ class DraftFeedbackUiReadbackTest {
|
|||
val completedSessionId = mutableStateOf<String?>(null)
|
||||
compose.setContent { TrainlogTheme {
|
||||
completedSessionId.value?.let { SessionDetailScreen(repository, it, {}, {}) }
|
||||
?: SessionScreen(repository, 0, {}, {}, {})
|
||||
?: SessionScreen(repository, 0, {}, {}, { _ -> }, {})
|
||||
} }
|
||||
compose.onNodeWithTag("draft-feedback-${entry.entryId}").fetchSemanticsNode()
|
||||
compose.onNodeWithText("Ressenti · texte UI durable").fetchSemanticsNode()
|
||||
|
|
|
|||
|
|
@ -7,15 +7,22 @@ package com.labfytools.trainlog.ui
|
|||
|
||||
import android.content.Context
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.hasClickAction
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.compose.ui.test.performSemanticsAction
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.performScrollToNode
|
||||
import androidx.compose.ui.semantics.SemanticsActions
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.labfytools.trainlog.data.ActiveDraftMutationResult
|
||||
import com.labfytools.trainlog.data.ActiveDraftLoadResult
|
||||
import com.labfytools.trainlog.data.BodyZoneRecentExposure
|
||||
import com.labfytools.trainlog.data.ExposureWindowSummary
|
||||
import com.labfytools.trainlog.data.CreateExerciseResult
|
||||
import com.labfytools.trainlog.data.GenerationWarningLevel
|
||||
import com.labfytools.trainlog.data.SessionGenerationPreview
|
||||
import com.labfytools.trainlog.data.SessionGenerationPreviewExercise
|
||||
|
|
@ -26,6 +33,13 @@ import com.labfytools.trainlog.data.SyncExporter
|
|||
import com.labfytools.trainlog.data.SyncRequestOutbox
|
||||
import com.labfytools.trainlog.data.TrainlogRepository
|
||||
import com.labfytools.trainlog.model.SessionExercisePlan
|
||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||
import com.labfytools.trainlog.model.NewExerciseProfile
|
||||
import com.labfytools.trainlog.model.RecordingMode
|
||||
import com.labfytools.trainlog.model.SessionExerciseDraft
|
||||
import com.labfytools.trainlog.model.SessionLoadMode
|
||||
import com.labfytools.trainlog.model.SessionSetDraft
|
||||
import com.labfytools.trainlog.model.TrackingMode
|
||||
import com.labfytools.trainlog.ui.theme.TrainlogTheme
|
||||
import java.security.MessageDigest
|
||||
import java.util.UUID
|
||||
|
|
@ -109,6 +123,106 @@ class TrainlogAppNavigationCallbackTest {
|
|||
assertArrayEquals(before, databaseDigest())
|
||||
}
|
||||
|
||||
@Test fun activeExerciseEditorCreatesEquipmentReturnsSelectsAndSaves() {
|
||||
val exercise = (repository.createExercise(
|
||||
NewExerciseProfile(
|
||||
"Goblet squat haltère",
|
||||
RecordingMode.SETS,
|
||||
TrackingMode.REPS,
|
||||
0,
|
||||
),
|
||||
) as CreateExerciseResult.Created).exercise
|
||||
val occurrence = SessionExerciseDraft(
|
||||
exercise = exercise,
|
||||
plan = SessionExercisePlan(
|
||||
sets = 3,
|
||||
reps = 10,
|
||||
weightKg = 8.0,
|
||||
loadMode = SessionLoadMode.EXTERNAL,
|
||||
restSeconds = 90,
|
||||
),
|
||||
)
|
||||
val before = SessionExerciseDraft(
|
||||
exercise = (repository.createExercise(
|
||||
NewExerciseProfile("Marche témoin", RecordingMode.CONTINUOUS, TrackingMode.DURATION, 0),
|
||||
) as CreateExerciseResult.Created).exercise,
|
||||
continuousDurationSeconds = 600,
|
||||
)
|
||||
val after = SessionExerciseDraft(
|
||||
exercise = (repository.createExercise(
|
||||
NewExerciseProfile("Presse témoin", RecordingMode.SETS, TrackingMode.REPS, 0),
|
||||
) as CreateExerciseResult.Created).exercise,
|
||||
sets = listOf(SessionSetDraft(reps = 12)),
|
||||
)
|
||||
repository.createCustomEquipment("ZZZ équipement témoin")
|
||||
assertEquals(
|
||||
ActiveDraftMutationResult.Saved,
|
||||
repository.saveActiveSessionDraft(
|
||||
ActiveSessionDraft(exercises = listOf(before, occurrence, after)),
|
||||
),
|
||||
)
|
||||
val editorState = TrainlogAppState().also {
|
||||
it.navigation.open(AppRoute.SessionEditor)
|
||||
}
|
||||
compose.setContent {
|
||||
TrainlogTheme {
|
||||
TrainlogApp(
|
||||
repository,
|
||||
SyncExporter(context, repository),
|
||||
SyncCatalogInbox(context, repository),
|
||||
SyncRequestOutbox(context),
|
||||
editorState,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithTag("edit-draft-exercise-${occurrence.entryId}")
|
||||
.performSemanticsAction(SemanticsActions.OnClick)
|
||||
compose.onNodeWithText("SAISIE — Goblet squat haltère")
|
||||
.performScrollTo()
|
||||
.assertIsDisplayed()
|
||||
compose.onNodeWithTag("session-equipment-results")
|
||||
.performScrollToNode(hasText("ZZZ équipement témoin"))
|
||||
compose.onNodeWithTag("session-create-equipment")
|
||||
.performSemanticsAction(SemanticsActions.OnClick)
|
||||
compose.waitForIdle()
|
||||
assertEquals(AppRoute.EquipmentCreate(AppRoute.SessionEditor), editorState.navigation.route)
|
||||
assertEquals(occurrence.entryId, editorState.pendingSessionEquipmentEntryId)
|
||||
|
||||
compose.onNodeWithTag("equipment-create-name").performTextInput("Haltère")
|
||||
compose.onNodeWithText("Créer")
|
||||
.performSemanticsAction(SemanticsActions.OnClick)
|
||||
compose.waitForIdle()
|
||||
|
||||
assertEquals(AppRoute.SessionEditor, editorState.navigation.route)
|
||||
compose.onNodeWithText("SAISIE — Goblet squat haltère")
|
||||
.performScrollTo()
|
||||
.assertIsDisplayed()
|
||||
compose.onNode(
|
||||
hasText("✓ Haltère") and hasText("Équipement sélectionné."),
|
||||
).performScrollTo().assertIsDisplayed()
|
||||
compose.onNodeWithTag("add-to-session")
|
||||
.performSemanticsAction(SemanticsActions.OnClick)
|
||||
compose.runOnIdle {
|
||||
val loaded = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
|
||||
assertEquals(3, loaded.draft.exercises.size)
|
||||
assertEquals(before, loaded.draft.exercises[0])
|
||||
assertEquals(occurrence.entryId, loaded.draft.exercises[1].entryId)
|
||||
assertTrue(loaded.draft.exercises[1].sets.isEmpty())
|
||||
assertEquals(3, loaded.draft.exercises[1].plan?.sets)
|
||||
assertEquals(10, loaded.draft.exercises[1].plan?.reps)
|
||||
assertEquals(90, loaded.draft.exercises[1].plan?.restSeconds)
|
||||
assertEquals(8.0, loaded.draft.exercises[1].plan?.weightKg)
|
||||
assertEquals(after, loaded.draft.exercises[2])
|
||||
assertEquals(
|
||||
"Haltère",
|
||||
repository.listEquipment().single {
|
||||
it.equipmentId == loaded.draft.exercises[1].equipmentId
|
||||
}.displayName,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun databaseDigest(): ByteArray = MessageDigest.getInstance("SHA-256")
|
||||
.digest(context.getDatabasePath(databaseName).readBytes())
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,23 @@
|
|||
# Android application
|
||||
|
||||
## Sleep Diary V1
|
||||
|
||||
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.
|
||||
|
||||
## Background USB and private Drive synchronization
|
||||
|
||||
SyncScreen and the explicitly enabled background service invoke one
|
||||
|
|
@ -23,7 +41,16 @@ Android schema v22 adds a pending manual-preparation store which is separate
|
|||
from AI proposals and the exactly-one active-session singleton. A received
|
||||
preparation is inert until explicit start; an occupied singleton is never
|
||||
overwritten. Starting preserves the desktop-reserved execution `session_id`,
|
||||
copies targets only and creates no performed facts.
|
||||
copies targets only and creates no performed facts. A Program-owned preparation
|
||||
also preserves `source_program_id` and `source_program_session_id` through the
|
||||
active draft and completed session. A legacy stored delivery may receive those
|
||||
two omitted fields later only when its revision, complete prior payload and
|
||||
reserved execution identity match exactly; this repairs provenance without
|
||||
altering performed workout facts.
|
||||
Acknowledged historical deliveries use the same exact-identity recovery. A
|
||||
local continuous duration target is validated during generation capture while
|
||||
the completed duration, speed and distance remain represented by the existing
|
||||
`continuous` history payload rather than an invented set target.
|
||||
|
||||
Android schema v23 adds the durable manual-preparation withdrawal ledger. The
|
||||
schema is unchanged by Web proposal withdrawal: the existing durable AI draft
|
||||
|
|
@ -866,8 +893,18 @@ decimal input is accepted (`12,5`); one value applies to all sets or values may
|
|||
be separated with `;`. Assisted equipment is explicitly labelled
|
||||
`Assistance (kg)`. Empty load remains distinct from an entered zero.
|
||||
|
||||
`Nouvelle machine` in that same selector creates a persistent local custom
|
||||
equipment entry with a generated stable `eq_…` ID and selects it immediately.
|
||||
`Créer un équipement` in that selector opens the existing catalogue creation
|
||||
route. An explicit successful creation returns automatically to the same
|
||||
exercise editor, restores the stable occurrence being edited, and selects the
|
||||
new persistent `eq_…` ID; the draft's other raw fields and target remain
|
||||
unchanged. No equipment is synthesized merely by opening the route.
|
||||
The selector presents every matching catalogue entry inside a bounded,
|
||||
independently scrollable result list; it does not truncate the selectable
|
||||
catalogue to its first rows. An existing target-only prepared occurrence may
|
||||
therefore change or create its equipment association and save in place while
|
||||
retaining its `entry_id`, plan and sibling occurrences. This operation does not
|
||||
fabricate a performed set; a partially entered performed row still follows the
|
||||
normal validation path and is never silently discarded.
|
||||
The shared bundled catalogue uses reserved canonical IDs. User-created IDs are
|
||||
exchanged first by definitions V1, so a V2 association can resolve them on the
|
||||
receiving side; conflicts remain explicit and are never converted to null.
|
||||
|
|
|
|||
|
|
@ -61,6 +61,14 @@ long-term history. Core owns data, persistence, business logic, calculations,
|
|||
read models, command services, and versioned contracts. An interface must not
|
||||
query tables directly to reconstruct its own business rules.
|
||||
|
||||
The Web Dashboard and Analyse workspace share `GET /api/v1/analysis`, backed by
|
||||
one C17 read model. Period filtering, active-Program progress, compatible
|
||||
exercise metrics, explicit MAX results, primary BODY ZONE exposures, and body
|
||||
measurement first/last/delta calculations occur in Core. React validates the
|
||||
bounded response and presents it; it does not load raw history or issue SQL.
|
||||
Absent values remain null, and the model never estimates physiological or
|
||||
performance scores.
|
||||
|
||||
Android local SQLite is a capture store, not a synchronization format.
|
||||
|
||||
## 2. Components
|
||||
|
|
|
|||
|
|
@ -1,20 +1,21 @@
|
|||
# Current implementation state
|
||||
|
||||
Full-generation Web synchronization now selects an actually usable MTP peer
|
||||
before USB, falls back to a separately configured private Drive generation
|
||||
namespace, and mirrors successful USB conversations to Drive on a best-effort
|
||||
basis without rolling back USB. Both transports move the same generation/ACK
|
||||
bytes; neither transports SQLite. Android has an explicitly enabled foreground
|
||||
USB listener with mandatory notification and a private-folder Drive grant with
|
||||
bounded WorkManager checks. Both use the same concurrency-guarded generation
|
||||
Full-generation synchronization uses one generation/ACK business engine across
|
||||
versioned transports. On the paired 0.1.4 deployment, automatic local selection
|
||||
is Bluetooth Classic RFCOMM first and direct USB/MTP second. The desktop BlueZ
|
||||
service accepts only the configured bonded Android address, Android proves the
|
||||
persisted Trainlog peer identity in the first bounded frame, and reconnect noise
|
||||
is rate-limited so proximity does not create repeated generations. MTP remains
|
||||
the wired recovery path. The separately configured private Drive transport
|
||||
remains available to its legacy mirror/fallback workflow. No transport copies
|
||||
SQLite.
|
||||
|
||||
Android synchronization paths share the same concurrency-guarded generation
|
||||
coordinator as SyncScreen. That coordinator has one process-wide ownership
|
||||
arbiter: explicit foreground intent has priority over automatic background
|
||||
resumption, while the foreground service may remain alive without owning the
|
||||
resumption, while a foreground service may remain alive without owning the
|
||||
conversation. An automatic run yields cooperatively at safe boundaries; its
|
||||
durable generation is retained for a correlated handoff rather than deleted.
|
||||
Background actionability distinguishes a new request or new correlated remote
|
||||
evidence from a merely stale resumable generation, preventing five-second
|
||||
terminal retry loops.
|
||||
|
||||
Direct-MTP publication now has an explicit Android visibility boundary.
|
||||
Durable immutable generation files are submitted to MediaProvider and all scan
|
||||
|
|
@ -45,23 +46,27 @@ and an accessible send action. Draft/local advances the same preparation to a
|
|||
ready revision before delivery; ready/local delivers the current revision.
|
||||
Completed history hides the active preparation exclusively through the
|
||||
delivery's stable execution-session identity and retains all provenance.
|
||||
Starting a delivered mixed preparation preserves continuous duration targets
|
||||
and set/repetition/load/rest targets in the active singleton without creating
|
||||
performed continuous activity, performed sets, MAX results, history, or
|
||||
feedback before explicit user capture.
|
||||
The active-session editor accepts target-only continuous and set-based
|
||||
occurrences: opening an existing stable entry renders its prefilled form,
|
||||
keeps the independent target intact on save, and leaves sibling occurrences
|
||||
unchanged on save or cancellation. A set-based target-only occurrence can add
|
||||
or change equipment without fabricating a performed set.
|
||||
|
||||
The opt-in full-generation path is operational on the private daily
|
||||
installation. Android explicit synchronization uses a dedicated coordination
|
||||
filename carrying the unchanged request-v1 payload. Legacy V3 signalling is
|
||||
additive and occurs only after a fresh strict V3 snapshot succeeds; daemon
|
||||
request identities are deduplicated across both bounded channels. The loopback
|
||||
Web control and foreground Android coordinator use
|
||||
the production direct-libmtp adapter, correlated manifest/ACK V1 objects, and
|
||||
persistent peer identities. Controlled USB and private Drive validation proved
|
||||
the complete Web-initiated request, generation, import, publication, and ACK
|
||||
conversation without opening SyncScreen for each run. USB remains preferred;
|
||||
the private `Trainlog/Sync/v1` namespace is configured as a verified mirror and
|
||||
fallback, independently from `Trainlog/AI`. Cross-transport replay is
|
||||
idempotent and neither transport carries SQLite.
|
||||
The full-generation path is operational on the paired daily installation.
|
||||
Bluetooth Classic is the primary local byte transport, MTP remains the wired
|
||||
recovery transport, and the existing private Drive namespace remains separately
|
||||
configured for its supported mirror/fallback path. All carry the same bounded
|
||||
generation/ACK artifacts and persistent peer identities; cross-transport replay
|
||||
is idempotent and no transport carries SQLite. The Bluetooth transport does not
|
||||
yet ingest heart-rate sensor data.
|
||||
|
||||
The public Android release chain advances from v0.1.1 versionCode 2 through
|
||||
v0.1.2 versionCode 3 to v0.1.3 versionCode 4 under certificate SHA-256
|
||||
v0.1.2 versionCode 3 and v0.1.3 versionCode 4 to v0.1.4 versionCode 5 under
|
||||
certificate SHA-256
|
||||
`5ef41117da107c9805ae3215c5ec823cff06216610e6e4f7542a754161c71bd2`.
|
||||
Higher-numbered private validation builds used a separate Android Debug
|
||||
certificate and are not members of the public update-signature chain.
|
||||
|
|
@ -76,7 +81,7 @@ The rollout packager now keeps the legacy request daemon and one-shot helper in
|
|||
the same relocatable, hashed bundle as the Web generation coordinator, avoiding
|
||||
mixed-checkout user-service execution during the controlled rollout.
|
||||
|
||||
Snapshot date: **2026-09-20**.
|
||||
Snapshot date: **2026-09-22**.
|
||||
|
||||
`TRAINLOG_CODE_READABILITY_V1` normalizes the recent generation-MTP,
|
||||
generation/ACK, Android-backup, Web Dashboard serialization, tests, doubles and
|
||||
|
|
@ -94,9 +99,9 @@ Android is the field companion: it captures training and body data, preserves
|
|||
the active draft, receives AI proposals, triggers synchronization, and shows
|
||||
quick summaries. The C17/Notcurses TUI is the administration, inspection,
|
||||
maintenance, import/export, and technical-tooling surface. The local Web
|
||||
sibling now provides its embedded application shell and implemented Dashboard;
|
||||
its future business surfaces will own broader analysis, program/session
|
||||
preparation, and exercise workflows through Trainlog Core.
|
||||
sibling provides its embedded application shell, Dashboard, Sessions,
|
||||
Programmes, Exercises, factual Analyse views, and Sleep Diary
|
||||
workflow through Trainlog Core.
|
||||
|
||||
Trainlog Core owns business truth. The desktop SQLite database remains the
|
||||
canonical local source of truth and long-term history; no interface owns a
|
||||
|
|
@ -107,11 +112,11 @@ parallel implementation of its rules.
|
|||
| Boundary | Current state |
|
||||
|---|---|
|
||||
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
|
||||
| Desktop SQLite | schema v28 |
|
||||
| Android SQLite | schema v25 |
|
||||
| Desktop SQLite | schema v29; Sleep Diary V1 is additive in v29 |
|
||||
| Android SQLite | schema v26; Sleep Diary V1 is additive in v26 |
|
||||
| Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport |
|
||||
| Desktop terminal backend | Notcurses only |
|
||||
| Trainlog product version | `0.1.3` stable, synchronized across Android and desktop; latest stable release: `v0.1.3` |
|
||||
| Trainlog product version | `0.1.4` stable, synchronized across Android and desktop; latest stable release: `v0.1.4` |
|
||||
| Interface language | `TRAINLOG_I18N_V0_1_1=PASS`: French default; English selectable in Settings → Language on both surfaces |
|
||||
| AI history export | `TRAINLOG_AI_EXPORT_V1` active |
|
||||
| AI session proposals | `TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING` |
|
||||
|
|
@ -129,6 +134,7 @@ parallel implementation of its rules.
|
|||
| Web Dashboard tiles | `WEB_DASHBOARD_TILES_V1=PASS/FROZEN` |
|
||||
| Web Dashboard visualizations | `WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` |
|
||||
| Web Dashboard V1 | `WEB_DASHBOARD_V1=PASS/FROZEN` |
|
||||
| Sleep Diary V1 | `SLEEP_DIARY_V1=PASS`: timestamped causal capture, Android/Web editing, full-generation synchronization, exact 18:00-to-18:00 agenda projection, medication snapshots, and localized FR/EN vector PDF export |
|
||||
| Web Sessions V1 | `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN` (controlled desktop/Android deployment validated) |
|
||||
| Web Sessions deletion and Programs V1 | `TRAINLOG_WEB_SESSIONS_DELETE_AND_PROGRAMS_V1=PASS` (private grouped rollout validated) |
|
||||
| Programs presentation, Android projection, and deletion | `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=PASS` (private coordinated deployment and restart/replay validated) |
|
||||
|
|
@ -138,17 +144,18 @@ parallel implementation of its rules.
|
|||
| Web sync API V1 | `TRAINLOG_WEB_SYNC_API_V1=PASS/FROZEN` |
|
||||
| Web sync button V1 | `TRAINLOG_WEB_SYNC_BUTTON_V1=PASS/FROZEN` |
|
||||
| Sync Web end-to-end wrapper | `TRAINLOG_SYNC_WEB_END_TO_END_V1=PASS/FROZEN` (isolated) |
|
||||
| Private full-generation rollout | `TRAINLOG_SYNC_FINALIZATION_AND_ROLLOUT_V1=PASS` (direct MTP, one authorized phone) |
|
||||
| Private full-generation rollout | `TRAINLOG_SYNC_FINALIZATION_AND_ROLLOUT_V1=PASS` (authenticated Bluetooth primary transport with direct MTP recovery on one authorized phone) |
|
||||
| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` |
|
||||
| Web Exercises V1 | `TRAINLOG_WEB_EXERCISES_V1=PASS` |
|
||||
| Current operational cursor | `TRAINLOG_ANDROID_UI_REDESIGN_V1_CONTRACT` |
|
||||
| Current operational cursor | `TRAINLOG_CARDIO_V1_NEXT_CONTRACT`: heart-rate sensor ingestion starts in 0.1.5 and remains distinct from the frozen Bluetooth synchronization transport |
|
||||
| Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered |
|
||||
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
|
||||
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |
|
||||
| Synchronization data/lifecycle slice | `TRAINLOG_SYNC_DATA_LIFECYCLE_V1=PASS/FROZEN` |
|
||||
| Synchronization causal deletion slice | `TRAINLOG_SYNC_CAUSAL_DELETE_V1=PASS/FROZEN` |
|
||||
| Synchronization generation/ACK slice | `TRAINLOG_SYNC_GENERATION_ACK_V1=PASS/FROZEN` (explicit staged entry points; active transport remains V3) |
|
||||
| Generation MTP and Android backup | Software complete and validated in the authorized private physical-device rollout |
|
||||
| Bluetooth synchronization transport | `TRAINLOG_SYNC_BLUETOOTH_TRANSPORT_V1=PASS/FROZEN`; independent from future heart-rate sensor ingestion |
|
||||
| Generation MTP and Android backup | Software complete and retained as the authorized wired recovery path |
|
||||
|
||||
Desktop and Android schema numbers are independent. Neither changes the frozen
|
||||
Trainlog JSON V1 contract.
|
||||
|
|
@ -228,6 +235,10 @@ Android currently provides:
|
|||
- exactly one durable active session draft with identity-preserving continuous
|
||||
multi-position drag reorder,
|
||||
resume, confirmed discard, and atomic completion;
|
||||
- complete scrollable equipment selection and caller-aware creation from an
|
||||
active occurrence editor, with the explicit catalogue-created ID selected on
|
||||
return and the occurrence identity, plan, raw form and siblings retained for
|
||||
in-place save;
|
||||
- completed-session consultation, transient drag reorder, and atomic factual or
|
||||
order correction while retaining session/occurrence/feedback identities for
|
||||
synchronization;
|
||||
|
|
@ -291,7 +302,24 @@ explicit proposal derivation and generation-backed Android delivery. The
|
|||
top-level `/programmes` route is an operational daily active-Program calendar;
|
||||
Sessions → Programmes remains the technical administration/import, list,
|
||||
detail, archive, and delete surface. `/exercices` is the operational desktop
|
||||
catalogue administration surface; Analyse remains an explicit placeholder.
|
||||
catalogue administration surface. `/analyse` is implemented with Overview,
|
||||
Exercises, Distribution, and Measurements sections over one bounded Core read
|
||||
model. Its global 7/30/90-day/all filter is applied in the backend; Dashboard
|
||||
uses the same 30-day calculations for activity, active-Program progress,
|
||||
exercise progression, measurements, and BODY ZONE links.
|
||||
|
||||
The Analyse API validates period, exercise identity, measurement metric and
|
||||
page, rejects unknown or duplicate parameters, and bounds exercises and chart
|
||||
points. Missing facts remain null. Exercise output respects persisted
|
||||
tracking/load modes, explicit MAX rows are never estimated, and duration,
|
||||
distance, speed, external load, and external volume appear only when their
|
||||
source observations support them. BODY ZONES are labelled as recorded primary
|
||||
zone exposures/associated sets rather than physiological percentages. All
|
||||
fourteen stored body metrics expose period-first, period-last, and an absolute
|
||||
delta only with at least two points. The route and Dashboard deep links have
|
||||
safe invalid-query fallbacks, French/English labels, keyboard controls, and
|
||||
responsive no-overflow layouts. Visual inspection remains pending before any
|
||||
freeze marker.
|
||||
|
||||
Exercises uses bounded Core-owned list/detail reads, canonical name
|
||||
normalization, BODY ZONES taxonomy, profiled creation/update commands and
|
||||
|
|
@ -377,10 +405,10 @@ The full Android lint comparison against an isolated clean `main` worktree is
|
|||
unchanged at ten pre-existing errors: one `NewApi`, one
|
||||
`LocalContextConfigurationRead`, and eight bilingual `StringFormatMatches`
|
||||
diagnostics. This UI tranche adds no lint error and changes no lint policy.
|
||||
The next private daily Android candidate remains on development
|
||||
`versionName=0.1.3`; only its strictly increasing `versionCode` may be supplied
|
||||
as a build-time override. No public tag or release is created by that private
|
||||
candidate process.
|
||||
The stable Android 0.1.4 source uses `versionName=0.1.4` and public
|
||||
versionCode 5. Private validation builds may still supply only a strictly
|
||||
increasing `versionCode` as a build-time override; that does not create a
|
||||
public release or change schemas and protocols.
|
||||
|
||||
The private Program execution rollout installed desktop schema v27 and the
|
||||
signed nondebuggable Android versionCode 17/schema v25 update without
|
||||
|
|
@ -399,6 +427,17 @@ ACK without duplicating the execution or resurrecting the Program. A final
|
|||
correlated exchange remained completed with no missing capability. The real
|
||||
Program remains active and unique with 24 sessions and 164 ordered entries.
|
||||
|
||||
Program-owned manual preparations now carry their stable Program provenance to
|
||||
Android. Starting and finalizing such a delivery retains both identities, and a
|
||||
strict same-delivery enrichment repairs completed sessions produced from the
|
||||
earlier omission without changing workout facts. Android and Web therefore
|
||||
derive the completed count and per-session state from the same execution
|
||||
identity after synchronization and restart.
|
||||
The historical S1 A delivery was recovered onto its original completed
|
||||
`session_id`; Android and Web now both report one completed session out of 24.
|
||||
Generation capture also accepts its valid local continuous duration plans
|
||||
without projecting them as unsupported set targets.
|
||||
|
||||
The private Programs rollout migrated desktop schema v25 to v26 and Android
|
||||
schema v23 to v24 after verified backups, then installed the signed
|
||||
non-debuggable versionCode 16 APK without uninstalling or clearing data. The
|
||||
|
|
@ -587,9 +626,8 @@ tests, Android debug assembly, and 211 Android tests (207 passed, four optional
|
|||
historical-fixture skips). Strict desktop builds use Clang 22.1.8; the compared
|
||||
pre-existing GCC 16.2.1 Web warning gate remains documented rather than green.
|
||||
|
||||
- `TRAINLOG_WEB_EXERCISES_V1=PASS`. The next planned work is
|
||||
`TRAINLOG_ANDROID_UI_REDESIGN_V1`; it remains unimplemented. Analyse remains
|
||||
a placeholder and is not a v0.1.3 priority.
|
||||
- `TRAINLOG_WEB_EXERCISES_V1=PASS`. `TRAINLOG_WEB_ANALYSIS_V1` is implemented
|
||||
on the 0.1.4 branch and awaits the requested Dashboard/Analyse visual review.
|
||||
- `APP_SHELL_V1` retains a recorded legacy visual/accessibility review gate; its
|
||||
observations are inputs to, not decisions for, the new 0.1.3 Android contract.
|
||||
- Session Generator V1 is hidden while V2 planning semantics are developed.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,38 @@
|
|||
# Desktop database
|
||||
|
||||
## 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 unreleased 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; `user_version` remains 29 and no
|
||||
existing row is rewritten.
|
||||
|
||||
Mutations advance `current_revision_id` only when the caller supplies the
|
||||
current parent. Logical deletion advances the revision and retains the
|
||||
tombstone. Qualities preserve `TB`, `B`, `Moy`, `M`, and `TM` exactly.
|
||||
|
||||
`sleep_diary_publication_state` separates the durable local tip from the tip
|
||||
explicitly validated for publication and the exact revision covered by a
|
||||
full-generation ACK. `sleep_diary_generation_entries` binds captured revisions
|
||||
to immutable generation identities. Consequently a new entry is a draft, a
|
||||
validated tip is ready, an ACKed tip is synchronized, and any later local tip
|
||||
is modified without rewriting its acknowledged ancestor. One stable entry is
|
||||
owned by each `night_start_date`; old drafts remain editable indefinitely.
|
||||
|
||||
The absolute timestamps are the future heart-rate correlation boundary. Sleep
|
||||
Diary V1 does not store, infer, or simulate heart-rate data.
|
||||
|
||||
## Schema v28: terminal Program execution provenance
|
||||
|
||||
Schema v28 extends `program_session_executions.state` with the terminal
|
||||
|
|
|
|||
196
docs/design/sync_bluetooth_transport_v1.md
Normal file
196
docs/design/sync_bluetooth_transport_v1.md
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
# Bluetooth synchronization transport V1
|
||||
|
||||
## Status
|
||||
|
||||
`TRAINLOG_SYNC_BLUETOOTH_TRANSPORT_V1=PASS/FROZEN`
|
||||
|
||||
This transport is additive. It does not change generation manifests, artifact
|
||||
formats, causal deletion, revision ancestry, acknowledgement semantics, SQLite
|
||||
schemas, or the frozen Trainlog session format.
|
||||
|
||||
## Product behavior
|
||||
|
||||
Normal synchronization is automatic when the authorized Android phone and
|
||||
desktop are within Bluetooth range.
|
||||
|
||||
1. Android's already user-enabled sync foreground service maintains a bonded
|
||||
Bluetooth Classic RFCOMM connection to the selected desktop.
|
||||
2. The desktop advertises one Trainlog-specific RFCOMM profile.
|
||||
3. On a fresh Bluetooth connection Android publishes one bounded
|
||||
full-generation request. Reconnect noise is rate-limited.
|
||||
4. The existing full-generation orchestrator performs the same bidirectional
|
||||
generation/ACK conversation through the Bluetooth byte transport.
|
||||
5. If Bluetooth is unavailable, the existing MTP transport remains the wired
|
||||
fallback.
|
||||
6. Google Drive is not a synchronization transport in the target configuration.
|
||||
After convergence, the desktop AI export is transferred to Android and
|
||||
Android publishes it to the user-selected `Trainlog/AI` Drive folder.
|
||||
Drive publication failure never rolls back a completed sync.
|
||||
|
||||
The manual Web and Android Synchroniser actions remain as recovery/forced-sync
|
||||
controls.
|
||||
|
||||
## Private live-device evidence — 2026-09-22
|
||||
|
||||
The authorized Samsung Android peer and ArchASP completed a physical Bluetooth
|
||||
Classic/RFCOMM synchronization without MTP or Drive as a sync transport.
|
||||
|
||||
- request: `sr_86ccc07f-cc5b-4506-86c1-06c00bd8b06a`;
|
||||
- run: `sy_df8f87fb-e4c1-44f5-b3f7-8fe87942d19e`;
|
||||
- inbound Android generation:
|
||||
`gen_9609d163-4a3a-4187-a806-84379a1bcf4c`;
|
||||
- outbound desktop generation:
|
||||
`gen_bc1d5e4b-4b16-49bc-b228-c3ff1f1a1705`;
|
||||
- final transport: `bt`;
|
||||
- final Bluetooth state: `success`;
|
||||
- final phase/result: `completed/completed`;
|
||||
- desktop SQLite integrity: `ok`;
|
||||
- post-sync AI export: `delivered_to_android`;
|
||||
- Android copy:
|
||||
`Documents/Trainlog/trainlog_ai_export_v1.json`, 66,971 bytes,
|
||||
SHA-256 `49f7f02fdeebd15699655e43fdb475bb20d9dd2d1bc59d2bdb4cd099ae50ecda`.
|
||||
|
||||
The initial legacy bond contained mismatched link-key state. The private device
|
||||
was explicitly re-paired; Android then reported the bond ready and established
|
||||
the Trainlog RFCOMM service. Android now also repairs a missing bond through the
|
||||
normal platform pairing flow before attempting RFCOMM.
|
||||
|
||||
The Android SAF destination `Trainlog/AI` was then selected explicitly by
|
||||
the user. A Web-triggered Bluetooth-only run completed immediately afterward:
|
||||
|
||||
- request: `sy_ab1869d3-7f25-497f-a338-97213ec08370`;
|
||||
- run: `sy_f3ab061f-b4f8-44e9-9911-c0ad3283b457`;
|
||||
- inbound Android generation:
|
||||
`gen_c74705f9-04dc-4ffe-9e67-6ae5bf08d1a2`;
|
||||
- outbound desktop generation:
|
||||
`gen_0b33d209-35f1-41c0-9f98-6adf843b2a5f`;
|
||||
- `transport=bt`, `bluetooth_state=success`;
|
||||
- both directions are durable `sqlite-commit-full`;
|
||||
- `ai_post_sync=delivered_to_android`;
|
||||
- desktop SQLite integrity: `ok`.
|
||||
|
||||
The resulting local AI export was 66,971 bytes with SHA-256
|
||||
`73ce8d3ca6cef3080ed2da77d58ef600bb79e6d2089f3472632b59aea76a8b90`.
|
||||
The exact Google Drive object in `Trainlog/AI/trainlog_ai_export_v1.json` was
|
||||
modified at `2026-09-22T14:50:11.875Z`, had the same 66,971-byte size, and
|
||||
the downloaded Drive object had the exact same SHA-256. This closes the
|
||||
Android-SAF publication proof without rclone.
|
||||
|
||||
## Trust and pairing
|
||||
|
||||
Bluetooth device names are presentation only and are never authority.
|
||||
|
||||
Desktop trusted configuration pins:
|
||||
|
||||
- Android sync `peer_id`;
|
||||
- bonded Android Bluetooth device address;
|
||||
- Trainlog RFCOMM service UUID.
|
||||
|
||||
Android persists the selected bonded desktop address after an explicit one-time
|
||||
selection. The RFCOMM service uses normal bonded-device authentication.
|
||||
|
||||
Current private deployment values are installation configuration, not protocol
|
||||
constants.
|
||||
|
||||
## RFCOMM service
|
||||
|
||||
Trainlog service UUID:
|
||||
|
||||
`f0d1c0de-7a11-4f62-9b7c-545241494e4c`
|
||||
|
||||
The desktop registers an `org.bluez.Profile1` server with BlueZ. Android is
|
||||
the RFCOMM client. This direction lets Android resolve the advertised service
|
||||
through SDP without hidden Android channel APIs.
|
||||
|
||||
The desktop Bluetooth agent is persistent and owns the BlueZ profile. Local
|
||||
sync workers never talk to BlueZ directly; a bounded local adapter talks to the
|
||||
agent over a mode-0600 Unix socket.
|
||||
|
||||
## Bluetooth byte envelope
|
||||
|
||||
Each Bluetooth frame is:
|
||||
|
||||
- 4-byte unsigned big-endian JSON header length;
|
||||
- UTF-8 JSON header (maximum 64 KiB);
|
||||
- optional payload whose length and SHA-256 are declared in the header.
|
||||
|
||||
Payload limit is 256 MiB and individual exchange files remain subject to their
|
||||
existing generation bounds. A receiver validates length and SHA-256 before
|
||||
making received bytes visible.
|
||||
|
||||
Initial frame:
|
||||
|
||||
```json
|
||||
{
|
||||
"format": "trainlog-bt-frame",
|
||||
"version": 1,
|
||||
"type": "hello",
|
||||
"peer_id": "peer_<uuid>",
|
||||
"protocol": "trainlog-bt-files-v1"
|
||||
}
|
||||
```
|
||||
|
||||
The local adapter exposes only `pull` and `push`, matching the existing MTP
|
||||
adapter boundary. Transfers use a bounded ZIP archive containing relative
|
||||
exchange paths. Extraction rejects absolute paths, `..`, symbolic links,
|
||||
duplicates and excess file/byte counts. Each destination is replaced from a
|
||||
same-directory temporary after complete bytes are durable.
|
||||
|
||||
## Pull ownership
|
||||
|
||||
Android pull snapshots include only Android-owned/current coordination objects:
|
||||
|
||||
- `android-peer-v1.json`;
|
||||
- `trainlog-sync-full-generation-request-v1.json`;
|
||||
- `trainlog-sync-request-v1.json`;
|
||||
- `android-generation-v1.json`;
|
||||
- `android-consumption-ack-v1.json`;
|
||||
- `android-generation-error-v1.json`;
|
||||
- the immutable generation referenced by `android-generation-v1.json`.
|
||||
|
||||
Desktop-owned coordination files are never treated as Android source
|
||||
authority.
|
||||
|
||||
## Push ownership
|
||||
|
||||
Desktop workers already construct phase-specific disposable outboxes. Bluetooth
|
||||
push accepts exactly that bounded outbox and atomically publishes those files
|
||||
under Android's canonical `Documents/Trainlog` directory.
|
||||
|
||||
## Concurrency
|
||||
|
||||
Bluetooth and MTP have independent physical transport locks. The common
|
||||
business `sync.lock` still serializes synchronization conversations.
|
||||
|
||||
A background discovery probe must never own `sync.lock`. A Web-triggered
|
||||
conversation must never fail merely because an idle transport probe is in
|
||||
progress.
|
||||
|
||||
## Automatic arrival trigger
|
||||
|
||||
A successful RFCOMM connection is an arrival event. Android publishes at most
|
||||
one automatic request per connection and persists a short reconnect cooldown.
|
||||
The same connection is not a repeating timer; no connection means no automatic
|
||||
request.
|
||||
|
||||
A later optimization may compare explicit dirty revisions before generating a
|
||||
request, but V1 correctness does not depend on such an optimization.
|
||||
|
||||
## MTP fallback
|
||||
|
||||
MTP remains supported and keeps its current fail-closed semantics. Automatic
|
||||
desktop selection is:
|
||||
|
||||
`Bluetooth -> MTP -> device_unavailable`
|
||||
|
||||
Drive is never an implicit fallback in the target configuration.
|
||||
|
||||
## AI export publication
|
||||
|
||||
The desktop remains the canonical producer of
|
||||
`trainlog_ai_export_v1.json`. After a completed bidirectional sync it sends
|
||||
the exact export bytes to Android. Android publishes/replaces the export in the
|
||||
persisted SAF tree selected for `Trainlog/AI`.
|
||||
|
||||
The export has its own pending/success state. A Drive provider error is not a
|
||||
sync error and is retried later by Android.
|
||||
|
|
@ -6,6 +6,7 @@ than later development. GitHub and Forgejo publication must preserve the
|
|||
existing release, tag, status, and assets while keeping their note bodies equal
|
||||
to the corresponding source file.
|
||||
|
||||
- [Trainlog v0.1.4](v0.1.4.md)
|
||||
- [Trainlog v0.1.3](v0.1.3.md)
|
||||
- [Trainlog v0.1.2](v0.1.2.md)
|
||||
- [Trainlog v0.1.1](v0.1.1.md)
|
||||
|
|
|
|||
57
docs/releases/v0.1.4.md
Normal file
57
docs/releases/v0.1.4.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
Trainlog v0.1.4 extends the local-first training workflow with factual
|
||||
analysis, a synchronized Sleep Diary, improved prepared-session execution, and
|
||||
an authenticated Bluetooth synchronization transport.
|
||||
|
||||
## Analyse and Dashboard
|
||||
|
||||
- The local Web Analyse workspace exposes factual 7/30/90-day and all-history
|
||||
views through Trainlog Core.
|
||||
- Activity, active-Program progress, compatible exercise metrics, persisted MAX
|
||||
results, BODY ZONE exposure, and body measurements share the bounded Core
|
||||
read model used by Dashboard summaries.
|
||||
- Body-measurement trends are charted from persisted observations, and the next
|
||||
session projection refreshes after synchronization.
|
||||
- Analyse deliberately does not invent readiness, fatigue, recovery, body-fat,
|
||||
or estimated performance values.
|
||||
|
||||
## Sleep Diary
|
||||
|
||||
- Sleep Diary V1 is available across desktop schema v29 and Android schema v26.
|
||||
- Entries use absolute timestamped events, causal revisions, appreciations,
|
||||
notes, medication catalog entries, and timestamped intake snapshots.
|
||||
- The Web agenda uses one exact 18:00-to-18:00 projection and follows the latest
|
||||
locally persisted revision without leaking another selected night.
|
||||
- Local vector PDF reports are available in French and English.
|
||||
- Sleep Diary participates in the full-generation synchronization and
|
||||
acknowledgement flow.
|
||||
|
||||
## Android session execution
|
||||
|
||||
- Prepared continuous-duration exercises remain planning-only until actual work
|
||||
is explicitly recorded.
|
||||
- Target-only continuous and set-based occurrences can be edited without
|
||||
fabricating performed activity or duplicate occurrences.
|
||||
- Equipment can be created and selected from the active-session editor while
|
||||
preserving the current durable draft.
|
||||
- Program provenance is preserved through preparation, Android execution,
|
||||
completion, synchronization, and progress display, including safe recovery
|
||||
of compatible historical deliveries.
|
||||
|
||||
## Bluetooth synchronization
|
||||
|
||||
- The paired deployment adds Bluetooth Classic RFCOMM as the primary local
|
||||
Trainlog synchronization transport and retains direct MTP as wired recovery.
|
||||
- The desktop accepts only the configured bonded Android device, and Android
|
||||
proves the persisted Trainlog peer identity before exchanging bounded frames.
|
||||
- Reconnect noise is rate-limited and does not create repeated generations.
|
||||
- This Bluetooth feature transports Trainlog synchronization data only.
|
||||
Heart-rate sensor ingestion is intentionally deferred to the 0.1.5 cardio
|
||||
cycle.
|
||||
|
||||
## Assets
|
||||
|
||||
- `trainlog-android-v0.1.4.apk`: signed public Android APK.
|
||||
- `trainlog-tui-linux-x86_64-v0.1.4`: Linux x86-64 runtime bundle containing
|
||||
the TUI, local Web adapter, synchronization helpers, Bluetooth transport
|
||||
helper, and required catalogs.
|
||||
- `SHA256SUMS`: SHA-256 checksums for both artifacts.
|
||||
BIN
docs/reviews/evidence/sleep-diary-alignment-firefox.png
Normal file
BIN
docs/reviews/evidence/sleep-diary-alignment-firefox.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 139 KiB |
BIN
docs/reviews/evidence/sleep-diary-empty-night-firefox.png
Normal file
BIN
docs/reviews/evidence/sleep-diary-empty-night-firefox.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
387
docs/reviews/evidence/sleep-diary-fr-7-days.pdf
Normal file
387
docs/reviews/evidence/sleep-diary-fr-7-days.pdf
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
%PDF-1.4
|
||||
%Trainlog
|
||||
1 0 obj
|
||||
<< /Type /Catalog /Pages 2 0 R >>
|
||||
endobj
|
||||
2 0 obj
|
||||
<< /Type /Pages /Kids [4 0 R] /Count 1 >>
|
||||
endobj
|
||||
3 0 obj
|
||||
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>
|
||||
endobj
|
||||
4 0 obj
|
||||
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 842 595] /Resources << /Font << /F1 3 0 R >> >> /Contents 5 0 R >>
|
||||
endobj
|
||||
5 0 obj
|
||||
<< /Length 17659 >>
|
||||
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 (20/09/2026) Tj ET
|
||||
0 g BT /F1 5 Tf 33.0 515.0 Td (au 21/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 | Nuit de co) Tj ET
|
||||
0 g BT /F1 4 Tf 730.0 482.0 Td (ntr\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
|
||||
18059
|
||||
%%EOF
|
||||
BIN
docs/reviews/evidence/sleep-diary-fr-7-days.png
Normal file
BIN
docs/reviews/evidence/sleep-diary-fr-7-days.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 123 KiB |
BIN
docs/reviews/evidence/sleep-diary-live-agenda-firefox.png
Normal file
BIN
docs/reviews/evidence/sleep-diary-live-agenda-firefox.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 148 KiB |
BIN
docs/reviews/evidence/sleep-diary-v1-final-firefox.png
Normal file
BIN
docs/reviews/evidence/sleep-diary-v1-final-firefox.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 315 KiB |
|
|
@ -0,0 +1,32 @@
|
|||
# Sleep Diary active-night and Agenda geometry evidence
|
||||
|
||||
Date: 2026-09-21
|
||||
|
||||
Branch: `trainlog-0.1.4-sleep-diary-v1`
|
||||
|
||||
## Scope
|
||||
|
||||
The Web Sleep Diary uses a single timestamp projection from 18:00 on the
|
||||
selected start date to 18:00 on the following date. Hour ticks, interval edges,
|
||||
point events and grouped medication markers use that same rendered timeline
|
||||
box. The editor clears date-dependent state immediately when the selected
|
||||
night changes and applies an asynchronous diary response only while its request
|
||||
identity and requested date still match the active night.
|
||||
|
||||
The local editor retains user-facing Draft, Day validated and Modified states.
|
||||
Synchronization transport state remains available to the existing backend
|
||||
contracts but is not presented as a per-night action or status.
|
||||
|
||||
## Retained Firefox evidence
|
||||
|
||||
- [Populated aligned night](evidence/sleep-diary-alignment-firefox.png): a
|
||||
synthetic night with events at 22:30, 03:30, 04:45 and 15:30. Browser
|
||||
assertions compare the rendered marker/interval coordinates with the shared
|
||||
axis at a maximum tolerance of one pixel.
|
||||
- [Empty selected night](evidence/sleep-diary-empty-night-firefox.png): after
|
||||
changing dates without reload, the Agenda, chronology, appreciations,
|
||||
medication intakes, notes and factual summary contain no data from the
|
||||
preceding night. No empty entry is persisted by viewing the date.
|
||||
|
||||
The same scenario returns to the populated night without reload and verifies
|
||||
that its persisted events reappear.
|
||||
113
docs/reviews/sleep_diary_capture_ux_v1_evidence.md
Normal file
113
docs/reviews/sleep_diary_capture_ux_v1_evidence.md
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
# Sleep Diary capture UX and black-screen correction evidence
|
||||
|
||||
## Scope
|
||||
|
||||
This record covers the focused 0.1.4 Web correction and presentation tranche.
|
||||
It does not change Sleep Diary domain semantics, desktop schema v29, Android
|
||||
schema v26, synchronization, publication lifecycle, PDF semantics or any
|
||||
frozen exchange format.
|
||||
|
||||
## Runtime diagnosis
|
||||
|
||||
The deployed bundle was run through `http://trainlog.perf/analyse?section=sleep`
|
||||
in Firefox 156 with an isolated synthetic XDG data directory. The browser
|
||||
reported `isSecureContext === false` and did not expose
|
||||
`crypto.randomUUID`. Clicking **Ajouter** for a bedtime event raised:
|
||||
|
||||
```text
|
||||
TypeError: crypto.randomUUID is not a function
|
||||
```
|
||||
|
||||
The stack entered the `addEvent` state updater in
|
||||
`web/src/routes/SleepDiaryWorkspace.tsx`. React then unmounted the application
|
||||
tree, leaving an empty body. The router remained on the Sleep Analyse URL.
|
||||
There was no mutation request, HTTP status or response body because the
|
||||
exception happened while constructing the optimistic event. No unhandled
|
||||
promise rejection was present. Medication intake identity construction used
|
||||
the same unsupported API and had the same latent failure.
|
||||
|
||||
## Correction
|
||||
|
||||
Sleep event and medication-intake identities now use `crypto.getRandomValues`,
|
||||
set the UUIDv4 version and RFC 4122 variant bits explicitly, and never fall back
|
||||
to `Math.random`. A section-local React error boundary keeps the Trainlog shell,
|
||||
header and navigation mounted and offers a retry card for unexpected future
|
||||
render errors. This boundary is defense in depth; it is not the black-screen
|
||||
fix.
|
||||
|
||||
The capture presentation now uses the existing Trainlog panel, quiet-action,
|
||||
primary-action and danger-action vocabulary. Dates, lifecycle badge and local
|
||||
save state form one night header. Morning/day appreciations, event creation,
|
||||
timeline, intake creation, notes and actions have explicit hierarchy. The
|
||||
medication catalog is a compact list whose creation form opens on demand and
|
||||
whose saved item becomes immediately selectable for an intake.
|
||||
|
||||
## Validation evidence
|
||||
|
||||
- Web TypeScript, all Vitest tests and the Vite production build pass.
|
||||
- Component regressions cover bedtime, sleep, long awakening, final get-up,
|
||||
nap and sleepiness, stable entry identity, advanced revisions, medication
|
||||
creation, intake creation, edit and delete.
|
||||
- Real Firefox against the production C server created two medications, two
|
||||
intakes and five events, then reloaded the same durable entry and revision.
|
||||
- Every captured mutation returned HTTP 200 with its expected JSON identity or
|
||||
revision response. The console error and unhandled-rejection collections
|
||||
remained empty.
|
||||
- The responsive layout had no document-level horizontal overflow.
|
||||
- The retained post-reload screenshot is
|
||||
[`evidence/sleep-diary-v1-final-firefox.png`](evidence/sleep-diary-v1-final-firefox.png).
|
||||
|
||||
## Private deployment evidence
|
||||
|
||||
The functional bundle was built from
|
||||
`915728cec4ffbb2c90eb3f4b47d349a6ebd17fe9`. Its embedded desktop binary has
|
||||
SHA-256 `e45c2fd8807dd9c28128206ee57e2869b64726397ae6072d521a903d3d4afdc9`.
|
||||
Before the switch, the live schema-v29 database was backed up as
|
||||
`trainlog-before-915728c97f420f824c7831024d61ee9c08645321.db`, SHA-256
|
||||
`9579e1365164a082e328c4c5bc1ffc7b45bf234a1f5bf1812842837c8ecc05f7`;
|
||||
its integrity check was `ok` and its foreign-key check was empty.
|
||||
|
||||
The versioned installation is
|
||||
`~/.local/share/trainlog/installations/915728cec4ffbb2c90eb3f4b47d349a6ebd17fe9`.
|
||||
After the atomic user-bin switch and service restart,
|
||||
`trainlog-web.service` was active as PID `2322898`, and
|
||||
`/proc/2322898/exe` resolved to that installation's `libexec/trainlog`.
|
||||
The deployed executable and footer both reported version `0.1.4`.
|
||||
|
||||
Real Firefox 156 then used the actual deployed profile to create `TestMed A`
|
||||
5 mg and `TestMed B` 25 mg intakes at 22:00, bedtime at 22:30, sleep from
|
||||
23:00 to 03:00, a long awakening from 03:00 to 03:30, sleep from 03:30 to
|
||||
06:30 and final get-up at 07:00. Reload retained entry
|
||||
`sl_71b9c7a5-cabe-4d22-86fc-bde8a34590f9` at revision
|
||||
`slr_c1e8f641-4db7-43f1-8a9c-e7ef15b2c1df`, with five events and two
|
||||
intakes. All nine mutations returned HTTP 200. Browser console errors,
|
||||
network fetch errors and unhandled rejections were all zero. The exact
|
||||
390×844 viewport had zero document overflow. No main merge, tag or release
|
||||
was created.
|
||||
|
||||
## Live persisted Agenda follow-up
|
||||
|
||||
The Agenda now consumes the same canonical entry revision returned by Core as
|
||||
the Chronology, factual summary, PDF and synchronization surfaces. After a
|
||||
successful mutation the existing persistence queue re-reads the diary, finds
|
||||
the exact returned `entry_id` and `revision_id`, and only then marks the entry
|
||||
as locally saved. Request sequencing prevents an older GET from replacing a
|
||||
newer projection, while the serialized mutation queue supplies each returned
|
||||
revision to the next write.
|
||||
|
||||
The compact row derives bedtime, final get-up, declared sleep, time in bed,
|
||||
long-awakening totals, naps and sleepiness directly from the canonical event
|
||||
list. Medication intakes at one timestamp use one grouped marker and retain
|
||||
their factual dose snapshots. Catalog and selector labels use stable
|
||||
medication identities and display localized usual dose/unit, so
|
||||
same-name/different-dose records remain independently selectable.
|
||||
|
||||
The isolated real-Firefox scenario observed, without reload, two venlafaxine
|
||||
intakes at 22:00 (75 mg and 37.5 mg), bedtime at 22:45, sleep from 23:15 to
|
||||
03:00, a long awakening from 03:00 to 03:30, sleep from 03:30 to 06:45 and
|
||||
final get-up at 07:10. The Agenda immediately reported 7 h 00 min declared
|
||||
sleep, 8 h 25 min in bed, one 30-minute long awakening and one grouped `M ×2`
|
||||
marker. All captured requests returned HTTP 200, browser error and unhandled
|
||||
rejection collectors stayed empty, and reload reproduced the same Agenda row.
|
||||
The retained capture is
|
||||
[`evidence/sleep-diary-live-agenda-firefox.png`](evidence/sleep-diary-live-agenda-firefox.png).
|
||||
42
docs/reviews/sleep_diary_pdf_localization_v1_evidence.md
Normal file
42
docs/reviews/sleep_diary_pdf_localization_v1_evidence.md
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# Sleep Diary PDF localization evidence
|
||||
|
||||
## Scope
|
||||
|
||||
This focused 0.1.4 correction changes only the presentation and encoding of the
|
||||
local vector Sleep Diary PDF. It does not change Sleep Diary event codes,
|
||||
timestamps, revisions, schemas, synchronization, publication or Android.
|
||||
|
||||
## French localization
|
||||
|
||||
The French PDF owns a complete vocabulary for column headings, timeline
|
||||
ranges, point markers, legend, observations, draft warning and plurals.
|
||||
`QUALITÉ DU RÉVEIL` remains an appreciation column; `Long réveil` identifies
|
||||
the distinct temporal interval. Dates render as two localized lines such as
|
||||
`20/09/2026` and `au 21/09/2026`. WinAnsi PDF strings preserve French accents
|
||||
in rendering and text extraction.
|
||||
|
||||
The factual summary uses human-readable durations and correct singular/plural
|
||||
forms. Medication rows keep the actual intake timestamp, name, localized dose
|
||||
and unit; same-name intakes with different doses remain separate snapshots.
|
||||
The English generator uses an independent English vocabulary.
|
||||
|
||||
## Long-awakening geometry
|
||||
|
||||
The regression fixture contains sleep from 23:00 to 03:00, a long awakening
|
||||
from 03:00 to 03:45 and sleep from 03:45 to 07:00. The generated content draws
|
||||
three ordered, non-overlapping proportional rectangles. The 45-minute interval
|
||||
uses a distinct dark fill and visibly interrupts the two lighter sleep ranges;
|
||||
the French legend names it `Long réveil`, never `AWAKE`.
|
||||
|
||||
## Extraction and visual evidence
|
||||
|
||||
Real Firefox exported a seven-night French PDF through the embedded production
|
||||
Web bundle and an isolated synthetic database. `pdftotext` confirms the French
|
||||
headings, legend, observations, pluralized seven-day warning and absence of the
|
||||
forbidden English labels. `pdfinfo` reports one unencrypted A4 landscape PDF
|
||||
1.4 page without JavaScript. `pdftoppm` produced the retained raster used for
|
||||
visual inspection of headings, rows, temporal intervals, medications,
|
||||
observations, legend and pagination.
|
||||
|
||||
- [Seven-night French PDF](evidence/sleep-diary-fr-7-days.pdf)
|
||||
- [Rasterized first page](evidence/sleep-diary-fr-7-days.png)
|
||||
|
|
@ -5,9 +5,10 @@ physical libmtp, verified backups, signing continuity, migrations, correlated
|
|||
ACKs, restart, and idempotent replay passed. Generation mode remains an
|
||||
explicit per-installation opt-in. The additive private Drive transport and
|
||||
Android background software are implemented and validated with fake transport
|
||||
and the private production namespace. USB remains preferred, Drive is the
|
||||
configured mirror/fallback, and Android background operation retains its
|
||||
documented post-reboot platform limits.
|
||||
and the private production namespace. Stable v0.1.4 adds authenticated
|
||||
Bluetooth Classic RFCOMM as the primary paired local transport and retains direct MTP as wired recovery; Drive
|
||||
remains a separately configured supported path. Android background operation
|
||||
retains its documented post-reboot platform limits.
|
||||
|
||||
This document owns future work. The implemented baseline is summarized in
|
||||
[current_state.md](current_state.md); completed narratives and evidence belong
|
||||
|
|
@ -23,19 +24,20 @@ Android executes in the field, captures actual work/feedback, and returns data.
|
|||
```
|
||||
|
||||
Future work must preserve the frozen Trainlog JSON V1 contract, stable
|
||||
identities, actual-versus-planned separation, direct MTP transport, and the
|
||||
distinction between measured values and estimates.
|
||||
identities, actual-versus-planned separation, the frozen synchronization
|
||||
transport boundaries, and the distinction between measured values and
|
||||
estimates.
|
||||
|
||||
## Current baseline
|
||||
|
||||
Stable v0.1.2 is the release baseline for the v0.1.3 development cycle.
|
||||
Desktop schema v28, Android schema v25, Notcurses, direct `Documents/Trainlog`
|
||||
Stable v0.1.4 is the release baseline for subsequent development.
|
||||
Desktop schema v29, Android schema v26, Notcurses, direct `Documents/Trainlog`
|
||||
storage, mobile export V3, Training Knowledge V1, Body Zones V1, Training
|
||||
Feedback V1/V2, STATS V1, and Session Generator V1 are implemented. Session
|
||||
Generator V1 is hidden pending V2. AI session-draft exchange retains its
|
||||
explicit manual validation gate. APP_SHELL_V1 remains useful historical design
|
||||
evidence, but its September 2026 observations must be revalidated against the
|
||||
v0.1.3 baseline rather than treated as a current implementation plan.
|
||||
v0.1.4 baseline rather than treated as a current implementation plan.
|
||||
`TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`; its local-only
|
||||
CLI/HTTP infrastructure, embedded frontend shell and the frozen Dashboard data
|
||||
contract, frozen interactive grid, versioned layout persistence and factual
|
||||
|
|
@ -43,9 +45,9 @@ tile rendering exist. The Dashboard's bounded progression and BODY ZONES
|
|||
visualizations and Sessions V1 are implemented. The top-level `/programmes`
|
||||
route is an operational daily active-Program calendar, while
|
||||
Sessions → Programmes remains the technical administration/import, list,
|
||||
detail, archive, and delete surface. Web Exercises V1 is implemented; Analyse
|
||||
remains a placeholder outside this cycle unless a later explicit decision
|
||||
changes that order.
|
||||
detail, archive, and delete surface. Web Exercises V1, the factual
|
||||
Dashboard/Analyse read-model foundation, and Sleep Diary V1 are included in
|
||||
stable v0.1.4.
|
||||
The corrective prepared-item projection, bounded phase-owned MTP outbox,
|
||||
Sessions presentation correction, local date preference and durable preparation
|
||||
withdrawal are implemented. The withdrawal uses the separate versioned
|
||||
|
|
@ -54,12 +56,17 @@ mobile snapshot or `TRAINLOG_FORMAT_V1` contracts.
|
|||
|
||||
## Current cursor
|
||||
|
||||
The v0.1.3 development cursor proceeds from the closed stable v0.1.2 baseline
|
||||
and its validated Web, Program execution, USB/Drive generation, ACK, and
|
||||
causal-deletion contracts without reopening those completed gates.
|
||||
Stable v0.1.4 closes the Analyse/Sleep Diary cycle and freezes the Bluetooth
|
||||
Classic synchronization transport independently from future sensor work.
|
||||
|
||||
The next development cycle is 0.1.5 cardio. Android will own heart-rate sensor
|
||||
discovery/acquisition and timestamped local capture; synchronized desktop/Web
|
||||
surfaces will consume recorded measurements and will not connect directly to
|
||||
the sensor. Heart-rate ingestion must not reuse or overload the frozen Trainlog
|
||||
Bluetooth synchronization transport contract.
|
||||
|
||||
```text
|
||||
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_ANDROID_UI_REDESIGN_V1_CONTRACT
|
||||
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_CARDIO_V1_NEXT_CONTRACT
|
||||
```
|
||||
|
||||
The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this
|
||||
|
|
|
|||
|
|
@ -1,6 +1,70 @@
|
|||
# Synchronization exchange
|
||||
|
||||
## USB-priority and private Drive transport
|
||||
## Sleep Diary companion
|
||||
|
||||
`trainlog-sleep-diary` version 1 is finalized in stable v0.1.4 with both the
|
||||
medication catalog and the entry-owned intake snapshots.
|
||||
The artifact preserves catalog and diary causal parents, rejects concurrent
|
||||
siblings and resurrection, and treats an exact revision replay as idempotent.
|
||||
This completion happened before publication or freezing of the companion;
|
||||
`TRAINLOG_FORMAT_V1` remains unchanged and frozen.
|
||||
|
||||
Every field edit commits locally as a new immutable diary revision but does
|
||||
not itself enter a generation. Explicit day validation marks only the guarded
|
||||
current tip publishable and starts no transport. Generation capture exports
|
||||
validated current tips, records each exact generation/revision relation, and
|
||||
only a correlated consumed ACK advances the acknowledged marker. A correction
|
||||
after ACK therefore remains local and appears as modified until it is
|
||||
revalidated and acknowledged by a later generation. Imported tips are already
|
||||
validated and acknowledged because their containing generation was durably
|
||||
consumed. Draft and synchronized state are not inferred from wall-clock time.
|
||||
|
||||
`trainlog-sleep-diary`, version 1, is emitted by full generations in stable
|
||||
v0.1.4. It is optional when consuming an older generation so a pre-domain
|
||||
artifact remains replayable. It carries bounded complete entry snapshots with
|
||||
stable identities, the current immutable revision and causal parent,
|
||||
offset-bearing timestamps, appreciations, notes and logical deletion. Exact
|
||||
revision replay is unchanged; sibling revisions and resurrection after deletion
|
||||
are rejected in the generation consumption transaction.
|
||||
|
||||
This does not change `TRAINLOG_FORMAT_V1`, manifests, ACKs or causal
|
||||
ownership.
|
||||
|
||||
## Bluetooth-priority transport and Android AI publication
|
||||
|
||||
The private 0.1.4 installation now adds Bluetooth Classic RFCOMM as the primary
|
||||
byte transport without changing the full-generation business protocol. The
|
||||
desktop registers the Trainlog-specific service UUID
|
||||
`f0d1c0de-7a11-4f62-9b7c-545241494e4c` through BlueZ and accepts only the
|
||||
configured bonded Android device address. Android connects as the RFCOMM
|
||||
client, then proves the already persisted Trainlog `peer_id` in the first
|
||||
bounded frame. Bluetooth name strings are never authority.
|
||||
|
||||
Automatic transport selection is `Bluetooth -> MTP -> unavailable` for the
|
||||
current target configuration. MTP remains the wired recovery transport. The
|
||||
business `sync.lock` remains common while Bluetooth and MTP have independent
|
||||
physical transport locks, so an idle discovery probe cannot reject a Web
|
||||
synchronization.
|
||||
|
||||
Entering Bluetooth range establishes one connection and publishes one bounded
|
||||
Android full-generation request. Reconnect noise is rate-limited; a persistent
|
||||
connection is not a timer that creates generations repeatedly. The existing
|
||||
background generation coordinator still owns capture, validation, causal
|
||||
consumption and durable ACKs.
|
||||
|
||||
After both generation directions are durably acknowledged, desktop generates
|
||||
the canonical `trainlog_ai_export_v1.json` with the existing read-only AI
|
||||
exporter and best-effort delivers that file to Android over the selected local
|
||||
transport. Android stores it in `Documents/Trainlog`, then a separate SAF
|
||||
publisher replaces the same file in the user-selected `Trainlog/AI` Google
|
||||
Drive folder and verifies the provider bytes by size and SHA-256. Drive
|
||||
publication failure is retryable and never rolls back or fails an already
|
||||
completed synchronization. Desktop rclone is not part of this target path.
|
||||
|
||||
The detailed framing, trust and fallback contract is
|
||||
[Bluetooth synchronization transport V1](design/sync_bluetooth_transport_v1.md).
|
||||
|
||||
## Legacy USB-priority and private Drive transport
|
||||
|
||||
Full-generation synchronization has one business engine and two byte
|
||||
transports. Automatic desktop admission acquires the existing global lock,
|
||||
|
|
@ -41,7 +105,11 @@ full run, generation, peer, hash and ACK envelope and never starts a workout.
|
|||
`trainlog-session-preparations` V2 is a separate optional desktop-to-Android
|
||||
generation artifact. It carries immutable ready revision snapshots and stable
|
||||
delivery, preparation, occurrence and reserved execution identities, plus
|
||||
immutable preparation-withdrawal operations. It never
|
||||
immutable preparation-withdrawal operations. Program-owned deliveries also
|
||||
carry the paired stable Program and Program-session identities. Android keeps
|
||||
reading legacy deliveries that omitted this pair and accepts a later enrichment
|
||||
only when all legacy fields and the reserved execution identity are identical.
|
||||
It never
|
||||
overloads mobile export, AI proposals or execution-drafts V1. Android validates
|
||||
the complete artifact in the generation transaction and stores it as pending;
|
||||
start remains an explicit foreground action. Desktop records the exact
|
||||
|
|
@ -100,6 +168,7 @@ mode retain their explicit compatibility rejection instead of losing its unit.
|
|||
## 1. Status
|
||||
|
||||
```text
|
||||
TRAINLOG_SYNC_BLUETOOTH_TRANSPORT_V1=PASS/FROZEN
|
||||
DIRECT_MTP_TRANSPORT=PASS
|
||||
ANDROID_TO_PC_IMPORT=PASS
|
||||
PC_TO_ANDROID_CATALOG=PASS
|
||||
|
|
@ -216,6 +285,16 @@ is idempotent; identity reuse or regression is a hard conflict. Because the
|
|||
descriptor is optional, peers predating this companion keep exchanging all
|
||||
existing required domains without a capability failure.
|
||||
|
||||
For Program-owned manual preparations, an acknowledged delivery is republished
|
||||
while its matching canonical execution evidence is absent on the PC. This
|
||||
bounded recovery lets Android enrich legacy deliveries with Program provenance
|
||||
and stops automatically when the execution returns through this companion.
|
||||
The recovery emission retains the delivery's original acknowledged generation
|
||||
relation and requires exact delivery, revision, execution and Program
|
||||
identities. A current-state Sleep Diary snapshot may initialize a fresh peer
|
||||
from a non-root current revision while retaining the unavailable parent
|
||||
identity for subsequent causal advances.
|
||||
|
||||
Desktop schema v28 adds terminal `deleted` provenance for a completed session
|
||||
removed through causal history deletion. A retained older
|
||||
`program-executions-v1` fact is skipped only when the matching session has a
|
||||
|
|
|
|||
|
|
@ -1,5 +1,35 @@
|
|||
# Tests and validation
|
||||
|
||||
Sleep Diary coverage includes schema creation/migration, midnight and DST
|
||||
offsets, revision conflict, logical deletion/reopen, companion replay and
|
||||
rollback, Web periods/deep links, Android persistence, and vector PDF
|
||||
construction/pagination for 7, 14, 21 and 30 synthetic days.
|
||||
Lifecycle regressions additionally cover durable drafts excluded from export,
|
||||
guarded validation, reopen, post-validation correction, exact
|
||||
generation-to-revision capture and ACK-only acknowledgement.
|
||||
Web component regressions add each point/interval capture type, verify that a
|
||||
successful mutation rerenders without losing the workspace, preserve one entry
|
||||
identity while advancing revisions, and exercise medication creation, immediate
|
||||
intake availability, event/intake editing and deletion. The UUID regression
|
||||
removes `crypto.randomUUID()` and proves the HTTP-origin-compatible UUIDv4
|
||||
path. A real Firefox run through the production C server and `trainlog.perf`
|
||||
creates events, medications and intakes, reloads their durable state, checks a
|
||||
responsive viewport and captures console errors and unhandled rejections.
|
||||
Program recovery coverage starts from an acknowledged legacy preparation,
|
||||
repairs the same completed Android `session_id`, publishes one completed
|
||||
execution and verifies Web `1 / 24` presentation. Related regressions cover
|
||||
planned continuous activity capture and fresh-peer Sleep Diary bootstrap from
|
||||
non-root current revisions.
|
||||
|
||||
`web_analysis` covers empty history, every 7/30/90/all period selector,
|
||||
loaded and unloaded repetitions, continuous duration/distance/speed, explicit
|
||||
persisted MAX, multiple primary BODY ZONES, partial measurements, one- and
|
||||
multiple-point measurement semantics, and active-Program projection. The HTTP
|
||||
suite exercises the real bounded endpoint plus invalid and duplicate query
|
||||
parameters. Vitest validates the typed parser, null preservation, route and
|
||||
section changes, period/exercise/metric selection, invalid deep-link fallback,
|
||||
critical French/English labels, and Dashboard layout regressions.
|
||||
|
||||
`test_sync_drive_transport` proves Drive artifact-before-manifest and
|
||||
manifest-before-reference publication, rejects unsafe/AI namespaces, and keeps
|
||||
a partial generation invisible. Orchestrator coverage proves that Drive mirror
|
||||
|
|
@ -105,6 +135,13 @@ through Program deletion, restart and old-generation replay without duplication
|
|||
or resurrection. The desktop populated migration test now continues through
|
||||
schema v27 and proves the new execution ledger starts empty.
|
||||
|
||||
The delivered-Program regression covers the previously omitted provenance
|
||||
pair end to end: legacy preparation receipt, start, performed capture,
|
||||
finalization, exact-payload enrichment, completed Android projection, execution
|
||||
companion output and restart. Export tests assert both stable IDs, the C Web API
|
||||
asserts `completed`, and the Web component verifies `1/24`, `Effectuée` and the
|
||||
absence of a preparation action on the completed session.
|
||||
|
||||
`schema_v28_migration` migrates a populated v27 fixture, preserves Program,
|
||||
session and execution identities byte-for-byte, admits the terminal deleted
|
||||
state, reports `integrity_check=ok` and an empty foreign-key check, and proves
|
||||
|
|
@ -224,6 +261,27 @@ clients, CSRF/Origin rejection and `trainlog.perf` Origin acceptance. Vitest
|
|||
covers default/persisted/invalid sources, ETag/token parsing, canonical-only
|
||||
PUT, conflict translation, loading, local cancel/reset and durable Save.
|
||||
|
||||
Sleep Diary Web tests cover canonical snapshot adoption after every successful
|
||||
autosave, ordered revision chaining, rejection of stale asynchronous reads,
|
||||
and live Agenda updates for bedtime, final get-up, split sleep, time in bed,
|
||||
long awakenings, naps, sleepiness and grouped medication intakes. They also
|
||||
cover exact 18:00-to-18:00 timestamp geometry, active-night isolation, empty
|
||||
night state, return navigation and rejection of an earlier date response that
|
||||
finishes late. Localized dose/unit labels, same-name medications with different
|
||||
usual doses and editable intake-dose prefill remain covered.
|
||||
`test_web_sleep_browser.py` exercises the embedded production bundle in real
|
||||
Firefox against an isolated synthetic database, observes every mutation
|
||||
without reload, verifies HTTP success and browser error collectors, measures
|
||||
ticks, point markers and intervals within one rendered pixel, switches to an
|
||||
empty night and back, then reloads and compares the persisted Agenda.
|
||||
The PDF suite separately asserts complete French and English vocabularies,
|
||||
accent-preserving WinAnsi text, human-readable duration and plural forms,
|
||||
localized dates, medication dose snapshots, pagination and the exact ordered
|
||||
geometry of sleep / 45-minute long-awakening / sleep intervals. The same real
|
||||
Firefox scenario exports a seven-night French PDF from the embedded production
|
||||
bundle, extracts its text with `pdftotext` and rasterizes its A4 landscape page
|
||||
with `pdftoppm` for retained visual inspection.
|
||||
|
||||
`WEB_DASHBOARD_TILES_V1` adds strict TypeScript parsing for every consumed
|
||||
field and Vitest fixtures confined to test sources. Coverage proves the 90-day
|
||||
activity map and direct totals, all compact/medium/large disclosure levels,
|
||||
|
|
@ -656,10 +714,10 @@ tagged source state, and the tag, Android `versionName`, Meson project version,
|
|||
TUI product version, asset names, and release title must agree.
|
||||
|
||||
Private update rollout may set `TRAINLOG_ANDROID_VERSION_CODE` to a positive
|
||||
integer above the installed package while retaining the current development
|
||||
`versionName=0.1.3`. The version-code override is build-only and does not change
|
||||
any schema or exchange-format version. Private daily candidates must not
|
||||
downgrade the logical product version to the earlier 0.1.2 stable line.
|
||||
integer above the installed package while retaining the tagged product
|
||||
`versionName`. The version-code override is build-only and does not change any
|
||||
schema or exchange-format version. Private daily candidates must never
|
||||
downgrade the logical product version.
|
||||
|
||||
For `TRAINLOG_UI_ANDROID_WEB_UNIFICATION_V1`, `lintDebug` was also run from a
|
||||
detached clean worktree at the exact `main` baseline and from the mission
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
project(
|
||||
'trainlog',
|
||||
'c',
|
||||
version: '0.1.3',
|
||||
version: '0.1.4',
|
||||
meson_version: '>=1.3.0',
|
||||
default_options: [
|
||||
'c_std=c17',
|
||||
|
|
|
|||
|
|
@ -95,6 +95,54 @@ class GenerationArchiveTest(unittest.TestCase):
|
|||
with sqlite3.connect(self.database) as database:
|
||||
self.assertEqual(0, database.execute("SELECT COUNT(*) FROM sync_generation_archives").fetchone()[0])
|
||||
|
||||
def test_historical_acknowledged_rows_without_ack_ledger_do_not_exhaust_admission(self):
|
||||
with sqlite3.connect(self.database) as database:
|
||||
producer = generation.peer_identity(database, "desktop")
|
||||
database.commit()
|
||||
parent = None
|
||||
for index in range(generation.MAX_RETAINED_PER_PEER):
|
||||
suffix = f"{index + 1:012d}"
|
||||
generation_id = f"gen_10000000-0000-4000-8000-{suffix}"
|
||||
run_id = f"sy_20000000-0000-4000-8000-{suffix}"
|
||||
database.execute(
|
||||
"INSERT INTO sync_generations VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
generation_id,
|
||||
run_id,
|
||||
producer,
|
||||
PEER,
|
||||
"desktop",
|
||||
f"2026-09-16T00:00:{index:02d}+00:00",
|
||||
parent,
|
||||
"0" * 64,
|
||||
"acknowledged",
|
||||
"{}",
|
||||
str(self.owned / "staging" / generation_id),
|
||||
f"2026-09-16T00:01:{index:02d}+00:00",
|
||||
),
|
||||
)
|
||||
parent = generation_id
|
||||
database.commit()
|
||||
|
||||
stage, manifest, _ = generation.capture_desktop(self.database, self.owned, PEER)
|
||||
self.assertTrue(stage.is_dir())
|
||||
self.assertEqual(parent, manifest["parent_generation_id"])
|
||||
with sqlite3.connect(self.database) as database:
|
||||
self.assertEqual(
|
||||
0,
|
||||
database.execute(
|
||||
"SELECT COUNT(*) FROM sync_acknowledgements "
|
||||
"WHERE generation_id LIKE 'gen_10000000-%'"
|
||||
).fetchone()[0],
|
||||
)
|
||||
self.assertEqual(
|
||||
0,
|
||||
database.execute(
|
||||
"SELECT COUNT(*) FROM sync_generation_archives "
|
||||
"WHERE generation_id LIKE 'gen_10000000-%'"
|
||||
).fetchone()[0],
|
||||
)
|
||||
|
||||
def test_missing_then_late_ack_is_protected_until_correlated(self):
|
||||
values = self.create_acknowledged()
|
||||
stage, manifest, digest = generation.capture_desktop(self.database, self.owned, PEER)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ class SessionPreparationExportTest(unittest.TestCase):
|
|||
current_revision_id TEXT NOT NULL,
|
||||
source_proposal_id TEXT,
|
||||
source_payload_sha256 TEXT,
|
||||
source_program_id TEXT,
|
||||
source_program_session_id TEXT,
|
||||
withdrawn_at TEXT
|
||||
);
|
||||
CREATE TABLE session_preparation_revisions(
|
||||
|
|
@ -56,6 +58,13 @@ class SessionPreparationExportTest(unittest.TestCase):
|
|||
state TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE program_session_executions(
|
||||
program_session_id TEXT PRIMARY KEY,
|
||||
program_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL UNIQUE,
|
||||
state TEXT NOT NULL,
|
||||
observed_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE session_preparation_withdrawals(
|
||||
withdrawal_id TEXT PRIMARY KEY,
|
||||
preparation_id TEXT NOT NULL,
|
||||
|
|
@ -77,12 +86,14 @@ class SessionPreparationExportTest(unittest.TestCase):
|
|||
delivery = f"spd_{suffix}"
|
||||
execution = f"se_{suffix}"
|
||||
self.database.execute(
|
||||
"INSERT INTO session_preparations VALUES(?,?,?,?,?)",
|
||||
"INSERT INTO session_preparations VALUES(?,?,?,?,?,?,?)",
|
||||
(
|
||||
preparation,
|
||||
revision,
|
||||
"aid_11111111-1111-4111-8111-111111111111",
|
||||
"a" * 64,
|
||||
"pg_11111111-1111-4111-8111-111111111111",
|
||||
"pgs_22222222-2222-4222-8222-222222222222",
|
||||
"2026-09-18T12:00:00Z" if withdrawn else None,
|
||||
),
|
||||
)
|
||||
|
|
@ -120,6 +131,14 @@ class SessionPreparationExportTest(unittest.TestCase):
|
|||
self.assertEqual("trainlog-session-preparations", payload["format"])
|
||||
self.assertEqual(2, payload["version"])
|
||||
self.assertEqual([f"spd_{kept}"], [row["delivery_id"] for row in payload["deliveries"]])
|
||||
self.assertEqual(
|
||||
"pg_11111111-1111-4111-8111-111111111111",
|
||||
payload["deliveries"][0]["source_program_id"],
|
||||
)
|
||||
self.assertEqual(
|
||||
"pgs_22222222-2222-4222-8222-222222222222",
|
||||
payload["deliveries"][0]["source_program_session_id"],
|
||||
)
|
||||
self.assertEqual(1, len(payload["withdrawals"]))
|
||||
removal = payload["withdrawals"][0]
|
||||
self.assertEqual(f"spw_{withdrawn}", removal["withdrawal_id"])
|
||||
|
|
@ -147,6 +166,35 @@ class SessionPreparationExportTest(unittest.TestCase):
|
|||
self.assertEqual([], payload["deliveries"])
|
||||
self.assertEqual([], payload["withdrawals"])
|
||||
|
||||
def test_acknowledged_program_delivery_is_republished_until_execution_returns(self):
|
||||
suffix = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"
|
||||
self.add_preparation(suffix, withdrawn=False)
|
||||
self.database.execute(
|
||||
"UPDATE session_preparation_deliveries SET state='acknowledged'"
|
||||
)
|
||||
self.database.commit()
|
||||
|
||||
payload = export_session_preparations.build_export(self.database)
|
||||
|
||||
self.assertEqual([f"spd_{suffix}"], [row["delivery_id"] for row in payload["deliveries"]])
|
||||
self.assertEqual("remote_unknown", payload["deliveries"][0]["state"])
|
||||
|
||||
self.database.execute(
|
||||
"INSERT INTO program_session_executions VALUES(?,?,?,?,?)",
|
||||
(
|
||||
"pgs_22222222-2222-4222-8222-222222222222",
|
||||
"pg_11111111-1111-4111-8111-111111111111",
|
||||
f"se_{suffix}",
|
||||
"completed",
|
||||
"2026-09-18T12:10:00Z",
|
||||
),
|
||||
)
|
||||
self.database.commit()
|
||||
|
||||
payload = export_session_preparations.build_export(self.database)
|
||||
|
||||
self.assertEqual([], payload["deliveries"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
161
tests/test_sleep_diary_exchange.py
Normal file
161
tests/test_sleep_diary_exchange.py
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
#!/usr/bin/env python3
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
import sleep_diary_exchange as exchange
|
||||
|
||||
|
||||
def entry(revision, parent=None, deleted=False):
|
||||
return {"entry_id": "sl_10000000-0000-4000-8000-000000000001",
|
||||
"night_start_date": "2026-10-24", "night_end_date": "2026-10-25",
|
||||
"created_at": "2026-10-24T22:00:00+02:00", "updated_at": "2026-10-25T18:00:00+01:00",
|
||||
"revision_id": revision, "parent_revision_id": parent, "deleted": deleted,
|
||||
"sleep_quality": "B", "wake_quality": "Moy", "day_form": "TB",
|
||||
"treatment_and_notes": "Synthetic fixture", "events": [
|
||||
{"event_id": "sle_30000000-0000-4000-8000-000000000003", "type": "sleep",
|
||||
"start_at": "2026-10-24T23:30:00+02:00", "end_at": "2026-10-25T06:30:00+01:00"}],
|
||||
"intakes": [{"intake_id": "mdi_70000000-0000-4000-8000-000000000007",
|
||||
"medication_id": "med_80000000-0000-4000-8000-000000000008",
|
||||
"medication_name": "Synthetic medication", "taken_at": "2026-10-25T05:15:00+01:00",
|
||||
"dose_value": 10, "dose_unit": "mg", "note": "", "created_at": "2026-10-24T22:00:00+02:00"}]}
|
||||
|
||||
|
||||
class SleepExchangeTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.db = sqlite3.connect(":memory:")
|
||||
self.db.executescript("""
|
||||
CREATE TABLE sleep_diary_entries(entry_id TEXT PRIMARY KEY,night_start_date TEXT,night_end_date TEXT,created_at TEXT,updated_at TEXT,current_revision_id TEXT,deleted INTEGER);
|
||||
CREATE TABLE sleep_diary_revisions(revision_id TEXT PRIMARY KEY,entry_id TEXT,parent_revision_id TEXT,created_at TEXT,sleep_quality TEXT,wake_quality TEXT,day_form TEXT,treatment_and_notes TEXT);
|
||||
CREATE TABLE sleep_diary_events(revision_id TEXT,event_id TEXT,event_type TEXT,start_at TEXT,end_at TEXT,PRIMARY KEY(revision_id,event_id));
|
||||
CREATE TABLE sleep_medications(medication_id TEXT PRIMARY KEY,created_at TEXT,updated_at TEXT,current_revision_id TEXT,deleted INTEGER);
|
||||
CREATE TABLE sleep_medication_revisions(revision_id TEXT PRIMARY KEY,medication_id TEXT,parent_revision_id TEXT,created_at TEXT,name TEXT,default_dose_value REAL,default_dose_unit TEXT,form TEXT,note TEXT,active INTEGER);
|
||||
CREATE TABLE sleep_medication_intakes(revision_id TEXT,intake_id TEXT,medication_id TEXT,medication_name TEXT,taken_at TEXT,dose_value REAL,dose_unit TEXT,note TEXT,created_at TEXT,PRIMARY KEY(revision_id,intake_id));
|
||||
CREATE TABLE sleep_diary_publication_state(entry_id TEXT PRIMARY KEY,validated_revision_id TEXT,validated_at TEXT,acknowledged_revision_id TEXT,acknowledged_at TEXT);
|
||||
""")
|
||||
|
||||
def document(self, item):
|
||||
medication = {"medication_id": "med_80000000-0000-4000-8000-000000000008",
|
||||
"revision_id": "medr_90000000-0000-4000-8000-000000000009", "parent_revision_id": None,
|
||||
"created_at": "2026-10-24T18:00:00+02:00", "updated_at": "2026-10-24T18:00:00+02:00",
|
||||
"name": "Synthetic medication", "default_dose_value": 5, "default_dose_unit": "mg",
|
||||
"form": "tablet", "note": "", "active": True, "deleted": False}
|
||||
return {"format": exchange.FORMAT, "version": 1, "generated_at": "2026-10-25T18:00:00+01:00", "entries": [item], "medications": [medication]}
|
||||
|
||||
def test_roundtrip_replay_conflict_and_non_resurrection(self):
|
||||
first = "slr_20000000-0000-4000-8000-000000000002"
|
||||
second = "slr_40000000-0000-4000-8000-000000000004"
|
||||
self.assertEqual((1, 0), exchange.apply(self.db, self.document(entry(first))))
|
||||
self.assertEqual((0, 1), exchange.apply(self.db, self.document(entry(first))))
|
||||
self.assertEqual((1, 0), exchange.apply(self.db, self.document(entry(second, first, True))))
|
||||
with self.assertRaisesRegex(ValueError, "resurrect"):
|
||||
exchange.apply(self.db, self.document(entry("slr_50000000-0000-4000-8000-000000000005", second, False)))
|
||||
exported = exchange.build(self.db)
|
||||
self.assertTrue(exported["entries"][0]["deleted"])
|
||||
|
||||
def test_stale_ancestor_snapshot_does_not_regress_newer_tip(self):
|
||||
first = "slr_20000000-0000-4000-8000-000000000002"
|
||||
second = "slr_40000000-0000-4000-8000-000000000004"
|
||||
first_document = self.document(entry(first))
|
||||
second_document = self.document(entry(second, first))
|
||||
second_medication = second_document["medications"][0]
|
||||
second_medication["parent_revision_id"] = first_document["medications"][0]["revision_id"]
|
||||
second_medication["revision_id"] = "medr_a0000000-0000-4000-8000-00000000000a"
|
||||
second_medication["updated_at"] = "2026-10-25T18:05:00+01:00"
|
||||
second_medication["name"] = "Synthetic medication revised"
|
||||
|
||||
self.assertEqual((1, 0), exchange.apply(self.db, first_document))
|
||||
self.assertEqual((1, 0), exchange.apply(self.db, second_document))
|
||||
self.assertEqual((0, 1), exchange.apply(self.db, first_document))
|
||||
self.assertEqual(
|
||||
second,
|
||||
self.db.execute(
|
||||
"SELECT current_revision_id FROM sleep_diary_entries WHERE entry_id=?",
|
||||
(first_document["entries"][0]["entry_id"],),
|
||||
).fetchone()[0],
|
||||
)
|
||||
self.assertEqual(
|
||||
second_medication["revision_id"],
|
||||
self.db.execute(
|
||||
"SELECT current_revision_id FROM sleep_medications WHERE medication_id=?",
|
||||
(second_medication["medication_id"],),
|
||||
).fetchone()[0],
|
||||
)
|
||||
|
||||
def test_ancestry_allows_multi_hop_remote_advance_but_not_sibling(self):
|
||||
first = "slr_20000000-0000-4000-8000-000000000002"
|
||||
middle = "slr_30000000-0000-4000-8000-000000000003"
|
||||
latest = "slr_40000000-0000-4000-8000-000000000004"
|
||||
first_document = self.document(entry(first))
|
||||
latest_document = self.document(entry(latest, middle))
|
||||
latest_document["entries"][0]["ancestry"] = [latest, middle, first]
|
||||
|
||||
medication_first = first_document["medications"][0]["revision_id"]
|
||||
medication_middle = "medr_a0000000-0000-4000-8000-00000000000a"
|
||||
medication_latest = "medr_b0000000-0000-4000-8000-00000000000b"
|
||||
medication = latest_document["medications"][0]
|
||||
medication["revision_id"] = medication_latest
|
||||
medication["parent_revision_id"] = medication_middle
|
||||
medication["ancestry"] = [medication_latest, medication_middle, medication_first]
|
||||
medication["updated_at"] = "2026-10-25T18:05:00+01:00"
|
||||
medication["name"] = "Synthetic medication revised"
|
||||
|
||||
self.assertEqual((1, 0), exchange.apply(self.db, first_document))
|
||||
self.assertEqual((1, 0), exchange.apply(self.db, latest_document))
|
||||
self.assertEqual(
|
||||
latest,
|
||||
self.db.execute(
|
||||
"SELECT current_revision_id FROM sleep_diary_entries WHERE entry_id=?",
|
||||
(first_document["entries"][0]["entry_id"],),
|
||||
).fetchone()[0],
|
||||
)
|
||||
self.assertEqual(
|
||||
medication_latest,
|
||||
self.db.execute(
|
||||
"SELECT current_revision_id FROM sleep_medications WHERE medication_id=?",
|
||||
(medication["medication_id"],),
|
||||
).fetchone()[0],
|
||||
)
|
||||
|
||||
sibling = self.document(
|
||||
entry("slr_50000000-0000-4000-8000-000000000005", first)
|
||||
)
|
||||
sibling["entries"][0]["ancestry"] = [
|
||||
sibling["entries"][0]["revision_id"],
|
||||
first,
|
||||
]
|
||||
with self.assertRaisesRegex(ValueError, "concurrent"):
|
||||
exchange.apply(self.db, sibling)
|
||||
|
||||
def test_rejects_concurrent_sibling_and_negative_absolute_interval(self):
|
||||
first = "slr_20000000-0000-4000-8000-000000000002"
|
||||
self.assertEqual((1, 0), exchange.apply(self.db, self.document(entry(first))))
|
||||
with self.assertRaisesRegex(ValueError, "concurrent"):
|
||||
exchange.apply(self.db, self.document(entry("slr_50000000-0000-4000-8000-000000000005", None)))
|
||||
invalid = entry("slr_60000000-0000-4000-8000-000000000006", first)
|
||||
invalid["events"][0]["end_at"] = "2026-10-24T22:30:00+02:00"
|
||||
with self.assertRaisesRegex(ValueError, "positive"):
|
||||
exchange.validate(self.document(invalid))
|
||||
|
||||
def test_draft_is_durable_but_not_exported_until_validated(self):
|
||||
revision = "slr_20000000-0000-4000-8000-000000000002"
|
||||
item = entry(revision)
|
||||
self.db.execute("INSERT INTO sleep_diary_entries VALUES(?,?,?,?,?,?,?)",
|
||||
(item["entry_id"], item["night_start_date"], item["night_end_date"],
|
||||
item["created_at"], item["updated_at"], revision, 0))
|
||||
self.db.execute("INSERT INTO sleep_diary_revisions VALUES(?,?,?,?,?,?,?,?)",
|
||||
(revision, item["entry_id"], None, item["updated_at"], "B", "Moy", "TB",
|
||||
item["treatment_and_notes"]))
|
||||
self.assertEqual([], exchange.build(self.db)["entries"])
|
||||
self.db.execute("INSERT INTO sleep_diary_publication_state VALUES(?,?,?,NULL,NULL)",
|
||||
(item["entry_id"], revision, item["updated_at"]))
|
||||
self.assertEqual(revision, exchange.build(self.db)["entries"][0]["revision_id"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
129
tests/test_sync_bluetooth_transport.py
Normal file
129
tests/test_sync_bluetooth_transport.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression coverage for the bounded Trainlog Bluetooth byte transport."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def load(name: str, path: Path):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
adapter = load(
|
||||
"trainlog_generation_bt_adapter",
|
||||
ROOT / "tools/trainlog_generation_bt_adapter.py",
|
||||
)
|
||||
agent = load("trainlog_bt_agent", ROOT / "tools/trainlog_bt_agent.py")
|
||||
|
||||
|
||||
class BluetoothTransportTest(unittest.TestCase):
|
||||
def test_archive_roundtrip_preserves_bounded_relative_files(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
source = root / "source"
|
||||
destination = root / "destination"
|
||||
(source / "android-objects/generations/gen_test").mkdir(parents=True)
|
||||
(source / "android-peer-v1.json").write_text('{"peer":"ok"}')
|
||||
artifact = source / "android-objects/generations/gen_test/artifact.json"
|
||||
artifact.write_text('{"value":1}')
|
||||
raw = adapter.build_archive(source)
|
||||
adapter.extract_archive(raw, destination)
|
||||
self.assertEqual('{"peer":"ok"}', (destination / "android-peer-v1.json").read_text())
|
||||
self.assertEqual('{"value":1}', (destination / artifact.relative_to(source)).read_text())
|
||||
|
||||
def test_archive_rejects_parent_traversal(self):
|
||||
payload = io.BytesIO()
|
||||
with zipfile.ZipFile(payload, "w") as archive:
|
||||
archive.writestr("../escape.json", "{}")
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
with self.assertRaises(adapter.AdapterError):
|
||||
adapter.extract_archive(payload.getvalue(), Path(directory))
|
||||
|
||||
def test_agent_hello_and_pull_are_peer_correlated(self):
|
||||
peer = "peer_11111111-1111-4111-8111-111111111111"
|
||||
state = agent.ConnectionState("AA:BB:CC:DD:EE:FF", peer)
|
||||
desktop, android = socket.socketpair()
|
||||
payload = b"bounded-archive"
|
||||
observed = []
|
||||
|
||||
def phone():
|
||||
try:
|
||||
agent.send_frame(
|
||||
android,
|
||||
{
|
||||
"type": "hello",
|
||||
"protocol": agent.FILE_PROTOCOL,
|
||||
"peer_id": peer,
|
||||
},
|
||||
)
|
||||
hello, hello_payload = agent.recv_frame(android)
|
||||
observed.append((hello, hello_payload))
|
||||
request, request_payload = agent.recv_frame(android)
|
||||
observed.append((request, request_payload))
|
||||
agent.send_frame(
|
||||
android,
|
||||
{
|
||||
"type": "result",
|
||||
"request_id": request["request_id"],
|
||||
"result": "ok",
|
||||
},
|
||||
payload,
|
||||
)
|
||||
finally:
|
||||
android.close()
|
||||
|
||||
thread = threading.Thread(target=phone)
|
||||
thread.start()
|
||||
state.install(desktop)
|
||||
header, response = state.transact("pull", peer, b"")
|
||||
thread.join(timeout=5)
|
||||
self.assertFalse(thread.is_alive())
|
||||
self.assertEqual("ok", header["result"])
|
||||
self.assertEqual(payload, response)
|
||||
self.assertEqual("hello_ack", observed[0][0]["type"])
|
||||
self.assertEqual(b"", observed[0][1])
|
||||
self.assertEqual("pull", observed[1][0]["type"])
|
||||
self.assertEqual(b"", observed[1][1])
|
||||
state.close()
|
||||
|
||||
def test_config_pins_expected_bluetooth_address(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "config.json"
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"format": "trainlog-sync-orchestrator-config",
|
||||
"version": 3,
|
||||
"enabled": True,
|
||||
"mode": "auto",
|
||||
"expected_peer_id": "peer_11111111-1111-4111-8111-111111111111",
|
||||
"transport_root": "/tmp/transport",
|
||||
"owned_root": "/tmp/owned",
|
||||
"timeout_seconds": 30,
|
||||
"bluetooth_enabled": True,
|
||||
"bluetooth_device_address": "aa:bb:cc:dd:ee:ff",
|
||||
}
|
||||
)
|
||||
)
|
||||
value = agent.load_config(path)
|
||||
self.assertEqual("AA:BB:CC:DD:EE:FF", value["bluetooth_device_address"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -65,16 +65,24 @@ class DeploymentToolsTest(unittest.TestCase):
|
|||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
inventory = json.loads((output / "candidate-inventory.json").read_text())
|
||||
self.assertEqual(inventory["product_version"], "0.1.3")
|
||||
self.assertEqual(inventory["desktop_schema"], 28)
|
||||
self.assertEqual(inventory["android_schema"], 25)
|
||||
self.assertEqual(inventory["product_version"], "0.1.4")
|
||||
self.assertEqual(inventory["desktop_schema"], 29)
|
||||
self.assertEqual(inventory["android_schema"], 26)
|
||||
self.assertIn("session-preparations-v2", inventory["protocols"])
|
||||
self.assertIn("programs-v1", inventory["protocols"])
|
||||
self.assertIn("program-executions-v1", inventory["protocols"])
|
||||
self.assertIn("sleep-diary-v1", inventory["protocols"])
|
||||
self.assertIn("bluetooth-files-v1", inventory["protocols"])
|
||||
self.assertTrue((output / "bin/trainlog").is_file())
|
||||
self.assertTrue((output / "bin/trainlog-sync-once").is_file())
|
||||
self.assertTrue((output / "bin/trainlog-syncd").is_file())
|
||||
self.assertTrue((output / "bin/trainlog-btd").is_file())
|
||||
self.assertTrue((output / "libexec/trainlog-sync-once").is_file())
|
||||
self.assertTrue((output / "libexec/trainlog-generation-bt-adapter").is_file())
|
||||
self.assertIn(
|
||||
'TRAINLOG_SYNC_BT_ADAPTER="$root/libexec/trainlog-generation-bt-adapter"',
|
||||
(output / "bin/trainlog-syncd").read_text(),
|
||||
)
|
||||
self.assertIn(
|
||||
'TRAINLOG_SYNC_MTP_ADAPTER="$root/libexec/trainlog-generation-mtp-adapter"',
|
||||
(output / "bin/trainlog-syncd").read_text(),
|
||||
|
|
@ -88,7 +96,7 @@ class DeploymentToolsTest(unittest.TestCase):
|
|||
text=True,
|
||||
)
|
||||
self.assertEqual(linked.returncode, 0, linked.stderr)
|
||||
self.assertIn("trainlog 0.1.3", linked.stdout)
|
||||
self.assertIn("trainlog 0.1.4", linked.stdout)
|
||||
imported = subprocess.run(
|
||||
[
|
||||
"python3",
|
||||
|
|
|
|||
|
|
@ -129,18 +129,21 @@ class SyncErrorClassificationTest(unittest.TestCase):
|
|||
|
||||
def test_transport_timeout_has_a_stable_code_and_bounded_diagnostic(self):
|
||||
timeout = subprocess.TimeoutExpired(["adapter", "push"], 30)
|
||||
with mock.patch.object(sync_peer_worker.subprocess, "run", side_effect=timeout):
|
||||
with self.assertRaisesRegex(
|
||||
RuntimeError,
|
||||
r"^transport_timeout: MTP push did not finish within 30 seconds$",
|
||||
) as caught:
|
||||
sync_peer_worker.run_adapter(
|
||||
Path("/adapter"),
|
||||
"push",
|
||||
"peer_00000000-0000-4000-8000-000000000001",
|
||||
Path("/transport"),
|
||||
sync_peer_worker.time.monotonic() + 60,
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
transport = Path(directory) / "transport"
|
||||
transport.mkdir()
|
||||
with mock.patch.object(sync_peer_worker.subprocess, "run", side_effect=timeout):
|
||||
with self.assertRaisesRegex(
|
||||
RuntimeError,
|
||||
r"^transport_timeout: MTP push did not finish within 30 seconds$",
|
||||
) as caught:
|
||||
sync_peer_worker.run_adapter(
|
||||
Path("/adapter"),
|
||||
"push",
|
||||
"peer_00000000-0000-4000-8000-000000000001",
|
||||
transport,
|
||||
sync_peer_worker.time.monotonic() + 60,
|
||||
)
|
||||
self.assertEqual(sync_orchestrator.failure_code(caught.exception), "transport_timeout")
|
||||
|
||||
def test_known_operational_failures_remain_distinct(self):
|
||||
|
|
@ -204,7 +207,7 @@ class SyncErrorClassificationTest(unittest.TestCase):
|
|||
(root / name).write_text("{}")
|
||||
observed = []
|
||||
|
||||
def inspect(_adapter, _operation, _peer, outbox, _deadline):
|
||||
def inspect(_adapter, _operation, _peer, outbox, _deadline, **_kwargs):
|
||||
observed.append(
|
||||
sorted(
|
||||
str(path.relative_to(outbox))
|
||||
|
|
|
|||
|
|
@ -71,6 +71,38 @@ class GenerationTest(unittest.TestCase):
|
|||
with closing(sqlite3.connect(destination)) as reopened:
|
||||
self.assertEqual(ack,generation.record_consumed(reopened,manifest,checksum));self.assertEqual(1,reopened.execute("SELECT count(*) FROM facts WHERE id='imported'").fetchone()[0])
|
||||
|
||||
def test_sleep_revision_becomes_acknowledged_only_after_correlated_ack(self):
|
||||
stage, manifest, checksum = self.capture()
|
||||
self.publish()
|
||||
entry = "sl_10000000-0000-4000-8000-000000000001"
|
||||
revision = "slr_20000000-0000-4000-8000-000000000002"
|
||||
with closing(sqlite3.connect(self.database)) as db:
|
||||
db.executescript("""
|
||||
CREATE TABLE sleep_diary_publication_state(entry_id TEXT PRIMARY KEY,validated_revision_id TEXT,validated_at TEXT,acknowledged_revision_id TEXT,acknowledged_at TEXT);
|
||||
CREATE TABLE sleep_diary_generation_entries(generation_id TEXT,entry_id TEXT,revision_id TEXT,PRIMARY KEY(generation_id,entry_id));
|
||||
CREATE TABLE program_deletions(generation_id TEXT,acknowledged_at TEXT);
|
||||
PRAGMA user_version=29;
|
||||
""")
|
||||
db.execute("INSERT INTO sleep_diary_publication_state VALUES(?,?,?,NULL,NULL)",
|
||||
(entry, revision, "2026-09-21T17:30:00+02:00"))
|
||||
db.execute("INSERT INTO sleep_diary_generation_entries VALUES(?,?,?)",
|
||||
(GEN, entry, revision))
|
||||
db.execute("UPDATE sync_generations SET status='waiting_acknowledgement' WHERE generation_id=?", (GEN,))
|
||||
db.commit()
|
||||
ack = generation.ack_document(
|
||||
manifest["run_id"], manifest["generation_id"], manifest["producer"]["peer_id"],
|
||||
manifest["consumer_peer_id"], checksum, "consumed",
|
||||
"2026-09-21T18:00:00+02:00",
|
||||
)
|
||||
ack_path = self.root / "sleep-ack.json"
|
||||
ack_path.write_bytes(generation.canonical(ack))
|
||||
self.assertEqual("acknowledged", generation.accept_ack(self.database, ack_path))
|
||||
with closing(sqlite3.connect(self.database)) as db:
|
||||
self.assertEqual((revision,), db.execute(
|
||||
"SELECT acknowledged_revision_id FROM sleep_diary_publication_state WHERE entry_id=?",
|
||||
(entry,),
|
||||
).fetchone())
|
||||
|
||||
def test_withdrawal_generation_relation_is_durable_until_ack(self):
|
||||
withdrawal = "spw_77777777-7777-4777-8777-777777777777"
|
||||
preparation = "sp_88888888-8888-4888-8888-888888888888"
|
||||
|
|
@ -136,6 +168,60 @@ class GenerationTest(unittest.TestCase):
|
|||
"SELECT acknowledged_at FROM session_preparation_withdrawals",
|
||||
).fetchone()[0],
|
||||
)
|
||||
|
||||
def test_acknowledged_program_recovery_keeps_original_generation_relation(self):
|
||||
delivery = "spd_55555555-5555-4555-8555-555555555555"
|
||||
preparation = "sp_66666666-6666-4666-8666-666666666666"
|
||||
revision = "spr_77777777-7777-4777-8777-777777777777"
|
||||
execution = "se_88888888-8888-4888-8888-888888888888"
|
||||
program = "pg_99999999-9999-4999-8999-999999999999"
|
||||
program_session = "pgs_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
||||
original_generation = "gen_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
|
||||
with closing(sqlite3.connect(self.database)) as db:
|
||||
db.execute("ALTER TABLE session_preparations ADD COLUMN source_program_id TEXT")
|
||||
db.execute("ALTER TABLE session_preparations ADD COLUMN source_program_session_id TEXT")
|
||||
db.execute(
|
||||
"CREATE TABLE program_session_executions(program_session_id TEXT PRIMARY KEY,"
|
||||
"program_id TEXT,session_id TEXT UNIQUE,state TEXT,observed_at TEXT)"
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO session_preparations VALUES(?,?,?,?,?,?)",
|
||||
(preparation, revision, "acknowledged", None, program, program_session),
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO session_preparation_deliveries VALUES(?,?,?,?,?,?,?,?)",
|
||||
(delivery, preparation, revision, execution, "acknowledged",
|
||||
"2026-09-21T12:00:00Z", original_generation, "2026-09-21T12:05:00Z"),
|
||||
)
|
||||
db.commit()
|
||||
artifacts = (("session-preparations", "trainlog-session-preparations", 2,
|
||||
"session-preparations-v2.json", False, "controlled.py", ()),)
|
||||
payload = {
|
||||
"format": "trainlog-session-preparations", "version": 2,
|
||||
"generated_at": "2026-09-21T12:10:00Z", "withdrawals": [],
|
||||
"deliveries": [{
|
||||
"delivery_id": delivery, "preparation_id": preparation,
|
||||
"revision_id": revision, "execution_session_id": execution,
|
||||
"state": "remote_unknown", "source_program_id": program,
|
||||
"source_program_session_id": program_session,
|
||||
}],
|
||||
}
|
||||
def exporter(_tool, _extra, output, _snapshot):
|
||||
output.write_text(json.dumps(payload))
|
||||
with mock.patch.object(generation, "ARTIFACTS", artifacts), \
|
||||
mock.patch.object(generation, "SUPPORTED", {("trainlog-session-preparations", 2)}), \
|
||||
mock.patch.object(generation, "run_export", exporter):
|
||||
generation.capture_desktop(
|
||||
self.database, self.root / "program-recovery", PEER_B, RUN, GEN,
|
||||
)
|
||||
with closing(sqlite3.connect(self.database)) as db:
|
||||
self.assertEqual(
|
||||
("acknowledged", original_generation),
|
||||
db.execute(
|
||||
"SELECT state,generation_id FROM session_preparation_deliveries "
|
||||
"WHERE delivery_id=?", (delivery,),
|
||||
).fetchone(),
|
||||
)
|
||||
def test_substitution_and_transaction_failure_leave_no_consumption(self):
|
||||
_,manifest,checksum=self.capture();published=self.publish()
|
||||
(published/"history.json").write_text("{}")
|
||||
|
|
@ -203,6 +289,8 @@ class GenerationTest(unittest.TestCase):
|
|||
self.assertEqual([operation["payload_sha256"] for operation in operations],[row[0] for row in db.execute("SELECT payload_sha256 FROM sync_causal_operations ORDER BY operation_id")])
|
||||
|
||||
def test_real_consumer_rolls_back_earlier_domain_write_on_late_failure(self):
|
||||
# A pre-Sleep-Diary generation remains consumable after the companion
|
||||
# is introduced; the late-domain failure still rolls everything back.
|
||||
names=("catalog","history","execution-drafts","exercise-aliases","exercise-profile-state","equipment-definitions","equipment-associations","body-zones","feedback","causal-deletions")
|
||||
directory=self.root/"incoming";directory.mkdir()
|
||||
artifacts=[]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import json, os, signal, subprocess, tempfile, time, unittest, uuid
|
||||
import fcntl, json, os, signal, subprocess, tempfile, time, unittest, uuid
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
|
@ -134,6 +134,118 @@ class OrchestratorTest(unittest.TestCase):
|
|||
os.kill(pid, 0)
|
||||
self.assertEqual(json.loads(state.read_text())["phase"], "interrupted")
|
||||
|
||||
def test_auto_transport_prefers_bluetooth_before_mtp(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
sandbox = root / "program"
|
||||
sandbox.mkdir()
|
||||
(sandbox / "sync_orchestrator.py").write_bytes(ORCHESTRATOR.read_bytes())
|
||||
peer, desktop = identity("peer_"), identity("peer_")
|
||||
inbound, outbound = identity("gen_"), identity("gen_")
|
||||
report = {
|
||||
"format": "trainlog-sync-worker-report", "version": 1,
|
||||
"run_id": "RUN", "producer_peer_id": peer, "consumer_peer_id": desktop,
|
||||
"inbound_generation_id": inbound, "outbound_generation_id": outbound,
|
||||
"manifest_sha256": "a" * 64, "result": "completed",
|
||||
"sessions_reconciled": 0, "domains": {}, "drafts": [],
|
||||
"ai_midpoint": {"result": "not_configured"},
|
||||
"ai_post_sync": {"result": "delivered_to_android"},
|
||||
}
|
||||
(sandbox / "sync_peer_worker.py").write_text(
|
||||
"import json,sys\n"
|
||||
"assert sys.argv[sys.argv.index('--mode')+1]=='bt'\n"
|
||||
"assert '--bt-adapter' in sys.argv\n"
|
||||
"v=" + repr(report) +
|
||||
"\nv['run_id']=sys.argv[sys.argv.index('--run-id')+1]\nprint(json.dumps(v))\n"
|
||||
)
|
||||
bt_marker = root / "bt-probe"
|
||||
bt_adapter = sandbox / "trainlog_generation_bt_adapter.py"
|
||||
bt_adapter.write_text(f"#!/bin/sh\necho probe >> {bt_marker}\nexit 0\n")
|
||||
bt_adapter.chmod(0o755)
|
||||
mtp_marker = root / "mtp-probe"
|
||||
mtp_adapter = root / "build/tui/trainlog-generation-mtp-adapter"
|
||||
mtp_adapter.parent.mkdir(parents=True)
|
||||
mtp_adapter.write_text(f"#!/bin/sh\necho probe >> {mtp_marker}\nexit 0\n")
|
||||
mtp_adapter.chmod(0o755)
|
||||
values = self.fixture(
|
||||
root,
|
||||
version=3,
|
||||
mode="auto",
|
||||
bluetooth_enabled=True,
|
||||
bluetooth_device_address="AA:BB:CC:DD:EE:FF",
|
||||
)
|
||||
result = self.invoke(sandbox / "sync_orchestrator.py", values)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertTrue(bt_marker.is_file())
|
||||
self.assertFalse(mtp_marker.exists())
|
||||
final = json.loads(values[1].read_text())
|
||||
self.assertEqual("completed", final["phase"])
|
||||
self.assertEqual("bt", final["transport"])
|
||||
self.assertEqual("success", final["bluetooth_state"])
|
||||
self.assertEqual("unavailable", final["usb_state"])
|
||||
|
||||
def test_auto_transport_waits_for_mtp_lock_instead_of_falling_back_to_drive(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
sandbox = root / "program"
|
||||
sandbox.mkdir()
|
||||
(sandbox / "sync_orchestrator.py").write_bytes(ORCHESTRATOR.read_bytes())
|
||||
peer, desktop = identity("peer_"), identity("peer_")
|
||||
inbound, outbound = identity("gen_"), identity("gen_")
|
||||
report = {
|
||||
"format": "trainlog-sync-worker-report", "version": 1,
|
||||
"run_id": "RUN", "producer_peer_id": peer, "consumer_peer_id": desktop,
|
||||
"inbound_generation_id": inbound, "outbound_generation_id": outbound,
|
||||
"manifest_sha256": "a" * 64, "result": "completed",
|
||||
"sessions_reconciled": 0, "domains": {}, "drafts": [],
|
||||
"ai_midpoint": {"result": "not_configured"},
|
||||
"ai_post_sync": {"result": "not_configured"},
|
||||
}
|
||||
(sandbox / "sync_peer_worker.py").write_text(
|
||||
"import json,sys\nv=" + repr(report) +
|
||||
"\nv['run_id']=sys.argv[sys.argv.index('--run-id')+1]\nprint(json.dumps(v))\n"
|
||||
)
|
||||
adapter = root / "build/tui/trainlog-generation-mtp-adapter"
|
||||
adapter.parent.mkdir(parents=True)
|
||||
marker = root / "mtp-probe"
|
||||
adapter.write_text(f"#!/bin/sh\necho probe >> {marker}\nexit 0\n")
|
||||
adapter.chmod(0o755)
|
||||
values = self.fixture(
|
||||
root,
|
||||
version=2,
|
||||
mode="auto",
|
||||
drive_enabled=True,
|
||||
drive_remote="fake:Trainlog/Sync/v1",
|
||||
)
|
||||
db, state, config, run, request = values
|
||||
mtp_lock = root / "mtp.lock"
|
||||
lock_fd = os.open(mtp_lock, os.O_RDWR | os.O_CREAT, 0o600)
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
||||
process = subprocess.Popen(
|
||||
[
|
||||
"python3", str(sandbox / "sync_orchestrator.py"),
|
||||
"--database", str(db), "--state", str(state),
|
||||
"--config", str(config), "--run-id", run, "--request-id", request,
|
||||
],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
try:
|
||||
time.sleep(0.2)
|
||||
self.assertIsNone(process.poll())
|
||||
self.assertFalse(marker.exists())
|
||||
finally:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||
os.close(lock_fd)
|
||||
stdout, stderr = process.communicate(timeout=10)
|
||||
self.assertEqual(process.returncode, 0, stderr or stdout)
|
||||
self.assertTrue(marker.is_file())
|
||||
final = json.loads(state.read_text())
|
||||
self.assertEqual("completed", final["phase"])
|
||||
self.assertEqual("mtp", final["transport"])
|
||||
self.assertEqual("success", final["usb_state"])
|
||||
|
||||
def test_committed_primary_run_survives_drive_mirror_failure(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import argparse
|
||||
import importlib.util
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
|
@ -17,6 +18,14 @@ SPEC.loader.exec_module(worker)
|
|||
|
||||
|
||||
class SyncPeerWorkerTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory(prefix="trainlog-peer-worker-test-")
|
||||
self.transport = Path(self.temporary.name) / "transport"
|
||||
self.transport.mkdir()
|
||||
|
||||
def tearDown(self):
|
||||
self.temporary.cleanup()
|
||||
|
||||
def test_drive_adapter_uses_remaining_conversation_deadline(self):
|
||||
completed = subprocess.CompletedProcess([], 0, b"", b"")
|
||||
with mock.patch.object(worker.subprocess, "run", return_value=completed) as run:
|
||||
|
|
@ -24,7 +33,7 @@ class SyncPeerWorkerTest(unittest.TestCase):
|
|||
Path("/fixed/adapter.py"),
|
||||
"pull",
|
||||
"remote:Trainlog/Sync-Test/run",
|
||||
Path("/private/transport"),
|
||||
self.transport,
|
||||
time.monotonic() + 180,
|
||||
)
|
||||
timeout = run.call_args.kwargs["timeout"]
|
||||
|
|
@ -36,10 +45,25 @@ class SyncPeerWorkerTest(unittest.TestCase):
|
|||
with mock.patch.object(worker.subprocess, "run", return_value=completed) as run:
|
||||
worker.run_adapter(
|
||||
Path("/fixed/adapter"), "pull", "peer_fixed",
|
||||
Path("/private/transport"), 100.0, clock=lambda: 50.0,
|
||||
self.transport, 100.0, clock=lambda: 50.0,
|
||||
)
|
||||
self.assertEqual(worker.MTP_OPERATION_TIMEOUT_SECONDS, run.call_args.kwargs["timeout"])
|
||||
|
||||
def test_mtp_adapter_uses_and_releases_shared_transport_lock(self):
|
||||
completed = subprocess.CompletedProcess([], 0, b"", b"")
|
||||
real_flock = worker.fcntl.flock
|
||||
with mock.patch.object(worker.subprocess, "run", return_value=completed), mock.patch.object(
|
||||
worker.fcntl, "flock", wraps=real_flock
|
||||
) as flock:
|
||||
worker.run_adapter(
|
||||
Path("/fixed/adapter"), "pull", "peer_fixed",
|
||||
self.transport, 100.0, clock=lambda: 50.0,
|
||||
)
|
||||
self.assertTrue((self.transport.parent / "mtp.lock").is_file())
|
||||
operations = [call.args[1] for call in flock.call_args_list]
|
||||
self.assertIn(worker.fcntl.LOCK_EX | worker.fcntl.LOCK_NB, operations)
|
||||
self.assertEqual(worker.fcntl.LOCK_UN, operations[-1])
|
||||
|
||||
def test_mtp_pull_pump_is_throttled(self):
|
||||
now = [0.0]
|
||||
pulls = []
|
||||
|
|
@ -55,7 +79,7 @@ class SyncPeerWorkerTest(unittest.TestCase):
|
|||
with self.assertRaises(worker.ConversationDeadlineExpired):
|
||||
worker.run_adapter(
|
||||
Path("/fixed/adapter"), "pull", "peer_fixed",
|
||||
Path("/private/transport"), 10.0, clock=lambda: 10.0,
|
||||
self.transport, 10.0, clock=lambda: 10.0,
|
||||
)
|
||||
|
||||
def test_deadline_limited_pull_becomes_protocol_timeout(self):
|
||||
|
|
@ -65,7 +89,7 @@ class SyncPeerWorkerTest(unittest.TestCase):
|
|||
with self.assertRaises(worker.ConversationDeadlineExpired):
|
||||
worker.run_adapter(
|
||||
Path("/fixed/adapter"), "pull", "peer_fixed",
|
||||
Path("/private/transport"), 10.5, clock=lambda: 10.0,
|
||||
self.transport, 10.5, clock=lambda: 10.0,
|
||||
)
|
||||
|
||||
def test_blocked_full_budget_pull_is_transport_timeout(self):
|
||||
|
|
@ -75,7 +99,7 @@ class SyncPeerWorkerTest(unittest.TestCase):
|
|||
with self.assertRaisesRegex(RuntimeError, "transport_timeout"):
|
||||
worker.run_adapter(
|
||||
Path("/fixed/adapter"), "pull", "peer_fixed",
|
||||
Path("/private/transport"), 100.0, clock=lambda: 50.0,
|
||||
self.transport, 100.0, clock=lambda: 50.0,
|
||||
)
|
||||
|
||||
def test_delayed_correlated_generation_survives_multiple_polls(self):
|
||||
|
|
@ -118,6 +142,91 @@ class SyncPeerWorkerTest(unittest.TestCase):
|
|||
)
|
||||
self.assertEqual("sy_fresh", value["run_id"])
|
||||
|
||||
def test_bluetooth_adapter_uses_distinct_transport_lock(self):
|
||||
completed = subprocess.CompletedProcess([], 0, b"", b"")
|
||||
with mock.patch.object(worker.subprocess, "run", return_value=completed):
|
||||
worker.run_adapter(
|
||||
Path("/fixed/bt-adapter"),
|
||||
"pull",
|
||||
"peer_fixed",
|
||||
self.transport,
|
||||
100.0,
|
||||
clock=lambda: 50.0,
|
||||
transport_name="Bluetooth",
|
||||
lock_name="bt.lock",
|
||||
)
|
||||
self.assertTrue((self.transport.parent / "bt.lock").is_file())
|
||||
|
||||
def test_ai_export_delivery_is_best_effort_and_uses_bluetooth(self):
|
||||
database = Path(self.temporary.name) / "trainlog.db"
|
||||
database.touch()
|
||||
args = argparse.Namespace(
|
||||
database=database,
|
||||
transport_root=self.transport,
|
||||
mode="bt",
|
||||
bt_adapter=Path("/fixed/bt-adapter"),
|
||||
mtp_adapter=None,
|
||||
expected_peer="peer_fixed",
|
||||
)
|
||||
|
||||
def execute(command, **kwargs):
|
||||
if command[0] == worker.sys.executable:
|
||||
output = Path(command[2])
|
||||
output.write_text(
|
||||
'{"format":"TRAINLOG_AI_EXPORT","version":1}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
return subprocess.CompletedProcess(
|
||||
command,
|
||||
0,
|
||||
"TRAINLOG_AI_EXPORT_V1=PASS\n",
|
||||
"",
|
||||
)
|
||||
self.assertEqual("/fixed/bt-adapter", command[0])
|
||||
self.assertEqual("push", command[1])
|
||||
return subprocess.CompletedProcess(command, 0, b"", b"")
|
||||
|
||||
with mock.patch.object(worker.subprocess, "run", side_effect=execute):
|
||||
result = worker.publish_ai_export_to_android(args, time.monotonic() + 30)
|
||||
self.assertEqual({"result": "delivered_to_android"}, result)
|
||||
self.assertTrue((self.transport / "trainlog_ai_export_v1.json").is_file())
|
||||
|
||||
def test_ai_export_transport_failure_does_not_raise(self):
|
||||
database = Path(self.temporary.name) / "trainlog.db"
|
||||
database.touch()
|
||||
args = argparse.Namespace(
|
||||
database=database,
|
||||
transport_root=self.transport,
|
||||
mode="bt",
|
||||
bt_adapter=Path("/fixed/bt-adapter"),
|
||||
mtp_adapter=None,
|
||||
expected_peer="peer_fixed",
|
||||
)
|
||||
|
||||
def execute(command, **kwargs):
|
||||
if command[0] == worker.sys.executable:
|
||||
Path(command[2]).write_text(
|
||||
'{"format":"TRAINLOG_AI_EXPORT","version":1}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
return subprocess.CompletedProcess(
|
||||
command,
|
||||
0,
|
||||
"TRAINLOG_AI_EXPORT_V1=PASS\n",
|
||||
"",
|
||||
)
|
||||
return subprocess.CompletedProcess(
|
||||
command,
|
||||
2,
|
||||
b"",
|
||||
b"Bluetooth adapter failed status=6 diagnostic=peer vanished",
|
||||
)
|
||||
|
||||
with mock.patch.object(worker.subprocess, "run", side_effect=execute):
|
||||
result = worker.publish_ai_export_to_android(args, time.monotonic() + 30)
|
||||
self.assertEqual("pending", result["result"])
|
||||
self.assertIn("peer vanished", result["diagnostic"])
|
||||
|
||||
def test_missing_generation_reports_correlated_protocol_timeout(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
reference = Path(directory) / "android-generation-v1.json"
|
||||
|
|
|
|||
|
|
@ -126,6 +126,45 @@ class SyncdRoutingTest(unittest.TestCase):
|
|||
self.assertEqual(1, len(commands))
|
||||
self.assertEqual("sy_old", json.loads(state.read_text())["run_id"])
|
||||
|
||||
def test_daemon_skips_mtp_probe_while_transport_lock_is_busy(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
transport = root / "transport"
|
||||
transport.mkdir()
|
||||
config = root / "config.json"
|
||||
config.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"format": "trainlog-sync-orchestrator-config",
|
||||
"version": 1,
|
||||
"enabled": True,
|
||||
"mode": "mtp",
|
||||
"expected_peer_id": "peer_11111111-1111-4111-8111-111111111111",
|
||||
"transport_root": str(transport),
|
||||
"owned_root": str(root / "owned"),
|
||||
"timeout_seconds": 30,
|
||||
}
|
||||
)
|
||||
)
|
||||
args = argparse.Namespace(
|
||||
mtp_adapter=root / "adapter",
|
||||
state=root / "trainlog.db.sync-run.json",
|
||||
trigger="android",
|
||||
orchestrator=root / "sync_orchestrator.py",
|
||||
database=root / "trainlog.db",
|
||||
)
|
||||
|
||||
def flock(_fd, operation):
|
||||
if operation == syncd.fcntl.LOCK_EX | syncd.fcntl.LOCK_NB:
|
||||
raise BlockingIOError
|
||||
self.assertEqual(syncd.fcntl.LOCK_UN, operation)
|
||||
|
||||
with mock.patch.object(syncd.fcntl, "flock", side_effect=flock), mock.patch.object(
|
||||
syncd.subprocess, "run"
|
||||
) as run:
|
||||
self.assertEqual(3, syncd.run_full_generation(args, config))
|
||||
run.assert_not_called()
|
||||
|
||||
def test_android_production_publishes_full_generation_before_optional_legacy(self):
|
||||
source = (ROOT / "android/app/src/main/java/com/labfytools/trainlog/ui/SyncScreen.kt").read_text()
|
||||
production = source.split("internal suspend fun publishBundleAndRequest", 1)[1].split(
|
||||
|
|
|
|||
667
tests/test_web_sleep_browser.py
Normal file
667
tests/test_web_sleep_browser.py
Normal file
|
|
@ -0,0 +1,667 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Exercise the persisted Sleep Diary projection incrementally in Firefox."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BUILD = ROOT / os.environ.get("TRAINLOG_WEB_E2E_BUILD", "build") / "tui"
|
||||
EXECUTABLE = Path(os.environ.get("TRAINLOG_WEB_E2E_EXECUTABLE", BUILD / "trainlog"))
|
||||
GECKO = Path(
|
||||
os.environ.get(
|
||||
"TRAINLOG_GECKODRIVER", "/home/fy59/.cache/trainlog/web-e2e-tools/geckodriver"
|
||||
)
|
||||
)
|
||||
EVIDENCE = ROOT / "docs" / "reviews" / "evidence"
|
||||
|
||||
|
||||
class BrowserSleepDiaryTest(unittest.TestCase):
|
||||
def test_live_agenda_uses_each_persisted_revision_without_reload(self):
|
||||
if not GECKO.is_file():
|
||||
self.skipTest("pinned geckodriver unavailable")
|
||||
|
||||
try:
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.firefox.service import Service
|
||||
from selenium.webdriver.support.ui import Select, WebDriverWait
|
||||
import websocket
|
||||
except ModuleNotFoundError:
|
||||
self.skipTest("selenium unavailable")
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="trainlog-web-sleep-") as directory:
|
||||
root = Path(directory)
|
||||
environment = os.environ.copy()
|
||||
environment.update(
|
||||
{
|
||||
"XDG_DATA_HOME": str(root / "data"),
|
||||
"XDG_CONFIG_HOME": str(root / "config"),
|
||||
"XDG_CACHE_HOME": str(root / "cache"),
|
||||
}
|
||||
)
|
||||
configured_port = os.environ.get("TRAINLOG_WEB_E2E_PORT")
|
||||
if configured_port is None:
|
||||
with socket.socket() as reserved:
|
||||
reserved.bind(("127.0.0.1", 0))
|
||||
port = reserved.getsockname()[1]
|
||||
else:
|
||||
port = int(configured_port)
|
||||
server = subprocess.Popen(
|
||||
[str(EXECUTABLE), "--web", "--port", str(port)],
|
||||
env=environment,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
driver = None
|
||||
try:
|
||||
for _ in range(200):
|
||||
with socket.socket() as probe:
|
||||
if probe.connect_ex(("127.0.0.1", port)) == 0:
|
||||
break
|
||||
time.sleep(0.025)
|
||||
|
||||
EVIDENCE.mkdir(parents=True, exist_ok=True)
|
||||
options = webdriver.FirefoxOptions()
|
||||
options.add_argument("-headless")
|
||||
options.set_capability("webSocketUrl", True)
|
||||
options.set_preference("browser.download.folderList", 2)
|
||||
options.set_preference("browser.download.dir", str(EVIDENCE))
|
||||
options.set_preference("browser.download.useDownloadDir", True)
|
||||
options.set_preference(
|
||||
"browser.helperApps.neverAsk.saveToDisk", "application/pdf"
|
||||
)
|
||||
options.set_preference("pdfjs.disabled", True)
|
||||
driver = webdriver.Firefox(options=options, service=Service(str(GECKO)))
|
||||
driver.set_window_size(1440, 1100)
|
||||
wait = WebDriverWait(driver, 15)
|
||||
base_url = os.environ.get(
|
||||
"TRAINLOG_WEB_E2E_BASE_URL", f"http://127.0.0.1:{port}"
|
||||
)
|
||||
driver.get(f"{base_url}/analyse?section=sleep")
|
||||
wait.until(
|
||||
lambda current: current.find_element(
|
||||
By.CSS_SELECTOR, "[data-testid='sleep-workspace']"
|
||||
)
|
||||
)
|
||||
driver.execute_script(
|
||||
"""
|
||||
window.__trainlogErrors = [];
|
||||
window.__trainlogFetches = [];
|
||||
addEventListener('error', event => window.__trainlogErrors.push(String(event.error || event.message)));
|
||||
addEventListener('unhandledrejection', event => window.__trainlogErrors.push(String(event.reason)));
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = async (...args) => {
|
||||
try {
|
||||
const response = await originalFetch(...args);
|
||||
window.__trainlogFetches.push({url: String(args[0]), method: args[1]?.method || 'GET', status: response.status});
|
||||
return response;
|
||||
} catch (error) {
|
||||
window.__trainlogErrors.push(String(error));
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
"""
|
||||
)
|
||||
|
||||
def set_value(test_id: str, value: str):
|
||||
element = driver.find_element(
|
||||
By.CSS_SELECTOR, f"[data-testid='{test_id}']"
|
||||
)
|
||||
driver.execute_script(
|
||||
"Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set.call(arguments[0],arguments[1]);"
|
||||
"arguments[0].dispatchEvent(new Event('input',{bubbles:true}));"
|
||||
"arguments[0].dispatchEvent(new Event('change',{bubbles:true}));",
|
||||
element,
|
||||
value,
|
||||
)
|
||||
|
||||
def click(element):
|
||||
driver.execute_script(
|
||||
"arguments[0].scrollIntoView({block:'center'}); arguments[0].click();",
|
||||
element,
|
||||
)
|
||||
|
||||
def post_count() -> int:
|
||||
return driver.execute_script(
|
||||
"return window.__trainlogFetches.filter(item => item.method === 'POST').length"
|
||||
)
|
||||
|
||||
def await_persisted(previous_posts: int):
|
||||
wait.until(lambda current: post_count() > previous_posts)
|
||||
wait.until(
|
||||
lambda current: current.find_element(
|
||||
By.CSS_SELECTOR, "[data-testid='sleep-save-state']"
|
||||
).text
|
||||
== "Enregistré localement"
|
||||
)
|
||||
|
||||
def add_medication(dose: str):
|
||||
click(driver.find_element(
|
||||
By.XPATH,
|
||||
"//button[normalize-space()='+ Ajouter un médicament']",
|
||||
))
|
||||
set_value("sleep-medication-name", "venlafaxine")
|
||||
set_value("sleep-medication-dose", dose)
|
||||
before = post_count()
|
||||
click(driver.find_element(
|
||||
By.CSS_SELECTOR, "[data-testid='sleep-add-medication']"
|
||||
))
|
||||
wait.until(lambda current: post_count() > before)
|
||||
wait.until(
|
||||
lambda current: not current.find_elements(
|
||||
By.CSS_SELECTOR, "[data-testid='sleep-medication-name']"
|
||||
)
|
||||
)
|
||||
|
||||
add_medication("75")
|
||||
add_medication("37.5")
|
||||
medication_select = Select(
|
||||
driver.find_element(
|
||||
By.CSS_SELECTOR, "[data-testid='sleep-intake-medication']"
|
||||
)
|
||||
)
|
||||
option_labels = [option.text for option in medication_select.options]
|
||||
self.assertIn("venlafaxine — 75 mg", option_labels)
|
||||
self.assertIn("venlafaxine — 37,5 mg", option_labels)
|
||||
|
||||
for label, dose in (
|
||||
("venlafaxine — 75 mg", "75"),
|
||||
("venlafaxine — 37,5 mg", "37.5"),
|
||||
):
|
||||
medication_select.select_by_visible_text(label)
|
||||
wait.until(
|
||||
lambda current: current.find_element(
|
||||
By.CSS_SELECTOR, "[data-testid='sleep-intake-dose']"
|
||||
).get_attribute("value")
|
||||
== dose
|
||||
)
|
||||
before = post_count()
|
||||
click(driver.find_element(
|
||||
By.CSS_SELECTOR, "[data-testid='sleep-add-intake']"
|
||||
))
|
||||
await_persisted(before)
|
||||
wait.until(
|
||||
lambda current: current.find_element(
|
||||
By.CSS_SELECTOR, "[data-testid='sleep-agenda-medication']"
|
||||
).text
|
||||
== "M ×2"
|
||||
)
|
||||
|
||||
def add_event(kind: str, start: str, end: str | None, assertion):
|
||||
Select(
|
||||
driver.find_element(
|
||||
By.CSS_SELECTOR, "[data-testid='sleep-event-type']"
|
||||
)
|
||||
).select_by_value(kind)
|
||||
set_value("sleep-event-start", start)
|
||||
if end is not None:
|
||||
set_value("sleep-event-end", end)
|
||||
before = post_count()
|
||||
click(driver.find_element(
|
||||
By.CSS_SELECTOR, "[data-testid='sleep-add-event']"
|
||||
))
|
||||
await_persisted(before)
|
||||
wait.until(assertion)
|
||||
|
||||
add_event(
|
||||
"bed_time",
|
||||
"22:45",
|
||||
None,
|
||||
lambda current: current.find_element(
|
||||
By.CSS_SELECTOR, "[data-testid='sleep-agenda-bed-time']"
|
||||
).text.endswith("22:45"),
|
||||
)
|
||||
add_event(
|
||||
"sleep",
|
||||
"23:15",
|
||||
"03:00",
|
||||
lambda current: "3 h 45 min"
|
||||
in current.find_element(
|
||||
By.CSS_SELECTOR, "[data-testid='sleep-agenda-sleep-duration']"
|
||||
).text,
|
||||
)
|
||||
add_event(
|
||||
"long_awake",
|
||||
"03:00",
|
||||
"03:30",
|
||||
lambda current: "1 · 30 min"
|
||||
in current.find_element(
|
||||
By.CSS_SELECTOR, "[data-testid='sleep-agenda-long-awake']"
|
||||
).text,
|
||||
)
|
||||
add_event(
|
||||
"sleep",
|
||||
"03:30",
|
||||
"06:45",
|
||||
lambda current: "7 h 00 min"
|
||||
in current.find_element(
|
||||
By.CSS_SELECTOR, "[data-testid='sleep-agenda-sleep-duration']"
|
||||
).text,
|
||||
)
|
||||
add_event(
|
||||
"final_get_up",
|
||||
"07:10",
|
||||
None,
|
||||
lambda current: "8 h 25 min"
|
||||
in current.find_element(
|
||||
By.CSS_SELECTOR, "[data-testid='sleep-agenda-time-in-bed']"
|
||||
).text,
|
||||
)
|
||||
|
||||
expected = driver.find_element(
|
||||
By.CSS_SELECTOR, "[data-testid^='sleep-agenda-row-']"
|
||||
).text
|
||||
mutation_errors = driver.execute_script(
|
||||
"return window.__trainlogErrors"
|
||||
)
|
||||
mutation_fetches = driver.execute_script(
|
||||
"return window.__trainlogFetches"
|
||||
)
|
||||
self.assertEqual([], mutation_errors)
|
||||
self.assertTrue(mutation_fetches)
|
||||
self.assertTrue(
|
||||
all(item["status"] == 200 for item in mutation_fetches)
|
||||
)
|
||||
driver.refresh()
|
||||
wait.until(
|
||||
lambda current: "8 h 25 min"
|
||||
in current.find_element(
|
||||
By.CSS_SELECTOR, "[data-testid='sleep-agenda-time-in-bed']"
|
||||
).text
|
||||
)
|
||||
actual = driver.find_element(
|
||||
By.CSS_SELECTOR, "[data-testid^='sleep-agenda-row-']"
|
||||
).text
|
||||
self.assertEqual(expected, actual)
|
||||
self.assertTrue(
|
||||
driver.find_element(
|
||||
By.CSS_SELECTOR, "[data-testid='sleep-workspace']"
|
||||
).is_displayed()
|
||||
)
|
||||
self.assertLessEqual(
|
||||
driver.execute_script("return document.documentElement.scrollWidth"),
|
||||
driver.execute_script("return document.documentElement.clientWidth"),
|
||||
)
|
||||
|
||||
def stable_id(prefix: str) -> str:
|
||||
return f"{prefix}_{uuid.uuid4()}"
|
||||
|
||||
def post_entries(entries: list[dict]):
|
||||
return driver.execute_async_script(
|
||||
"""
|
||||
const entries=arguments[0], done=arguments[arguments.length-1];
|
||||
(async () => {
|
||||
const status = await fetch('/api/v1/sync/status');
|
||||
const token = status.headers.get('X-Trainlog-CSRF-Token');
|
||||
const responses = [];
|
||||
for (const entry of entries) {
|
||||
const response = await fetch('/api/v1/sleep-diary', {
|
||||
method: 'POST',
|
||||
headers: {'Accept':'application/json','Content-Type':'application/json','X-Trainlog-CSRF-Token':token},
|
||||
body: JSON.stringify(entry),
|
||||
});
|
||||
responses.push({status: response.status, body: await response.json()});
|
||||
}
|
||||
done(responses);
|
||||
})().catch(error => done([{status:0, body:String(error)}]));
|
||||
""",
|
||||
entries,
|
||||
)
|
||||
|
||||
alignment_medication_id = Select(
|
||||
driver.find_element(
|
||||
By.CSS_SELECTOR, "[data-testid='sleep-intake-medication']"
|
||||
)
|
||||
).options[1].get_attribute("value")
|
||||
alignment_entry = {
|
||||
"entry_id": "",
|
||||
"expected_revision": None,
|
||||
"night_start_date": "2026-09-19",
|
||||
"night_end_date": "2026-09-20",
|
||||
"created_at": "2026-09-19T18:00:00+02:00",
|
||||
"updated_at": "2026-09-20T16:00:00+02:00",
|
||||
"sleep_quality": "B",
|
||||
"wake_quality": "TB",
|
||||
"day_form": "Moy",
|
||||
"treatment_and_notes": "Nuit de contrôle géométrique.",
|
||||
"events": [
|
||||
{
|
||||
"event_id": stable_id("sle"),
|
||||
"type": "bed_time",
|
||||
"start_at": "2026-09-19T22:30:00+02:00",
|
||||
"end_at": None,
|
||||
},
|
||||
{
|
||||
"event_id": stable_id("sle"),
|
||||
"type": "sleep",
|
||||
"start_at": "2026-09-19T22:30:00+02:00",
|
||||
"end_at": "2026-09-20T03:30:00+02:00",
|
||||
},
|
||||
{
|
||||
"event_id": stable_id("sle"),
|
||||
"type": "final_get_up",
|
||||
"start_at": "2026-09-20T04:45:00+02:00",
|
||||
"end_at": None,
|
||||
},
|
||||
{
|
||||
"event_id": stable_id("sle"),
|
||||
"type": "daytime_sleepiness",
|
||||
"start_at": "2026-09-20T15:30:00+02:00",
|
||||
"end_at": None,
|
||||
},
|
||||
],
|
||||
"intakes": [
|
||||
{
|
||||
"intake_id": stable_id("mdi"),
|
||||
"medication_id": alignment_medication_id,
|
||||
"medication_name": "venlafaxine",
|
||||
"taken_at": "2026-09-19T22:30:00+02:00",
|
||||
"dose_value": 75,
|
||||
"dose_unit": "mg",
|
||||
"note": "",
|
||||
"created_at": "2026-09-19T22:30:00+02:00",
|
||||
}
|
||||
],
|
||||
}
|
||||
responses = post_entries([alignment_entry])
|
||||
self.assertEqual([200], [item["status"] for item in responses])
|
||||
set_value("sleep-night-start", "2026-09-19")
|
||||
wait.until(
|
||||
lambda current: current.find_elements(
|
||||
By.CSS_SELECTOR, "[data-testid='sleep-agenda-event-bed_time']"
|
||||
)
|
||||
)
|
||||
geometry = driver.execute_script(
|
||||
"""
|
||||
const axis=document.querySelector('.sleep-hour-axis').getBoundingClientRect();
|
||||
const box=id => document
|
||||
.querySelector(`[data-testid='${id}']`)
|
||||
.getBoundingClientRect();
|
||||
const point=id => {
|
||||
const value=document
|
||||
.querySelector(`[data-testid='${id}'] > span`)
|
||||
.getBoundingClientRect();
|
||||
return value.left + value.width / 2;
|
||||
};
|
||||
const sleep=box('sleep-agenda-event-sleep');
|
||||
const ticks=[...document.querySelectorAll('.sleep-hour-axis > span')]
|
||||
.map(value => {
|
||||
const tick=value.getBoundingClientRect();
|
||||
return {
|
||||
label:value.textContent,
|
||||
center:tick.left + tick.width / 2,
|
||||
};
|
||||
});
|
||||
return {
|
||||
left:axis.left,
|
||||
width:axis.width,
|
||||
bed:point('sleep-agenda-event-bed_time'),
|
||||
medication:point('sleep-agenda-medication'),
|
||||
sleepLeft:sleep.left,
|
||||
sleepRight:sleep.right,
|
||||
getUp:point('sleep-agenda-event-final_get_up'),
|
||||
sleepiness:point('sleep-agenda-event-daytime_sleepiness'),
|
||||
ticks,
|
||||
};
|
||||
"""
|
||||
)
|
||||
expected_positions = {
|
||||
"bed": 4.5 / 24,
|
||||
"medication": 4.5 / 24,
|
||||
"sleepLeft": 4.5 / 24,
|
||||
"sleepRight": 9.5 / 24,
|
||||
"getUp": 10.75 / 24,
|
||||
"sleepiness": 21.5 / 24,
|
||||
}
|
||||
for key, fraction in expected_positions.items():
|
||||
expected_x = geometry["left"] + geometry["width"] * fraction
|
||||
self.assertAlmostEqual(
|
||||
expected_x, geometry[key], delta=1.0, msg=key
|
||||
)
|
||||
for tick_index in (4, 9, 10, 11, 21, 22):
|
||||
expected_x = geometry["left"] + geometry["width"] * tick_index / 24
|
||||
self.assertAlmostEqual(
|
||||
expected_x,
|
||||
geometry["ticks"][tick_index]["center"],
|
||||
delta=1.0,
|
||||
)
|
||||
self.assertTrue(
|
||||
driver.save_screenshot(
|
||||
str(EVIDENCE / "sleep-diary-alignment-firefox.png")
|
||||
)
|
||||
)
|
||||
|
||||
set_value("sleep-night-start", "2026-09-21")
|
||||
wait.until(
|
||||
lambda current: "Aucune donnée pour cette nuit."
|
||||
in current.page_source
|
||||
)
|
||||
self.assertFalse(
|
||||
driver.find_elements(
|
||||
By.CSS_SELECTOR,
|
||||
"[data-testid='sleep-timeline'] [data-testid^='sleep-event-']",
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
driver.find_elements(By.CSS_SELECTOR, "[data-testid='sleep-intake-row']")
|
||||
)
|
||||
self.assertTrue(
|
||||
driver.save_screenshot(
|
||||
str(EVIDENCE / "sleep-diary-empty-night-firefox.png")
|
||||
)
|
||||
)
|
||||
set_value("sleep-night-start", "2026-09-19")
|
||||
wait.until(
|
||||
lambda current: current.find_elements(
|
||||
By.CSS_SELECTOR, "[data-testid='sleep-event-bed_time']"
|
||||
)
|
||||
)
|
||||
bidi = websocket.create_connection(
|
||||
driver.capabilities["webSocketUrl"], suppress_origin=True
|
||||
)
|
||||
bidi.send(
|
||||
json.dumps(
|
||||
{"id": 1, "method": "browsingContext.getTree", "params": {}}
|
||||
)
|
||||
)
|
||||
tree = json.loads(bidi.recv())
|
||||
context = tree["result"]["contexts"][0]["context"]
|
||||
bidi.send(
|
||||
json.dumps(
|
||||
{
|
||||
"id": 2,
|
||||
"method": "browsingContext.setViewport",
|
||||
"params": {
|
||||
"context": context,
|
||||
"viewport": {"width": 390, "height": 844},
|
||||
"devicePixelRatio": 1,
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
response = json.loads(bidi.recv())
|
||||
self.assertEqual("success", response["type"])
|
||||
wait.until(lambda current: current.execute_script("return innerWidth") == 390)
|
||||
self.assertLessEqual(
|
||||
driver.execute_script("return document.documentElement.scrollWidth"),
|
||||
driver.execute_script("return document.documentElement.clientWidth"),
|
||||
)
|
||||
bidi.send(
|
||||
json.dumps(
|
||||
{
|
||||
"id": 3,
|
||||
"method": "browsingContext.setViewport",
|
||||
"params": {
|
||||
"context": context,
|
||||
"viewport": {"width": 1440, "height": 1100},
|
||||
"devicePixelRatio": 1,
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
self.assertEqual("success", json.loads(bidi.recv())["type"])
|
||||
self.assertTrue(
|
||||
driver.save_screenshot(
|
||||
str(EVIDENCE / "sleep-diary-live-agenda-firefox.png")
|
||||
)
|
||||
)
|
||||
|
||||
extra_entries = []
|
||||
for day in range(14, 19):
|
||||
start_date = f"2026-09-{day:02d}"
|
||||
end_date = f"2026-09-{day + 1:02d}"
|
||||
|
||||
def at(clock: str) -> str:
|
||||
date = start_date if int(clock[:2]) >= 18 else end_date
|
||||
return f"{date}T{clock}:00+02:00"
|
||||
|
||||
extra_entries.append(
|
||||
{
|
||||
"entry_id": "",
|
||||
"expected_revision": None,
|
||||
"night_start_date": start_date,
|
||||
"night_end_date": end_date,
|
||||
"created_at": at("18:00"),
|
||||
"updated_at": at("18:00"),
|
||||
"sleep_quality": "B",
|
||||
"wake_quality": "Moy",
|
||||
"day_form": "TB",
|
||||
"treatment_and_notes": "Remarque française synthétique.",
|
||||
"events": [
|
||||
{
|
||||
"event_id": stable_id("sle"),
|
||||
"type": "bed_time",
|
||||
"start_at": at("22:30"),
|
||||
"end_at": None,
|
||||
},
|
||||
{
|
||||
"event_id": stable_id("sle"),
|
||||
"type": "sleep",
|
||||
"start_at": at("23:00"),
|
||||
"end_at": at("03:00"),
|
||||
},
|
||||
{
|
||||
"event_id": stable_id("sle"),
|
||||
"type": "long_awake",
|
||||
"start_at": at("03:00"),
|
||||
"end_at": at("03:45"),
|
||||
},
|
||||
{
|
||||
"event_id": stable_id("sle"),
|
||||
"type": "sleep",
|
||||
"start_at": at("03:45"),
|
||||
"end_at": at("07:00"),
|
||||
},
|
||||
{
|
||||
"event_id": stable_id("sle"),
|
||||
"type": "half_sleep",
|
||||
"start_at": at("07:00"),
|
||||
"end_at": at("07:10"),
|
||||
},
|
||||
{
|
||||
"event_id": stable_id("sle"),
|
||||
"type": "final_get_up",
|
||||
"start_at": at("07:15"),
|
||||
"end_at": None,
|
||||
},
|
||||
{
|
||||
"event_id": stable_id("sle"),
|
||||
"type": "nap",
|
||||
"start_at": at("14:00"),
|
||||
"end_at": at("14:35"),
|
||||
},
|
||||
{
|
||||
"event_id": stable_id("sle"),
|
||||
"type": "daytime_sleepiness",
|
||||
"start_at": at("15:00"),
|
||||
"end_at": None,
|
||||
},
|
||||
],
|
||||
"intakes": [],
|
||||
}
|
||||
)
|
||||
responses = post_entries(extra_entries)
|
||||
self.assertEqual([200] * 5, [item["status"] for item in responses])
|
||||
driver.refresh()
|
||||
wait.until(
|
||||
lambda current: len(
|
||||
current.find_elements(
|
||||
By.CSS_SELECTOR, "[data-testid^='sleep-agenda-row-']"
|
||||
)
|
||||
)
|
||||
== 1
|
||||
)
|
||||
EVIDENCE.mkdir(parents=True, exist_ok=True)
|
||||
download_path = EVIDENCE / "trainlog-sleep-diary.pdf"
|
||||
pdf_path = EVIDENCE / "sleep-diary-fr-7-days.pdf"
|
||||
raster_prefix = EVIDENCE / "sleep-diary-fr-7-days"
|
||||
raster_path = EVIDENCE / "sleep-diary-fr-7-days.png"
|
||||
numbered_download = EVIDENCE / "trainlog-sleep-diary(1).pdf"
|
||||
for artifact in (
|
||||
download_path,
|
||||
numbered_download,
|
||||
pdf_path,
|
||||
raster_path,
|
||||
):
|
||||
if artifact.exists():
|
||||
artifact.unlink()
|
||||
click(driver.find_element(By.XPATH, "//button[normalize-space()='Exporter PDF']"))
|
||||
wait.until(
|
||||
lambda current: download_path.is_file()
|
||||
and download_path.stat().st_size > 0
|
||||
)
|
||||
download_path.replace(pdf_path)
|
||||
extracted = root / "sleep-diary-fr-7-days.txt"
|
||||
subprocess.run(
|
||||
["pdftotext", str(pdf_path), str(extracted)], check=True
|
||||
)
|
||||
pdf_text = extracted.read_text(encoding="utf-8")
|
||||
self.assertIn("QUALITÉ DU", pdf_text)
|
||||
self.assertIn("LONG RÉVEIL", pdf_text.upper())
|
||||
self.assertIn("AVERTISSEMENT : 7 jours non validés", pdf_text)
|
||||
for forbidden in (
|
||||
"WAKE",
|
||||
"TREATMENT",
|
||||
"AWAKE",
|
||||
"sleepiness",
|
||||
"medication intake",
|
||||
"nights",
|
||||
):
|
||||
self.assertNotIn(forbidden, pdf_text)
|
||||
subprocess.run(
|
||||
[
|
||||
"pdftoppm",
|
||||
"-png",
|
||||
"-f",
|
||||
"1",
|
||||
"-singlefile",
|
||||
"-r",
|
||||
"150",
|
||||
str(pdf_path),
|
||||
str(raster_prefix),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
bidi.close()
|
||||
finally:
|
||||
if driver is not None:
|
||||
driver.quit()
|
||||
try:
|
||||
os.killpg(server.pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
server.wait(timeout=10)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -38,8 +38,8 @@ def digest(value) -> str:
|
|||
|
||||
def require_schema(db: sqlite3.Connection) -> None:
|
||||
version = db.execute("PRAGMA user_version").fetchone()[0]
|
||||
if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28):
|
||||
raise CausalError(f"desktop schema v20-v28 required, found v{version}")
|
||||
if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
|
||||
raise CausalError(f"desktop schema v20-v29 required, found v{version}")
|
||||
|
||||
|
||||
def target_snapshot(db: sqlite3.Connection, kind: str, target: str):
|
||||
|
|
|
|||
|
|
@ -182,8 +182,8 @@ def causal_payload(draft):
|
|||
|
||||
def require_schema(connection):
|
||||
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||
if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28):
|
||||
raise LifecycleError(f"desktop schema v19-v28 required, found v{version}")
|
||||
if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
|
||||
raise LifecycleError(f"desktop schema v19-v29 required, found v{version}")
|
||||
|
||||
|
||||
def import_document(connection, document, own_transaction=True):
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from pathlib import Path
|
|||
from trainlog_sqlite import configure_connection
|
||||
|
||||
|
||||
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28)
|
||||
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29)
|
||||
MEASUREMENT_FIELDS = (
|
||||
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
|
||||
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm",
|
||||
|
|
@ -125,7 +125,7 @@ def build_export(connection, generated_at):
|
|||
"""Build the raw export using only explicitly ordered SELECT statements."""
|
||||
schema_version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||
if schema_version not in SUPPORTED_SCHEMA_VERSIONS:
|
||||
raise ValueError("schema desktop v15-v28 requis")
|
||||
raise ValueError("schema desktop v15-v29 requis")
|
||||
|
||||
root = {
|
||||
"format": "TRAINLOG_AI_EXPORT",
|
||||
|
|
|
|||
|
|
@ -20,9 +20,9 @@ ID_PREFIX = "aid_"
|
|||
def ensure_publication_state(connection):
|
||||
"""Install the additive v18 cursor for direct syncd/tool invocations."""
|
||||
owned_transaction = not connection.in_transaction
|
||||
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28)
|
||||
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v18-v28 requis")
|
||||
raise ValueError("schema desktop v18-v29 requis")
|
||||
columns = {row[1] for row in connection.execute(
|
||||
"PRAGMA table_info(ai_session_drafts)")}
|
||||
if "published_at" not in columns:
|
||||
|
|
|
|||
|
|
@ -28,9 +28,9 @@ def main():
|
|||
args = parser.parse_args()
|
||||
connection = connect_database(args.database)
|
||||
try:
|
||||
supported_versions = tuple(range(8, 29))
|
||||
supported_versions = tuple(range(8, 30))
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v8 à v28 requis")
|
||||
raise ValueError("schema desktop v8 à v29 requis")
|
||||
known_equipment = load_supplied_equipment_ids(args.catalog)
|
||||
known_equipment.update(row[0] for row in connection.execute(
|
||||
"SELECT equipment_id FROM custom_equipment"))
|
||||
|
|
|
|||
|
|
@ -24,9 +24,9 @@ def main(complete_causal_envelope=False):
|
|||
connection = connect_database(args.database)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
supported_versions = tuple(range(8, 29))
|
||||
supported_versions = tuple(range(8, 30))
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v8 à v28 requis")
|
||||
raise ValueError("schema desktop v8 à v29 requis")
|
||||
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
|
||||
raise ValueError("causal equipment protection requires the staged artifact")
|
||||
equipment = [dict(row) for row in connection.execute(
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ def main():
|
|||
con = connect_database(args.database)
|
||||
try:
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (
|
||||
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28):
|
||||
raise ValueError("schema desktop v12-v28 requis")
|
||||
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
|
||||
raise ValueError("schema desktop v12-v29 requis")
|
||||
rows = con.execute(
|
||||
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
|
||||
"ORDER BY source_exercise_id COLLATE BINARY"
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ def main(complete_causal_envelope=False):
|
|||
connection = connect_database(args.database)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
supported_versions = tuple(range(11, 29))
|
||||
supported_versions = tuple(range(11, 30))
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v11-v16 requis")
|
||||
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone():
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ def main():
|
|||
con.row_factory = sqlite3.Row
|
||||
try:
|
||||
version = con.execute("PRAGMA user_version").fetchone()[0]
|
||||
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28):
|
||||
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
|
||||
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
|
||||
rows = con.execute("""
|
||||
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ def main():
|
|||
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
|
||||
# catalogue artifact is unchanged, but it must read the current
|
||||
# canonical desktop schema rather than accept a stale pre-v8 database.
|
||||
if version not in range(8, 29):
|
||||
if version not in range(8, 30):
|
||||
raise SystemExit(
|
||||
"PC_CATALOG_EXPORT=FAIL "
|
||||
f"schema={version}"
|
||||
|
|
|
|||
|
|
@ -83,9 +83,9 @@ def main(complete_causal_envelope=False):
|
|||
con.row_factory = sqlite3.Row
|
||||
try:
|
||||
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
|
||||
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28):
|
||||
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
|
||||
raise ValueError("mobile V4 exige le schéma desktop v19 ou v20")
|
||||
supported_versions = range(11, 29)
|
||||
supported_versions = range(11, 30)
|
||||
legacy_v2_schema = args.version == 2 and schema_version == 10
|
||||
if schema_version not in supported_versions and not legacy_v2_schema:
|
||||
raise ValueError("schema desktop v11-v16 requis (v10 accepté pour export V2 explicite)")
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ def nullable(row, index):
|
|||
|
||||
def export_programs(database: Path) -> dict:
|
||||
with closing(connect_database(database)) as db:
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28):
|
||||
raise ValueError("desktop schema v26, v27 or v28 required")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29):
|
||||
raise ValueError("desktop schema v26, v27, v28 or v29 required")
|
||||
programs = []
|
||||
for row in db.execute(
|
||||
"SELECT program_id,revision_id,title,note,state,start_date,end_date,created_at,"
|
||||
|
|
|
|||
|
|
@ -17,17 +17,26 @@ MAX_WITHDRAWALS = 128
|
|||
|
||||
|
||||
def build_export(connection: sqlite3.Connection) -> dict:
|
||||
supported_versions = (24, 25, 26, 27, 28)
|
||||
supported_versions = (24, 25, 26, 27, 28, 29)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("desktop schema v24, v25, v26, v27 or v28 required")
|
||||
raise ValueError("desktop schema v24, v25, v26, v27, v28 or v29 required")
|
||||
deliveries = []
|
||||
rows = connection.execute(
|
||||
"SELECT d.delivery_id,d.preparation_id,d.revision_id,d.execution_session_id,d.state,"
|
||||
"r.title,r.session_type,r.planned_for,r.notes,p.source_proposal_id,p.source_payload_sha256 "
|
||||
"SELECT d.delivery_id,d.preparation_id,d.revision_id,d.execution_session_id,"
|
||||
"CASE WHEN d.state='acknowledged' THEN 'remote_unknown' ELSE d.state END,"
|
||||
"r.title,r.session_type,r.planned_for,r.notes,p.source_proposal_id,p.source_payload_sha256,"
|
||||
"p.source_program_id,p.source_program_session_id "
|
||||
"FROM session_preparation_deliveries d "
|
||||
"JOIN session_preparations p ON p.preparation_id=d.preparation_id "
|
||||
"JOIN session_preparation_revisions r ON r.revision_id=d.revision_id "
|
||||
"WHERE p.withdrawn_at IS NULL AND d.state IN('pending','remote_unknown') "
|
||||
"WHERE p.withdrawn_at IS NULL AND ("
|
||||
"d.state IN('pending','remote_unknown') OR ("
|
||||
"d.state='acknowledged' AND p.source_program_id IS NOT NULL AND "
|
||||
"p.source_program_session_id IS NOT NULL AND NOT EXISTS("
|
||||
"SELECT 1 FROM program_session_executions x "
|
||||
"WHERE x.program_session_id=p.source_program_session_id "
|
||||
"AND x.program_id=p.source_program_id AND x.session_id=d.execution_session_id"
|
||||
"))) "
|
||||
"ORDER BY d.created_at,d.delivery_id LIMIT ?",
|
||||
(MAX_DELIVERIES + 1,),
|
||||
).fetchall()
|
||||
|
|
@ -48,6 +57,7 @@ def build_export(connection: sqlite3.Connection) -> dict:
|
|||
"execution_session_id": row[3], "state": row[4], "title": row[5],
|
||||
"session_type": row[6], "planned_for": row[7], "notes": row[8],
|
||||
"source_proposal_id": row[9], "source_payload_sha256": row[10],
|
||||
"source_program_id": row[11], "source_program_session_id": row[12],
|
||||
"occurrences": [{
|
||||
"entry_id": entry[0], "position": entry[1], "exercise_id": entry[2],
|
||||
"equipment_id": entry[3], "recording_mode": entry[4],
|
||||
|
|
|
|||
|
|
@ -10,9 +10,9 @@ def main(complete_causal_envelope=False):
|
|||
parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args()
|
||||
db=connect_database(args.database)
|
||||
try:
|
||||
supported_versions = range(15, 29)
|
||||
supported_versions = range(15, 30)
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ValueError("schema desktop v15-v28 requis")
|
||||
raise ValueError("schema desktop v15-v29 requis")
|
||||
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0] >= 20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal feedback protection requires the staged artifact")
|
||||
roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall()
|
||||
followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall()
|
||||
|
|
|
|||
|
|
@ -193,9 +193,9 @@ def import_payload(connection, payload, imported_at=None):
|
|||
INVARIANT: validation and every child insert share one IMMEDIATE transaction;
|
||||
any failure leaves no draft, child, or import-identity row behind.
|
||||
"""
|
||||
supported_versions = range(18, 29)
|
||||
supported_versions = range(18, 30)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ImportFailure("schema desktop v18-v28 requis")
|
||||
raise ImportFailure("schema desktop v18-v29 requis")
|
||||
draft, entries = validate(payload, connection)
|
||||
digest = canonical_digest(payload)
|
||||
imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||
|
|
|
|||
|
|
@ -206,7 +206,7 @@ def main():
|
|||
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
|
||||
connection = connect_database(args.database)
|
||||
try:
|
||||
supported_versions = range(8, 29)
|
||||
supported_versions = range(8, 30)
|
||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
|
||||
fail("schema desktop v8 à v21 requis")
|
||||
proof_path = args.mobile_export
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ def validate(payload, reserved):
|
|||
|
||||
def apply_definitions(connection, definitions, complete_causal_envelope=False):
|
||||
"""Apply validated definitions without owning the surrounding transaction."""
|
||||
supported_versions = range(8, 29)
|
||||
supported_versions = range(8, 30)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
fail("schema desktop v8 à v21 requis")
|
||||
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ def load(path):
|
|||
|
||||
def apply_aliases(con, aliases):
|
||||
"""Apply parsed aliases inside the caller-owned transaction."""
|
||||
supported_versions = range(12, 29)
|
||||
supported_versions = range(12, 30)
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
fail("schema desktop v12-v21 requis")
|
||||
for source, canonical in aliases:
|
||||
|
|
|
|||
|
|
@ -151,9 +151,9 @@ def replace(connection, row_id, primary, secondary):
|
|||
|
||||
def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False):
|
||||
"""Apply validated zone rows without owning transaction boundaries."""
|
||||
supported_versions = range(11, 29)
|
||||
supported_versions = range(11, 30)
|
||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise ImportFailure("schema desktop v11-v28 requis")
|
||||
raise ImportFailure("schema desktop v11-v29 requis")
|
||||
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0]>=20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ImportFailure("causal BODY ZONES protection requires the staged artifact")
|
||||
connection.execute("PRAGMA foreign_keys=ON");grouped={}
|
||||
# CONTRACT: resolve complete alias groups before the first write; direct
|
||||
|
|
|
|||
|
|
@ -50,8 +50,8 @@ def apply_profile_state(con, root, allow_pending=False):
|
|||
if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide")
|
||||
parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row
|
||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (
|
||||
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28):
|
||||
fail("schema desktop v17-v28 requis")
|
||||
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
|
||||
fail("schema desktop v17-v29 requis")
|
||||
applied=equal=ancestor=pending=0;seen=set()
|
||||
for item in root["exercises"]:
|
||||
validate(item);eid=item["exercise_id"]
|
||||
|
|
|
|||
|
|
@ -847,9 +847,9 @@ def require_supported_schema(connection):
|
|||
|
||||
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
|
||||
# readable for legacy artifacts and are never made to fake that table.
|
||||
if version not in range(5, 29):
|
||||
if version not in range(5, 30):
|
||||
raise ImportFailure(
|
||||
f"base desktop schema v5 à v28 attendue, version trouvée: {version}"
|
||||
f"base desktop schema v5 à v29 attendue, version trouvée: {version}"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -2002,8 +2002,8 @@ def apply_payload(connection, payload, trace_exercises=False, complete_causal_en
|
|||
require_supported_schema(connection)
|
||||
|
||||
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
|
||||
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28):
|
||||
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v28")
|
||||
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29):
|
||||
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v29")
|
||||
if schema_version >= 20:
|
||||
protected = {(row[0], row[1]) for row in connection.execute(
|
||||
"SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")}
|
||||
|
|
|
|||
|
|
@ -52,9 +52,9 @@ def merge_revisions(db,table,parent_column,parent_id,revisions):
|
|||
def apply_feedback(db,root,complete_causal_envelope=False):
|
||||
roots_added=roots_skipped=revisions_added=revisions_skipped=0
|
||||
db.execute("PRAGMA foreign_keys=ON")
|
||||
supported_versions = range(15, 29)
|
||||
supported_versions = range(15, 30)
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
fail("schema desktop v15-v28 requis")
|
||||
fail("schema desktop v15-v29 requis")
|
||||
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0]>=20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone():fail("causal feedback protection requires the staged artifact")
|
||||
seen=set()
|
||||
for index,item in enumerate(root["exercise_feedback"]):
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
#!/usr/bin/env bash
|
||||
# Trainlog install syncd user helper.
|
||||
# Owns this command-line workflow while preserving the versioned database and exchange contracts it consumes.
|
||||
# Install the private Trainlog automatic synchronization user services.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(
|
||||
|
|
@ -10,41 +9,50 @@ ROOT="$(
|
|||
|
||||
SYNC_ONCE="$ROOT/build/tui/trainlog-sync-once"
|
||||
MTP_ADAPTER="$ROOT/build/tui/trainlog-generation-mtp-adapter"
|
||||
BT_ADAPTER="$ROOT/tools/trainlog_generation_bt_adapter.py"
|
||||
BT_AGENT="$ROOT/tools/trainlog_bt_agent.py"
|
||||
DAEMON="$ROOT/tools/trainlog_syncd.py"
|
||||
GEN_CONFIG="${TRAINLOG_SYNC_GENERATION_CONFIG:-$HOME/.config/trainlog/sync-generation-config-v1.json}"
|
||||
|
||||
if [[ ! -x "$SYNC_ONCE" ]]; then
|
||||
echo "missing executable: $SYNC_ONCE" >&2
|
||||
echo "run meson compile -C build first" >&2
|
||||
exit 1
|
||||
fi
|
||||
for executable in "$SYNC_ONCE" "$MTP_ADAPTER" "$BT_ADAPTER" "$BT_AGENT" "$DAEMON"; do
|
||||
if [[ ! -x "$executable" ]]; then
|
||||
echo "missing executable: $executable" >&2
|
||||
echo "run meson compile -C build first and keep Trainlog tools executable" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ! -x "$MTP_ADAPTER" ]]; then
|
||||
echo "missing executable: $MTP_ADAPTER" >&2
|
||||
echo "run meson compile -C build first" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$HOME/.local/bin" "$HOME/.config/systemd/user"
|
||||
|
||||
mkdir -p \
|
||||
"$HOME/.local/bin" \
|
||||
"$HOME/.config/systemd/user"
|
||||
ln -sfn "$SYNC_ONCE" "$HOME/.local/bin/trainlog-sync-once"
|
||||
ln -sfn "$DAEMON" "$HOME/.local/bin/trainlog-syncd"
|
||||
ln -sfn "$BT_AGENT" "$HOME/.local/bin/trainlog-btd"
|
||||
|
||||
ln -sfn \
|
||||
"$SYNC_ONCE" \
|
||||
"$HOME/.local/bin/trainlog-sync-once"
|
||||
|
||||
ln -sfn \
|
||||
"$DAEMON" \
|
||||
"$HOME/.local/bin/trainlog-syncd"
|
||||
|
||||
UNIT="$HOME/.config/systemd/user/trainlog-syncd.service"
|
||||
|
||||
cat > "$UNIT" <<EOF
|
||||
cat > "$HOME/.config/systemd/user/trainlog-syncd.service" <<EOF
|
||||
[Unit]
|
||||
Description=Trainlog Android MTP synchronization agent
|
||||
Description=Trainlog automatic synchronization agent
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/python3 "$DAEMON" --sync-once "$SYNC_ONCE" --mtp-adapter "$MTP_ADAPTER" --interval 3
|
||||
Environment=TRAINLOG_SYNC_BT_ADAPTER=$BT_ADAPTER
|
||||
Environment=TRAINLOG_SYNC_MTP_ADAPTER=$MTP_ADAPTER
|
||||
ExecStart=/usr/bin/python3 "$DAEMON" --sync-once "$SYNC_ONCE" --bt-adapter "$BT_ADAPTER" --mtp-adapter "$MTP_ADAPTER" --interval 3
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
EOF
|
||||
|
||||
cat > "$HOME/.config/systemd/user/trainlog-btd.service" <<EOF
|
||||
[Unit]
|
||||
Description=Trainlog Bluetooth RFCOMM agent
|
||||
ConditionPathExists=$GEN_CONFIG
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecCondition=/usr/bin/grep -Eq '"bluetooth_enabled"[[:space:]]*:[[:space:]]*true' "$GEN_CONFIG"
|
||||
ExecStart=/usr/bin/python3 "$BT_AGENT" --config "$GEN_CONFIG"
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
|
||||
|
|
@ -54,6 +62,11 @@ EOF
|
|||
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now trainlog-syncd.service
|
||||
systemctl --user enable trainlog-btd.service
|
||||
if [[ -f "$GEN_CONFIG" ]] && grep -Eq '"bluetooth_enabled"[[:space:]]*:[[:space:]]*true' "$GEN_CONFIG"; then
|
||||
systemctl --user restart trainlog-btd.service
|
||||
fi
|
||||
|
||||
echo "TRAINLOG_SYNCD_INSTALL=PASS"
|
||||
systemctl --user --no-pager --full status trainlog-syncd.service || true
|
||||
systemctl --user --no-pager --full status trainlog-btd.service || true
|
||||
|
|
|
|||
|
|
@ -12,10 +12,12 @@ from pathlib import Path
|
|||
ROOT = Path(__file__).resolve().parents[1]
|
||||
RUNTIME_TOOLS = [
|
||||
"trainlog_syncd.py",
|
||||
"trainlog_bt_agent.py",
|
||||
"sync_orchestrator.py",
|
||||
"sync_peer_worker.py",
|
||||
"sync_drive_transport.py",
|
||||
"sync_generation_exchange.py",
|
||||
"sleep_diary_exchange.py",
|
||||
"trainlog_sqlite.py",
|
||||
"validate_json.py",
|
||||
"validate_training_knowledge.py",
|
||||
|
|
@ -80,13 +82,17 @@ def main() -> int:
|
|||
shutil.copy2(binary, args.output / "libexec/trainlog")
|
||||
shutil.copy2(sync_once, args.output / "libexec/trainlog-sync-once")
|
||||
shutil.copy2(mtp_adapter, args.output / "libexec/trainlog-generation-mtp-adapter")
|
||||
shutil.copy2(
|
||||
ROOT / "tools/trainlog_generation_bt_adapter.py",
|
||||
args.output / "libexec/trainlog-generation-bt-adapter",
|
||||
)
|
||||
for name in RUNTIME_TOOLS:
|
||||
shutil.copy2(ROOT / "tools" / name, args.output / "tools" / name)
|
||||
for source in sorted((ROOT / "catalog").glob("*.json")):
|
||||
shutil.copy2(source, args.output / "catalog" / source.name)
|
||||
launcher = args.output / "bin/trainlog"
|
||||
launcher.write_text(
|
||||
'#!/bin/sh\nset -eu\nscript=$(readlink -f -- "$0")\nroot=$(CDPATH= cd -- "$(dirname -- "$script")/.." && pwd)\nexport TRAINLOG_SYNC_TOOLS_DIR="$root/tools"\nexport TRAINLOG_SYNC_MTP_ADAPTER="$root/libexec/trainlog-generation-mtp-adapter"\nexec "$root/libexec/trainlog" "$@"\n'
|
||||
'#!/bin/sh\nset -eu\nscript=$(readlink -f -- "$0")\nroot=$(CDPATH= cd -- "$(dirname -- "$script")/.." && pwd)\nexport TRAINLOG_SYNC_TOOLS_DIR="$root/tools"\nexport TRAINLOG_SYNC_BT_ADAPTER="$root/libexec/trainlog-generation-bt-adapter"\nexport TRAINLOG_SYNC_MTP_ADAPTER="$root/libexec/trainlog-generation-mtp-adapter"\nexec "$root/libexec/trainlog" "$@"\n'
|
||||
)
|
||||
sync_once_launcher = args.output / "bin/trainlog-sync-once"
|
||||
sync_once_launcher.write_text(
|
||||
|
|
@ -94,7 +100,11 @@ def main() -> int:
|
|||
)
|
||||
syncd_launcher = args.output / "bin/trainlog-syncd"
|
||||
syncd_launcher.write_text(
|
||||
'#!/bin/sh\nset -eu\nscript=$(readlink -f -- "$0")\nroot=$(CDPATH= cd -- "$(dirname -- "$script")/.." && pwd)\nexport TRAINLOG_SYNC_MTP_ADAPTER="$root/libexec/trainlog-generation-mtp-adapter"\nexec python3 "$root/tools/trainlog_syncd.py" --sync-once "$root/bin/trainlog-sync-once" "$@"\n'
|
||||
'#!/bin/sh\nset -eu\nscript=$(readlink -f -- "$0")\nroot=$(CDPATH= cd -- "$(dirname -- "$script")/.." && pwd)\nexport TRAINLOG_SYNC_BT_ADAPTER="$root/libexec/trainlog-generation-bt-adapter"\nexport TRAINLOG_SYNC_MTP_ADAPTER="$root/libexec/trainlog-generation-mtp-adapter"\nexec python3 "$root/tools/trainlog_syncd.py" --sync-once "$root/bin/trainlog-sync-once" "$@"\n'
|
||||
)
|
||||
btd_launcher = args.output / "bin/trainlog-btd"
|
||||
btd_launcher.write_text(
|
||||
'#!/bin/sh\nset -eu\nscript=$(readlink -f -- "$0")\nroot=$(CDPATH= cd -- "$(dirname -- "$script")/.." && pwd)\nexec python3 "$root/tools/trainlog_bt_agent.py" "$@"\n'
|
||||
)
|
||||
files = sorted(path for path in args.output.rglob("*") if path.is_file())
|
||||
inventory = {
|
||||
|
|
@ -103,9 +113,9 @@ def main() -> int:
|
|||
"source_commit": subprocess.check_output(
|
||||
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
|
||||
).strip(),
|
||||
"product_version": "0.1.3",
|
||||
"desktop_schema": 28,
|
||||
"android_schema": 25,
|
||||
"product_version": "0.1.4",
|
||||
"desktop_schema": 29,
|
||||
"android_schema": 26,
|
||||
"protocols": [
|
||||
"mobile-export-v3",
|
||||
"mobile-history-v4",
|
||||
|
|
@ -114,15 +124,19 @@ def main() -> int:
|
|||
"generation-manifest-v1",
|
||||
"generation-ack-v1",
|
||||
"generation-archive-v1",
|
||||
"bluetooth-files-v1",
|
||||
"session-preparations-v2",
|
||||
"ai-session-drafts-v2",
|
||||
"programs-v1",
|
||||
"program-executions-v1",
|
||||
"sleep-diary-v1",
|
||||
],
|
||||
"entry_points": [
|
||||
"bin/trainlog",
|
||||
"bin/trainlog-sync-once",
|
||||
"bin/trainlog-syncd",
|
||||
"bin/trainlog-btd",
|
||||
"libexec/trainlog-generation-bt-adapter",
|
||||
"libexec/trainlog-generation-mtp-adapter",
|
||||
"tools/sync_orchestrator.py",
|
||||
],
|
||||
|
|
@ -133,6 +147,9 @@ def main() -> int:
|
|||
"libuuid",
|
||||
"libudev",
|
||||
"libmtp",
|
||||
"bluez",
|
||||
"python-dbus",
|
||||
"python-gobject",
|
||||
"notcurses",
|
||||
"coreutils-readlink",
|
||||
],
|
||||
|
|
@ -151,8 +168,10 @@ def main() -> int:
|
|||
os.chmod(args.output / "bin/trainlog", 0o755)
|
||||
os.chmod(args.output / "bin/trainlog-sync-once", 0o755)
|
||||
os.chmod(args.output / "bin/trainlog-syncd", 0o755)
|
||||
os.chmod(args.output / "bin/trainlog-btd", 0o755)
|
||||
os.chmod(args.output / "libexec/trainlog", 0o755)
|
||||
os.chmod(args.output / "libexec/trainlog-sync-once", 0o755)
|
||||
os.chmod(args.output / "libexec/trainlog-generation-bt-adapter", 0o755)
|
||||
os.chmod(args.output / "libexec/trainlog-generation-mtp-adapter", 0o755)
|
||||
return 0
|
||||
|
||||
|
|
|
|||
|
|
@ -87,8 +87,8 @@ def load(path: Path) -> dict:
|
|||
|
||||
|
||||
def apply_executions(db: sqlite3.Connection, root: dict) -> tuple[int, int, int]:
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28):
|
||||
fail("desktop schema v27 or v28 required")
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28, 29):
|
||||
fail("desktop schema v27, v28 or v29 required")
|
||||
added = 0
|
||||
advanced = 0
|
||||
skipped = 0
|
||||
|
|
|
|||
436
tools/sleep_diary_exchange.py
Normal file
436
tools/sleep_diary_exchange.py
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Export, validate and merge the versioned Sleep Diary V1 companion."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from trainlog_sqlite import connect_database
|
||||
|
||||
|
||||
FORMAT = "trainlog-sleep-diary"
|
||||
VERSION = 1
|
||||
MAX_BYTES = 16 * 1024 * 1024
|
||||
MAX_ENTRIES = 3660
|
||||
MAX_EVENTS = 64
|
||||
MAX_INTAKES = 32
|
||||
MAX_MEDICATIONS = 1000
|
||||
MAX_ANCESTRY = 256
|
||||
UUID4 = r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"
|
||||
ENTRY_ID = re.compile(rf"^sl_{UUID4}$")
|
||||
REVISION_ID = re.compile(rf"^slr_{UUID4}$")
|
||||
EVENT_ID = re.compile(rf"^sle_{UUID4}$")
|
||||
MEDICATION_ID = re.compile(rf"^med_{UUID4}$")
|
||||
MEDICATION_REVISION_ID = re.compile(rf"^medr_{UUID4}$")
|
||||
INTAKE_ID = re.compile(rf"^mdi_{UUID4}$")
|
||||
POINTS = {"bed_time", "final_get_up", "night_get_up", "daytime_sleepiness"}
|
||||
INTERVALS = {"sleep", "nap", "long_awake", "half_sleep"}
|
||||
QUALITIES = {"TB", "B", "Moy", "M", "TM"}
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def unique_object(pairs):
|
||||
result = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
fail("duplicate JSON field: " + key)
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def timestamp(value: object) -> str:
|
||||
if not isinstance(value, str) or re.search(r"(?:Z|[+-][0-9]{2}:[0-9]{2})$", value) is None:
|
||||
fail("invalid sleep diary timestamp")
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as error:
|
||||
raise ValueError("invalid sleep diary timestamp") from error
|
||||
if parsed.utcoffset() is None:
|
||||
fail("sleep diary timestamp lacks an offset")
|
||||
return value
|
||||
|
||||
|
||||
def validate(root: object) -> dict:
|
||||
if not isinstance(root, dict) or set(root) != {"format", "version", "generated_at", "entries", "medications"}:
|
||||
fail("invalid sleep diary envelope")
|
||||
if root["format"] != FORMAT or type(root["version"]) is not int or root["version"] != VERSION:
|
||||
fail("unsupported sleep diary format")
|
||||
timestamp(root["generated_at"])
|
||||
entries = root["entries"]
|
||||
if not isinstance(entries, list) or len(entries) > MAX_ENTRIES:
|
||||
fail("sleep diary entry bound exceeded")
|
||||
seen_entries = set()
|
||||
for item in entries:
|
||||
keys = {"entry_id", "night_start_date", "night_end_date", "created_at", "updated_at",
|
||||
"revision_id", "parent_revision_id", "deleted", "sleep_quality", "wake_quality",
|
||||
"day_form", "treatment_and_notes", "events", "intakes"}
|
||||
if not isinstance(item, dict) or set(item) not in (keys, keys | {"ancestry"}):
|
||||
fail("invalid sleep diary entry")
|
||||
if not ENTRY_ID.fullmatch(item["entry_id"]) or item["entry_id"] in seen_entries:
|
||||
fail("invalid or duplicate sleep diary entry identity")
|
||||
seen_entries.add(item["entry_id"])
|
||||
if not REVISION_ID.fullmatch(item["revision_id"]):
|
||||
fail("invalid sleep diary revision identity")
|
||||
parent = item["parent_revision_id"]
|
||||
if parent is not None and (not isinstance(parent, str) or not REVISION_ID.fullmatch(parent) or parent == item["revision_id"]):
|
||||
fail("invalid sleep diary parent revision")
|
||||
ancestry = item.get("ancestry")
|
||||
if ancestry is not None:
|
||||
if (not isinstance(ancestry, list) or not 1 <= len(ancestry) <= MAX_ANCESTRY or
|
||||
ancestry[0] != item["revision_id"] or len(set(ancestry)) != len(ancestry) or
|
||||
any(not isinstance(value, str) or not REVISION_ID.fullmatch(value)
|
||||
for value in ancestry)):
|
||||
fail("invalid sleep diary ancestry")
|
||||
if parent is None:
|
||||
if len(ancestry) != 1:
|
||||
fail("invalid sleep diary ancestry root")
|
||||
elif len(ancestry) < 2 or ancestry[1] != parent:
|
||||
fail("invalid sleep diary ancestry parent")
|
||||
try:
|
||||
start_date = date.fromisoformat(item["night_start_date"])
|
||||
end_date = date.fromisoformat(item["night_end_date"])
|
||||
except (TypeError, ValueError) as error:
|
||||
raise ValueError("invalid sleep diary date") from error
|
||||
if start_date >= end_date:
|
||||
fail("sleep diary date range is empty")
|
||||
created = datetime.fromisoformat(timestamp(item["created_at"]).replace("Z", "+00:00"))
|
||||
updated = datetime.fromisoformat(timestamp(item["updated_at"]).replace("Z", "+00:00"))
|
||||
if updated < created or type(item["deleted"]) is not bool:
|
||||
fail("invalid sleep diary lifecycle")
|
||||
for name in ("sleep_quality", "wake_quality", "day_form"):
|
||||
if item[name] is not None and item[name] not in QUALITIES:
|
||||
fail("invalid sleep diary appreciation")
|
||||
notes = item["treatment_and_notes"]
|
||||
if not isinstance(notes, str) or len(notes.encode()) > 16384:
|
||||
fail("sleep diary notes bound exceeded")
|
||||
events = item["events"]
|
||||
if not isinstance(events, list) or len(events) > MAX_EVENTS:
|
||||
fail("sleep diary event bound exceeded")
|
||||
seen_events = set()
|
||||
for event in events:
|
||||
if not isinstance(event, dict) or set(event) != {"event_id", "type", "start_at", "end_at"}:
|
||||
fail("invalid sleep diary event")
|
||||
if not EVENT_ID.fullmatch(event["event_id"]) or event["event_id"] in seen_events:
|
||||
fail("invalid or duplicate sleep event identity")
|
||||
seen_events.add(event["event_id"])
|
||||
start = datetime.fromisoformat(timestamp(event["start_at"]).replace("Z", "+00:00"))
|
||||
if event["type"] in POINTS:
|
||||
if event["end_at"] is not None:
|
||||
fail("point sleep event has an end")
|
||||
elif event["type"] in INTERVALS:
|
||||
end = datetime.fromisoformat(timestamp(event["end_at"]).replace("Z", "+00:00"))
|
||||
if end <= start:
|
||||
fail("sleep interval is not positive in absolute time")
|
||||
else:
|
||||
fail("unknown sleep diary event type")
|
||||
intakes = item["intakes"]
|
||||
if not isinstance(intakes, list) or len(intakes) > MAX_INTAKES:
|
||||
fail("medication intake bound exceeded")
|
||||
seen_intakes = set()
|
||||
for intake in intakes:
|
||||
keys = {"intake_id", "medication_id", "medication_name", "taken_at",
|
||||
"dose_value", "dose_unit", "note", "created_at"}
|
||||
if not isinstance(intake, dict) or set(intake) != keys:
|
||||
fail("invalid medication intake")
|
||||
if (not INTAKE_ID.fullmatch(intake["intake_id"]) or
|
||||
intake["intake_id"] in seen_intakes or
|
||||
not MEDICATION_ID.fullmatch(intake["medication_id"])):
|
||||
fail("invalid medication intake identity")
|
||||
seen_intakes.add(intake["intake_id"])
|
||||
timestamp(intake["taken_at"]); timestamp(intake["created_at"])
|
||||
if not isinstance(intake["medication_name"], str) or not intake["medication_name"].strip():
|
||||
fail("invalid medication snapshot name")
|
||||
has_dose = intake["dose_value"] is not None
|
||||
if has_dose != (intake["dose_unit"] is not None) or (has_dose and
|
||||
(type(intake["dose_value"]) not in (int, float) or intake["dose_value"] <= 0 or
|
||||
not isinstance(intake["dose_unit"], str) or not intake["dose_unit"].strip())):
|
||||
fail("invalid medication intake dose")
|
||||
medications = root["medications"]
|
||||
if not isinstance(medications, list) or len(medications) > MAX_MEDICATIONS:
|
||||
fail("medication catalog bound exceeded")
|
||||
seen_medications = set()
|
||||
for medication in medications:
|
||||
keys = {"medication_id", "revision_id", "parent_revision_id", "created_at", "updated_at",
|
||||
"name", "default_dose_value", "default_dose_unit", "form", "note", "active", "deleted"}
|
||||
if not isinstance(medication, dict) or set(medication) not in (keys, keys | {"ancestry"}):
|
||||
fail("invalid medication")
|
||||
if (not MEDICATION_ID.fullmatch(medication["medication_id"]) or medication["medication_id"] in seen_medications or
|
||||
not MEDICATION_REVISION_ID.fullmatch(medication["revision_id"])):
|
||||
fail("invalid medication identity")
|
||||
seen_medications.add(medication["medication_id"])
|
||||
parent = medication["parent_revision_id"]
|
||||
if parent is not None and not MEDICATION_REVISION_ID.fullmatch(parent): fail("invalid medication parent")
|
||||
ancestry = medication.get("ancestry")
|
||||
if ancestry is not None:
|
||||
if (not isinstance(ancestry, list) or not 1 <= len(ancestry) <= MAX_ANCESTRY or
|
||||
ancestry[0] != medication["revision_id"] or len(set(ancestry)) != len(ancestry) or
|
||||
any(not isinstance(value, str) or not MEDICATION_REVISION_ID.fullmatch(value)
|
||||
for value in ancestry)):
|
||||
fail("invalid medication ancestry")
|
||||
if parent is None:
|
||||
if len(ancestry) != 1:
|
||||
fail("invalid medication ancestry root")
|
||||
elif len(ancestry) < 2 or ancestry[1] != parent:
|
||||
fail("invalid medication ancestry parent")
|
||||
timestamp(medication["created_at"]); timestamp(medication["updated_at"])
|
||||
if not isinstance(medication["name"], str) or not medication["name"].strip(): fail("invalid medication name")
|
||||
has_dose = medication["default_dose_value"] is not None
|
||||
if has_dose != (medication["default_dose_unit"] is not None) or (has_dose and
|
||||
(type(medication["default_dose_value"]) not in (int, float) or medication["default_dose_value"] <= 0)):
|
||||
fail("invalid default medication dose")
|
||||
return root
|
||||
|
||||
|
||||
def load(path: Path) -> dict:
|
||||
if path.stat().st_size > MAX_BYTES:
|
||||
fail("sleep diary artifact exceeds 16 MiB")
|
||||
return validate(json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_object))
|
||||
|
||||
|
||||
def revision_ancestry(db: sqlite3.Connection, parent_sql: str, owner_id: str,
|
||||
current_revision: str, pattern: re.Pattern[str]) -> list[str]:
|
||||
result = []
|
||||
revision = current_revision
|
||||
seen = set()
|
||||
first = True
|
||||
while revision is not None:
|
||||
if revision in seen or len(result) >= MAX_ANCESTRY or not pattern.fullmatch(revision):
|
||||
fail("invalid sleep revision ancestry")
|
||||
seen.add(revision)
|
||||
result.append(revision)
|
||||
row = db.execute(parent_sql, (owner_id, revision)).fetchone()
|
||||
if row is None:
|
||||
if first:
|
||||
fail("sleep current revision is missing")
|
||||
break
|
||||
revision = row[0]
|
||||
first = False
|
||||
return result
|
||||
|
||||
|
||||
def build(db: sqlite3.Connection) -> dict:
|
||||
entries = []
|
||||
# CONTRACT: local durability does not publish a draft. A generation may
|
||||
# capture only the current tip explicitly validated by the user.
|
||||
rows = db.execute(
|
||||
"SELECT e.entry_id,e.night_start_date,e.night_end_date,e.created_at,e.updated_at,"
|
||||
"e.current_revision_id,e.deleted FROM sleep_diary_entries e JOIN "
|
||||
"sleep_diary_publication_state p ON p.entry_id=e.entry_id AND "
|
||||
"p.validated_revision_id=e.current_revision_id ORDER BY e.entry_id"
|
||||
).fetchall()
|
||||
if len(rows) > MAX_ENTRIES:
|
||||
fail("sleep diary entry bound exceeded")
|
||||
for entry_id, night_start, night_end, created_at, updated_at, revision_id, deleted in rows:
|
||||
revision = db.execute(
|
||||
"SELECT parent_revision_id,sleep_quality,wake_quality,day_form,treatment_and_notes "
|
||||
"FROM sleep_diary_revisions WHERE revision_id=? AND entry_id=?", (revision_id, entry_id)
|
||||
).fetchone()
|
||||
if revision is None:
|
||||
fail("sleep diary current revision is missing")
|
||||
ancestry = revision_ancestry(
|
||||
db,
|
||||
"SELECT parent_revision_id FROM sleep_diary_revisions "
|
||||
"WHERE entry_id=? AND revision_id=?",
|
||||
entry_id,
|
||||
revision_id,
|
||||
REVISION_ID,
|
||||
)
|
||||
events = [
|
||||
{"event_id": row[0], "type": row[1], "start_at": row[2], "end_at": row[3]}
|
||||
for row in db.execute(
|
||||
"SELECT event_id,event_type,start_at,end_at FROM sleep_diary_events "
|
||||
"WHERE revision_id=? ORDER BY event_id", (revision_id,)
|
||||
)
|
||||
]
|
||||
intakes = [{"intake_id": row[0], "medication_id": row[1], "medication_name": row[2],
|
||||
"taken_at": row[3], "dose_value": row[4], "dose_unit": row[5],
|
||||
"note": row[6], "created_at": row[7]}
|
||||
for row in db.execute("SELECT intake_id,medication_id,medication_name,taken_at,dose_value,dose_unit,note,created_at FROM sleep_medication_intakes WHERE revision_id=? ORDER BY intake_id", (revision_id,))]
|
||||
entries.append({"entry_id": entry_id, "night_start_date": night_start,
|
||||
"night_end_date": night_end, "created_at": created_at, "updated_at": updated_at,
|
||||
"revision_id": revision_id, "parent_revision_id": revision[0], "ancestry": ancestry,
|
||||
"deleted": bool(deleted), "sleep_quality": revision[1], "wake_quality": revision[2], "day_form": revision[3],
|
||||
"treatment_and_notes": revision[4] or "", "events": events, "intakes": intakes})
|
||||
medications = []
|
||||
for row in db.execute("SELECT m.medication_id,m.created_at,m.updated_at,m.current_revision_id,m.deleted,r.parent_revision_id,r.name,r.default_dose_value,r.default_dose_unit,r.form,r.note,r.active FROM sleep_medications m JOIN sleep_medication_revisions r ON r.revision_id=m.current_revision_id ORDER BY m.medication_id"):
|
||||
ancestry = revision_ancestry(
|
||||
db,
|
||||
"SELECT parent_revision_id FROM sleep_medication_revisions "
|
||||
"WHERE medication_id=? AND revision_id=?",
|
||||
row[0],
|
||||
row[3],
|
||||
MEDICATION_REVISION_ID,
|
||||
)
|
||||
medications.append({"medication_id": row[0], "created_at": row[1], "updated_at": row[2],
|
||||
"revision_id": row[3], "deleted": bool(row[4]), "parent_revision_id": row[5],
|
||||
"ancestry": ancestry, "name": row[6], "default_dose_value": row[7],
|
||||
"default_dose_unit": row[8], "form": row[9], "note": row[10], "active": bool(row[11])})
|
||||
return validate({"format": FORMAT, "version": VERSION,
|
||||
"generated_at": datetime.now().astimezone().isoformat(), "entries": entries,
|
||||
"medications": medications})
|
||||
|
||||
|
||||
def revision_is_ancestor(db: sqlite3.Connection, parent_sql: str, owner_id: str,
|
||||
ancestor_revision: str, current_revision: str) -> bool:
|
||||
"""Return whether an incoming current-state revision is already in the local lineage."""
|
||||
revision = current_revision
|
||||
seen = set()
|
||||
while revision is not None:
|
||||
if revision == ancestor_revision:
|
||||
return True
|
||||
if revision in seen:
|
||||
fail("sleep revision chain cycle")
|
||||
seen.add(revision)
|
||||
row = db.execute(parent_sql, (owner_id, revision)).fetchone()
|
||||
if row is None:
|
||||
return False
|
||||
revision = row[0]
|
||||
return False
|
||||
|
||||
|
||||
def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]:
|
||||
validate(root)
|
||||
applied = 0
|
||||
unchanged = 0
|
||||
for medication in root["medications"]:
|
||||
local = db.execute("SELECT current_revision_id,deleted FROM sleep_medications WHERE medication_id=?", (medication["medication_id"],)).fetchone()
|
||||
if local is not None and local[0] == medication["revision_id"]:
|
||||
persisted = next(value for value in build(db)["medications"]
|
||||
if value["medication_id"] == medication["medication_id"])
|
||||
persisted_payload = dict(persisted)
|
||||
incoming_payload = dict(medication)
|
||||
persisted_payload.pop("ancestry", None)
|
||||
incoming_payload.pop("ancestry", None)
|
||||
if persisted_payload != incoming_payload:
|
||||
fail("medication revision identity reused with different content")
|
||||
continue
|
||||
if local is not None and revision_is_ancestor(
|
||||
db,
|
||||
"SELECT parent_revision_id FROM sleep_medication_revisions "
|
||||
"WHERE medication_id=? AND revision_id=?",
|
||||
medication["medication_id"],
|
||||
medication["revision_id"],
|
||||
local[0],
|
||||
):
|
||||
continue
|
||||
remote_descends_from_local = (
|
||||
local is not None and local[0] in medication.get("ancestry", [])[1:]
|
||||
)
|
||||
if local is not None and not remote_descends_from_local and medication["parent_revision_id"] != local[0]:
|
||||
fail("concurrent medication revision")
|
||||
if local is None and medication["parent_revision_id"] is not None: fail("unknown medication parent")
|
||||
if local is not None and local[1] and not medication["deleted"]: fail("medication resurrection")
|
||||
if local is None:
|
||||
db.execute("INSERT INTO sleep_medications VALUES(?,?,?,?,?)", (medication["medication_id"], medication["created_at"], medication["updated_at"], medication["revision_id"], int(medication["deleted"])))
|
||||
else:
|
||||
db.execute("UPDATE sleep_medications SET updated_at=?,current_revision_id=?,deleted=? WHERE medication_id=? AND current_revision_id=?", (medication["updated_at"], medication["revision_id"], int(medication["deleted"]), medication["medication_id"], local[0]))
|
||||
db.execute("INSERT INTO sleep_medication_revisions VALUES(?,?,?,?,?,?,?,?,?,?)", (medication["revision_id"], medication["medication_id"], medication["parent_revision_id"], medication["updated_at"], medication["name"], medication["default_dose_value"], medication["default_dose_unit"], medication["form"], medication["note"], int(medication["active"])))
|
||||
for item in root["entries"]:
|
||||
local = db.execute(
|
||||
"SELECT current_revision_id,deleted FROM sleep_diary_entries WHERE entry_id=?",
|
||||
(item["entry_id"],),
|
||||
).fetchone()
|
||||
if local is not None and local[0] == item["revision_id"]:
|
||||
persisted = next(
|
||||
value for value in build(db)["entries"] if value["entry_id"] == item["entry_id"]
|
||||
)
|
||||
persisted_payload = dict(persisted)
|
||||
incoming_payload = dict(item)
|
||||
persisted_payload.pop("ancestry", None)
|
||||
incoming_payload.pop("ancestry", None)
|
||||
if persisted_payload != incoming_payload:
|
||||
fail("sleep diary revision identity reused with different content")
|
||||
db.execute(
|
||||
"INSERT INTO sleep_diary_publication_state(entry_id,validated_revision_id,validated_at,"
|
||||
"acknowledged_revision_id,acknowledged_at) VALUES(?,?,?,?,?) ON CONFLICT(entry_id) "
|
||||
"DO UPDATE SET validated_revision_id=excluded.validated_revision_id,"
|
||||
"validated_at=excluded.validated_at,acknowledged_revision_id=excluded.acknowledged_revision_id,"
|
||||
"acknowledged_at=excluded.acknowledged_at",
|
||||
(item["entry_id"], item["revision_id"], root["generated_at"],
|
||||
item["revision_id"], root["generated_at"]),
|
||||
)
|
||||
unchanged += 1
|
||||
continue
|
||||
if local is not None and revision_is_ancestor(
|
||||
db,
|
||||
"SELECT parent_revision_id FROM sleep_diary_revisions "
|
||||
"WHERE entry_id=? AND revision_id=?",
|
||||
item["entry_id"],
|
||||
item["revision_id"],
|
||||
local[0],
|
||||
):
|
||||
# Current-state companions may legitimately arrive out of order.
|
||||
# A known ancestor is stale evidence, not a concurrent branch.
|
||||
unchanged += 1
|
||||
continue
|
||||
remote_descends_from_local = local is not None and local[0] in item.get("ancestry", [])[1:]
|
||||
if local is not None and not remote_descends_from_local and item["parent_revision_id"] != local[0]:
|
||||
fail("concurrent sleep diary revision")
|
||||
if local is None and item["parent_revision_id"] is not None:
|
||||
fail("sleep diary revision has an unknown parent")
|
||||
if local is not None and local[1] and not item["deleted"]:
|
||||
fail("sleep diary deletion cannot be resurrected")
|
||||
if local is None:
|
||||
db.execute("INSERT INTO sleep_diary_entries VALUES(?,?,?,?,?,?,?)",
|
||||
(item["entry_id"], item["night_start_date"], item["night_end_date"],
|
||||
item["created_at"], item["updated_at"], item["revision_id"], int(item["deleted"])))
|
||||
else:
|
||||
db.execute("UPDATE sleep_diary_entries SET night_start_date=?,night_end_date=?,updated_at=?,"
|
||||
"current_revision_id=?,deleted=? WHERE entry_id=? AND current_revision_id=?",
|
||||
(item["night_start_date"], item["night_end_date"], item["updated_at"],
|
||||
item["revision_id"], int(item["deleted"]), item["entry_id"], local[0]))
|
||||
db.execute("INSERT INTO sleep_diary_revisions VALUES(?,?,?,?,?,?,?,?)",
|
||||
(item["revision_id"], item["entry_id"], item["parent_revision_id"], item["updated_at"],
|
||||
item["sleep_quality"], item["wake_quality"], item["day_form"], item["treatment_and_notes"]))
|
||||
db.executemany("INSERT INTO sleep_diary_events VALUES(?,?,?,?,?)",
|
||||
[(item["revision_id"], event["event_id"], event["type"], event["start_at"], event["end_at"])
|
||||
for event in item["events"]])
|
||||
db.executemany("INSERT INTO sleep_medication_intakes VALUES(?,?,?,?,?,?,?,?,?)",
|
||||
[(item["revision_id"], intake["intake_id"], intake["medication_id"],
|
||||
intake["medication_name"], intake["taken_at"], intake["dose_value"],
|
||||
intake["dose_unit"], intake["note"], intake["created_at"])
|
||||
for intake in item["intakes"]])
|
||||
# An imported revision has already crossed the synchronization
|
||||
# boundary; recording both markers prevents it appearing as a draft.
|
||||
db.execute(
|
||||
"INSERT INTO sleep_diary_publication_state(entry_id,validated_revision_id,validated_at,"
|
||||
"acknowledged_revision_id,acknowledged_at) VALUES(?,?,?,?,?) ON CONFLICT(entry_id) "
|
||||
"DO UPDATE SET validated_revision_id=excluded.validated_revision_id,"
|
||||
"validated_at=excluded.validated_at,acknowledged_revision_id=excluded.acknowledged_revision_id,"
|
||||
"acknowledged_at=excluded.acknowledged_at",
|
||||
(item["entry_id"], item["revision_id"], root["generated_at"],
|
||||
item["revision_id"], root["generated_at"]),
|
||||
)
|
||||
applied += 1
|
||||
return applied, unchanged
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
export = sub.add_parser("export")
|
||||
export.add_argument("output", type=Path)
|
||||
export.add_argument("--database", required=True, type=Path)
|
||||
import_command = sub.add_parser("import")
|
||||
import_command.add_argument("input", type=Path)
|
||||
import_command.add_argument("--database", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
with connect_database(args.database) as db:
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] != 29:
|
||||
fail("desktop schema v29 required")
|
||||
if args.command == "export":
|
||||
args.output.write_text(json.dumps(build(db), ensure_ascii=False, sort_keys=True,
|
||||
separators=(",", ":")), encoding="utf-8")
|
||||
else:
|
||||
apply(db, load(args.input))
|
||||
db.commit()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -29,6 +29,7 @@ import import_exercise_profile_state
|
|||
import import_mobile_export
|
||||
import import_training_feedback
|
||||
import program_execution_exchange
|
||||
import sleep_diary_exchange
|
||||
import export_equipment_definitions
|
||||
import export_exercise_body_zones
|
||||
import export_pc_mobile
|
||||
|
|
@ -75,6 +76,8 @@ ARTIFACTS = (
|
|||
"session-preparations-v2.json", False, "export_session_preparations.py", ()),
|
||||
("programs-v1", "trainlog-programs", 1, "programs-v1.json", False,
|
||||
"export_programs.py", ()),
|
||||
("sleep-diary", "trainlog-sleep-diary", 1, "sleep-diary-v1.json", False,
|
||||
"sleep_diary_exchange.py", ("export",)),
|
||||
)
|
||||
SUPPORTED = {(row[1], row[2]) for row in ARTIFACTS} | {
|
||||
("trainlog-program-executions", 1),
|
||||
|
|
@ -206,9 +209,9 @@ def validate_manifest_bytes(raw: bytes, expected_consumer: str | None = None):
|
|||
|
||||
|
||||
def require_schema(db: sqlite3.Connection) -> None:
|
||||
supported_versions = (24, 25, 26, 27, 28)
|
||||
supported_versions = (24, 25, 26, 27, 28, 29)
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||
raise GenerationError("desktop schema v24, v25, v26, v27 or v28 required")
|
||||
raise GenerationError("desktop schema v24, v25, v26, v27, v28 or v29 required")
|
||||
|
||||
|
||||
def peer_identity(db: sqlite3.Connection, kind: str) -> str:
|
||||
|
|
@ -325,7 +328,8 @@ def archive_acknowledged(database: Path, owned_root: Path, consumer: str) -> int
|
|||
|
||||
def run_export(tool: str, extra: tuple[str, ...], output: Path, snapshot: Path) -> None:
|
||||
command = [sys.executable, str(ROOT / "tools" / tool)]
|
||||
if tool in ("execution_draft_exchange.py", "causal_delete_exchange.py"):
|
||||
if tool in ("execution_draft_exchange.py", "causal_delete_exchange.py",
|
||||
"sleep_diary_exchange.py"):
|
||||
command.extend(extra); command.append(str(output))
|
||||
else:
|
||||
command.append(str(output)); command.extend(extra)
|
||||
|
|
@ -360,7 +364,22 @@ def capture_desktop(database: Path, owned_root: Path, consumer: str,
|
|||
archive_acknowledged(database, owned_root, consumer)
|
||||
with closing(connect_database(database)) as admission:
|
||||
require_schema(admission)
|
||||
retained = admission.execute("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)", (consumer,)).fetchone()[0]
|
||||
# WHY: early private generation builds can contain terminal
|
||||
# acknowledged rows from before durable ACK-ledger insertion was
|
||||
# enforced. They cannot be archived without fabricating missing ACK
|
||||
# evidence, but they are terminal and must not permanently consume the
|
||||
# active admission window. Current production acknowledgements always
|
||||
# have a durable sync_acknowledgements row and remain counted until
|
||||
# archive_acknowledged() safely moves them out of the active window.
|
||||
retained = admission.execute(
|
||||
"SELECT COUNT(*) FROM sync_generations g WHERE consumer_peer_id=? "
|
||||
"AND (g.status IN('captured','published','waiting_acknowledgement') OR "
|
||||
"(g.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)",
|
||||
(consumer,),
|
||||
).fetchone()[0]
|
||||
if retained >= MAX_RETAINED_PER_PEER:
|
||||
raise GenerationError("generation retention capacity exhausted")
|
||||
stage = owned_root / "staging" / generation
|
||||
|
|
@ -426,7 +445,34 @@ def capture_desktop(database: Path, owned_root: Path, consumer: str,
|
|||
(generation, delivery["delivery_id"], delivery["revision_id"]),
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
raise GenerationError("preparation delivery changed during capture")
|
||||
# WHY: Program-provenance recovery republishes an
|
||||
# already acknowledged legacy delivery as
|
||||
# remote_unknown until its canonical execution comes
|
||||
# back. CONTRACT: this exception is allowed only for
|
||||
# the exact immutable delivery/revision/execution and
|
||||
# Program identities captured in the artifact.
|
||||
# INVARIANT: no delivery lifecycle or generation
|
||||
# relation is rewritten by the recovery emission.
|
||||
recovery = db.execute(
|
||||
"SELECT 1 FROM session_preparation_deliveries d "
|
||||
"JOIN session_preparations p ON p.preparation_id=d.preparation_id "
|
||||
"WHERE d.delivery_id=? AND d.revision_id=? "
|
||||
"AND d.execution_session_id=? AND d.state='acknowledged' "
|
||||
"AND p.source_program_id=? AND p.source_program_session_id=? "
|
||||
"AND NOT EXISTS(SELECT 1 FROM program_session_executions x "
|
||||
"WHERE x.program_id=p.source_program_id "
|
||||
"AND x.program_session_id=p.source_program_session_id "
|
||||
"AND x.session_id=d.execution_session_id)",
|
||||
(
|
||||
delivery["delivery_id"],
|
||||
delivery["revision_id"],
|
||||
delivery["execution_session_id"],
|
||||
delivery.get("source_program_id"),
|
||||
delivery.get("source_program_session_id"),
|
||||
),
|
||||
).fetchone()
|
||||
if recovery is None:
|
||||
raise GenerationError("preparation delivery changed during capture")
|
||||
for withdrawal in captured_preparations["withdrawals"]:
|
||||
cursor = db.execute(
|
||||
"UPDATE session_preparation_withdrawals SET generation_id=? "
|
||||
|
|
@ -452,6 +498,15 @@ def capture_desktop(database: Path, owned_root: Path, consumer: str,
|
|||
)
|
||||
if cursor.rowcount != 1:
|
||||
raise GenerationError("program deletion changed during capture")
|
||||
sleep_path = stage / "sleep-diary-v1.json"
|
||||
if sleep_path.is_file():
|
||||
captured_sleep = sleep_diary_exchange.load(sleep_path)
|
||||
db.executemany(
|
||||
"INSERT INTO sleep_diary_generation_entries(generation_id,entry_id,revision_id) "
|
||||
"VALUES(?,?,?)",
|
||||
[(generation, item["entry_id"], item["revision_id"])
|
||||
for item in captured_sleep["entries"]],
|
||||
)
|
||||
db.commit()
|
||||
return stage, manifest, checksum
|
||||
except Exception:
|
||||
|
|
@ -610,6 +665,8 @@ def consume_desktop(database: Path, directory: Path) -> dict:
|
|||
zones_payload = strict_json(required("body-zones").read_bytes(), MAX_ARTIFACT)
|
||||
parsed_zones = import_exercise_body_zones.parse_payload(zones_payload)
|
||||
feedback = import_training_feedback.load(required("feedback"))
|
||||
sleep_diary = (sleep_diary_exchange.load(listed["sleep-diary"])
|
||||
if "sleep-diary" in listed else None)
|
||||
program_executions = (
|
||||
program_execution_exchange.load(listed["program-executions"])
|
||||
if "program-executions" in listed else None
|
||||
|
|
@ -651,6 +708,8 @@ def consume_desktop(database: Path, directory: Path) -> dict:
|
|||
import_exercise_body_zones.apply_body_zones(db, parsed_zones, {}, complete_causal_envelope=True)
|
||||
import_training_feedback.apply_feedback(db, feedback, complete_causal_envelope=True)
|
||||
execution_draft_exchange.import_document(db, drafts, own_transaction=False)
|
||||
if sleep_diary is not None:
|
||||
sleep_diary_exchange.apply(db, sleep_diary)
|
||||
if program_executions is not None:
|
||||
program_execution_exchange.apply_executions(db, program_executions)
|
||||
ack = record_consumed(db, manifest, manifest_digest)
|
||||
|
|
@ -722,6 +781,18 @@ def accept_ack(database: Path, path: Path) -> str:
|
|||
db.execute("UPDATE sync_generations SET status=?,acknowledged_at=? WHERE generation_id=?",
|
||||
(status, ack["consumed_at"], ack["generation_id"]))
|
||||
if status == "acknowledged":
|
||||
# The relation was captured from the immutable staged artifact;
|
||||
# ACK therefore advances exactly those revisions and never a
|
||||
# later local edit.
|
||||
if db.execute("PRAGMA user_version").fetchone()[0] >= 29:
|
||||
db.execute(
|
||||
"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)",
|
||||
(ack["generation_id"], ack["consumed_at"], ack["generation_id"]),
|
||||
)
|
||||
db.execute(
|
||||
"UPDATE session_preparation_deliveries SET state='acknowledged',acknowledged_at=? "
|
||||
"WHERE generation_id=? AND state IN('pending','remote_unknown')",
|
||||
|
|
|
|||
|
|
@ -183,10 +183,59 @@ def validate_result(value: dict, run_id: str) -> dict:
|
|||
return value
|
||||
|
||||
|
||||
def bluetooth_adapter_path() -> Path:
|
||||
configured = os.environ.get("TRAINLOG_SYNC_BT_ADAPTER")
|
||||
if configured:
|
||||
return Path(configured)
|
||||
return Path(__file__).with_name("trainlog_generation_bt_adapter.py")
|
||||
|
||||
|
||||
def drive_adapter_path() -> Path:
|
||||
return Path(__file__).with_name("sync_drive_transport.py")
|
||||
|
||||
|
||||
def probe_local_adapter(
|
||||
adapter: Path,
|
||||
expected_peer: str,
|
||||
transport_root: Path,
|
||||
timeout: int,
|
||||
lock_name: str,
|
||||
) -> bool:
|
||||
if not adapter.is_absolute() or not os.access(adapter, os.X_OK):
|
||||
return False
|
||||
lock_path = transport_root.parent / lock_name
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
lock_fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600)
|
||||
probe_deadline = time.monotonic() + min(timeout, 30)
|
||||
try:
|
||||
while time.monotonic() < probe_deadline:
|
||||
try:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
break
|
||||
except BlockingIOError:
|
||||
time.sleep(0.05)
|
||||
else:
|
||||
return False
|
||||
remaining = max(0.01, probe_deadline - time.monotonic())
|
||||
try:
|
||||
probe = subprocess.run(
|
||||
[str(adapter), "pull", expected_peer, str(transport_root)],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=remaining,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
return probe.returncode == 0
|
||||
finally:
|
||||
try:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||
finally:
|
||||
os.close(lock_fd)
|
||||
|
||||
|
||||
def mirror_current_exchange(transport_root: Path, remote: str, timeout: int) -> None:
|
||||
"""Mirror only current protocol objects; never traverse application data."""
|
||||
coordination = (
|
||||
|
|
@ -339,22 +388,45 @@ def run(args: argparse.Namespace) -> int:
|
|||
"timeout_seconds",
|
||||
}
|
||||
drive_keys = {"drive_enabled", "drive_remote"}
|
||||
if set(config) not in (base_keys, base_keys | drive_keys):
|
||||
bluetooth_keys = {"bluetooth_enabled", "bluetooth_device_address"}
|
||||
allowed_key_sets = (
|
||||
base_keys,
|
||||
base_keys | drive_keys,
|
||||
base_keys | bluetooth_keys,
|
||||
base_keys | drive_keys | bluetooth_keys,
|
||||
)
|
||||
if set(config) not in allowed_key_sets:
|
||||
raise RuntimeError("invalid trusted sync configuration")
|
||||
if (
|
||||
config["format"] != "trainlog-sync-orchestrator-config"
|
||||
or config["version"] not in (1, 2)
|
||||
or config["version"] not in (1, 2, 3)
|
||||
or config["enabled"] is not True
|
||||
):
|
||||
raise RuntimeError("full-generation synchronization is disabled")
|
||||
if config["mode"] not in ("directory", "mtp", "drive", "auto"):
|
||||
if config["mode"] not in ("directory", "bt", "mtp", "drive", "auto"):
|
||||
raise RuntimeError("invalid transport mode")
|
||||
drive_enabled = config.get("drive_enabled", False)
|
||||
drive_remote = config.get("drive_remote", "")
|
||||
if type(drive_enabled) is not bool or (drive_enabled and (not isinstance(drive_remote, str) or not drive_remote)):
|
||||
if type(drive_enabled) is not bool or (
|
||||
drive_enabled and (not isinstance(drive_remote, str) or not drive_remote)
|
||||
):
|
||||
raise RuntimeError("invalid Drive configuration")
|
||||
if config["mode"] in ("drive", "auto") and not drive_enabled:
|
||||
bluetooth_enabled = config.get("bluetooth_enabled", False)
|
||||
bluetooth_address = config.get("bluetooth_device_address", "")
|
||||
if type(bluetooth_enabled) is not bool or (
|
||||
bluetooth_enabled
|
||||
and (
|
||||
config["version"] < 3
|
||||
or not isinstance(bluetooth_address, str)
|
||||
or re.fullmatch(r"[0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5}", bluetooth_address)
|
||||
is None
|
||||
)
|
||||
):
|
||||
raise RuntimeError("invalid Bluetooth configuration")
|
||||
if config["mode"] == "drive" and not drive_enabled:
|
||||
raise RuntimeError("Drive transport is not configured")
|
||||
if config["mode"] == "bt" and not bluetooth_enabled:
|
||||
raise RuntimeError("Bluetooth transport is not configured")
|
||||
for key in ("transport_root", "owned_root"):
|
||||
if not isinstance(config[key], str) or not Path(config[key]).is_absolute():
|
||||
raise RuntimeError("trusted paths must be absolute")
|
||||
|
|
@ -364,7 +436,7 @@ def run(args: argparse.Namespace) -> int:
|
|||
):
|
||||
raise RuntimeError("invalid expected peer identity")
|
||||
timeout = config["timeout_seconds"]
|
||||
if not isinstance(timeout, int) or timeout < 1 or timeout > 900:
|
||||
if type(timeout) is not int or timeout < 1 or timeout > 900:
|
||||
raise RuntimeError("invalid worker timeout")
|
||||
# CONTRACT: this is the same XDG data-directory lock used by the legacy
|
||||
# C engine, so Web, TUI and daemon admissions cannot overlap.
|
||||
|
|
@ -400,21 +472,30 @@ def run(args: argparse.Namespace) -> int:
|
|||
str(Path(__file__).resolve().parents[1] / "build/tui/trainlog-generation-mtp-adapter"),
|
||||
)
|
||||
)
|
||||
bt_adapter = bluetooth_adapter_path()
|
||||
if selected_mode == "auto":
|
||||
# WHY: cable presence is not peer availability. A successful pull
|
||||
# is the bounded proof that the configured Trainlog peer is usable.
|
||||
try:
|
||||
probe = subprocess.run(
|
||||
[str(adapter), "pull", config["expected_peer_id"], config["transport_root"]],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=min(timeout, 30),
|
||||
check=False,
|
||||
) if adapter.is_absolute() and os.access(adapter, os.X_OK) else None
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
probe = None
|
||||
selected_mode = "mtp" if probe is not None and probe.returncode == 0 else "drive"
|
||||
transport_root = Path(config["transport_root"])
|
||||
selected_mode = ""
|
||||
if bluetooth_enabled and probe_local_adapter(
|
||||
bt_adapter,
|
||||
config["expected_peer_id"],
|
||||
transport_root,
|
||||
timeout,
|
||||
"bt.lock",
|
||||
):
|
||||
selected_mode = "bt"
|
||||
elif probe_local_adapter(
|
||||
adapter,
|
||||
config["expected_peer_id"],
|
||||
transport_root,
|
||||
timeout,
|
||||
"mtp.lock",
|
||||
):
|
||||
selected_mode = "mtp"
|
||||
elif drive_enabled:
|
||||
selected_mode = "drive"
|
||||
else:
|
||||
raise RuntimeError("expected Android peer not found")
|
||||
command = [
|
||||
sys.executable,
|
||||
str(Path(__file__).with_name("sync_peer_worker.py")),
|
||||
|
|
@ -433,7 +514,11 @@ def run(args: argparse.Namespace) -> int:
|
|||
"--mode",
|
||||
selected_mode,
|
||||
]
|
||||
if selected_mode == "mtp":
|
||||
if selected_mode == "bt":
|
||||
if not bt_adapter.is_absolute() or not os.access(bt_adapter, os.X_OK):
|
||||
raise RuntimeError("fixed Bluetooth adapter executable is unavailable")
|
||||
command.extend(["--bt-adapter", str(bt_adapter)])
|
||||
elif selected_mode == "mtp":
|
||||
if not adapter.is_absolute() or not os.access(adapter, os.X_OK):
|
||||
raise RuntimeError("fixed MTP adapter executable is unavailable")
|
||||
command.extend(["--mtp-adapter", str(adapter)])
|
||||
|
|
@ -493,6 +578,7 @@ def run(args: argparse.Namespace) -> int:
|
|||
manifest_sha256=result["manifest_sha256"],
|
||||
ai_midpoint=result["ai_midpoint"],
|
||||
ai_post_sync=result["ai_post_sync"],
|
||||
bluetooth_state="success" if selected_mode == "bt" else "unavailable",
|
||||
usb_state="success" if selected_mode == "mtp" else "unavailable",
|
||||
drive_state=drive_state,
|
||||
drive_diagnostic=drive_diagnostic,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ always produced and consumed by the existing generation services.
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
|
|
@ -122,36 +123,58 @@ def run_adapter(
|
|||
deadline: float,
|
||||
allow_missing_peer: bool = False,
|
||||
clock=time.monotonic,
|
||||
transport_name: str = "MTP",
|
||||
lock_name: str = "mtp.lock",
|
||||
) -> bool:
|
||||
remaining = deadline - clock()
|
||||
if remaining <= 0:
|
||||
raise ConversationDeadlineExpired()
|
||||
timeout = min(remaining, MTP_OPERATION_TIMEOUT_SECONDS)
|
||||
deadline_limited = remaining < MTP_OPERATION_TIMEOUT_SECONDS
|
||||
lock_path = root.parent / lock_name
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
lock_fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[str(adapter), operation, peer, str(root)],
|
||||
stdin=subprocess.DEVNULL,
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
if deadline_limited:
|
||||
raise ConversationDeadlineExpired() from error
|
||||
# CONTRACT: an adapter timeout is an ambiguous transport failure. It
|
||||
# never implies rollback, replay, or permission to retry a mutation.
|
||||
raise RuntimeError(
|
||||
f"transport_timeout: MTP {operation} did not finish within "
|
||||
f"{MTP_OPERATION_TIMEOUT_SECONDS:g} seconds"
|
||||
) from error
|
||||
while True:
|
||||
remaining = deadline - clock()
|
||||
if remaining <= 0:
|
||||
raise ConversationDeadlineExpired()
|
||||
try:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
break
|
||||
except BlockingIOError:
|
||||
time.sleep(min(0.05, remaining))
|
||||
remaining = deadline - clock()
|
||||
if remaining <= 0:
|
||||
raise ConversationDeadlineExpired()
|
||||
timeout = min(remaining, MTP_OPERATION_TIMEOUT_SECONDS)
|
||||
deadline_limited = remaining < MTP_OPERATION_TIMEOUT_SECONDS
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[str(adapter), operation, peer, str(root)],
|
||||
stdin=subprocess.DEVNULL,
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
if deadline_limited:
|
||||
raise ConversationDeadlineExpired() from error
|
||||
# CONTRACT: an adapter timeout is an ambiguous transport failure. It
|
||||
# never implies rollback, replay, or permission to retry a mutation.
|
||||
raise RuntimeError(
|
||||
f"transport_timeout: {transport_name} {operation} did not finish within "
|
||||
f"{MTP_OPERATION_TIMEOUT_SECONDS:g} seconds"
|
||||
) from error
|
||||
finally:
|
||||
try:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||
finally:
|
||||
os.close(lock_fd)
|
||||
if result.returncode != 0:
|
||||
diagnostic = (
|
||||
result.stderr.decode("utf-8", "replace")[:1024] or "MTP adapter failed"
|
||||
result.stderr.decode("utf-8", "replace")[:1024]
|
||||
or f"{transport_name} adapter failed"
|
||||
).strip()
|
||||
missing_peer = diagnostic in {
|
||||
"double failed 6 expected Android peer not found",
|
||||
"MTP adapter failed status=6 diagnostic=expected Android peer not found",
|
||||
"Bluetooth adapter failed status=6 diagnostic=expected Android peer not connected",
|
||||
}
|
||||
if allow_missing_peer and missing_peer:
|
||||
return False
|
||||
|
|
@ -165,6 +188,8 @@ def push_adapter(
|
|||
root: Path,
|
||||
relative_paths: list[str],
|
||||
deadline: float,
|
||||
transport_name: str = "MTP",
|
||||
lock_name: str = "mtp.lock",
|
||||
) -> None:
|
||||
"""Publish only the phase-owned bounded MTP objects.
|
||||
|
||||
|
|
@ -197,7 +222,15 @@ def push_adapter(
|
|||
shutil.copy2(source, destination)
|
||||
else:
|
||||
raise RuntimeError(f"invalid MTP outbox object: {relative}")
|
||||
run_adapter(adapter, "push", peer, outbox, deadline)
|
||||
run_adapter(
|
||||
adapter,
|
||||
"push",
|
||||
peer,
|
||||
outbox,
|
||||
deadline,
|
||||
transport_name=transport_name,
|
||||
lock_name=lock_name,
|
||||
)
|
||||
|
||||
|
||||
def push_desktop_generation(
|
||||
|
|
@ -206,9 +239,19 @@ def push_desktop_generation(
|
|||
root: Path,
|
||||
relative_path: str,
|
||||
deadline: float,
|
||||
transport_name: str = "MTP",
|
||||
lock_name: str = "mtp.lock",
|
||||
) -> None:
|
||||
"""Commit immutable bytes before making their correlated reference visible."""
|
||||
push_adapter(adapter, peer, root, [relative_path], deadline)
|
||||
push_adapter(
|
||||
adapter,
|
||||
peer,
|
||||
root,
|
||||
[relative_path],
|
||||
deadline,
|
||||
transport_name=transport_name,
|
||||
lock_name=lock_name,
|
||||
)
|
||||
push_adapter(
|
||||
adapter,
|
||||
peer,
|
||||
|
|
@ -220,6 +263,8 @@ def push_desktop_generation(
|
|||
"desktop-generation-v1.json",
|
||||
],
|
||||
deadline,
|
||||
transport_name=transport_name,
|
||||
lock_name=lock_name,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -357,6 +402,35 @@ def refresh_mtp_peer(
|
|||
time.sleep(min(MTP_POLL_INTERVAL_SECONDS, max(0.0, deadline - time.monotonic())))
|
||||
|
||||
|
||||
def refresh_bluetooth_peer(
|
||||
adapter: Path,
|
||||
peer: str,
|
||||
root: Path,
|
||||
deadline: float,
|
||||
) -> None:
|
||||
"""Require one successful Bluetooth pull before trusting retained peer state."""
|
||||
peer_path = root / "android-peer-v1.json"
|
||||
while True:
|
||||
try:
|
||||
pulled = run_adapter(
|
||||
adapter,
|
||||
"pull",
|
||||
peer,
|
||||
root,
|
||||
deadline,
|
||||
True,
|
||||
transport_name="Bluetooth",
|
||||
lock_name="bt.lock",
|
||||
)
|
||||
except ConversationDeadlineExpired as error:
|
||||
raise RuntimeError("timeout waiting for android-peer-v1.json") from error
|
||||
if pulled and peer_path.is_file():
|
||||
return
|
||||
if time.monotonic() >= deadline:
|
||||
raise RuntimeError("timeout waiting for android-peer-v1.json")
|
||||
time.sleep(min(1.0, max(0.0, deadline - time.monotonic())))
|
||||
|
||||
|
||||
def correlated_peer_error(root: Path, run_id: str) -> None:
|
||||
path = root / "android-generation-error-v1.json"
|
||||
if not path.is_file():
|
||||
|
|
@ -393,6 +467,76 @@ def load_peer(root: Path, expected: str) -> dict:
|
|||
return value
|
||||
|
||||
|
||||
def publish_ai_export_to_android(
|
||||
args: argparse.Namespace,
|
||||
deadline: float,
|
||||
) -> dict:
|
||||
"""Best-effort post-convergence AI export delivery.
|
||||
|
||||
The generation/ACK conversation is already complete when this runs.
|
||||
Export or publication failure therefore remains separately observable and
|
||||
never rolls back a durable synchronization.
|
||||
"""
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 1:
|
||||
return {"result": "pending", "diagnostic": "sync deadline exhausted before AI export"}
|
||||
output = args.transport_root / "trainlog_ai_export_v1.json"
|
||||
exporter = Path(__file__).with_name("export_ai_history.py")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(exporter),
|
||||
str(output),
|
||||
"--database",
|
||||
str(args.database),
|
||||
],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=min(60.0, max(1.0, remaining - 0.5)),
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as error:
|
||||
return {"result": "pending", "diagnostic": str(error)[:1024]}
|
||||
if result.returncode != 0 or "TRAINLOG_AI_EXPORT_V1=PASS" not in result.stdout:
|
||||
diagnostic = (result.stderr or result.stdout or "AI export failed").strip()
|
||||
return {"result": "pending", "diagnostic": diagnostic[-1024:]}
|
||||
if not output.is_file():
|
||||
return {"result": "pending", "diagnostic": "AI export file is missing after exporter success"}
|
||||
|
||||
try:
|
||||
if args.mode == "bt":
|
||||
push_adapter(
|
||||
args.bt_adapter,
|
||||
args.expected_peer,
|
||||
args.transport_root,
|
||||
["trainlog_ai_export_v1.json"],
|
||||
deadline,
|
||||
transport_name="Bluetooth",
|
||||
lock_name="bt.lock",
|
||||
)
|
||||
elif args.mode == "mtp":
|
||||
push_adapter(
|
||||
args.mtp_adapter,
|
||||
args.expected_peer,
|
||||
args.transport_root,
|
||||
["trainlog_ai_export_v1.json"],
|
||||
deadline,
|
||||
)
|
||||
elif args.mode == "directory":
|
||||
pass
|
||||
else:
|
||||
return {
|
||||
"result": "local_only",
|
||||
"diagnostic": "AI export generated locally; Android delivery transport unavailable",
|
||||
}
|
||||
except Exception as error:
|
||||
return {"result": "pending", "diagnostic": str(error)[:1024]}
|
||||
return {"result": "delivered_to_android"}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--database", required=True, type=Path)
|
||||
|
|
@ -401,14 +545,41 @@ def main() -> int:
|
|||
parser.add_argument("--run-id", required=True)
|
||||
parser.add_argument("--expected-peer", required=True)
|
||||
parser.add_argument("--timeout", required=True, type=int)
|
||||
parser.add_argument("--mode", choices=("directory", "mtp", "drive"), default="directory")
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
choices=("directory", "bt", "mtp", "drive"),
|
||||
default="directory",
|
||||
)
|
||||
parser.add_argument("--bt-adapter", type=Path)
|
||||
parser.add_argument("--mtp-adapter", type=Path)
|
||||
parser.add_argument("--drive-adapter", type=Path)
|
||||
parser.add_argument("--drive-remote")
|
||||
args = parser.parse_args()
|
||||
deadline = time.monotonic() + args.timeout
|
||||
pump = None
|
||||
if args.mode == "mtp":
|
||||
if args.mode == "bt":
|
||||
if args.bt_adapter is None:
|
||||
raise RuntimeError("Bluetooth adapter is required")
|
||||
refresh_bluetooth_peer(
|
||||
args.bt_adapter,
|
||||
args.expected_peer,
|
||||
args.transport_root,
|
||||
deadline,
|
||||
)
|
||||
pump = MtpPullPump(
|
||||
lambda: run_adapter(
|
||||
args.bt_adapter,
|
||||
"pull",
|
||||
args.expected_peer,
|
||||
args.transport_root,
|
||||
deadline,
|
||||
True,
|
||||
transport_name="Bluetooth",
|
||||
lock_name="bt.lock",
|
||||
),
|
||||
interval=1.0,
|
||||
)
|
||||
elif args.mode == "mtp":
|
||||
if args.mtp_adapter is None:
|
||||
raise RuntimeError("MTP adapter is required")
|
||||
refresh_mtp_peer(
|
||||
|
|
@ -475,7 +646,17 @@ def main() -> int:
|
|||
}
|
||||
archive_ack_path = args.transport_root / "desktop-archive-acknowledgements-v1.json"
|
||||
publish_json(archive_ack_path, archive_acknowledgements)
|
||||
if args.mode == "mtp":
|
||||
if args.mode == "bt":
|
||||
push_adapter(
|
||||
args.bt_adapter,
|
||||
args.expected_peer,
|
||||
args.transport_root,
|
||||
["request-v1.json", "desktop-archive-acknowledgements-v1.json"],
|
||||
deadline,
|
||||
transport_name="Bluetooth",
|
||||
lock_name="bt.lock",
|
||||
)
|
||||
elif args.mode == "mtp":
|
||||
push_adapter(
|
||||
args.mtp_adapter,
|
||||
args.expected_peer,
|
||||
|
|
@ -510,7 +691,17 @@ def main() -> int:
|
|||
ack = generation.consume_desktop(args.database, inbound_dir)
|
||||
ack_path = args.transport_root / "desktop-consumption-ack-v1.json"
|
||||
publish_json(ack_path, ack)
|
||||
if args.mode == "mtp":
|
||||
if args.mode == "bt":
|
||||
push_adapter(
|
||||
args.bt_adapter,
|
||||
args.expected_peer,
|
||||
args.transport_root,
|
||||
["request-v1.json", "desktop-archive-acknowledgements-v1.json", "desktop-consumption-ack-v1.json"],
|
||||
deadline,
|
||||
transport_name="Bluetooth",
|
||||
lock_name="bt.lock",
|
||||
)
|
||||
elif args.mode == "mtp":
|
||||
push_adapter(
|
||||
args.mtp_adapter,
|
||||
args.expected_peer,
|
||||
|
|
@ -555,7 +746,17 @@ def main() -> int:
|
|||
"relative_path": str(published.relative_to(args.transport_root)),
|
||||
}
|
||||
publish_json(args.transport_root / "desktop-generation-v1.json", ref)
|
||||
if args.mode == "mtp":
|
||||
if args.mode == "bt":
|
||||
push_desktop_generation(
|
||||
args.bt_adapter,
|
||||
args.expected_peer,
|
||||
args.transport_root,
|
||||
ref["relative_path"],
|
||||
deadline,
|
||||
transport_name="Bluetooth",
|
||||
lock_name="bt.lock",
|
||||
)
|
||||
elif args.mode == "mtp":
|
||||
# CONTRACT: the immutable generation must be fully visible before its
|
||||
# mutable run-correlated reference. Publishing both in one MTP walk
|
||||
# lets Android observe the reference while artifacts are still being
|
||||
|
|
@ -629,6 +830,7 @@ def main() -> int:
|
|||
if inbound_row != ("consumed",) or outbound_row != ("acknowledged",):
|
||||
raise RuntimeError("generation evidence did not corroborate completion")
|
||||
emit(args.run_id, "peer_consumed", outbound_generation_id=outgoing_id)
|
||||
ai_post_sync = publish_ai_export_to_android(args, deadline)
|
||||
report = {
|
||||
"format": "trainlog-sync-worker-report",
|
||||
"version": 1,
|
||||
|
|
@ -655,7 +857,7 @@ def main() -> int:
|
|||
},
|
||||
"drafts": drafts,
|
||||
"ai_midpoint": {"result": "not_configured"},
|
||||
"ai_post_sync": {"result": "not_configured"},
|
||||
"ai_post_sync": ai_post_sync,
|
||||
}
|
||||
print(json.dumps(report, sort_keys=True, separators=(",", ":")), flush=True)
|
||||
return 0
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue