feat(sync): add generation MTP and Android backup

This commit is contained in:
fy59 2026-09-17 14:38:23 +02:00
parent 86b317c5bd
commit 34c6c0b369
34 changed files with 1180 additions and 34 deletions

View file

@ -2,6 +2,15 @@
## Unreleased — 0.1.2
- Added the production libudev/libmtp generation adapter with bounded peer
selection, object transfer and manifest-last publication; wired the trusted
orchestrator mode and proved the full Firefox/Android/desktop conversation
with an I/O-boundary object double. Added complete versioned Android
backup/verify/restore and a reproducible, non-installed schema-v17 bridge
candidate with v17 round-trip and v17-to-v20 migration tests. Physical MTP,
signing, device installation, user-data operations and rollout remain
pending.
- Added opt-in foreground generation coordination, peer-evidenced capabilities,
truthful bounded Web progress, descendant cleanup, an integrated Android ↔
desktop Firefox proof, and synthetic candidate/SQLite recovery tooling on the

View file

@ -0,0 +1,176 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import java.io.File
import java.io.InputStream
import java.io.OutputStream
import java.security.MessageDigest
import java.time.OffsetDateTime
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
import org.json.JSONArray
import org.json.JSONObject
internal sealed interface AndroidBackupResult {
data class Success(val bytes: Long) : AndroidBackupResult
data class Error(val message: String) : AndroidBackupResult
}
internal data class VerifiedAndroidBackup(val directory: File, val database: File, val preferences: File)
/** Complete same-installation backup container; plaintext is explicit by design. */
internal class AndroidBackupService(private val context: Context) {
companion object {
const val FORMAT = "trainlog-android-backup"
const val VERSION = 1
const val MAX_ENTRY_BYTES = 768L * 1024L * 1024L
const val MAX_TOTAL_BYTES = 1024L * 1024L * 1024L
private val PREFERENCES = listOf("trainlog-sync", "trainlog_presentation_settings")
}
private fun digest(file: File): String {
val value = MessageDigest.getInstance("SHA-256")
file.inputStream().use { input ->
val buffer = ByteArray(64 * 1024)
while (true) { val count = input.read(buffer); if (count < 0) break; value.update(buffer, 0, count) }
}
return value.digest().joinToString("") { "%02x".format(it) }
}
private fun preferencesJson(): String = JSONObject().apply {
put("format", "trainlog-android-preferences"); put("version", 1)
put("stores", JSONObject().also { stores -> PREFERENCES.forEach { name ->
stores.put(name, JSONObject().also { values ->
context.getSharedPreferences(name, Context.MODE_PRIVATE).all.toSortedMap().forEach { (key, value) ->
when (value) {
null -> values.put(key, JSONObject.NULL)
is String, is Boolean, is Int, is Long, is Float -> values.put(key, value)
is Set<*> -> values.put(key, JSONArray(value.filterIsInstance<String>().sorted()))
else -> error("Unsupported preference type")
}
}
})
} })
}.toString()
fun createPrivate(repository: TrainlogRepository): Result<File> = runCatching {
val directory = File(context.filesDir, "backups").apply { mkdirs() }
val archive = File(directory, "trainlog-backup-${java.util.UUID.randomUUID()}.tlbackup")
archive.outputStream().use { destination ->
val result = writeArchive(repository, destination)
if (result is AndroidBackupResult.Error) error(result.message)
}
archive
}
fun create(repository: TrainlogRepository, destination: OutputStream): AndroidBackupResult {
val archive = createPrivate(repository).getOrElse { return AndroidBackupResult.Error(it.message ?: "Backup failed.") }
return try { archive.inputStream().use { it.copyTo(destination) }; destination.flush()
val bytes = archive.length(); archive.delete(); AndroidBackupResult.Success(bytes)
} catch (error: Exception) {
AndroidBackupResult.Error("Export failed; private backup retained at ${archive.name}: ${error.message}")
}
}
private fun writeArchive(repository: TrainlogRepository, destination: OutputStream): AndroidBackupResult {
val stage = File(context.cacheDir, "backup-${java.util.UUID.randomUUID()}")
if (!stage.mkdir()) return AndroidBackupResult.Error("Cannot create private backup staging.")
val database = File(stage, "database.sqlite"); val preferences = File(stage, "preferences.json")
return try {
repository.backupDatabaseTo(database); preferences.writeText(preferencesJson())
val schema = validateDatabase(database)
val files = listOf("database.sqlite" to database, "preferences.json" to preferences)
val manifest = JSONObject().put("format", FORMAT).put("version", VERSION)
.put("package", context.packageName).put("schema", schema).put("product_version", "0.1.2")
.put("plaintext", true).put("created_at", OffsetDateTime.now().toString())
.put("entries", JSONArray(files.map { (name, file) -> JSONObject().put("path", name)
.put("size", file.length()).put("sha256", digest(file)) }))
var written = 0L
ZipOutputStream(destination).use { zip ->
files.forEach { (name, file) -> zip.putNextEntry(ZipEntry(name)); file.inputStream().use { input -> written += input.copyTo(zip) }; zip.closeEntry() }
zip.putNextEntry(ZipEntry("manifest.json")); val raw = manifest.toString().toByteArray(); zip.write(raw); written += raw.size; zip.closeEntry()
}
AndroidBackupResult.Success(written)
} catch (error: Exception) { AndroidBackupResult.Error(error.message ?: "Backup failed.")
} finally { stage.deleteRecursively() }
}
fun verify(input: InputStream): Result<VerifiedAndroidBackup> {
val stage = File(context.cacheDir, "restore-${java.util.UUID.randomUUID()}")
if (!stage.mkdir()) return Result.failure(IllegalStateException("Cannot create restore staging."))
return runCatching {
var total = 0L; val names = mutableSetOf<String>()
ZipInputStream(input).use { zip ->
while (true) {
val entry = zip.nextEntry ?: break
require(!entry.isDirectory && entry.name in setOf("database.sqlite", "preferences.json", "manifest.json") && names.add(entry.name)) { "Invalid backup entry." }
val target = File(stage, entry.name); target.outputStream().use { output ->
val buffer = ByteArray(64 * 1024); var entryBytes = 0L
while (true) { val count = zip.read(buffer); if (count < 0) break; entryBytes += count; total += count
require(entryBytes <= MAX_ENTRY_BYTES && total <= MAX_TOTAL_BYTES) { "Backup exceeds bounds." }; output.write(buffer, 0, count) }
}
}
}
require(names == setOf("database.sqlite", "preferences.json", "manifest.json")) { "Backup entries are incomplete." }
val manifest = JSONObject(File(stage, "manifest.json").readText())
require(manifest.getString("format") == FORMAT && manifest.getInt("version") == VERSION &&
manifest.getString("package") == context.packageName && manifest.getBoolean("plaintext") &&
manifest.getInt("schema") in 17..20) { "Unsupported backup." }
val expected = manifest.getJSONArray("entries")
require(expected.length() == 2)
val manifestPaths = mutableSetOf<String>()
for (index in 0 until expected.length()) { val item = expected.getJSONObject(index); val path = item.getString("path")
require(path in setOf("database.sqlite", "preferences.json") && manifestPaths.add(path)) { "Invalid manifest entry." }; val file = File(stage, path)
require(file.canonicalFile.parentFile == stage.canonicalFile && file.length() == item.getLong("size") && digest(file) == item.getString("sha256")) { "Backup digest mismatch." } }
require(manifestPaths == setOf("database.sqlite", "preferences.json")) { "Backup manifest is incomplete." }
require(validateDatabase(File(stage, "database.sqlite")) == manifest.getInt("schema"))
val preferences = JSONObject(File(stage, "preferences.json").readText())
require(preferences.getString("format") == "trainlog-android-preferences" && preferences.getInt("version") == 1)
VerifiedAndroidBackup(stage, File(stage, "database.sqlite"), File(stage, "preferences.json"))
}.onFailure { stage.deleteRecursively() }
}
fun restore(repository: TrainlogRepository, verified: VerifiedAndroidBackup, databaseName: String = "trainlog-android.db"): AndroidBackupResult {
val destination = context.getDatabasePath(databaseName)
val previous = File(destination.parentFile, "${destination.name}.pre-restore-${java.util.UUID.randomUUID()}")
val previousPreferences = File(context.cacheDir, "preferences-pre-restore-${java.util.UUID.randomUUID()}.json")
var moved = false
return try {
previousPreferences.writeText(preferencesJson())
repository.close(); destination.parentFile?.mkdirs()
if (destination.exists()) { check(destination.renameTo(previous)); moved = true }
File("${destination.path}-wal").delete(); File("${destination.path}-shm").delete()
verified.database.copyTo(destination, overwrite = false)
validateDatabase(destination); restorePreferences(verified.preferences)
previous.delete(); previousPreferences.delete(); verified.directory.deleteRecursively()
AndroidBackupResult.Success(destination.length())
} catch (error: Exception) {
destination.delete()
if (moved) previous.renameTo(destination)
if (previousPreferences.isFile) runCatching { restorePreferences(previousPreferences) }
previousPreferences.delete()
AndroidBackupResult.Error(error.message ?: "Restore failed.")
}
}
private fun validateDatabase(file: File): Int {
val db = SQLiteDatabase.openDatabase(file.path, null, SQLiteDatabase.OPEN_READONLY)
try { require(db.rawQuery("PRAGMA integrity_check", null).use { it.moveToFirst() && it.getString(0) == "ok" })
require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() })
require(db.version in 17..20) { "Unsupported Android schema ${db.version}." }
return db.version
} finally { db.close() }
}
private fun restorePreferences(file: File) {
val stores = JSONObject(file.readText()).getJSONObject("stores")
PREFERENCES.forEach { name -> val editor = context.getSharedPreferences(name, Context.MODE_PRIVATE).edit().clear(); val values = stores.getJSONObject(name)
values.keys().forEach { key -> when (val value = values.get(key)) {
is String -> editor.putString(key, value); is Boolean -> editor.putBoolean(key, value)
is Int -> editor.putInt(key, value); is Long -> editor.putLong(key, value)
is Double -> editor.putFloat(key, value.toFloat()); is JSONArray -> editor.putStringSet(key, (0 until value.length()).map { value.getString(it) }.toSet())
} }; check(editor.commit()) }
}
}

View file

@ -40,6 +40,7 @@ import com.labfytools.trainlog.model.TrackingMode
import org.json.JSONArray
import org.json.JSONObject
import java.text.Normalizer
import java.io.File
import java.io.StringReader
import java.nio.ByteBuffer
import java.nio.charset.StandardCharsets
@ -443,6 +444,13 @@ class TrainlogRepository(
database.close()
}
/** Create a transactionally consistent SQLite snapshot without exposing WAL files. */
internal fun backupDatabaseTo(destination: File) {
require(!destination.exists()) { "Backup snapshot destination already exists." }
val escaped = destination.absolutePath.replace("'", "''")
database.writableDatabase.execSQL("VACUUM INTO '$escaped'")
}
/**
* Runs generation work in one SQLite scope. WHY: independently opened
* exporter/importer connections cannot prove a coherent generation.

View file

@ -31,6 +31,8 @@ import com.labfytools.trainlog.data.KnowledgeConfidence
import com.labfytools.trainlog.data.SyncCatalogInbox
import com.labfytools.trainlog.data.StartAiSessionDraftResult
import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.data.AndroidBackupService
import com.labfytools.trainlog.data.AndroidBackupResult
import com.labfytools.trainlog.data.directStoragePermissionIntent
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
@ -275,7 +277,7 @@ private fun catalogCountsText(context: android.content.Context, imported: Int, r
).joinToString(", ")
@Composable
fun SettingsScreen(inbox: SyncCatalogInbox, onCatalogChanged: () -> Unit) {
fun SettingsScreen(repository: TrainlogRepository, inbox: SyncCatalogInbox, onCatalogChanged: () -> Unit) {
val colors = LocalTrainlogColors.current
val context = LocalContext.current
val language = LocalLanguagePresentation.current
@ -298,6 +300,25 @@ fun SettingsScreen(inbox: SyncCatalogInbox, onCatalogChanged: () -> Unit) {
}
}
}
val backupService = remember { AndroidBackupService(context) }
val backupLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/zip")) { uri ->
if (uri != null) {
val result = runCatching { context.contentResolver.openOutputStream(uri, "w")!!.use { backupService.create(repository, it) } }.getOrElse { AndroidBackupResult.Error(it.message ?: "Backup export failed.") }
message = strings.getString(if (result is AndroidBackupResult.Success) R.string.settings_backup_complete else R.string.settings_backup_failed)
}
}
val restoreLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri != null) {
val verified = runCatching { context.contentResolver.openInputStream(uri)!!.use { backupService.verify(it).getOrThrow() } }
val result = verified.fold({ backupService.restore(repository, it) }, { AndroidBackupResult.Error(it.message ?: "Backup verification failed.") })
if (result is AndroidBackupResult.Error) {
android.widget.Toast.makeText(context, strings.getString(R.string.settings_restore_failed), android.widget.Toast.LENGTH_LONG).show()
}
// Restore closes the repository before replacement. Recreate even
// after a rolled-back failure so no UI owner retains that handle.
(context as? android.app.Activity)?.recreate()
}
}
TrainlogScreen(strings.getString(R.string.settings_screen)) {
TrainlogFrame(strings.getString(R.string.settings_language_section)) {
TrainlogInfo("${strings.getString(R.string.settings_language_label)} : " +
@ -331,5 +352,12 @@ fun SettingsScreen(inbox: SyncCatalogInbox, onCatalogChanged: () -> Unit) {
})
message?.let { TrainlogInfo(it, if (authorized) colors.success else colors.error) }
}
TrainlogFrame(strings.getString(R.string.settings_backup_section)) {
TrainlogInfo(strings.getString(R.string.settings_backup_plaintext))
TrainlogAction(strings.getString(R.string.settings_backup_create), strings.getString(R.string.settings_backup_create_description),
{ backupLauncher.launch("trainlog-backup.tlbackup") })
TrainlogAction(strings.getString(R.string.settings_backup_restore), strings.getString(R.string.settings_backup_restore_description),
{ restoreLauncher.launch(arrayOf("application/zip", "application/octet-stream")) })
}
}
}

View file

@ -140,7 +140,7 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
AppRoute.BodyMeasurements -> BodyScreen(repository, bodyState, { exportSnapshot() }, { back() })
AppRoute.LatestMaxima -> LatestMaximaScreen(repository)
AppRoute.Sync -> SyncScreen(repository, inbox, exporter, requestOutbox, { exportSnapshot(); catalogRevision++ }, { back() })
AppRoute.Settings -> SettingsScreen(inbox) { exportSnapshot(); catalogRevision++ }
AppRoute.Settings -> SettingsScreen(repository, inbox) { exportSnapshot(); catalogRevision++ }
}}
}

View file

@ -396,4 +396,13 @@
<string name="exercise_order_saved">Session order saved.</string>
<string name="exercise_reorder_failed">Reorder was not saved: %1$s</string>
<string name="invalid_decimal_value">Enter a valid decimal value using “,” or “.”.</string>
<string name="settings_backup_section">Complete local backup</string>
<string name="settings_backup_plaintext">The file contains plaintext health data. Keep it in a private location.</string>
<string name="settings_backup_create">Create backup</string>
<string name="settings_backup_create_description">Export the complete database, active draft, and preferences.</string>
<string name="settings_backup_restore">Restore backup</string>
<string name="settings_backup_restore_description">Verify and restore this installation. Synchronization must remain stopped.</string>
<string name="settings_backup_complete">Complete backup exported.</string>
<string name="settings_backup_failed">Backup failed; any private staged copy was retained.</string>
<string name="settings_restore_failed">The backup is invalid or could not be restored.</string>
</resources>

View file

@ -396,4 +396,13 @@
<string name="exercise_order_saved">Ordre de la séance enregistré.</string>
<string name="exercise_reorder_failed">Réorganisation non enregistrée : %1$s</string>
<string name="invalid_decimal_value">Saisissez une valeur décimale valide avec « , » ou « . ».</string>
<string name="settings_backup_section">Sauvegarde locale complète</string>
<string name="settings_backup_plaintext">Le fichier contient vos données de santé en clair. Conservez-le dans un emplacement privé.</string>
<string name="settings_backup_create">Créer une sauvegarde</string>
<string name="settings_backup_create_description">Exporter la base complète, le brouillon actif et les préférences.</string>
<string name="settings_backup_restore">Restaurer une sauvegarde</string>
<string name="settings_backup_restore_description">Vérifier puis restaurer cette installation. La synchronisation doit rester arrêtée.</string>
<string name="settings_backup_complete">Sauvegarde complète exportée.</string>
<string name="settings_backup_failed">Échec de la sauvegarde ; toute copie privée créée est conservée.</string>
<string name="settings_restore_failed">Sauvegarde invalide ou restauration impossible.</string>
</resources>

View file

@ -0,0 +1,62 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionDraftForm
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.TrackingMode
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class AndroidBackupServiceTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test fun completeBackupRestoresDraftPeerLedgersAndPreferences() {
val name = "backup-${UUID.randomUUID()}.db"
var repository = TrainlogRepository(context, name)
val service = AndroidBackupService(context)
try {
val exercise = (repository.createExercise(NewExerciseProfile("Backup exercise",
RecordingMode.SETS, TrackingMode.REPS, 0)) as CreateExerciseResult.Created).exercise
assertEquals(ActiveDraftMutationResult.Saved, repository.saveActiveSessionDraft(ActiveSessionDraft(
exercises = listOf(SessionExerciseDraft(exercise = exercise,
sets = listOf(SessionSetDraft(reps = 9, weightKg = 31.5)))),
form = SessionDraftForm(selectedExercise = exercise, weightText = "31,"))))
val peer = SyncGenerationService(repository).peerId()
context.getSharedPreferences("trainlog_presentation_settings", Context.MODE_PRIVATE)
.edit().putString("language", "en").commit()
val output = ByteArrayOutputStream()
assertTrue(service.create(repository, output) is AndroidBackupResult.Success)
val verified = service.verify(ByteArrayInputStream(output.toByteArray())).getOrThrow()
assertTrue(service.restore(repository, verified, name) is AndroidBackupResult.Success)
repository = TrainlogRepository(context, name)
val draft = repository.loadActiveSessionDraft()
assertTrue(draft is ActiveDraftLoadResult.Loaded)
assertEquals("31,", (draft as ActiveDraftLoadResult.Loaded).draft.form.weightText)
assertEquals(peer, SyncGenerationService(repository).peerId())
assertEquals("en", context.getSharedPreferences("trainlog_presentation_settings", Context.MODE_PRIVATE).getString("language", null))
} finally { repository.close(); context.deleteDatabase(name) }
}
@Test fun corruptAndTraversalContainersAreRejectedBeforeRestore() {
val service = AndroidBackupService(context)
val output = ByteArrayOutputStream()
java.util.zip.ZipOutputStream(output).use { zip ->
zip.putNextEntry(java.util.zip.ZipEntry("../database.sqlite")); zip.write(byteArrayOf(1)); zip.closeEntry()
}
assertTrue(service.verify(ByteArrayInputStream(output.toByteArray())).isFailure)
}
}

View file

@ -0,0 +1,31 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import java.io.FileInputStream
import java.io.File
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class) @Config(sdk=[35])
class BridgeBackupMigrationTest {
@Test fun verifiedSchemaSeventeenBridgeBackupMigratesThroughCurrentOwner() {
val source=System.getenv("TRAINLOG_BRIDGE_BACKUP_INPUT")?.let(::File)
assumeTrue(source?.isFile==true)
val context:Context=ApplicationProvider.getApplicationContext();val name="bridge-migrate-${UUID.randomUUID()}.db"
val service=AndroidBackupService(context);var repository=TrainlogRepository(context,name)
try {
val verified=FileInputStream(source!!).use { service.verify(it).getOrThrow() }
assertTrue(service.restore(repository,verified,name) is AndroidBackupResult.Success)
repository=TrainlogRepository(context,name)
assertEquals("21,",(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft.form.weightText)
android.database.sqlite.SQLiteDatabase.openDatabase(context.getDatabasePath(name).path,null,android.database.sqlite.SQLiteDatabase.OPEN_READONLY).use { assertEquals(20,it.version) }
} finally {repository.close();context.deleteDatabase(name)}
}
}

View file

@ -936,3 +936,23 @@ domains plus the durable generation record in one outer transaction; and
replays the stored logical ACK after restart. The complete-envelope context is
internal and typed: standalone artifact methods retain their causal guards.
This service is not selected by the automatic V3 storage/request path.
## Complete local backup and restore
Settings exposes an explicit plaintext `.tlbackup` export and restore flow for
the same application identity. The versioned ZIP contains a SQLite-consistent
`VACUUM INTO` snapshot, active drafts and raw partial form input, synchronization
peer/generation/causal state, and the owned presentation/sync preferences. A
manifest written last records schema, package, sizes and SHA-256 digests.
Verification stages only the three exact bounded entries, validates integrity,
foreign keys, package, supported schema and digests before closing the live
repository. Restore retains the previous database and preferences until the
replacement validates; failure restores both. This is not an interchange
format and is intentionally unencrypted, so the UI warns the user to protect
the exported file.
The installed schema-v17 release has no backup UI. The reproducible
`tools/build_android_backup_bridge.py` recipe materializes that exact source,
adds only the backup surface, assigns a higher candidate version code and
builds/tests an APK without installing it. Its synthetic v17 archive is also
restored by current code and migrated through the normal v17-to-v20 path.

View file

@ -430,6 +430,15 @@ transport only from a trusted local configuration. The HTTP event loop only
admits work and reads a bounded durable report. V3 remains the default when the
trusted generation configuration is absent.
In `mtp` mode the worker invokes one fixed packaged helper. Its production
callbacks are the existing libudev/libmtp object operations; tests replace only
that typed I/O table. Candidate selection requires one exact stable peer
advertisement and required capabilities. Downloads are bounded and size
checked. Upload retries accept byte-identical objects, and each generation
manifest is published after its member objects. The helper runs inside the
orchestrator-owned process group and cannot be selected or parameterized by an
HTTP request.
It polls for a new Android request and invokes the same shared C synchronization
engine used by the TUI.

View file

@ -1,9 +1,12 @@
# Current implementation state
The development branch has opt-in full-generation coordination with durable Web
progress and a foreground Android conversation. The cross-implementation
directory-I/O-double proof is integrated. Physical MTP, release signing, user
backups, main integration and deployment are not complete.
progress and a foreground Android conversation. Its production generation MTP
adapter is integrated and tested through an object-I/O boundary double. Android
also has a complete same-installation backup/verify/restore container, and a
non-installed schema-v17 bridge candidate is reproducibly built and tested.
Physical-device MTP, release signing, real user backup/restore, main integration
and deployment are not complete.
Snapshot date: **2026-09-17**.
@ -63,6 +66,7 @@ parallel implementation of its rules.
| Synchronization data/lifecycle slice | `TRAINLOG_SYNC_DATA_LIFECYCLE_V1=PASS/FROZEN` |
| Synchronization causal deletion slice | `TRAINLOG_SYNC_CAUSAL_DELETE_V1=PASS/FROZEN` |
| Synchronization generation/ACK slice | `TRAINLOG_SYNC_GENERATION_ACK_V1=PASS/FROZEN` (explicit staged entry points; active transport remains V3) |
| Generation MTP and Android backup | `TRAINLOG_SYNC_MTP_AND_ANDROID_BACKUP_V1=SOFTWARE_COMPLETE` (physical-device and user-operation gates remain) |
Desktop and Android schema numbers are independent. Neither changes the frozen
Trainlog JSON V1 contract.
@ -107,8 +111,9 @@ Android inbox/outbox still select V3 and do not move the draft artifact.
few domain-specific explicit states make defined replay paths idempotent.
Explicit staged generation services now provide coherent V4/companion
capture, immutable manifests, whole-generation SQLite consumption and
durable peer ACKs. The active automatic path remains V3 and does not select
these services.
durable peer ACKs. A trusted opt-in configuration may select the bounded
production generation MTP adapter; absent configuration leaves V3 as the
default.
- SQLite database files are never synchronized.
- The desktop AI flow uses external `rclone` for Drive inbox/archive and
read-only history export. Android owns no Drive credentials.

View file

@ -1,6 +1,7 @@
# Sync deployment readiness V1 evidence
Status: **SOFTWARE BLOCKED — physical production MTP adapter not completed**.
Status: **SUPERSEDED SOFTWARE BLOCKER — adapter and backup completed by the
MTP/Android-backup follow-up; physical and rollout gates remain**.
This corrective tranche replaces the earlier configurable peer fixture with a
fixed desktop worker and a production Android foreground coordinator. The real

View file

@ -0,0 +1,26 @@
# Sync MTP and Android backup V1 evidence
Status: **SOFTWARE COMPLETE — hardware and user-operation gates pending**.
The production C adapter resolves the direct-MTP object tree and is selected
only by trusted `mode: mtp` configuration. Unit tests substitute its typed
libudev/libmtp callbacks, not its selection, bounds, download, retry or
manifest-last publication logic. The integrated headless-Firefox proof uses the
same algorithm over an object-store double and completes the real Android and
desktop generation/ACK conversation.
Android creates a SQLite-consistent, versioned plaintext archive containing the
database and owned preferences, verifies exact entries, bounds, hashes,
integrity, foreign keys, package and schema before replacement, and retains the
old database/preferences until restore verification succeeds. The pinned
schema-v17 bridge candidate builds without installation, passes its v17
round-trip test, and produces an archive that current Android restores and
migrates to schema v20.
Software evidence includes the Meson transport test, orchestrator process and
configuration tests, Android backup tests, bridge/current migration chain,
Android debug assembly, full isolated validation harness, and candidate
inventory verification. Physical MTP with an unlocked phone, release-signature
continuity, bridge/current APK installation, real user backup/restore, service
changes, main integration and deployment were not performed and require their
separate approvals.

View file

@ -143,7 +143,11 @@ the separate execution-draft codec are implemented behind explicit entry
points, while active transport remains V3. Causal deletion is implemented and
validated through explicit staged entry points, including both real producer
directions for every contracted target kind; generation/consumption
acknowledgement, orchestration/reporting and Web sync remain unimplemented.
acknowledgement, orchestration/reporting, Web sync, the production generation
MTP adapter and complete Android backup/restore are implemented on the
development branch. Remaining gates are physical-device validation, signed
bridge/current APK procedures, real user backups and an explicitly authorized
rollout.
`WEB_DASHBOARD_V1=PASS/FROZEN` remains closed and unchanged.
### Gym catalog V1

View file

@ -745,8 +745,9 @@ between passes and the three explicit unclassified states remained intact.
## 18. Staged coherent generations and durable acknowledgement
`tools/sync_generation_exchange.py` and Android `SyncGenerationService` are
the production staged owners for manifest V1 and ACK V1. Neither is wired into
the automatic V3 MTP selection. Both allocate stable installation peer IDs and
the production staged owners for manifest V1 and ACK V1. The trusted opt-in
orchestrator can carry their object tree through the production direct-MTP
adapter; automatic V3 selection remains unchanged. Both allocate stable installation peer IDs and
opaque UUIDv4 generation/run IDs only in the explicitly opened staged store.
Desktop capture uses one private SQLite backup and runs every established
@ -775,6 +776,15 @@ idempotently. Causal V1 operation bytes remain unchanged across first emission
and retransmission because generation membership lives only in
`sync_causal_publications`.
The generation MTP adapter resolves `Documents/Trainlog` through libudev and
libmtp, selects one exact advertised peer/capability set across bounded devices
and storages, and mirrors only bounded regular object names. It downloads to
private temporary files before replacement, verifies reported and received
sizes, rejects ambiguous peers, traversal, excess depth/count/bytes and
truncation, and publishes generation manifests last. Production behavior is
tested by replacing only the typed libmtp/udev I/O callbacks with a filesystem
object double; the same compiled transport algorithm remains under test.
## 19. Legacy active-protocol limitations
The Web end-to-end tranche adds an explicit opt-in application orchestrator.

View file

@ -10,9 +10,12 @@ bounded typed progress and its descendant process group. The foreground Android
coordinator advertises its real peer identity, publishes manifest-last bytes,
consumes the return generation and emits a durable correlated ACK.
The integrated proof uses a directory object-I/O double. It is not evidence for
physical libmtp behavior. The shipped physical generation adapter is not yet
complete, so generation mode must not be enabled on services.
The integrated proof uses the production generation adapter with only its
typed libudev/libmtp I/O callbacks replaced by an object-store double. This is
software evidence for selection, bounds, publication ordering and the complete
conversation, but not evidence for a particular phone's physical libmtp
behavior. Generation mode must remain disabled until that hardware gate and
the separately authorized rollout are complete.
### Candidate, backup and recovery
@ -25,10 +28,12 @@ complete, so generation mode must not be enabled on services.
3. Quiesce old writers only during an authorized rollout. Desktop backup uses
`backup_trainlog_sqlite.py` and SQLite's backup API, then integrity and
foreign-key checks.
4. V3 is not an Android backup and omits active-draft and causal state. No safe
backup path for the installed release was demonstrated without device access.
This is a hard gate: an authorized procedure must capture the app database,
WAL-consistent state and active draft before update.
4. V3 is not an Android backup. Current Android has a complete verified backup
container. For the installed schema-v17 release, build the pinned bridge
candidate, verify signing/version continuity separately, install only with
explicit approval, create and verify the user backup, and retain it before
installing current Android. None of those device/user operations occurred in
this software mission.
5. Replacing a binary does not roll back a schema. Before any exchange, a
mutually compatible captured pair may be restored. After causal writes or
ACKs, never restore only one peer: stop synchronization, preserve both stores

View file

@ -1289,3 +1289,21 @@ production-service suite. Android adds
`importedCausalOperationsCannotRetireBuiltInIdentities`; the existing session
round trip now also changes and reverts a confirmed set before proving the
remote deletion conflicts without losing business or causal state.
## Generation MTP and Android backup
`generation_mtp` drives the production transport algorithm through typed fake
libudev/libmtp callbacks. It covers exact peer selection, bounded download,
manifest-last publication, ambiguity and truncated-object rejection. The
Firefox end-to-end scenario also runs in `mtp` mode: only the callback table is
replaced by a filesystem object store while the C transport, Python worker,
Android generation service, desktop consumers and durable ACK path remain real.
`AndroidBackupServiceTest` creates and restores a populated current-schema
backup including an active draft, raw partial input, stable peer identity and
preferences, and rejects corrupt and traversal entries. The bridge build runs
the analogous test on the exact schema-v17 source and feeds its archive to
`BridgeBackupMigrationTest`, which verifies normal migration to schema v20.
These are software proofs. They do not claim physical USB/MTP behavior,
installation over the signed release, a real user backup, or a real-device
restore.

View file

@ -17,7 +17,7 @@ class OrchestratorTest(unittest.TestCase):
db,state,config,run,request=values
return subprocess.run(["python3",str(executable),"--database",str(db),"--state",str(state),"--config",str(config),"--run-id",run,"--request-id",request],capture_output=True,text=True)
def test_browser_configuration_cannot_select_command_or_boolean_timeout(self):
for override in ({"peer_command":["evil"]},{"timeout_seconds":True},{"mode":"mtp"}):
for override in ({"peer_command":["evil"]},{"timeout_seconds":True},{"mode":"unsupported"}):
with self.subTest(override=override),tempfile.TemporaryDirectory() as directory:
root=Path(directory);values=self.fixture(root,**override);result=self.invoke(ORCHESTRATOR,values)
self.assertEqual(result.returncode,2);self.assertEqual(json.loads(values[1].read_text())["phase"],"failed")

View file

@ -23,21 +23,25 @@ class BrowserSyncTest(unittest.TestCase):
from selenium.webdriver.support.ui import WebDriverWait
with tempfile.TemporaryDirectory(prefix="trainlog-web-sync-") as directory:
root=Path(directory);xdg=root/"data";xdg.mkdir();config=root/"config.json"
mode=os.environ.get("TRAINLOG_WEB_E2E_TRANSPORT_MODE","directory")
transport=root/"transport";transport.mkdir();owned=root/"owned";owned.mkdir()
remote=(root/"device/Documents/Trainlog") if mode=="mtp" else transport
remote.mkdir(parents=True,exist_ok=True)
release=root/"release-android";android_log=(root/"android.log").open("wb")
android_env=os.environ.copy();android_env.update({"JAVA_HOME":"/usr/lib/jvm/java-17-openjdk","TRAINLOG_SYNC_TRANSPORT_ROOT":str(transport),"TRAINLOG_SYNC_TEST_RELEASE_FILE":str(release)})
android_env=os.environ.copy();android_env.update({"JAVA_HOME":"/usr/lib/jvm/java-17-openjdk","TRAINLOG_SYNC_TRANSPORT_ROOT":str(remote),"TRAINLOG_SYNC_TEST_RELEASE_FILE":str(release)})
android=subprocess.Popen(["./gradlew","--no-daemon","testDebugUnitTest","--tests","com.labfytools.trainlog.data.SyncDeploymentConversationTest","--rerun-tasks"],cwd=ROOT/"android",env=android_env,stdout=android_log,stderr=subprocess.STDOUT,start_new_session=True)
try:
peer_file=await_condition(lambda: (transport/"android-peer-v1.json") if (transport/"android-peer-v1.json").is_file() else None,120)
peer_file=await_condition(lambda: (remote/"android-peer-v1.json") if (remote/"android-peer-v1.json").is_file() else None,120)
except Exception as error:
if android.poll() is None: os.killpg(android.pid,signal.SIGTERM)
android.wait(timeout=10);android_log.close()
raise AssertionError(f"Android bridge did not advertise:\n{(root/'android.log').read_text(errors='replace')}") from error
peer=json.loads(peer_file.read_text())
config.write_text(json.dumps({"format":"trainlog-sync-orchestrator-config","version":1,"enabled":True,"mode":"directory","expected_peer_id":peer["peer_id"],"transport_root":str(transport),"owned_root":str(owned),"timeout_seconds":30}))
config.write_text(json.dumps({"format":"trainlog-sync-orchestrator-config","version":1,"enabled":True,"mode":mode,"expected_peer_id":peer["peer_id"],"transport_root":str(transport),"owned_root":str(owned),"timeout_seconds":60}))
with socket.socket() as reserved:
reserved.bind(("127.0.0.1",0));port=reserved.getsockname()[1]
env=os.environ.copy();env.update({"XDG_DATA_HOME":str(xdg),"XDG_CONFIG_HOME":str(root/"config"),"XDG_CACHE_HOME":str(root/"cache"),"TRAINLOG_SYNC_GENERATION_CONFIG":str(config)})
if mode=="mtp": env.update({"TRAINLOG_SYNC_MTP_ADAPTER":str(ROOT/"build/tui/generation-mtp-adapter-double"),"TRAINLOG_MTP_DOUBLE_ROOT":str(root/"device")})
server=subprocess.Popen([str(ROOT/"build/tui/trainlog"),"--web","--port",str(port)],env=env,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,start_new_session=True)
driver=None
try:
@ -52,7 +56,9 @@ class BrowserSyncTest(unittest.TestCase):
button=wait.until(lambda d:d.find_element(By.XPATH,"//button[normalize-space()='Synchroniser']"))
wait.until(lambda _ : button.is_enabled());button.click()
state_path=Path(str(xdg/"trainlog"/"trainlog.db")+".sync-run.json")
committed=await_condition(lambda: json.loads(state_path.read_text()) if state_path.is_file() and json.loads(state_path.read_text()).get("phase")=="local_import_committed" else None)
observed=await_condition(lambda: json.loads(state_path.read_text()) if state_path.is_file() and json.loads(state_path.read_text()).get("phase") in ("local_import_committed","failed","interrupted") else None,70)
self.assertEqual(observed["phase"],"local_import_committed",f"state={observed}\nandroid={(root/'android.log').read_text(errors='replace')}")
committed=observed
self.assertEqual(committed["result"],"running")
with sqlite3.connect(xdg/"trainlog"/"trainlog.db") as database:
self.assertEqual(database.execute("SELECT COUNT(*) FROM execution_drafts").fetchone()[0],1)

View file

@ -0,0 +1,37 @@
package com.labfytools.trainlog.data
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.widget.Button
import android.widget.LinearLayout
import android.widget.TextView
/** Minimal non-migrating bridge UI; it intentionally exposes backup/restore only. */
class BackupBridgeActivity : Activity() {
private lateinit var repository: TrainlogRepository
private lateinit var status: TextView
private val service by lazy { AndroidBackupService(applicationContext) }
override fun onCreate(state: Bundle?) {
super.onCreate(state); repository = TrainlogRepository(applicationContext)
status = TextView(this).apply { text = "Plaintext local backup. Keep the exported file private." }
val backup = Button(this).apply { text = "Create complete backup"; setOnClickListener {
startActivityForResult(Intent(Intent.ACTION_CREATE_DOCUMENT).setType("application/zip")
.putExtra(Intent.EXTRA_TITLE, "trainlog-backup.tlbackup").addCategory(Intent.CATEGORY_OPENABLE), 1) } }
val restore = Button(this).apply { text = "Verify and restore backup"; setOnClickListener {
startActivityForResult(Intent(Intent.ACTION_OPEN_DOCUMENT).setType("application/zip")
.addCategory(Intent.CATEGORY_OPENABLE), 2) } }
setContentView(LinearLayout(this).apply { orientation = LinearLayout.VERTICAL; addView(status); addView(backup); addView(restore) })
}
override fun onActivityResult(request: Int, result: Int, data: Intent?) {
super.onActivityResult(request, result, data); if (result != RESULT_OK || data?.data == null) return
if (request == 1) contentResolver.openOutputStream(data.data!!, "w")!!.use {
status.text = service.create(repository, it).toString()
} else if (request == 2) contentResolver.openInputStream(data.data!!)!!.use {
val verified = service.verify(it).getOrElse { error -> status.text = error.message; return }
status.text = service.restore(repository, verified).toString()
recreate()
}
}
override fun onDestroy() { repository.close(); super.onDestroy() }
}

