fix(android): repair bluetooth bond on startup

This commit is contained in:
fy59 2026-09-22 16:25:40 +02:00
parent d5b4584ae2
commit 1d8450ad1c
3 changed files with 89 additions and 8 deletions

View file

@ -14,6 +14,9 @@ import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.data.SyncExporter import com.labfytools.trainlog.data.SyncExporter
import com.labfytools.trainlog.data.SyncCatalogInbox import com.labfytools.trainlog.data.SyncCatalogInbox
import com.labfytools.trainlog.data.SyncRequestOutbox 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.TrainlogApp
import com.labfytools.trainlog.ui.TrainlogAppState import com.labfytools.trainlog.ui.TrainlogAppState
import com.labfytools.trainlog.ui.LanguagePresentation import com.labfytools.trainlog.ui.LanguagePresentation
@ -30,6 +33,13 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
languageSettings = LanguageSettingsOwner(applicationContext) languageSettings = LanguageSettingsOwner(applicationContext)
if (
BackgroundSyncSettings(applicationContext).enabled &&
hasBluetoothConnectPermission(applicationContext)
) {
SyncBackgroundService.start(applicationContext)
}
val repository = val repository =
TrainlogRepository( TrainlogRepository(
applicationContext applicationContext

View file

@ -3,11 +3,13 @@ package com.labfytools.trainlog.data
import android.Manifest import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.bluetooth.BluetoothClass import android.bluetooth.BluetoothClass
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothManager import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothSocket import android.bluetooth.BluetoothSocket
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.util.Log
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import java.io.BufferedInputStream import java.io.BufferedInputStream
import java.io.BufferedOutputStream import java.io.BufferedOutputStream
@ -355,6 +357,9 @@ internal class BluetoothSyncClient(
private val context: Context, private val context: Context,
private val repository: TrainlogRepository, private val repository: TrainlogRepository,
) { ) {
companion object {
private const val TAG = "TrainlogBluetoothSync"
}
@Volatile private var activeSocket: BluetoothSocket? = null @Volatile private var activeSocket: BluetoothSocket? = null
private val settings = BluetoothSyncSettings(context) private val settings = BluetoothSyncSettings(context)
private val codec = BluetoothFrameCodec(context.cacheDir) private val codec = BluetoothFrameCodec(context.cacheDir)
@ -374,12 +379,22 @@ internal class BluetoothSyncClient(
continue continue
} }
val address = settings.desktopAddress ?: 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 = val socket =
runCatching { runCatching {
adapter device.createRfcommSocketToServiceRecord(
.getRemoteDevice(address) UUID.fromString(TRAINLOG_BLUETOOTH_SERVICE_UUID)
.createRfcommSocketToServiceRecord(UUID.fromString(TRAINLOG_BLUETOOTH_SERVICE_UUID)) )
}.getOrNull() }
.onFailure { Log.w(TAG, "Unable to create RFCOMM socket", it) }
.getOrNull()
if (socket == null) { if (socket == null) {
sleep(stopped, Duration.ofSeconds(10)) sleep(stopped, Duration.ofSeconds(10))
continue continue
@ -387,10 +402,10 @@ internal class BluetoothSyncClient(
activeSocket = socket activeSocket = socket
try { try {
socket.connect() socket.connect()
Log.i(TAG, "RFCOMM connected to selected Trainlog desktop")
serve(socket, stopped) serve(socket, stopped)
} catch (_: Exception) { } catch (error: Exception) {
// Arrival polling is intentionally silent. A foreground manual Log.w(TAG, "RFCOMM connection ended: ${error.message}", error)
// synchronization still exposes its own correlated diagnostic.
} finally { } finally {
activeSocket = null activeSocket = null
runCatching { socket.close() } runCatching { socket.close() }
@ -404,6 +419,33 @@ internal class BluetoothSyncClient(
activeSocket = null 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) { private fun sleep(stopped: AtomicBoolean, duration: Duration) {
val deadline = System.nanoTime() + duration.toNanos() val deadline = System.nanoTime() + duration.toNanos()
while (!stopped.get() && System.nanoTime() < deadline) { while (!stopped.get() && System.nanoTime() < deadline) {

View file

@ -2,7 +2,7 @@
## Status ## 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 This transport is additive. It does not change generation manifests, artifact
formats, causal deletion, revision ancestry, acknowledgement semantics, SQLite 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 The manual Web and Android Synchroniser actions remain as recovery/forced-sync
controls. 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 ## Trust and pairing
Bluetooth device names are presentation only and are never authority. Bluetooth device names are presentation only and are never authority.