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;
- validated the application on a real Samsung device through ADB.
<!-- 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"
)
implementation("androidx.documentfile:documentfile:1.1.0")
debugImplementation(
"androidx.compose.ui:ui-tooling"
)

View file

@ -4,6 +4,9 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
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.theme.TrainlogTheme
@ -18,10 +21,31 @@ class MainActivity : ComponentActivity() {
applicationContext
)
val exporter =
SyncExporter(
applicationContext,
repository,
)
val inbox =
SyncCatalogInbox(
applicationContext,
repository,
)
val requestOutbox =
SyncRequestOutbox(
applicationContext
)
setContent {
TrainlogTheme {
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
fun BodyScreen(
repository: TrainlogRepository,
onBodySaved: () -> Unit,
onBack: () -> Unit,
) {
val colors =
@ -415,6 +416,8 @@ fun BodyScreen(
revision += 1
message =
"Mensurations enregistrées."
onBodySaved()
}
SaveBodyObservationResult.Invalid -> {

View file

@ -8,6 +8,7 @@ fun HomeScreen(
onExercise: () -> Unit,
onBody: () -> Unit,
onHistory: () -> Unit,
onSync: () -> Unit,
) {
TrainlogScreen(
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(
title = "STATUT",
active = false,

View file

@ -36,6 +36,7 @@ fun SessionScreen(
catalogRevision: Int,
onBack: () -> Unit,
onCreateExercise: () -> Unit,
onSessionSaved: () -> Unit,
) {
val colors =
LocalTrainlogColors.current
@ -240,6 +241,8 @@ fun SessionScreen(
message =
"Séance enregistrée."
onSessionSaved()
}
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
/* TRAINLOG_PC_CATALOG_AUTO_APPLY */
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import 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 {
HOME,
@ -16,11 +23,15 @@ private enum class TrainlogScreenId {
BODY,
HISTORY,
SESSION_DETAIL,
SYNC,
}
@Composable
fun TrainlogApp(
repository: TrainlogRepository,
exporter: SyncExporter,
inbox: SyncCatalogInbox,
requestOutbox: SyncRequestOutbox,
) {
var screen by
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 (
screen !=
TrainlogScreenId.HOME
@ -89,6 +120,10 @@ fun TrainlogApp(
screen =
TrainlogScreenId.HISTORY
},
onSync = {
screen =
TrainlogScreenId.SYNC
},
)
TrainlogScreenId.SESSION ->
@ -107,6 +142,9 @@ fun TrainlogApp(
screen =
TrainlogScreenId.EXERCISE
},
onSessionSaved = {
exporter.exportMobileBundle()
},
)
TrainlogScreenId.EXERCISE ->
@ -120,6 +158,8 @@ fun TrainlogApp(
exerciseReturnTarget
},
onSaved = {
exporter.exportMobileBundle()
catalogRevision += 1
screen =
@ -130,6 +170,9 @@ fun TrainlogApp(
TrainlogScreenId.BODY ->
BodyScreen(
repository = repository,
onBodySaved = {
exporter.exportMobileBundle()
},
onBack = {
screen =
TrainlogScreenId.HOME
@ -163,5 +206,21 @@ fun TrainlogApp(
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
```
<!-- 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
```
<!-- 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.
<!-- 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
);
TrainlogStatus trainlog_mtp_delete_object(
unsigned int bus_number,
unsigned int device_number,
uint32_t item_id
);
#endif

View file

@ -279,3 +279,11 @@ 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
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;
}