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
|
||||
|
||||
# Local user data
|
||||
data/
|
||||
/data/
|
||||
exports/
|
||||
|
||||
# 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_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
|
||||
|
||||
/* TRAINLOG_SYNC_AUTO_APPLY */
|
||||
/* TRAINLOG_ANDROID_TRIGGERED_SYNC_V1 */
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
|
|
@ -12,9 +12,11 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.setValue
|
||||
import com.labfytools.trainlog.data.CatalogInboxResult
|
||||
import com.labfytools.trainlog.data.SyncCatalogInbox
|
||||
import com.labfytools.trainlog.data.SyncReceiptResult
|
||||
import com.labfytools.trainlog.data.SyncRequestOutbox
|
||||
import com.labfytools.trainlog.data.SyncRequestResult
|
||||
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
fun SyncScreen(
|
||||
|
|
@ -45,6 +47,13 @@ fun SyncScreen(
|
|||
)
|
||||
}
|
||||
|
||||
var pendingRequestId by
|
||||
remember {
|
||||
mutableStateOf<String?>(
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
when (
|
||||
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 =
|
||||
rememberLauncherForActivityResult(
|
||||
contract =
|
||||
|
|
@ -122,13 +249,14 @@ fun SyncScreen(
|
|||
|
||||
CatalogInboxResult.FileNotFound -> {
|
||||
success = true
|
||||
|
||||
status =
|
||||
"Dossier autorisé · aucun catalogue PC reçu."
|
||||
}
|
||||
|
||||
CatalogInboxResult.FolderNotAuthorized,
|
||||
is CatalogInboxResult.Error -> {
|
||||
/* Keep the permission status already shown. */
|
||||
/* Keep permission state. */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -151,18 +279,43 @@ fun SyncScreen(
|
|||
title = "SYNCHRONISER"
|
||||
) {
|
||||
TrainlogInfo(
|
||||
"Le snapshot Android est maintenu automatiquement.",
|
||||
color = colors.accent,
|
||||
text =
|
||||
"Le snapshot Android est maintenu automatiquement.",
|
||||
color =
|
||||
colors.accent,
|
||||
)
|
||||
|
||||
TrainlogAction(
|
||||
label =
|
||||
"Synchroniser maintenant",
|
||||
if (
|
||||
pendingRequestId !=
|
||||
null
|
||||
) {
|
||||
"Synchronisation en cours..."
|
||||
} else {
|
||||
"Synchroniser maintenant"
|
||||
},
|
||||
description =
|
||||
"Envoie une demande au service Trainlog du PC.",
|
||||
"Android → PC puis PC → Android, en une seule opération.",
|
||||
accent =
|
||||
colors.success,
|
||||
onClick = {
|
||||
if (
|
||||
pendingRequestId !=
|
||||
null
|
||||
) {
|
||||
return@TrainlogAction
|
||||
}
|
||||
|
||||
if (!folderAuthorized) {
|
||||
success = false
|
||||
|
||||
status =
|
||||
"Autorisez d'abord Téléchargements/Trainlog."
|
||||
|
||||
return@TrainlogAction
|
||||
}
|
||||
|
||||
when (
|
||||
val result =
|
||||
requestOutbox
|
||||
|
|
@ -171,11 +324,11 @@ fun SyncScreen(
|
|||
is SyncRequestResult.Requested -> {
|
||||
success = true
|
||||
|
||||
pendingRequestId =
|
||||
result.requestId
|
||||
|
||||
status =
|
||||
(
|
||||
"Demande envoyée : " +
|
||||
result.requestId
|
||||
)
|
||||
"Demande envoyée · attente du PC..."
|
||||
}
|
||||
|
||||
SyncRequestResult.Unsupported -> {
|
||||
|
|
@ -198,11 +351,11 @@ fun SyncScreen(
|
|||
|
||||
TrainlogFrame(
|
||||
title =
|
||||
"CATALOGUE PC → ANDROID"
|
||||
"DOSSIER D'ECHANGE"
|
||||
) {
|
||||
if (folderAuthorized) {
|
||||
TrainlogInfo(
|
||||
"Dossier Trainlog autorisé.",
|
||||
"Téléchargements/Trainlog autorisé.",
|
||||
color =
|
||||
colors.success,
|
||||
)
|
||||
|
|
@ -228,14 +381,13 @@ fun SyncScreen(
|
|||
|
||||
TrainlogAction(
|
||||
label =
|
||||
"Appliquer le dernier catalogue PC",
|
||||
"Relire le catalogue PC",
|
||||
description =
|
||||
"Réconcilie les exercices publiés par le PC.",
|
||||
"Action de récupération manuelle si nécessaire.",
|
||||
onClick = {
|
||||
when (
|
||||
val result =
|
||||
inbox
|
||||
.importPcCatalog()
|
||||
inbox.importPcCatalog()
|
||||
) {
|
||||
is CatalogInboxResult.Imported -> {
|
||||
success = true
|
||||
|
|
@ -245,7 +397,7 @@ fun SyncScreen(
|
|||
"Catalogue PC : " +
|
||||
"${result.imported} nouveau(x), " +
|
||||
"${result.reconciled} réconcilié(s), " +
|
||||
"${result.skipped} déjà présent(s)."
|
||||
"${result.skipped} présent(s)."
|
||||
)
|
||||
|
||||
onCatalogChanged()
|
||||
|
|
@ -255,7 +407,7 @@ fun SyncScreen(
|
|||
success = false
|
||||
|
||||
status =
|
||||
"Autorisez d'abord Download/Trainlog."
|
||||
"Autorisez d'abord Téléchargements/Trainlog."
|
||||
}
|
||||
|
||||
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_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_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_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_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/mtp.c',
|
||||
'src/reps.c',
|
||||
'src/sync.c',
|
||||
)
|
||||
|
||||
trainlog_core = static_library(
|
||||
|
|
@ -333,3 +334,10 @@ test(
|
|||
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 ||
|
||||
strcmp(prefix, "se") == 0 ||
|
||||
strcmp(prefix, "bo") == 0;
|
||||
strcmp(prefix, "bo") == 0 ||
|
||||
strcmp(prefix, "sy") == 0;
|
||||
}
|
||||
|
||||
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 second[TRAINLOG_GENERATED_ID_CAPACITY];
|
||||
char sync_id[TRAINLOG_GENERATED_ID_CAPACITY];
|
||||
|
||||
CHECK(
|
||||
trainlog_id_generate("ex", first, sizeof(first)) ==
|
||||
|
|
@ -67,6 +68,28 @@ static bool test_generated_ids(void)
|
|||
CHECK(strcmp(first, second) != 0);
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
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