View file

@ -0,0 +1,44 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionDraftForm
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.TrackingMode
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class) @Config(sdk=[35])
class BackupBridgeServiceTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test fun schemaSeventeenWalDraftRoundTripsWithoutMigration() {
val name="bridge-${UUID.randomUUID()}.db";var repository=TrainlogRepository(context,name);val service=AndroidBackupService(context)
try {
val exercise=(repository.createExercise(NewExerciseProfile("Bridge",RecordingMode.SETS,TrackingMode.REPS,0)) as CreateExerciseResult.Created).exercise
assertEquals(ActiveDraftMutationResult.Saved,repository.saveActiveSessionDraft(ActiveSessionDraft(
exercises=listOf(SessionExerciseDraft(exercise=exercise,sets=listOf(SessionSetDraft(reps=7,weightKg=20.0)))),
form=SessionDraftForm(selectedExercise=exercise,weightText="20,"))))
context.getDatabasePath(name).let { android.database.sqlite.SQLiteDatabase.openDatabase(it.path,null,android.database.sqlite.SQLiteDatabase.OPEN_READWRITE).use { db ->
db.enableWriteAheadLogging();db.execSQL("UPDATE active_session_draft SET weight_text='21,' WHERE id=1")
assertEquals(17,db.version)
} }
val bytes=ByteArrayOutputStream();assertTrue(service.create(repository,bytes) is AndroidBackupResult.Success)
System.getenv("TRAINLOG_BRIDGE_BACKUP_OUTPUT")?.let { java.io.File(it).writeBytes(bytes.toByteArray()) }
val verified=service.verify(ByteArrayInputStream(bytes.toByteArray())).getOrThrow()
assertTrue(service.restore(repository,verified,name) is AndroidBackupResult.Success)
repository=TrainlogRepository(context,name);assertEquals("21,",(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft.form.weightText)
context.getDatabasePath(name).let { android.database.sqlite.SQLiteDatabase.openDatabase(it.path,null,android.database.sqlite.SQLiteDatabase.OPEN_READONLY).use { db -> assertEquals(17,db.version) } }
} finally {repository.close();context.deleteDatabase(name)}
}
}

View file

@ -0,0 +1,42 @@
#!/usr/bin/env python3
"""Materialize and build the non-migrating schema-v17 Android backup bridge."""
import argparse, io, os, shutil, subprocess, tarfile
from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]
BASE="77c15172e1401b91b8e2537c4b8548095ed50053"
def replace(path:Path,old:str,new:str):
value=path.read_text()
if value.count(old)!=1: raise RuntimeError(f"bridge patch anchor mismatch: {path}: {old[:40]}")
path.write_text(value.replace(old,new))
def main():
parser=argparse.ArgumentParser();parser.add_argument("--output",required=True,type=Path);parser.add_argument("--jdk",default="/usr/lib/jvm/java-17-openjdk")
args=parser.parse_args()
if args.output.exists(): raise RuntimeError("bridge output already exists")
args.output.mkdir(parents=True)
raw=subprocess.check_output(["git","archive","--format=tar",BASE,"android","catalog"],cwd=ROOT)
with tarfile.open(fileobj=io.BytesIO(raw)) as archive:
for member in archive.getmembers():
target=(args.output/member.name).resolve()
if args.output.resolve() not in target.parents: raise RuntimeError("unsafe archive member")
archive.extractall(args.output,filter="data")
android=args.output/"android"
repository=android/"app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt"
replace(repository,"import java.text.Normalizer","import java.text.Normalizer\nimport java.io.File")
replace(repository," fun close() {\n database.close()\n }"," fun close() {\n database.close()\n }\n\n internal fun backupDatabaseTo(destination: File) {\n require(!destination.exists())\n val escaped = destination.absolutePath.replace(\"'\", \"''\")\n database.writableDatabase.execSQL(\"VACUUM INTO '$escaped'\")\n }")
shutil.copy2(ROOT/"android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt",android/"app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt")
shutil.copy2(ROOT/"tools/android_backup_bridge/BackupBridgeActivity.kt",android/"app/src/main/java/com/labfytools/trainlog/data/BackupBridgeActivity.kt")
shutil.copy2(ROOT/"tools/android_backup_bridge/BackupBridgeServiceTest.kt",android/"app/src/test/java/com/labfytools/trainlog/data/BackupBridgeServiceTest.kt")
manifest=android/"app/src/main/AndroidManifest.xml"
replace(manifest," <activity\n android:name=\".MainActivity\"", " <activity android:name=\".data.BackupBridgeActivity\" android:exported=\"true\" android:label=\"Trainlog Backup\">\n <intent-filter><action android:name=\"android.intent.action.MAIN\"/><category android:name=\"android.intent.category.LAUNCHER\"/></intent-filter>\n </activity>\n <activity\n android:name=\".MainActivity\"")
gradle=android/"app/build.gradle.kts"
replace(gradle,"versionCode = 3","versionCode = providers.environmentVariable(\"TRAINLOG_BRIDGE_VERSION_CODE\").orNull?.toInt() ?: 4")
env=os.environ.copy();env["JAVA_HOME"]=args.jdk;env["TRAINLOG_BRIDGE_VERSION_CODE"]=env.get("TRAINLOG_BRIDGE_VERSION_CODE","4");env["TRAINLOG_BRIDGE_BACKUP_OUTPUT"]=str(args.output/"schema17-synthetic.tlbackup")
subprocess.run([str(android/"gradlew"),"--no-daemon",":app:testDebugUnitTest","--tests","com.labfytools.trainlog.data.BackupBridgeServiceTest",":app:assembleDebug"],cwd=android,env=env,check=True)
migration_env=os.environ.copy();migration_env["JAVA_HOME"]=args.jdk;migration_env["TRAINLOG_BRIDGE_BACKUP_INPUT"]=env["TRAINLOG_BRIDGE_BACKUP_OUTPUT"]
subprocess.run([str(ROOT/"android/gradlew"),"--no-daemon",":app:testDebugUnitTest","--tests","com.labfytools.trainlog.data.BridgeBackupMigrationTest","--rerun-tasks"],cwd=ROOT/"android",env=migration_env,check=True)
print(f"BRIDGE_BASE={BASE}");print(f"BRIDGE_RECIPE={subprocess.check_output(['git','rev-parse','HEAD'],cwd=ROOT,text=True).strip()}")
print(f"BRIDGE_APK={android/'app/build/outputs/apk/debug/app-debug.apk'}")
if __name__=="__main__": main()

View file

@ -38,19 +38,23 @@ def main() -> int:
if args.output.exists():
raise RuntimeError("candidate output already exists")
binary = args.build / "tui/trainlog"
mtp_adapter = args.build / "tui/trainlog-generation-mtp-adapter"
if not binary.is_file():
raise RuntimeError("compiled trainlog executable is missing")
if not mtp_adapter.is_file():
raise RuntimeError("compiled generation MTP adapter is missing")
(args.output / "bin").mkdir(parents=True)
(args.output / "libexec").mkdir(parents=True)
(args.output / "tools").mkdir(parents=True)
(args.output / "catalog").mkdir(parents=True)
shutil.copy2(binary, args.output / "libexec/trainlog")
shutil.copy2(mtp_adapter, args.output / "libexec/trainlog-generation-mtp-adapter")
for name in RUNTIME_TOOLS:
shutil.copy2(ROOT / "tools" / name, args.output / "tools" / name)
for source in sorted((ROOT / "catalog").glob("*.json")):
shutil.copy2(source, args.output / "catalog" / source.name)
launcher = args.output / "bin/trainlog"
launcher.write_text("#!/bin/sh\nset -eu\nroot=$(CDPATH= cd -- \"$(dirname -- \"$0\")/..\" && pwd)\nexport TRAINLOG_SYNC_TOOLS_DIR=\"$root/tools\"\nexec \"$root/libexec/trainlog\" \"$@\"\n")
launcher.write_text("#!/bin/sh\nset -eu\nroot=$(CDPATH= cd -- \"$(dirname -- \"$0\")/..\" && pwd)\nexport TRAINLOG_SYNC_TOOLS_DIR=\"$root/tools\"\nexport TRAINLOG_SYNC_MTP_ADAPTER=\"$root/libexec/trainlog-generation-mtp-adapter\"\nexec \"$root/libexec/trainlog\" \"$@\"\n")
files = sorted(path for path in args.output.rglob("*") if path.is_file())
inventory = {
"format": "trainlog-sync-candidate-inventory", "version": 1,
@ -58,13 +62,15 @@ def main() -> int:
"product_version": "0.1.2", "desktop_schema": 21, "android_schema": 20,
"protocols": ["mobile-export-v3", "mobile-history-v4", "causal-delete-v1",
"execution-draft-v1", "generation-manifest-v1", "generation-ack-v1"],
"entry_points": ["bin/trainlog", "tools/sync_orchestrator.py"],
"entry_points": ["bin/trainlog", "libexec/trainlog-generation-mtp-adapter",
"tools/sync_orchestrator.py"],
"runtime_dependencies": ["python>=3.11", "sqlite3", "utf8proc", "libuuid", "libudev", "libmtp", "notcurses"],
"files": [{"path": str(path.relative_to(args.output)), "sha256": digest(path), "size": path.stat().st_size} for path in files],
}
(args.output / "candidate-inventory.json").write_text(json.dumps(inventory, indent=2, sort_keys=True) + "\n")
os.chmod(args.output / "bin/trainlog", 0o755)
os.chmod(args.output / "libexec/trainlog", 0o755)
os.chmod(args.output / "libexec/trainlog-generation-mtp-adapter", 0o755)
return 0

View file

@ -171,8 +171,8 @@ def run(args: argparse.Namespace) -> int:
raise RuntimeError("invalid trusted sync configuration")
if config["format"] != "trainlog-sync-orchestrator-config" or config["version"] != 1 or config["enabled"] is not True:
raise RuntimeError("full-generation synchronization is disabled")
if config["mode"] != "directory":
raise RuntimeError("physical MTP generation transport is not implemented")
if config["mode"] not in ("directory", "mtp"):
raise RuntimeError("invalid transport mode")
for key in ("transport_root","owned_root"):
if not isinstance(config[key],str) or not Path(config[key]).is_absolute(): raise RuntimeError("trusted paths must be absolute")
if not isinstance(config["expected_peer_id"],str) or not re.fullmatch(r"peer_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}",config["expected_peer_id"]): raise RuntimeError("invalid expected peer identity")
@ -193,7 +193,13 @@ def run(args: argparse.Namespace) -> int:
environment.update({"TRAINLOG_SYNC_RUN_ID": args.run_id,
"TRAINLOG_SYNC_TRIGGER": state["trigger"],
"TRAINLOG_SYNC_DATABASE": args.database})
command=[sys.executable,str(Path(__file__).with_name("sync_peer_worker.py")),"--database",args.database,"--transport-root",config["transport_root"],"--owned-root",config["owned_root"],"--run-id",args.run_id,"--expected-peer",config["expected_peer_id"],"--timeout",str(timeout)]
command=[sys.executable,str(Path(__file__).with_name("sync_peer_worker.py")),"--database",args.database,"--transport-root",config["transport_root"],"--owned-root",config["owned_root"],"--run-id",args.run_id,"--expected-peer",config["expected_peer_id"],"--timeout",str(timeout),"--mode",config["mode"]]
if config["mode"] == "mtp":
adapter = os.environ.get("TRAINLOG_SYNC_MTP_ADAPTER",
str(Path(__file__).resolve().parents[1] / "build/tui/trainlog-generation-mtp-adapter"))
if not Path(adapter).is_absolute() or not os.access(adapter, os.X_OK):
raise RuntimeError("fixed MTP adapter executable is unavailable")
command.extend(["--mtp-adapter", adapter])
child = subprocess.Popen(command, stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=environment,
preexec_fn=_child_setup)

View file

@ -32,9 +32,30 @@ def publish_json(path:Path,value:dict)->None:
stream.write(generation.canonical(value));stream.flush();os.fsync(stream.fileno())
os.chmod(temporary,0o600);os.replace(temporary,path)
def wait_file(path:Path,deadline:float)->None:
def run_adapter(adapter:Path,operation:str,peer:str,root:Path,deadline:float,allow_missing_peer:bool=False)->bool:
remaining=max(1,int(deadline-time.monotonic()))
result=__import__("subprocess").run([str(adapter),operation,peer,str(root)],stdin=__import__("subprocess").DEVNULL,
capture_output=True,timeout=min(remaining,30),check=False)
if result.returncode!=0:
diagnostic=(result.stderr.decode("utf-8","replace")[:1024] or "MTP adapter failed").strip()
if allow_missing_peer and "expected peer was not found" in diagnostic: return False
raise RuntimeError(diagnostic)
return True
def wait_file(path:Path,deadline:float,pump=None)->None:
while not path.is_file():
if time.monotonic()>=deadline: raise RuntimeError(f"timeout waiting for {path.name}")
if pump is not None: pump()
time.sleep(.05)
def wait_json(path:Path,deadline:float,predicate,pump=None)->dict:
while True:
wait_file(path,deadline,pump)
try: value=json.loads(bounded(path))
except (OSError,json.JSONDecodeError): value=None
if isinstance(value,dict) and predicate(value): return value
if time.monotonic()>=deadline: raise RuntimeError(f"timeout waiting for correlated {path.name}")
if pump is not None: pump()
time.sleep(.05)
def load_peer(root:Path,expected:str)->dict:
@ -44,18 +65,29 @@ def load_peer(root:Path,expected:str)->dict:
return value
def main()->int:
parser=argparse.ArgumentParser();parser.add_argument("--database",required=True,type=Path);parser.add_argument("--transport-root",required=True,type=Path);parser.add_argument("--owned-root",required=True,type=Path);parser.add_argument("--run-id",required=True);parser.add_argument("--expected-peer",required=True);parser.add_argument("--timeout",required=True,type=int)
parser=argparse.ArgumentParser();parser.add_argument("--database",required=True,type=Path);parser.add_argument("--transport-root",required=True,type=Path);parser.add_argument("--owned-root",required=True,type=Path);parser.add_argument("--run-id",required=True);parser.add_argument("--expected-peer",required=True);parser.add_argument("--timeout",required=True,type=int);parser.add_argument("--mode",choices=("directory","mtp"),default="directory");parser.add_argument("--mtp-adapter",type=Path)
args=parser.parse_args();deadline=time.monotonic()+args.timeout
pump=None
if args.mode=="mtp":
if args.mtp_adapter is None: raise RuntimeError("MTP adapter is required")
pump=lambda:run_adapter(args.mtp_adapter,"pull",args.expected_peer,args.transport_root,deadline,True)
# Android publishes its durable identity only while participating. A
# missing expected advertisement is therefore a bounded wait state;
# device, transport, ambiguity and malformed-object failures remain
# immediate and distinct adapter errors.
wait_file(args.transport_root/"android-peer-v1.json",deadline,pump)
peer=load_peer(args.transport_root,args.expected_peer)
with closing(generation.connect_database(args.database)) as db:
generation.require_schema(db);desktop_peer=generation.peer_identity(db,"desktop");db.commit()
request={"format":"trainlog-sync-generation-request","version":1,"run_id":args.run_id,"desktop_peer_id":desktop_peer,"android_peer_id":peer["peer_id"],"capabilities":sorted(CAPS)}
request_path=args.transport_root/"request-v1.json";publish_json(request_path,request)
if args.mode=="mtp": run_adapter(args.mtp_adapter,"push",args.expected_peer,args.transport_root,deadline)
emit(args.run_id,"waiting_android_publication",producer_peer_id=peer["peer_id"],consumer_peer_id=desktop_peer)
inbound_ref=args.transport_root/"android-generation-v1.json";wait_file(inbound_ref,deadline)
inbound=json.loads(bounded(inbound_ref));inbound_dir=args.transport_root/inbound["relative_path"]
inbound_ref=args.transport_root/"android-generation-v1.json"
inbound=wait_json(inbound_ref,deadline,lambda value:value.get("run_id")==args.run_id,pump);inbound_dir=args.transport_root/inbound["relative_path"]
ack=generation.consume_desktop(args.database,inbound_dir)
ack_path=args.transport_root/"desktop-consumption-ack-v1.json";publish_json(ack_path,ack)
if args.mode=="mtp": run_adapter(args.mtp_adapter,"push",args.expected_peer,args.transport_root,deadline)
if ack["result"]!="consumed": raise RuntimeError("desktop rejected Android generation")
emit(args.run_id,"local_import_committed",inbound_generation_id=ack["generation_id"],manifest_sha256=ack["manifest_sha256"])
outgoing_id="gen_"+str(uuid.uuid4())
@ -68,10 +100,11 @@ def main()->int:
published=generation.publish(args.database,outgoing_id,args.transport_root/"desktop-objects")
ref={"format":"trainlog-sync-generation-reference","version":1,"run_id":args.run_id,"generation_id":outgoing_id,"manifest_sha256":digest,"relative_path":str(published.relative_to(args.transport_root))}
publish_json(args.transport_root/"desktop-generation-v1.json",ref)
if args.mode=="mtp": run_adapter(args.mtp_adapter,"push",args.expected_peer,args.transport_root,deadline)
emit(args.run_id,"published",outbound_generation_id=outgoing_id,manifest_sha256=digest)
emit(args.run_id,"waiting_acknowledgement",outbound_generation_id=outgoing_id)
android_ack=args.transport_root/"android-consumption-ack-v1.json";wait_file(android_ack,deadline)
android_ack_value=json.loads(bounded(android_ack))
android_ack=args.transport_root/"android-consumption-ack-v1.json"
android_ack_value=wait_json(android_ack,deadline,lambda value:value.get("run_id")==args.run_id and value.get("generation_id")==outgoing_id,pump)
result=generation.accept_ack(args.database,android_ack)
if result not in ("acknowledged","unchanged"): raise RuntimeError(f"Android did not durably consume desktop generation: {result}: {android_ack_value.get('diagnostic','')}")
with closing(generation.connect_database(args.database)) as db:

View file

@ -0,0 +1,35 @@
#ifndef TRAINLOG_GENERATION_MTP_H
#define TRAINLOG_GENERATION_MTP_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "trainlog/mtp.h"
#include "trainlog/status.h"
#include "trainlog/usb.h"
#define TRAINLOG_GENERATION_MTP_MAX_DEVICES 8U
#define TRAINLOG_GENERATION_MTP_MAX_STORAGES 8U
#define TRAINLOG_GENERATION_MTP_MAX_CHILDREN 512U
#define TRAINLOG_GENERATION_MTP_MAX_DEPTH 5U
#define TRAINLOG_GENERATION_MTP_MAX_TOTAL_BYTES (512ULL * 1024ULL * 1024ULL)
typedef struct TrainlogGenerationMtpIo {
TrainlogStatus (*devices)(TrainlogUsbDevice *, size_t, size_t *);
TrainlogStatus (*storages)(unsigned int, unsigned int, TrainlogMtpStorage *, size_t, size_t *);
TrainlogStatus (*children)(unsigned int, unsigned int, uint32_t, uint32_t, TrainlogMtpEntry *, size_t, size_t *);
TrainlogStatus (*ensure_folder)(unsigned int, unsigned int, uint32_t, uint32_t, const char *, uint32_t *, bool *);
TrainlogStatus (*receive)(unsigned int, unsigned int, uint32_t, const char *);
TrainlogStatus (*send)(unsigned int, unsigned int, uint32_t, uint32_t, const char *, const char *, uint32_t *);
TrainlogStatus (*remove)(unsigned int, unsigned int, uint32_t);
TrainlogStatus (*rename)(unsigned int, unsigned int, uint32_t, const char *);
} TrainlogGenerationMtpIo;
/** Mirror remote generation-protocol objects to or from a private local root. */
TrainlogStatus trainlog_generation_mtp_pull(const TrainlogGenerationMtpIo *io,
const char *expected_peer_id, const char *local_root, char *diagnostic, size_t diagnostic_size);
TrainlogStatus trainlog_generation_mtp_push(const TrainlogGenerationMtpIo *io,
const char *expected_peer_id, const char *local_root, char *diagnostic, size_t diagnostic_size);
const TrainlogGenerationMtpIo *trainlog_generation_mtp_production_io(void);
#endif

View file

@ -59,6 +59,23 @@ TrainlogStatus trainlog_mtp_ensure_root_folder(
bool *output_created
);
/**
* @brief Find or create one exact folder below an explicit parent.
*
* Duplicate names are rejected as a conflict. The returned object identifier
* is valid only for the current connection epoch and must be resolved again
* after reconnecting.
*/
TrainlogStatus trainlog_mtp_ensure_folder(
unsigned int bus_number,
unsigned int device_number,
uint32_t storage_id,
uint32_t parent_folder_id,
const char *folder_name,
uint32_t *output_folder_id,
bool *output_created
);
/**
* @brief Upload one local text file into an exact MTP folder.
*/
@ -116,4 +133,11 @@ TrainlogStatus trainlog_mtp_delete_object(
uint32_t item_id
);
TrainlogStatus trainlog_mtp_rename_object(
unsigned int bus_number,
unsigned int device_number,
uint32_t item_id,
const char *new_name
);
#endif

View file

