Add bidirectional Android sync foundation

This commit is contained in:
fy59 2026-09-06 16:07:04 +02:00
parent 7e950baf61
commit 28f6f8ea44
20 changed files with 5049 additions and 471 deletions

View file

@ -185,3 +185,17 @@ Next:
- added persistent body measurement recording; - added persistent body measurement recording;
- validated the application on a real Samsung device through ADB. - validated the application on a real Samsung device through ADB.
<!-- TRAINLOG_ANDROID_LOCAL_CHANGELOG _END --> <!-- TRAINLOG_ANDROID_LOCAL_CHANGELOG _END -->
<!-- TRAINLOG_SYNC_FOUNDATION_CHANGELOG -->
### Bidirectional synchronization foundation
- validated Android-to-PC domain snapshot transfer through direct MTP;
- added strict transactional and idempotent desktop mobile import;
- added canonical PC exercise-catalog export;
- validated PC-to-Android catalog publication through direct MTP;
- added Android Storage Access Framework access for PC-created catalog files;
- made the Android synchronization folder selection recoverable/changeable;
- kept synchronization artifacts separate from frozen Trainlog session JSON v1;
- documented the next synchronization architecture: structured history,
detailed sync inspection, common sync engine and PC-side `trainlog-syncd`.
<!-- TRAINLOG_SYNC_FOUNDATION_CHANGELOG _END -->

View file

@ -48,6 +48,8 @@ dependencies {
"androidx.compose.ui:ui-tooling-preview" "androidx.compose.ui:ui-tooling-preview"
) )
implementation("androidx.documentfile:documentfile:1.1.0")
debugImplementation( debugImplementation(
"androidx.compose.ui:ui-tooling" "androidx.compose.ui:ui-tooling"
) )

View file

@ -4,6 +4,9 @@ import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import com.labfytools.trainlog.data.TrainlogRepository 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.ui.TrainlogApp import com.labfytools.trainlog.ui.TrainlogApp
import com.labfytools.trainlog.ui.theme.TrainlogTheme import com.labfytools.trainlog.ui.theme.TrainlogTheme
@ -18,10 +21,31 @@ class MainActivity : ComponentActivity() {
applicationContext applicationContext
) )
val exporter =
SyncExporter(
applicationContext,
repository,
)
val inbox =
SyncCatalogInbox(
applicationContext,
repository,
)
val requestOutbox =
SyncRequestOutbox(
applicationContext
)
setContent { setContent {
TrainlogTheme { TrainlogTheme {
TrainlogApp( TrainlogApp(
repository = repository repository = repository,
exporter = exporter,
inbox = inbox,
requestOutbox =
requestOutbox,
) )
} }
} }

View file

