diff --git a/kable-core/src/androidMain/kotlin/AndroidL2CapSocket.kt b/kable-core/src/androidMain/kotlin/AndroidL2CapSocket.kt new file mode 100644 index 000000000..651103bb8 --- /dev/null +++ b/kable-core/src/androidMain/kotlin/AndroidL2CapSocket.kt @@ -0,0 +1,43 @@ +package com.juul.kable + +import android.bluetooth.BluetoothSocket +import android.bluetooth.BluetoothSocketException +import android.os.Build +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +internal class AndroidL2CapSocket( + socket: BluetoothSocket, +) : L2CapSocket { + private val inputStream = socket.inputStream + private val outputStream = socket.outputStream + + override val isReady: StateFlow = MutableStateFlow(true) + + override suspend fun receive(maxBytesToRead: Int): ByteArray { + try { + val buffer = ByteArray(maxBytesToRead) + val bytesRead = inputStream.read(buffer) + return buffer.take(bytesRead).toByteArray() + } catch (e: Exception) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && e is BluetoothSocketException) { + throw L2CapException(e.message, e, e.errorCode.toLong()) + } else { + throw L2CapException(e.message, e, 0) + } + } + } + + override suspend fun send(packet: ByteArray): Long { + try { + outputStream.write(packet) + return packet.size.toLong() + } catch (e: Exception) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && e is BluetoothSocketException) { + throw L2CapException(e.message, e, e.errorCode.toLong()) + } else { + throw L2CapException(e.message, e, 0) + } + } + } +} \ No newline at end of file diff --git a/kable-core/src/androidMain/kotlin/AndroidPeripheral.kt b/kable-core/src/androidMain/kotlin/AndroidPeripheral.kt index cc7653910..40939007a 100644 --- a/kable-core/src/androidMain/kotlin/AndroidPeripheral.kt +++ b/kable-core/src/androidMain/kotlin/AndroidPeripheral.kt @@ -136,6 +136,9 @@ public interface AndroidPeripheral : Peripheral { */ public suspend fun requestMtu(mtu: Int): Int + public suspend fun createInsecureL2capChannel(psm: Int): L2CapSocket + public suspend fun createL2capChannel(psm: Int): L2CapSocket + /** * @see Peripheral.write * @throws NotConnectedException if invoked without an established [connection][connect]. diff --git a/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt b/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt index 249012f87..e1f20b9a2 100644 --- a/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt +++ b/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt @@ -15,6 +15,8 @@ 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.os.Build +import androidx.annotation.RequiresApi import com.juul.kable.AndroidPeripheral.Priority import com.juul.kable.AndroidPeripheral.Type import com.juul.kable.State.Disconnected @@ -192,6 +194,20 @@ internal class BluetoothDeviceAndroidPeripheral( return connectionOrThrow().requestMtu(mtu) } + @RequiresApi(Build.VERSION_CODES.Q) + override suspend fun createInsecureL2capChannel(psm: Int): L2CapSocket { + val socket = bluetoothDevice.createInsecureL2capChannel(psm) + socket.connect() + return AndroidL2CapSocket(socket) + } + + @RequiresApi(Build.VERSION_CODES.Q) + override suspend fun createL2capChannel(psm: Int): L2CapSocket { + val socket = bluetoothDevice.createL2capChannel(psm) + socket.connect() + return AndroidL2CapSocket(socket) + } + override suspend fun write( characteristic: Characteristic, data: ByteArray, diff --git a/kable-core/src/appleMain/kotlin/AppleL2CapSocket.kt b/kable-core/src/appleMain/kotlin/AppleL2CapSocket.kt new file mode 100644 index 000000000..ed5bfb33e --- /dev/null +++ b/kable-core/src/appleMain/kotlin/AppleL2CapSocket.kt @@ -0,0 +1,94 @@ +package com.juul.kable + +import kotlinx.cinterop.CPointer +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.UByteVar +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.allocArray +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.set +import kotlinx.cinterop.usePinned +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import platform.CoreBluetooth.CBL2CAPChannel +import platform.CoreFoundation.kCFStreamEventCanAcceptBytes +import platform.CoreFoundation.kCFStreamEventEndEncountered +import platform.CoreFoundation.kCFStreamEventErrorOccurred +import platform.CoreFoundation.kCFStreamEventHasBytesAvailable +import platform.CoreFoundation.kCFStreamEventOpenCompleted +import platform.Foundation.NSStream +import platform.Foundation.NSStreamDelegateProtocol +import platform.Foundation.NSStreamEvent +import platform.darwin.NSObject +import kotlin.concurrent.atomics.AtomicInt +import kotlin.concurrent.atomics.ExperimentalAtomicApi + +@OptIn(ExperimentalAtomicApi::class) +internal class AppleL2CapSocket(private val channel: CBL2CAPChannel) : L2CapSocket { + private val inputStream = channel.inputStream + private val outputStream = channel.outputStream + + private val _isReady = MutableStateFlow(false) + override val isReady: StateFlow = _isReady + + private var openChannels = AtomicInt(0) + + private val delegate = object : NSObject(), NSStreamDelegateProtocol { + override fun stream(aStream: NSStream, handleEvent: NSStreamEvent) { + super.stream(aStream, handleEvent) + when (handleEvent) { + kCFStreamEventOpenCompleted -> { + val value = openChannels.addAndFetch(1) + if (value == 2) _isReady.tryEmit(true) + } + + kCFStreamEventHasBytesAvailable -> {} + kCFStreamEventCanAcceptBytes -> {} + kCFStreamEventErrorOccurred -> {} + kCFStreamEventEndEncountered -> {} + } + } + } + + init { + inputStream?.open() + outputStream?.open() + } + + @OptIn(ExperimentalForeignApi::class) + override suspend fun receive(maxBytesToRead: Int): ByteArray { + val buffer = ByteArray(maxBytesToRead) + val readBytes = buffer.usePinned { pinned -> + channel.inputStream?.read( + pinned.addressOf(0).reinterpret(), + maxBytesToRead.toULong(), + ) ?: -1 + } + return if (readBytes > 0) buffer.copyOf(readBytes.toInt()) else ByteArray(0) + } + + + @OptIn(ExperimentalForeignApi::class) + override suspend fun send(packet: ByteArray): Long { + val writeBytes = channel.outputStream?.write(packet.toCPointer(), packet.size.toULong()) + if (writeBytes != null) { + return writeBytes + } else { + throw L2CapException("couldn't send packet", code = 0L) + } + } + + @OptIn(ExperimentalForeignApi::class) + private fun ByteArray.toCPointer(): CPointer? { + memScoped { + usePinned { + return memScope.allocArray(this@toCPointer.size).also { cArray -> + for (i in indices) { + cArray[i] = this@toCPointer[i].toUByte() + } + } + } + } + } +} \ No newline at end of file diff --git a/kable-core/src/appleMain/kotlin/CBPeripheralCoreBluetoothPeripheral.kt b/kable-core/src/appleMain/kotlin/CBPeripheralCoreBluetoothPeripheral.kt index c130ecf23..e861c9f4f 100644 --- a/kable-core/src/appleMain/kotlin/CBPeripheralCoreBluetoothPeripheral.kt +++ b/kable-core/src/appleMain/kotlin/CBPeripheralCoreBluetoothPeripheral.kt @@ -27,11 +27,13 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onSubscription import kotlinx.coroutines.flow.updateAndGet +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.sync.withLock import kotlinx.io.IOException import platform.CoreBluetooth.CBCharacteristicWriteWithResponse import platform.CoreBluetooth.CBCharacteristicWriteWithoutResponse import platform.CoreBluetooth.CBDescriptor +import platform.CoreBluetooth.CBL2CAPChannel import platform.CoreBluetooth.CBManagerState import platform.CoreBluetooth.CBManagerStatePoweredOn import platform.CoreBluetooth.CBPeripheral @@ -99,7 +101,8 @@ internal class CBPeripheralCoreBluetoothPeripheral( forceCharacteristicEqualityByUuid, exceptionHandler = observationExceptionHandler, ) - private val canSendWriteWithoutResponse = MutableStateFlow(cbPeripheral.canSendWriteWithoutResponse) + private val canSendWriteWithoutResponse = + MutableStateFlow(cbPeripheral.canSendWriteWithoutResponse) private val _services = MutableStateFlow?>(null) override val services = _services.asStateFlow() @@ -381,6 +384,12 @@ internal class CBPeripheralCoreBluetoothPeripheral( cbPeripheral.identifier.UUIDString, ) + override suspend fun openL2CapChannel(psm: UShort): L2CapSocket = + suspendCancellableCoroutine { cont -> + (cbPeripheral.delegate as PeripheralDelegate).awaitingL2CapOpen = cont + cbPeripheral.openL2CAPChannel(psm) + } + override fun close() { scope.cancel("$this closed") } diff --git a/kable-core/src/appleMain/kotlin/CoreBluetoothPeripheral.kt b/kable-core/src/appleMain/kotlin/CoreBluetoothPeripheral.kt index 8ce8f3c2f..8162e0941 100644 --- a/kable-core/src/appleMain/kotlin/CoreBluetoothPeripheral.kt +++ b/kable-core/src/appleMain/kotlin/CoreBluetoothPeripheral.kt @@ -23,4 +23,7 @@ public interface CoreBluetoothPeripheral : Peripheral { @Throws(CancellationException::class, IOException::class) public suspend fun readAsNSData(characteristic: Characteristic): NSData + + @Throws(CancellationException::class, IOException::class) + public suspend fun openL2CapChannel(psm: UShort): L2CapSocket } diff --git a/kable-core/src/appleMain/kotlin/PeripheralDelegate.kt b/kable-core/src/appleMain/kotlin/PeripheralDelegate.kt index 07ddc578d..f8938acbf 100644 --- a/kable-core/src/appleMain/kotlin/PeripheralDelegate.kt +++ b/kable-core/src/appleMain/kotlin/PeripheralDelegate.kt @@ -29,6 +29,9 @@ import platform.Foundation.NSError import platform.Foundation.NSNumber import platform.Foundation.NSUUID import platform.darwin.NSObject +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException // https://developer.apple.com/documentation/corebluetooth/cbperipheraldelegate internal class PeripheralDelegate( @@ -94,6 +97,8 @@ internal class PeripheralDelegate( val onServiceChanged = Channel(CONFLATED) + internal lateinit var awaitingL2CapOpen: Continuation + private val logger = Logger(logging, tag = "Kable/Delegate", identifier = identifier) /* Discovering Services */ @@ -317,7 +322,26 @@ internal class PeripheralDelegate( logger.debug(error) { message = "didOpenL2CAPChannel" } - // todo + if (error != null) { + awaitingL2CapOpen.resumeWithException( + L2CapException( + error.description, + code = error.code, + ), + ) + } else if (didOpenL2CAPChannel == null) { + awaitingL2CapOpen.resumeWithException( + L2CapException( + "couldn't open L2CAPChannel", + code = 0, + ), + ) + } else { + logger.info { + message = "L2CAP channel open. Peer: ${didOpenL2CAPChannel.peer?.identifier}" + } + awaitingL2CapOpen.resume(AppleL2CapSocket(didOpenL2CAPChannel)) + } } fun close(cause: Throwable?) { diff --git a/kable-core/src/commonMain/kotlin/L2CapException.kt b/kable-core/src/commonMain/kotlin/L2CapException.kt new file mode 100644 index 000000000..5cfe1c189 --- /dev/null +++ b/kable-core/src/commonMain/kotlin/L2CapException.kt @@ -0,0 +1,9 @@ +package com.juul.kable + +import kotlinx.io.IOException + +public class L2CapException( + message: String? = null, + cause: Throwable? = null, + public val code: Long, +) : IOException(message, cause) \ No newline at end of file diff --git a/kable-core/src/commonMain/kotlin/L2CapSocket.kt b/kable-core/src/commonMain/kotlin/L2CapSocket.kt new file mode 100644 index 000000000..4aa7acc9e --- /dev/null +++ b/kable-core/src/commonMain/kotlin/L2CapSocket.kt @@ -0,0 +1,22 @@ +package com.juul.kable + +import kotlinx.coroutines.flow.StateFlow + +public interface L2CapSocket { + /** + * Contains whether the socket has been setup and it can be used to receive and send packets. + */ + public val isReady: StateFlow + + /** + * Reads up to [maxBytesToRead] from the socket and returns the a [ByteArray] of size equal to + * the amount of bytes read. The size of the returned [ByteArray] is <= [maxBytesToRead] + */ + public suspend fun receive(maxBytesToRead: Int = Int.MAX_VALUE): ByteArray + + /** + * Sends a [ByteArray] through the socket and returns how many bytes were sent. The returned + * value is <= [packet] size + */ + public suspend fun send(packet: ByteArray): Long +} \ No newline at end of file