Skip to content

feat(pairing)!: report typed PairingState instead of bool - #308

Draft
arxdeus wants to merge 12 commits into
Navideck:mainfrom
arxdeus:main
Draft

arxdeus wants to merge 12 commits into
Navideck:mainfrom
arxdeus:main

Conversation

@arxdeus

@arxdeus arxdeus commented Sep 15, 2026

Copy link
Copy Markdown

universal_ble resolves user's connect cancellation of Bluetooth pairing as just "not paired", instead of give us exact state

pairingStateStream, onPairingStateChange and the native onPairStateChange callback now emit a PairingState (paired / pairing / rejectedByUser / failed / unpaired).

  • Android: read BluetoothDevice.EXTRA_REASON from ACTION_BOND_STATE_CHANGED and map UNBOND_REASON_AUTH_REJECTED, AUTH_CANCELED and AUTH_TIMEOUT to rejectedByUser.
  • Android: BOND_BONDING no longer completes a pending pair() future; it is surfaced as PairingState.pairing progress.
  • Linux: map BlueZ AuthenticationRejected/Canceled/Timeout to rejectedByUser.

BREAKING CHANGE: pairingStateStream, onPairingStateChange and onPairStateChange expose PairingState instead of bool.

Platforms only reported "paired / not paired", which conflated two very
different outcomes: the user explicitly refusing the system pairing
dialog, and the peripheral being unable to bond at all. Consumers could
not tell them apart, so an app had no way to abort a connection when the
user said no, while still tolerating devices that cannot bond.

`pairingStateStream`, `onPairingStateChange` and the native
`onPairStateChange` callback now emit a `PairingState`
(paired / pairing / rejectedByUser / failed / unpaired).

- Android: read `BluetoothDevice.EXTRA_REASON` from
  ACTION_BOND_STATE_CHANGED and map UNBOND_REASON_AUTH_REJECTED,
  AUTH_CANCELED and AUTH_TIMEOUT to `rejectedByUser`.
- Android: BOND_BONDING no longer completes a pending `pair()` future;
  it is surfaced as `PairingState.pairing` progress.
- Linux: map BlueZ AuthenticationRejected/Canceled/Timeout to
  `rejectedByUser`.

BREAKING CHANGE: `pairingStateStream`, `onPairingStateChange` and
`onPairStateChange` expose `PairingState` instead of `bool`.
The initial PairingState work only classified the outcome on Android and
Linux. Windows kept passing a bool into the regenerated
`OnPairStateChange(..., PairingState, ...)` signature, which no longer
compiles, and Apple reported no pairing outcome at all.

- Windows: map `DevicePairingResultStatus` to `PairingState`, so a
  cancelled prompt or a handler rejection becomes `rejectedByUser` while
  unpairable hardware stays `failed`.
- Apple: CoreBluetooth has no bonding API — pairing is triggered by
  touching an encrypted characteristic, so classify the error of that
  operation and publish the resulting `PairingState`. Errors unrelated to
  the ceremony report nothing instead of a misleading state.

@fotiDim fotiDim left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two correctness issues in the Android reason mapping, plus one minor note.

Minor: lib/src/universal_ble.g.dart is committed as raw Pigeon output (} else if (...), } catch (e) {) rather than formatted code. build_pigeon.sh runs dart format lib/src/universal_ble.g.dart after generation; re-running it would keep the file consistent and remove most of the diff churn. Not a correctness issue.


/// `BluetoothDevice.UNBOND_REASON_AUTH_TIMEOUT` (@hide) — the dialog was
/// shown and simply ignored until it expired, which is a refusal too.
private const val UNBOND_REASON_AUTH_TIMEOUT = 7

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BluetoothDevice.UNBOND_REASON_AUTH_TIMEOUT is 6, not 7 (7 is UNBOND_REASON_REPEATED_ATTEMPTS). As written, a pairing dialog that times out (reason 6) matches no branch and falls through to else -> FAILED, which is the case the enum doc and the comment above intend to surface as rejectedByUser; conversely a repeated-attempts failure (7) is misreported as rejectedByUser. The comment describes value 6, so the literal should be 6.

UNBOND_REASON_AUTH_TIMEOUT,
-> PairingState.REJECTED_BY_USER

BluetoothDevice.ERROR -> PairingState.UNPAIRED

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BluetoothDevice.EXTRA_REASON is populated whenever EXTRA_BOND_STATE == BOND_NONE, and an intentional unpair (removeBond() or the user removing the bond in system settings) sends UNBOND_REASON_REMOVED (9). Since only the BluetoothDevice.ERROR sentinel maps to UNPAIRED, a normal unpair now hits else -> FAILED, emits the "Failed to Pair" error, and completes any pending pair future with false. This is a behavior regression versus the previous "not paired" result and contradicts the unpaired doc ("the bond was removed ... without an attempt being in flight"). Consider mapping UNBOND_REASON_REMOVED (and reason 0, which the stable-state broadcast can carry) to UNPAIRED.

- UNBOND_REASON_AUTH_TIMEOUT is 6, not 7 (7 is REPEATED_ATTEMPTS), so a timed out dialog maps to rejectedByUser and repeated attempts to failed.
- Map UNBOND_REASON_REMOVED (9) and reason 0 to unpaired so an intentional removeBond()/system-settings unpair is no longer reported as a failure.
Android surfaces a removed bond through ACTION_BOND_STATE_CHANGED and Linux through the BlueZ Paired property, but Windows has no equivalent broadcast, so pairingStateStream never reported a successful UnPair().
removeDevice() drops the BlueZ object, so the Paired property change never arrives and pairingStateStream missed the removed bond.
updatePairingState deduped every state, so on Windows and Apple, which have no intermediate 'pairing' event, a second identical refusal was swallowed and a retried pair() produced no result. Dedupe only the steady paired/unpaired states.
toFlutterError() ran the NSError's numeric code through mapErrorCodeToEnum(), which only matches textual codes we raise ourselves, so every CoreBluetooth failure reached Dart as unknownError.

This made the Apple half of PairingState inert: pairingStateFromError() keys off codes such as insufficientAuthentication, which could never arrive, so a refused pairing dialog was never reported as rejectedByUser. Inspect the CBATTError/CBError domains first and fall back to the string mapping.
@arxdeus
arxdeus marked this pull request as draft September 17, 2026 22:47
@fotiDim

fotiDim commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

@arxdeus are you done with the changes? Should the PR be in draft?

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants