Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,42 @@ When a write type is not specified, [`WithoutResponse`] is used.
> [!NOTE]
> _Write type only applies to characteristic writes (descriptor writes are always acknowledged by the peripheral)._

### L2CAP

In addition to GATT, a connected peripheral can open an L2CAP connection-oriented channel (CoC) on a PSM
(protocol/service multiplexer). Unlike GATT's message-based characteristics, a channel is a bidirectional
byte stream, so callers are responsible for framing their own protocol. L2CAP is available on Android and
Apple platforms (it has no Web Bluetooth equivalent), so a channel is opened from the platform-specific
peripheral.

On Android (requires API level 29 or higher):

```kotlin
val socket = (peripheral as AndroidPeripheral).openL2CapChannel(psm = 0x0080)
// or `openInsecureL2CapChannel(...)` for a channel without authentication/encryption
```

On Apple platforms:

```kotlin
val socket = (peripheral as CoreBluetoothPeripheral).openL2CapChannel(psm = 0x0080)
```

The returned `L2CapSocket` is already open. Read into a caller-provided buffer, and write whole packets:

```kotlin
val buffer = ByteArray(1024)
val count = socket.read(buffer) // suspends until data arrives; returns -1 at end-of-stream

socket.write(byteArrayOf(1, 2, 3))

socket.close()
```

> [!NOTE]
> _Opening a channel throws `L2CapException` on failure. `read` should be called from a single coroutine
> at a time; `write` may run concurrently with it._

### Observation

