diff --git a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepository.kt b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepository.kt index 026aa70e2..57b327361 100644 --- a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepository.kt +++ b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepository.kt @@ -142,10 +142,31 @@ class AndroidBluetoothRepository( } if (!remoteDevice.createBond()) { - try { - context.unregisterReceiver(receiver) - } catch (ignored: Exception) {} - if (cont.isActive) cont.resumeWith(Result.failure(Exception("Failed to initiate bonding"))) + // createBond() returns false when a bond is already in flight (initiated by the OS or + // triggered by a GATT operation hitting a secured characteristic) or already established. + // The ACTION_BOND_STATE_CHANGED broadcast is unreliable on some devices (see Kable #111), + // so re-check bondState directly rather than failing the whole flow. + when (remoteDevice.bondState) { + android.bluetooth.BluetoothDevice.BOND_BONDED -> { + try { + context.unregisterReceiver(receiver) + } catch (ignored: Exception) {} + if (cont.isActive) cont.resume(Unit) + } + + android.bluetooth.BluetoothDevice.BOND_BONDING -> { + // Bond already in progress; leave the receiver registered to resolve it on the + // terminal BOND_BONDED / BOND_NONE transition instead of treating this as a failure. + Logger.d { "createBond() returned false but bonding is already in progress" } + } + + else -> { + try { + context.unregisterReceiver(receiver) + } catch (ignored: Exception) {} + if (cont.isActive) cont.resumeWith(Result.failure(Exception("Failed to initiate bonding"))) + } + } } } updateBluetoothState() diff --git a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt index 0754671d4..5577e427a 100644 --- a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt +++ b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt @@ -68,26 +68,34 @@ class AndroidScannerViewModel( Logger.i { "Starting bonding for ${entry.device.address.anonymize}" } viewModelScope.launch { @Suppress("TooGenericExceptionCaught") - try { - bluetoothRepository.bond(entry.device) - Logger.i { "Bonding complete for ${entry.device.address.anonymize}, selecting device..." } - changeDeviceAddress(entry.fullAddress) - } catch (ex: SecurityException) { - Logger.w(ex) { "Bonding failed for ${entry.device.address.anonymize} Permissions not granted" } - serviceRepository.setErrorMessage( - text = "Bonding failed: ${ex.message} Permissions not granted", - severity = Severity.Warn, - ) - } catch (ex: Exception) { - // Bonding is often flaky and can fail for many reasons (timeout, user cancel, etc) - val message = ex.message ?: "" - if (message.contains("Received bond state changed 11")) { - // This is a known issue where bonding is still in progress, ignore as error - Logger.d { "Bonding still in progress for ${entry.device.address.anonymize}" } - } else { - Logger.w(ex) { "Bonding failed for ${entry.device.address.anonymize}" } - serviceRepository.setErrorMessage(text = "Bonding failed: ${ex.message}", severity = Severity.Warn) + val armTransport = + try { + bluetoothRepository.bond(entry.device) + Logger.i { "Bonding complete for ${entry.device.address.anonymize}, selecting device..." } + true + } catch (ex: SecurityException) { + // No BLUETOOTH_CONNECT permission — connecting would fail the same way, so surface the + // error and do not arm the transport. + Logger.w(ex) { "Bonding failed for ${entry.device.address.anonymize} Permissions not granted" } + serviceRepository.setErrorMessage( + text = "Bonding failed: ${ex.message} Permissions not granted", + severity = Severity.Warn, + ) + false + } catch (ex: Exception) { + // Bonding is flaky and can fail for many reasons: user cancel/timeout, an unreliable + // ACTION_BOND_STATE_CHANGED broadcast, or an OS/GATT-initiated bond already in flight + // (see Kable #111). Don't treat any of these as terminal — arm the transport anyway so + // its persistent reconnect loop (which bonds on demand and retries with backoff) can + // converge, instead of leaving the device inert until the user re-selects it. + Logger.w(ex) { + "Bonding did not complete cleanly for ${entry.device.address.anonymize}; " + + "arming transport to retry" + } + true } + if (armTransport) { + changeDeviceAddress(entry.fullAddress) } } }