Compare commits

..

No commits in common. "5d58071601d5613f236c414cbe36cf1390ec2d90" and "bedf392542ef8ec76a3525b3696aedd6b87c5dc0" have entirely different histories.

160 changed files with 1286 additions and 20743 deletions

View file

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

View file

@ -1,22 +1,22 @@
# Trainlog
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.
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.
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, Exercises, and factual Analyse/Sleep Diary
surfaces.
Dashboard, Sessions, Programmes, and Exercises surfaces while reserving Analyse
for separately contracted work.
Android and desktop each own a local SQLite database. The desktop database is
the canonical long-term history. Trainlog synchronizes versioned JSON artifacts
over its authenticated local transports and optional configured Drive workflow;
it never copies SQLite database files between devices.
over direct MTP; it never copies SQLite database files between devices.
## Product split
@ -24,7 +24,7 @@ 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.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. |
| 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. |
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. Version 0.1.4 also includes the
factual Dashboard/Analyse read model and the synchronized Sleep Diary workspace.
Web Exercises administration is implemented for 0.1.3 development. The next
separately contracted priority is the Android UX redesign.
## 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.4** on Android and desktop; this is one shared
Trainlog version, not separate interface versions. Version 0.1.3 is the
latest stable version is **0.1.3** on Android and desktop; this is one shared
Trainlog version, not separate interface versions. Version 0.1.2 is the
previous stable release.
Trainlog presents French by default, with English selectable
@ -109,14 +109,14 @@ independent.
### Daily synchronization on an opted-in installation
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.
After explicitly enabling Android background synchronization once, connect and
unlock the paired phone and select **Synchronize** in the local Web Dashboard.
USB/MTP is preferred when the Trainlog peer is usable; the separately selected
private Drive folder is updated as a mirror and is the fallback transport. Wait
until Web reports the correlated generation and acknowledgements. A failure
remains durable and should be diagnosed before retrying; do not delete
generation, ACK, database, or backup files. Full-generation mode and Drive
remain trusted per-installation configuration.
### Build from source
@ -157,7 +157,7 @@ updates; generating a replacement key is not a normal release procedure.
### Linux runtime dependencies
The published v0.1.4 x86-64 desktop runtime is dynamically linked. Its tagged
The published v0.1.3 x86-64 desktop runtime is dynamically linked. Its tagged
source directly requires compatible versions of:
- glibc and the GCC support runtime;
@ -169,7 +169,7 @@ source directly requires compatible versions of:
- Notcurses Core;
- the standard math library.
Version 0.1.4 additionally links GNU libmicrohttpd and yyjson for the local Web
Version 0.1.3 additionally links GNU libmicrohttpd and yyjson for the local Web
adapter and Dashboard layout configuration. Those are not retroactive
requirements of the pre-Web v0.1.1 tagged source.
@ -184,9 +184,6 @@ 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
@ -338,7 +335,7 @@ not contain cloud credentials and does not run `rclone`.
As of 2026-09-18:
- `TRAINLOG_FORMAT_V1=PASS/FROZEN`;
- desktop SQLite schema v29 and Android SQLite schema v26;
- desktop SQLite schema v28 and Android SQLite schema v25;
- Notcurses is the only active desktop terminal backend;
- direct storage is `/storage/emulated/0/Documents/Trainlog` under Android's
all-files access setting;
@ -353,8 +350,7 @@ 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` now provides the 0.1.4 factual
Dashboard/Analyse read-model foundation pending visual review;
`TRAINLOG_WEB_EXERCISES_V1=PASS`; Analyse remains a placeholder;
- `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=PASS`: the coordinated
private desktop/Android deployment, real Firefox presentation, correlated
`programs-v1` deletion ACK, restart/replay, and non-resurrection checks pass;

View file

@ -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")
} ?: 5
} ?: 4
android {
namespace = "com.labfytools.trainlog"
@ -55,7 +55,7 @@ android {
targetSdk = 36
versionCode = trainlogVersionCode
versionName = "0.1.4"
versionName = "0.1.3"
testInstrumentationRunner =
"androidx.test.runner.AndroidJUnitRunner"
}

View file

@ -60,7 +60,6 @@ class DraftUiTestActivity : ComponentActivity() {
sessionVisible = false
},
onCreateExercise = {},
onCreateEquipment = { _ -> },
/* CONTRACT: this host never writes shared export data. */
onSessionSaved = {},
)

View file

@ -6,16 +6,8 @@
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"
@ -41,6 +33,6 @@
<service
android:name=".data.SyncBackgroundService"
android:exported="false"
android:foregroundServiceType="connectedDevice|dataSync" />
android:foregroundServiceType="dataSync" />
</application>
</manifest>

View file

@ -14,9 +14,6 @@ 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
@ -33,13 +30,6 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
languageSettings = LanguageSettingsOwner(applicationContext)
if (
BackgroundSyncSettings(applicationContext).enabled &&
hasBluetoothConnectPermission(applicationContext)
) {
SyncBackgroundService.start(applicationContext)
}
val repository =
TrainlogRepository(
applicationContext

View file

@ -1,235 +0,0 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import java.io.File
import java.io.FileOutputStream
import java.security.MessageDigest
import java.util.concurrent.TimeUnit
import org.json.JSONObject
internal const val AI_EXPORT_FILENAME = "trainlog_ai_export_v1.json"
internal sealed interface AiExportDriveResult {
data object NotConfigured : AiExportDriveResult
data object Missing : AiExportDriveResult
data object Unchanged : AiExportDriveResult
data class Published(val sha256: String) : AiExportDriveResult
data class Error(val message: String) : AiExportDriveResult
}
internal class AiExportDriveSettings(private val context: Context) {
private val preferences =
context.applicationContext.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
val treeUri: Uri?
get() = preferences.getString(TREE, null)?.let(Uri::parse)
val connected: Boolean
get() = treeUri != null
fun connect(uri: Uri) {
context.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
)
preferences.edit().putString(TREE, uri.toString()).apply()
schedule(context)
}
fun disconnect() {
treeUri?.let { uri ->
runCatching {
context.contentResolver.releasePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
)
}
}
preferences.edit()
.remove(TREE)
.remove(LAST_SHA256)
.remove(LAST_SIZE)
.remove(LAST_MODIFIED)
.apply()
WorkManager.getInstance(context).cancelUniqueWork(PERIODIC_WORK)
WorkManager.getInstance(context).cancelUniqueWork(IMMEDIATE_WORK)
}
fun alreadyPublished(source: File, sha256: String): Boolean =
preferences.getString(LAST_SHA256, null) == sha256 &&
preferences.getLong(LAST_SIZE, -1L) == source.length() &&
preferences.getLong(LAST_MODIFIED, -1L) == source.lastModified()
fun needsPublication(source: File): Boolean =
connected &&
source.isFile &&
(
preferences.getString(LAST_SHA256, null) == null ||
preferences.getLong(LAST_SIZE, -1L) != source.length() ||
preferences.getLong(LAST_MODIFIED, -1L) != source.lastModified()
)
fun recordPublished(source: File, sha256: String) {
preferences.edit()
.putString(LAST_SHA256, sha256)
.putLong(LAST_SIZE, source.length())
.putLong(LAST_MODIFIED, source.lastModified())
.apply()
}
companion object {
private const val PREFERENCES = "trainlog-ai-export-drive"
private const val TREE = "tree_uri"
private const val LAST_SHA256 = "last_sha256"
private const val LAST_SIZE = "last_size"
private const val LAST_MODIFIED = "last_modified"
private const val PERIODIC_WORK = "trainlog-ai-export-drive-periodic-v1"
private const val IMMEDIATE_WORK = "trainlog-ai-export-drive-now-v1"
fun sourceFile(): File = File(canonicalExchangeDirectory(), AI_EXPORT_FILENAME)
fun schedule(context: Context) {
val constraints =
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val periodic =
PeriodicWorkRequestBuilder<AiExportDriveWorker>(15, TimeUnit.MINUTES)
.setConstraints(constraints)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
PERIODIC_WORK,
ExistingPeriodicWorkPolicy.UPDATE,
periodic,
)
enqueueImmediate(context)
}
fun enqueueImmediate(context: Context) {
val settings = AiExportDriveSettings(context)
val source = sourceFile()
if (!settings.needsPublication(source)) return
val request =
OneTimeWorkRequestBuilder<AiExportDriveWorker>()
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
IMMEDIATE_WORK,
ExistingWorkPolicy.KEEP,
request,
)
}
}
}
internal class AiExportDrivePublisher(private val context: Context) {
private val settings = AiExportDriveSettings(context)
private fun sha256(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().use { input ->
val buffer = ByteArray(64 * 1024)
while (true) {
val count = input.read(buffer)
if (count < 0) break
digest.update(buffer, 0, count)
}
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
private fun validateSource(source: File) {
require(source.isFile && !java.nio.file.Files.isSymbolicLink(source.toPath())) {
"Export IA local indisponible."
}
require(source.length() in 1..64L * 1024L * 1024L) {
"Export IA local invalide."
}
val root =
source.inputStream().bufferedReader(Charsets.UTF_8).use { reader ->
JSONObject(reader.readText())
}
require(
root.optString("format") == "TRAINLOG_AI_EXPORT" &&
root.optInt("version", -1) == 1
) { "Format d’export IA invalide." }
}
fun publishPending(): AiExportDriveResult {
val uri = settings.treeUri ?: return AiExportDriveResult.NotConfigured
val source = AiExportDriveSettings.sourceFile()
if (!source.isFile) return AiExportDriveResult.Missing
return try {
validateSource(source)
val sourceSha = sha256(source)
if (settings.alreadyPublished(source, sourceSha)) {
return AiExportDriveResult.Unchanged
}
val root =
DocumentFile.fromTreeUri(context, uri)
?: return AiExportDriveResult.Error("Dossier Drive IA indisponible.")
require(root.isDirectory) { "Dossier Drive IA invalide." }
val destination =
root.findFile(AI_EXPORT_FILENAME)
?: root.createFile("application/json", AI_EXPORT_FILENAME)
?: throw IllegalStateException("Impossible de créer l’export IA sur Drive.")
require(destination.isFile) { "Destination Drive IA invalide." }
context.contentResolver.openOutputStream(destination.uri, "wt")?.use { output ->
source.inputStream().use { input -> input.copyTo(output, 64 * 1024) }
output.flush()
} ?: throw IllegalStateException("Impossible d’écrire l’export IA sur Drive.")
val verification =
File.createTempFile("trainlog-ai-drive-verify-", ".json", context.cacheDir)
try {
context.contentResolver.openInputStream(destination.uri)?.use { input ->
FileOutputStream(verification).use { output ->
input.copyTo(output, 64 * 1024)
output.fd.sync()
}
} ?: throw IllegalStateException("Impossible de relire l’export IA Drive.")
require(
verification.length() == source.length() &&
sha256(verification) == sourceSha
) { "Vérification de l’export IA Drive échouée." }
} finally {
verification.delete()
}
settings.recordPublished(source, sourceSha)
AiExportDriveResult.Published(sourceSha)
} catch (error: Exception) {
AiExportDriveResult.Error(error.message ?: error.javaClass.simpleName)
}
}
}
class AiExportDriveWorker(
context: Context,
parameters: WorkerParameters,
) : CoroutineWorker(context, parameters) {
override suspend fun doWork(): Result =
when (AiExportDrivePublisher(applicationContext).publishPending()) {
AiExportDriveResult.NotConfigured,
AiExportDriveResult.Missing,
AiExportDriveResult.Unchanged,
is AiExportDriveResult.Published -> Result.success()
is AiExportDriveResult.Error -> Result.retry()
}
}

View file

@ -225,7 +225,7 @@ internal class AndroidBackupService(
manifest.getInt("version") == VERSION &&
manifest.getString("package") == context.packageName &&
manifest.getBoolean("plaintext") &&
manifest.getInt("schema") in 17..26
manifest.getInt("schema") in 17..25
) {
"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..26) { "Unsupported Android schema ${db.version}." }
require(db.version in 17..25) { "Unsupported Android schema ${db.version}." }
return db.version
} finally {
db.close()

View file

@ -1,539 +0,0 @@
package com.labfytools.trainlog.data
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothClass
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothSocket
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.core.content.ContextCompat
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.nio.file.AtomicMoveNotSupportedException
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.security.MessageDigest
import java.time.Duration
import java.util.UUID
import java.util.concurrent.atomic.AtomicBoolean
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
import java.util.zip.ZipOutputStream
import org.json.JSONObject
internal const val TRAINLOG_BLUETOOTH_SERVICE_UUID =
"f0d1c0de-7a11-4f62-9b7c-545241494e4c"
internal const val TRAINLOG_BLUETOOTH_FILE_PROTOCOL = "trainlog-bt-files-v1"
private const val BLUETOOTH_FRAME_FORMAT = "trainlog-bt-frame"
private const val BLUETOOTH_FRAME_VERSION = 1
private const val MAX_BT_HEADER_BYTES = 64 * 1024
private const val MAX_BT_PAYLOAD_BYTES = 256L * 1024L * 1024L
private const val MAX_BT_FILE_BYTES = 64L * 1024L * 1024L
private const val MAX_BT_FILES = 128
private const val AUTO_REQUEST_COOLDOWN_MS = 5L * 60L * 1000L
internal 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
}
}
}

View file

@ -32,29 +32,37 @@ internal class BackgroundSyncSettings(context: Context) {
}
/**
* User-enabled foreground listener for automatic Bluetooth and MTP synchronization.
* User-enabled foreground listener for PC-initiated USB generation requests.
*
* WHY: the phone must reconnect to the paired desktop while Compose is not
* 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
* 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
* 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.newFixedThreadPool(2)
private val executor = Executors.newSingleThreadExecutor()
private var repository: TrainlogRepository? = null
private var bluetoothClient: BluetoothSyncClient? = null
override fun onCreate() {
super.onCreate()
createChannel()
promoteForeground()
val notification = NotificationCompat.Builder(this, CHANNEL)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(getString(R.string.background_sync_notification_title))
.setContentText(getString(R.string.background_sync_notification_text))
.setOngoing(true)
.setOnlyAlertOnce(true)
.build()
if (Build.VERSION.SDK_INT >= 29) {
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
startForeground(NOTIFICATION_ID, notification)
}
repository = TrainlogRepository(applicationContext)
bluetoothClient = BluetoothSyncClient(applicationContext, requireNotNull(repository))
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@ -62,11 +70,7 @@ 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),
@ -112,7 +116,6 @@ class SyncBackgroundService : Service() {
is ForegroundGenerationResult.Cancelled -> Unit
}
}
AiExportDriveSettings.enqueueImmediate(applicationContext)
if (!stopped.get()) runCatching { Thread.sleep(5_000) }
}
stopSelf()
@ -122,8 +125,6 @@ class SyncBackgroundService : Service() {
override fun onDestroy() {
stopped.set(true)
bluetoothClient?.shutdown()
bluetoothClient = null
executor.shutdownNow()
repository?.close()
repository = null
@ -140,28 +141,6 @@ class SyncBackgroundService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
private fun promoteForeground() {
val notification =
NotificationCompat.Builder(this, CHANNEL)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(getString(R.string.background_sync_notification_title))
.setContentText(getString(R.string.background_sync_notification_text))
.setOngoing(true)
.setOnlyAlertOnce(true)
.build()
if (Build.VERSION.SDK_INT >= 29) {
val serviceType =
if (hasBluetoothConnectPermission(this)) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
} else {
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
}
startForeground(NOTIFICATION_ID, notification, serviceType)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
private fun createChannel() {
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(

View file

@ -541,12 +541,6 @@ internal class SyncGenerationCoordinator(
phase(currentRunId, "cancelled", result = "interrupted")
ForegroundGenerationResult.Cancelled(currentRunId)
} catch (error: Exception) {
Log.e(
"TrainlogSyncGeneration",
"run_id=${currentRunId ?: "pending"} phase=error " +
"type=${error.javaClass.name} message=${error.message}",
error,
)
if (error.message == "generation retention capacity exhausted") {
try {
val request = JSONObject(File(directory, "request-v1.json").readText())

View file

@ -68,7 +68,6 @@ 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 +
@ -412,16 +411,6 @@ 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) {
@ -675,7 +664,6 @@ 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()
}
@ -852,10 +840,6 @@ 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"]))
@ -1049,17 +1033,6 @@ 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
}
}

View file

@ -69,16 +69,6 @@ class SyncRequestOutbox private constructor(
}
}
internal fun requestFullGeneration(): SyncRequestResult {
return when (val opened = publisher.snapshot()) {
is DirectExchangeSnapshotResult.Ready -> {
val intent = newIntent()
publishFullGeneration(opened.snapshot, intent)
}
is DirectExchangeSnapshotResult.Error -> SyncRequestResult.Error(opened.message)
}
}
internal fun requestSync(snapshot: DirectExchangeSnapshot): SyncRequestResult {
if (
Build.VERSION.SDK_INT <

View file

@ -1,34 +0,0 @@
package com.labfytools.trainlog.model
enum class SleepQuality(val wireValue: String) { TB("TB"), B("B"), MOY("Moy"), M("M"), TM("TM") }
enum class SleepEventType(val wireValue: String) {
BED_TIME("bed_time"), FINAL_GET_UP("final_get_up"), NIGHT_GET_UP("night_get_up"),
SLEEP("sleep"), NAP("nap"), LONG_AWAKE("long_awake"), HALF_SLEEP("half_sleep"),
DAYTIME_SLEEPINESS("daytime_sleepiness"),
}
enum class SleepPublicationStatus { DRAFT, READY, SYNCHRONIZED, MODIFIED }
data class SleepDiaryEvent(val eventId: String, val type: SleepEventType, val startAt: String, val endAt: String?)
data class SleepMedication(
val medicationId: String, val revisionId: String, val createdAt: String, val updatedAt: String,
val name: String, val defaultDoseValue: Double?, val defaultDoseUnit: String?,
val form: String, val note: String, val active: Boolean,
)
data class MedicationIntake(
val intakeId: String, val medicationId: String, val medicationName: String,
val takenAt: String, val doseValue: Double?, val doseUnit: String?, val note: String,
val createdAt: String,
)
data class SleepDiaryEntry(
val entryId: String, val nightStartDate: String, val nightEndDate: String,
val createdAt: String, val updatedAt: String, val revisionId: String,
val sleepQuality: SleepQuality?, val wakeQuality: SleepQuality?, val dayForm: SleepQuality?,
val treatmentAndNotes: String, val events: List<SleepDiaryEvent>, val intakes: List<MedicationIntake>,
val publicationStatus: SleepPublicationStatus,
)
data class SleepDiaryDraft(
val entryId: String? = null, val expectedRevision: String? = null,
val nightStartDate: String, val nightEndDate: String, val createdAt: String,
val updatedAt: String, val sleepQuality: SleepQuality?, val wakeQuality: SleepQuality?,
val dayForm: SleepQuality?, val treatmentAndNotes: String, val events: List<SleepDiaryEvent>,
val intakes: List<MedicationIntake> = emptyList(),
)

View file

@ -43,7 +43,6 @@ 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,
)
@ -61,7 +60,6 @@ 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) {
@ -75,7 +73,6 @@ 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)

View file

@ -12,7 +12,7 @@ import androidx.lifecycle.ViewModel
enum class AppSection {
HOME, SESSIONS, EXERCISES,
EQUIPMENT, SLEEP, SYNC, SETTINGS,
EQUIPMENT, SYNC, SETTINGS,
}
sealed interface AppRoute {
@ -35,7 +35,6 @@ 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 }
@ -46,7 +45,6 @@ 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
}
@ -171,11 +169,5 @@ class TrainlogAppState : ViewModel() {
val equipment = EquipmentScreenState()
val exercise = ExerciseScreenState()
val body = BodyScreenState()
/* WHY: the durable form intentionally does not serialize transient edit
* position, but a catalogue detour must still return to the same row.
* CONTRACT: this stable occurrence ID spans only that caller-aware detour;
* all actual form fields remain repository-owned and durable.
* INVARIANT: null means a new occurrence, never an inferred row. */
var pendingSessionEquipmentEntryId: String? = null
val navigationController = AppNavigationController(navigation, generator, equipment, exercise, body)
}

View file

@ -423,29 +423,17 @@ fun EquipmentDetailScreen(repository: TrainlogRepository, equipmentId: String, o
}
@Composable
fun EquipmentCreateScreen(
repository: TrainlogRepository,
state: EquipmentScreenState,
onCreated: (EquipmentCatalogEntry) -> Unit,
) {
fun EquipmentCreateScreen(repository: TrainlogRepository, state: EquipmentScreenState, onCreated: () -> 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 },
testTag = "equipment-create-name",
)
TrainlogInputField(strings.getString(R.string.name), state.customName, { state.customName = it; state.message = null })
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(result.equipment)
state.customName = ""; state.message = null; state.revision++; onCreated()
}
CreateEquipmentResult.Invalid -> state.message = strings.getString(R.string.equipment_name_invalid)
CreateEquipmentResult.Conflict -> state.message = strings.getString(R.string.equipment_name_exists)

View file

@ -25,13 +25,10 @@ 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
@ -51,6 +48,7 @@ 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
@ -79,10 +77,7 @@ fun SessionScreen(
catalogRevision: Int,
onBack: () -> Unit,
onCreateExercise: () -> Unit,
onCreateEquipment: (editingEntryId: String?) -> Unit,
onSessionSaved: () -> Unit,
restoreEditingEntryId: String? = null,
onEditingContextRestored: () -> Unit = {},
) {
val colors =
LocalTrainlogColors.current
@ -102,27 +97,13 @@ 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(
if (restoredIndex == null) loadedDraft else loadedDraft?.let { draft ->
draft.copy(
form = draft.form.copy(
editingExerciseIndex = restoredIndex,
editingEntryId = restoreEditingEntryId,
),
)
}
(initialLoad as?
ActiveDraftLoadResult.Loaded)
?.draft
)
}
LaunchedEffect(restoreEditingEntryId) {
if (restoreEditingEntryId != null) onEditingContextRestored()
}
var message by
remember(catalogRevision) {
mutableStateOf<
@ -361,14 +342,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),
),
strings.getString(R.string.editor_opened, draft.exercise.name),
null,
)
message = strings.getString(R.string.editor_opened, draft.exercise.name)
},
)
if (currentDraft.form.editingEntryId == draft.entryId) {
@ -452,11 +433,8 @@ fun SessionScreen(
sessionType = currentDraft.sessionType,
initialForm =
currentDraft.form,
initialOccurrence = currentDraft.form.editingExerciseIndex?.let {
currentDraft.exercises.getOrNull(it)
},
onCreateEquipment = {
onCreateEquipment(currentDraft.form.editingEntryId)
initialPlan = currentDraft.form.editingExerciseIndex?.let {
currentDraft.exercises.getOrNull(it)?.plan
},
onFormChanged = {
form ->
@ -882,8 +860,7 @@ private fun SessionExerciseForm(
exercise: ExerciseProfile,
sessionType: SessionType,
initialForm: SessionDraftForm,
initialOccurrence: SessionExerciseDraft?,
onCreateEquipment: () -> Unit,
initialPlan: SessionExercisePlan?,
onFormChanged: (SessionDraftForm) -> Unit,
onCancel: () -> Unit,
onAdd:
@ -893,7 +870,6 @@ private fun SessionExerciseForm(
val colors =
LocalTrainlogColors.current
val strings = localizedContext()
val initialPlan = initialOccurrence?.plan
var setCountText by
remember(key) {
@ -959,11 +935,13 @@ private fun SessionExerciseForm(
)
}
val equipmentEntries = remember(key) { repository.listEquipment() }
var equipmentRevision by remember(key) { mutableStateOf(0) }
val equipmentEntries = remember(equipmentRevision) { repository.listEquipment() }
val fixedEquipmentId = remember(exercise.exerciseId) {
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)
}
@ -1000,18 +978,34 @@ 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_equipment),
description = strings.getString(R.string.create_equipment_from_session),
label = strings.getString(R.string.create_context),
description = strings.getString(R.string.context_compatibility),
accent = colors.success,
modifier = Modifier.testTag("session-create-equipment"),
onClick = onCreateEquipment,
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))
}
},
)
}
val selectedEquipment = equipmentEntries.firstOrNull { it.equipmentId == selectedEquipmentId }
val percentResult = remember(
exercise.exerciseId, selectedEquipmentId, targetPercentText,
targetChoice,
targetChoice, equipmentRevision,
) {
if (targetChoice == ManualTargetChoice.PERCENT_MAX)
repository.calculateManualPercentMaxTarget(
@ -1031,29 +1025,16 @@ private fun SessionExerciseForm(
},
)
}
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 (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 (sessionType == SessionType.MAX_TEST) {
@ -1284,7 +1265,6 @@ private fun SessionExerciseForm(
SessionNumberField(
label =
strings.getString(R.string.duration_minutes),
testTag = "session-continuous-duration",
value =
durationText,
onValueChange = {
@ -1371,7 +1351,7 @@ private fun SessionExerciseForm(
modifier = Modifier.fillMaxWidth().testTag("add-to-session"),
containerColor = colors.success,
onClick = {
val capturedDraft =
val draft =
buildSessionExerciseDraft(
exercise =
exercise,
@ -1393,26 +1373,6 @@ 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) {
@ -1436,9 +1396,7 @@ private fun SessionExerciseForm(
onFailure = { error = localizedTargetError(it.message, strings) },
)
} else {
/* CONTRACT: editing performed continuous facts must not
* erase the independent target copied from preparation. */
onAdd(draft.copy(plan = initialPlan))
onAdd(draft.copy(plan = null))
}
},
)
@ -1450,7 +1408,6 @@ private fun SessionExerciseForm(
strings.getString(R.string.return_catalog),
accent =
colors.muted,
modifier = Modifier.testTag("cancel-session-exercise-editor"),
onClick =
onCancel,
)

View file

@ -1,273 +0,0 @@
package com.labfytools.trainlog.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.FilterChip
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import 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
})

View file

@ -35,10 +35,7 @@ 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.AiExportDriveSettings
import com.labfytools.trainlog.data.BluetoothSyncSettings
import com.labfytools.trainlog.data.bondedBluetoothDesktops
import com.labfytools.trainlog.data.hasBluetoothConnectPermission
import com.labfytools.trainlog.data.DriveSyncSettings
import com.labfytools.trainlog.data.canonicalExchangeDirectory
import com.labfytools.trainlog.data.AndroidMtpPublicationVisibility
import com.labfytools.trainlog.data.MtpPublicationVisibility
@ -243,20 +240,8 @@ fun SyncScreen(
var syncRunning by remember { mutableStateOf(false) }
val backgroundSettings = remember { BackgroundSyncSettings(context) }
var backgroundEnabled by remember { mutableStateOf(backgroundSettings.enabled) }
val driveSettings = remember { AiExportDriveSettings(context) }
val driveSettings = remember { DriveSyncSettings(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(),
@ -271,23 +256,6 @@ fun SyncScreen(
}
}
val bluetoothPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
bluetoothPermission = granted || Build.VERSION.SDK_INT < Build.VERSION_CODES.S
bondedDesktops =
if (bluetoothPermission) bondedBluetoothDesktops(context) else emptyList()
if (bluetoothPermission && backgroundSettings.enabled) {
SyncBackgroundService.start(context)
}
status =
if (bluetoothPermission) {
strings.getString(R.string.bluetooth_sync_permission_granted)
} else {
strings.getString(R.string.bluetooth_sync_permission_required)
}
}
val notificationPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted ->
@ -605,69 +573,6 @@ fun SyncScreen(
)
}
TrainlogFrame(title = strings.getString(R.string.bluetooth_sync_title)) {
TrainlogInfo(
text = strings.getString(R.string.bluetooth_sync_description),
color =
if (bluetoothDesktop != null && bluetoothPermission) colors.success
else colors.warning,
)
if (!bluetoothPermission) {
TrainlogAction(
label = strings.getString(R.string.bluetooth_sync_permission),
description = strings.getString(R.string.bluetooth_sync_permission_required),
accent = colors.success,
onClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
bluetoothPermissionLauncher.launch(Manifest.permission.BLUETOOTH_CONNECT)
} else {
bluetoothPermission = true
bondedDesktops = bondedBluetoothDesktops(context)
}
},
)
} else {
if (bondedDesktops.isEmpty()) {
TrainlogInfo(
text = strings.getString(R.string.bluetooth_sync_no_desktop),
color = colors.warning,
)
}
bondedDesktops.forEach { desktop ->
TrainlogAction(
label = strings.getString(R.string.bluetooth_sync_select, desktop.name),
description = strings.getString(R.string.bluetooth_sync_address, desktop.address),
accent =
if (bluetoothDesktop == desktop.address) colors.success else colors.accent,
onClick = {
bluetoothSettings.selectDesktop(desktop.address)
bluetoothDesktop = desktop.address
status =
strings.getString(R.string.bluetooth_sync_selected, desktop.name)
if (backgroundSettings.enabled) {
SyncBackgroundService.start(context)
}
},
)
}
if (bluetoothDesktop != null) {
TrainlogAction(
label = strings.getString(R.string.bluetooth_sync_disconnect),
description =
strings.getString(
R.string.bluetooth_sync_address,
bluetoothDesktop ?: "",
),
accent = colors.warning,
onClick = {
bluetoothSettings.disconnect()
bluetoothDesktop = null
},
)
}
}
}
TrainlogFrame(title = strings.getString(R.string.background_sync_title)) {
TrainlogInfo(
text = strings.getString(R.string.background_sync_description),

View file

@ -105,21 +105,7 @@ 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)) },
{ editingEntryId ->
appState.pendingSessionEquipmentEntryId = editingEntryId
open(AppRoute.EquipmentCreate(AppRoute.SessionEditor))
},
{ exportSnapshot(); draftRevision++ },
restoreEditingEntryId = appState.pendingSessionEquipmentEntryId,
onEditingContextRestored = {
appState.pendingSessionEquipmentEntryId = null
},
)
AppRoute.SessionEditor -> SessionScreen(repository, catalogRevision, { draftRevision++; back() }, { open(AppRoute.ExerciseCreate(AppRoute.SessionEditor)) }, { exportSnapshot(); draftRevision++ })
AppRoute.SessionGenerator -> SessionGeneratorScreen(repository, generatorState, { back() }, { generatorState.abandon(); draftRevision++; open(AppRoute.SessionEditor) }, {
draftMessage = strings.getString(R.string.existing_draft_warning)
draftRevision++
@ -152,47 +138,8 @@ 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) { 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()
}
is AppRoute.EquipmentCreate -> EquipmentCreateScreen(repository, equipmentState) { 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++ }

View file

@ -8,31 +8,6 @@
<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>
@ -417,7 +392,7 @@
<string name="regenerate_description">Run again with the same inputs and reference instant.</string><string name="accept_actual_description">Create the normal draft without prefilling performed sets.</string><string name="acknowledge_exposure_first">Confirm the recent-exposure warning first.</string>
<string name="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="create_equipment_from_session">Create it in the catalogue, then return to this exercise.</string><string name="search_equipment">Search by name, label, or alias</string>
<string name="create_equipment">Create equipment</string><string name="create_personal_machine">Add a personal machine.</string><string name="search_equipment">Search by name, label, or alias</string>
<string name="unknown_reference">Unknown reference · %1$s</string><string name="definition">Definition</string><string name="personal">Personal</string><string name="provided">Provided</string><string name="label_value">Label: %1$s</string>
<string name="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>
@ -473,11 +448,4 @@
<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>

View file

@ -8,31 +8,6 @@
<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>
@ -129,23 +104,13 @@
<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="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="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="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>
@ -427,7 +392,7 @@
<string name="regenerate_description">Relancer les mêmes entrées et le même instant de référence.</string><string name="accept_actual_description">Créer le brouillon normal sans préremplir les séries réalisées.</string><string name="acknowledge_exposure_first">Confirmez d’abord l’avertissement d’exposition récente.</string>
<string name="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="create_equipment_from_session">Créer dans le catalogue puis revenir à cet exercice.</string><string name="search_equipment">Rechercher par nom, étiquette ou alias</string>
<string name="create_equipment">Créer un équipement</string><string name="create_personal_machine">Ajouter une machine personnelle.</string><string name="search_equipment">Rechercher par nom, étiquette ou alias</string>
<string name="unknown_reference">Référence inconnue · %1$s</string><string name="definition">Définition</string><string name="personal">Personnel</string><string name="provided">Fourni</string><string name="label_value">Étiquette : %1$s</string>
<string name="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>
@ -483,11 +448,4 @@
<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>

View file

@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest {
TrainlogRepository(context, name).useForTest { it.listSessions() }
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(26, scalar(db, "PRAGMA user_version"))
assertEquals(25, scalar(db, "PRAGMA user_version"))
for (table in listOf("session_exercises", "draft_session_exercises")) {
assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')"))
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL"))

View file

@ -69,7 +69,7 @@ class AndroidV11ExerciseAliasMigrationTest {
migrated.close()
}
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(26, scalar(db, "PRAGMA user_version"))
assertEquals(25, scalar(db, "PRAGMA user_version"))
/* v13 intentionally adds the frozen machine identities and
* exercise-owned metadata; all non-exercise facts stay exact. */
val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") }

View file

@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest {
migrated.close()
}
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(26, scalar(db, "PRAGMA user_version"))
assertEquals(25, scalar(db, "PRAGMA user_version"))
assertEquals(before, listOf(
scalar(db, "SELECT COUNT(*) FROM sessions"),
scalar(db, "SELECT COUNT(*) FROM performed_sets"),

View file

@ -69,7 +69,7 @@ class AndroidV25ProgramExecutionMigrationTest {
reopened.close()
}
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(26, scalar(db, "PRAGMA user_version"))
assertEquals(25, scalar(db, "PRAGMA user_version"))
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM sessions WHERE source_program_id IS NOT NULL"))
assertEquals("ok", text(db, "PRAGMA integrity_check"))
db.rawQuery("PRAGMA foreign_key_check", null).use { cursor ->

View file

@ -1,50 +0,0 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class AndroidV26SleepMigrationTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun v25AddsEmptySleepDomainAndReopens() {
val name = "sleep-v25-${UUID.randomUUID()}.db"
TrainlogRepository(context, name).useForTest { assertTrueEmpty(it) }
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null, SQLiteDatabase.OPEN_READWRITE).use { db ->
db.execSQL("DROP TABLE sleep_diary_events")
db.execSQL("DROP TABLE sleep_diary_revisions")
db.execSQL("DROP TABLE sleep_diary_entries")
db.version = 25
}
var repository = TrainlogRepository(context, name)
try {
assertTrueEmpty(repository)
repository.close()
repository = TrainlogRepository(context, name)
assertTrueEmpty(repository)
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
).use { assertEquals(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() }
}
}

View file

@ -328,7 +328,7 @@ class BodyZonesTest {
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
SQLiteDatabase.OPEN_READONLY).use { db ->
db.rawQuery("PRAGMA user_version", null).use { cursor ->
assertTrue(cursor.moveToFirst()); assertEquals(26, cursor.getInt(0))
assertTrue(cursor.moveToFirst()); assertEquals(25, cursor.getInt(0))
}
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))

View file

@ -43,7 +43,7 @@ class BridgeBackupMigrationTest {
null,
android.database.sqlite.SQLiteDatabase.OPEN_READONLY,
)
.use { assertEquals(26, it.version) }
.use { assertEquals(25, it.version) }
} finally {
repository.close()
context.deleteDatabase(name)

View file

@ -70,57 +70,6 @@ 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")
@ -245,103 +194,4 @@ class ProgramsProjectionRepositoryTest {
context.deleteDatabase(name)
}
}
@Test
fun deliveredProgramPreparationCompletesProgramProgressAcrossRestart() {
val name = "program-delivery-${UUID.randomUUID()}.db"
var repository = TrainlogRepository(context, name)
try {
val catalog =
JSONObject()
.put("format", "trainlog-pc-catalog")
.put("version", 1)
.put(
"exercises",
JSONArray().put(
JSONObject()
.put("exercise_id", "ex_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee")
.put("name", "Exercise fixture")
.put("recording_mode", "sets")
.put("tracking_mode", "reps")
.put("data_fields", 0),
),
)
.toString()
assertTrue(repository.applyPcCatalogJson(catalog) is PcCatalogImportResult.Applied)
assertEquals(
ProgramsImportResult.Applied(1, 0, 0),
repository.applyProgramsV1Json(artifact(JSONArray().put(program()))),
)
assertEquals(
AiSessionDraftImportResult.Applied(1, 0),
repository.applySessionPreparationsJson(
preparationArtifact(includeProgramProvenance = false),
),
)
assertEquals(
StartAiSessionDraftResult.Started,
repository.startPreparedSession("spd_22222222-2222-4222-8222-222222222222"),
)
val active =
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
assertEquals(null, active.sourceProgramId)
assertEquals(null, active.sourceProgramSessionId)
val performed =
active.copy(
exercises =
active.exercises.map { occurrence ->
occurrence.copy(
sets = listOf(SessionSetDraft(reps = 8, weightKg = 40.0)),
)
},
)
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(performed),
)
assertTrue(repository.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved)
assertEquals(
ProgramSessionExecutionState.TODO,
repository.getSyncedProgram("pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")!!
.sessions.single().executionState,
)
assertEquals(
AiSessionDraftImportResult.Applied(0, 1),
repository.applySessionPreparationsJson(
preparationArtifact(deliveryState = "remote_unknown"),
),
)
val detail = repository.getSyncedProgram("pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")!!
assertEquals(ProgramSessionExecutionState.COMPLETED, detail.sessions.single().executionState)
val execution =
JSONObject(repository.buildProgramExecutionsV1Json())
.getJSONArray("executions")
.getJSONObject(0)
assertEquals("completed", execution.getString("state"))
assertEquals(
"pgs_cccccccc-cccc-4ccc-8ccc-cccccccccccc",
execution.getString("program_session_id"),
)
} finally {
repository.close()
}
repository = TrainlogRepository(context, name)
try {
val detail = repository.getSyncedProgram("pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")!!
assertEquals(1, detail.sessions.count {
it.executionState == ProgramSessionExecutionState.COMPLETED
})
assertEquals(
StartProgramSessionResult.AlreadyCompleted,
repository.startSyncedProgramSession(
"pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
"pgs_cccccccc-cccc-4ccc-8ccc-cccccccccccc",
),
)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

@ -105,106 +105,6 @@ class SessionPreparationExchangeTest {
}
}
@Test
fun startingMixedPreparationCopiesTargetsWithoutPerformedRows() {
val name = "prepared-mixed-${System.nanoTime()}.db"
val repository = TrainlogRepository(context, name)
try {
assertTrue(
repository.createExercise(
NewExerciseProfile(
name = "Test prepared walk",
recordingMode = RecordingMode.CONTINUOUS,
trackingMode = TrackingMode.DURATION,
dataFields = 0,
),
) is CreateExerciseResult.Created,
)
assertTrue(
repository.createExercise(
NewExerciseProfile(
name = "Test prepared press",
recordingMode = RecordingMode.SETS,
trackingMode = TrackingMode.REPS,
dataFields = 0,
),
) is CreateExerciseResult.Created,
)
val continuous = repository.listExercises().single { it.name == "Test prepared walk" }
val sets = repository.listExercises().single { it.name == "Test prepared press" }
val payload = JSONObject(artifact(repository))
val delivery = payload.getJSONArray("deliveries").getJSONObject(0)
delivery.put(
"occurrences",
JSONArray()
.put(
JSONObject()
.put("entry_id", "spe_11111111-1111-4111-8111-111111111111")
.put("position", 0)
.put("exercise_id", continuous.exerciseId)
.put("equipment_id", JSONObject.NULL)
.put("recording_mode", "continuous")
.put("tracking_mode", "duration")
.put("data_fields", continuous.dataFields)
.put("load_mode", "none")
.put("rest_seconds", 0)
.put("target_sets", JSONObject.NULL)
.put("target_reps", JSONObject.NULL)
.put("target_duration_seconds", 600)
.put("target_weight_kg", JSONObject.NULL)
.put("notes", "Target only"),
)
.put(
JSONObject()
.put("entry_id", "spe_22222222-2222-4222-8222-222222222222")
.put("position", 1)
.put("exercise_id", sets.exerciseId)
.put("equipment_id", JSONObject.NULL)
.put("recording_mode", "sets")
.put("tracking_mode", "reps")
.put("data_fields", sets.dataFields)
.put("load_mode", "external")
.put("rest_seconds", 90)
.put("target_sets", 3)
.put("target_reps", 10)
.put("target_duration_seconds", JSONObject.NULL)
.put("target_weight_kg", 52.0)
.put("notes", JSONObject.NULL),
),
)
assertEquals(
AiSessionDraftImportResult.Applied(1, 0),
repository.applySessionPreparationsJson(payload.toString()),
)
assertEquals(
StartAiSessionDraftResult.Started,
repository.startPreparedSession("spd_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
)
val loadResult = repository.loadActiveSessionDraft()
assertTrue(loadResult.toString(), loadResult is ActiveDraftLoadResult.Loaded)
val active = loadResult as ActiveDraftLoadResult.Loaded
assertEquals(2, active.draft.exercises.size)
assertEquals(600, active.draft.exercises[0].plan?.durationSeconds)
assertEquals(0, active.draft.exercises[0].continuousDurationSeconds)
assertEquals(3, active.draft.exercises[1].plan?.sets)
assertEquals(10, active.draft.exercises[1].plan?.reps)
assertEquals(52.0, active.draft.exercises[1].plan?.weightKg)
assertTrue(active.draft.exercises[1].sets.isEmpty())
assertEquals(1, scalarInt(name, "SELECT COUNT(*) FROM active_session_draft"))
assertEquals(0, scalarInt(name, "SELECT COUNT(*) FROM draft_continuous_activity"))
assertEquals(0, scalarInt(name, "SELECT COUNT(*) FROM draft_performed_sets"))
assertEquals(0, scalarInt(name, "SELECT COUNT(*) FROM draft_max_results"))
assertEquals(0, scalarInt(name, "SELECT COUNT(*) FROM draft_exercise_feedback"))
assertEquals(0, scalarInt(name, "SELECT COUNT(*) FROM sessions"))
assertEquals("ok", scalarText(name, "PRAGMA integrity_check"))
assertEquals(0, scalarInt(name, "SELECT COUNT(*) FROM pragma_foreign_key_check"))
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun deliveryReplaysStartsExplicitlyAndNeverOverwritesActiveDraft() {
val name = "prepared-${System.nanoTime()}.db"
@ -360,7 +260,7 @@ class SessionPreparationExchangeTest {
repository = TrainlogRepository(context, name)
assertEquals(1, repository.listPreparedSessions().size)
repository.close()
assertEquals("26", scalarText(name, "PRAGMA user_version"))
assertEquals("25", scalarText(name, "PRAGMA user_version"))
assertEquals(
"0",
scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"),

View file

@ -1,287 +0,0 @@
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)
}
}
}

View file

@ -506,7 +506,7 @@ class SyncGenerationServiceTest {
.use { db ->
db.rawQuery("PRAGMA user_version", null).use {
assertTrue(it.moveToFirst())
assertEquals(26, it.getInt(0))
assertEquals(25, it.getInt(0))
}
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
assertTrue(it.moveToFirst())

View file

@ -2260,7 +2260,7 @@ class TrainlogRepositoryDraftTest {
).use { db ->
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(26, cursor.getInt(0))
assertEquals(25, 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(26, cursor.getInt(0))
assertEquals(25, cursor.getInt(0))
}
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
assertTrue(cursor.moveToFirst())
@ -2695,46 +2695,6 @@ class TrainlogRepositoryDraftTest {
} catch (_: IllegalStateException) { }
}
@Test
fun completedContinuousPlanKeepsPerformedFactAndExportsWithoutSetTarget() {
val repo = openRepository()
val exercise = createExercise(
repo,
"Marche planifiée",
RecordingMode.CONTINUOUS,
TrackingMode.DURATION,
)
val plan = SessionExercisePlan(sets = 0, durationSeconds = 600)
assertEquals(
ActiveDraftMutationResult.Saved,
repo.saveActiveSessionDraft(
ActiveSessionDraft(
exercises = listOf(
SessionExerciseDraft(
exercise = exercise,
plan = plan,
continuousDurationSeconds = 540,
),
),
),
),
)
val result = repo.finalizeActiveSessionDraft() as FinalizeActiveDraftResult.Saved
val detail = repo.getSessionDetail(result.sessionId)!!
assertEquals(plan, detail.exercises.single().plan)
val exported =
JSONObject(repo.buildMobileExportV4Json())
.getJSONArray("sessions")
.getJSONObject(0)
.getJSONArray("exercises")
.getJSONObject(0)
assertTrue(exported.isNull("target"))
assertEquals(540, exported.getJSONObject("continuous").getInt("duration_seconds"))
assertTrue(!exported.getJSONObject("continuous").has("speed_kmh"))
assertTrue(!exported.getJSONObject("continuous").has("distance_km"))
}
@Test
fun targetOnlyDraftPersistsButCannotFinalizeWithoutActualWork() {
val repo = openRepository()

View file

@ -12,9 +12,8 @@ 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
@ -29,12 +28,8 @@ 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
@ -120,7 +115,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")
@ -135,84 +130,6 @@ 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()
@ -382,15 +299,6 @@ class AiSessionDraftUiWiringTest {
return payload
}
private fun createExercise(
name: String,
recordingMode: RecordingMode,
trackingMode: TrackingMode,
) =
(repository.createExercise(
NewExerciseProfile(name, recordingMode, trackingMode, 0),
) as CreateExerciseResult.Created).exercise
private fun artifact(exerciseId: String): String {
val target = JSONObject().put("sets", 3).put("reps", 10)
.put("duration_seconds", JSONObject.NULL).put("weight_kg", JSONObject.NULL)

View file

@ -37,7 +37,7 @@ class AppNavigationStateTest {
@After fun tearDown() { context.deleteDatabase(databaseName) }
@Test fun captureSectionsHaveTypedRootsWithoutMobileStatistics() {
assertEquals(7, AppSection.entries.size)
assertEquals(6, AppSection.entries.size)
assertFalse(AppSection.entries.any { it.name == "STATISTICS" })
AppSection.entries.forEach { assertEquals(it, it.rootRoute().section) }
assertEquals(AppSection.SESSIONS, AppRoute.SessionDetail("se_1").section)

View file

@ -67,7 +67,7 @@ class DraftFeedbackUiReadbackTest {
val completedSessionId = mutableStateOf<String?>(null)
compose.setContent { TrainlogTheme {
completedSessionId.value?.let { SessionDetailScreen(repository, it, {}, {}) }
?: SessionScreen(repository, 0, {}, {}, { _ -> }, {})
?: SessionScreen(repository, 0, {}, {}, {})
} }
compose.onNodeWithTag("draft-feedback-${entry.entryId}").fetchSemanticsNode()
compose.onNodeWithText("Ressenti · texte UI durable").fetchSemanticsNode()

View file

@ -7,22 +7,15 @@ 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
@ -33,13 +26,6 @@ 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
@ -123,106 +109,6 @@ class TrainlogAppNavigationCallbackTest {
assertArrayEquals(before, databaseDigest())
}
@Test fun activeExerciseEditorCreatesEquipmentReturnsSelectsAndSaves() {
val exercise = (repository.createExercise(
NewExerciseProfile(
"Goblet squat haltère",
RecordingMode.SETS,
TrackingMode.REPS,
0,
),
) as CreateExerciseResult.Created).exercise
val occurrence = SessionExerciseDraft(
exercise = exercise,
plan = SessionExercisePlan(
sets = 3,
reps = 10,
weightKg = 8.0,
loadMode = SessionLoadMode.EXTERNAL,
restSeconds = 90,
),
)
val before = SessionExerciseDraft(
exercise = (repository.createExercise(
NewExerciseProfile("Marche témoin", RecordingMode.CONTINUOUS, TrackingMode.DURATION, 0),
) as CreateExerciseResult.Created).exercise,
continuousDurationSeconds = 600,
)
val after = SessionExerciseDraft(
exercise = (repository.createExercise(
NewExerciseProfile("Presse témoin", RecordingMode.SETS, TrackingMode.REPS, 0),
) as CreateExerciseResult.Created).exercise,
sets = listOf(SessionSetDraft(reps = 12)),
)
repository.createCustomEquipment("ZZZ équipement témoin")
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
ActiveSessionDraft(exercises = listOf(before, occurrence, after)),
),
)
val editorState = TrainlogAppState().also {
it.navigation.open(AppRoute.SessionEditor)
}
compose.setContent {
TrainlogTheme {
TrainlogApp(
repository,
SyncExporter(context, repository),
SyncCatalogInbox(context, repository),
SyncRequestOutbox(context),
editorState,
)
}
}
compose.onNodeWithTag("edit-draft-exercise-${occurrence.entryId}")
.performSemanticsAction(SemanticsActions.OnClick)
compose.onNodeWithText("SAISIE — Goblet squat haltère")
.performScrollTo()
.assertIsDisplayed()
compose.onNodeWithTag("session-equipment-results")
.performScrollToNode(hasText("ZZZ équipement témoin"))
compose.onNodeWithTag("session-create-equipment")
.performSemanticsAction(SemanticsActions.OnClick)
compose.waitForIdle()
assertEquals(AppRoute.EquipmentCreate(AppRoute.SessionEditor), editorState.navigation.route)
assertEquals(occurrence.entryId, editorState.pendingSessionEquipmentEntryId)
compose.onNodeWithTag("equipment-create-name").performTextInput("Haltère")
compose.onNodeWithText("Créer")
.performSemanticsAction(SemanticsActions.OnClick)
compose.waitForIdle()
assertEquals(AppRoute.SessionEditor, editorState.navigation.route)
compose.onNodeWithText("SAISIE — Goblet squat haltère")
.performScrollTo()
.assertIsDisplayed()
compose.onNode(
hasText("✓ Haltère") and hasText("Équipement sélectionné."),
).performScrollTo().assertIsDisplayed()
compose.onNodeWithTag("add-to-session")
.performSemanticsAction(SemanticsActions.OnClick)
compose.runOnIdle {
val loaded = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
assertEquals(3, loaded.draft.exercises.size)
assertEquals(before, loaded.draft.exercises[0])
assertEquals(occurrence.entryId, loaded.draft.exercises[1].entryId)
assertTrue(loaded.draft.exercises[1].sets.isEmpty())
assertEquals(3, loaded.draft.exercises[1].plan?.sets)
assertEquals(10, loaded.draft.exercises[1].plan?.reps)
assertEquals(90, loaded.draft.exercises[1].plan?.restSeconds)
assertEquals(8.0, loaded.draft.exercises[1].plan?.weightKg)
assertEquals(after, loaded.draft.exercises[2])
assertEquals(
"Haltère",
repository.listEquipment().single {
it.equipmentId == loaded.draft.exercises[1].equipmentId
}.displayName,
)
}
}
private fun databaseDigest(): ByteArray = MessageDigest.getInstance("SHA-256")
.digest(context.getDatabasePath(databaseName).readBytes())

View file

@ -1,23 +1,5 @@
# 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
@ -41,16 +23,7 @@ Android schema v22 adds a pending manual-preparation store which is separate
from AI proposals and the exactly-one active-session singleton. A received
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. 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.
copies targets only and creates no performed facts.
Android schema v23 adds the durable manual-preparation withdrawal ledger. The
schema is unchanged by Web proposal withdrawal: the existing durable AI draft
@ -893,18 +866,8 @@ decimal input is accepted (`12,5`); one value applies to all sets or values may
be separated with `;`. Assisted equipment is explicitly labelled
`Assistance (kg)`. Empty load remains distinct from an entered zero.
`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.
`Nouvelle machine` in that same selector creates a persistent local custom
equipment entry with a generated stable `eq_…` ID and selects it immediately.
The shared bundled catalogue uses reserved canonical IDs. User-created IDs are
exchanged first by definitions V1, so a V2 association can resolve them on the
receiving side; conflicts remain explicit and are never converted to null.

