feat(sync): add generation MTP and Android backup
This commit is contained in:
parent
86b317c5bd
commit
34c6c0b369
34 changed files with 1180 additions and 34 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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++ }
|
||||
}}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
26
docs/design/sync_mtp_and_android_backup_v1_evidence.md
Normal file
26
docs/design/sync_mtp_and_android_backup_v1_evidence.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
37
tools/android_backup_bridge/BackupBridgeActivity.kt
Normal file
37
tools/android_backup_bridge/BackupBridgeActivity.kt
Normal 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() }
|
||||
}
|
||||
44
tools/android_backup_bridge/BackupBridgeServiceTest.kt
Normal file
44
tools/android_backup_bridge/BackupBridgeServiceTest.kt
Normal 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)}
|
||||
}
|
||||
}
|
||||
42
tools/build_android_backup_bridge.py
Normal file
42
tools/build_android_backup_bridge.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
35
tui/include/trainlog/generation_mtp.h
Normal file
35
tui/include/trainlog/generation_mtp.h
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
301
tui/src/generation_mtp.c
Normal 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; }
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
24
tui/tests/generation_mtp_adapter_double.c
Normal file
24
tui/tests/generation_mtp_adapter_double.c
Normal 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&©(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;}
|
||||
26
tui/tests/test_generation_mtp.c
Normal file
26
tui/tests/test_generation_mtp.c
Normal 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;}
|
||||
23
tui/tools/generation_mtp_adapter.c
Normal file
23
tui/tools/generation_mtp_adapter.c
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue