Add shared bidirectional sync engine
This commit is contained in:
parent
b3734b8674
commit
18958c5001
20 changed files with 6905 additions and 1559 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -22,7 +22,7 @@ local.properties
|
||||||
*.db-wal
|
*.db-wal
|
||||||
|
|
||||||
# Local user data
|
# Local user data
|
||||||
data/
|
/data/
|
||||||
exports/
|
exports/
|
||||||
|
|
||||||
# Python helpers
|
# Python helpers
|
||||||
|
|
|
||||||
113
CHANGELOG.md
113
CHANGELOG.md
|
|
@ -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 -->
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
@ -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 -> {
|
||||||
|
|
|
||||||
113
docs/android.md
113
docs/android.md
|
|
@ -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 -->
|
||||||
|
|
|
||||||
113
docs/roadmap.md
113
docs/roadmap.md
|
|
@ -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 -->
|
||||||
|
|
|
||||||
|
|
@ -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 -->
|
||||||
|
|
|
||||||
113
docs/tui.md
113
docs/tui.md
|
|
@ -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
50
tools/install_syncd_user.sh
Executable 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
138
tools/trainlog_syncd.py
Executable 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())
|
||||||
91
tui/include/trainlog/sync.h
Normal file
91
tui/include/trainlog/sync.h
Normal 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
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
2634
tui/src/sync.c
Normal file
File diff suppressed because it is too large
Load diff
2048
tui/src/tui.c
2048
tui/src/tui.c
File diff suppressed because it is too large
Load diff
|
|
@ -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
176
tui/tools/sync_once.c
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue