fix(ble): never give up while user has device selected

The reconnect policy previously capped at 10 consecutive failures and
emitted a permanent disconnect, which terminated the reconnect loop and
required the user to manually re-select the device. BleRadioTransport is
only ever instantiated for the user-selected address (verified via
SharedRadioInterfaceService.startTransportLocked), so the only legitimate
permanent-disconnect path is explicit close() owned by the service layer.

- BleRadioTransport: pass maxFailures = Int.MAX_VALUE; backoff still
  caps at 60 s so battery impact remains bounded.
- BleExceptionClassifier: flip UnmetRequirementException (BT off /
  permission missing) to non-permanent — both can resolve without the
  user re-selecting the device.
- Test: replace the old 'gives up after DEFAULT_MAX_FAILURES' test with
  an inverted contract test that runs past the legacy threshold and
  asserts the policy never emits isPermanent=true on its own.
This commit is contained in:
James Rich 2026-04-19 12:45:28 -05:00
parent a6f3a6b4a5
commit 2137ef3410
3 changed files with 28 additions and 14 deletions

View file

@ -26,7 +26,9 @@ import com.juul.kable.UnmetRequirementException
/**
* Classification of a BLE-layer exception for the transport layer to act on.
*
* @property isPermanent `true` if the condition won't resolve without user intervention (e.g. Bluetooth disabled).
* @property isPermanent `true` if the condition cannot resolve without explicit user re-selection of the device.
* Currently always `false` all known BLE exceptions can resolve without user intervention (BT toggling, permission
* grants, transient GATT errors). Reserved for future use.
* @property gattStatus the platform GATT status code when available (Android-specific).
* @property message a human-readable description of the failure.
*/
@ -50,6 +52,9 @@ fun Throwable.classifyBleException(): BleExceptionInfo? = when (this) {
is GattRequestRejectedException ->
BleExceptionInfo(isPermanent = false, message = "GATT request rejected (busy)")
is UnmetRequirementException ->
BleExceptionInfo(isPermanent = true, message = message ?: "Bluetooth LE unavailable")
// Bluetooth disabled or runtime permission missing. Both can resolve without re-selecting the
// device (user re-enables BT, or grants permission). Surface as transient so the transport keeps
// retrying; UI can show a hint based on the message.
BleExceptionInfo(isPermanent = false, message = message ?: "Bluetooth LE unavailable")
else -> null
}