@ -198,6 +198,7 @@ trainlog_core_sources = files(
'src/training_context.c',
'src/usb.c',
'src/mtp.c',
'src/generation_mtp.c',
'src/reps.c',
'src/session_generation.c',
'src/statistics.c',
@ -223,6 +224,7 @@ trainlog_core = static_library(
m_dep,
udev_dep,
mtp_dep,
yyjson_dep,
],
c_args: strict_c_args,
)
@ -237,6 +239,7 @@ trainlog_core_dep = declare_dependency(
m_dep,
udev_dep,
mtp_dep,
yyjson_dep,
],
)
@ -552,6 +555,13 @@ trainlog_mtp_probe = executable(
c_args: strict_c_args,
)
trainlog_generation_mtp_adapter = executable(
'trainlog-generation-mtp-adapter',
'tools/generation_mtp_adapter.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test_mtp = executable(
'test_mtp',
'tests/test_mtp.c',
@ -564,6 +574,21 @@ test(
test_mtp,
)
test_generation_mtp = executable(
'test_generation_mtp',
'tests/test_generation_mtp.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test('generation_mtp', test_generation_mtp)
generation_mtp_adapter_double = executable(
'generation-mtp-adapter-double',
'tests/generation_mtp_adapter_double.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
trainlog_mtp_exchange_probe = executable(
'trainlog-mtp-exchange-probe',
'tools/mtp_exchange_probe.c',

301
tui/src/generation_mtp.c Normal file
View file

@ -0,0 +1,301 @@
#include "trainlog/generation_mtp.h"
#include <dirent.h>
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include <yyjson.h>
typedef struct Selection {
unsigned int bus;
unsigned int device;
uint32_t storage;
uint32_t root;
} Selection;
static void diag(char *output, size_t size, const char *value)
{
if (output != NULL && size > 0U) (void)snprintf(output, size, "%s", value);
}
static bool safe_leaf(const char *name)
{
return name != NULL && name[0] != '\0' && strcmp(name, ".") != 0 &&
strcmp(name, "..") != 0 && strchr(name, '/') == NULL && strchr(name, '\\') == NULL;
}
static TrainlogStatus children(const TrainlogGenerationMtpIo *io, const Selection *selection,
uint32_t parent, TrainlogMtpEntry *entries, size_t *count)
{
return io->children(selection->bus, selection->device, selection->storage,
parent, entries, TRAINLOG_GENERATION_MTP_MAX_CHILDREN, count);
}
static TrainlogStatus one_named(const TrainlogGenerationMtpIo *io, const Selection *selection,
uint32_t parent, const char *name, bool folder, TrainlogMtpEntry *result)
{
TrainlogMtpEntry entries[TRAINLOG_GENERATION_MTP_MAX_CHILDREN];
size_t count = 0U, index, matches = 0U;
TrainlogStatus status = children(io, selection, parent, entries, &count);
if (status != TRAINLOG_STATUS_OK) return status;
for (index = 0U; index < count; ++index) {
if (entries[index].folder == folder && strcmp(entries[index].name, name) == 0) {
*result = entries[index]; ++matches;
}
}
if (matches > 1U) return TRAINLOG_STATUS_CONFLICT;
return matches == 1U ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_NOT_FOUND;
}
static bool peer_document_matches(const char *path, const char *expected)
{
int fd = open(path, O_RDONLY | O_CLOEXEC);
char buffer[16385];
ssize_t count;
yyjson_doc *document;
yyjson_val *root, *capabilities, *item;
size_t index, maximum;
bool manifest = false, acknowledgement = false, valid;
if (fd < 0) return false;
count = read(fd, buffer, sizeof(buffer));
(void)close(fd);
if (count <= 0 || (size_t)count >= sizeof(buffer)) return false;
buffer[count] = '\0';
document = yyjson_read(buffer, (size_t)count, YYJSON_READ_NOFLAG);
if (document == NULL) return false;
root = yyjson_doc_get_root(document);
capabilities = yyjson_obj_get(root, "capabilities");
valid = yyjson_is_obj(root) && yyjson_obj_size(root) == 4U &&
yyjson_equals_str(yyjson_obj_get(root, "format"), "trainlog-sync-peer") &&
yyjson_is_uint(yyjson_obj_get(root, "version")) &&
yyjson_get_uint(yyjson_obj_get(root, "version")) == 1U &&
yyjson_equals_str(yyjson_obj_get(root, "peer_id"), expected) &&
yyjson_is_arr(capabilities);
if (valid) {
yyjson_arr_foreach(capabilities, index, maximum, item) {
if (!yyjson_is_str(item)) { valid = false; break; }
if (yyjson_equals_str(item, "generation-manifest-v1")) manifest = true;
if (yyjson_equals_str(item, "generation-ack-v1")) acknowledgement = true;
}
}
yyjson_doc_free(document);
return valid && manifest && acknowledgement;
}
static TrainlogStatus select_peer(const TrainlogGenerationMtpIo *io, const char *expected,
const char *local_root, Selection *output, char *diagnostic, size_t diagnostic_size)
{
TrainlogUsbDevice devices[TRAINLOG_GENERATION_MTP_MAX_DEVICES];
TrainlogMtpStorage storages[TRAINLOG_GENERATION_MTP_MAX_STORAGES];
size_t device_count = 0U, storage_count, di, si, matches = 0U;
char peer_path[1024];
TrainlogStatus status;
if (snprintf(peer_path, sizeof(peer_path), "%s/.peer-candidate", local_root) < 0 ||
strlen(local_root) + strlen("/.peer-candidate") >= sizeof(peer_path)) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
status = io->devices(devices, TRAINLOG_GENERATION_MTP_MAX_DEVICES, &device_count);
if (status != TRAINLOG_STATUS_OK || device_count == 0U) {
diag(diagnostic, diagnostic_size, "no MTP device available"); return TRAINLOG_STATUS_NOT_FOUND;
}
for (di = 0U; di < device_count; ++di) {
status = io->storages(devices[di].bus_number, devices[di].device_number,
storages, TRAINLOG_GENERATION_MTP_MAX_STORAGES, &storage_count);
if (status != TRAINLOG_STATUS_OK) continue;
for (si = 0U; si < storage_count; ++si) {
Selection candidate = {devices[di].bus_number, devices[di].device_number,
storages[si].storage_id, 0U};
TrainlogMtpEntry documents, root, peer;
if (one_named(io, &candidate, UINT32_MAX, "Documents", true, &documents) != TRAINLOG_STATUS_OK ||
one_named(io, &candidate, documents.item_id, "Trainlog", true, &root) != TRAINLOG_STATUS_OK) continue;
candidate.root = root.item_id;
if (one_named(io, &candidate, candidate.root, "android-peer-v1.json", false, &peer) != TRAINLOG_STATUS_OK) continue;
(void)unlink(peer_path);
if (peer.size_bytes > 16384U || io->receive(candidate.bus, candidate.device,
peer.item_id, peer_path) != TRAINLOG_STATUS_OK ||
!peer_document_matches(peer_path, expected)) continue;
*output = candidate; ++matches;
}
}
(void)unlink(peer_path);
if (matches == 0U) { diag(diagnostic, diagnostic_size, "expected Android peer not found"); return TRAINLOG_STATUS_NOT_FOUND; }
if (matches > 1U) { diag(diagnostic, diagnostic_size, "ambiguous compatible Android peers"); return TRAINLOG_STATUS_CONFLICT; }
return TRAINLOG_STATUS_OK;
}
static TrainlogStatus mkdir_private(const char *path)
{
if (mkdir(path, 0700) == 0 || errno == EEXIST) return TRAINLOG_STATUS_OK;
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
static TrainlogStatus pull_folder(const TrainlogGenerationMtpIo *io, const Selection *selection,
uint32_t parent, const char *local, unsigned int depth, uint64_t *total, size_t *objects)
{
TrainlogMtpEntry entries[TRAINLOG_GENERATION_MTP_MAX_CHILDREN];
size_t count = 0U, index;
TrainlogStatus status;
if (depth > TRAINLOG_GENERATION_MTP_MAX_DEPTH) return TRAINLOG_STATUS_INVALID_ARGUMENT;
status = mkdir_private(local); if (status != TRAINLOG_STATUS_OK) return status;
status = children(io, selection, parent, entries, &count); if (status != TRAINLOG_STATUS_OK) return status;
if (*objects > TRAINLOG_GENERATION_MTP_MAX_CHILDREN - count) return TRAINLOG_STATUS_INVALID_ARGUMENT;
*objects += count;
for (index = 0U; index < count; ++index) {
char path[1024], temporary[1060]; int written;
written = snprintf(path, sizeof(path), "%s/%s", local, entries[index].name);
if (!safe_leaf(entries[index].name) || written < 0 || (size_t)written >= sizeof(path)) return TRAINLOG_STATUS_INVALID_ARGUMENT;
if (entries[index].folder) {
status = pull_folder(io, selection, entries[index].item_id, path, depth + 1U, total, objects);
} else {
struct stat value;
if (entries[index].size_bytes > TRAINLOG_GENERATION_MTP_MAX_TOTAL_BYTES ||
*total > TRAINLOG_GENERATION_MTP_MAX_TOTAL_BYTES - entries[index].size_bytes) return TRAINLOG_STATUS_INVALID_ARGUMENT;
*total += entries[index].size_bytes;
written = snprintf(temporary, sizeof(temporary), "%s.part.%ld", path, (long)getpid());
if (written < 0 || (size_t)written >= sizeof(temporary)) return TRAINLOG_STATUS_INVALID_ARGUMENT;
(void)unlink(temporary);
status = io->receive(selection->bus, selection->device, entries[index].item_id, temporary);
if (status == TRAINLOG_STATUS_OK && (stat(temporary, &value) != 0 || value.st_size < 0 ||
(uint64_t)value.st_size != entries[index].size_bytes || rename(temporary, path) != 0)) status = TRAINLOG_STATUS_SYSTEM_ERROR;
if (status != TRAINLOG_STATUS_OK) (void)unlink(temporary);
}
if (status != TRAINLOG_STATUS_OK) return status;
}
return TRAINLOG_STATUS_OK;
}
static bool same_files(const char *left, const char *right)
{
int a = open(left, O_RDONLY | O_CLOEXEC), b = open(right, O_RDONLY | O_CLOEXEC);
char ab[8192], bb[8192]; ssize_t ac = 0, bc; bool same = true;
if (a < 0 || b < 0) same = false;
while (same && (ac = read(a, ab, sizeof(ab))) > 0) {
bc = read(b, bb, (size_t)ac); if (bc != ac || memcmp(ab, bb, (size_t)ac) != 0) same = false;
}
if (same && (ac < 0 || read(b, bb, 1U) != 0)) same = false;
if (a >= 0) (void)close(a); if (b >= 0) (void)close(b); return same;
}
static TrainlogStatus push_file(const TrainlogGenerationMtpIo *io, const Selection *selection,
uint32_t parent, const char *local, const char *name)
{
TrainlogMtpEntry existing; TrainlogStatus status;
char comparison[1060], temporary_name[256]; uint32_t uploaded = 0U;
status = one_named(io, selection, parent, name, false, &existing);
if (status == TRAINLOG_STATUS_OK) {
(void)snprintf(comparison, sizeof(comparison), "%s.compare.%ld", local, (long)getpid());
(void)unlink(comparison);
status = io->receive(selection->bus, selection->device, existing.item_id, comparison);
if (status != TRAINLOG_STATUS_OK) return status;
if (same_files(local, comparison)) { (void)unlink(comparison); return TRAINLOG_STATUS_OK; }
(void)unlink(comparison);
(void)snprintf(temporary_name, sizeof(temporary_name), ".trainlog-%ld-%s", (long)getpid(), name);
status = io->send(selection->bus, selection->device, selection->storage, parent, local, temporary_name, &uploaded);
if (status != TRAINLOG_STATUS_OK) return status;
(void)snprintf(comparison, sizeof(comparison), "%s.verify.%ld", local, (long)getpid());
(void)unlink(comparison);
status = io->receive(selection->bus, selection->device, uploaded, comparison);
if (status != TRAINLOG_STATUS_OK || !same_files(local, comparison)) {
(void)unlink(comparison); (void)io->remove(selection->bus, selection->device, uploaded);
return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_CONFLICT : status;
}
(void)unlink(comparison);
status = io->remove(selection->bus, selection->device, existing.item_id);
if (status == TRAINLOG_STATUS_OK) status = io->rename(selection->bus, selection->device, uploaded, name);
return status;
}
if (status != TRAINLOG_STATUS_NOT_FOUND) return status;
status = io->send(selection->bus, selection->device, selection->storage, parent, local, name, &uploaded);
if (status != TRAINLOG_STATUS_OK) return status;
(void)snprintf(comparison, sizeof(comparison), "%s.verify.%ld", local, (long)getpid());
(void)unlink(comparison);
status = io->receive(selection->bus, selection->device, uploaded, comparison);
if (status != TRAINLOG_STATUS_OK || !same_files(local, comparison)) {
(void)unlink(comparison); (void)io->remove(selection->bus, selection->device, uploaded);
return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_CONFLICT : status;
}
(void)unlink(comparison);
return TRAINLOG_STATUS_OK;
}
static TrainlogStatus push_folder(const TrainlogGenerationMtpIo *io, const Selection *selection,
uint32_t parent, const char *local, unsigned int depth, bool manifests)
{
DIR *directory; struct dirent *entry; TrainlogStatus status = TRAINLOG_STATUS_OK;
if (depth > TRAINLOG_GENERATION_MTP_MAX_DEPTH || (directory = opendir(local)) == NULL) return TRAINLOG_STATUS_SYSTEM_ERROR;
while ((entry = readdir(directory)) != NULL && status == TRAINLOG_STATUS_OK) {
char path[1024]; struct stat value; uint32_t child; bool created;
if (!safe_leaf(entry->d_name) || entry->d_name[0] == '.') continue;
if ((strcmp(entry->d_name, "manifest.json") == 0) != manifests) continue;
int written = snprintf(path, sizeof(path), "%s/%s", local, entry->d_name);
if (written < 0 || (size_t)written >= sizeof(path) || lstat(path, &value) != 0 || S_ISLNK(value.st_mode)) { status = TRAINLOG_STATUS_INVALID_ARGUMENT; break; }
if (S_ISDIR(value.st_mode)) {
if (manifests) continue;
status = io->ensure_folder(selection->bus, selection->device, selection->storage, parent, entry->d_name, &child, &created);
if (status == TRAINLOG_STATUS_OK) status = push_folder(io, selection, child, path, depth + 1U, false);
if (status == TRAINLOG_STATUS_OK) status = push_folder(io, selection, child, path, depth + 1U, true);
} else if (S_ISREG(value.st_mode)) status = push_file(io, selection, parent, path, entry->d_name);
}
(void)closedir(directory); return status;
}
static TrainlogStatus validate_local_folder(const char *local, unsigned int depth,
uint64_t *total, size_t *objects)
{
DIR *directory;
struct dirent *entry;
TrainlogStatus status = TRAINLOG_STATUS_OK;
if (depth > TRAINLOG_GENERATION_MTP_MAX_DEPTH || (directory = opendir(local)) == NULL)
return TRAINLOG_STATUS_INVALID_ARGUMENT;
while ((entry = readdir(directory)) != NULL && status == TRAINLOG_STATUS_OK) {
char path[1024]; struct stat value; int written;
if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) continue;
if (!safe_leaf(entry->d_name) || entry->d_name[0] == '.') { status = TRAINLOG_STATUS_INVALID_ARGUMENT; break; }
written = snprintf(path, sizeof(path), "%s/%s", local, entry->d_name);
if (written < 0 || (size_t)written >= sizeof(path) || lstat(path, &value) != 0 ||
S_ISLNK(value.st_mode)) { status = TRAINLOG_STATUS_INVALID_ARGUMENT; break; }
if (*objects == TRAINLOG_GENERATION_MTP_MAX_CHILDREN) { status = TRAINLOG_STATUS_INVALID_ARGUMENT; break; }
++*objects;
if (S_ISDIR(value.st_mode)) status = validate_local_folder(path, depth + 1U, total, objects);
else if (S_ISREG(value.st_mode)) {
if (value.st_size < 0 || (uint64_t)value.st_size > TRAINLOG_GENERATION_MTP_MAX_TOTAL_BYTES - *total)
status = TRAINLOG_STATUS_INVALID_ARGUMENT;
else *total += (uint64_t)value.st_size;
} else status = TRAINLOG_STATUS_INVALID_ARGUMENT;
}
(void)closedir(directory);
return status;
}
TrainlogStatus trainlog_generation_mtp_pull(const TrainlogGenerationMtpIo *io,
const char *expected, const char *local, char *diagnostic, size_t diagnostic_size)
{
Selection selection; uint64_t total = 0U; size_t objects = 0U; TrainlogStatus status;
if (io == NULL || expected == NULL || local == NULL) return TRAINLOG_STATUS_INVALID_ARGUMENT;
status = mkdir_private(local); if (status == TRAINLOG_STATUS_OK) status = select_peer(io, expected, local, &selection, diagnostic, diagnostic_size);
if (status == TRAINLOG_STATUS_OK) status = pull_folder(io, &selection, selection.root, local, 0U, &total, &objects);
return status;
}
TrainlogStatus trainlog_generation_mtp_push(const TrainlogGenerationMtpIo *io,
const char *expected, const char *local, char *diagnostic, size_t diagnostic_size)
{
Selection selection; TrainlogStatus status; uint64_t total = 0U; size_t objects = 0U;
if (io == NULL || expected == NULL || local == NULL) return TRAINLOG_STATUS_INVALID_ARGUMENT;
status = validate_local_folder(local, 0U, &total, &objects);
if (status == TRAINLOG_STATUS_OK) status = select_peer(io, expected, local, &selection, diagnostic, diagnostic_size);
if (status == TRAINLOG_STATUS_OK) status = push_folder(io, &selection, selection.root, local, 0U, false);
if (status == TRAINLOG_STATUS_OK) status = push_folder(io, &selection, selection.root, local, 0U, true);
return status;
}
static const TrainlogGenerationMtpIo production = {
trainlog_usb_list_mtp_devices, trainlog_mtp_list_storages, trainlog_mtp_list_folder,
trainlog_mtp_ensure_folder, trainlog_mtp_receive_file, trainlog_mtp_send_text_file,
trainlog_mtp_delete_object, trainlog_mtp_rename_object
};
const TrainlogGenerationMtpIo *trainlog_generation_mtp_production_io(void) { return &production; }

View file

@ -613,6 +613,58 @@ TrainlogStatus trainlog_mtp_send_text_file(
return TRAINLOG_STATUS_OK;
}
TrainlogStatus trainlog_mtp_ensure_folder(
unsigned int bus_number,
unsigned int device_number,
uint32_t storage_id,
uint32_t parent_folder_id,
const char *folder_name,
uint32_t *output_folder_id,
bool *output_created
)
{
TrainlogMtpEntry entries[256];
size_t count = 0U;
size_t index;
size_t matches = 0U;
LIBMTP_mtpdevice_t *device = NULL;
TrainlogStatus status;
uint32_t created;
char mutable_name[TRAINLOG_MTP_ENTRY_NAME_MAX + 1U];
if (folder_name == NULL || folder_name[0] == '\0' ||
strlen(folder_name) > TRAINLOG_MTP_ENTRY_NAME_MAX ||
output_folder_id == NULL || output_created == NULL) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
*output_folder_id = 0U;
*output_created = false;
status = trainlog_mtp_list_folder(bus_number, device_number, storage_id,
parent_folder_id, entries, 256U, &count);
if (status != TRAINLOG_STATUS_OK) return status;
for (index = 0U; index < count; ++index) {
if (entries[index].folder && strcmp(entries[index].name, folder_name) == 0) {
*output_folder_id = entries[index].item_id;
++matches;
}
}
if (matches > 1U) return TRAINLOG_STATUS_CONFLICT;
if (matches == 1U) return TRAINLOG_STATUS_OK;
status = mtp_open_exact_device(bus_number, device_number, &device);
if (status != TRAINLOG_STATUS_OK) return status;
(void)snprintf(mutable_name, sizeof(mutable_name), "%s", folder_name);
created = LIBMTP_Create_Folder(device, mutable_name, parent_folder_id, storage_id);
if (created == 0U) {
LIBMTP_Clear_Errorstack(device);
LIBMTP_Release_Device(device);
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
LIBMTP_Release_Device(device);
*output_folder_id = created;
*output_created = true;
return TRAINLOG_STATUS_OK;
}
TrainlogStatus trainlog_mtp_list_folder(
unsigned int bus_number,
unsigned int device_number,
@ -830,3 +882,35 @@ TrainlogStatus trainlog_mtp_delete_object(
return TRAINLOG_STATUS_OK;
}
TrainlogStatus trainlog_mtp_rename_object(
unsigned int bus_number,
unsigned int device_number,
uint32_t item_id,
const char *new_name
)
{
LIBMTP_mtpdevice_t *device = NULL;
LIBMTP_file_t *metadata;
TrainlogStatus status;
int rc;
if (item_id == 0U || new_name == NULL || new_name[0] == '\0' ||
strlen(new_name) > TRAINLOG_MTP_ENTRY_NAME_MAX) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
status = mtp_open_exact_device(bus_number, device_number, &device);
if (status != TRAINLOG_STATUS_OK) return status;
metadata = LIBMTP_Get_Filemetadata(device, item_id);
if (metadata == NULL) {
LIBMTP_Clear_Errorstack(device); LIBMTP_Release_Device(device);
return TRAINLOG_STATUS_NOT_FOUND;
}
rc = LIBMTP_Set_File_Name(device, metadata, new_name);
LIBMTP_destroy_file_t(metadata);
if (rc != 0) {
LIBMTP_Clear_Errorstack(device); LIBMTP_Release_Device(device);
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
LIBMTP_Release_Device(device);
return TRAINLOG_STATUS_OK;
}

View file

@ -0,0 +1,24 @@
#include <dirent.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include "trainlog/generation_mtp.h"
typedef struct Node { uint32_t id,parent; bool folder; char path[1024],name[256]; uint64_t size; } Node;
static Node nodes[2048]; static size_t node_count; static const char *remote;
static uint32_t scan(const char *path,uint32_t parent){DIR *d;struct dirent *e;uint32_t self=(uint32_t)(node_count+1U);Node *n=&nodes[node_count++];struct stat st;lstat(path,&st);n->id=self;n->parent=parent;n->folder=S_ISDIR(st.st_mode);n->size=(uint64_t)st.st_size;snprintf(n->path,sizeof(n->path),"%s",path);const char *slash=strrchr(path,'/');snprintf(n->name,sizeof(n->name),"%s",slash?slash+1:path);if(n->folder&&(d=opendir(path))!=NULL){while((e=readdir(d)))if(strcmp(e->d_name,".")&&strcmp(e->d_name,"..")){char child[1024];snprintf(child,sizeof(child),"%s/%s",path,e->d_name);scan(child,self);}closedir(d);}return self;}
static void rebuild(void){node_count=0U;scan(remote,UINT32_MAX);}
static Node *node(uint32_t id){size_t i;for(i=0;i<node_count;i++)if(nodes[i].id==id)return &nodes[i];return NULL;}
static TrainlogStatus devices(TrainlogUsbDevice *o,size_t c,size_t *n){if(c<1U)return TRAINLOG_STATUS_INVALID_ARGUMENT;memset(o,0,sizeof(*o));o->bus_number=1;o->device_number=1;*n=1;return TRAINLOG_STATUS_OK;}
static TrainlogStatus storages(unsigned int b,unsigned int d,TrainlogMtpStorage *o,size_t c,size_t *n){(void)b;(void)d;if(c<1U)return TRAINLOG_STATUS_INVALID_ARGUMENT;memset(o,0,sizeof(*o));o->storage_id=1;*n=1;return TRAINLOG_STATUS_OK;}
static TrainlogStatus children(unsigned int b,unsigned int d,uint32_t s,uint32_t p,TrainlogMtpEntry *o,size_t c,size_t *n){size_t i,k=0;(void)b;(void)d;(void)s;rebuild();if(p==UINT32_MAX)p=nodes[0].id;for(i=0;i<node_count;i++)if(nodes[i].parent==p){if(k>=c)return TRAINLOG_STATUS_INVALID_ARGUMENT;memset(&o[k],0,sizeof(o[k]));o[k].item_id=nodes[i].id;o[k].parent_id=p;o[k].storage_id=1;o[k].folder=nodes[i].folder;o[k].size_bytes=nodes[i].size;snprintf(o[k].name,sizeof(o[k].name),"%s",nodes[i].name);k++;}*n=k;return TRAINLOG_STATUS_OK;}
static TrainlogStatus ensure(unsigned int b,unsigned int d,uint32_t s,uint32_t p,const char *name,uint32_t *id,bool *created){Node *parent;char path[1024];(void)b;(void)d;(void)s;rebuild();parent=node(p);if(!parent)return TRAINLOG_STATUS_NOT_FOUND;snprintf(path,sizeof(path),"%s/%s",parent->path,name);if(mkdir(path,0700)==0)*created=true;else if(access(path,F_OK)==0)*created=false;else return TRAINLOG_STATUS_SYSTEM_ERROR;rebuild();size_t i;for(i=0;i<node_count;i++)if(strcmp(nodes[i].path,path)==0){*id=nodes[i].id;return TRAINLOG_STATUS_OK;}return TRAINLOG_STATUS_SYSTEM_ERROR;}
static int copy(const char *a,const char *b){FILE *in=fopen(a,"rb"),*out=fopen(b,"wb");char buf[8192];size_t n;if(!in||!out)return -1;while((n=fread(buf,1,sizeof(buf),in))>0)if(fwrite(buf,1,n,out)!=n)return -1;return fclose(in)|fclose(out);}
static TrainlogStatus receive(unsigned int b,unsigned int d,uint32_t id,const char *path){Node *n;(void)b;(void)d;rebuild();n=node(id);return n&&copy(n->path,path)==0?TRAINLOG_STATUS_OK:TRAINLOG_STATUS_SYSTEM_ERROR;}
static TrainlogStatus send_file(unsigned int b,unsigned int d,uint32_t s,uint32_t p,const char *local,const char *name,uint32_t *id){Node *parent;char path[1024];(void)b;(void)d;(void)s;rebuild();parent=node(p);if(!parent)return TRAINLOG_STATUS_NOT_FOUND;snprintf(path,sizeof(path),"%s/%s",parent->path,name);if(copy(local,path)!=0)return TRAINLOG_STATUS_SYSTEM_ERROR;rebuild();size_t i;for(i=0;i<node_count;i++)if(strcmp(nodes[i].path,path)==0){*id=nodes[i].id;return TRAINLOG_STATUS_OK;}return TRAINLOG_STATUS_SYSTEM_ERROR;}
static TrainlogStatus remove_object(unsigned int b,unsigned int d,uint32_t id){Node *n;(void)b;(void)d;rebuild();n=node(id);return n&&unlink(n->path)==0?TRAINLOG_STATUS_OK:TRAINLOG_STATUS_SYSTEM_ERROR;}
static TrainlogStatus rename_object(unsigned int b,unsigned int d,uint32_t id,const char *name){Node *n;char path[1024],*slash;(void)b;(void)d;rebuild();n=node(id);if(!n)return TRAINLOG_STATUS_NOT_FOUND;snprintf(path,sizeof(path),"%s",n->path);slash=strrchr(path,'/');if(!slash)return TRAINLOG_STATUS_SYSTEM_ERROR;snprintf(slash+1,(size_t)(path+sizeof(path)-(slash+1)),"%s",name);return rename(n->path,path)==0?TRAINLOG_STATUS_OK:TRAINLOG_STATUS_SYSTEM_ERROR;}
static const TrainlogGenerationMtpIo io={devices,storages,children,ensure,receive,send_file,remove_object,rename_object};
int main(int argc,char **argv){char diag[1024]={0};TrainlogStatus status;remote=getenv("TRAINLOG_MTP_DOUBLE_ROOT");if(argc!=4||!remote)return 64;if(strcmp(argv[1],"pull")==0)status=trainlog_generation_mtp_pull(&io,argv[2],argv[3],diag,sizeof(diag));else if(strcmp(argv[1],"push")==0)status=trainlog_generation_mtp_push(&io,argv[2],argv[3],diag,sizeof(diag));else return 64;if(status!=TRAINLOG_STATUS_OK){fprintf(stderr,"double failed %d %s\n",status,diag);return 2;}return 0;}

View file

@ -0,0 +1,26 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include "trainlog/generation_mtp.h"
#define CHECK(x) do { if (!(x)) { fprintf(stderr,"CHECK %s:%d: %s\n",__FILE__,__LINE__,#x); return 1; } } while (0)
typedef struct Object { uint32_t id,parent; bool folder; char name[256]; char data[4096]; size_t size; } Object;
static Object objects[64]; static size_t object_count; static unsigned int device_count=1U; static char send_order[1024];
static void add(uint32_t id,uint32_t parent,bool folder,const char *name,const char *data){Object *o=&objects[object_count++];memset(o,0,sizeof(*o));o->id=id;o->parent=parent;o->folder=folder;snprintf(o->name,sizeof(o->name),"%s",name);if(data){o->size=strlen(data);memcpy(o->data,data,o->size);}}
static void reset(void){object_count=0U;device_count=1U;send_order[0]='\0';add(1,UINT32_MAX,true,"Documents",NULL);add(2,1,true,"Trainlog",NULL);add(3,2,false,"android-peer-v1.json","{\"format\":\"trainlog-sync-peer\",\"version\":1,\"peer_id\":\"peer_11111111-1111-4111-8111-111111111111\",\"capabilities\":[\"generation-manifest-v1\",\"generation-ack-v1\"]}");}
static TrainlogStatus devices(TrainlogUsbDevice *out,size_t cap,size_t *count){size_t i;if(cap<device_count)return TRAINLOG_STATUS_INVALID_ARGUMENT;*count=device_count;for(i=0;i<device_count;i++){memset(&out[i],0,sizeof(out[i]));out[i].bus_number=1U;out[i].device_number=(unsigned int)i+1U;}return TRAINLOG_STATUS_OK;}
static TrainlogStatus storages(unsigned int bus,unsigned int dev,TrainlogMtpStorage *out,size_t cap,size_t *count){(void)bus;if(cap<1U)return TRAINLOG_STATUS_INVALID_ARGUMENT;memset(out,0,sizeof(*out));out->storage_id=dev;*count=1U;return TRAINLOG_STATUS_OK;}
static TrainlogStatus children(unsigned int bus,unsigned int dev,uint32_t storage,uint32_t parent,TrainlogMtpEntry *out,size_t cap,size_t *count){size_t i,n=0;(void)bus;(void)storage;for(i=0;i<object_count;i++)if(objects[i].parent==parent && (dev==1U || objects[i].id<=3U)){if(n>=cap)return TRAINLOG_STATUS_INVALID_ARGUMENT;memset(&out[n],0,sizeof(out[n]));out[n].item_id=objects[i].id;out[n].parent_id=parent;out[n].storage_id=dev;out[n].folder=objects[i].folder;out[n].size_bytes=objects[i].size;snprintf(out[n].name,sizeof(out[n].name),"%s",objects[i].name);n++;}*count=n;return TRAINLOG_STATUS_OK;}
static TrainlogStatus ensure_folder(unsigned int b,unsigned int d,uint32_t s,uint32_t p,const char *name,uint32_t *id,bool *created){size_t i;(void)b;(void)d;(void)s;for(i=0;i<object_count;i++)if(objects[i].parent==p&&objects[i].folder&&strcmp(objects[i].name,name)==0){*id=objects[i].id;*created=false;return TRAINLOG_STATUS_OK;}*id=(uint32_t)(100U+object_count);*created=true;add(*id,p,true,name,NULL);return TRAINLOG_STATUS_OK;}
static Object *find(uint32_t id){size_t i;for(i=0;i<object_count;i++)if(objects[i].id==id)return &objects[i];return NULL;}
static TrainlogStatus receive(unsigned int b,unsigned int d,uint32_t id,const char *path){FILE *f;Object *o=find(id);size_t bytes;(void)b;(void)d;if(!o)return TRAINLOG_STATUS_NOT_FOUND;f=fopen(path,"wb");if(!f)return TRAINLOG_STATUS_SYSTEM_ERROR;bytes=id==9U?3U:o->size;if(fwrite(o->data,1,bytes,f)!=bytes){fclose(f);return TRAINLOG_STATUS_SYSTEM_ERROR;}return fclose(f)==0?TRAINLOG_STATUS_OK:TRAINLOG_STATUS_SYSTEM_ERROR;}
static TrainlogStatus send_file(unsigned int b,unsigned int d,uint32_t s,uint32_t p,const char *path,const char *name,uint32_t *id){FILE *f;Object *o;long size;(void)b;(void)d;(void)s;f=fopen(path,"rb");if(!f)return TRAINLOG_STATUS_SYSTEM_ERROR;fseek(f,0,SEEK_END);size=ftell(f);rewind(f);if(size<0||size>(long)sizeof(objects[0].data)){fclose(f);return TRAINLOG_STATUS_INVALID_ARGUMENT;}*id=(uint32_t)(100U+object_count);add(*id,p,false,name,NULL);o=&objects[object_count-1];o->size=(size_t)size;if(fread(o->data,1,o->size,f)!=o->size){fclose(f);return TRAINLOG_STATUS_SYSTEM_ERROR;}fclose(f);strncat(send_order,name,sizeof(send_order)-strlen(send_order)-2U);strncat(send_order,"\n",sizeof(send_order)-strlen(send_order)-1U);return TRAINLOG_STATUS_OK;}
static TrainlogStatus remove_object(unsigned int b,unsigned int d,uint32_t id){Object *o=find(id);(void)b;(void)d;if(!o)return TRAINLOG_STATUS_NOT_FOUND;o->name[0]='\0';return TRAINLOG_STATUS_OK;}
static TrainlogStatus rename_object(unsigned int b,unsigned int d,uint32_t id,const char *name){Object *o=find(id);(void)b;(void)d;if(!o)return TRAINLOG_STATUS_NOT_FOUND;snprintf(o->name,sizeof(o->name),"%s",name);return TRAINLOG_STATUS_OK;}
static const TrainlogGenerationMtpIo io={devices,storages,children,ensure_folder,receive,send_file,remove_object,rename_object};
static int write_file(const char *path,const char *value){FILE *f=fopen(path,"wb");CHECK(f!=NULL);CHECK(fwrite(value,1,strlen(value),f)==strlen(value));CHECK(fclose(f)==0);return 0;}
static int test_pull_and_manifest_last(void){char root[]="/tmp/trainlog-generation-mtp-XXXXXX",path[512],diag[256]={0};char *manifest;reset();CHECK(mkdtemp(root)!=NULL);add(4,2,true,"android-objects",NULL);add(5,4,true,"generations",NULL);add(6,5,true,"gen_11111111-1111-4111-8111-111111111111",NULL);add(7,6,false,"payload.json","payload");add(8,6,false,"manifest.json","manifest");CHECK(trainlog_generation_mtp_pull(&io,"peer_11111111-1111-4111-8111-111111111111",root,diag,sizeof(diag))==TRAINLOG_STATUS_OK);snprintf(path,sizeof(path),"%s/android-objects/generations/gen_11111111-1111-4111-8111-111111111111/payload.json",root);CHECK(access(path,R_OK)==0);snprintf(path,sizeof(path),"%s/out",root);CHECK(mkdir(path,0700)==0);char gen[512];snprintf(gen,sizeof(gen),"%s/generation",path);CHECK(mkdir(gen,0700)==0);char artifact[512];snprintf(artifact,sizeof(artifact),"%s/artifact.json",gen);CHECK(write_file(artifact,"a")==0);char marker[512];snprintf(marker,sizeof(marker),"%s/manifest.json",gen);CHECK(write_file(marker,"m")==0);CHECK(trainlog_generation_mtp_push(&io,"peer_11111111-1111-4111-8111-111111111111",root,diag,sizeof(diag))==TRAINLOG_STATUS_OK);manifest=strstr(send_order,"manifest.json\n");CHECK(manifest!=NULL);CHECK(strstr(send_order,"artifact.json\n")<manifest);return 0;}
static int test_ambiguity_and_truncation(void){char root[]="/tmp/trainlog-generation-mtp-error-XXXXXX",diag[256]={0},path[512];TrainlogStatus status;Object *peer;reset();CHECK(mkdtemp(root)!=NULL);device_count=2U;CHECK(trainlog_generation_mtp_pull(&io,"peer_11111111-1111-4111-8111-111111111111",root,diag,sizeof(diag))==TRAINLOG_STATUS_CONFLICT);reset();peer=find(3);CHECK(peer!=NULL);snprintf(peer->data,sizeof(peer->data),"not-json containing peer_11111111-1111-4111-8111-111111111111 generation-manifest-v1 generation-ack-v1");peer->size=strlen(peer->data);CHECK(trainlog_generation_mtp_pull(&io,"peer_11111111-1111-4111-8111-111111111111",root,diag,sizeof(diag))==TRAINLOG_STATUS_NOT_FOUND);reset();add(9,2,false,"bad.bin","abc");find(9)->size=99U;status=trainlog_generation_mtp_pull(&io,"peer_11111111-1111-4111-8111-111111111111",root,diag,sizeof(diag));CHECK(status!=TRAINLOG_STATUS_OK);snprintf(path,sizeof(path),"%s/bad.bin",root);CHECK(access(path,F_OK)!=0);return 0;}
int main(void){CHECK(test_pull_and_manifest_last()==0);CHECK(test_ambiguity_and_truncation()==0);return 0;}

View file

@ -0,0 +1,23 @@
#include <stdio.h>
#include <string.h>
#include "trainlog/generation_mtp.h"
int main(int argc, char **argv)
{
char diagnostic[1024] = {0}; TrainlogStatus status;
if (argc == 2 && strcmp(argv[1], "--help") == 0) {
(void)puts("usage: trainlog-generation-mtp-adapter pull|push EXPECTED_PEER LOCAL_ROOT");
return 0;
}
if (argc != 4) return 64;
if (strcmp(argv[1], "pull") == 0) status = trainlog_generation_mtp_pull(
trainlog_generation_mtp_production_io(), argv[2], argv[3], diagnostic, sizeof(diagnostic));
else if (strcmp(argv[1], "push") == 0) status = trainlog_generation_mtp_push(
trainlog_generation_mtp_production_io(), argv[2], argv[3], diagnostic, sizeof(diagnostic));
else return 64;
if (status != TRAINLOG_STATUS_OK) {
(void)fprintf(stderr, "MTP adapter failed status=%d diagnostic=%s\n", (int)status, diagnostic);
return 2;
}
return 0;
}