View file

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

View file

@ -1,21 +1,20 @@
# Current implementation state
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
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
coordinator as SyncScreen. That coordinator has one process-wide ownership
arbiter: explicit foreground intent has priority over automatic background
resumption, while a foreground service may remain alive without owning the
resumption, while the foreground service may remain alive without owning the
conversation. An automatic run yields cooperatively at safe boundaries; its
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
@ -46,27 +45,23 @@ and an accessible send action. Draft/local advances the same preparation to a
ready revision before delivery; ready/local delivers the current revision.
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 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 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 public Android release chain advances from v0.1.1 versionCode 2 through
v0.1.2 versionCode 3 and v0.1.3 versionCode 4 to v0.1.4 versionCode 5 under
certificate SHA-256
v0.1.2 versionCode 3 to v0.1.3 versionCode 4 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.
@ -81,7 +76,7 @@ The rollout packager now keeps the legacy request daemon and one-shot helper in
the same relocatable, hashed bundle as the Web generation coordinator, avoiding
mixed-checkout user-service execution during the controlled rollout.
Snapshot date: **2026-09-22**.
Snapshot date: **2026-09-20**.
`TRAINLOG_CODE_READABILITY_V1` normalizes the recent generation-MTP,
generation/ACK, Android-backup, Web Dashboard serialization, tests, doubles and
@ -99,9 +94,9 @@ Android is the field companion: it captures training and body data, preserves
the active draft, receives AI proposals, triggers synchronization, and shows
quick summaries. The C17/Notcurses TUI is the administration, inspection,
maintenance, import/export, and technical-tooling surface. The local Web
sibling provides its embedded application shell, Dashboard, Sessions,
Programmes, Exercises, factual Analyse views, and Sleep Diary
workflow through Trainlog Core.
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.
Trainlog Core owns business truth. The desktop SQLite database remains the
canonical local source of truth and long-term history; no interface owns a
@ -112,11 +107,11 @@ parallel implementation of its rules.
| Boundary | Current state |
|---|---|
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
| Desktop SQLite | schema v29; Sleep Diary V1 is additive in v29 |
| Android SQLite | schema v26; Sleep Diary V1 is additive in v26 |
| Desktop SQLite | schema v28 |
| Android SQLite | schema v25 |
| 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.4` stable, synchronized across Android and desktop; latest stable release: `v0.1.4` |
| Trainlog product version | `0.1.3` stable, synchronized across Android and desktop; latest stable release: `v0.1.3` |
| Interface language | `TRAINLOG_I18N_V0_1_1=PASS`: French default; English selectable in Settings → Language on both surfaces |
| AI history export | `TRAINLOG_AI_EXPORT_V1` active |
| AI session proposals | `TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING` |
@ -134,7 +129,6 @@ 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) |
@ -144,18 +138,17 @@ 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` (authenticated Bluetooth primary transport with direct MTP recovery on one authorized phone) |
| Private full-generation rollout | `TRAINLOG_SYNC_FINALIZATION_AND_ROLLOUT_V1=PASS` (direct MTP, one authorized phone) |
| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` |
| Web Exercises V1 | `TRAINLOG_WEB_EXERCISES_V1=PASS` |
| 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 |
| Current operational cursor | `TRAINLOG_ANDROID_UI_REDESIGN_V1_CONTRACT` |
| 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) |
| 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 |
| Generation MTP and Android backup | Software complete and validated in the authorized private physical-device rollout |
Desktop and Android schema numbers are independent. Neither changes the frozen
Trainlog JSON V1 contract.
@ -235,10 +228,6 @@ 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;
@ -302,24 +291,7 @@ 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` 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.
catalogue administration surface; Analyse remains an explicit placeholder.
Exercises uses bounded Core-owned list/detail reads, canonical name
normalization, BODY ZONES taxonomy, profiled creation/update commands and
@ -405,10 +377,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 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 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 private Program execution rollout installed desktop schema v27 and the
signed nondebuggable Android versionCode 17/schema v25 update without
@ -427,17 +399,6 @@ 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
@ -626,8 +587,9 @@ tests, Android debug assembly, and 211 Android tests (207 passed, four optional
historical-fixture skips). Strict desktop builds use Clang 22.1.8; the compared
pre-existing GCC 16.2.1 Web warning gate remains documented rather than green.
- `TRAINLOG_WEB_EXERCISES_V1=PASS`. `TRAINLOG_WEB_ANALYSIS_V1` is implemented
on the 0.1.4 branch and awaits the requested Dashboard/Analyse visual review.
- `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.
- `APP_SHELL_V1` retains a recorded legacy visual/accessibility review gate; its
observations are inputs to, not decisions for, the new 0.1.3 Android contract.
- Session Generator V1 is hidden while V2 planning semantics are developed.

View file

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

View file

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

View file

@ -6,7 +6,6 @@ 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)

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

View file

@ -1,387 +0,0 @@
%PDF-1.4
%Trainlog
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [4 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>
endobj
4 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 842 595] /Resources << /Font << /F1 3 0 R >> >> /Contents 5 0 R >>
endobj
5 0 obj
<< /Length 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 315 KiB

View file

@ -1,32 +0,0 @@
# 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.

View file

@ -1,113 +0,0 @@
# 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).

View file

@ -1,42 +0,0 @@
# 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)

View file

@ -5,10 +5,9 @@ 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. 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.
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.
This document owns future work. The implemented baseline is summarized in
[current_state.md](current_state.md); completed narratives and evidence belong
@ -24,20 +23,19 @@ 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, the frozen synchronization
transport boundaries, and the distinction between measured values and
estimates.
identities, actual-versus-planned separation, direct MTP transport, and the
distinction between measured values and estimates.
## Current baseline
Stable v0.1.4 is the release baseline for subsequent development.
Desktop schema v29, Android schema v26, Notcurses, direct `Documents/Trainlog`
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`
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.4 baseline rather than treated as a current implementation plan.
v0.1.3 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
@ -45,9 +43,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, the factual
Dashboard/Analyse read-model foundation, and Sleep Diary V1 are included in
stable v0.1.4.
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.
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
@ -56,17 +54,12 @@ mobile snapshot or `TRAINLOG_FORMAT_V1` contracts.
## Current cursor
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.
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.
```text
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_CARDIO_V1_NEXT_CONTRACT
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_ANDROID_UI_REDESIGN_V1_CONTRACT
```
The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this

View file

@ -1,70 +1,6 @@
# Synchronization exchange
## 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
## 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,
@ -105,11 +41,7 @@ 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. 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
immutable preparation-withdrawal operations. 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
@ -168,7 +100,6 @@ 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
@ -285,16 +216,6 @@ 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

View file

@ -1,35 +1,5 @@
# 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
@ -135,13 +105,6 @@ 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
@ -261,27 +224,6 @@ 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,
@ -714,10 +656,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 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.
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.
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

View file

@ -1,7 +1,7 @@
project(
'trainlog',
'c',
version: '0.1.4',
version: '0.1.3',
meson_version: '>=1.3.0',
default_options: [
'c_std=c17',

View file

@ -95,54 +95,6 @@ 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)

View file

@ -22,8 +22,6 @@ 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(
@ -58,13 +56,6 @@ 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,
@ -86,14 +77,12 @@ 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,
),
)
@ -131,14 +120,6 @@ 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"])
@ -166,35 +147,6 @@ 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()

View file

@ -1,161 +0,0 @@
#!/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()

View file

@ -1,129 +0,0 @@
#!/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()

View file

@ -65,24 +65,16 @@ 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.4")
self.assertEqual(inventory["desktop_schema"], 29)
self.assertEqual(inventory["android_schema"], 26)
self.assertEqual(inventory["product_version"], "0.1.3")
self.assertEqual(inventory["desktop_schema"], 28)
self.assertEqual(inventory["android_schema"], 25)
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(),
@ -96,7 +88,7 @@ class DeploymentToolsTest(unittest.TestCase):
text=True,
)
self.assertEqual(linked.returncode, 0, linked.stderr)
self.assertIn("trainlog 0.1.4", linked.stdout)
self.assertIn("trainlog 0.1.3", linked.stdout)
imported = subprocess.run(
[
"python3",

View file

@ -129,21 +129,18 @@ class SyncErrorClassificationTest(unittest.TestCase):
def test_transport_timeout_has_a_stable_code_and_bounded_diagnostic(self):
timeout = subprocess.TimeoutExpired(["adapter", "push"], 30)
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,
)
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,
)
self.assertEqual(sync_orchestrator.failure_code(caught.exception), "transport_timeout")
def test_known_operational_failures_remain_distinct(self):
@ -207,7 +204,7 @@ class SyncErrorClassificationTest(unittest.TestCase):
(root / name).write_text("{}")
observed = []
def inspect(_adapter, _operation, _peer, outbox, _deadline, **_kwargs):
def inspect(_adapter, _operation, _peer, outbox, _deadline):
observed.append(
sorted(
str(path.relative_to(outbox))

View file

@ -71,38 +71,6 @@ 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"
@ -168,60 +136,6 @@ 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("{}")
@ -289,8 +203,6 @@ 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=[]

View file

@ -1,4 +1,4 @@
import fcntl, json, os, signal, subprocess, tempfile, time, unittest, uuid
import json, os, signal, subprocess, tempfile, time, unittest, uuid
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
@ -134,118 +134,6 @@ 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)

View file

@ -1,4 +1,3 @@
import argparse
import importlib.util
import subprocess
import tempfile
@ -18,14 +17,6 @@ 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:
@ -33,7 +24,7 @@ class SyncPeerWorkerTest(unittest.TestCase):
Path("/fixed/adapter.py"),
"pull",
"remote:Trainlog/Sync-Test/run",
self.transport,
Path("/private/transport"),
time.monotonic() + 180,
)
timeout = run.call_args.kwargs["timeout"]
@ -45,25 +36,10 @@ 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",
self.transport, 100.0, clock=lambda: 50.0,
Path("/private/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 = []
@ -79,7 +55,7 @@ class SyncPeerWorkerTest(unittest.TestCase):
with self.assertRaises(worker.ConversationDeadlineExpired):
worker.run_adapter(
Path("/fixed/adapter"), "pull", "peer_fixed",
self.transport, 10.0, clock=lambda: 10.0,
Path("/private/transport"), 10.0, clock=lambda: 10.0,
)
def test_deadline_limited_pull_becomes_protocol_timeout(self):
@ -89,7 +65,7 @@ class SyncPeerWorkerTest(unittest.TestCase):
with self.assertRaises(worker.ConversationDeadlineExpired):
worker.run_adapter(
Path("/fixed/adapter"), "pull", "peer_fixed",
self.transport, 10.5, clock=lambda: 10.0,
Path("/private/transport"), 10.5, clock=lambda: 10.0,
)
def test_blocked_full_budget_pull_is_transport_timeout(self):
@ -99,7 +75,7 @@ class SyncPeerWorkerTest(unittest.TestCase):
with self.assertRaisesRegex(RuntimeError, "transport_timeout"):
worker.run_adapter(
Path("/fixed/adapter"), "pull", "peer_fixed",
self.transport, 100.0, clock=lambda: 50.0,
Path("/private/transport"), 100.0, clock=lambda: 50.0,
)
def test_delayed_correlated_generation_survives_multiple_polls(self):
@ -142,91 +118,6 @@ 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"

View file

@ -126,45 +126,6 @@ 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(

View file

@ -1,667 +0,0 @@
#!/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()

View file

@ -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, 29):
raise CausalError(f"desktop schema v20-v29 required, found v{version}")
if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28):
raise CausalError(f"desktop schema v20-v28 required, found v{version}")
def target_snapshot(db: sqlite3.Connection, kind: str, target: str):

View file

@ -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, 29):
raise LifecycleError(f"desktop schema v19-v29 required, found v{version}")
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}")
def import_document(connection, document, own_transaction=True):

View file

@ -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, 29)
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28)
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-v29 requis")
raise ValueError("schema desktop v15-v28 requis")
root = {
"format": "TRAINLOG_AI_EXPORT",

View file

@ -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, 29)
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v18-v29 requis")
raise ValueError("schema desktop v18-v28 requis")
columns = {row[1] for row in connection.execute(
"PRAGMA table_info(ai_session_drafts)")}
if "published_at" not in columns:

View file

@ -28,9 +28,9 @@ def main():
args = parser.parse_args()
connection = connect_database(args.database)
try:
supported_versions = tuple(range(8, 30))
supported_versions = tuple(range(8, 29))
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v8 à v29 requis")
raise ValueError("schema desktop v8 à v28 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"))

View file

@ -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, 30))
supported_versions = tuple(range(8, 29))
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v8 à v29 requis")
raise ValueError("schema desktop v8 à v28 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(

View file

@ -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, 29):
raise ValueError("schema desktop v12-v29 requis")
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28):
raise ValueError("schema desktop v12-v28 requis")
rows = con.execute(
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
"ORDER BY source_exercise_id COLLATE BINARY"

View file

@ -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, 30))
supported_versions = tuple(range(11, 29))
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():

View file

@ -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, 29):
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28):
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,

View file

@ -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, 30):
if version not in range(8, 29):
raise SystemExit(
"PC_CATALOG_EXPORT=FAIL "
f"schema={version}"

View file

@ -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, 29):
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28):
raise ValueError("mobile V4 exige le schéma desktop v19 ou v20")
supported_versions = range(11, 30)
supported_versions = range(11, 29)
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)")

View file

@ -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, 29):
raise ValueError("desktop schema v26, v27, v28 or v29 required")
if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28):
raise ValueError("desktop schema v26, v27 or v28 required")
programs = []
for row in db.execute(
"SELECT program_id,revision_id,title,note,state,start_date,end_date,created_at,"

View file

@ -17,26 +17,17 @@ MAX_WITHDRAWALS = 128
def build_export(connection: sqlite3.Connection) -> dict:
supported_versions = (24, 25, 26, 27, 28, 29)
supported_versions = (24, 25, 26, 27, 28)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("desktop schema v24, v25, v26, v27, v28 or v29 required")
raise ValueError("desktop schema v24, v25, v26, v27 or v28 required")
deliveries = []
rows = connection.execute(
"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 "
"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 "
"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') 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"
"))) "
"WHERE p.withdrawn_at IS NULL AND d.state IN('pending','remote_unknown') "
"ORDER BY d.created_at,d.delivery_id LIMIT ?",
(MAX_DELIVERIES + 1,),
).fetchall()
@ -57,7 +48,6 @@ 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],

