Add shared bidirectional sync engine

This commit is contained in:
fy59 2026-09-06 19:25:13 +02:00
parent b3734b8674
commit 18958c5001
20 changed files with 6905 additions and 1559 deletions

2
.gitignore vendored
View file

@ -22,7 +22,7 @@ local.properties
*.db-wal *.db-wal
# Local user data # Local user data
data/ /data/
exports/ exports/
# Python helpers # Python helpers

View file

@ -280,3 +280,116 @@ The database replacement remains transactional.
`TRAINLOG_FORMAT_V1` remains frozen and unchanged. `TRAINLOG_FORMAT_V1` remains frozen and unchanged.
<!-- TRAINLOG_VARIABLE_SETS_CHECKPOINT_FINAL _END --> <!-- TRAINLOG_VARIABLE_SETS_CHECKPOINT_FINAL _END -->
<!-- TRAINLOG_SHARED_SYNC_ENGINE_V1 -->
## Shared bidirectional synchronization v1
Validated architecture:
```text
Android local write
-> automatic mobile snapshot
Android "Synchroniser maintenant"
-> trainlog-sync-request-v1.json
trainlog-syncd
-> shared C synchronization engine
-> Android → PC mobile import
-> PC → Android catalog publish
-> trainlog-sync-receipt-v1.json
Android
-> receipt matched by request_id
-> PC catalog applied locally
-> final result displayed
```
The ncurses TUI and `trainlog-syncd` call the same
`trainlog_sync_run()` implementation.
Direct libmtp remains mandatory. No filesystem mount and no SQLite-file
synchronization are introduced.
### Concurrency
The shared engine owns:
```text
$XDG_DATA_HOME/trainlog/sync.lock
```
A TUI-triggered transaction waits for the lock. Daemon request polling is
non-blocking and retries later.
### Sync history
Every actual synchronization transaction creates:
```text
$XDG_DATA_HOME/trainlog/sync_runs/sy_*.json
$XDG_DATA_HOME/trainlog/sync_runs/sy_*.txt
```
and appends a compact entry to:
```text
$XDG_DATA_HOME/trainlog/sync_history.log
```
The TUI behaves like:
```text
git log
↑/↓ select synchronization
git show
Enter opens structured detail
```
Legacy three-field history entries remain readable but have no structured
detail file.
### Android request and receipt
Request:
```text
format = trainlog-sync-request
version = 1
```
Receipt:
```text
format = trainlog-sync-receipt
version = 1
```
The receipt carries the originating `request_id`, a generated `sync_id`,
status, summary and synchronization counts. Android ignores a receipt for a
different request ID.
### User service
Install/refresh the user service with:
```text
bash tools/install_syncd_user.sh
```
No root privilege is required.
### Status
```text
COMMON_SYNC_ENGINE=PASS
TUI_SYNC_LOG_SHOW=PASS
TRAINLOG_SYNCD=PASS
ANDROID_TRIGGERED_SYNC=PASS
ANDROID_SYNC_RECEIPT=PASS
BIDIRECTIONAL_SYNC_V1=PASS
```
Frozen `TRAINLOG_FORMAT_V1` remains unchanged.
<!-- TRAINLOG_SHARED_SYNC_ENGINE_V1 _END -->

View file

@ -0,0 +1,289 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import org.json.JSONObject
sealed interface CatalogInboxResult {
data class Imported(
val imported: Int,
val reconciled: Int,
val skipped: Int,
) : CatalogInboxResult
data object FolderNotAuthorized :
CatalogInboxResult
data object FileNotFound :
CatalogInboxResult
data class Error(
val message: String,
) : CatalogInboxResult
}
sealed interface SyncReceiptResult {
data object Pending :
SyncReceiptResult
data object FolderNotAuthorized :
SyncReceiptResult
data class Received(
val syncId: String,
val success: Boolean,
val summary: String,
) : SyncReceiptResult
data class Error(
val message: String,
) : SyncReceiptResult
}
class SyncCatalogInbox(
context: Context,
private val repository:
TrainlogRepository,
) {
private val appContext =
context.applicationContext
private val preferences =
appContext.getSharedPreferences(
"trainlog-sync",
Context.MODE_PRIVATE,
)
fun hasFolderAccess(): Boolean =
savedTreeUri() != null
fun saveTreeUri(
uri: Uri,
): Boolean {
return try {
appContext
.contentResolver
.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
)
preferences
.edit()
.putString(
KEY_TREE_URI,
uri.toString(),
)
.apply()
true
} catch (
error: SecurityException
) {
false
}
}
fun importPcCatalog():
CatalogInboxResult {
val treeUri =
savedTreeUri()
?: return CatalogInboxResult
.FolderNotAuthorized
val directory =
DocumentFile
.fromTreeUri(
appContext,
treeUri,
)
?: return CatalogInboxResult.Error(
"Dossier Trainlog inaccessible."
)
val file =
directory.findFile(
"trainlog-pc-catalog-v1.json"
)
?: return CatalogInboxResult.FileNotFound
return try {
val stream =
appContext
.contentResolver
.openInputStream(
file.uri
)
?: return CatalogInboxResult.Error(
"Lecture du catalogue impossible."
)
val json =
stream.bufferedReader(
Charsets.UTF_8
)
.use {
it.readText()
}
when (
val result =
repository
.applyPcCatalogJson(
json
)
) {
is PcCatalogImportResult.Applied ->
CatalogInboxResult.Imported(
imported =
result.imported,
reconciled =
result.reconciled,
skipped =
result.skipped,
)
is PcCatalogImportResult.Invalid ->
CatalogInboxResult.Error(
result.message
)
PcCatalogImportResult.DatabaseError ->
CatalogInboxResult.Error(
"Erreur base locale."
)
}
} catch (
error: Exception
) {
CatalogInboxResult.Error(
error.message
?: "Import catalogue impossible."
)
}
}
fun readSyncReceipt(
requestId: String,
): SyncReceiptResult {
val treeUri =
savedTreeUri()
?: return SyncReceiptResult
.FolderNotAuthorized
val directory =
DocumentFile
.fromTreeUri(
appContext,
treeUri,
)
?: return SyncReceiptResult.Error(
"Dossier Trainlog inaccessible."
)
val file =
directory.findFile(
"trainlog-sync-receipt-v1.json"
)
?: return SyncReceiptResult.Pending
return try {
val stream =
appContext
.contentResolver
.openInputStream(
file.uri
)
?: return SyncReceiptResult.Error(
"Lecture du reçu impossible."
)
val json =
stream.bufferedReader(
Charsets.UTF_8
)
.use {
it.readText()
}
val root =
JSONObject(json)
if (
root.optString(
"format"
) !=
"trainlog-sync-receipt" ||
root.optInt(
"version",
-1
) != 1
) {
return SyncReceiptResult.Error(
"Reçu de synchronisation invalide."
)
}
if (
root.optString(
"request_id"
) != requestId
) {
return SyncReceiptResult.Pending
}
val status =
root.optString(
"status"
)
if (
status != "success" &&
status != "failure"
) {
return SyncReceiptResult.Error(
"État de synchronisation invalide."
)
}
SyncReceiptResult.Received(
syncId =
root.optString(
"sync_id"
),
success =
status == "success",
summary =
root.optString(
"summary",
"Synchronisation terminée."
),
)
} catch (
error: Exception
) {
SyncReceiptResult.Error(
error.message
?: "Lecture du reçu impossible."
)
}
}
private fun savedTreeUri(): Uri? =
preferences
.getString(
KEY_TREE_URI,
null,
)
?.let {
Uri.parse(it)
}
private companion object {
const val KEY_TREE_URI =
"trainlog_tree_uri"
}
}

View file

@ -0,0 +1,253 @@
package com.labfytools.trainlog.data
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
sealed interface SyncExportResult {
data class Exported(
val displayPath: String,
val bytes: Int,
) : SyncExportResult
data object Unsupported :
SyncExportResult
data class Error(
val message: String,
) : SyncExportResult
}
class SyncExporter(
context: Context,
private val repository:
TrainlogRepository,
) {
private val appContext =
context.applicationContext
fun exportMobileBundle():
SyncExportResult {
if (
Build.VERSION.SDK_INT <
Build.VERSION_CODES.Q
) {
return SyncExportResult.Unsupported
}
val json =
repository.buildMobileExportJson()
val bytes =
json.toByteArray(
Charsets.UTF_8
)
val resolver =
appContext.contentResolver
val collection =
MediaStore.Downloads
.getContentUri(
MediaStore
.VOLUME_EXTERNAL_PRIMARY
)
val relativePath =
Environment.DIRECTORY_DOWNLOADS +
"/Trainlog/"
val displayName =
"trainlog-mobile-export-v1.json"
val existing =
findExisting(
collection,
displayName,
relativePath
)
val targetUri: Uri
val created: Boolean
if (existing != null) {
targetUri = existing
created = false
} else {
val values =
ContentValues().apply {
put(
MediaStore
.MediaColumns
.DISPLAY_NAME,
displayName
)
put(
MediaStore
.MediaColumns
.MIME_TYPE,
"application/json"
)
put(
MediaStore
.MediaColumns
.RELATIVE_PATH,
relativePath
)
put(
MediaStore
.MediaColumns
.IS_PENDING,
1
)
}
val inserted =
resolver.insert(
collection,
values
)
if (inserted == null) {
return SyncExportResult.Error(
"Création du fichier impossible."
)
}
targetUri = inserted
created = true
}
try {
val stream =
resolver.openOutputStream(
targetUri,
"wt"
)
if (stream == null) {
if (created) {
resolver.delete(
targetUri,
null,
null
)
}
return SyncExportResult.Error(
"Flux d'écriture indisponible."
)
}
stream.use {
it.write(bytes)
it.flush()
}
if (created) {
val finished =
ContentValues().apply {
put(
MediaStore
.MediaColumns
.IS_PENDING,
0
)
}
resolver.update(
targetUri,
finished,
null,
null
)
}
return SyncExportResult.Exported(
displayPath =
"Download/Trainlog/" +
displayName,
bytes =
bytes.size,
)
} catch (
error: Exception
) {
if (created) {
resolver.delete(
targetUri,
null,
null
)
}
return SyncExportResult.Error(
error.message
?: "Erreur d'export."
)
}
}
private fun findExisting(
collection: Uri,
displayName: String,
relativePath: String,
): Uri? {
val projection =
arrayOf(
MediaStore
.MediaColumns
._ID
)
val selection =
(
MediaStore
.MediaColumns
.DISPLAY_NAME +
" = ? AND " +
MediaStore
.MediaColumns
.RELATIVE_PATH +
" = ?"
)
val cursor =
appContext
.contentResolver
.query(
collection,
projection,
selection,
arrayOf(
displayName,
relativePath,
),
null,
)
?: return null
try {
if (!cursor.moveToFirst()) {
return null
}
val itemId =
cursor.getLong(0)
return ContentUris.withAppendedId(
collection,
itemId
)
} finally {
cursor.close()
}
}
}

