fix(android): repair bluetooth bond on startup
This commit is contained in:
parent
d5b4584ae2
commit
1d8450ad1c
3 changed files with 89 additions and 8 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue