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;
|
- 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 -->
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 -> {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 -> {
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 -->
|
||||||
|
|
|
||||||
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
|
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
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.
|
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
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
|
const char *local_path
|
||||||
);
|
);
|
||||||
|
|
||||||
|
TrainlogStatus trainlog_mtp_delete_object(
|
||||||
|
unsigned int bus_number,
|
||||||
|
unsigned int device_number,
|
||||||
|
uint32_t item_id
|
||||||
|
);
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
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