View file

@ -0,0 +1,251 @@
package com.labfytools.trainlog.data
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import org.json.JSONObject
import java.time.OffsetDateTime
import java.util.UUID
sealed interface SyncRequestResult {
data class Requested(
val requestId: String,
) : SyncRequestResult
data object Unsupported :
SyncRequestResult
data class Error(
val message: String,
) : SyncRequestResult
}
class SyncRequestOutbox(
context: Context,
) {
private val appContext =
context.applicationContext
fun requestSync():
SyncRequestResult {
if (
Build.VERSION.SDK_INT <
Build.VERSION_CODES.Q
) {
return SyncRequestResult.Unsupported
}
val requestId =
"sr_" +
UUID.randomUUID()
.toString()
val payload =
JSONObject()
.put(
"format",
"trainlog-sync-request",
)
.put(
"version",
1,
)
.put(
"request_id",
requestId,
)
.put(
"requested_at",
OffsetDateTime.now()
.toString(),
)
.toString()
val bytes =
payload.toByteArray(
Charsets.UTF_8
)
val resolver =
appContext.contentResolver
val collection =
MediaStore.Downloads
.getContentUri(
MediaStore
.VOLUME_EXTERNAL_PRIMARY
)
val relativePath =
Environment
.DIRECTORY_DOWNLOADS +
"/Trainlog/"
val displayName =
"trainlog-sync-request-v1.json"
val existing =
findExisting(
collection,
displayName,
relativePath,
)
val targetUri: Uri
val created: Boolean
if (existing != null) {
targetUri = existing
created = false
} else {
val values =
ContentValues().apply {
put(
MediaStore
.MediaColumns
.DISPLAY_NAME,
displayName
)
put(
MediaStore
.MediaColumns
.MIME_TYPE,
"application/json"
)
put(
MediaStore
.MediaColumns
.RELATIVE_PATH,
relativePath
)
put(
MediaStore
.MediaColumns
.IS_PENDING,
1
)
}
val inserted =
resolver.insert(
collection,
values
)
?: return SyncRequestResult.Error(
"Création de la demande impossible."
)
targetUri = inserted
created = true
}
return try {
val stream =
resolver.openOutputStream(
targetUri,
"wt"
)
?: return SyncRequestResult.Error(
"Écriture de la demande impossible."
)
stream.use {
it.write(bytes)
it.flush()
}
if (created) {
val finished =
ContentValues().apply {
put(
MediaStore
.MediaColumns
.IS_PENDING,
0
)
}
resolver.update(
targetUri,
finished,
null,
null
)
}
SyncRequestResult.Requested(
requestId
)
} catch (
error: Exception
) {
if (created) {
resolver.delete(
targetUri,
null,
null
)
}
SyncRequestResult.Error(
error.message
?: "Demande de synchronisation impossible."
)
}
}
private fun findExisting(
collection: Uri,
displayName: String,
relativePath: String,
): Uri? {
val cursor =
appContext
.contentResolver
.query(
collection,
arrayOf(
MediaStore
.MediaColumns
._ID
),
(
MediaStore
.MediaColumns
.DISPLAY_NAME +
" = ? AND " +
MediaStore
.MediaColumns
.RELATIVE_PATH +
" = ?"
),
arrayOf(
displayName,
relativePath,
),
null,
)
?: return null
try {
if (!cursor.moveToFirst()) {
return null
}
return ContentUris
.withAppendedId(
collection,
cursor.getLong(0)
)
} finally {
cursor.close()
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
package com.labfytools.trainlog.ui package com.labfytools.trainlog.ui
/* TRAINLOG_SYNC_AUTO_APPLY */ /* TRAINLOG_ANDROID_TRIGGERED_SYNC_V1 */
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@ -12,9 +12,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import com.labfytools.trainlog.data.CatalogInboxResult import com.labfytools.trainlog.data.CatalogInboxResult
import com.labfytools.trainlog.data.SyncCatalogInbox import com.labfytools.trainlog.data.SyncCatalogInbox
import com.labfytools.trainlog.data.SyncReceiptResult
import com.labfytools.trainlog.data.SyncRequestOutbox import com.labfytools.trainlog.data.SyncRequestOutbox
import com.labfytools.trainlog.data.SyncRequestResult import com.labfytools.trainlog.data.SyncRequestResult
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
import kotlinx.coroutines.delay
@Composable @Composable
fun SyncScreen( fun SyncScreen(
@ -45,6 +47,13 @@ fun SyncScreen(
) )
} }
var pendingRequestId by
remember {
mutableStateOf<String?>(
null
)
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
when ( when (
val result = val result =
@ -76,6 +85,124 @@ fun SyncScreen(
} }
} }
LaunchedEffect(
pendingRequestId
) {
val requestId =
pendingRequestId
?: return@LaunchedEffect
repeat(60) {
when (
val receipt =
inbox.readSyncReceipt(
requestId
)
) {
SyncReceiptResult.Pending -> {
delay(1000)
}
SyncReceiptResult.FolderNotAuthorized -> {
success = false
status =
"Dossier Trainlog non autorisé."
pendingRequestId =
null
return@LaunchedEffect
}
is SyncReceiptResult.Error -> {
success = false
status =
receipt.message
pendingRequestId =
null
return@LaunchedEffect
}
is SyncReceiptResult.Received -> {
if (!receipt.success) {
success = false
status =
receipt.summary
pendingRequestId =
null
return@LaunchedEffect
}
when (
val catalog =
inbox.importPcCatalog()
) {
is CatalogInboxResult.Imported -> {
success = true
status =
(
"Synchronisation terminée · " +
receipt.summary +
" · Android catalogue : " +
"${catalog.imported} nouveau(x), " +
"${catalog.reconciled} réconcilié(s), " +
"${catalog.skipped} présent(s)."
)
onCatalogChanged()
}
CatalogInboxResult.FileNotFound -> {
success = false
status =
(
"Sync PC terminée, mais catalogue reçu introuvable."
)
}
CatalogInboxResult.FolderNotAuthorized -> {
success = false
status =
"Sync PC terminée, dossier Trainlog non autorisé."
}
is CatalogInboxResult.Error -> {
success = false
status =
(
"Sync PC terminée, import Android : " +
catalog.message
)
}
}
pendingRequestId =
null
return@LaunchedEffect
}
}
}
success = false
status =
"Le PC n'a pas répondu dans les 60 secondes."
pendingRequestId =
null
}
val folderLauncher = val folderLauncher =
rememberLauncherForActivityResult( rememberLauncherForActivityResult(
contract = contract =
@ -122,13 +249,14 @@ fun SyncScreen(
CatalogInboxResult.FileNotFound -> { CatalogInboxResult.FileNotFound -> {
success = true success = true
status = status =
"Dossier autorisé · aucun catalogue PC reçu." "Dossier autorisé · aucun catalogue PC reçu."
} }
CatalogInboxResult.FolderNotAuthorized, CatalogInboxResult.FolderNotAuthorized,
is CatalogInboxResult.Error -> { is CatalogInboxResult.Error -> {
/* Keep the permission status already shown. */ /* Keep permission state. */
} }
} }
} }
@ -151,18 +279,43 @@ fun SyncScreen(
title = "SYNCHRONISER" title = "SYNCHRONISER"
) { ) {
TrainlogInfo( TrainlogInfo(
"Le snapshot Android est maintenu automatiquement.", text =
color = colors.accent, "Le snapshot Android est maintenu automatiquement.",
color =
colors.accent,
) )
TrainlogAction( TrainlogAction(
label = label =
"Synchroniser maintenant", if (
pendingRequestId !=
null
) {
"Synchronisation en cours..."
} else {
"Synchroniser maintenant"
},
description = description =
"Envoie une demande au service Trainlog du PC.", "Android → PC puis PC → Android, en une seule opération.",
accent = accent =
colors.success, colors.success,
onClick = { onClick = {
if (
pendingRequestId !=
null
) {
return@TrainlogAction
}
if (!folderAuthorized) {
success = false
status =
"Autorisez d'abord Téléchargements/Trainlog."
return@TrainlogAction
}
when ( when (
val result = val result =
requestOutbox requestOutbox
@ -171,11 +324,11 @@ fun SyncScreen(
is SyncRequestResult.Requested -> { is SyncRequestResult.Requested -> {
success = true success = true
pendingRequestId =
result.requestId
status = status =
( "Demande envoyée · attente du PC..."
"Demande envoyée : " +
result.requestId
)
} }
SyncRequestResult.Unsupported -> { SyncRequestResult.Unsupported -> {
@ -198,11 +351,11 @@ fun SyncScreen(
TrainlogFrame( TrainlogFrame(
title = title =
"CATALOGUE PC → ANDROID" "DOSSIER D'ECHANGE"
) { ) {
if (folderAuthorized) { if (folderAuthorized) {
TrainlogInfo( TrainlogInfo(
"Dossier Trainlog autorisé.", "Téléchargements/Trainlog autorisé.",
color = color =
colors.success, colors.success,
) )
@ -228,14 +381,13 @@ fun SyncScreen(
TrainlogAction( TrainlogAction(
label = label =
"Appliquer le dernier catalogue PC", "Relire le catalogue PC",
description = description =
"Réconcilie les exercices publiés par le PC.", "Action de récupération manuelle si nécessaire.",
onClick = { onClick = {
when ( when (
val result = val result =
inbox inbox.importPcCatalog()
.importPcCatalog()
) { ) {
is CatalogInboxResult.Imported -> { is CatalogInboxResult.Imported -> {
success = true success = true
@ -245,7 +397,7 @@ fun SyncScreen(
"Catalogue PC : " + "Catalogue PC : " +
"${result.imported} nouveau(x), " + "${result.imported} nouveau(x), " +
"${result.reconciled} réconcilié(s), " + "${result.reconciled} réconcilié(s), " +
"${result.skipped} déjà présent(s)." "${result.skipped} présent(s)."
) )
onCatalogChanged() onCatalogChanged()
@ -255,7 +407,7 @@ fun SyncScreen(
success = false success = false
status = status =
"Autorisez d'abord Download/Trainlog." "Autorisez d'abord Téléchargements/Trainlog."
} }
CatalogInboxResult.FileNotFound -> { CatalogInboxResult.FileNotFound -> {

View file

@ -756,3 +756,116 @@ The database replacement remains transactional.
`TRAINLOG_FORMAT_V1` remains frozen and unchanged. `TRAINLOG_FORMAT_V1` remains frozen and unchanged.
<!-- TRAINLOG_VARIABLE_SETS_CHECKPOINT_FINAL _END --> <!-- TRAINLOG_VARIABLE_SETS_CHECKPOINT_FINAL _END -->
<!-- TRAINLOG_SHARED_SYNC_ENGINE_V1 -->
## Shared bidirectional synchronization v1
Validated architecture:
```text
Android local write
-> automatic mobile snapshot
Android "Synchroniser maintenant"
-> trainlog-sync-request-v1.json
trainlog-syncd
-> shared C synchronization engine
-> Android → PC mobile import
-> PC → Android catalog publish
-> trainlog-sync-receipt-v1.json
Android
-> receipt matched by request_id
-> PC catalog applied locally
-> final result displayed
```
The ncurses TUI and `trainlog-syncd` call the same
`trainlog_sync_run()` implementation.
Direct libmtp remains mandatory. No filesystem mount and no SQLite-file
synchronization are introduced.
### Concurrency
The shared engine owns:
```text
$XDG_DATA_HOME/trainlog/sync.lock
```
A TUI-triggered transaction waits for the lock. Daemon request polling is
non-blocking and retries later.
### Sync history
Every actual synchronization transaction creates:
```text
$XDG_DATA_HOME/trainlog/sync_runs/sy_*.json
$XDG_DATA_HOME/trainlog/sync_runs/sy_*.txt
```
and appends a compact entry to:
```text
$XDG_DATA_HOME/trainlog/sync_history.log
```
The TUI behaves like:
```text
git log
↑/↓ select synchronization
git show
Enter opens structured detail
```
Legacy three-field history entries remain readable but have no structured
detail file.
### Android request and receipt
Request:
```text
format = trainlog-sync-request
version = 1
```
Receipt:
```text
format = trainlog-sync-receipt
version = 1
```
The receipt carries the originating `request_id`, a generated `sync_id`,
status, summary and synchronization counts. Android ignores a receipt for a
different request ID.
### User service
Install/refresh the user service with:
```text
bash tools/install_syncd_user.sh
```
No root privilege is required.
### Status
```text
COMMON_SYNC_ENGINE=PASS
TUI_SYNC_LOG_SHOW=PASS
TRAINLOG_SYNCD=PASS
ANDROID_TRIGGERED_SYNC=PASS
ANDROID_SYNC_RECEIPT=PASS
BIDIRECTIONAL_SYNC_V1=PASS
```
Frozen `TRAINLOG_FORMAT_V1` remains unchanged.
<!-- TRAINLOG_SHARED_SYNC_ENGINE_V1 _END -->

View file

@ -584,3 +584,116 @@ The database replacement remains transactional.
`TRAINLOG_FORMAT_V1` remains frozen and unchanged. `TRAINLOG_FORMAT_V1` remains frozen and unchanged.
<!-- TRAINLOG_VARIABLE_SETS_CHECKPOINT_FINAL _END --> <!-- TRAINLOG_VARIABLE_SETS_CHECKPOINT_FINAL _END -->
<!-- TRAINLOG_SHARED_SYNC_ENGINE_V1 -->
## Shared bidirectional synchronization v1
Validated architecture:
```text
Android local write
-> automatic mobile snapshot
Android "Synchroniser maintenant"
-> trainlog-sync-request-v1.json
trainlog-syncd
-> shared C synchronization engine
-> Android → PC mobile import
-> PC → Android catalog publish
-> trainlog-sync-receipt-v1.json
Android
-> receipt matched by request_id
-> PC catalog applied locally
-> final result displayed
```
The ncurses TUI and `trainlog-syncd` call the same
`trainlog_sync_run()` implementation.
Direct libmtp remains mandatory. No filesystem mount and no SQLite-file
synchronization are introduced.
### Concurrency
The shared engine owns:
```text
$XDG_DATA_HOME/trainlog/sync.lock
```
A TUI-triggered transaction waits for the lock. Daemon request polling is
non-blocking and retries later.
### Sync history
Every actual synchronization transaction creates:
```text
$XDG_DATA_HOME/trainlog/sync_runs/sy_*.json
$XDG_DATA_HOME/trainlog/sync_runs/sy_*.txt
```
and appends a compact entry to:
```text
$XDG_DATA_HOME/trainlog/sync_history.log
```
The TUI behaves like:
```text
git log
↑/↓ select synchronization
git show
Enter opens structured detail
```
Legacy three-field history entries remain readable but have no structured
detail file.
### Android request and receipt
Request:
```text
format = trainlog-sync-request
version = 1
```
Receipt:
```text
format = trainlog-sync-receipt
version = 1
```
The receipt carries the originating `request_id`, a generated `sync_id`,
status, summary and synchronization counts. Android ignores a receipt for a
different request ID.
### User service
Install/refresh the user service with:
```text
bash tools/install_syncd_user.sh
```
No root privilege is required.
### Status
```text
COMMON_SYNC_ENGINE=PASS
TUI_SYNC_LOG_SHOW=PASS
TRAINLOG_SYNCD=PASS
ANDROID_TRIGGERED_SYNC=PASS
ANDROID_SYNC_RECEIPT=PASS
BIDIRECTIONAL_SYNC_V1=PASS
```
Frozen `TRAINLOG_FORMAT_V1` remains unchanged.
<!-- TRAINLOG_SHARED_SYNC_ENGINE_V1 _END -->

View file

@ -363,3 +363,116 @@ The database replacement remains transactional.
`TRAINLOG_FORMAT_V1` remains frozen and unchanged. `TRAINLOG_FORMAT_V1` remains frozen and unchanged.
<!-- TRAINLOG_VARIABLE_SETS_CHECKPOINT_FINAL _END --> <!-- TRAINLOG_VARIABLE_SETS_CHECKPOINT_FINAL _END -->
<!-- TRAINLOG_SHARED_SYNC_ENGINE_V1 -->
## Shared bidirectional synchronization v1
Validated architecture:
```text
Android local write
-> automatic mobile snapshot
Android "Synchroniser maintenant"
-> trainlog-sync-request-v1.json
trainlog-syncd
-> shared C synchronization engine
-> Android → PC mobile import
-> PC → Android catalog publish
-> trainlog-sync-receipt-v1.json
Android
-> receipt matched by request_id
-> PC catalog applied locally
-> final result displayed
```
The ncurses TUI and `trainlog-syncd` call the same
`trainlog_sync_run()` implementation.
Direct libmtp remains mandatory. No filesystem mount and no SQLite-file
synchronization are introduced.
### Concurrency
The shared engine owns:
```text
$XDG_DATA_HOME/trainlog/sync.lock
```
A TUI-triggered transaction waits for the lock. Daemon request polling is
non-blocking and retries later.
### Sync history
Every actual synchronization transaction creates:
```text
$XDG_DATA_HOME/trainlog/sync_runs/sy_*.json
$XDG_DATA_HOME/trainlog/sync_runs/sy_*.txt
```
and appends a compact entry to:
```text
$XDG_DATA_HOME/trainlog/sync_history.log
```
The TUI behaves like:
```text
git log
↑/↓ select synchronization
git show
Enter opens structured detail
```
Legacy three-field history entries remain readable but have no structured
detail file.
### Android request and receipt
Request:
```text
format = trainlog-sync-request
version = 1
```
Receipt:
```text
format = trainlog-sync-receipt
version = 1
```
The receipt carries the originating `request_id`, a generated `sync_id`,
status, summary and synchronization counts. Android ignores a receipt for a
different request ID.
### User service
Install/refresh the user service with:
```text
bash tools/install_syncd_user.sh
```
No root privilege is required.
### Status
```text
COMMON_SYNC_ENGINE=PASS
TUI_SYNC_LOG_SHOW=PASS
TRAINLOG_SYNCD=PASS
ANDROID_TRIGGERED_SYNC=PASS
ANDROID_SYNC_RECEIPT=PASS
BIDIRECTIONAL_SYNC_V1=PASS
```
Frozen `TRAINLOG_FORMAT_V1` remains unchanged.
<!-- TRAINLOG_SHARED_SYNC_ENGINE_V1 _END -->

View file

@ -884,3 +884,116 @@ The database replacement remains transactional.
`TRAINLOG_FORMAT_V1` remains frozen and unchanged. `TRAINLOG_FORMAT_V1` remains frozen and unchanged.
<!-- TRAINLOG_VARIABLE_SETS_CHECKPOINT_FINAL _END --> <!-- TRAINLOG_VARIABLE_SETS_CHECKPOINT_FINAL _END -->
<!-- TRAINLOG_SHARED_SYNC_ENGINE_V1 -->
## Shared bidirectional synchronization v1
Validated architecture:
```text
Android local write
-> automatic mobile snapshot
Android "Synchroniser maintenant"
-> trainlog-sync-request-v1.json
trainlog-syncd
-> shared C synchronization engine
-> Android → PC mobile import
-> PC → Android catalog publish
-> trainlog-sync-receipt-v1.json
Android
-> receipt matched by request_id
-> PC catalog applied locally
-> final result displayed
```
The ncurses TUI and `trainlog-syncd` call the same
`trainlog_sync_run()` implementation.
Direct libmtp remains mandatory. No filesystem mount and no SQLite-file
synchronization are introduced.
### Concurrency
The shared engine owns:
```text
$XDG_DATA_HOME/trainlog/sync.lock
```
A TUI-triggered transaction waits for the lock. Daemon request polling is
non-blocking and retries later.
### Sync history
Every actual synchronization transaction creates:
```text
$XDG_DATA_HOME/trainlog/sync_runs/sy_*.json
$XDG_DATA_HOME/trainlog/sync_runs/sy_*.txt
```
and appends a compact entry to:
```text
$XDG_DATA_HOME/trainlog/sync_history.log
```
The TUI behaves like:
```text
git log
↑/↓ select synchronization
git show
Enter opens structured detail
```
Legacy three-field history entries remain readable but have no structured
detail file.
### Android request and receipt
Request:
```text
format = trainlog-sync-request
version = 1
```
Receipt:
```text
format = trainlog-sync-receipt
version = 1
```
The receipt carries the originating `request_id`, a generated `sync_id`,
status, summary and synchronization counts. Android ignores a receipt for a
different request ID.
### User service
Install/refresh the user service with:
```text
bash tools/install_syncd_user.sh
```
No root privilege is required.
### Status
```text
COMMON_SYNC_ENGINE=PASS
TUI_SYNC_LOG_SHOW=PASS
TRAINLOG_SYNCD=PASS
ANDROID_TRIGGERED_SYNC=PASS
ANDROID_SYNC_RECEIPT=PASS
BIDIRECTIONAL_SYNC_V1=PASS
```
Frozen `TRAINLOG_FORMAT_V1` remains unchanged.
<!-- TRAINLOG_SHARED_SYNC_ENGINE_V1 _END -->

50
tools/install_syncd_user.sh Executable file
View file

@ -0,0 +1,50 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(
CDPATH= cd -- "$(dirname -- "$0")/.." &&
pwd
)"
SYNC_ONCE="$ROOT/build/tui/trainlog-sync-once"
DAEMON="$ROOT/tools/trainlog_syncd.py"
if [[ ! -x "$SYNC_ONCE" ]]; then
echo "missing executable: $SYNC_ONCE" >&2
echo "run meson compile -C build first" >&2
exit 1
fi
mkdir -p \
"$HOME/.local/bin" \
"$HOME/.config/systemd/user"
ln -sfn \
"$SYNC_ONCE" \
"$HOME/.local/bin/trainlog-sync-once"
ln -sfn \
"$DAEMON" \
"$HOME/.local/bin/trainlog-syncd"
UNIT="$HOME/.config/systemd/user/trainlog-syncd.service"
cat > "$UNIT" <<EOF
[Unit]
Description=Trainlog Android MTP synchronization agent
[Service]
Type=simple
ExecStart=/usr/bin/python3 "$DAEMON" --sync-once "$SYNC_ONCE" --interval 3
Restart=on-failure
RestartSec=2
[Install]
WantedBy=default.target
EOF
systemctl --user daemon-reload
systemctl --user enable --now trainlog-syncd.service
echo "TRAINLOG_SYNCD_INSTALL=PASS"
systemctl --user --no-pager --full status trainlog-syncd.service || true

138
tools/trainlog_syncd.py Executable file
View file

@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""Small user-session daemon for Android-triggered Trainlog sync requests."""
from __future__ import annotations
import argparse
import signal
import subprocess
import time
from pathlib import Path
STOP = False
def request_stop(
_signum: int,
_frame: object,
) -> None:
global STOP
STOP = True
def append_log(
text: str,
) -> None:
state_home = Path.home() / ".local" / "state" / "trainlog"
state_home.mkdir(
parents=True,
exist_ok=True,
)
with (
state_home / "syncd.log"
).open(
"a",
encoding="utf-8",
) as handle:
handle.write(
time.strftime(
"%Y-%m-%d %H:%M:%S "
)
)
handle.write(text.rstrip())
handle.write("\n")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument(
"--sync-once",
required=True,
type=Path,
)
parser.add_argument(
"--interval",
type=float,
default=3.0,
)
args = parser.parse_args()
if not args.sync_once.exists():
raise SystemExit(
f"sync executable missing: {args.sync_once}"
)
signal.signal(
signal.SIGTERM,
request_stop,
)
signal.signal(
signal.SIGINT,
request_stop,
)
append_log(
"trainlog-syncd started"
)
while not STOP:
result = subprocess.run(
[
str(args.sync_once),
"--request-only",
"--trigger",
"android",
],
capture_output=True,
text=True,
check=False,
)
if result.returncode == 0:
append_log(
result.stdout
)
elif result.returncode not in (
3,
):
detail = (
result.stderr.strip()
or result.stdout.strip()
or f"exit={result.returncode}"
)
append_log(
detail
)
deadline = (
time.monotonic()
+ max(
args.interval,
1.0,
)
)
while (
not STOP
and time.monotonic()
< deadline
):
time.sleep(0.2)
append_log(
"trainlog-syncd stopped"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,91 @@
#ifndef TRAINLOG_SYNC_H
#define TRAINLOG_SYNC_H
/**
* @file sync.h
* @brief Shared bidirectional Android/desktop synchronization engine.
*/
#include <stdbool.h>
#include <stddef.h>
#include "trainlog/model.h"
#include "trainlog/mtp.h"
#include "trainlog/status.h"
#include "trainlog/usb.h"
#define TRAINLOG_SYNC_SUMMARY_MAX 255U
#define TRAINLOG_SYNC_ERROR_MAX 511U
typedef enum TrainlogSyncTrigger {
TRAINLOG_SYNC_TRIGGER_TUI = 0,
TRAINLOG_SYNC_TRIGGER_ANDROID,
TRAINLOG_SYNC_TRIGGER_DAEMON
} TrainlogSyncTrigger;
typedef struct TrainlogSyncDeviceInfo {
bool connected;
bool storage_ready;
TrainlogUsbDevice device;
TrainlogMtpStorage storage;
} TrainlogSyncDeviceInfo;
typedef struct TrainlogSyncReport {
bool success;
bool request_present;
char sync_id[
TRAINLOG_ID_MAX + 1U
];
char request_id[
TRAINLOG_ID_MAX + 1U
];
char started_at[
TRAINLOG_TIMESTAMP_MAX + 1U
];
size_t exercises_imported;
size_t exercises_reconciled;
size_t exercises_skipped;
size_t sessions_imported;
size_t sessions_skipped;
size_t body_imported;
size_t body_skipped;
size_t catalog_published;
char summary[
TRAINLOG_SYNC_SUMMARY_MAX + 1U
];
char error[
TRAINLOG_SYNC_ERROR_MAX + 1U
];
} TrainlogSyncReport;
/**
* @brief Probe one currently connected MTP device without mutating it.
*/
TrainlogStatus trainlog_sync_probe(
TrainlogSyncDeviceInfo *output
);
/**
* @brief Run one complete bidirectional synchronization transaction.
*
* When require_request is true, the transaction runs only if Android has
* published a new trainlog-sync-request-v1 request ID.
*
* The engine is shared by the ncurses TUI and trainlog-syncd.
*/
TrainlogStatus trainlog_sync_run(
TrainlogSyncTrigger trigger,
bool require_request,
TrainlogSyncReport *output
);
#endif

View file

@ -36,6 +36,7 @@ trainlog_core_sources = files(
'src/usb.c', 'src/usb.c',
'src/mtp.c', 'src/mtp.c',
'src/reps.c', 'src/reps.c',
'src/sync.c',
) )
trainlog_core = static_library( trainlog_core = static_library(
@ -333,3 +334,10 @@ test(
meson.project_source_root() / 'tests/test_mobile_import_variable_sets.py', meson.project_source_root() / 'tests/test_mobile_import_variable_sets.py',
], ],
) )
trainlog_sync_once = executable(
'trainlog-sync-once',
'tools/sync_once.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)

View file

@ -20,7 +20,8 @@ static bool prefix_is_supported(const char *prefix)
*/ */
return strcmp(prefix, "ex") == 0 || return strcmp(prefix, "ex") == 0 ||
strcmp(prefix, "se") == 0 || strcmp(prefix, "se") == 0 ||
strcmp(prefix, "bo") == 0; strcmp(prefix, "bo") == 0 ||
strcmp(prefix, "sy") == 0;
} }
TrainlogStatus trainlog_id_generate( TrainlogStatus trainlog_id_generate(

2634
tui/src/sync.c Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -53,6 +53,7 @@ static bool test_generated_ids(void)
{ {
char first[TRAINLOG_GENERATED_ID_CAPACITY]; char first[TRAINLOG_GENERATED_ID_CAPACITY];
char second[TRAINLOG_GENERATED_ID_CAPACITY]; char second[TRAINLOG_GENERATED_ID_CAPACITY];
char sync_id[TRAINLOG_GENERATED_ID_CAPACITY];
CHECK( CHECK(
trainlog_id_generate("ex", first, sizeof(first)) == trainlog_id_generate("ex", first, sizeof(first)) ==
@ -67,6 +68,28 @@ static bool test_generated_ids(void)
CHECK(strcmp(first, second) != 0); CHECK(strcmp(first, second) != 0);
CHECK(first[3U + 14U] == '4'); CHECK(first[3U + 14U] == '4');
/* TRAINLOG_SYNC_ID_PREFIX_TEST */
CHECK(
trainlog_id_generate(
"sy",
sync_id,
sizeof(sync_id)
) == TRAINLOG_STATUS_OK
);
CHECK(
strncmp(
sync_id,
"sy_",
3U
) == 0
);
CHECK(
sync_id[3U + 14U] ==
'4'
);
return true; return true;
} }

176
tui/tools/sync_once.c Normal file
View file

@ -0,0 +1,176 @@
/**
* @file sync_once.c
* @brief Non-interactive entry point for the shared Trainlog sync engine.
*/
#include <stdbool.h>
#include <stdio.h>
#include <string.h>
#include "trainlog/sync.h"
static bool parse_trigger(
const char *text,
TrainlogSyncTrigger *output
)
{
if (
text == NULL ||
output == NULL
) {
return false;
}
if (
strcmp(
text,
"tui"
) == 0
) {
*output =
TRAINLOG_SYNC_TRIGGER_TUI;
return true;
}
if (
strcmp(
text,
"android"
) == 0
) {
*output =
TRAINLOG_SYNC_TRIGGER_ANDROID;
return true;
}
if (
strcmp(
text,
"daemon"
) == 0
) {
*output =
TRAINLOG_SYNC_TRIGGER_DAEMON;
return true;
}
return false;
}
int main(
int argc,
char **argv
)
{
bool request_only = false;
TrainlogSyncTrigger trigger =
TRAINLOG_SYNC_TRIGGER_DAEMON;
TrainlogSyncReport report;
TrainlogStatus status;
int index;
for (
index = 1;
index < argc;
++index
) {
if (
strcmp(
argv[index],
"--request-only"
) == 0
) {
request_only = true;
continue;
}
if (
strcmp(
argv[index],
"--trigger"
) == 0 &&
index + 1 < argc
) {
++index;
if (
!parse_trigger(
argv[index],
&trigger
)
) {
(void)fprintf(
stderr,
"invalid trigger\n"
);
return 64;
}
continue;
}
(void)fprintf(
stderr,
"usage: %s [--request-only] [--trigger tui|android|daemon]\n",
argv[0]
);
return 64;
}
status =
trainlog_sync_run(
trigger,
request_only,
&report
);
if (
request_only &&
status ==
TRAINLOG_STATUS_NOT_FOUND
) {
(void)printf(
"SYNC_REQUEST=NONE\n"
);
return 3;
}
if (
status ==
TRAINLOG_STATUS_OK &&
report.success
) {
(void)printf(
"SYNC_RUN=PASS\n"
"sync_id=%s\n"
"request_id=%s\n"
"summary=%s\n",
report.sync_id,
report.request_id,
report.summary
);
return 0;
}
(void)fprintf(
stderr,
"SYNC_RUN=FAIL\n"
"status=%d\n"
"error=%s\n",
(int)status,
report.error[0] != '\0'
? report.error
: "Erreur interne sans diagnostic."
);
return 2;
}