diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 9a42954..7e5baf2 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -6,8 +6,16 @@ user-visible directory, Documents/Trainlog, without a SAF provider. --> + + + + + android:foregroundServiceType="connectedDevice|dataSync" /> diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/AiExportDrivePublisher.kt b/android/app/src/main/java/com/labfytools/trainlog/data/AiExportDrivePublisher.kt new file mode 100644 index 0000000..e74710c --- /dev/null +++ b/android/app/src/main/java/com/labfytools/trainlog/data/AiExportDrivePublisher.kt @@ -0,0 +1,235 @@ +package com.labfytools.trainlog.data + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import java.io.File +import java.io.FileOutputStream +import java.security.MessageDigest +import java.util.concurrent.TimeUnit +import org.json.JSONObject + +internal const val AI_EXPORT_FILENAME = "trainlog_ai_export_v1.json" + +internal sealed interface AiExportDriveResult { + data object NotConfigured : AiExportDriveResult + data object Missing : AiExportDriveResult + data object Unchanged : AiExportDriveResult + data class Published(val sha256: String) : AiExportDriveResult + data class Error(val message: String) : AiExportDriveResult +} + +internal class AiExportDriveSettings(private val context: Context) { + private val preferences = + context.applicationContext.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) + + val treeUri: Uri? + get() = preferences.getString(TREE, null)?.let(Uri::parse) + + val connected: Boolean + get() = treeUri != null + + fun connect(uri: Uri) { + context.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, + ) + preferences.edit().putString(TREE, uri.toString()).apply() + schedule(context) + } + + fun disconnect() { + treeUri?.let { uri -> + runCatching { + context.contentResolver.releasePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, + ) + } + } + preferences.edit() + .remove(TREE) + .remove(LAST_SHA256) + .remove(LAST_SIZE) + .remove(LAST_MODIFIED) + .apply() + WorkManager.getInstance(context).cancelUniqueWork(PERIODIC_WORK) + WorkManager.getInstance(context).cancelUniqueWork(IMMEDIATE_WORK) + } + + fun alreadyPublished(source: File, sha256: String): Boolean = + preferences.getString(LAST_SHA256, null) == sha256 && + preferences.getLong(LAST_SIZE, -1L) == source.length() && + preferences.getLong(LAST_MODIFIED, -1L) == source.lastModified() + + fun needsPublication(source: File): Boolean = + connected && + source.isFile && + ( + preferences.getString(LAST_SHA256, null) == null || + preferences.getLong(LAST_SIZE, -1L) != source.length() || + preferences.getLong(LAST_MODIFIED, -1L) != source.lastModified() + ) + + fun recordPublished(source: File, sha256: String) { + preferences.edit() + .putString(LAST_SHA256, sha256) + .putLong(LAST_SIZE, source.length()) + .putLong(LAST_MODIFIED, source.lastModified()) + .apply() + } + + companion object { + private const val PREFERENCES = "trainlog-ai-export-drive" + private const val TREE = "tree_uri" + private const val LAST_SHA256 = "last_sha256" + private const val LAST_SIZE = "last_size" + private const val LAST_MODIFIED = "last_modified" + private const val PERIODIC_WORK = "trainlog-ai-export-drive-periodic-v1" + private const val IMMEDIATE_WORK = "trainlog-ai-export-drive-now-v1" + + fun sourceFile(): File = File(canonicalExchangeDirectory(), AI_EXPORT_FILENAME) + + fun schedule(context: Context) { + val constraints = + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + val periodic = + PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) + .setConstraints(constraints) + .build() + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + PERIODIC_WORK, + ExistingPeriodicWorkPolicy.UPDATE, + periodic, + ) + enqueueImmediate(context) + } + + fun enqueueImmediate(context: Context) { + val settings = AiExportDriveSettings(context) + val source = sourceFile() + if (!settings.needsPublication(source)) return + val request = + OneTimeWorkRequestBuilder() + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ) + .build() + WorkManager.getInstance(context).enqueueUniqueWork( + IMMEDIATE_WORK, + ExistingWorkPolicy.KEEP, + request, + ) + } + } +} + +internal class AiExportDrivePublisher(private val context: Context) { + private val settings = AiExportDriveSettings(context) + + private fun sha256(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + file.inputStream().use { input -> + val buffer = ByteArray(64 * 1024) + while (true) { + val count = input.read(buffer) + if (count < 0) break + digest.update(buffer, 0, count) + } + } + return digest.digest().joinToString("") { "%02x".format(it) } + } + + private fun validateSource(source: File) { + require(source.isFile && !java.nio.file.Files.isSymbolicLink(source.toPath())) { + "Export IA local indisponible." + } + require(source.length() in 1..64L * 1024L * 1024L) { + "Export IA local invalide." + } + val root = + source.inputStream().bufferedReader(Charsets.UTF_8).use { reader -> + JSONObject(reader.readText()) + } + require( + root.optString("format") == "TRAINLOG_AI_EXPORT" && + root.optInt("version", -1) == 1 + ) { "Format d’export IA invalide." } + } + + fun publishPending(): AiExportDriveResult { + val uri = settings.treeUri ?: return AiExportDriveResult.NotConfigured + val source = AiExportDriveSettings.sourceFile() + if (!source.isFile) return AiExportDriveResult.Missing + return try { + validateSource(source) + val sourceSha = sha256(source) + if (settings.alreadyPublished(source, sourceSha)) { + return AiExportDriveResult.Unchanged + } + val root = + DocumentFile.fromTreeUri(context, uri) + ?: return AiExportDriveResult.Error("Dossier Drive IA indisponible.") + require(root.isDirectory) { "Dossier Drive IA invalide." } + val destination = + root.findFile(AI_EXPORT_FILENAME) + ?: root.createFile("application/json", AI_EXPORT_FILENAME) + ?: throw IllegalStateException("Impossible de créer l’export IA sur Drive.") + require(destination.isFile) { "Destination Drive IA invalide." } + + context.contentResolver.openOutputStream(destination.uri, "wt")?.use { output -> + source.inputStream().use { input -> input.copyTo(output, 64 * 1024) } + output.flush() + } ?: throw IllegalStateException("Impossible d’écrire l’export IA sur Drive.") + + val verification = + File.createTempFile("trainlog-ai-drive-verify-", ".json", context.cacheDir) + try { + context.contentResolver.openInputStream(destination.uri)?.use { input -> + FileOutputStream(verification).use { output -> + input.copyTo(output, 64 * 1024) + output.fd.sync() + } + } ?: throw IllegalStateException("Impossible de relire l’export IA Drive.") + require( + verification.length() == source.length() && + sha256(verification) == sourceSha + ) { "Vérification de l’export IA Drive échouée." } + } finally { + verification.delete() + } + settings.recordPublished(source, sourceSha) + AiExportDriveResult.Published(sourceSha) + } catch (error: Exception) { + AiExportDriveResult.Error(error.message ?: error.javaClass.simpleName) + } + } +} + +class AiExportDriveWorker( + context: Context, + parameters: WorkerParameters, +) : CoroutineWorker(context, parameters) { + override suspend fun doWork(): Result = + when (AiExportDrivePublisher(applicationContext).publishPending()) { + AiExportDriveResult.NotConfigured, + AiExportDriveResult.Missing, + AiExportDriveResult.Unchanged, + is AiExportDriveResult.Published -> Result.success() + is AiExportDriveResult.Error -> Result.retry() + } +} diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/BluetoothSyncTransport.kt b/android/app/src/main/java/com/labfytools/trainlog/data/BluetoothSyncTransport.kt new file mode 100644 index 0000000..04cbd81 --- /dev/null +++ b/android/app/src/main/java/com/labfytools/trainlog/data/BluetoothSyncTransport.kt @@ -0,0 +1,497 @@ +package com.labfytools.trainlog.data + +import android.Manifest +import android.annotation.SuppressLint +import android.bluetooth.BluetoothClass +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothSocket +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.content.ContextCompat +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.DataInputStream +import java.io.DataOutputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.security.MessageDigest +import java.time.Duration +import java.util.UUID +import java.util.concurrent.atomic.AtomicBoolean +import java.util.zip.ZipEntry +import java.util.zip.ZipFile +import java.util.zip.ZipOutputStream +import org.json.JSONObject + +internal const val TRAINLOG_BLUETOOTH_SERVICE_UUID = + "f0d1c0de-7a11-4f62-9b7c-545241494e4c" +internal const val TRAINLOG_BLUETOOTH_FILE_PROTOCOL = "trainlog-bt-files-v1" +private const val BLUETOOTH_FRAME_FORMAT = "trainlog-bt-frame" +private const val BLUETOOTH_FRAME_VERSION = 1 +private const val MAX_BT_HEADER_BYTES = 64 * 1024 +private const val MAX_BT_PAYLOAD_BYTES = 256L * 1024L * 1024L +private const val MAX_BT_FILE_BYTES = 64L * 1024L * 1024L +private const val MAX_BT_FILES = 128 +private const val AUTO_REQUEST_COOLDOWN_MS = 5L * 60L * 1000L + +internal data class BondedBluetoothDesktop( + val name: String, + val address: String, +) + +internal class BluetoothSyncSettings(context: Context) { + private val preferences = + context.applicationContext.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) + + val desktopAddress: String? + get() = preferences.getString(DESKTOP_ADDRESS, null) + + val configured: Boolean + get() = desktopAddress != null + + fun selectDesktop(address: String) { + require(BLUETOOTH_ADDRESS.matches(address.uppercase())) { + "Adresse Bluetooth invalide." + } + preferences.edit().putString(DESKTOP_ADDRESS, address.uppercase()).apply() + } + + fun disconnect() { + preferences.edit().remove(DESKTOP_ADDRESS).apply() + } + + fun shouldPublishAutomaticRequest(nowMillis: Long): Boolean { + val previous = preferences.getLong(LAST_AUTO_REQUEST, 0L) + return previous <= 0L || nowMillis - previous >= AUTO_REQUEST_COOLDOWN_MS + } + + fun recordAutomaticRequest(nowMillis: Long) { + preferences.edit().putLong(LAST_AUTO_REQUEST, nowMillis).apply() + } + + companion object { + private const val PREFERENCES = "trainlog-bluetooth-sync" + private const val DESKTOP_ADDRESS = "desktop_address" + private const val LAST_AUTO_REQUEST = "last_auto_request_ms" + private val BLUETOOTH_ADDRESS = + Regex("^[0-9A-F]{2}(:[0-9A-F]{2}){5}$") + } +} + +internal fun hasBluetoothConnectPermission(context: Context): Boolean = + Build.VERSION.SDK_INT < Build.VERSION_CODES.S || + ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == + PackageManager.PERMISSION_GRANTED + +@SuppressLint("MissingPermission") +internal fun bondedBluetoothDesktops(context: Context): List { + if (!hasBluetoothConnectPermission(context)) return emptyList() + val adapter = + context.getSystemService(BluetoothManager::class.java)?.adapter + ?: return emptyList() + return adapter.bondedDevices + .asSequence() + .filter { device -> + device.bluetoothClass?.majorDeviceClass == BluetoothClass.Device.Major.COMPUTER + } + .map { device -> + BondedBluetoothDesktop( + name = device.name?.takeIf(String::isNotBlank) ?: device.address, + address = device.address.uppercase(), + ) + } + .sortedWith(compareBy { it.name }.thenBy { it.address }) + .toList() +} + +private fun sha256(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + FileInputStream(file).use { input -> + val buffer = ByteArray(64 * 1024) + while (true) { + val count = input.read(buffer) + if (count < 0) break + digest.update(buffer, 0, count) + } + } + return digest.digest().joinToString("") { "%02x".format(it) } +} + +private data class BluetoothFrame( + val header: JSONObject, + val payload: File?, +) : AutoCloseable { + override fun close() { + payload?.delete() + } +} + +private class BluetoothFrameCodec(private val cacheDirectory: File) { + fun read(input: DataInputStream): BluetoothFrame { + val headerSize = input.readInt() + if (headerSize !in 1..MAX_BT_HEADER_BYTES) + throw IllegalStateException("En-tête Bluetooth invalide.") + val rawHeader = ByteArray(headerSize) + input.readFully(rawHeader) + val header = JSONObject(rawHeader.toString(Charsets.UTF_8)) + require( + header.optString("format") == BLUETOOTH_FRAME_FORMAT && + header.optInt("version", -1) == BLUETOOTH_FRAME_VERSION + ) { "Trame Bluetooth non supportée." } + val payloadSize = + if (header.has("payload_size")) header.getLong("payload_size") else 0L + require(payloadSize in 0..MAX_BT_PAYLOAD_BYTES) { + "Charge utile Bluetooth invalide." + } + if (payloadSize == 0L) { + require(!header.has("payload_sha256") || header.optString("payload_sha256").isEmpty()) + return BluetoothFrame(header, null) + } + val expected = header.getString("payload_sha256") + require(expected.matches(Regex("^[0-9a-f]{64}$"))) { + "Empreinte Bluetooth invalide." + } + val temporary = + File.createTempFile("trainlog-bt-frame-", ".bin", cacheDirectory) + try { + val digest = MessageDigest.getInstance("SHA-256") + FileOutputStream(temporary).use { output -> + var remaining = payloadSize + val buffer = ByteArray(64 * 1024) + while (remaining > 0L) { + val wanted = minOf(buffer.size.toLong(), remaining).toInt() + val count = input.read(buffer, 0, wanted) + if (count < 0) throw IllegalStateException("Connexion Bluetooth interrompue.") + output.write(buffer, 0, count) + digest.update(buffer, 0, count) + remaining -= count.toLong() + } + output.fd.sync() + } + val actual = digest.digest().joinToString("") { "%02x".format(it) } + require(actual == expected) { "Empreinte Bluetooth incorrecte." } + return BluetoothFrame(header, temporary) + } catch (error: Exception) { + temporary.delete() + throw error + } + } + + fun write(output: DataOutputStream, header: JSONObject, payload: File? = null) { + val payloadSize = payload?.length() ?: 0L + require(payloadSize in 0..MAX_BT_PAYLOAD_BYTES) + val value = + JSONObject(header.toString()) + .put("format", BLUETOOTH_FRAME_FORMAT) + .put("version", BLUETOOTH_FRAME_VERSION) + .put("payload_size", payloadSize) + if (payload != null) { + require(payload.isFile) + value.put("payload_sha256", sha256(payload)) + } + val raw = value.toString().toByteArray(Charsets.UTF_8) + require(raw.size in 1..MAX_BT_HEADER_BYTES) + output.writeInt(raw.size) + output.write(raw) + if (payload != null) { + FileInputStream(payload).use { input -> input.copyTo(output, 64 * 1024) } + } + output.flush() + } +} + +private class BluetoothExchangeArchive( + private val context: Context, +) { + private val exchangeDirectory = canonicalExchangeDirectory() + + private fun safeRelative(relative: String): List { + require(relative.isNotBlank() && '\\' !in relative) + val parts = relative.split('/') + require(parts.size in 1..6 && parts.all { it.isNotBlank() && it != "." && it != ".." }) + return parts + } + + private fun addFile( + archive: ZipOutputStream, + file: File, + relative: String, + seen: MutableSet, + totals: LongArray, + ) { + if (!file.isFile || Files.isSymbolicLink(file.toPath())) return + safeRelative(relative) + require(seen.add(relative)) { "Chemin Bluetooth dupliqué." } + val size = file.length() + require(size in 0..MAX_BT_FILE_BYTES) { "Fichier Bluetooth trop volumineux." } + totals[0] += 1 + totals[1] = Math.addExact(totals[1], size) + require(totals[0] <= MAX_BT_FILES && totals[1] <= MAX_BT_PAYLOAD_BYTES) { + "Archive Bluetooth trop volumineuse." + } + archive.putNextEntry(ZipEntry(relative).also { it.size = size }) + FileInputStream(file).use { input -> input.copyTo(archive, 64 * 1024) } + archive.closeEntry() + } + + fun buildAndroidPullArchive(): File { + require(exchangeDirectory.isDirectory) { "Dossier d'échange Trainlog indisponible." } + val output = File.createTempFile("trainlog-bt-pull-", ".zip", context.cacheDir) + val seen = linkedSetOf() + val totals = longArrayOf(0L, 0L) + try { + ZipOutputStream(BufferedOutputStream(FileOutputStream(output))).use { zip -> + listOf( + "android-peer-v1.json", + FULL_GENERATION_REQUEST_NAME, + LEGACY_SYNC_REQUEST_NAME, + "android-generation-v1.json", + "android-consumption-ack-v1.json", + "android-generation-error-v1.json", + ).forEach { name -> + addFile(zip, File(exchangeDirectory, name), name, seen, totals) + } + val reference = File(exchangeDirectory, "android-generation-v1.json") + if (reference.isFile && reference.length() <= MAX_BT_HEADER_BYTES) { + val value = JSONObject(reference.readText()) + val relative = value.optString("relative_path") + val generationId = value.optString("generation_id") + val expected = "android-objects/generations/$generationId" + require(relative == expected) { "Référence de génération Android invalide." } + val directory = File(exchangeDirectory, relative) + require(directory.isDirectory && !Files.isSymbolicLink(directory.toPath())) + directory.listFiles().orEmpty().sortedBy(File::getName).forEach { file -> + addFile(zip, file, "$relative/${file.name}", seen, totals) + } + } + } + require(output.length() <= MAX_BT_PAYLOAD_BYTES) + return output + } catch (error: Exception) { + output.delete() + throw error + } + } + + fun applyDesktopPushArchive(archiveFile: File) { + require(archiveFile.isFile && archiveFile.length() in 1..MAX_BT_PAYLOAD_BYTES) + if (!exchangeDirectory.exists()) require(exchangeDirectory.mkdirs()) + require(exchangeDirectory.isDirectory) + val root = exchangeDirectory.canonicalFile + val seen = linkedSetOf() + val published = mutableListOf() + var count = 0L + var total = 0L + ZipFile(archiveFile).use { archive -> + val entries = archive.entries() + while (entries.hasMoreElements()) { + val entry = entries.nextElement() + if (entry.isDirectory) continue + val parts = safeRelative(entry.name) + require(seen.add(entry.name)) { "Chemin Bluetooth dupliqué." } + require(entry.size in 0..MAX_BT_FILE_BYTES) { "Fichier Bluetooth trop volumineux." } + count += 1 + total = Math.addExact(total, entry.size) + require(count <= MAX_BT_FILES && total <= MAX_BT_PAYLOAD_BYTES) + val destination = + parts.fold(root) { parent, name -> File(parent, name) } + val parent = requireNotNull(destination.parentFile) + require(parent.mkdirs() || parent.isDirectory) + require(parent.canonicalFile.toPath().startsWith(root.toPath())) { + "Chemin Bluetooth hors du dossier Trainlog." + } + val temporary = + File(parent, ".${destination.name}.bt-${UUID.randomUUID()}.tmp") + try { + archive.getInputStream(entry).use { input -> + FileOutputStream(temporary).use { output -> + var copied = 0L + val buffer = ByteArray(64 * 1024) + while (true) { + val read = input.read(buffer) + if (read < 0) break + copied += read.toLong() + require(copied <= entry.size) + output.write(buffer, 0, read) + } + require(copied == entry.size) + output.fd.sync() + } + } + try { + Files.move( + temporary.toPath(), + destination.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move( + temporary.toPath(), + destination.toPath(), + StandardCopyOption.REPLACE_EXISTING, + ) + } + published += destination + } catch (error: Exception) { + temporary.delete() + throw error + } + } + } + AndroidMtpPublicationVisibility(context).confirm(published) + if (AI_EXPORT_FILENAME in seen) { + AiExportDriveSettings.enqueueImmediate(context) + } + } +} + +internal class BluetoothSyncClient( + private val context: Context, + private val repository: TrainlogRepository, +) { + @Volatile private var activeSocket: BluetoothSocket? = null + private val settings = BluetoothSyncSettings(context) + private val codec = BluetoothFrameCodec(context.cacheDir) + private val archive = BluetoothExchangeArchive(context) + + @SuppressLint("MissingPermission") + fun run(stopped: AtomicBoolean) { + while (!stopped.get()) { + if (!settings.configured || !hasBluetoothConnectPermission(context)) { + sleep(stopped, Duration.ofSeconds(10)) + continue + } + val adapter = + context.getSystemService(BluetoothManager::class.java)?.adapter + if (adapter == null || !adapter.isEnabled) { + sleep(stopped, Duration.ofSeconds(10)) + continue + } + val address = settings.desktopAddress ?: continue + val socket = + runCatching { + adapter + .getRemoteDevice(address) + .createRfcommSocketToServiceRecord(UUID.fromString(TRAINLOG_BLUETOOTH_SERVICE_UUID)) + }.getOrNull() + if (socket == null) { + sleep(stopped, Duration.ofSeconds(10)) + continue + } + activeSocket = socket + try { + socket.connect() + serve(socket, stopped) + } catch (_: Exception) { + // Arrival polling is intentionally silent. A foreground manual + // synchronization still exposes its own correlated diagnostic. + } finally { + activeSocket = null + runCatching { socket.close() } + } + if (!stopped.get()) sleep(stopped, Duration.ofSeconds(5)) + } + } + + fun shutdown() { + runCatching { activeSocket?.close() } + activeSocket = null + } + + private fun sleep(stopped: AtomicBoolean, duration: Duration) { + val deadline = System.nanoTime() + duration.toNanos() + while (!stopped.get() && System.nanoTime() < deadline) { + try { + Thread.sleep(250) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + return + } + } + } + + private fun serve(socket: BluetoothSocket, stopped: AtomicBoolean) { + val input = DataInputStream(BufferedInputStream(socket.inputStream)) + val output = DataOutputStream(BufferedOutputStream(socket.outputStream)) + val peer = SyncGenerationService(repository).peerId() + codec.write( + output, + JSONObject() + .put("type", "hello") + .put("protocol", TRAINLOG_BLUETOOTH_FILE_PROTOCOL) + .put("peer_id", peer), + ) + codec.read(input).use { response -> + require( + response.payload == null && + response.header.optString("type") == "hello_ack" && + response.header.optString("protocol") == TRAINLOG_BLUETOOTH_FILE_PROTOCOL && + response.header.optString("peer_id") == peer + ) { "Accusé Bluetooth Trainlog invalide." } + } + + publishArrivalRequest() + + while (!stopped.get()) { + codec.read(input).use { frame -> + val type = frame.header.optString("type") + val requestId = frame.header.optString("request_id") + require(requestId.startsWith("bt_")) { "Requête Bluetooth non corrélée." } + when (type) { + "pull" -> { + require(frame.payload == null) + val payload = archive.buildAndroidPullArchive() + try { + codec.write( + output, + JSONObject() + .put("type", "result") + .put("request_id", requestId) + .put("result", "ok"), + payload, + ) + } finally { + payload.delete() + } + } + "push" -> { + val payload = requireNotNull(frame.payload) { + "Archive Bluetooth desktop absente." + } + archive.applyDesktopPushArchive(payload) + codec.write( + output, + JSONObject() + .put("type", "result") + .put("request_id", requestId) + .put("result", "ok"), + ) + } + else -> throw IllegalStateException("Opération Bluetooth non supportée.") + } + } + } + } + + private fun publishArrivalRequest() { + if (!BackgroundSyncSettings(context).enabled) return + val now = System.currentTimeMillis() + if (!settings.shouldPublishAutomaticRequest(now)) return + val directory = canonicalExchangeDirectory() + if (!directory.isDirectory) return + val coordinator = + SyncGenerationCoordinator(repository, AndroidMtpPublicationVisibility(context)) + coordinator.publishPeer(directory) + when (SyncRequestOutbox(context).requestFullGeneration()) { + is SyncRequestResult.Requested, + is SyncRequestResult.Completed -> settings.recordAutomaticRequest(now) + else -> Unit + } + } +} diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SyncBackgroundService.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SyncBackgroundService.kt index 6d0a73b..8fdd59a 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/SyncBackgroundService.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncBackgroundService.kt @@ -32,37 +32,29 @@ internal class BackgroundSyncSettings(context: Context) { } /** - * User-enabled foreground listener for PC-initiated USB generation requests. + * User-enabled foreground listener for automatic Bluetooth and MTP synchronization. * - * WHY: a Web request must be consumed while Compose is not visible, but modern - * Android does not permit an invisible permanent background loop. - * CONTRACT: the service shows a mandatory notification, invokes the same - * generation coordinator as SyncScreen, and never starts an exercise. - * INVARIANT: filesystem visibility only triggers protocol validation; stable + * WHY: the phone must reconnect to the paired desktop while Compose is not + * visible, but modern Android does not permit an invisible permanent loop. + * CONTRACT: the service shows a mandatory notification, owns the RFCOMM client, + * invokes the same generation coordinator as SyncScreen, and never starts an + * exercise. MTP remains a filesystem fallback. + * INVARIANT: transport visibility only triggers protocol validation; stable * run/generation/ACK identities remain the sole business authority. */ class SyncBackgroundService : Service() { private val stopped = AtomicBoolean(false) private val launched = AtomicBoolean(false) - private val executor = Executors.newSingleThreadExecutor() + private val executor = Executors.newFixedThreadPool(2) private var repository: TrainlogRepository? = null + private var bluetoothClient: BluetoothSyncClient? = null override fun onCreate() { super.onCreate() createChannel() - val notification = NotificationCompat.Builder(this, CHANNEL) - .setSmallIcon(R.mipmap.ic_launcher) - .setContentTitle(getString(R.string.background_sync_notification_title)) - .setContentText(getString(R.string.background_sync_notification_text)) - .setOngoing(true) - .setOnlyAlertOnce(true) - .build() - if (Build.VERSION.SDK_INT >= 29) { - startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) - } else { - startForeground(NOTIFICATION_ID, notification) - } + promoteForeground() repository = TrainlogRepository(applicationContext) + bluetoothClient = BluetoothSyncClient(applicationContext, requireNotNull(repository)) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -70,7 +62,11 @@ class SyncBackgroundService : Service() { stopSelf() return START_NOT_STICKY } + promoteForeground() if (!launched.compareAndSet(false, true)) return START_NOT_STICKY + executor.execute { + bluetoothClient?.run(stopped) + } executor.execute { val coordinator = SyncGenerationCoordinator( requireNotNull(repository), @@ -116,6 +112,7 @@ class SyncBackgroundService : Service() { is ForegroundGenerationResult.Cancelled -> Unit } } + AiExportDriveSettings.enqueueImmediate(applicationContext) if (!stopped.get()) runCatching { Thread.sleep(5_000) } } stopSelf() @@ -125,6 +122,8 @@ class SyncBackgroundService : Service() { override fun onDestroy() { stopped.set(true) + bluetoothClient?.shutdown() + bluetoothClient = null executor.shutdownNow() repository?.close() repository = null @@ -141,6 +140,28 @@ class SyncBackgroundService : Service() { override fun onBind(intent: Intent?): IBinder? = null + private fun promoteForeground() { + val notification = + NotificationCompat.Builder(this, CHANNEL) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(getString(R.string.background_sync_notification_title)) + .setContentText(getString(R.string.background_sync_notification_text)) + .setOngoing(true) + .setOnlyAlertOnce(true) + .build() + if (Build.VERSION.SDK_INT >= 29) { + val serviceType = + if (hasBluetoothConnectPermission(this)) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE + } else { + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + } + startForeground(NOTIFICATION_ID, notification, serviceType) + } else { + startForeground(NOTIFICATION_ID, notification) + } + } + private fun createChannel() { val manager = getSystemService(NotificationManager::class.java) manager.createNotificationChannel( diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SyncRequestOutbox.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SyncRequestOutbox.kt index 9edac49..516376d 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/SyncRequestOutbox.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncRequestOutbox.kt @@ -69,6 +69,16 @@ class SyncRequestOutbox private constructor( } } + internal fun requestFullGeneration(): SyncRequestResult { + return when (val opened = publisher.snapshot()) { + is DirectExchangeSnapshotResult.Ready -> { + val intent = newIntent() + publishFullGeneration(opened.snapshot, intent) + } + is DirectExchangeSnapshotResult.Error -> SyncRequestResult.Error(opened.message) + } + } + internal fun requestSync(snapshot: DirectExchangeSnapshot): SyncRequestResult { if ( Build.VERSION.SDK_INT < diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SyncScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SyncScreen.kt index 4ed4792..e6eb213 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SyncScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SyncScreen.kt @@ -35,7 +35,10 @@ import com.labfytools.trainlog.data.SyncConversationIntent import com.labfytools.trainlog.data.SyncGenerationForegroundCoordinator import com.labfytools.trainlog.data.BackgroundSyncSettings import com.labfytools.trainlog.data.SyncBackgroundService -import com.labfytools.trainlog.data.DriveSyncSettings +import com.labfytools.trainlog.data.AiExportDriveSettings +import com.labfytools.trainlog.data.BluetoothSyncSettings +import com.labfytools.trainlog.data.bondedBluetoothDesktops +import com.labfytools.trainlog.data.hasBluetoothConnectPermission import com.labfytools.trainlog.data.canonicalExchangeDirectory import com.labfytools.trainlog.data.AndroidMtpPublicationVisibility import com.labfytools.trainlog.data.MtpPublicationVisibility @@ -240,8 +243,20 @@ fun SyncScreen( var syncRunning by remember { mutableStateOf(false) } val backgroundSettings = remember { BackgroundSyncSettings(context) } var backgroundEnabled by remember { mutableStateOf(backgroundSettings.enabled) } - val driveSettings = remember { DriveSyncSettings(context) } + val driveSettings = remember { AiExportDriveSettings(context) } var driveConnected by remember { mutableStateOf(driveSettings.connected) } + val bluetoothSettings = remember { BluetoothSyncSettings(context) } + var bluetoothPermission by remember { + mutableStateOf(hasBluetoothConnectPermission(context)) + } + var bluetoothDesktop by remember { + mutableStateOf(bluetoothSettings.desktopAddress) + } + var bondedDesktops by remember { + mutableStateOf( + if (bluetoothPermission) bondedBluetoothDesktops(context) else emptyList() + ) + } val driveFolderLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.OpenDocumentTree(), @@ -256,6 +271,23 @@ fun SyncScreen( } } + val bluetoothPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { granted -> + bluetoothPermission = granted || Build.VERSION.SDK_INT < Build.VERSION_CODES.S + bondedDesktops = + if (bluetoothPermission) bondedBluetoothDesktops(context) else emptyList() + if (bluetoothPermission && backgroundSettings.enabled) { + SyncBackgroundService.start(context) + } + status = + if (bluetoothPermission) { + strings.getString(R.string.bluetooth_sync_permission_granted) + } else { + strings.getString(R.string.bluetooth_sync_permission_required) + } + } + val notificationPermissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission(), ) { granted -> @@ -573,6 +605,69 @@ fun SyncScreen( ) } + TrainlogFrame(title = strings.getString(R.string.bluetooth_sync_title)) { + TrainlogInfo( + text = strings.getString(R.string.bluetooth_sync_description), + color = + if (bluetoothDesktop != null && bluetoothPermission) colors.success + else colors.warning, + ) + if (!bluetoothPermission) { + TrainlogAction( + label = strings.getString(R.string.bluetooth_sync_permission), + description = strings.getString(R.string.bluetooth_sync_permission_required), + accent = colors.success, + onClick = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + bluetoothPermissionLauncher.launch(Manifest.permission.BLUETOOTH_CONNECT) + } else { + bluetoothPermission = true + bondedDesktops = bondedBluetoothDesktops(context) + } + }, + ) + } else { + if (bondedDesktops.isEmpty()) { + TrainlogInfo( + text = strings.getString(R.string.bluetooth_sync_no_desktop), + color = colors.warning, + ) + } + bondedDesktops.forEach { desktop -> + TrainlogAction( + label = strings.getString(R.string.bluetooth_sync_select, desktop.name), + description = strings.getString(R.string.bluetooth_sync_address, desktop.address), + accent = + if (bluetoothDesktop == desktop.address) colors.success else colors.accent, + onClick = { + bluetoothSettings.selectDesktop(desktop.address) + bluetoothDesktop = desktop.address + status = + strings.getString(R.string.bluetooth_sync_selected, desktop.name) + if (backgroundSettings.enabled) { + SyncBackgroundService.start(context) + } + }, + ) + } + if (bluetoothDesktop != null) { + TrainlogAction( + label = strings.getString(R.string.bluetooth_sync_disconnect), + description = + strings.getString( + R.string.bluetooth_sync_address, + bluetoothDesktop ?: "", + ), + accent = colors.warning, + onClick = { + bluetoothSettings.disconnect() + bluetoothDesktop = null + }, + ) + } + } + } + TrainlogFrame(title = strings.getString(R.string.background_sync_title)) { TrainlogInfo( text = strings.getString(R.string.background_sync_description), diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 1a11e82..e93ac73 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -129,13 +129,23 @@ Trainlog est prêt à synchroniser Le service attend une demande du PC. Autorisez les notifications pour activer la synchronisation en arrière-plan. - Google Drive privé - Sélectionnez le dossier privé Trainlog/Sync/v1. Trainlog n’accède qu’à ce dossier et n’y copie jamais SQLite. - Connecter Google Drive - Déconnecter Google Drive - La vérification en arrière-plan est bornée par Android et peut prendre au moins 15 minutes. - Dossier Drive connecté. Une vérification est planifiée. - Impossible de conserver l’accès au dossier Drive. + Bluetooth automatique + Trainlog se connecte automatiquement au PC appairé dès qu’il est à portée. MTP reste disponible en secours. + Autoriser l’accès Bluetooth + Autorisation Bluetooth requise pour la connexion automatique. + Bluetooth autorisé. Sélectionnez le PC appairé. + Aucun ordinateur appairé détecté. + Utiliser %1$s + PC Bluetooth sélectionné : %1$s. + Oublier le PC Bluetooth + %1$s + Export IA Google Drive + Sélectionnez une fois le dossier Trainlog/AI. Après chaque synchronisation, Android y publie automatiquement trainlog_ai_export_v1.json. + Choisir Trainlog/AI + Déconnecter l’export IA Drive + L’échec de Drive ne bloque jamais la synchronisation. Android conserve l’export local et réessaie automatiquement. + Dossier Trainlog/AI connecté. L’export sera publié automatiquement. + Impossible de conserver l’accès au dossier Trainlog/AI. Android → PC puis PC → Android, en une seule opération. Accès fichiers requis. Autorisez d’abord Documents/Trainlog. Demande envoyée · attente du PC… diff --git a/docs/design/sync_bluetooth_transport_v1.md b/docs/design/sync_bluetooth_transport_v1.md new file mode 100644 index 0000000..f3cf445 --- /dev/null +++ b/docs/design/sync_bluetooth_transport_v1.md @@ -0,0 +1,150 @@ +# Bluetooth synchronization transport V1 + +## Status + +`TRAINLOG_SYNC_BLUETOOTH_TRANSPORT_V1=SOFTWARE_VALIDATED_DEVICE_TEST_PENDING` + +This transport is additive. It does not change generation manifests, artifact +formats, causal deletion, revision ancestry, acknowledgement semantics, SQLite +schemas, or the frozen Trainlog session format. + +## Product behavior + +Normal synchronization is automatic when the authorized Android phone and +desktop are within Bluetooth range. + +1. Android's already user-enabled sync foreground service maintains a bonded + Bluetooth Classic RFCOMM connection to the selected desktop. +2. The desktop advertises one Trainlog-specific RFCOMM profile. +3. On a fresh Bluetooth connection Android publishes one bounded + full-generation request. Reconnect noise is rate-limited. +4. The existing full-generation orchestrator performs the same bidirectional + generation/ACK conversation through the Bluetooth byte transport. +5. If Bluetooth is unavailable, the existing MTP transport remains the wired + fallback. +6. Google Drive is not a synchronization transport in the target configuration. + After convergence, the desktop AI export is transferred to Android and + Android publishes it to the user-selected `Trainlog/AI` Drive folder. + Drive publication failure never rolls back a completed sync. + +The manual Web and Android Synchroniser actions remain as recovery/forced-sync +controls. + +## Trust and pairing + +Bluetooth device names are presentation only and are never authority. + +Desktop trusted configuration pins: + +- Android sync `peer_id`; +- bonded Android Bluetooth device address; +- Trainlog RFCOMM service UUID. + +Android persists the selected bonded desktop address after an explicit one-time +selection. The RFCOMM service uses normal bonded-device authentication. + +Current private deployment values are installation configuration, not protocol +constants. + +## RFCOMM service + +Trainlog service UUID: + +`f0d1c0de-7a11-4f62-9b7c-545241494e4c` + +The desktop registers an `org.bluez.Profile1` server with BlueZ. Android is +the RFCOMM client. This direction lets Android resolve the advertised service +through SDP without hidden Android channel APIs. + +The desktop Bluetooth agent is persistent and owns the BlueZ profile. Local +sync workers never talk to BlueZ directly; a bounded local adapter talks to the +agent over a mode-0600 Unix socket. + +## Bluetooth byte envelope + +Each Bluetooth frame is: + +- 4-byte unsigned big-endian JSON header length; +- UTF-8 JSON header (maximum 64 KiB); +- optional payload whose length and SHA-256 are declared in the header. + +Payload limit is 256 MiB and individual exchange files remain subject to their +existing generation bounds. A receiver validates length and SHA-256 before +making received bytes visible. + +Initial frame: + +```json +{ + "format": "trainlog-bt-frame", + "version": 1, + "type": "hello", + "peer_id": "peer_", + "protocol": "trainlog-bt-files-v1" +} +``` + +The local adapter exposes only `pull` and `push`, matching the existing MTP +adapter boundary. Transfers use a bounded ZIP archive containing relative +exchange paths. Extraction rejects absolute paths, `..`, symbolic links, +duplicates and excess file/byte counts. Each destination is replaced from a +same-directory temporary after complete bytes are durable. + +## Pull ownership + +Android pull snapshots include only Android-owned/current coordination objects: + +- `android-peer-v1.json`; +- `trainlog-sync-full-generation-request-v1.json`; +- `trainlog-sync-request-v1.json`; +- `android-generation-v1.json`; +- `android-consumption-ack-v1.json`; +- `android-generation-error-v1.json`; +- the immutable generation referenced by `android-generation-v1.json`. + +Desktop-owned coordination files are never treated as Android source +authority. + +## Push ownership + +Desktop workers already construct phase-specific disposable outboxes. Bluetooth +push accepts exactly that bounded outbox and atomically publishes those files +under Android's canonical `Documents/Trainlog` directory. + +## Concurrency + +Bluetooth and MTP have independent physical transport locks. The common +business `sync.lock` still serializes synchronization conversations. + +A background discovery probe must never own `sync.lock`. A Web-triggered +conversation must never fail merely because an idle transport probe is in +progress. + +## Automatic arrival trigger + +A successful RFCOMM connection is an arrival event. Android publishes at most +one automatic request per connection and persists a short reconnect cooldown. +The same connection is not a repeating timer; no connection means no automatic +request. + +A later optimization may compare explicit dirty revisions before generating a +request, but V1 correctness does not depend on such an optimization. + +## MTP fallback + +MTP remains supported and keeps its current fail-closed semantics. Automatic +desktop selection is: + +`Bluetooth -> MTP -> device_unavailable` + +Drive is never an implicit fallback in the target configuration. + +## AI export publication + +The desktop remains the canonical producer of +`trainlog_ai_export_v1.json`. After a completed bidirectional sync it sends +the exact export bytes to Android. Android publishes/replaces the export in the +persisted SAF tree selected for `Trainlog/AI`. + +The export has its own pending/success state. A Drive provider error is not a +sync error and is retried later by Android. diff --git a/docs/sync_exchange.md b/docs/sync_exchange.md index 1ac300f..883f1e6 100644 --- a/docs/sync_exchange.md +++ b/docs/sync_exchange.md @@ -27,10 +27,44 @@ offset-bearing timestamps, appreciations, notes and logical deletion. Exact revision replay is unchanged; sibling revisions and resurrection after deletion are rejected in the generation consumption transaction. -This does not change `TRAINLOG_FORMAT_V1`, manifests, ACKs, MTP or Drive -transport ownership. +This does not change `TRAINLOG_FORMAT_V1`, manifests, ACKs or causal +ownership. -## USB-priority and private Drive transport +## Bluetooth-priority transport and Android AI publication + +The private 0.1.4 installation now adds Bluetooth Classic RFCOMM as the primary +byte transport without changing the full-generation business protocol. The +desktop registers the Trainlog-specific service UUID +`f0d1c0de-7a11-4f62-9b7c-545241494e4c` through BlueZ and accepts only the +configured bonded Android device address. Android connects as the RFCOMM +client, then proves the already persisted Trainlog `peer_id` in the first +bounded frame. Bluetooth name strings are never authority. + +Automatic transport selection is `Bluetooth -> MTP -> unavailable` for the +current target configuration. MTP remains the wired recovery transport. The +business `sync.lock` remains common while Bluetooth and MTP have independent +physical transport locks, so an idle discovery probe cannot reject a Web +synchronization. + +Entering Bluetooth range establishes one connection and publishes one bounded +Android full-generation request. Reconnect noise is rate-limited; a persistent +connection is not a timer that creates generations repeatedly. The existing +background generation coordinator still owns capture, validation, causal +consumption and durable ACKs. + +After both generation directions are durably acknowledged, desktop generates +the canonical `trainlog_ai_export_v1.json` with the existing read-only AI +exporter and best-effort delivers that file to Android over the selected local +transport. Android stores it in `Documents/Trainlog`, then a separate SAF +publisher replaces the same file in the user-selected `Trainlog/AI` Google +Drive folder and verifies the provider bytes by size and SHA-256. Drive +publication failure is retryable and never rolls back or fails an already +completed synchronization. Desktop rclone is not part of this target path. + +The detailed framing, trust and fallback contract is +[Bluetooth synchronization transport V1](design/sync_bluetooth_transport_v1.md). + +## Legacy USB-priority and private Drive transport Full-generation synchronization has one business engine and two byte transports. Automatic desktop admission acquires the existing global lock, diff --git a/tests/test_sync_bluetooth_transport.py b/tests/test_sync_bluetooth_transport.py new file mode 100644 index 0000000..fe659e4 --- /dev/null +++ b/tests/test_sync_bluetooth_transport.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Regression coverage for the bounded Trainlog Bluetooth byte transport.""" + +from __future__ import annotations + +import importlib.util +import io +import json +import os +import socket +import tempfile +import threading +import unittest +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def load(name: str, path: Path): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +adapter = load( + "trainlog_generation_bt_adapter", + ROOT / "tools/trainlog_generation_bt_adapter.py", +) +agent = load("trainlog_bt_agent", ROOT / "tools/trainlog_bt_agent.py") + + +class BluetoothTransportTest(unittest.TestCase): + def test_archive_roundtrip_preserves_bounded_relative_files(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "source" + destination = root / "destination" + (source / "android-objects/generations/gen_test").mkdir(parents=True) + (source / "android-peer-v1.json").write_text('{"peer":"ok"}') + artifact = source / "android-objects/generations/gen_test/artifact.json" + artifact.write_text('{"value":1}') + raw = adapter.build_archive(source) + adapter.extract_archive(raw, destination) + self.assertEqual('{"peer":"ok"}', (destination / "android-peer-v1.json").read_text()) + self.assertEqual('{"value":1}', (destination / artifact.relative_to(source)).read_text()) + + def test_archive_rejects_parent_traversal(self): + payload = io.BytesIO() + with zipfile.ZipFile(payload, "w") as archive: + archive.writestr("../escape.json", "{}") + with tempfile.TemporaryDirectory() as directory: + with self.assertRaises(adapter.AdapterError): + adapter.extract_archive(payload.getvalue(), Path(directory)) + + def test_agent_hello_and_pull_are_peer_correlated(self): + peer = "peer_11111111-1111-4111-8111-111111111111" + state = agent.ConnectionState("AA:BB:CC:DD:EE:FF", peer) + desktop, android = socket.socketpair() + payload = b"bounded-archive" + observed = [] + + def phone(): + try: + agent.send_frame( + android, + { + "type": "hello", + "protocol": agent.FILE_PROTOCOL, + "peer_id": peer, + }, + ) + hello, hello_payload = agent.recv_frame(android) + observed.append((hello, hello_payload)) + request, request_payload = agent.recv_frame(android) + observed.append((request, request_payload)) + agent.send_frame( + android, + { + "type": "result", + "request_id": request["request_id"], + "result": "ok", + }, + payload, + ) + finally: + android.close() + + thread = threading.Thread(target=phone) + thread.start() + state.install(desktop) + header, response = state.transact("pull", peer, b"") + thread.join(timeout=5) + self.assertFalse(thread.is_alive()) + self.assertEqual("ok", header["result"]) + self.assertEqual(payload, response) + self.assertEqual("hello_ack", observed[0][0]["type"]) + self.assertEqual(b"", observed[0][1]) + self.assertEqual("pull", observed[1][0]["type"]) + self.assertEqual(b"", observed[1][1]) + state.close() + + def test_config_pins_expected_bluetooth_address(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "config.json" + path.write_text( + json.dumps( + { + "format": "trainlog-sync-orchestrator-config", + "version": 3, + "enabled": True, + "mode": "auto", + "expected_peer_id": "peer_11111111-1111-4111-8111-111111111111", + "transport_root": "/tmp/transport", + "owned_root": "/tmp/owned", + "timeout_seconds": 30, + "bluetooth_enabled": True, + "bluetooth_device_address": "aa:bb:cc:dd:ee:ff", + } + ) + ) + value = agent.load_config(path) + self.assertEqual("AA:BB:CC:DD:EE:FF", value["bluetooth_device_address"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sync_deployment_tools.py b/tests/test_sync_deployment_tools.py index bc27370..1643762 100644 --- a/tests/test_sync_deployment_tools.py +++ b/tests/test_sync_deployment_tools.py @@ -72,10 +72,17 @@ class DeploymentToolsTest(unittest.TestCase): self.assertIn("programs-v1", inventory["protocols"]) self.assertIn("program-executions-v1", inventory["protocols"]) self.assertIn("sleep-diary-v1", inventory["protocols"]) + self.assertIn("bluetooth-files-v1", inventory["protocols"]) self.assertTrue((output / "bin/trainlog").is_file()) self.assertTrue((output / "bin/trainlog-sync-once").is_file()) self.assertTrue((output / "bin/trainlog-syncd").is_file()) + self.assertTrue((output / "bin/trainlog-btd").is_file()) self.assertTrue((output / "libexec/trainlog-sync-once").is_file()) + self.assertTrue((output / "libexec/trainlog-generation-bt-adapter").is_file()) + self.assertIn( + 'TRAINLOG_SYNC_BT_ADAPTER="$root/libexec/trainlog-generation-bt-adapter"', + (output / "bin/trainlog-syncd").read_text(), + ) self.assertIn( 'TRAINLOG_SYNC_MTP_ADAPTER="$root/libexec/trainlog-generation-mtp-adapter"', (output / "bin/trainlog-syncd").read_text(), diff --git a/tests/test_sync_error_classification.py b/tests/test_sync_error_classification.py index 0381275..e42dcc9 100644 --- a/tests/test_sync_error_classification.py +++ b/tests/test_sync_error_classification.py @@ -207,7 +207,7 @@ class SyncErrorClassificationTest(unittest.TestCase): (root / name).write_text("{}") observed = [] - def inspect(_adapter, _operation, _peer, outbox, _deadline): + def inspect(_adapter, _operation, _peer, outbox, _deadline, **_kwargs): observed.append( sorted( str(path.relative_to(outbox)) diff --git a/tests/test_sync_orchestrator.py b/tests/test_sync_orchestrator.py index 6c9b2f5..ecc849e 100644 --- a/tests/test_sync_orchestrator.py +++ b/tests/test_sync_orchestrator.py @@ -134,6 +134,56 @@ class OrchestratorTest(unittest.TestCase): os.kill(pid, 0) self.assertEqual(json.loads(state.read_text())["phase"], "interrupted") + def test_auto_transport_prefers_bluetooth_before_mtp(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + sandbox = root / "program" + sandbox.mkdir() + (sandbox / "sync_orchestrator.py").write_bytes(ORCHESTRATOR.read_bytes()) + peer, desktop = identity("peer_"), identity("peer_") + inbound, outbound = identity("gen_"), identity("gen_") + report = { + "format": "trainlog-sync-worker-report", "version": 1, + "run_id": "RUN", "producer_peer_id": peer, "consumer_peer_id": desktop, + "inbound_generation_id": inbound, "outbound_generation_id": outbound, + "manifest_sha256": "a" * 64, "result": "completed", + "sessions_reconciled": 0, "domains": {}, "drafts": [], + "ai_midpoint": {"result": "not_configured"}, + "ai_post_sync": {"result": "delivered_to_android"}, + } + (sandbox / "sync_peer_worker.py").write_text( + "import json,sys\n" + "assert sys.argv[sys.argv.index('--mode')+1]=='bt'\n" + "assert '--bt-adapter' in sys.argv\n" + "v=" + repr(report) + + "\nv['run_id']=sys.argv[sys.argv.index('--run-id')+1]\nprint(json.dumps(v))\n" + ) + bt_marker = root / "bt-probe" + bt_adapter = sandbox / "trainlog_generation_bt_adapter.py" + bt_adapter.write_text(f"#!/bin/sh\necho probe >> {bt_marker}\nexit 0\n") + bt_adapter.chmod(0o755) + mtp_marker = root / "mtp-probe" + mtp_adapter = root / "build/tui/trainlog-generation-mtp-adapter" + mtp_adapter.parent.mkdir(parents=True) + mtp_adapter.write_text(f"#!/bin/sh\necho probe >> {mtp_marker}\nexit 0\n") + mtp_adapter.chmod(0o755) + values = self.fixture( + root, + version=3, + mode="auto", + bluetooth_enabled=True, + bluetooth_device_address="AA:BB:CC:DD:EE:FF", + ) + result = self.invoke(sandbox / "sync_orchestrator.py", values) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue(bt_marker.is_file()) + self.assertFalse(mtp_marker.exists()) + final = json.loads(values[1].read_text()) + self.assertEqual("completed", final["phase"]) + self.assertEqual("bt", final["transport"]) + self.assertEqual("success", final["bluetooth_state"]) + self.assertEqual("unavailable", final["usb_state"]) + def test_auto_transport_waits_for_mtp_lock_instead_of_falling_back_to_drive(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) diff --git a/tests/test_sync_peer_worker.py b/tests/test_sync_peer_worker.py index c328796..bcbc4fd 100644 --- a/tests/test_sync_peer_worker.py +++ b/tests/test_sync_peer_worker.py @@ -1,3 +1,4 @@ +import argparse import importlib.util import subprocess import tempfile @@ -141,6 +142,91 @@ class SyncPeerWorkerTest(unittest.TestCase): ) self.assertEqual("sy_fresh", value["run_id"]) + def test_bluetooth_adapter_uses_distinct_transport_lock(self): + completed = subprocess.CompletedProcess([], 0, b"", b"") + with mock.patch.object(worker.subprocess, "run", return_value=completed): + worker.run_adapter( + Path("/fixed/bt-adapter"), + "pull", + "peer_fixed", + self.transport, + 100.0, + clock=lambda: 50.0, + transport_name="Bluetooth", + lock_name="bt.lock", + ) + self.assertTrue((self.transport.parent / "bt.lock").is_file()) + + def test_ai_export_delivery_is_best_effort_and_uses_bluetooth(self): + database = Path(self.temporary.name) / "trainlog.db" + database.touch() + args = argparse.Namespace( + database=database, + transport_root=self.transport, + mode="bt", + bt_adapter=Path("/fixed/bt-adapter"), + mtp_adapter=None, + expected_peer="peer_fixed", + ) + + def execute(command, **kwargs): + if command[0] == worker.sys.executable: + output = Path(command[2]) + output.write_text( + '{"format":"TRAINLOG_AI_EXPORT","version":1}\n', + encoding="utf-8", + ) + return subprocess.CompletedProcess( + command, + 0, + "TRAINLOG_AI_EXPORT_V1=PASS\n", + "", + ) + self.assertEqual("/fixed/bt-adapter", command[0]) + self.assertEqual("push", command[1]) + return subprocess.CompletedProcess(command, 0, b"", b"") + + with mock.patch.object(worker.subprocess, "run", side_effect=execute): + result = worker.publish_ai_export_to_android(args, time.monotonic() + 30) + self.assertEqual({"result": "delivered_to_android"}, result) + self.assertTrue((self.transport / "trainlog_ai_export_v1.json").is_file()) + + def test_ai_export_transport_failure_does_not_raise(self): + database = Path(self.temporary.name) / "trainlog.db" + database.touch() + args = argparse.Namespace( + database=database, + transport_root=self.transport, + mode="bt", + bt_adapter=Path("/fixed/bt-adapter"), + mtp_adapter=None, + expected_peer="peer_fixed", + ) + + def execute(command, **kwargs): + if command[0] == worker.sys.executable: + Path(command[2]).write_text( + '{"format":"TRAINLOG_AI_EXPORT","version":1}\n', + encoding="utf-8", + ) + return subprocess.CompletedProcess( + command, + 0, + "TRAINLOG_AI_EXPORT_V1=PASS\n", + "", + ) + return subprocess.CompletedProcess( + command, + 2, + b"", + b"Bluetooth adapter failed status=6 diagnostic=peer vanished", + ) + + with mock.patch.object(worker.subprocess, "run", side_effect=execute): + result = worker.publish_ai_export_to_android(args, time.monotonic() + 30) + self.assertEqual("pending", result["result"]) + self.assertIn("peer vanished", result["diagnostic"]) + def test_missing_generation_reports_correlated_protocol_timeout(self): with tempfile.TemporaryDirectory() as directory: reference = Path(directory) / "android-generation-v1.json" diff --git a/tools/install_syncd_user.sh b/tools/install_syncd_user.sh index 13e5c57..64e0064 100755 --- a/tools/install_syncd_user.sh +++ b/tools/install_syncd_user.sh @@ -1,6 +1,5 @@ #!/usr/bin/env bash -# Trainlog install syncd user helper. -# Owns this command-line workflow while preserving the versioned database and exchange contracts it consumes. +# Install the private Trainlog automatic synchronization user services. set -euo pipefail ROOT="$( @@ -10,41 +9,50 @@ ROOT="$( SYNC_ONCE="$ROOT/build/tui/trainlog-sync-once" MTP_ADAPTER="$ROOT/build/tui/trainlog-generation-mtp-adapter" +BT_ADAPTER="$ROOT/tools/trainlog_generation_bt_adapter.py" +BT_AGENT="$ROOT/tools/trainlog_bt_agent.py" DAEMON="$ROOT/tools/trainlog_syncd.py" +GEN_CONFIG="${TRAINLOG_SYNC_GENERATION_CONFIG:-$HOME/.config/trainlog/sync-generation-config-v1.json}" -if [[ ! -x "$SYNC_ONCE" ]]; then - echo "missing executable: $SYNC_ONCE" >&2 - echo "run meson compile -C build first" >&2 - exit 1 -fi +for executable in "$SYNC_ONCE" "$MTP_ADAPTER" "$BT_ADAPTER" "$BT_AGENT" "$DAEMON"; do + if [[ ! -x "$executable" ]]; then + echo "missing executable: $executable" >&2 + echo "run meson compile -C build first and keep Trainlog tools executable" >&2 + exit 1 + fi +done -if [[ ! -x "$MTP_ADAPTER" ]]; then - echo "missing executable: $MTP_ADAPTER" >&2 - echo "run meson compile -C build first" >&2 - exit 1 -fi +mkdir -p "$HOME/.local/bin" "$HOME/.config/systemd/user" -mkdir -p \ - "$HOME/.local/bin" \ - "$HOME/.config/systemd/user" +ln -sfn "$SYNC_ONCE" "$HOME/.local/bin/trainlog-sync-once" +ln -sfn "$DAEMON" "$HOME/.local/bin/trainlog-syncd" +ln -sfn "$BT_AGENT" "$HOME/.local/bin/trainlog-btd" -ln -sfn \ - "$SYNC_ONCE" \ - "$HOME/.local/bin/trainlog-sync-once" - -ln -sfn \ - "$DAEMON" \ - "$HOME/.local/bin/trainlog-syncd" - -UNIT="$HOME/.config/systemd/user/trainlog-syncd.service" - -cat > "$UNIT" < "$HOME/.config/systemd/user/trainlog-syncd.service" < "$HOME/.config/systemd/user/trainlog-btd.service" < int: shutil.copy2(binary, args.output / "libexec/trainlog") shutil.copy2(sync_once, args.output / "libexec/trainlog-sync-once") shutil.copy2(mtp_adapter, args.output / "libexec/trainlog-generation-mtp-adapter") + shutil.copy2( + ROOT / "tools/trainlog_generation_bt_adapter.py", + args.output / "libexec/trainlog-generation-bt-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\nscript=$(readlink -f -- "$0")\nroot=$(CDPATH= cd -- "$(dirname -- "$script")/.." && pwd)\nexport TRAINLOG_SYNC_TOOLS_DIR="$root/tools"\nexport TRAINLOG_SYNC_MTP_ADAPTER="$root/libexec/trainlog-generation-mtp-adapter"\nexec "$root/libexec/trainlog" "$@"\n' + '#!/bin/sh\nset -eu\nscript=$(readlink -f -- "$0")\nroot=$(CDPATH= cd -- "$(dirname -- "$script")/.." && pwd)\nexport TRAINLOG_SYNC_TOOLS_DIR="$root/tools"\nexport TRAINLOG_SYNC_BT_ADAPTER="$root/libexec/trainlog-generation-bt-adapter"\nexport TRAINLOG_SYNC_MTP_ADAPTER="$root/libexec/trainlog-generation-mtp-adapter"\nexec "$root/libexec/trainlog" "$@"\n' ) sync_once_launcher = args.output / "bin/trainlog-sync-once" sync_once_launcher.write_text( @@ -95,7 +100,11 @@ def main() -> int: ) syncd_launcher = args.output / "bin/trainlog-syncd" syncd_launcher.write_text( - '#!/bin/sh\nset -eu\nscript=$(readlink -f -- "$0")\nroot=$(CDPATH= cd -- "$(dirname -- "$script")/.." && pwd)\nexport TRAINLOG_SYNC_MTP_ADAPTER="$root/libexec/trainlog-generation-mtp-adapter"\nexec python3 "$root/tools/trainlog_syncd.py" --sync-once "$root/bin/trainlog-sync-once" "$@"\n' + '#!/bin/sh\nset -eu\nscript=$(readlink -f -- "$0")\nroot=$(CDPATH= cd -- "$(dirname -- "$script")/.." && pwd)\nexport TRAINLOG_SYNC_BT_ADAPTER="$root/libexec/trainlog-generation-bt-adapter"\nexport TRAINLOG_SYNC_MTP_ADAPTER="$root/libexec/trainlog-generation-mtp-adapter"\nexec python3 "$root/tools/trainlog_syncd.py" --sync-once "$root/bin/trainlog-sync-once" "$@"\n' + ) + btd_launcher = args.output / "bin/trainlog-btd" + btd_launcher.write_text( + '#!/bin/sh\nset -eu\nscript=$(readlink -f -- "$0")\nroot=$(CDPATH= cd -- "$(dirname -- "$script")/.." && pwd)\nexec python3 "$root/tools/trainlog_bt_agent.py" "$@"\n' ) files = sorted(path for path in args.output.rglob("*") if path.is_file()) inventory = { @@ -115,6 +124,7 @@ def main() -> int: "generation-manifest-v1", "generation-ack-v1", "generation-archive-v1", + "bluetooth-files-v1", "session-preparations-v2", "ai-session-drafts-v2", "programs-v1", @@ -125,6 +135,8 @@ def main() -> int: "bin/trainlog", "bin/trainlog-sync-once", "bin/trainlog-syncd", + "bin/trainlog-btd", + "libexec/trainlog-generation-bt-adapter", "libexec/trainlog-generation-mtp-adapter", "tools/sync_orchestrator.py", ], @@ -135,6 +147,9 @@ def main() -> int: "libuuid", "libudev", "libmtp", + "bluez", + "python-dbus", + "python-gobject", "notcurses", "coreutils-readlink", ], @@ -153,8 +168,10 @@ def main() -> int: os.chmod(args.output / "bin/trainlog", 0o755) os.chmod(args.output / "bin/trainlog-sync-once", 0o755) os.chmod(args.output / "bin/trainlog-syncd", 0o755) + os.chmod(args.output / "bin/trainlog-btd", 0o755) os.chmod(args.output / "libexec/trainlog", 0o755) os.chmod(args.output / "libexec/trainlog-sync-once", 0o755) + os.chmod(args.output / "libexec/trainlog-generation-bt-adapter", 0o755) os.chmod(args.output / "libexec/trainlog-generation-mtp-adapter", 0o755) return 0 diff --git a/tools/sync_orchestrator.py b/tools/sync_orchestrator.py index d89b29a..165ad52 100644 --- a/tools/sync_orchestrator.py +++ b/tools/sync_orchestrator.py @@ -183,10 +183,59 @@ def validate_result(value: dict, run_id: str) -> dict: return value +def bluetooth_adapter_path() -> Path: + configured = os.environ.get("TRAINLOG_SYNC_BT_ADAPTER") + if configured: + return Path(configured) + return Path(__file__).with_name("trainlog_generation_bt_adapter.py") + + def drive_adapter_path() -> Path: return Path(__file__).with_name("sync_drive_transport.py") +def probe_local_adapter( + adapter: Path, + expected_peer: str, + transport_root: Path, + timeout: int, + lock_name: str, +) -> bool: + if not adapter.is_absolute() or not os.access(adapter, os.X_OK): + return False + lock_path = transport_root.parent / lock_name + lock_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + lock_fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600) + probe_deadline = time.monotonic() + min(timeout, 30) + try: + while time.monotonic() < probe_deadline: + try: + fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + break + except BlockingIOError: + time.sleep(0.05) + else: + return False + remaining = max(0.01, probe_deadline - time.monotonic()) + try: + probe = subprocess.run( + [str(adapter), "pull", expected_peer, str(transport_root)], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=remaining, + check=False, + ) + except (OSError, subprocess.TimeoutExpired): + return False + return probe.returncode == 0 + finally: + try: + fcntl.flock(lock_fd, fcntl.LOCK_UN) + finally: + os.close(lock_fd) + + def mirror_current_exchange(transport_root: Path, remote: str, timeout: int) -> None: """Mirror only current protocol objects; never traverse application data.""" coordination = ( @@ -339,22 +388,45 @@ def run(args: argparse.Namespace) -> int: "timeout_seconds", } drive_keys = {"drive_enabled", "drive_remote"} - if set(config) not in (base_keys, base_keys | drive_keys): + bluetooth_keys = {"bluetooth_enabled", "bluetooth_device_address"} + allowed_key_sets = ( + base_keys, + base_keys | drive_keys, + base_keys | bluetooth_keys, + base_keys | drive_keys | bluetooth_keys, + ) + if set(config) not in allowed_key_sets: raise RuntimeError("invalid trusted sync configuration") if ( config["format"] != "trainlog-sync-orchestrator-config" - or config["version"] not in (1, 2) + or config["version"] not in (1, 2, 3) or config["enabled"] is not True ): raise RuntimeError("full-generation synchronization is disabled") - if config["mode"] not in ("directory", "mtp", "drive", "auto"): + if config["mode"] not in ("directory", "bt", "mtp", "drive", "auto"): raise RuntimeError("invalid transport mode") drive_enabled = config.get("drive_enabled", False) drive_remote = config.get("drive_remote", "") - if type(drive_enabled) is not bool or (drive_enabled and (not isinstance(drive_remote, str) or not drive_remote)): + if type(drive_enabled) is not bool or ( + drive_enabled and (not isinstance(drive_remote, str) or not drive_remote) + ): raise RuntimeError("invalid Drive configuration") - if config["mode"] in ("drive", "auto") and not drive_enabled: + bluetooth_enabled = config.get("bluetooth_enabled", False) + bluetooth_address = config.get("bluetooth_device_address", "") + if type(bluetooth_enabled) is not bool or ( + bluetooth_enabled + and ( + config["version"] < 3 + or not isinstance(bluetooth_address, str) + or re.fullmatch(r"[0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5}", bluetooth_address) + is None + ) + ): + raise RuntimeError("invalid Bluetooth configuration") + if config["mode"] == "drive" and not drive_enabled: raise RuntimeError("Drive transport is not configured") + if config["mode"] == "bt" and not bluetooth_enabled: + raise RuntimeError("Bluetooth transport is not configured") 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") @@ -400,44 +472,30 @@ def run(args: argparse.Namespace) -> int: str(Path(__file__).resolve().parents[1] / "build/tui/trainlog-generation-mtp-adapter"), ) ) + bt_adapter = bluetooth_adapter_path() if selected_mode == "auto": - # WHY: cable presence is not peer availability. A successful pull - # is the bounded proof that the configured Trainlog peer is usable. - # The daemon performs the same libmtp operation for Android-origin - # discovery, so auto probing must share the dedicated transport - # lock rather than racing libmtp and falsely falling back to Drive. - probe = None - if adapter.is_absolute() and os.access(adapter, os.X_OK): - mtp_lock_path = Path(config["transport_root"]).parent / "mtp.lock" - mtp_lock_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) - mtp_lock_fd = os.open(mtp_lock_path, os.O_RDWR | os.O_CREAT, 0o600) - probe_deadline = time.monotonic() + min(timeout, 30) - try: - while time.monotonic() < probe_deadline: - try: - fcntl.flock(mtp_lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) - break - except BlockingIOError: - time.sleep(0.05) - else: - raise subprocess.TimeoutExpired([str(adapter), "pull"], min(timeout, 30)) - remaining = max(0.01, probe_deadline - time.monotonic()) - probe = subprocess.run( - [str(adapter), "pull", config["expected_peer_id"], config["transport_root"]], - stdin=subprocess.DEVNULL, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - timeout=remaining, - check=False, - ) - except (OSError, subprocess.TimeoutExpired): - probe = None - finally: - try: - fcntl.flock(mtp_lock_fd, fcntl.LOCK_UN) - finally: - os.close(mtp_lock_fd) - selected_mode = "mtp" if probe is not None and probe.returncode == 0 else "drive" + transport_root = Path(config["transport_root"]) + selected_mode = "" + if bluetooth_enabled and probe_local_adapter( + bt_adapter, + config["expected_peer_id"], + transport_root, + timeout, + "bt.lock", + ): + selected_mode = "bt" + elif probe_local_adapter( + adapter, + config["expected_peer_id"], + transport_root, + timeout, + "mtp.lock", + ): + selected_mode = "mtp" + elif drive_enabled: + selected_mode = "drive" + else: + raise RuntimeError("expected Android peer not found") command = [ sys.executable, str(Path(__file__).with_name("sync_peer_worker.py")), @@ -456,7 +514,11 @@ def run(args: argparse.Namespace) -> int: "--mode", selected_mode, ] - if selected_mode == "mtp": + if selected_mode == "bt": + if not bt_adapter.is_absolute() or not os.access(bt_adapter, os.X_OK): + raise RuntimeError("fixed Bluetooth adapter executable is unavailable") + command.extend(["--bt-adapter", str(bt_adapter)]) + elif selected_mode == "mtp": if not adapter.is_absolute() or not os.access(adapter, os.X_OK): raise RuntimeError("fixed MTP adapter executable is unavailable") command.extend(["--mtp-adapter", str(adapter)]) @@ -516,6 +578,7 @@ def run(args: argparse.Namespace) -> int: manifest_sha256=result["manifest_sha256"], ai_midpoint=result["ai_midpoint"], ai_post_sync=result["ai_post_sync"], + bluetooth_state="success" if selected_mode == "bt" else "unavailable", usb_state="success" if selected_mode == "mtp" else "unavailable", drive_state=drive_state, drive_diagnostic=drive_diagnostic, diff --git a/tools/sync_peer_worker.py b/tools/sync_peer_worker.py index c0020b3..be87971 100644 --- a/tools/sync_peer_worker.py +++ b/tools/sync_peer_worker.py @@ -123,8 +123,10 @@ def run_adapter( deadline: float, allow_missing_peer: bool = False, clock=time.monotonic, + transport_name: str = "MTP", + lock_name: str = "mtp.lock", ) -> bool: - lock_path = root.parent / "mtp.lock" + lock_path = root.parent / lock_name lock_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) lock_fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600) try: @@ -156,7 +158,7 @@ def run_adapter( # CONTRACT: an adapter timeout is an ambiguous transport failure. It # never implies rollback, replay, or permission to retry a mutation. raise RuntimeError( - f"transport_timeout: MTP {operation} did not finish within " + f"transport_timeout: {transport_name} {operation} did not finish within " f"{MTP_OPERATION_TIMEOUT_SECONDS:g} seconds" ) from error finally: @@ -166,11 +168,13 @@ def run_adapter( os.close(lock_fd) if result.returncode != 0: diagnostic = ( - result.stderr.decode("utf-8", "replace")[:1024] or "MTP adapter failed" + result.stderr.decode("utf-8", "replace")[:1024] + or f"{transport_name} adapter failed" ).strip() missing_peer = diagnostic in { "double failed 6 expected Android peer not found", "MTP adapter failed status=6 diagnostic=expected Android peer not found", + "Bluetooth adapter failed status=6 diagnostic=expected Android peer not connected", } if allow_missing_peer and missing_peer: return False @@ -184,6 +188,8 @@ def push_adapter( root: Path, relative_paths: list[str], deadline: float, + transport_name: str = "MTP", + lock_name: str = "mtp.lock", ) -> None: """Publish only the phase-owned bounded MTP objects. @@ -216,7 +222,15 @@ def push_adapter( shutil.copy2(source, destination) else: raise RuntimeError(f"invalid MTP outbox object: {relative}") - run_adapter(adapter, "push", peer, outbox, deadline) + run_adapter( + adapter, + "push", + peer, + outbox, + deadline, + transport_name=transport_name, + lock_name=lock_name, + ) def push_desktop_generation( @@ -225,9 +239,19 @@ def push_desktop_generation( root: Path, relative_path: str, deadline: float, + transport_name: str = "MTP", + lock_name: str = "mtp.lock", ) -> None: """Commit immutable bytes before making their correlated reference visible.""" - push_adapter(adapter, peer, root, [relative_path], deadline) + push_adapter( + adapter, + peer, + root, + [relative_path], + deadline, + transport_name=transport_name, + lock_name=lock_name, + ) push_adapter( adapter, peer, @@ -239,6 +263,8 @@ def push_desktop_generation( "desktop-generation-v1.json", ], deadline, + transport_name=transport_name, + lock_name=lock_name, ) @@ -376,6 +402,35 @@ def refresh_mtp_peer( time.sleep(min(MTP_POLL_INTERVAL_SECONDS, max(0.0, deadline - time.monotonic()))) +def refresh_bluetooth_peer( + adapter: Path, + peer: str, + root: Path, + deadline: float, +) -> None: + """Require one successful Bluetooth pull before trusting retained peer state.""" + peer_path = root / "android-peer-v1.json" + while True: + try: + pulled = run_adapter( + adapter, + "pull", + peer, + root, + deadline, + True, + transport_name="Bluetooth", + lock_name="bt.lock", + ) + except ConversationDeadlineExpired as error: + raise RuntimeError("timeout waiting for android-peer-v1.json") from error + if pulled and peer_path.is_file(): + return + if time.monotonic() >= deadline: + raise RuntimeError("timeout waiting for android-peer-v1.json") + time.sleep(min(1.0, max(0.0, deadline - time.monotonic()))) + + def correlated_peer_error(root: Path, run_id: str) -> None: path = root / "android-generation-error-v1.json" if not path.is_file(): @@ -412,6 +467,76 @@ def load_peer(root: Path, expected: str) -> dict: return value +def publish_ai_export_to_android( + args: argparse.Namespace, + deadline: float, +) -> dict: + """Best-effort post-convergence AI export delivery. + + The generation/ACK conversation is already complete when this runs. + Export or publication failure therefore remains separately observable and + never rolls back a durable synchronization. + """ + remaining = deadline - time.monotonic() + if remaining <= 1: + return {"result": "pending", "diagnostic": "sync deadline exhausted before AI export"} + output = args.transport_root / "trainlog_ai_export_v1.json" + exporter = Path(__file__).with_name("export_ai_history.py") + try: + result = subprocess.run( + [ + sys.executable, + str(exporter), + str(output), + "--database", + str(args.database), + ], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=min(60.0, max(1.0, remaining - 0.5)), + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as error: + return {"result": "pending", "diagnostic": str(error)[:1024]} + if result.returncode != 0 or "TRAINLOG_AI_EXPORT_V1=PASS" not in result.stdout: + diagnostic = (result.stderr or result.stdout or "AI export failed").strip() + return {"result": "pending", "diagnostic": diagnostic[-1024:]} + if not output.is_file(): + return {"result": "pending", "diagnostic": "AI export file is missing after exporter success"} + + try: + if args.mode == "bt": + push_adapter( + args.bt_adapter, + args.expected_peer, + args.transport_root, + ["trainlog_ai_export_v1.json"], + deadline, + transport_name="Bluetooth", + lock_name="bt.lock", + ) + elif args.mode == "mtp": + push_adapter( + args.mtp_adapter, + args.expected_peer, + args.transport_root, + ["trainlog_ai_export_v1.json"], + deadline, + ) + elif args.mode == "directory": + pass + else: + return { + "result": "local_only", + "diagnostic": "AI export generated locally; Android delivery transport unavailable", + } + except Exception as error: + return {"result": "pending", "diagnostic": str(error)[:1024]} + return {"result": "delivered_to_android"} + + def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--database", required=True, type=Path) @@ -420,14 +545,41 @@ def main() -> int: 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", "drive"), default="directory") + parser.add_argument( + "--mode", + choices=("directory", "bt", "mtp", "drive"), + default="directory", + ) + parser.add_argument("--bt-adapter", type=Path) parser.add_argument("--mtp-adapter", type=Path) parser.add_argument("--drive-adapter", type=Path) parser.add_argument("--drive-remote") args = parser.parse_args() deadline = time.monotonic() + args.timeout pump = None - if args.mode == "mtp": + if args.mode == "bt": + if args.bt_adapter is None: + raise RuntimeError("Bluetooth adapter is required") + refresh_bluetooth_peer( + args.bt_adapter, + args.expected_peer, + args.transport_root, + deadline, + ) + pump = MtpPullPump( + lambda: run_adapter( + args.bt_adapter, + "pull", + args.expected_peer, + args.transport_root, + deadline, + True, + transport_name="Bluetooth", + lock_name="bt.lock", + ), + interval=1.0, + ) + elif args.mode == "mtp": if args.mtp_adapter is None: raise RuntimeError("MTP adapter is required") refresh_mtp_peer( @@ -494,7 +646,17 @@ def main() -> int: } archive_ack_path = args.transport_root / "desktop-archive-acknowledgements-v1.json" publish_json(archive_ack_path, archive_acknowledgements) - if args.mode == "mtp": + if args.mode == "bt": + push_adapter( + args.bt_adapter, + args.expected_peer, + args.transport_root, + ["request-v1.json", "desktop-archive-acknowledgements-v1.json"], + deadline, + transport_name="Bluetooth", + lock_name="bt.lock", + ) + elif args.mode == "mtp": push_adapter( args.mtp_adapter, args.expected_peer, @@ -529,7 +691,17 @@ def main() -> int: 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": + if args.mode == "bt": + push_adapter( + args.bt_adapter, + args.expected_peer, + args.transport_root, + ["request-v1.json", "desktop-archive-acknowledgements-v1.json", "desktop-consumption-ack-v1.json"], + deadline, + transport_name="Bluetooth", + lock_name="bt.lock", + ) + elif args.mode == "mtp": push_adapter( args.mtp_adapter, args.expected_peer, @@ -574,7 +746,17 @@ def main() -> int: "relative_path": str(published.relative_to(args.transport_root)), } publish_json(args.transport_root / "desktop-generation-v1.json", ref) - if args.mode == "mtp": + if args.mode == "bt": + push_desktop_generation( + args.bt_adapter, + args.expected_peer, + args.transport_root, + ref["relative_path"], + deadline, + transport_name="Bluetooth", + lock_name="bt.lock", + ) + elif args.mode == "mtp": # CONTRACT: the immutable generation must be fully visible before its # mutable run-correlated reference. Publishing both in one MTP walk # lets Android observe the reference while artifacts are still being @@ -648,6 +830,7 @@ def main() -> int: if inbound_row != ("consumed",) or outbound_row != ("acknowledged",): raise RuntimeError("generation evidence did not corroborate completion") emit(args.run_id, "peer_consumed", outbound_generation_id=outgoing_id) + ai_post_sync = publish_ai_export_to_android(args, deadline) report = { "format": "trainlog-sync-worker-report", "version": 1, @@ -674,7 +857,7 @@ def main() -> int: }, "drafts": drafts, "ai_midpoint": {"result": "not_configured"}, - "ai_post_sync": {"result": "not_configured"}, + "ai_post_sync": ai_post_sync, } print(json.dumps(report, sort_keys=True, separators=(",", ":")), flush=True) return 0 diff --git a/tools/trainlog_bt_agent.py b/tools/trainlog_bt_agent.py new file mode 100755 index 0000000..cfda713 --- /dev/null +++ b/tools/trainlog_bt_agent.py @@ -0,0 +1,427 @@ +#!/usr/bin/env python3 +"""Persistent BlueZ RFCOMM bridge for Trainlog generation byte transport.""" + +from __future__ import annotations + +import argparse +import dbus +import dbus.service +import dbus.mainloop.glib +import hashlib +import json +import os +import signal +import socket +import struct +import threading +import uuid +from pathlib import Path + +from gi.repository import GLib + +FRAME_FORMAT = "trainlog-bt-frame" +FRAME_VERSION = 1 +FILE_PROTOCOL = "trainlog-bt-files-v1" +DEFAULT_UUID = "f0d1c0de-7a11-4f62-9b7c-545241494e4c" +DEFAULT_CHANNEL = 23 +MAX_HEADER = 64 * 1024 +MAX_PAYLOAD = 256 * 1024 * 1024 +PROFILE_PATH = "/com/labfytools/trainlog/profile" + + +class BluetoothTransportError(RuntimeError): + pass + + +def canonical_header(value: dict) -> bytes: + return json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") + + +def recv_exact(stream: socket.socket, size: int) -> bytes: + if size < 0 or size > MAX_PAYLOAD: + raise BluetoothTransportError("invalid Bluetooth payload size") + chunks = bytearray() + while len(chunks) < size: + part = stream.recv(min(64 * 1024, size - len(chunks))) + if not part: + raise BluetoothTransportError("Bluetooth connection closed") + chunks.extend(part) + return bytes(chunks) + + +def recv_frame(stream: socket.socket) -> tuple[dict, bytes]: + raw_length = recv_exact(stream, 4) + header_length = struct.unpack(">I", raw_length)[0] + if header_length <= 0 or header_length > MAX_HEADER: + raise BluetoothTransportError("invalid Bluetooth header size") + try: + header = json.loads(recv_exact(stream, header_length).decode("utf-8")) + except (UnicodeError, json.JSONDecodeError) as error: + raise BluetoothTransportError("invalid Bluetooth header") from error + if not isinstance(header, dict): + raise BluetoothTransportError("invalid Bluetooth header") + if header.get("format") != FRAME_FORMAT or header.get("version") != FRAME_VERSION: + raise BluetoothTransportError("unsupported Bluetooth frame") + payload_length = header.get("payload_size", 0) + if type(payload_length) is not int or payload_length < 0 or payload_length > MAX_PAYLOAD: + raise BluetoothTransportError("invalid Bluetooth payload size") + payload = recv_exact(stream, payload_length) if payload_length else b"" + expected = header.get("payload_sha256") + if payload: + if not isinstance(expected, str) or len(expected) != 64: + raise BluetoothTransportError("missing Bluetooth payload digest") + if hashlib.sha256(payload).hexdigest() != expected: + raise BluetoothTransportError("Bluetooth payload digest mismatch") + elif expected not in (None, ""): + raise BluetoothTransportError("unexpected Bluetooth payload digest") + return header, payload + + +def send_frame(stream: socket.socket, header: dict, payload: bytes = b"") -> None: + if len(payload) > MAX_PAYLOAD: + raise BluetoothTransportError("Bluetooth payload exceeds bound") + value = dict(header) + value["format"] = FRAME_FORMAT + value["version"] = FRAME_VERSION + value["payload_size"] = len(payload) + if payload: + value["payload_sha256"] = hashlib.sha256(payload).hexdigest() + raw = canonical_header(value) + if len(raw) > MAX_HEADER: + raise BluetoothTransportError("Bluetooth header exceeds bound") + stream.sendall(struct.pack(">I", len(raw))) + stream.sendall(raw) + if payload: + stream.sendall(payload) + + +def normalized_address(value: str) -> str: + pieces = value.strip().upper().split(":") + if len(pieces) != 6 or any(len(piece) != 2 for piece in pieces): + raise BluetoothTransportError("invalid configured Bluetooth address") + try: + if any(int(piece, 16) > 255 for piece in pieces): + raise ValueError + except ValueError as error: + raise BluetoothTransportError("invalid configured Bluetooth address") from error + return ":".join(pieces) + + +def runtime_socket_path(config: dict) -> Path: + configured = config.get("bluetooth_socket") + if configured: + path = Path(configured) + if not path.is_absolute(): + raise BluetoothTransportError("bluetooth_socket must be absolute") + return path + runtime = os.environ.get("XDG_RUNTIME_DIR") + if not runtime: + raise BluetoothTransportError("XDG_RUNTIME_DIR is unavailable") + return Path(runtime) / "trainlog-bt.sock" + + +def load_config(path: Path) -> dict: + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict) or value.get("format") != "trainlog-sync-orchestrator-config": + raise BluetoothTransportError("invalid Trainlog sync configuration") + peer = value.get("expected_peer_id") + if not isinstance(peer, str) or not peer.startswith("peer_"): + raise BluetoothTransportError("invalid expected peer") + address = value.get("bluetooth_device_address") + if not isinstance(address, str): + raise BluetoothTransportError("Bluetooth device address is not configured") + value["bluetooth_device_address"] = normalized_address(address) + return value + + +class ConnectionState: + def __init__(self, expected_address: str, expected_peer: str): + self.expected_address = expected_address + self.expected_peer = expected_peer + self._condition = threading.Condition() + self._stream: socket.socket | None = None + self._peer_id: str | None = None + self._operation_lock = threading.Lock() + + def close(self) -> None: + with self._condition: + stream = self._stream + self._stream = None + self._peer_id = None + self._condition.notify_all() + if stream is not None: + try: + stream.shutdown(socket.SHUT_RDWR) + except OSError: + pass + stream.close() + + def install(self, stream: socket.socket) -> None: + stream.settimeout(15) + try: + header, payload = recv_frame(stream) + if payload or header.get("type") != "hello": + raise BluetoothTransportError("Android Bluetooth hello is missing") + if header.get("protocol") != FILE_PROTOCOL: + raise BluetoothTransportError("Android Bluetooth protocol mismatch") + peer = header.get("peer_id") + if peer != self.expected_peer: + raise BluetoothTransportError("Android Bluetooth peer identity mismatch") + send_frame( + stream, + { + "type": "hello_ack", + "protocol": FILE_PROTOCOL, + "peer_id": peer, + }, + ) + stream.settimeout(60) + except Exception: + stream.close() + raise + with self._condition: + old = self._stream + self._stream = stream + self._peer_id = peer + self._condition.notify_all() + if old is not None and old is not stream: + try: + old.close() + except OSError: + pass + + def transact(self, operation: str, peer: str, payload: bytes) -> tuple[dict, bytes]: + if peer != self.expected_peer: + raise BluetoothTransportError("expected Android peer mismatch") + if operation not in {"pull", "push"}: + raise BluetoothTransportError("unsupported Bluetooth operation") + with self._operation_lock: + with self._condition: + stream = self._stream + connected_peer = self._peer_id + if stream is None or connected_peer != peer: + raise BluetoothTransportError("expected Android peer not connected") + request_id = "bt_" + str(uuid.uuid4()) + try: + send_frame( + stream, + { + "type": operation, + "request_id": request_id, + "protocol": FILE_PROTOCOL, + "peer_id": peer, + }, + payload, + ) + header, response = recv_frame(stream) + if header.get("type") != "result" or header.get("request_id") != request_id: + raise BluetoothTransportError("uncorrelated Bluetooth response") + if header.get("result") != "ok": + diagnostic = header.get("diagnostic") + raise BluetoothTransportError( + diagnostic if isinstance(diagnostic, str) and diagnostic + else "Android Bluetooth operation failed" + ) + if operation == "pull" and not response: + raise BluetoothTransportError("Android Bluetooth pull returned no archive") + if operation == "push" and response: + raise BluetoothTransportError("Android Bluetooth push returned unexpected payload") + return header, response + except Exception: + self.close() + raise + + +class TrainlogProfile(dbus.service.Object): + def __init__(self, bus: dbus.SystemBus, state: ConnectionState): + super().__init__(bus, PROFILE_PATH) + self.bus = bus + self.state = state + + def _device_address(self, device: str) -> str: + proxy = self.bus.get_object("org.bluez", device) + properties = dbus.Interface(proxy, "org.freedesktop.DBus.Properties") + return normalized_address(str(properties.Get("org.bluez.Device1", "Address"))) + + @dbus.service.method("org.bluez.Profile1", in_signature="", out_signature="") + def Release(self): + self.state.close() + + @dbus.service.method("org.bluez.Profile1", in_signature="oha{sv}", out_signature="") + def NewConnection(self, device, fd, properties): + del properties + address = self._device_address(str(device)) + raw_fd = fd.take() + stream = socket.socket(fileno=raw_fd) + if address != self.state.expected_address: + stream.close() + raise dbus.exceptions.DBusException( + "org.bluez.Error.Rejected", "unauthorized Bluetooth device" + ) + + def establish() -> None: + try: + self.state.install(stream) + except Exception: + try: + stream.close() + except OSError: + pass + + threading.Thread(target=establish, name="trainlog-bt-hello", daemon=True).start() + + @dbus.service.method("org.bluez.Profile1", in_signature="o", out_signature="") + def RequestDisconnection(self, device): + del device + self.state.close() + + @dbus.service.method("org.bluez.Profile1", in_signature="", out_signature="") + def Cancel(self): + self.state.close() + + +class LocalAdapterServer: + def __init__(self, path: Path, state: ConnectionState): + self.path = path + self.state = state + self.stop = threading.Event() + self.listener: socket.socket | None = None + + def serve(self) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + try: + self.path.unlink() + except FileNotFoundError: + pass + listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.listener = listener + listener.bind(str(self.path)) + os.chmod(self.path, 0o600) + listener.listen(4) + listener.settimeout(1) + while not self.stop.is_set(): + try: + client, _ = listener.accept() + except socket.timeout: + continue + except OSError: + break + threading.Thread( + target=self._client, + args=(client,), + name="trainlog-bt-local", + daemon=True, + ).start() + try: + listener.close() + finally: + try: + self.path.unlink() + except FileNotFoundError: + pass + + def _client(self, client: socket.socket) -> None: + with client: + client.settimeout(60) + try: + header, payload = recv_frame(client) + if header.get("type") != "adapter_request": + raise BluetoothTransportError("invalid local Bluetooth adapter request") + operation = header.get("operation") + peer = header.get("peer_id") + if not isinstance(operation, str) or not isinstance(peer, str): + raise BluetoothTransportError("invalid local Bluetooth adapter request") + _, response = self.state.transact(operation, peer, payload) + send_frame( + client, + { + "type": "adapter_result", + "operation": operation, + "result": "ok", + }, + response, + ) + except Exception as error: + try: + send_frame( + client, + { + "type": "adapter_result", + "result": "error", + "diagnostic": str(error)[:1024], + }, + ) + except Exception: + pass + + def shutdown(self) -> None: + self.stop.set() + if self.listener is not None: + try: + self.listener.close() + except OSError: + pass + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--config", required=True, type=Path) + parser.add_argument("--uuid", default=DEFAULT_UUID) + parser.add_argument("--channel", type=int, default=DEFAULT_CHANNEL) + args = parser.parse_args() + + config = load_config(args.config) + expected_address = config["bluetooth_device_address"] + expected_peer = config["expected_peer_id"] + socket_path = runtime_socket_path(config) + + if args.channel < 1 or args.channel > 30: + raise SystemExit("RFCOMM channel must be in 1..30") + + dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) + bus = dbus.SystemBus() + state = ConnectionState(expected_address, expected_peer) + profile = TrainlogProfile(bus, state) + manager = dbus.Interface( + bus.get_object("org.bluez", "/org/bluez"), + "org.bluez.ProfileManager1", + ) + manager.RegisterProfile( + PROFILE_PATH, + args.uuid, + { + "Name": "Trainlog Sync", + "Role": "server", + "Channel": dbus.UInt16(args.channel), + "RequireAuthentication": dbus.Boolean(True), + "RequireAuthorization": dbus.Boolean(False), + }, + ) + + local = LocalAdapterServer(socket_path, state) + thread = threading.Thread(target=local.serve, name="trainlog-bt-unix", daemon=True) + thread.start() + loop = GLib.MainLoop() + + def stop(_signum, _frame): + local.shutdown() + state.close() + loop.quit() + + signal.signal(signal.SIGTERM, stop) + signal.signal(signal.SIGINT, stop) + try: + loop.run() + finally: + local.shutdown() + state.close() + try: + manager.UnregisterProfile(PROFILE_PATH) + except dbus.DBusException: + pass + thread.join(timeout=2) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/trainlog_generation_bt_adapter.py b/tools/trainlog_generation_bt_adapter.py new file mode 100755 index 0000000..ed478ad --- /dev/null +++ b/tools/trainlog_generation_bt_adapter.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python3 +"""Bounded local adapter between Trainlog sync workers and trainlog-btd.""" + +from __future__ import annotations + +import hashlib +import io +import json +import os +import socket +import stat +import struct +import sys +import tempfile +import zipfile +from pathlib import Path + +FRAME_FORMAT = "trainlog-bt-frame" +FRAME_VERSION = 1 +MAX_HEADER = 64 * 1024 +MAX_PAYLOAD = 256 * 1024 * 1024 +MAX_FILES = 128 +MAX_FILE = 64 * 1024 * 1024 + + +class AdapterError(RuntimeError): + pass + + +def socket_path() -> Path: + configured = os.environ.get("TRAINLOG_SYNC_BT_SOCKET") + if configured: + path = Path(configured) + if not path.is_absolute(): + raise AdapterError("TRAINLOG_SYNC_BT_SOCKET must be absolute") + return path + runtime = os.environ.get("XDG_RUNTIME_DIR") + if not runtime: + raise AdapterError("XDG_RUNTIME_DIR is unavailable") + return Path(runtime) / "trainlog-bt.sock" + + +def canonical_header(value: dict) -> bytes: + return json.dumps(value, sort_keys=True, separators=(",", ":")).encode() + + +def recv_exact(stream: socket.socket, size: int) -> bytes: + if size < 0 or size > MAX_PAYLOAD: + raise AdapterError("invalid Bluetooth adapter payload size") + value = bytearray() + while len(value) < size: + chunk = stream.recv(min(64 * 1024, size - len(value))) + if not chunk: + raise AdapterError("Bluetooth agent connection closed") + value.extend(chunk) + return bytes(value) + + +def recv_frame(stream: socket.socket) -> tuple[dict, bytes]: + header_size = struct.unpack(">I", recv_exact(stream, 4))[0] + if header_size <= 0 or header_size > MAX_HEADER: + raise AdapterError("invalid Bluetooth adapter header size") + try: + header = json.loads(recv_exact(stream, header_size)) + except (UnicodeError, json.JSONDecodeError) as error: + raise AdapterError("invalid Bluetooth adapter header") from error + if ( + not isinstance(header, dict) + or header.get("format") != FRAME_FORMAT + or header.get("version") != FRAME_VERSION + ): + raise AdapterError("unsupported Bluetooth adapter frame") + payload_size = header.get("payload_size", 0) + if type(payload_size) is not int or payload_size < 0 or payload_size > MAX_PAYLOAD: + raise AdapterError("invalid Bluetooth adapter payload size") + payload = recv_exact(stream, payload_size) if payload_size else b"" + digest = header.get("payload_sha256") + if payload: + if not isinstance(digest, str) or hashlib.sha256(payload).hexdigest() != digest: + raise AdapterError("Bluetooth adapter payload digest mismatch") + elif digest not in (None, ""): + raise AdapterError("unexpected Bluetooth adapter payload digest") + return header, payload + + +def send_frame(stream: socket.socket, header: dict, payload: bytes = b"") -> None: + if len(payload) > MAX_PAYLOAD: + raise AdapterError("Bluetooth adapter payload exceeds bound") + value = dict(header) + value.update( + { + "format": FRAME_FORMAT, + "version": FRAME_VERSION, + "payload_size": len(payload), + } + ) + if payload: + value["payload_sha256"] = hashlib.sha256(payload).hexdigest() + raw = canonical_header(value) + if len(raw) > MAX_HEADER: + raise AdapterError("Bluetooth adapter header exceeds bound") + stream.sendall(struct.pack(">I", len(raw)) + raw) + if payload: + stream.sendall(payload) + + +def safe_relative(name: str) -> Path: + if not name or "\\" in name: + raise AdapterError("invalid archive path") + path = Path(name) + if path.is_absolute() or any(piece in ("", ".", "..") for piece in path.parts): + raise AdapterError("invalid archive path") + return path + + +def build_archive(root: Path) -> bytes: + if not root.is_dir(): + raise AdapterError("Bluetooth adapter root is unavailable") + buffer = io.BytesIO() + count = 0 + total = 0 + with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_STORED) as archive: + for path in sorted(root.rglob("*")): + if path.is_symlink(): + raise AdapterError("Bluetooth adapter refuses symbolic links") + if not path.is_file(): + continue + relative = path.relative_to(root) + safe_relative(relative.as_posix()) + mode = path.stat().st_mode + if not stat.S_ISREG(mode): + raise AdapterError("Bluetooth adapter refuses non-regular files") + size = path.stat().st_size + if size > MAX_FILE: + raise AdapterError("Bluetooth adapter file exceeds bound") + count += 1 + total += size + if count > MAX_FILES or total > MAX_PAYLOAD: + raise AdapterError("Bluetooth adapter archive exceeds bound") + archive.write(path, relative.as_posix()) + raw = buffer.getvalue() + if len(raw) > MAX_PAYLOAD: + raise AdapterError("Bluetooth adapter archive exceeds bound") + return raw + + +def extract_archive(raw: bytes, root: Path) -> None: + if not raw or len(raw) > MAX_PAYLOAD: + raise AdapterError("invalid Bluetooth adapter archive") + root.mkdir(parents=True, exist_ok=True, mode=0o700) + seen: set[str] = set() + total = 0 + count = 0 + with zipfile.ZipFile(io.BytesIO(raw), "r") as archive: + for info in archive.infolist(): + if info.is_dir(): + continue + relative = safe_relative(info.filename) + if info.filename in seen: + raise AdapterError("duplicate Bluetooth archive path") + seen.add(info.filename) + count += 1 + total += info.file_size + if ( + count > MAX_FILES + or info.file_size < 0 + or info.file_size > MAX_FILE + or total > MAX_PAYLOAD + ): + raise AdapterError("Bluetooth adapter archive exceeds bound") + destination = root / relative + resolved_parent = destination.parent.resolve() + if root.resolve() != resolved_parent and root.resolve() not in resolved_parent.parents: + raise AdapterError("Bluetooth archive escapes transport root") + destination.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + descriptor, temporary_name = tempfile.mkstemp( + prefix="." + destination.name + ".bt-", + dir=destination.parent, + ) + try: + os.fchmod(descriptor, 0o600) + with os.fdopen(descriptor, "wb") as output, archive.open(info, "r") as source: + copied = 0 + while True: + chunk = source.read(64 * 1024) + if not chunk: + break + copied += len(chunk) + if copied > info.file_size: + raise AdapterError("Bluetooth archive size mismatch") + output.write(chunk) + if copied != info.file_size: + raise AdapterError("Bluetooth archive size mismatch") + output.flush() + os.fsync(output.fileno()) + os.replace(temporary_name, destination) + finally: + try: + os.unlink(temporary_name) + except FileNotFoundError: + pass + + +def run(operation: str, peer: str, root: Path) -> int: + if operation not in {"pull", "push"}: + raise AdapterError("usage: trainlog-generation-bt-adapter PEER ROOT") + if not peer.startswith("peer_"): + raise AdapterError("invalid expected Android peer") + payload = build_archive(root) if operation == "push" else b"" + stream = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + stream.settimeout(45) + try: + stream.connect(str(socket_path())) + send_frame( + stream, + { + "type": "adapter_request", + "operation": operation, + "peer_id": peer, + }, + payload, + ) + header, response = recv_frame(stream) + except (FileNotFoundError, ConnectionRefusedError): + print( + "Bluetooth adapter failed status=6 diagnostic=expected Android peer not connected", + file=sys.stderr, + ) + return 2 + finally: + stream.close() + if header.get("type") != "adapter_result": + raise AdapterError("invalid Bluetooth agent response") + if header.get("result") != "ok": + diagnostic = header.get("diagnostic") + text = diagnostic if isinstance(diagnostic, str) and diagnostic else "Bluetooth transport failed" + print(f"Bluetooth adapter failed status=6 diagnostic={text}", file=sys.stderr) + return 2 + if operation == "pull": + extract_archive(response, root) + elif response: + raise AdapterError("unexpected Bluetooth push response payload") + return 0 + + +def main() -> int: + if len(sys.argv) != 4: + raise SystemExit("usage: trainlog-generation-bt-adapter PEER ROOT") + try: + return run(sys.argv[1], sys.argv[2], Path(sys.argv[3])) + except (AdapterError, OSError, zipfile.BadZipFile) as error: + print(f"Bluetooth adapter failed status=6 diagnostic={str(error)[:1024]}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/trainlog_syncd.py b/tools/trainlog_syncd.py index 59239bd..7826fbb 100755 --- a/tools/trainlog_syncd.py +++ b/tools/trainlog_syncd.py @@ -87,9 +87,9 @@ def load_generation_config(path: Path) -> dict: not isinstance(value, dict) or value.get("format") != "trainlog-sync-orchestrator-config" or value.get("enabled") is not True - or value.get("mode") not in ("mtp", "auto") + or value.get("mode") not in ("bt", "mtp", "auto") ): - raise RuntimeError("trusted full-generation MTP configuration is invalid") + raise RuntimeError("trusted full-generation transport configuration is invalid") return value @@ -164,38 +164,47 @@ def select_request(root: Path, seen: list[str]) -> tuple[str, str, list[str]] | return None -def run_full_generation(args: argparse.Namespace, config_path: Path) -> int: - config = load_generation_config(config_path) - root = Path(config["transport_root"]) - expected_peer = config["expected_peer_id"] - adapter = args.mtp_adapter - # WHY: the daemon must probe MTP to discover Android-origin requests, but - # Web/TUI orchestration can use the same libmtp device concurrently. A - # business sync.lock is too broad here: holding it during discovery makes - # a user Web click fail with sync_in_progress. CONTRACT: discovery instead - # shares a dedicated physical-transport lock with every worker MTP adapter - # call and skips this poll when that transport is busy. INVARIANT: no MTP - # adapter processes overlap, while business-sync admission stays independent. - lock_path = root.parent / "mtp.lock" +def pull_transport( + adapter: Path, + lock_name: str, + expected_peer: str, + root: Path, +) -> bool: + lock_path = root.parent / lock_name lock_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) lock_fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600) try: try: fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError: - return 3 - pull = subprocess.run( - [str(adapter), "pull", expected_peer, str(root)], - capture_output=True, - text=True, - check=False, - ) + return False + try: + pull = subprocess.run( + [str(adapter), "pull", expected_peer, str(root)], + capture_output=True, + text=True, + check=False, + ) + except OSError: + return False + return pull.returncode == 0 finally: try: fcntl.flock(lock_fd, fcntl.LOCK_UN) finally: os.close(lock_fd) - if pull.returncode != 0: + + +def run_full_generation(args: argparse.Namespace, config_path: Path) -> int: + config = load_generation_config(config_path) + root = Path(config["transport_root"]) + expected_peer = config["expected_peer_id"] + pulled = False + if config.get("bluetooth_enabled") is True and args.bt_adapter is not None: + pulled = pull_transport(args.bt_adapter, "bt.lock", expected_peer, root) + if not pulled and config.get("mode") in ("mtp", "auto"): + pulled = pull_transport(args.mtp_adapter, "mtp.lock", expected_peer, root) + if not pulled: return 3 _, seen = load_request_state(args.state) selected = select_request(root, seen) @@ -270,6 +279,11 @@ def main() -> int: ) parser.add_argument("--database", type=Path, default=default_database()) parser.add_argument("--state", type=Path) + parser.add_argument( + "--bt-adapter", + type=Path, + default=Path(os.environ.get("TRAINLOG_SYNC_BT_ADAPTER", "")), + ) parser.add_argument( "--mtp-adapter", type=Path, @@ -295,8 +309,13 @@ def main() -> int: ) if args.generation_config is not None: - if not args.orchestrator.is_file() or not args.mtp_adapter.is_file(): + config = load_generation_config(args.generation_config) + if not args.orchestrator.is_file(): raise SystemExit("full-generation runtime is incomplete") + if config.get("mode") in ("mtp", "auto") and not args.mtp_adapter.is_file(): + raise SystemExit("full-generation MTP runtime is incomplete") + if config.get("bluetooth_enabled") is True and not args.bt_adapter.is_file(): + raise SystemExit("full-generation Bluetooth runtime is incomplete") signal.signal( signal.SIGTERM, diff --git a/tui/meson.build b/tui/meson.build index 1da1d54..b457eb8 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -928,6 +928,11 @@ test( find_program('python3'), args: [meson.project_source_root() / 'tests/test_sync_drive_transport.py'], ) +test( + 'sync_bluetooth_transport', + find_program('python3'), + args: [meson.project_source_root() / 'tests/test_sync_bluetooth_transport.py'], +) test( 'sync_deployment_tools', find_program('python3'), diff --git a/web/src/api/sync.ts b/web/src/api/sync.ts index 8d16b83..f77280d 100644 --- a/web/src/api/sync.ts +++ b/web/src/api/sync.ts @@ -37,7 +37,8 @@ export interface SyncStatus { drafts?: SyncDraftSummary[] started_at?: string | null finished_at?: string | null - transport?: 'directory' | 'mtp' | 'drive' + transport?: 'directory' | 'bt' | 'mtp' | 'drive' + bluetooth_state?: 'unavailable' | 'skipped' | 'running' | 'success' | 'failed' usb_state?: 'unavailable' | 'skipped' | 'running' | 'success' | 'failed' drive_state?: 'disabled' | 'unavailable' | 'running' | 'mirrored' | 'success' | 'failed' drive_diagnostic?: string @@ -90,7 +91,9 @@ function parse(value: unknown): SyncStatus { !optionalString(status.started_at) || !optionalString(status.finished_at) || !optionalString(status.drive_diagnostic) || - (status.transport !== undefined && !['directory', 'mtp', 'drive'].includes(status.transport)) || + (status.transport !== undefined && !['directory', 'bt', 'mtp', 'drive'].includes(status.transport)) || + (status.bluetooth_state !== undefined && + !['unavailable', 'skipped', 'running', 'success', 'failed'].includes(status.bluetooth_state)) || (status.usb_state !== undefined && !['unavailable', 'skipped', 'running', 'success', 'failed'].includes(status.usb_state)) || (status.drive_state !== undefined && !['disabled', 'unavailable', 'running', 'mirrored', 'success', 'failed'].includes(status.drive_state)) || (status.progress_revision !== undefined && diff --git a/web/src/components/SyncControl.tsx b/web/src/components/SyncControl.tsx index e96de29..108878a 100644 --- a/web/src/components/SyncControl.tsx +++ b/web/src/components/SyncControl.tsx @@ -135,6 +135,10 @@ export function SyncControl({ onCommitted }: { onCommitted: () => void }) { {action &&

{action}

} {status?.error_code &&

Code : {status.error_code}

} {status?.diagnostic &&

{status.diagnostic}

} + {status?.bluetooth_state &&

Android Bluetooth : {{ + unavailable: 'non disponible', skipped: 'ignoré', running: 'en cours', + success: '✓ synchronisé', failed: '⚠ non traité', + }[status.bluetooth_state]}

} {status?.usb_state &&

Android USB : {{ unavailable: 'non disponible', skipped: 'ignoré', running: 'en cours', success: '✓ synchronisé', failed: '⚠ non traité',