Repository navigation
feat: generic serial (SPP/RFCOMM) transport for non-iOS platforms - #18
Merged
Merged
Conversation
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.
rohitsangwan01
approved these changes
Oct 15, 2026
- 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.
rohitsangwan01
approved these changes
Oct 16, 2026
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.
rohitsangwan01
approved these changes
Oct 16, 2026
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #17.
Adds a generic Bluetooth Classic serial (SPP/RFCOMM) transport so the unified
connect/send/onData/disconnectAPI 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)
connect, defaulting to the current behavior:autoenum member: omittingtransport(or passingnull) selects the platform's natural transport — HID on Android and macOS, serial (SPP/RFCOMM) on Windows, and an External Accessory session on iOS.sppopens an RFCOMM connection.sppadded toBluetoothConnectionSource, alongsidehid/externalAccessory/system.serviceUuidis optional and defaults to the well-knownsppServiceUuidforspp; custom UUIDs are passed through as canonical strings.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/onDatasignatures are unchanged.AccessoryManagertracks the active transport per device and routes accordingly. Android tries the secure RFCOMM socket first and falls back to insecure.Changes
pigeon/bluetooth_serial_manager.dart→ Dart, Android Kotlin, macOS Swift, Windows C++ (BluetoothSerialPlatformChannel+BluetoothSerialCallbackChannel). The C++ output uses theuniversal_bluetooth_classic_serialnamespace so it can share a translation unit withuniversal_bluetooth.g.h(both emit aFlutterErrortype).BluetoothSerialManager.kt: background RFCOMM connect (secure → insecure), read loop toonData, connect/disconnect events; registered byUniversalBluetoothPlugin.BluetoothSerialManager.swift: SDP query →getRFCOMMChannelID→openRFCOMMChannelSync; a dedicated class to avoid thesend/disconnectselectors shared with the HID protocol.bluetooth_serial_manager.{h,cpp}:RfcommDeviceService+StreamSocket+DataReaderloop; added toCMakeLists.txt. Windows gainsconnect/send/onData/disconnectvia SPP (HID remains unimplemented).connectthrows a clearUnsupportedError(thebluezpackage exposes no RFCOMM socket API); documented as unsupported.transport: sppfails explicitly.connectrejects a second concurrent connect to the same device with aStateErrorinstead of overwriting the transport the first one recorded, anddisconnectwaits for an in-flight connect before closing. The Android HIDconnect/disconnectkeep converting aBluetoothHidDeviceproxy exception (for example aSecurityExceptionwhenBLUETOOTH_CONNECTis not granted) into a failure reply instead of leaving the caller on a generic channel error.closeSdpthrowsUnsupportedErrorwhere SDP registration is unsupported.Verification
flutter analyze,flutter test,flutter test --platform chrome— green.dart formatclean; 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.ConnectAsyncpattern and still needs aflutter build windows; a cross-platform RFCOMM byte-exchange test against hardware is documented in the README.Follow-ups