Add bidirectional Android sync foundation
This commit is contained in:
parent
7e950baf61
commit
28f6f8ea44
20 changed files with 5049 additions and 471 deletions
14
CHANGELOG.md
14
CHANGELOG.md
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ dependencies {
|
|||
"androidx.compose.ui:ui-tooling-preview"
|
||||
)
|
||||
|
||||
implementation("androidx.documentfile:documentfile:1.1.0")
|
||||
|
||||
debugImplementation(
|
||||
"androidx.compose.ui:ui-tooling"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -> {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 -> {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
223
docs/reviews/bidirectional_sync_checkpoint.md
Normal file
223
docs/reviews/bidirectional_sync_checkpoint.md
Normal 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
|
||||
```
|
||||
127
docs/roadmap.md
127
docs/roadmap.md
|
|
@ -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
284
docs/sync_exchange.md
Normal 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 -->
|
||||
73
docs/tui.md
73
docs/tui.md
|
|
@ -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
126
tools/export_pc_catalog.py
Executable 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
1336
tools/import_mobile_export.py
Executable file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
2453
tui/src/tui.c
2453
tui/src/tui.c
File diff suppressed because it is too large
Load diff
348
tui/tools/mtp_mobile_export_probe.c
Normal file
348
tui/tools/mtp_mobile_export_probe.c
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue