feat(sync): add automatic bluetooth transport

This commit is contained in:
fy59 2026-09-22 16:02:46 +02:00
parent b580b63886
commit d5b4584ae2
24 changed files with 2466 additions and 143 deletions

View file

@ -6,8 +6,16 @@
user-visible directory, Documents/Trainlog, without a SAF provider. -->
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission
android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<application
android:allowBackup="false"
@ -33,6 +41,6 @@
<service
android:name=".data.SyncBackgroundService"
android:exported="false"
android:foregroundServiceType="dataSync" />
android:foregroundServiceType="connectedDevice|dataSync" />
</application>
</manifest>

View file

@ -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<AiExportDriveWorker>(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<AiExportDriveWorker>()
.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()
}
}

View file

@ -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<BondedBluetoothDesktop> {
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<BondedBluetoothDesktop> { 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<String> {
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<String>,
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<String>()
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<String>()
val published = mutableListOf<File>()
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
}
}
}

View file

@ -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(

View file

@ -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 <

View file

@ -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),

View file

@ -129,13 +129,23 @@
<string name="background_sync_notification_title">Trainlog est prêt à synchroniser</string>
<string name="background_sync_notification_text">Le service attend une demande du PC.</string>
<string name="background_sync_notification_required">Autorisez les notifications pour activer la synchronisation en arrière-plan.</string>
<string name="drive_sync_title">Google Drive privé</string>
<string name="drive_sync_description">Sélectionnez le dossier privé Trainlog/Sync/v1. Trainlog n’accède qu’à ce dossier et n’y copie jamais SQLite.</string>
<string name="drive_sync_connect">Connecter Google Drive</string>
<string name="drive_sync_disconnect">Déconnecter Google Drive</string>
<string name="drive_sync_latency">La vérification en arrière-plan est bornée par Android et peut prendre au moins 15 minutes.</string>
<string name="drive_sync_connected">Dossier Drive connecté. Une vérification est planifiée.</string>
<string name="drive_sync_connection_failed">Impossible de conserver l’accès au dossier Drive.</string>
<string name="bluetooth_sync_title">Bluetooth automatique</string>
<string name="bluetooth_sync_description">Trainlog se connecte automatiquement au PC appairé dès qu’il est à portée. MTP reste disponible en secours.</string>
<string name="bluetooth_sync_permission">Autoriser l’accès Bluetooth</string>
<string name="bluetooth_sync_permission_required">Autorisation Bluetooth requise pour la connexion automatique.</string>
<string name="bluetooth_sync_permission_granted">Bluetooth autorisé. Sélectionnez le PC appairé.</string>
<string name="bluetooth_sync_no_desktop">Aucun ordinateur appairé détecté.</string>
<string name="bluetooth_sync_select">Utiliser %1$s</string>
<string name="bluetooth_sync_selected">PC Bluetooth sélectionné : %1$s.</string>
<string name="bluetooth_sync_disconnect">Oublier le PC Bluetooth</string>
<string name="bluetooth_sync_address">%1$s</string>
<string name="drive_sync_title">Export IA Google Drive</string>
<string name="drive_sync_description">Sélectionnez une fois le dossier Trainlog/AI. Après chaque synchronisation, Android y publie automatiquement trainlog_ai_export_v1.json.</string>
<string name="drive_sync_connect">Choisir Trainlog/AI</string>
<string name="drive_sync_disconnect">Déconnecter l’export IA Drive</string>
<string name="drive_sync_latency">L’échec de Drive ne bloque jamais la synchronisation. Android conserve l’export local et réessaie automatiquement.</string>
<string name="drive_sync_connected">Dossier Trainlog/AI connecté. L’export sera publié automatiquement.</string>
<string name="drive_sync_connection_failed">Impossible de conserver l’accès au dossier Trainlog/AI.</string>
<string name="sync_flow_description">Android → PC puis PC → Android, en une seule opération.</string>
<string name="sync_authorize_first">Accès fichiers requis. Autorisez d’abord Documents/Trainlog.</string>
<string name="sync_request_sent">Demande envoyée · attente du PC…</string>

View file

@ -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_<uuid>",
"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.

View file

@ -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,

View file

@ -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()

View file

@ -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(),

View file

@ -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))

View file

@ -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)

View file

@ -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"

View file

@ -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" <<EOF
cat > "$HOME/.config/systemd/user/trainlog-syncd.service" <<EOF
[Unit]
Description=Trainlog Android MTP synchronization agent
Description=Trainlog automatic synchronization agent
[Service]
Type=simple
ExecStart=/usr/bin/python3 "$DAEMON" --sync-once "$SYNC_ONCE" --mtp-adapter "$MTP_ADAPTER" --interval 3
Environment=TRAINLOG_SYNC_BT_ADAPTER=$BT_ADAPTER
Environment=TRAINLOG_SYNC_MTP_ADAPTER=$MTP_ADAPTER
ExecStart=/usr/bin/python3 "$DAEMON" --sync-once "$SYNC_ONCE" --bt-adapter "$BT_ADAPTER" --mtp-adapter "$MTP_ADAPTER" --interval 3
Restart=on-failure
RestartSec=2
[Install]
WantedBy=default.target
EOF
cat > "$HOME/.config/systemd/user/trainlog-btd.service" <<EOF
[Unit]
Description=Trainlog Bluetooth RFCOMM agent
ConditionPathExists=$GEN_CONFIG
[Service]
Type=simple
ExecCondition=/usr/bin/grep -Eq '"bluetooth_enabled"[[:space:]]*:[[:space:]]*true' "$GEN_CONFIG"
ExecStart=/usr/bin/python3 "$BT_AGENT" --config "$GEN_CONFIG"
Restart=on-failure
RestartSec=2
@ -54,6 +62,11 @@ EOF
systemctl --user daemon-reload
systemctl --user enable --now trainlog-syncd.service
systemctl --user enable trainlog-btd.service
if [[ -f "$GEN_CONFIG" ]] && grep -Eq '"bluetooth_enabled"[[:space:]]*:[[:space:]]*true' "$GEN_CONFIG"; then
systemctl --user restart trainlog-btd.service
fi
echo "TRAINLOG_SYNCD_INSTALL=PASS"
systemctl --user --no-pager --full status trainlog-syncd.service || true
systemctl --user --no-pager --full status trainlog-btd.service || true

View file

@ -12,6 +12,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
RUNTIME_TOOLS = [
"trainlog_syncd.py",
"trainlog_bt_agent.py",
"sync_orchestrator.py",
"sync_peer_worker.py",
"sync_drive_transport.py",
@ -81,13 +82,17 @@ def main() -> 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

View file

@ -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,

View file

@ -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

427
tools/trainlog_bt_agent.py Executable file
View file

@ -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())

View file

@ -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 <pull|push> 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 <pull|push> 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())

View file

@ -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,

View file

@ -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'),

View file

@ -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 &&

View file

@ -135,6 +135,10 @@ export function SyncControl({ onCommitted }: { onCommitted: () => void }) {
{action && <p>{action}</p>}
{status?.error_code && <p>Code : {status.error_code}</p>}
{status?.diagnostic && <p>{status.diagnostic}</p>}
{status?.bluetooth_state && <p>Android Bluetooth : {{
unavailable: 'non disponible', skipped: 'ignoré', running: 'en cours',
success: '✓ synchronisé', failed: '⚠ non traité',
}[status.bluetooth_state]}</p>}
{status?.usb_state && <p>Android USB : {{
unavailable: 'non disponible', skipped: 'ignoré', running: 'en cours',
success: '✓ synchronisé', failed: '⚠ non traité',