Fix BLE companion transport reliability

This commit is contained in:
2026-08-01 00:46:59 +02:00
parent 7bbf7aa4cc
commit 13c9f5d983
4 changed files with 136 additions and 57 deletions
@@ -7,6 +7,7 @@ import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.os.Build
import android.os.ParcelUuid
import android.os.Handler
import android.os.Looper
@@ -33,6 +34,7 @@ class BleClient(private val context: Context) {
private val incoming = StringBuilder()
private val pending = ConcurrentHashMap<String, CompletableDeferred<JSONObject>>()
private val writes = ArrayDeque<ByteArray>()
private var writeInProgress = false
private val handler = Handler(Looper.getMainLooper())
private var stopped = true
@@ -49,6 +51,7 @@ class BleClient(private val context: Context) {
handler.removeCallbacksAndMessages(null)
bluetoothManager.adapter?.bluetoothLeScanner?.stopScan(scanCallback)
gatt?.close(); gatt = null; rx = null
resetWrites()
state.value = ConnectionState.STOPPED
}
@@ -77,49 +80,119 @@ class BleClient(private val context: Context) {
private val gattCallback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
if (newState == BluetoothProfile.STATE_CONNECTED) { this@BleClient.gatt = gatt; gatt.requestMtu(247); gatt.discoverServices() }
if (status == BluetoothGatt.GATT_SUCCESS && newState == BluetoothProfile.STATE_CONNECTED) {
this@BleClient.gatt = gatt
// Android GATT operations are serialized. Starting service
// discovery while the MTU request is still pending can leave
// the client in CONNECTING forever on some phones.
if (!gatt.requestMtu(247)) gatt.discoverServices()
}
else {
rx = null; gatt.close(); state.value = ConnectionState.DISCONNECTED
rx = null; gatt.close(); resetWrites(); state.value = ConnectionState.DISCONNECTED
if (!stopped) handler.postDelayed({ connect() }, 3_000)
}
}
override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
gatt.discoverServices()
}
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
val service = gatt.getService(SERVICE_UUID) ?: return
rx = service.getCharacteristic(RX_UUID)
val tx = service.getCharacteristic(TX_UUID) ?: return
gatt.setCharacteristicNotification(tx, true)
tx.getDescriptor(CCC_UUID)?.let { descriptor ->
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
val service = gatt.getService(SERVICE_UUID)
val nextRx = service?.getCharacteristic(RX_UUID)
val tx = service?.getCharacteristic(TX_UUID)
val descriptor = tx?.getDescriptor(CCC_UUID)
if (status != BluetoothGatt.GATT_SUCCESS || nextRx == null || tx == null || descriptor == null) {
gatt.disconnect()
return
}
rx = nextRx
if (!gatt.setCharacteristicNotification(tx, true)) {
gatt.disconnect()
return
}
val descriptorStarted = if (Build.VERSION.SDK_INT >= 33) {
gatt.writeDescriptor(descriptor, BluetoothGattDescriptor.ENABLE_INDICATION_VALUE) == BluetoothStatusCodes.SUCCESS
} else {
descriptor.value = BluetoothGattDescriptor.ENABLE_INDICATION_VALUE
gatt.writeDescriptor(descriptor)
}
state.value = ConnectionState.CONNECTED
if (!descriptorStarted) gatt.disconnect()
}
override fun onDescriptorWrite(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
if (descriptor.uuid == CCC_UUID && status == BluetoothGatt.GATT_SUCCESS) {
state.value = ConnectionState.CONNECTED
} else {
gatt.disconnect()
}
}
@Deprecated("Used on Android 12 and earlier")
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
incoming.append(characteristic.value.toString(Charsets.UTF_8))
while (incoming.contains("\n")) {
val index = incoming.indexOf("\n")
val line = incoming.substring(0, index); incoming.delete(0, index + 1)
runCatching { JSONObject(line) }.getOrNull()?.let { response ->
pending.remove(response.optString("id"))?.let { deferred ->
if (response.optBoolean("ok")) deferred.complete(response.getJSONObject("data"))
else deferred.completeExceptionally(IllegalStateException(response.optJSONObject("error")?.optString("message") ?: "Bluetooth request failed"))
}
handleIncoming(characteristic.value)
}
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) {
handleIncoming(value)
}
override fun onCharacteristicWrite(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
if (status == BluetoothGatt.GATT_SUCCESS) writeNext() else {
synchronized(this@BleClient) { writeInProgress = false }
gatt.disconnect()
}
}
}
private fun handleIncoming(value: ByteArray) {
incoming.append(value.toString(Charsets.UTF_8))
while (incoming.contains("\n")) {
val index = incoming.indexOf("\n")
val line = incoming.substring(0, index); incoming.delete(0, index + 1)
runCatching { JSONObject(line) }.getOrNull()?.let { response ->
pending.remove(response.optString("id"))?.let { deferred ->
if (response.optBoolean("ok")) deferred.complete(response.getJSONObject("data"))
else deferred.completeExceptionally(IllegalStateException(response.optJSONObject("error")?.optString("message") ?: "Bluetooth request failed"))
}
}
}
override fun onCharacteristicWrite(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) { writeNext() }
}
@Synchronized private fun enqueue(bytes: ByteArray) {
bytes.asList().chunked(180).forEach { chunk -> writes.add(chunk.toByteArray()) }
if (writes.size == bytes.asList().chunked(180).size) writeNext()
val chunks = bytes.asList().chunked(180)
chunks.forEach { chunk -> writes.add(chunk.toByteArray()) }
if (!writeInProgress) {
writeInProgress = true
writeNext()
}
}
@Synchronized private fun writeNext() {
val characteristic = rx ?: return
val next = writes.removeFirstOrNull() ?: return
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
characteristic.value = next
gatt?.writeCharacteristic(characteristic)
val characteristic = rx
val currentGatt = gatt
if (characteristic == null || currentGatt == null) {
writeInProgress = false
return
}
val next = writes.removeFirstOrNull()
if (next == null) {
writeInProgress = false
return
}
val started = if (Build.VERSION.SDK_INT >= 33) {
currentGatt.writeCharacteristic(characteristic, next, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT) == BluetoothStatusCodes.SUCCESS
} else {
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
characteristic.value = next
currentGatt.writeCharacteristic(characteristic)
}
if (!started) {
writeInProgress = false
currentGatt.disconnect()
}
}
@Synchronized private fun resetWrites() {
writes.clear()
writeInProgress = false
}
}