feat(sync): add automatic bluetooth transport
This commit is contained in:
parent
b580b63886
commit
d5b4584ae2
24 changed files with 2466 additions and 143 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 <
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
150
docs/design/sync_bluetooth_transport_v1.md
Normal file
150
docs/design/sync_bluetooth_transport_v1.md
Normal 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.
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
129
tests/test_sync_bluetooth_transport.py
Normal file
129
tests/test_sync_bluetooth_transport.py
Normal 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()
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
427
tools/trainlog_bt_agent.py
Executable 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())
|
||||
257
tools/trainlog_generation_bt_adapter.py
Executable file
257
tools/trainlog_generation_bt_adapter.py
Executable 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())
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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é',
|
||||
|
|
|
|||
Loading…
Reference in a new issue