Skip to content
Draft
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
43 changes: 43 additions & 0 deletions kable-core/src/androidMain/kotlin/AndroidL2CapSocket.kt
Original file line number Diff line number Diff line change
@@ -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<Boolean> = 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)
}
}
}
}
3 changes: 3 additions & 0 deletions kable-core/src/androidMain/kotlin/AndroidPeripheral.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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].
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
94 changes: 94 additions & 0 deletions kable-core/src/appleMain/kotlin/AppleL2CapSocket.kt
Original file line number Diff line number Diff line change
@@ -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<Boolean> = _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<UByteVar>? {
memScoped {
usePinned {
return memScope.allocArray<UByteVar>(this@toCPointer.size).also { cArray ->
for (i in indices) {
cArray[i] = this@toCPointer[i].toUByte()
}
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<List<PlatformDiscoveredService>?>(null)
override val services = _services.asStateFlow()
Expand Down Expand Up @@ -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")
}
Expand Down
3 changes: 3 additions & 0 deletions kable-core/src/appleMain/kotlin/CoreBluetoothPeripheral.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
26 changes: 25 additions & 1 deletion kable-core/src/appleMain/kotlin/PeripheralDelegate.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -94,6 +97,8 @@ internal class PeripheralDelegate(

val onServiceChanged = Channel<OnServiceChanged>(CONFLATED)

internal lateinit var awaitingL2CapOpen: Continuation<L2CapSocket>

private val logger = Logger(logging, tag = "Kable/Delegate", identifier = identifier)

/* Discovering Services */
Expand Down Expand Up @@ -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?) {
Expand Down
9 changes: 9 additions & 0 deletions kable-core/src/commonMain/kotlin/L2CapException.kt
Original file line number Diff line number Diff line change
@@ -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)
22 changes: 22 additions & 0 deletions kable-core/src/commonMain/kotlin/L2CapSocket.kt
Original file line number Diff line number Diff line change
@@ -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<Boolean>

/**
* 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
}