Bluetooth Low Energy provides the capability of subscribing to characteristic changes by means of notifications and/or
Expand Down
14 changes: 14 additions & 0 deletions kable-core/api/jvm/kable-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,20 @@ public final class com/juul/kable/InternalError : java/lang/Error {
public abstract interface annotation class com/juul/kable/InternalKableApi : java/lang/annotation/Annotation {
}

public final class com/juul/kable/L2CapException : java/io/IOException {
public fun <init> (Ljava/lang/String;Ljava/lang/Throwable;J)V
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/Throwable;JILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun getCode ()J
}

public abstract interface class com/juul/kable/L2CapSocket {
public abstract fun close (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public abstract fun getHasReachedEof ()Lkotlinx/coroutines/flow/StateFlow;
public abstract fun isConnected ()Lkotlinx/coroutines/flow/StateFlow;
public abstract fun read ([BLkotlin/coroutines/Continuation;)Ljava/lang/Object;
public abstract fun write ([BLkotlin/coroutines/Continuation;)Ljava/lang/Object;
}

public final class com/juul/kable/LazyCharacteristic : com/juul/kable/Characteristic {
public final fun component1 ()Lkotlin/uuid/Uuid;
public final fun component2 ()Lkotlin/uuid/Uuid;
Expand Down
83 changes: 83 additions & 0 deletions kable-core/src/androidMain/kotlin/AndroidL2CapSocket.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package com.juul.kable

import android.bluetooth.BluetoothSocket
import android.bluetooth.BluetoothSocketException
import android.os.Build
import com.juul.kable.logs.Logger
import com.juul.kable.logs.Logging
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import kotlin.coroutines.cancellation.CancellationException

internal class AndroidL2CapSocket(
private val socket: BluetoothSocket,
logging: Logging,
) : L2CapSocket {

private val logger = Logger(logging, "Kable/L2CapSocket", socket.remoteDevice.address)

init {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
logger.info {
message = "L2CAP MTU tx=${socket.maxTransmitPacketSize}, rx=${socket.maxReceivePacketSize}"
}
}
}

private val inputStream = socket.inputStream
private val outputStream = socket.outputStream

private val _isConnected = MutableStateFlow(true)
override val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()

private val _hasReachedEof = MutableStateFlow(false)
override val hasReachedEof: StateFlow<Boolean> = _hasReachedEof.asStateFlow()

override suspend fun read(buffer: ByteArray): Int {
try {
val count = withContext(Dispatchers.IO) {
inputStream.read(buffer)
}
if (count < 0) {
_hasReachedEof.value = true
_isConnected.value = false
}
return count
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
_isConnected.value = false
logger.error(e) { message = "Failed to read bytes" }
throw e.toL2CapException()
}
}

override suspend fun write(packet: ByteArray) {
try {
withContext(Dispatchers.IO) {
outputStream.write(packet)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
_isConnected.value = false
logger.error(e) { message = "Failed to write packet" }
throw e.toL2CapException()
}
}

override suspend fun close() {
_isConnected.value = false
socket.close()
}
}

internal fun Exception.toL2CapException(): L2CapException =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && this is BluetoothSocketException) {
L2CapException(message, this, errorCode.toLong())
} else {
L2CapException(message, this, 0)
}
22 changes: 22 additions & 0 deletions kable-core/src/androidMain/kotlin/AndroidPeripheral.kt
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,28 @@ public interface AndroidPeripheral : Peripheral {
*/
public suspend fun requestMtu(mtu: Int): Int

/**
* Opens a secure (authenticated and encrypted) L2CAP channel to the peripheral on the given [psm],
* suspending until the channel is connected. The returned [L2CapSocket] is already connected.
*
* Requires [API level 29][Build.VERSION_CODES.Q] or higher.
*
* @see android.bluetooth.BluetoothDevice.createL2capChannel
* @throws L2CapException if the channel could not be opened.
*/
public suspend fun openL2CapChannel(psm: Int): L2CapSocket

/**
* Opens an insecure (no authentication or encryption) L2CAP channel to the peripheral on the given
* [psm], suspending until the channel is connected. The returned [L2CapSocket] is already connected.
*
* Requires [API level 29][Build.VERSION_CODES.Q] or higher.
*
* @see android.bluetooth.BluetoothDevice.createInsecureL2capChannel
* @throws L2CapException if the channel could not be opened.
*/
public suspend fun openInsecureL2CapChannel(psm: Int): L2CapSocket

/**
* @see Peripheral.write
* @throws NotConnectedException if invoked without an established [connection][connect].
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ import android.bluetooth.BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
import android.bluetooth.BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE
import android.bluetooth.BluetoothGattDescriptor.ENABLE_INDICATION_VALUE
import android.bluetooth.BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
import android.bluetooth.BluetoothSocket
import android.os.Build
import androidx.annotation.RequiresApi
import com.juul.kable.AndroidPeripheral.Priority
import com.juul.kable.AndroidPeripheral.Type
import com.juul.kable.State.Disconnected
Expand All @@ -34,13 +37,16 @@ import com.juul.kable.logs.Logging
import com.juul.kable.logs.Logging.DataProcessor.Operation
import com.juul.kable.logs.detail
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext
import java.io.IOException
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Duration

Expand Down Expand Up @@ -192,6 +198,46 @@ internal class BluetoothDeviceAndroidPeripheral(
return connectionOrThrow().requestMtu(mtu)
}

@RequiresApi(Build.VERSION_CODES.Q)
override suspend fun openL2CapChannel(psm: Int): L2CapSocket {
connectionOrThrow()
return connectL2CapSocket { bluetoothDevice.createL2capChannel(psm) }
}
Comment thread
torfinnberset marked this conversation as resolved.

@RequiresApi(Build.VERSION_CODES.Q)
override suspend fun openInsecureL2CapChannel(psm: Int): L2CapSocket {
connectionOrThrow()
return connectL2CapSocket { bluetoothDevice.createInsecureL2capChannel(psm) }
}

private suspend fun connectL2CapSocket(createSocket: () -> BluetoothSocket): L2CapSocket {
val socket = try {
createSocket()
} catch (e: IOException) {
throw e.toL2CapException()
}
try {
withContext(Dispatchers.IO) { socket.connect() }
} catch (e: CancellationException) {
socket.closeOrLog()
throw e
} catch (e: IOException) {
socket.closeOrLog()
throw e.toL2CapException()
}
return AndroidL2CapSocket(socket, logging)
}
Comment thread
torfinnberset marked this conversation as resolved.
Comment thread
torfinnberset marked this conversation as resolved.

// BluetoothSocket.close() aborts an in-progress connect, so a failed or cancelled open never leaks
// the socket (connect() is a blocking call that a cancelled coroutine cannot interrupt).
private fun BluetoothSocket.closeOrLog() {
try {
close()
} catch (e: IOException) {
logger.warn(e) { message = "Failed to close L2CAP socket" }
}
}

override suspend fun write(
characteristic: Characteristic,
data: ByteArray,
Expand Down
Loading