View file

@ -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, 30)
supported_versions = range(15, 29)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v15-v29 requis")
raise ValueError("schema desktop v15-v28 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()

View file

@ -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, 30)
supported_versions = range(18, 29)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ImportFailure("schema desktop v18-v29 requis")
raise ImportFailure("schema desktop v18-v28 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")

View file

@ -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, 30)
supported_versions = range(8, 29)
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
fail("schema desktop v8 à v21 requis")
proof_path = args.mobile_export

View file

@ -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, 30)
supported_versions = range(8, 29)
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():

View file

@ -56,7 +56,7 @@ def load(path):
def apply_aliases(con, aliases):
"""Apply parsed aliases inside the caller-owned transaction."""
supported_versions = range(12, 30)
supported_versions = range(12, 29)
if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v12-v21 requis")
for source, canonical in aliases:

View file

@ -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, 30)
supported_versions = range(11, 29)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ImportFailure("schema desktop v11-v29 requis")
raise ImportFailure("schema desktop v11-v28 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

View file

@ -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, 29):
fail("schema desktop v17-v29 requis")
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28):
fail("schema desktop v17-v28 requis")
applied=equal=ancestor=pending=0;seen=set()
for item in root["exercises"]:
validate(item);eid=item["exercise_id"]

View file

@ -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, 30):
if version not in range(5, 29):
raise ImportFailure(
f"base desktop schema v5 à v29 attendue, version trouvée: {version}"
f"base desktop schema v5 à v28 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, 29):
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v29")
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 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")}

View file

@ -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, 30)
supported_versions = range(15, 29)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v15-v29 requis")
fail("schema desktop v15-v28 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"]):

View file

@ -1,5 +1,6 @@
#!/usr/bin/env bash
# Install the private Trainlog automatic synchronization user services.
# Trainlog install syncd user helper.
# Owns this command-line workflow while preserving the versioned database and exchange contracts it consumes.
set -euo pipefail
ROOT="$(
@ -9,50 +10,41 @@ 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}"
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 "$SYNC_ONCE" ]]; then
echo "missing executable: $SYNC_ONCE" >&2
echo "run meson compile -C build first" >&2
exit 1
fi
mkdir -p "$HOME/.local/bin" "$HOME/.config/systemd/user"
if [[ ! -x "$MTP_ADAPTER" ]]; then
echo "missing executable: $MTP_ADAPTER" >&2
echo "run meson compile -C build first" >&2
exit 1
fi
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"
mkdir -p \
"$HOME/.local/bin" \
"$HOME/.config/systemd/user"
cat > "$HOME/.config/systemd/user/trainlog-syncd.service" <<EOF
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
[Unit]
Description=Trainlog automatic synchronization agent
Description=Trainlog Android MTP synchronization agent
[Service]
Type=simple
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"
ExecStart=/usr/bin/python3 "$DAEMON" --sync-once "$SYNC_ONCE" --mtp-adapter "$MTP_ADAPTER" --interval 3
Restart=on-failure
RestartSec=2
@ -62,11 +54,6 @@ 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

