diff --git a/android/app/src/main/java/com/labfytools/trainlog/MainActivity.kt b/android/app/src/main/java/com/labfytools/trainlog/MainActivity.kt index a4c5a26..bc51bdd 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/MainActivity.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/MainActivity.kt @@ -14,6 +14,9 @@ import com.labfytools.trainlog.data.TrainlogRepository import com.labfytools.trainlog.data.SyncExporter import com.labfytools.trainlog.data.SyncCatalogInbox import com.labfytools.trainlog.data.SyncRequestOutbox +import com.labfytools.trainlog.data.BackgroundSyncSettings +import com.labfytools.trainlog.data.SyncBackgroundService +import com.labfytools.trainlog.data.hasBluetoothConnectPermission import com.labfytools.trainlog.ui.TrainlogApp import com.labfytools.trainlog.ui.TrainlogAppState import com.labfytools.trainlog.ui.LanguagePresentation @@ -30,6 +33,13 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) languageSettings = LanguageSettingsOwner(applicationContext) + if ( + BackgroundSyncSettings(applicationContext).enabled && + hasBluetoothConnectPermission(applicationContext) + ) { + SyncBackgroundService.start(applicationContext) + } + val repository = TrainlogRepository( applicationContext diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/BluetoothSyncTransport.kt b/android/app/src/main/java/com/labfytools/trainlog/data/BluetoothSyncTransport.kt index 04cbd81..d07f940 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/BluetoothSyncTransport.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/BluetoothSyncTransport.kt @@ -3,11 +3,13 @@ package com.labfytools.trainlog.data import android.Manifest import android.annotation.SuppressLint import android.bluetooth.BluetoothClass +import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothManager import android.bluetooth.BluetoothSocket import android.content.Context import android.content.pm.PackageManager import android.os.Build +import android.util.Log import androidx.core.content.ContextCompat import java.io.BufferedInputStream import java.io.BufferedOutputStream @@ -355,6 +357,9 @@ internal class BluetoothSyncClient( private val context: Context, private val repository: TrainlogRepository, ) { + companion object { + private const val TAG = "TrainlogBluetoothSync" + } @Volatile private var activeSocket: BluetoothSocket? = null private val settings = BluetoothSyncSettings(context) private val codec = BluetoothFrameCodec(context.cacheDir) @@ -374,12 +379,22 @@ internal class BluetoothSyncClient( continue } val address = settings.desktopAddress ?: continue + val device = + runCatching { adapter.getRemoteDevice(address) } + .onFailure { Log.w(TAG, "Configured Bluetooth desktop is invalid", it) } + .getOrNull() + if (device == null || !ensureBond(device, stopped)) { + sleep(stopped, Duration.ofSeconds(5)) + continue + } val socket = runCatching { - adapter - .getRemoteDevice(address) - .createRfcommSocketToServiceRecord(UUID.fromString(TRAINLOG_BLUETOOTH_SERVICE_UUID)) - }.getOrNull() + device.createRfcommSocketToServiceRecord( + UUID.fromString(TRAINLOG_BLUETOOTH_SERVICE_UUID) + ) + } + .onFailure { Log.w(TAG, "Unable to create RFCOMM socket", it) } + .getOrNull() if (socket == null) { sleep(stopped, Duration.ofSeconds(10)) continue @@ -387,10 +402,10 @@ internal class BluetoothSyncClient( activeSocket = socket try { socket.connect() + Log.i(TAG, "RFCOMM connected to selected Trainlog desktop") serve(socket, stopped) - } catch (_: Exception) { - // Arrival polling is intentionally silent. A foreground manual - // synchronization still exposes its own correlated diagnostic. + } catch (error: Exception) { + Log.w(TAG, "RFCOMM connection ended: ${error.message}", error) } finally { activeSocket = null runCatching { socket.close() } @@ -404,6 +419,33 @@ internal class BluetoothSyncClient( activeSocket = null } + @SuppressLint("MissingPermission") + private fun ensureBond(device: BluetoothDevice, stopped: AtomicBoolean): Boolean { + if (device.bondState == BluetoothDevice.BOND_BONDED) return true + if (device.bondState == BluetoothDevice.BOND_NONE) { + Log.i(TAG, "Bluetooth bond missing; requesting one-time repair") + if (!runCatching { device.createBond() }.getOrDefault(false)) { + Log.w(TAG, "Bluetooth bond request was rejected by Android") + return false + } + } + val deadline = System.nanoTime() + Duration.ofSeconds(30).toNanos() + while (!stopped.get() && System.nanoTime() < deadline) { + when (device.bondState) { + BluetoothDevice.BOND_BONDED -> { + Log.i(TAG, "Bluetooth bond ready") + return true + } + BluetoothDevice.BOND_NONE -> { + Log.w(TAG, "Bluetooth bond repair failed") + return false + } + } + sleep(stopped, Duration.ofMillis(250)) + } + return device.bondState == BluetoothDevice.BOND_BONDED + } + private fun sleep(stopped: AtomicBoolean, duration: Duration) { val deadline = System.nanoTime() + duration.toNanos() while (!stopped.get() && System.nanoTime() < deadline) { diff --git a/docs/design/sync_bluetooth_transport_v1.md b/docs/design/sync_bluetooth_transport_v1.md index f3cf445..67a8ef5 100644 --- a/docs/design/sync_bluetooth_transport_v1.md +++ b/docs/design/sync_bluetooth_transport_v1.md @@ -2,7 +2,7 @@ ## Status -`TRAINLOG_SYNC_BLUETOOTH_TRANSPORT_V1=SOFTWARE_VALIDATED_DEVICE_TEST_PENDING` +`TRAINLOG_SYNC_BLUETOOTH_TRANSPORT_V1=LIVE_DEVICE_PASS_DRIVE_SETUP_PENDING` This transport is additive. It does not change generation manifests, artifact formats, causal deletion, revision ancestry, acknowledgement semantics, SQLite @@ -30,6 +30,35 @@ desktop are within Bluetooth range. The manual Web and Android Synchroniser actions remain as recovery/forced-sync controls. +## Private live-device evidence — 2026-09-22 + +The authorized Samsung Android peer and ArchASP completed a physical Bluetooth +Classic/RFCOMM synchronization without MTP or Drive as a sync transport. + +- request: `sr_86ccc07f-cc5b-4506-86c1-06c00bd8b06a`; +- run: `sy_df8f87fb-e4c1-44f5-b3f7-8fe87942d19e`; +- inbound Android generation: + `gen_9609d163-4a3a-4187-a806-84379a1bcf4c`; +- outbound desktop generation: + `gen_bc1d5e4b-4b16-49bc-b228-c3ff1f1a1705`; +- final transport: `bt`; +- final Bluetooth state: `success`; +- final phase/result: `completed/completed`; +- desktop SQLite integrity: `ok`; +- post-sync AI export: `delivered_to_android`; +- Android copy: + `Documents/Trainlog/trainlog_ai_export_v1.json`, 66,971 bytes, + SHA-256 `49f7f02fdeebd15699655e43fdb475bb20d9dd2d1bc59d2bdb4cd099ae50ecda`. + +The initial legacy bond contained mismatched link-key state. The private device +was explicitly re-paired; Android then reported the bond ready and established +the Trainlog RFCOMM service. Android now also repairs a missing bond through the +normal platform pairing flow before attempting RFCOMM. + +The one remaining manual setup step is selecting the Android SAF destination +`Trainlog/AI`. This is intentionally user-authorized provider access and is +not bypassed by ADB or Trainlog. + ## Trust and pairing Bluetooth device names are presentation only and are never authority.