Skip to content

feat: generic serial (SPP/RFCOMM) transport for non-iOS platforms - #18

Merged
fotiDim merged 19 commits into
mainfrom
feat/spp-rfcomm-transport
Oct 16, 2026
Merged

fotiDim merged 19 commits into
mainfrom
feat/spp-rfcomm-transport

Conversation

@fotiDim

@fotiDim fotiDim commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Closes #17.

Adds a generic Bluetooth Classic serial (SPP/RFCOMM) transport so the unified connect / send / onData / disconnect API can reach non-MFi serial accessories on Android, macOS, and Windows. iOS keeps External Accessory; Linux is documented as unsupported.

Design decisions (from issue #17)

  1. Transport selection — an explicit option on connect, defaulting to the current behavior:
    Future<void> connect(String deviceId, {
      BluetoothTransport? transport,
      String? serviceUuid,
    });
    There is no auto enum member: omitting transport (or passing null) selects the platform's natural transport — HID on Android and macOS, serial (SPP/RFCOMM) on Windows, and an External Accessory session on iOS. spp opens an RFCOMM connection.
  2. Connection source — spp added to BluetoothConnectionSource, alongside hid / externalAccessory / system.
  3. Service UUID — serviceUuid is optional and defaults to the well-known sppServiceUuid for spp; custom UUIDs are passed through as canonical strings.
  4. setupSdp — untouched. It is the HID device (server) role; this transport is the host/client role, and SPP server is out of scope.

send / disconnect / onData signatures are unchanged. AccessoryManager tracks the active transport per device and routes accordingly. Android tries the secure RFCOMM socket first and falls back to insecure.

Note: adding the optional transport / serviceUuid parameters to connect is a source-breaking change for custom UniversalBluetoothInterface implementations (callers are unaffected). Called out in the CHANGELOG.

Changes

  • New Pigeon channel pigeon/bluetooth_serial_manager.dart → Dart, Android Kotlin, macOS Swift, Windows C++ (BluetoothSerialPlatformChannel + BluetoothSerialCallbackChannel). The C++ output uses the universal_bluetooth_classic_serial namespace so it can share a translation unit with universal_bluetooth.g.h (both emit a FlutterError type).
  • Android — BluetoothSerialManager.kt: background RFCOMM connect (secure → insecure), read loop to onData, connect/disconnect events; registered by UniversalBluetoothPlugin.
  • macOS — BluetoothSerialManager.swift: SDP query → getRFCOMMChannelID → openRFCOMMChannelSync; a dedicated class to avoid the send / disconnect selectors shared with the HID protocol.
  • Windows — bluetooth_serial_manager.{h,cpp}: RfcommDeviceService + StreamSocket + DataReader loop; added to CMakeLists.txt. Windows gains connect / send / onData / disconnect via SPP (HID remains unimplemented).
  • Linux — connect throws a clear UnsupportedError (the bluez package exposes no RFCOMM socket API); documented as unsupported.
  • iOS — unchanged; transport: spp fails explicitly.
  • Connection lifecycle — connect rejects a second concurrent connect to the same device with a StateError instead of overwriting the transport the first one recorded, and disconnect waits for an in-flight connect before closing. The Android HID connect / disconnect keep converting a BluetoothHidDevice proxy exception (for example a SecurityException when BLUETOOTH_CONNECT is not granted) into a failure reply instead of leaving the caller on a generic channel error. closeSdp throws UnsupportedError where SDP registration is unsupported.
  • Docs — README support matrix + new "Serial (SPP/RFCOMM)" section with a repeatable manual verification path; CHANGELOG; CONTRIBUTING (Pigeon table/commands, transports); AGENTS.
  • Tests/example — Dart routing tests (including the concurrent-connect and pending-disconnect cases) and example SPP controls.

Verification

  • flutter analyze, flutter test, flutter test --platform chrome — green.
  • dart format clean; Pigeon regenerates with no drift on existing channels.
  • flutter build macos --debug — built.
  • flutter build apk --debug — built.

Not verifiable in the author's environment: the Windows build (no toolchain) and any real hardware RFCOMM loopback. The Windows code follows the documented RfcommDeviceService.GetDeviceSelectorForBluetoothDeviceAndServiceId + FromIdAsync + StreamSocket.ConnectAsync pattern and still needs a flutter build windows; a cross-platform RFCOMM byte-exchange test against hardware is documented in the README.

Follow-ups

  • Linux native plugin (raw BlueZ RFCOMM sockets).
  • Optional SPP server (device role).
  • Optional caller-selectable secure/insecure RFCOMM on Android.

Add connect(id, {transport, serviceUuid}) so the unified
connect/send/onData/disconnect API can reach serial accessories over
RFCOMM on Android, macOS, and Windows. iOS keeps External Accessory and
is rejected explicitly; Linux is documented as unsupported.

- New Pigeon channel BluetoothSerialPlatformChannel (Dart + Android,
  macOS, Windows). The C++ output uses its own namespace to coexist with
  the existing generated header.
- Add BluetoothTransport and sppServiceUuid, and add spp to
  BluetoothConnectionSource; AccessoryManager routes by active transport.
- README support matrix and Serial (SPP/RFCOMM) section, CHANGELOG,
  CONTRIBUTING and AGENTS updates.
- Dart routing tests and example SPP controls.
…egen

- Extract TransportRouter (connect resolution + active-transport tracking)
  and unit test it, including auto -> serial on Windows. Callback handlers
  share the router instead of reaching back through the singleton, and clear
  the tracked transport when a device disconnects.
- BluetoothTransport.auto resolves to the serial transport on Windows, where
  HID is not implemented. Update the interface/facade docs, README, AGENTS and
  CHANGELOG to match.
- Move the hand-maintained BluetoothDevice / DeviceClass / DeviceType doc
  comments into pigeon/universal_bluetooth.dart so regenerating that channel no
  longer drops them. Regenerating is now idempotent.
- Android: make serial send async and write on a single-thread executor so
  blocking socket writes stay off the platform thread; shut the executor down
  on dispose.
- Windows: send via a fire-and-forget coroutine instead of blocking on
  StoreAsync().get().
- macOS: send off the platform thread, and chunk writes to UInt16 sizes so
  payloads over 64 KiB are no longer silently truncated.
- macOS: reject a concurrent connect while an SDP query is pending instead of
  dropping the first completion.
- Dart: keep the last transport per device across a spontaneous disconnect so a
  later disconnect/send is not misrouted to HID; drop the transport if connect
  fails.
- Guard native read-loop teardown with the socket/generation it opened so a
  fast disconnect-then-reconnect cannot tear down the new connection.

Regenerated the serial Pigeon outputs for the async send signature.
- Take TransportRouter's platform as a required argument so the routing rules
  contain no platform check; AccessoryManager, a platform class, supplies
  defaultTargetPlatform.
- Report only spp as unsupported on Linux, leaving auto/hid requests with the
  base class's unimplemented error instead of blaming the serial transport.
- Own the Windows BluetoothSerialManager with a shared_ptr and hold a
  reference across its coroutines and posted callbacks, so a teardown that
  drops the plugin's reference cannot destroy it mid-flight.
- Report an already connected Windows device instead of opening a second
  socket, matching Android and macOS, and close any socket displaced by a
  concurrent connect.
- Explain in the iOS rejection why Apple exposes no RFCOMM socket or SDP API.
Record that iOS exposes no RFCOMM socket or SDP API, that MFi/iAP2 accessories
carry their data channel over RFCOMM through an EASession, and that spp must
not be routed to the External Accessory channel. Also note the TransportRouter
platform rule, explain why the serial transport is absent on iOS, and pass a
genuinely custom UUID in the service UUID example.
Bump the plugin version to 1.2.0 in pubspec.yaml and both podspecs, turn the
CHANGELOG's Unreleased section into 1.2.0, and update the README install
snippet and the example lockfile to match.
The repository moved from Navideck/universal_bluetooth to
Navideck/universal_bluetooth_classic. Update the pubspec repository,
issue_tracker and documentation fields and the README badges to match, so
pub.dev and the badges no longer depend on GitHub's redirect.
pana withholds points when a dependency constraint does not support the latest
stable release, and bluez 0.9.0 was already published. Bump the constraint: the
BlueZ implementation analyzes and passes its checks unchanged against 0.9.0
(which pulls in dbus 0.8.0), and bluez 0.9.0 requires Dart >=3.0.0, inside our
declared lower bound.
Regenerate every Pigeon binding with Pigeon 29.0.10 and follow the
upstream breaking changes in the hand-written native code:

* `@HostApi @async` and `@FlutterApi` methods are now suspending/`async`.
  Android now uses `CompletableDeferred` and coroutines (new
  `kotlinx-coroutines-android` dependency); the Apple implementations use
  `async`/`await` with continuations.
* Generated Swift data classes conform to `CustomStringConvertible`, so a
  field named `description` is rejected.
* Generated Swift uses Swift concurrency, which requires an iOS 13
  deployment target.

BREAKING CHANGE: `AndroidSdpConfig.description` is renamed to
`AndroidSdpConfig.serviceDescription` to satisfy Pigeon's Swift validation.
Resolve two couplings that forced compromises during the Pigeon migration.

* SDP registration no longer goes through a cross-platform `SdpConfig`
  union in Pigeon. Android and macOS each get their own channel
  (`pigeon/android_sdp.dart`, `pigeon/mac_sdp.dart`), so neither platform's
  generated code carries the other platform's config. That lets
  `AndroidSdpConfig.description` keep its name (the Swift generator rejects
  a field named `description`). The public `SdpConfig` is now hand-written
  Dart in `lib/src/sdp_config.dart`; its shape, and every call site, are
  unchanged. On Windows `setupSdp` now throws `UnsupportedError` instead of
  surfacing `MissingPluginException`.
* Callbacks moved off the six `static` fields on
  `UniversalBluetoothInterface` onto a `UniversalBluetoothCallbacks`
  registry that the interface exposes as `callbacks`. `UniversalBluetooth`
  owns the registry and injects it into the active implementation, so the
  `UniversalBluetooth.onXxx` setters are unchanged.

BREAKING CHANGE: a custom `UniversalBluetoothInterface` implementation must
forward its inbound events to `callbacks` instead of the removed static
fields.
- Android: reject a second concurrent serial connect with an InProgress
  error. The socket is opened on a background thread and only recorded
  once it is up, so an overlapping connect used to overwrite the entry
  and leak the first RFCOMM socket. Mirrors the macOS guard.
- iOS: throw UnsupportedError for BluetoothTransport.hid instead of
  silently opening an EASession, so an unsupported transport fails clearly.
- Docs: fix the README HID/Windows contradiction and document that iOS
  rejects hid (README transport section + AGENTS.md invariant).
- Revert the repository URL change. pana validates the pubspec repository
  against the pubspec on the default branch, so this branch cannot pass
  while main still points at the old URL; the URL migration belongs on
  main. pana is back to 160/160.
Replace the numbered footnotes on the support matrix with two tables on
separate axes: a platform x API support matrix with no footnotes, and a
platform x transport table carrying each platform's transport availability
and the BluetoothTransport.auto default. The per-platform identifier and
Linux disconnect details move to prose. Update the AGENTS.md documentation
invariant to cover both tables.
Transpose the platform x transport table so platforms are column headers,
matching the API support matrix.
Replace the `auto` enum member with a nullable `transport` argument so
omitting it selects the platform default (HID on Android and macOS, serial
on Windows, External Accessory on iOS). Add an `externalAccessory` member
for iOS. Reshape the README transport table to channel rows only and
document the default in prose. No caller-facing break: `connect(id)` still
compiles.
- macOS: run RFCOMM writes on a serial queue so overlapping sends cannot
  interleave, and key the pending-connect / open-channel maps by
  IOBluetooth's canonical address so an SDP completion always resolves the
  connect instead of leaving it hanging.
- Windows: queue sends so writes run one at a time, reject a malformed
  device address instead of silently using 0, and own the serial manager
  from the plugin so it is disposed with the engine rather than a
  never-reset global.
- Android: close the secure socket when connect fails with any exception,
  not only IOException, so it cannot leak before the insecure fallback.
- docs: correct the stale HID-report platform line.
- test: cover AccessoryManager transport routing (spp, default HID, and
  the connect-failure fallback).
Pigeon 29 turns @async into Kotlin coroutines and Swift concurrency
(suspend / async / @mainactor), which pulled in kotlinx-coroutines-android,
forced the native code onto continuations, and raised the iOS minimum to
13.0. Keep Pigeon 29 but switch every async method to @asynccallback on both
@HostApi and @FlutterApi, so the generated Kotlin and Swift stay callback
based.

- pigeon/*: @async -> @asynccallback (+ annotate @FlutterApi methods) and
  regenerate all bindings. The Windows C++ output is unaffected.
- Android: reimplement the HID, serial, and accessory host methods with
  completion callbacks; drop the coroutine scope from postCallback; remove
  the kotlinx-coroutines-android dependency.
- macOS/iOS: reimplement connect/pair/unpair/openSession/send/disconnect/
  closeSession with completion handlers; remove Task/@MainActor/
  withCheckedThrowingContinuation.
- Dart: the generated @FlutterApi methods now return Future, so the callback
  handlers return Future too.
- Restore the iOS 12 deployment target and drop the iOS 13 README note.
- Record the @asynccallback rule in AGENTS.md and CONTRIBUTING.md, and update
  the CHANGELOG.
Bundled fixes for the SPP/RFCOMM review:

- Android HID connect/disconnect again wrap the BluetoothHidDevice proxy
  calls in a try/catch, so a thrown exception (for example a
  SecurityException when BLUETOOTH_CONNECT is not granted) becomes a
  descriptive failure reply instead of an empty reply that the Dart side
  reports as a generic channel error.
- AccessoryManager rejects a second concurrent connect for the same device
  with a StateError, so it cannot overwrite the transport the first connect
  recorded, and disconnect waits for an in-flight connect before closing so
  the connection it opens is actually torn down.
- closeSdp throws UnsupportedError on platforms without SDP registration
  (Windows) instead of reaching an unregistered channel.
- ExternalAccessory.connect and AccessoryManagerBluez.connect return a
  failed Future instead of throwing synchronously, so catchError callers see
  the unsupported-transport error too.

Adds coverage for the concurrent connect, pending disconnect, and closeSdp
cases; updates CHANGELOG and README.
@fotiDim
fotiDim merged commit 28efc2e into main Oct 16, 2026
2 checks passed
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.

Add a generic serial (SPP/RFCOMM) transport for non-iOS platforms

2 participants