Skip to content

Add L2CAP channel support - #1231

Open
torfinnberset wants to merge 1 commit into
JuulLabs:mainfrom
torfinnberset:feature/l2cap-support
Open

Add L2CAP channel support#1231
torfinnberset wants to merge 1 commit into
JuulLabs:mainfrom
torfinnberset:feature/l2cap-support

Conversation

@torfinnberset

@torfinnberset torfinnberset commented Jul 20, 2026

Copy link
Copy Markdown

Adapted from #1023, and completing the implementation for both iOS and Android. Tested on both platforms with real devices, and also from macOS to iOS.

Adds L2CapSocket, a connection-oriented channel opened from a connected peripheral: AndroidPeripheral.openL2CapChannel / openInsecureL2CapChannel and CoreBluetoothPeripheral.openL2CapChannel. The socket is a bidirectional byte stream (read into a buffer, write a packet, close) exposing isConnected / hasReachedEof state; failures surface as L2CapException.

Android wraps BluetoothDevice.createL2capChannel, connecting on Dispatchers.IO. Apple confines the CBL2CAPChannel NSStreams to a single dedicated run-loop thread (they are not safe to drive from a rotating dispatcher pool) and serializes opens through the connection's guard, since CoreBluetooth's didOpenL2CAPChannel callback carries no PSM and so cannot be correlated to a request; a channel delivered to a cancelled or disconnected open is torn down rather than leaked.

Closes #810 #588 #1023.


Note

Medium Risk
New BLE transport path with substantial Apple concurrency/GC lifecycle logic; failures are mostly additive but incorrect teardown could leave PSMs stuck or leak channels until GC.

Overview
Adds L2CAP CoC alongside GATT: a new L2CapSocket byte-stream API and L2CapException, opened from connected platform peripherals after a GATT link exists.

On Android (API 29+), AndroidPeripheral exposes secure and insecure opens via BluetoothDevice L2CAP sockets, with I/O on Dispatchers.IO and socket cleanup on failed/cancelled connect.

On Apple, CoreBluetoothPeripheral.openL2CapChannel drives CBPeripheral.openL2CAPChannel; opens are single-flight under the connection guard because didOpenL2CAPChannel has no PSM. AppleL2CapSocket runs all NSStream work on a dedicated run-loop thread, retains/releases CBL2CAPChannel for lifecycle (including GC.collect() on close()), and abandons channels delivered after cancel/disconnect to avoid leaks or “PSM already connected”.

README documents usage; JVM API surface is updated in kable-core.api.

Reviewed by Cursor Bugbot for commit 58ed1f4. Configure here.

@torfinnberset
torfinnberset requested review from a team and twyatt as code owners July 20, 2026 12:42
@torfinnberset
torfinnberset requested a review from davertay-j July 20, 2026 12:42
@torfinnberset
torfinnberset force-pushed the feature/l2cap-support branch from 578ea5d to 974c8d9 Compare July 20, 2026 12:50
@twyatt twyatt added the minor Changes that should bump the MINOR version number label Jul 20, 2026
Adds `L2CapSocket`, a connection-oriented channel opened from a connected
peripheral: `AndroidPeripheral.openL2CapChannel` / `openInsecureL2CapChannel`
and `CoreBluetoothPeripheral.openL2CapChannel`. The socket is a bidirectional
byte stream (`read` into a buffer, `write` a packet, `close`) exposing
`isConnected` / `hasReachedEof` state; failures surface as `L2CapException`.

Android wraps `BluetoothDevice.createL2capChannel`, connecting on
`Dispatchers.IO`. Apple confines the `CBL2CAPChannel` NSStreams to a single
dedicated run-loop thread (they are not safe to drive from a rotating
dispatcher pool) and serializes opens through the connection's guard, since
CoreBluetooth's `didOpenL2CAPChannel` callback carries no PSM and so cannot be
correlated to a request; a channel delivered to a cancelled or disconnected
open is torn down rather than leaked.

Closing (or abandoning) a socket must actually free the PSM: CoreBluetooth
only disconnects a channel when the CBL2CAPChannel deallocates — closing
its streams is not enough — and Kotlin/Native releases Obj-C references
at collection time, so teardown drops the channel reference and close()
forces a collection. Without that, reopening the same PSM fails with
"L2CAP PSM already connected" until an incidental GC runs.
@torfinnberset
torfinnberset force-pushed the feature/l2cap-support branch from 974c8d9 to 58ed1f4 Compare July 27, 2026 06:16
@torfinnberset

Copy link
Copy Markdown
Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 58ed1f4. Configure here.

@twyatt

twyatt commented Jul 31, 2026

Copy link
Copy Markdown
Member

Thanks for the PR! I'll try to find time to review this soon.

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

Labels

minor Changes that should bump the MINOR version number

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add support for L2CAP

2 participants