@ -31,6 +31,7 @@ import com.labfytools.trainlog.ui.theme.TrainlogTypography
@Composable @Composable
fun BodyScreen( fun BodyScreen(
repository: TrainlogRepository, repository: TrainlogRepository,
onBodySaved: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
val colors = val colors =
@ -415,6 +416,8 @@ fun BodyScreen(
revision += 1 revision += 1
message = message =
"Mensurations enregistrées." "Mensurations enregistrées."
onBodySaved()
} }
SaveBodyObservationResult.Invalid -> { SaveBodyObservationResult.Invalid -> {

View file

@ -8,6 +8,7 @@ fun HomeScreen(
onExercise: () -> Unit, onExercise: () -> Unit,
onBody: () -> Unit, onBody: () -> Unit,
onHistory: () -> Unit, onHistory: () -> Unit,
onSync: () -> Unit,
) { ) {
TrainlogScreen( TrainlogScreen(
subtitle = "A C C U E I L" subtitle = "A C C U E I L"
@ -52,6 +53,16 @@ fun HomeScreen(
) )
} }
TrainlogFrame(
title = "SYNCHRONISATION"
) {
TrainlogAction(
label = "Synchroniser avec le PC",
description = "Préparer les données pour le transport MTP.",
onClick = onSync,
)
}
TrainlogFrame( TrainlogFrame(
title = "STATUT", title = "STATUT",
active = false, active = false,

View file

@ -36,6 +36,7 @@ fun SessionScreen(
catalogRevision: Int, catalogRevision: Int,
onBack: () -> Unit, onBack: () -> Unit,
onCreateExercise: () -> Unit, onCreateExercise: () -> Unit,
onSessionSaved: () -> Unit,
) { ) {
val colors = val colors =
LocalTrainlogColors.current LocalTrainlogColors.current
@ -240,6 +241,8 @@ fun SessionScreen(
message = message =
"Séance enregistrée." "Séance enregistrée."
onSessionSaved()
} }
SaveSessionResult.Invalid -> { SaveSessionResult.Invalid -> {

View file

@ -0,0 +1,297 @@
package com.labfytools.trainlog.ui
/* TRAINLOG_SYNC_AUTO_APPLY */
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
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 com.labfytools.trainlog.data.CatalogInboxResult
import com.labfytools.trainlog.data.SyncCatalogInbox
import com.labfytools.trainlog.data.SyncRequestOutbox
import com.labfytools.trainlog.data.SyncRequestResult
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
@Composable
fun SyncScreen(
inbox: SyncCatalogInbox,
requestOutbox: SyncRequestOutbox,
onCatalogChanged: () -> Unit,
onBack: () -> Unit,
) {
val colors =
LocalTrainlogColors.current
var status by
remember {
mutableStateOf<String?>(
null
)
}
var success by
remember {
mutableStateOf(false)
}
var folderAuthorized by
remember {
mutableStateOf(
inbox.hasFolderAccess()
)
}
LaunchedEffect(Unit) {
when (
val result =
inbox.importPcCatalog()
) {
is CatalogInboxResult.Imported -> {
if (
result.imported > 0 ||
result.reconciled > 0
) {
success = true
status =
(
"Catalogue PC appliqué automatiquement : " +
"${result.imported} nouveau(x), " +
"${result.reconciled} réconcilié(s)."
)
onCatalogChanged()
}
}
CatalogInboxResult.FolderNotAuthorized,
CatalogInboxResult.FileNotFound,
is CatalogInboxResult.Error -> {
/* Nothing to import yet. */
}
}
}
val folderLauncher =
rememberLauncherForActivityResult(
contract =
ActivityResultContracts
.OpenDocumentTree(),
) {
uri ->
if (uri != null) {
val saved =
inbox.saveTreeUri(
uri
)
folderAuthorized =
saved
success =
saved
status =
if (saved) {
"Dossier Trainlog autorisé."
} else {
"Autorisation du dossier impossible."
}
if (saved) {
when (
val result =
inbox.importPcCatalog()
) {
is CatalogInboxResult.Imported -> {
success = true
status =
(
"Dossier autorisé · catalogue PC : " +
"${result.imported} nouveau(x), " +
"${result.reconciled} réconcilié(s)."
)
onCatalogChanged()
}
CatalogInboxResult.FileNotFound -> {
success = true
status =
"Dossier autorisé · aucun catalogue PC reçu."
}
CatalogInboxResult.FolderNotAuthorized,
is CatalogInboxResult.Error -> {
/* Keep the permission status already shown. */
}
}
}
}
}
TrainlogScreen(
subtitle =
"S Y N C H R O N I S A T I O N"
) {
TrainlogAction(
label = "< Retour",
description =
"Revenir à l'accueil.",
onClick = onBack,
accent = colors.muted,
)
TrainlogFrame(
title = "SYNCHRONISER"
) {
TrainlogInfo(
"Le snapshot Android est maintenu automatiquement.",
color = colors.accent,
)
TrainlogAction(
label =
"Synchroniser maintenant",
description =
"Envoie une demande au service Trainlog du PC.",
accent =
colors.success,
onClick = {
when (
val result =
requestOutbox
.requestSync()
) {
is SyncRequestResult.Requested -> {
success = true
status =
(
"Demande envoyée : " +
result.requestId
)
}
SyncRequestResult.Unsupported -> {
success = false
status =
"Android non supporté."
}
is SyncRequestResult.Error -> {
success = false
status =
result.message
}
}
},
)
}
TrainlogFrame(
title =
"CATALOGUE PC → ANDROID"
) {
if (folderAuthorized) {
TrainlogInfo(
"Dossier Trainlog autorisé.",
color =
colors.success,
)
}
TrainlogAction(
label =
if (folderAuthorized) {
"Changer le dossier Trainlog"
} else {
"Autoriser le dossier Trainlog"
},
description =
"Choisir Téléchargements/Trainlog.",
onClick = {
folderLauncher.launch(
null
)
},
accent =
colors.warning,
)
TrainlogAction(
label =
"Appliquer le dernier catalogue PC",
description =
"Réconcilie les exercices publiés par le PC.",
onClick = {
when (
val result =
inbox
.importPcCatalog()
) {
is CatalogInboxResult.Imported -> {
success = true
status =
(
"Catalogue PC : " +
"${result.imported} nouveau(x), " +
"${result.reconciled} réconcilié(s), " +
"${result.skipped} déjà présent(s)."
)
onCatalogChanged()
}
CatalogInboxResult.FolderNotAuthorized -> {
success = false
status =
"Autorisez d'abord Download/Trainlog."
}
CatalogInboxResult.FileNotFound -> {
success = false
status =
"Aucun catalogue PC reçu."
}
is CatalogInboxResult.Error -> {
success = false
status =
result.message
}
}
},
)
}
if (status != null) {
TrainlogFrame(
title = "ETAT",
active = false,
) {
TrainlogInfo(
text =
status.orEmpty(),
color =
if (success) {
colors.success
} else {
colors.error
},
)
}
}
}
}

View file

@ -1,13 +1,20 @@
package com.labfytools.trainlog.ui package com.labfytools.trainlog.ui
/* TRAINLOG_PC_CATALOG_AUTO_APPLY */
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import com.labfytools.trainlog.data.TrainlogRepository import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.data.SyncExporter
import com.labfytools.trainlog.data.CatalogInboxResult
import com.labfytools.trainlog.data.SyncCatalogInbox
import com.labfytools.trainlog.data.SyncRequestOutbox
private enum class TrainlogScreenId { private enum class TrainlogScreenId {
HOME, HOME,
@ -16,11 +23,15 @@ private enum class TrainlogScreenId {
BODY, BODY,
HISTORY, HISTORY,
SESSION_DETAIL, SESSION_DETAIL,
SYNC,
} }
@Composable @Composable
fun TrainlogApp( fun TrainlogApp(
repository: TrainlogRepository, repository: TrainlogRepository,
exporter: SyncExporter,
inbox: SyncCatalogInbox,
requestOutbox: SyncRequestOutbox,
) { ) {
var screen by var screen by
remember { remember {
@ -48,6 +59,26 @@ fun TrainlogApp(
) )
} }
LaunchedEffect(Unit) {
when (
inbox.importPcCatalog()
) {
is CatalogInboxResult.Imported -> {
catalogRevision += 1
}
CatalogInboxResult.FolderNotAuthorized,
CatalogInboxResult.FileNotFound,
is CatalogInboxResult.Error -> {
/* Nothing to import yet. */
}
}
}
LaunchedEffect(Unit) {
exporter.exportMobileBundle()
}
if ( if (
screen != screen !=
TrainlogScreenId.HOME TrainlogScreenId.HOME
@ -89,6 +120,10 @@ fun TrainlogApp(
screen = screen =
TrainlogScreenId.HISTORY TrainlogScreenId.HISTORY
}, },
onSync = {
screen =
TrainlogScreenId.SYNC
},
) )
TrainlogScreenId.SESSION -> TrainlogScreenId.SESSION ->
@ -107,6 +142,9 @@ fun TrainlogApp(
screen = screen =
TrainlogScreenId.EXERCISE TrainlogScreenId.EXERCISE
}, },
onSessionSaved = {
exporter.exportMobileBundle()
},
) )
TrainlogScreenId.EXERCISE -> TrainlogScreenId.EXERCISE ->
@ -120,6 +158,8 @@ fun TrainlogApp(
exerciseReturnTarget exerciseReturnTarget
}, },
onSaved = { onSaved = {
exporter.exportMobileBundle()
catalogRevision += 1 catalogRevision += 1
screen = screen =
@ -130,6 +170,9 @@ fun TrainlogApp(
TrainlogScreenId.BODY -> TrainlogScreenId.BODY ->
BodyScreen( BodyScreen(
repository = repository, repository = repository,
onBodySaved = {
exporter.exportMobileBundle()
},
onBack = { onBack = {
screen = screen =
TrainlogScreenId.HOME TrainlogScreenId.HOME
@ -163,5 +206,21 @@ fun TrainlogApp(
TrainlogScreenId.HISTORY TrainlogScreenId.HISTORY
}, },
) )
TrainlogScreenId.SYNC ->
SyncScreen(
inbox = inbox,
requestOutbox =
requestOutbox,
onCatalogChanged = {
exporter.exportMobileBundle()
catalogRevision += 1
},
onBack = {
screen =
TrainlogScreenId.HOME
},
)
} }
} }

View file

@ -630,3 +630,81 @@ Next:
ANDROID_MTP_SYNC=NEXT ANDROID_MTP_SYNC=NEXT
``` ```
<!-- TRAINLOG_ANDROID_LOCAL_WORKFLOWS_PASS _END --> <!-- TRAINLOG_ANDROID_LOCAL_WORKFLOWS_PASS _END -->
<!-- TRAINLOG_MOBILE_EXPORT_V1 -->
## MTP mobile export v1
Android now prepares a versioned full mobile snapshot at:
```text
Download/Trainlog/trainlog-mobile-export-v1.json
```
The file contains:
```text
exercise profiles
sessions
body observations
```
It is explicitly separate from frozen `TRAINLOG_FORMAT_V1`.
Desktop direct-MTP validation is available through:
```text
./build/tui/trainlog-mtp-mobile-export-probe
```
The probe traverses:
```text
internal storage
→ Download
→ Trainlog
→ trainlog-mobile-export-v1.json
```
and downloads it directly through libmtp without a mount.
Next after hardware PASS:
```text
DESKTOP_MOBILE_EXPORT_IMPORT=NEXT
PC_TO_ANDROID_CATALOG=AFTER
```
<!-- TRAINLOG_MOBILE_EXPORT_V1 _END -->
<!-- TRAINLOG_ANDROID_SYNC_FOLDER_CHECKPOINT -->
## Android synchronization folder
PC-created synchronization artifacts are consumed through a persistent Storage
Access Framework grant.
Canonical selected folder:
```text
Download/Trainlog
```
The Sync screen always exposes the folder-selection action.
When a folder is already authorized, the action becomes:
```text
Changer le dossier Trainlog
```
This is required so a wrong persisted folder selection can be corrected without
clearing the Android application database.
Validated PC catalog publication:
```text
trainlog-pc-catalog-v1.json
```
The final Android synchronization workflow must evolve toward a single
`Synchroniser maintenant` action backed by a PC-side synchronization agent,
rather than manual export/import steps.
<!-- TRAINLOG_ANDROID_SYNC_FOLDER_CHECKPOINT _END -->

View file

@ -0,0 +1,223 @@
# Bidirectional synchronization checkpoint
## Validated status
```text
ANDROID_LOCAL_WORKFLOWS=PASS
ANDROID_TO_PC_MTP=PASS
DESKTOP_MOBILE_IMPORT_V1=PASS
DESKTOP_MOBILE_IMPORT_IDEMPOTENT=PASS
PC_CATALOG_EXPORT_V1=PASS
PC_TO_ANDROID_MTP_PUBLISH=PASS
ANDROID_SAF_FOLDER_SELECTION=PASS
ANDROID_SAF_FOLDER_CHANGE=PASS
```
This checkpoint represents the validated synchronization foundation on the
physical Samsung device.
## Android → PC
Android produces:
```text
Download/Trainlog/trainlog-mobile-export-v1.json
```
The desktop retrieves it through direct libmtp.
No GVFS/FUSE mount is used.
The desktop importer:
```text
tools/import_mobile_export.py
```
is:
```text
strict
transactional
idempotent by stable IDs
profile-aware
```
Validated behavior:
```text
first import:
new exercise/session/body observation imported
second import:
no duplicates
existing stable IDs skipped
```
## PC → Android
The desktop produces:
```text
/tmp/trainlog-pc-catalog-v1.json
```
with:
```text
format = trainlog-pc-catalog
version = 1
```
The catalog is published by direct MTP to:
```text
Download/Trainlog/trainlog-pc-catalog-v1.json
```
Physical-device publication has been validated.
The Android application accesses the PC-created file through one persistent
Storage Access Framework grant.
The selected folder must be:
```text
Download/Trainlog
```
The Sync screen must always allow changing this folder because a wrong
persisted SAF grant must be recoverable without clearing application data.
## Important semantics
The synchronization layer exchanges versioned Trainlog domain artifacts.
It does not synchronize SQLite files.
The frozen legacy session format remains:
```text
TRAINLOG_FORMAT_V1=FROZEN
```
Profile-aware synchronization uses separate explicit synchronization artifacts.
## Current TUI state
The existing desktop Sync action can:
```text
Android → PC import
PC → Android catalog publication
```
The current status/count-oriented Sync presentation is temporary.
A snapshot file remaining on Android is not a pending queue item. Therefore
labels such as:
```text
1 JSON candidate
```
must not be considered a final synchronization UX.
## Next synchronization UX
The TUI Sync page must become a persistent synchronization history, similar to:
```text
git log
```
Example:
```text
06/09/2026 16:00 ✓ Android +1 session · PC catalog 3 exercises
06/09/2026 15:42 ✓ no changes
06/09/2026 15:31 ! device disconnected
```
A synchronization entry must be selectable.
`Enter` opens a detailed view analogous to:
```text
git show
```
The detail must include:
```text
sync ID
trigger
start/end local time
status
Android → PC
exercises imported/reconciled/skipped
sessions imported/skipped
body observations imported/skipped
PC → Android
catalog exercises published
Android catalog apply result when available
transport/artifact diagnostics
```
## Android-triggered synchronization
The final Android Sync UX must not require:
```text
Prepare export
then use the PC manually
```
Target behavior:
```text
Android data change
-> mobile snapshot maintained automatically
Android "Synchronize now"
-> synchronization request
PC trainlog-syncd
-> detects request over direct MTP
-> runs the same bidirectional sync engine
-> writes synchronization receipt
Android
-> reads receipt
-> applies PC catalog
-> displays final synchronization result
```
MTP remains host-initiated. Therefore Android-triggered synchronization needs
a small PC-side agent; it cannot directly command libmtp operations on the PC.
## Display normalization
User-facing session history must use local presentation:
```text
DD/MM/YYYY HH:MM
```
Canonical RFC3339 timestamps remain unchanged in persistence and exchange.
## Next cursor
```text
SYNC_HISTORY_GIT_LIKE=NEXT
COMMON_SYNC_ENGINE=NEXT
TRAINLOG_SYNCD=NEXT
ANDROID_SYNC_REQUEST_RECEIPT=AFTER
ANDROID_AUTO_OUTBOX=AFTER_AGENT_FOUNDATION
```

View file

@ -376,3 +376,130 @@ TRAINLOG_FORMAT_V1 remains frozen
profile-aware data must not be forced into v1 profile-aware data must not be forced into v1
``` ```
<!-- TRAINLOG_ANDROID_LOCAL_CHECKPOINT _END --> <!-- TRAINLOG_ANDROID_LOCAL_CHECKPOINT _END -->
<!-- TRAINLOG_MOBILE_EXPORT_V1 -->
## MTP mobile export v1
Android now prepares a versioned full mobile snapshot at:
```text
Download/Trainlog/trainlog-mobile-export-v1.json
```
The file contains:
```text
exercise profiles
sessions
body observations
```
It is explicitly separate from frozen `TRAINLOG_FORMAT_V1`.
Desktop direct-MTP validation is available through:
```text
./build/tui/trainlog-mtp-mobile-export-probe
```
The probe traverses:
```text
internal storage
→ Download
→ Trainlog
→ trainlog-mobile-export-v1.json
```
and downloads it directly through libmtp without a mount.
Next after hardware PASS:
```text
DESKTOP_MOBILE_EXPORT_IMPORT=NEXT
PC_TO_ANDROID_CATALOG=AFTER
```
<!-- TRAINLOG_MOBILE_EXPORT_V1 _END -->
<!-- TRAINLOG_DESKTOP_MOBILE_IMPORT_CURSOR -->
## Mobile import cursor
```text
MOBILE_EXPORT_MTP=PASS
DESKTOP_MOBILE_IMPORT_V1=IMPLEMENTED
TUI_SYNC_ACTION=NEXT
PC_TO_ANDROID_CATALOG=AFTER
```
The CLI importer is the reference import engine for the next TUI Sync action.
<!-- TRAINLOG_DESKTOP_MOBILE_IMPORT_CURSOR _END -->
<!-- TRAINLOG_TUI_MOBILE_SYNC_CURSOR -->
## Sync implementation cursor
```text
MOBILE_EXPORT_MTP=PASS
DESKTOP_MOBILE_IMPORT_V1=PASS
TUI_ANDROID_TO_PC_SYNC_ACTION=IMPLEMENTED
TUI_ANDROID_TO_PC_SYNC_HARDWARE_VALIDATION=NEXT
PC_TO_ANDROID_CATALOG=AFTER
```
<!-- TRAINLOG_TUI_MOBILE_SYNC_CURSOR _END -->
<!-- TRAINLOG_BIDIRECTIONAL_SYNC_CURSOR -->
## Bidirectional sync cursor
```text
ANDROID_TO_PC_MTP=PASS
DESKTOP_MOBILE_IMPORT=PASS
PC_TO_ANDROID_CATALOG=IMPLEMENTED
SYNC_HISTORY_UI=IMPLEMENTED
BIDIRECTIONAL_HARDWARE_VALIDATION=NEXT
```
<!-- TRAINLOG_BIDIRECTIONAL_SYNC_CURSOR _END -->
<!-- TRAINLOG_SYNC_AGENT_CURSOR -->
## Sync agent cursor
```text
ANDROID_AUTO_OUTBOX=IMPLEMENTED
ANDROID_SYNC_REQUEST=IMPLEMENTED
TRAINLOG_SYNCD=NEXT
ANDROID_SYNC_RECEIPT=AFTER
TUI_SYNC_LOG_SHOW=AFTER_AGENT_FOUNDATION
```
<!-- TRAINLOG_SYNC_AGENT_CURSOR _END -->
<!-- TRAINLOG_SYNC_VALIDATED_ROADMAP -->
## Synchronization cursor
```text
ANDROID_LOCAL_WORKFLOWS=PASS
ANDROID_TO_PC_MTP=PASS
DESKTOP_MOBILE_IMPORT_V1=PASS
DESKTOP_MOBILE_IMPORT_IDEMPOTENT=PASS
PC_CATALOG_EXPORT_V1=PASS
PC_TO_ANDROID_MTP_PUBLISH=PASS
ANDROID_SAF_FOLDER_CHANGE=PASS
SYNC_HISTORY_GIT_LIKE=NEXT
COMMON_SYNC_ENGINE=NEXT
TRAINLOG_SYNCD=NEXT
ANDROID_REQUEST_RECEIPT=AFTER
AUTO_OUTBOX=AFTER_AGENT_FOUNDATION
```
Do not regress to:
```text
SQLite file synchronization
filesystem mounts
exercise-name heuristics
manual fake sets for continuous activities
overloading frozen Trainlog JSON v1
```
<!-- TRAINLOG_SYNC_VALIDATED_ROADMAP _END -->

284
docs/sync_exchange.md Normal file
View file

@ -0,0 +1,284 @@
# Trainlog synchronization exchange
## Status
```text
MOBILE_EXPORT_V1=FROZEN_FOR_IMPLEMENTATION
TRAINLOG_FORMAT_V1=FROZEN_UNCHANGED
```
This document defines a synchronization artifact. It is not the frozen
Trainlog session JSON v1 format.
## Android → PC artifact
Shared-storage path:
```text
Download/Trainlog/trainlog-mobile-export-v1.json
```
Format header:
```json
{
"format": "trainlog-mobile-export",
"version": 1
}
```
The artifact is a complete idempotent mobile snapshot containing:
```text
exercises
sessions
body_observations
```
### Exercises
Each exercise contains:
```text
exercise_id
name
recording_mode
tracking_mode
data_fields
```
### Sessions
Each session contains:
```text
session_id
started_at
session_type
exercises
```
Each session exercise snapshots:
```text
exercise_id
name
recording_mode
tracking_mode
data_fields
load_mode
rest_seconds
```
For `SETS`:
```text
sets[]
reps
or
duration_seconds
```
For `CONTINUOUS`:
```text
continuous
duration_seconds
speed_kmh optional
distance_km optional
```
A continuous exercise has no synthetic set.
### Body observations
Each body observation contains:
```text
observation_id
observed_at
only the metrics actually measured
```
## Transport
Android writes its own export into shared Downloads storage.
Desktop reads the artifact through direct libmtp transport.
No filesystem mount is required.
No SQLite database file is transferred.
## PC → Android
A separate canonical catalog artifact will be defined and implemented after
Android → PC export is validated on physical hardware.
The PC → Android path must not overload frozen Trainlog JSON v1.
<!-- TRAINLOG_DESKTOP_MOBILE_IMPORT_V1 -->
## Desktop import of mobile export v1
The desktop importer is:
```text
tools/import_mobile_export.py
```
It validates the complete mobile snapshot before opening a write transaction.
Properties:
```text
transactional
idempotent by stable IDs
exercise reconciliation by normalized name
profile conflicts rejected
unknown JSON fields rejected
no SQLite file copying
```
For mobile `SETS` v1, the Android form records one uniform set metric. The
desktop importer derives:
```text
target_sets = number of logged sets
target_reps or target_duration = uniform logged value
```
and preserves all performed sets separately.
A v1 mobile session with heterogeneous set metrics or `0 reps` is rejected
rather than inventing a desktop target.
Continuous activities remain target-less and are imported only into
`continuous_activity`.
Recommended validation sequence:
```bash
python tools/import_mobile_export.py /tmp/trainlog-mobile-export-v1.json --dry-run
python tools/import_mobile_export.py /tmp/trainlog-mobile-export-v1.json
```
Running the real import a second time must import nothing new and report the
existing IDs as skipped.
<!-- TRAINLOG_DESKTOP_MOBILE_IMPORT_V1 _END -->
<!-- TRAINLOG_BIDIRECTIONAL_SYNC_V1 -->
## Bidirectional synchronization v1
One desktop Sync action now performs both directions:
```text
Android → PC
direct-MTP download
strict transactional import
PC → Android
canonical PC exercise catalog export
direct-MTP publication
```
The Android app obtains one persistent Storage Access Framework grant for:
```text
Download/Trainlog
```
After this one-time grant, Android can import the PC-created catalog without
broad storage permissions.
The Sync page displays persistent synchronization history instead of remote
snapshot counts. A snapshot remaining present is not a pending queue item and
must not be shown as a "candidate".
User-facing session history timestamps are displayed as:
```text
DD/MM/YYYY HH:MM
```
Canonical RFC3339 storage remains unchanged.
<!-- TRAINLOG_BIDIRECTIONAL_SYNC_V1 _END -->
<!-- TRAINLOG_ANDROID_AUTO_OUTBOX_REQUEST -->
## Automatic Android outbox and sync request
Android no longer requires a manual export action.
The mobile snapshot is refreshed automatically on:
```text
application start
exercise save
session save
body observation save
PC catalog apply
```
The Android Sync screen exposes:
```text
Synchroniser maintenant
```
This writes:
```text
Download/Trainlog/trainlog-sync-request-v1.json
```
with a stable request ID and timestamp.
The next PC-agent slice consumes this request and writes a sync receipt.
<!-- TRAINLOG_ANDROID_AUTO_OUTBOX_REQUEST _END -->
<!-- TRAINLOG_BIDIRECTIONAL_VALIDATED_CHECKPOINT -->
## Validated bidirectional transport checkpoint
Validated on the physical Samsung device:
```text
ANDROID_TO_PC_MTP=PASS
DESKTOP_MOBILE_IMPORT_V1=PASS
DESKTOP_MOBILE_IMPORT_IDEMPOTENT=PASS
PC_CATALOG_EXPORT_V1=PASS
PC_TO_ANDROID_MTP_PUBLISH=PASS
```
Artifacts:
```text
Android → PC
Download/Trainlog/trainlog-mobile-export-v1.json
PC → Android
Download/Trainlog/trainlog-pc-catalog-v1.json
```
Both are synchronization artifacts and remain separate from frozen
`TRAINLOG_FORMAT_V1`.
The Android Storage Access Framework folder grant must target:
```text
Download/Trainlog
```
and the UI must permit changing the stored folder selection.
Remaining synchronization work:
```text
persistent structured sync history
selectable sync detail
common sync engine
trainlog-syncd
Android-triggered request/receipt workflow
automatic mobile snapshot maintenance
```
<!-- TRAINLOG_BIDIRECTIONAL_VALIDATED_CHECKPOINT _END -->

View file

@ -763,3 +763,76 @@ Réalisé : activité continue
Do not render set-oriented labels for a valid continuous activity. Do not render set-oriented labels for a valid continuous activity.
<!-- TRAINLOG_CONTINUOUS_TUI_IMPLEMENTED _END --> <!-- TRAINLOG_CONTINUOUS_TUI_IMPLEMENTED _END -->
<!-- TRAINLOG_TUI_MOBILE_SYNC_ACTION -->
## Android → PC synchronization action
The Sync page now exposes:
```text
s synchroniser
```
The action performs the complete validated chain:
```text
detect exact MTP device
→ locate Download/Trainlog/trainlog-mobile-export-v1.json
→ direct libmtp download
→ transactional mobile-export importer
→ refresh desktop overview
```
No mount is used.
The TUI resolves the reference importer relative to `/proc/self/exe`. In the
development layout this means:
```text
build/tui/trainlog
→ ../../tools/import_mobile_export.py
```
and therefore also works when `trainlog` is launched through the user's
`~/.local/bin/trainlog` symlink.
<!-- TRAINLOG_TUI_MOBILE_SYNC_ACTION _END -->
<!-- TRAINLOG_TUI_SYNC_FOUNDATION_CHECKPOINT -->
## Sync foundation checkpoint
The desktop Sync backend has validated physical transport in both directions:
```text
Android → PC mobile snapshot import
PC → Android canonical exercise catalog publication
```
Direct libmtp remains mandatory; no mount is introduced.
The current category/count presentation is transitional.
A persistent mobile snapshot is not a pending item, so a displayed
`JSON candidate count` must not be treated as the final synchronization model.
Next TUI design:
```text
HISTORIQUE DES SYNCHRONISATIONS
↑/↓ select
Enter detail
s synchronize
r refresh
```
The history/detail interaction should follow the conceptual model of
`git log` / `git show`.
User-facing session timestamps should be normalized to:
```text
DD/MM/YYYY HH:MM
```
while stored timestamps remain RFC3339.
<!-- TRAINLOG_TUI_SYNC_FOUNDATION_CHECKPOINT _END -->

126
tools/export_pc_catalog.py Executable file
View file

@ -0,0 +1,126 @@
#!/usr/bin/env python3
import argparse
import json
import os
import sqlite3
from datetime import datetime
from pathlib import Path
def default_database_path():
data_home = os.environ.get("XDG_DATA_HOME")
if data_home:
return (
Path(data_home)
/ "trainlog"
/ "trainlog.db"
)
return (
Path.home()
/ ".local"
/ "share"
/ "trainlog"
/ "trainlog.db"
)
def main():
parser = argparse.ArgumentParser(
description=(
"Export du catalogue canonique PC "
"vers un artifact Trainlog versionné."
)
)
parser.add_argument(
"output",
type=Path,
)
parser.add_argument(
"--database",
type=Path,
default=default_database_path(),
)
args = parser.parse_args()
if not args.database.exists():
raise SystemExit(
"PC_CATALOG_EXPORT=FAIL database not found"
)
connection = sqlite3.connect(
args.database
)
try:
version = connection.execute(
"PRAGMA user_version;"
).fetchone()[0]
if version != 4:
raise SystemExit(
"PC_CATALOG_EXPORT=FAIL "
f"schema={version}"
)
rows = connection.execute(
'''
SELECT
exercise_id,
name,
recording_mode,
tracking_mode,
data_fields
FROM exercises
ORDER BY
name COLLATE NOCASE,
exercise_id;
'''
).fetchall()
payload = {
"format": "trainlog-pc-catalog",
"version": 1,
"generated_at":
datetime.now()
.astimezone()
.isoformat(),
"exercises": [
{
"exercise_id": row[0],
"name": row[1],
"recording_mode": row[2],
"tracking_mode": row[3],
"data_fields": row[4],
}
for row in rows
],
}
args.output.parent.mkdir(
parents=True,
exist_ok=True,
)
args.output.write_text(
json.dumps(
payload,
ensure_ascii=False,
separators=(",", ":"),
),
encoding="utf-8",
)
print("PC_CATALOG_EXPORT=PASS")
print(f"exercises={len(rows)}")
print(f"output={args.output}")
finally:
connection.close()
if __name__ == "__main__":
main()

1336
tools/import_mobile_export.py Executable file

File diff suppressed because it is too large Load diff

View file

@ -104,4 +104,10 @@ TrainlogStatus trainlog_mtp_receive_file(
const char *local_path const char *local_path
); );
TrainlogStatus trainlog_mtp_delete_object(
unsigned int bus_number,
unsigned int device_number,
uint32_t item_id
);
#endif #endif

View file

@ -279,3 +279,11 @@ test(
'continuous_detail', 'continuous_detail',
test_continuous_detail, test_continuous_detail,
) )
trainlog_mtp_mobile_export_probe = executable(
'trainlog-mtp-mobile-export-probe',
'tools/mtp_mobile_export_probe.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)

View file

@ -777,3 +777,46 @@ TrainlogStatus trainlog_mtp_receive_file(
return return
TRAINLOG_STATUS_OK; TRAINLOG_STATUS_OK;
} }
TrainlogStatus trainlog_mtp_delete_object(
unsigned int bus_number,
unsigned int device_number,
uint32_t item_id
)
{
LIBMTP_mtpdevice_t *device = NULL;
TrainlogStatus status;
int rc;
if (item_id == 0U) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
status =
mtp_open_exact_device(
bus_number,
device_number,
&device
);
if (status != TRAINLOG_STATUS_OK) {
return status;
}
rc =
LIBMTP_Delete_Object(
device,
item_id
);
if (rc != 0) {
LIBMTP_Clear_Errorstack(device);
LIBMTP_Release_Device(device);
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
LIBMTP_Release_Device(device);
return TRAINLOG_STATUS_OK;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,348 @@
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "trainlog/mtp.h"
#include "trainlog/status.h"
#include "trainlog/usb.h"
#define MAX_DEVICES 8U
#define MAX_STORAGES 8U
#define MAX_ENTRIES 512U
static TrainlogStatus find_child_folder(
const TrainlogUsbDevice *device,
uint32_t storage_id,
uint32_t parent_id,
const char *name,
uint32_t *output_id
)
{
TrainlogMtpEntry
entries[MAX_ENTRIES];
size_t count = 0U;
size_t index;
TrainlogStatus status;
if (device == NULL ||
name == NULL ||
output_id == NULL) {
return
TRAINLOG_STATUS_INVALID_ARGUMENT;
}
status =
trainlog_mtp_list_folder(
device->bus_number,
device->device_number,
storage_id,
parent_id,
entries,
MAX_ENTRIES,
&count
);
if (status != TRAINLOG_STATUS_OK) {
return status;
}
for (index = 0U;
index < count;
++index) {
if (entries[index].folder &&
strcmp(
entries[index].name,
name
) == 0) {
*output_id =
entries[index].item_id;
return
TRAINLOG_STATUS_OK;
}
}
return TRAINLOG_STATUS_NOT_FOUND;
}
static TrainlogStatus find_child_file(
const TrainlogUsbDevice *device,
uint32_t storage_id,
uint32_t parent_id,
const char *name,
uint32_t *output_id,
uint64_t *output_size
)
{
TrainlogMtpEntry
entries[MAX_ENTRIES];
size_t count = 0U;
size_t index;
TrainlogStatus status;
if (device == NULL ||
name == NULL ||
output_id == NULL ||
output_size == NULL) {
return
TRAINLOG_STATUS_INVALID_ARGUMENT;
}
status =
trainlog_mtp_list_folder(
device->bus_number,
device->device_number,
storage_id,
parent_id,
entries,
MAX_ENTRIES,
&count
);
if (status != TRAINLOG_STATUS_OK) {
return status;
}
for (index = 0U;
index < count;
++index) {
if (!entries[index].folder &&
strcmp(
entries[index].name,
name
) == 0) {
*output_id =
entries[index].item_id;
*output_size =
entries[index].size_bytes;
return
TRAINLOG_STATUS_OK;
}
}
return TRAINLOG_STATUS_NOT_FOUND;
}
static int validate_download(
const char *path
)
{
FILE *file;
char buffer[4096];
size_t used;
file =
fopen(
path,
"rb"
);
if (file == NULL) {
return 1;
}
used =
fread(
buffer,
1U,
sizeof(buffer) - 1U,
file
);
if (ferror(file) != 0) {
(void)fclose(file);
return 1;
}
buffer[used] = '\0';
if (fclose(file) != 0) {
return 1;
}
if (strstr(
buffer,
"\"format\":\"trainlog-mobile-export\""
) == NULL ||
strstr(
buffer,
"\"version\":1"
) == NULL) {
return 1;
}
return 0;
}
int main(void)
{
TrainlogUsbDevice
devices[MAX_DEVICES];
TrainlogMtpStorage
storages[MAX_STORAGES];
size_t device_count = 0U;
size_t storage_count = 0U;
uint32_t download_id = 0U;
uint32_t trainlog_id = 0U;
uint32_t export_id = 0U;
uint64_t export_size = 0U;
const char *local_path =
"/tmp/trainlog-mobile-export-v1.json";
TrainlogStatus status;
status =
trainlog_usb_list_mtp_devices(
devices,
MAX_DEVICES,
&device_count
);
if (status != TRAINLOG_STATUS_OK ||
device_count == 0U) {
(void)fprintf(
stderr,
"MTP device not found\n"
);
return 1;
}
status =
trainlog_mtp_list_storages(
devices[0].bus_number,
devices[0].device_number,
storages,
MAX_STORAGES,
&storage_count
);
if (status != TRAINLOG_STATUS_OK ||
storage_count == 0U) {
(void)fprintf(
stderr,
"MTP storage not found\n"
);
return 1;
}
status =
find_child_folder(
&devices[0],
storages[0].storage_id,
UINT32_MAX,
"Download",
&download_id
);
if (status != TRAINLOG_STATUS_OK) {
(void)fprintf(
stderr,
"Download folder not found\n"
);
return 1;
}
status =
find_child_folder(
&devices[0],
storages[0].storage_id,
download_id,
"Trainlog",
&trainlog_id
);
if (status != TRAINLOG_STATUS_OK) {
(void)fprintf(
stderr,
"Download/Trainlog not found\n"
);
return 1;
}
status =
find_child_file(
&devices[0],
storages[0].storage_id,
trainlog_id,
"trainlog-mobile-export-v1.json",
&export_id,
&export_size
);
if (status != TRAINLOG_STATUS_OK) {
(void)fprintf(
stderr,
"mobile export not found\n"
);
return 1;
}
status =
trainlog_mtp_receive_file(
devices[0].bus_number,
devices[0].device_number,
export_id,
local_path
);
if (status != TRAINLOG_STATUS_OK) {
(void)fprintf(
stderr,
"MTP receive failed\n"
);
return 1;
}
if (validate_download(
local_path
) != 0) {
(void)fprintf(
stderr,
"export validation failed\n"
);
return 1;
}
(void)printf(
"MOBILE_EXPORT_MTP=PASS\n"
);
(void)printf(
"device=%s %s\n",
devices[0].vendor,
devices[0].model
);
(void)printf(
"remote=Download/Trainlog/"
"trainlog-mobile-export-v1.json\n"
);
(void)printf(
"size=%llu\n",
(unsigned long long)
export_size
);
(void)printf(
"local=%s\n",
local_path
);
return 0;
}