View file

@ -12,12 +12,10 @@ 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",
@ -82,17 +80,13 @@ 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_BT_ADAPTER="$root/libexec/trainlog-generation-bt-adapter"\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_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(
@ -100,11 +94,7 @@ 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_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'
'#!/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'
)
files = sorted(path for path in args.output.rglob("*") if path.is_file())
inventory = {
@ -113,9 +103,9 @@ def main() -> int:
"source_commit": subprocess.check_output(
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
).strip(),
"product_version": "0.1.4",
"desktop_schema": 29,
"android_schema": 26,
"product_version": "0.1.3",
"desktop_schema": 28,
"android_schema": 25,
"protocols": [
"mobile-export-v3",
"mobile-history-v4",
@ -124,19 +114,15 @@ 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",
],
@ -147,9 +133,6 @@ def main() -> int:
"libuuid",
"libudev",
"libmtp",
"bluez",
"python-dbus",
"python-gobject",
"notcurses",
"coreutils-readlink",
],
@ -168,10 +151,8 @@ 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

View file

@ -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, 29):
fail("desktop schema v27, v28 or v29 required")
if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28):
fail("desktop schema v27 or v28 required")
added = 0
advanced = 0
skipped = 0

View file

@ -1,436 +0,0 @@
#!/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()

View file

@ -29,7 +29,6 @@ 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
@ -76,8 +75,6 @@ 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),
@ -209,9 +206,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, 29)
supported_versions = (24, 25, 26, 27, 28)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise GenerationError("desktop schema v24, v25, v26, v27, v28 or v29 required")
raise GenerationError("desktop schema v24, v25, v26, v27 or v28 required")
def peer_identity(db: sqlite3.Connection, kind: str) -> str:
@ -328,8 +325,7 @@ 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",
"sleep_diary_exchange.py"):
if tool in ("execution_draft_exchange.py", "causal_delete_exchange.py"):
command.extend(extra); command.append(str(output))
else:
command.append(str(output)); command.extend(extra)
@ -364,22 +360,7 @@ 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)
# 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]
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]
if retained >= MAX_RETAINED_PER_PEER:
raise GenerationError("generation retention capacity exhausted")
stage = owned_root / "staging" / generation
@ -445,34 +426,7 @@ def capture_desktop(database: Path, owned_root: Path, consumer: str,
(generation, delivery["delivery_id"], delivery["revision_id"]),
)
if cursor.rowcount != 1:
# 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")
raise GenerationError("preparation delivery changed during capture")
for withdrawal in captured_preparations["withdrawals"]:
cursor = db.execute(
"UPDATE session_preparation_withdrawals SET generation_id=? "
@ -498,15 +452,6 @@ 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:
@ -665,8 +610,6 @@ 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
@ -708,8 +651,6 @@ 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)
@ -781,18 +722,6 @@ 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')",

View file

@ -183,59 +183,10 @@ 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 = (
@ -388,45 +339,22 @@ def run(args: argparse.Namespace) -> int:
"timeout_seconds",
}
drive_keys = {"drive_enabled", "drive_remote"}
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:
if set(config) not in (base_keys, base_keys | drive_keys):
raise RuntimeError("invalid trusted sync configuration")
if (
config["format"] != "trainlog-sync-orchestrator-config"
or config["version"] not in (1, 2, 3)
or config["version"] not in (1, 2)
or config["enabled"] is not True
):
raise RuntimeError("full-generation synchronization is disabled")
if config["mode"] not in ("directory", "bt", "mtp", "drive", "auto"):
if config["mode"] not in ("directory", "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")
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:
if config["mode"] in ("drive", "auto") 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")
@ -436,7 +364,7 @@ def run(args: argparse.Namespace) -> int:
):
raise RuntimeError("invalid expected peer identity")
timeout = config["timeout_seconds"]
if type(timeout) is not int or timeout < 1 or timeout > 900:
if not isinstance(timeout, 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.
@ -472,30 +400,21 @@ 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":
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")
# 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"
command = [
sys.executable,
str(Path(__file__).with_name("sync_peer_worker.py")),
@ -514,11 +433,7 @@ def run(args: argparse.Namespace) -> int:
"--mode",
selected_mode,
]
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 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)])
@ -578,7 +493,6 @@ 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,

View file

@ -9,7 +9,6 @@ always produced and consumed by the existing generation services.
from __future__ import annotations
import argparse
import fcntl
import hashlib
import json
import os
@ -123,58 +122,36 @@ def run_adapter(
deadline: float,
allow_missing_peer: bool = False,
clock=time.monotonic,
transport_name: str = "MTP",
lock_name: str = "mtp.lock",
) -> bool:
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)
remaining = deadline - clock()
if remaining <= 0:
raise ConversationDeadlineExpired()
timeout = min(remaining, MTP_OPERATION_TIMEOUT_SECONDS)
deadline_limited = remaining < MTP_OPERATION_TIMEOUT_SECONDS
try:
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)
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
if result.returncode != 0:
diagnostic = (
result.stderr.decode("utf-8", "replace")[:1024]
or f"{transport_name} adapter failed"
result.stderr.decode("utf-8", "replace")[:1024] or "MTP 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
@ -188,8 +165,6 @@ 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.
@ -222,15 +197,7 @@ def push_adapter(
shutil.copy2(source, destination)
else:
raise RuntimeError(f"invalid MTP outbox object: {relative}")
run_adapter(
adapter,
"push",
peer,
outbox,
deadline,
transport_name=transport_name,
lock_name=lock_name,
)
run_adapter(adapter, "push", peer, outbox, deadline)
def push_desktop_generation(
@ -239,19 +206,9 @@ 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,
transport_name=transport_name,
lock_name=lock_name,
)
push_adapter(adapter, peer, root, [relative_path], deadline)
push_adapter(
adapter,
peer,
@ -263,8 +220,6 @@ def push_desktop_generation(
"desktop-generation-v1.json",
],
deadline,
transport_name=transport_name,
lock_name=lock_name,
)
@ -402,35 +357,6 @@ 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():
@ -467,76 +393,6 @@ 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)
@ -545,41 +401,14 @@ 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", "bt", "mtp", "drive"),
default="directory",
)
parser.add_argument("--bt-adapter", type=Path)
parser.add_argument("--mode", choices=("directory", "mtp", "drive"), default="directory")
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 == "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.mode == "mtp":
if args.mtp_adapter is None:
raise RuntimeError("MTP adapter is required")
refresh_mtp_peer(
@ -646,17 +475,7 @@ def main() -> int:
}
archive_ack_path = args.transport_root / "desktop-archive-acknowledgements-v1.json"
publish_json(archive_ack_path, archive_acknowledgements)
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":
if args.mode == "mtp":
push_adapter(
args.mtp_adapter,
args.expected_peer,
@ -691,17 +510,7 @@ 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 == "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":
if args.mode == "mtp":
push_adapter(
args.mtp_adapter,
args.expected_peer,
@ -746,17 +555,7 @@ 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 == "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":
if 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
@ -830,7 +629,6 @@ 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,
@ -857,7 +655,7 @@ def main() -> int:
},
"drafts": drafts,
"ai_midpoint": {"result": "not_configured"},
"ai_post_sync": ai_post_sync,
"ai_post_sync": {"result": "not_configured"},
}
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