diff --git a/kable-core/src/androidMain/kotlin/AndroidPeripheral.kt b/kable-core/src/androidMain/kotlin/AndroidPeripheral.kt index cc7653910..99123a7b8 100644 --- a/kable-core/src/androidMain/kotlin/AndroidPeripheral.kt +++ b/kable-core/src/androidMain/kotlin/AndroidPeripheral.kt @@ -3,6 +3,7 @@ package com.juul.kable import android.Manifest import android.Manifest.permission.BLUETOOTH_CONNECT import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothGatt import android.bluetooth.BluetoothStatusCodes import android.os.Build @@ -160,4 +161,10 @@ public interface AndroidPeripheral : Peripheral { * is negotiated. */ public val mtu: StateFlow + + /** + * Underlying [BluetoothDevice] that the [AndroidPeripheral] represents. + */ + @KableInternalApi + public val bluetoothDevice: BluetoothDevice } diff --git a/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt b/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt index 249012f87..ce4bf89f8 100644 --- a/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt +++ b/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt @@ -51,8 +51,9 @@ private const val DISCOVER_SERVICES_RETRIES = 5 private const val DEFAULT_ATT_MTU = 23 private const val ATT_MTU_HEADER_SIZE = 3 +@OptIn(KableInternalApi::class) internal class BluetoothDeviceAndroidPeripheral( - private val bluetoothDevice: BluetoothDevice, + private val _bluetoothDevice: BluetoothDevice, private val autoConnectPredicate: () -> Boolean, private val transport: Transport, private val phy: Phy, @@ -61,7 +62,7 @@ internal class BluetoothDeviceAndroidPeripheral( private val onServicesDiscovered: ServicesDiscoveredAction, private val logging: Logging, private val disconnectTimeout: Duration, -) : BasePeripheral(bluetoothDevice.toString()), AndroidPeripheral { +) : BasePeripheral(_bluetoothDevice.toString()), AndroidPeripheral { init { onBluetoothDisabled { state -> @@ -73,10 +74,16 @@ internal class BluetoothDeviceAndroidPeripheral( } } + override val bluetoothDevice: BluetoothDevice = _bluetoothDevice + get() { + displayInternalLogWarning(logging) + return field + } + private val connectAction = scope.sharedRepeatableAction(::establishConnection) - override val identifier: String = bluetoothDevice.address - private val logger = Logger(logging, "Kable/Peripheral", bluetoothDevice.toString()) + override val identifier: String = _bluetoothDevice.address + private val logger = Logger(logging, "Kable/Peripheral", _bluetoothDevice.toString()) private val _state = MutableStateFlow(Disconnected()) override val state = _state.asStateFlow() @@ -96,13 +103,13 @@ internal class BluetoothDeviceAndroidPeripheral( ?: throw NotConnectedException("Connection not established, current state: ${state.value}") override val type: Type - get() = typeFrom(bluetoothDevice.type) + get() = typeFrom(_bluetoothDevice.type) - override val address: String = requireNonZeroAddress(bluetoothDevice.address) + override val address: String = requireNonZeroAddress(_bluetoothDevice.address) @ExperimentalApi override val name: String? - get() = bluetoothDevice.name + get() = _bluetoothDevice.name private suspend fun establishConnection(scope: CoroutineScope): CoroutineScope { checkBluetoothIsSupported() @@ -112,7 +119,7 @@ internal class BluetoothDeviceAndroidPeripheral( _state.value = State.Connecting.Bluetooth try { - connection.value = bluetoothDevice.connect( + connection.value = _bluetoothDevice.connect( scope.coroutineContext, applicationContext, autoConnectPredicate(), @@ -358,7 +365,7 @@ internal class BluetoothDeviceAndroidPeripheral( scope.cancel("$this closed") } - override fun toString(): String = "Peripheral(bluetoothDevice=$bluetoothDevice)" + override fun toString(): String = "Peripheral(_bluetoothDevice=$_bluetoothDevice)" } private val WriteType.intValue: Int diff --git a/kable-core/src/androidMain/kotlin/Peripheral.deprecated.kt b/kable-core/src/androidMain/kotlin/Peripheral.deprecated.kt index 8436861f6..6b7cad07f 100644 --- a/kable-core/src/androidMain/kotlin/Peripheral.deprecated.kt +++ b/kable-core/src/androidMain/kotlin/Peripheral.deprecated.kt @@ -15,7 +15,7 @@ public actual fun CoroutineScope.peripheral( builderAction: PeripheralBuilderAction, ): Peripheral { advertisement as ScanResultAndroidAdvertisement - return peripheral(advertisement.bluetoothDevice, builderAction) + return peripheral(advertisement._bluetoothDevice, builderAction) } @Deprecated( diff --git a/kable-core/src/androidMain/kotlin/Peripheral.kt b/kable-core/src/androidMain/kotlin/Peripheral.kt index b297cd51e..693881d9e 100644 --- a/kable-core/src/androidMain/kotlin/Peripheral.kt +++ b/kable-core/src/androidMain/kotlin/Peripheral.kt @@ -8,7 +8,7 @@ public actual fun Peripheral( builderAction: PeripheralBuilderAction, ): Peripheral { advertisement as ScanResultAndroidAdvertisement - return Peripheral(advertisement.bluetoothDevice, builderAction) + return Peripheral(advertisement._bluetoothDevice, builderAction) } @ExperimentalApi // Experimental while evaluating if this API introduces any footguns. diff --git a/kable-core/src/androidMain/kotlin/PlatformAdvertisement.kt b/kable-core/src/androidMain/kotlin/PlatformAdvertisement.kt index d05a1b72a..3cfa1a69c 100644 --- a/kable-core/src/androidMain/kotlin/PlatformAdvertisement.kt +++ b/kable-core/src/androidMain/kotlin/PlatformAdvertisement.kt @@ -1,5 +1,6 @@ package com.juul.kable +import android.bluetooth.BluetoothDevice import android.os.Parcelable public actual interface PlatformAdvertisement : Advertisement, Parcelable { @@ -13,4 +14,6 @@ public actual interface PlatformAdvertisement : Advertisement, Parcelable { public val address: String public val bondState: BondState public val bytes: ByteArray? + @KableInternalApi + public val bluetoothDevice: BluetoothDevice } diff --git a/kable-core/src/androidMain/kotlin/ScanResultAndroidAdvertisement.kt b/kable-core/src/androidMain/kotlin/ScanResultAndroidAdvertisement.kt index 27c74db6c..bc8880b86 100644 --- a/kable-core/src/androidMain/kotlin/ScanResultAndroidAdvertisement.kt +++ b/kable-core/src/androidMain/kotlin/ScanResultAndroidAdvertisement.kt @@ -11,19 +11,30 @@ import android.os.Build.VERSION_CODES import android.os.ParcelUuid import androidx.core.util.isNotEmpty import com.juul.kable.PlatformAdvertisement.BondState +import com.juul.kable.logs.Logging +import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import kotlin.uuid.Uuid import kotlin.uuid.toJavaUuid import kotlin.uuid.toKotlinUuid +@OptIn(KableInternalApi::class) @Parcelize internal class ScanResultAndroidAdvertisement( private val scanResult: ScanResult, + @IgnoredOnParcel + private val logging: Logging? = null, ) : PlatformAdvertisement { - internal val bluetoothDevice: BluetoothDevice + internal val _bluetoothDevice: BluetoothDevice get() = scanResult.device + override val bluetoothDevice: BluetoothDevice + get() { + displayInternalLogWarning(logging) + return scanResult.device + } + /** @see ScanRecord.getDeviceName */ override val name: String? get() = scanResult.scanRecord?.deviceName @@ -34,7 +45,7 @@ internal class ScanResultAndroidAdvertisement( * @see BluetoothDevice.getName */ override val peripheralName: String? - get() = bluetoothDevice.name + get() = _bluetoothDevice.name /** * Returns if the peripheral is connectable. Available on Android Oreo (API 26) and newer, on older versions of @@ -44,17 +55,17 @@ internal class ScanResultAndroidAdvertisement( get() = if (VERSION.SDK_INT >= VERSION_CODES.O) scanResult.isConnectable else null override val address: String - get() = bluetoothDevice.address + get() = _bluetoothDevice.address override val identifier: Identifier - get() = bluetoothDevice.address + get() = _bluetoothDevice.address override val bondState: BondState - get() = when (bluetoothDevice.bondState) { + get() = when (_bluetoothDevice.bondState) { BOND_NONE -> BondState.None BOND_BONDING -> BondState.Bonding BOND_BONDED -> BondState.Bonded - else -> error("Unknown bond state: ${bluetoothDevice.bondState}") + else -> error("Unknown bond state: ${_bluetoothDevice.bondState}") } /** Returns raw bytes of the underlying scan record. */ diff --git a/kable-core/src/appleMain/kotlin/CBPeripheralCoreBluetoothAdvertisement.kt b/kable-core/src/appleMain/kotlin/CBPeripheralCoreBluetoothAdvertisement.kt index dad5ac82d..5a4e6b70a 100644 --- a/kable-core/src/appleMain/kotlin/CBPeripheralCoreBluetoothAdvertisement.kt +++ b/kable-core/src/appleMain/kotlin/CBPeripheralCoreBluetoothAdvertisement.kt @@ -1,5 +1,6 @@ package com.juul.kable +import com.juul.kable.logs.Logging import platform.CoreBluetooth.CBAdvertisementDataIsConnectable import platform.CoreBluetooth.CBAdvertisementDataLocalNameKey import platform.CoreBluetooth.CBAdvertisementDataManufacturerDataKey @@ -13,14 +14,22 @@ import platform.Foundation.NSNumber import kotlin.experimental.ExperimentalNativeApi import kotlin.uuid.Uuid +@OptIn(KableInternalApi::class) internal class CBPeripheralCoreBluetoothAdvertisement( override val rssi: Int, private val data: Map, - internal val cbPeripheral: CBPeripheral, + internal val _cbPeripheral: CBPeripheral, + private val logging: Logging, ) : PlatformAdvertisement { + override val cbPeripheral: CBPeripheral = _cbPeripheral + get() { + displayInternalLogWarning(logging) + return field + } + override val identifier: Identifier - get() = cbPeripheral.identifier.toUuid() + get() = _cbPeripheral.identifier.toUuid() override val name: String? get() = data[CBAdvertisementDataLocalNameKey] as? String @@ -37,7 +46,7 @@ internal class CBPeripheralCoreBluetoothAdvertisement( * https://developer.apple.com/forums/thread/72343 */ override val peripheralName: String? - get() = cbPeripheral.name + get() = _cbPeripheral.name /** https://developer.apple.com/documentation/corebluetooth/cbadvertisementdataisconnectable */ override val isConnectable: Boolean? @@ -47,7 +56,8 @@ internal class CBPeripheralCoreBluetoothAdvertisement( get() = (data[CBAdvertisementDataTxPowerLevelKey] as? NSNumber)?.intValue override val uuids: List - get() = (data[CBAdvertisementDataServiceUUIDsKey] as? List)?.map { it.toUuid() } ?: emptyList() + get() = (data[CBAdvertisementDataServiceUUIDsKey] as? List)?.map { it.toUuid() } + ?: emptyList() override fun serviceData(uuid: Uuid): ByteArray? = serviceDataAsNSData(uuid)?.toByteArray() diff --git a/kable-core/src/appleMain/kotlin/CBPeripheralCoreBluetoothPeripheral.kt b/kable-core/src/appleMain/kotlin/CBPeripheralCoreBluetoothPeripheral.kt index c130ecf23..d3315694e 100644 --- a/kable-core/src/appleMain/kotlin/CBPeripheralCoreBluetoothPeripheral.kt +++ b/kable-core/src/appleMain/kotlin/CBPeripheralCoreBluetoothPeripheral.kt @@ -49,18 +49,25 @@ import kotlin.time.Duration import platform.CoreBluetooth.CBCharacteristicWriteWithResponse as CBWithResponse import platform.CoreBluetooth.CBCharacteristicWriteWithoutResponse as CBWithoutResponse +@OptIn(KableInternalApi::class) internal class CBPeripheralCoreBluetoothPeripheral( - private val cbPeripheral: CBPeripheral, + private val _cbPeripheral: CBPeripheral, observationExceptionHandler: ObservationExceptionHandler, private val onServicesDiscovered: ServicesDiscoveredAction, private val logging: Logging, private val disconnectTimeout: Duration, private val forceCharacteristicEqualityByUuid: Boolean, -) : BasePeripheral(cbPeripheral.identifier.toUuid()), CoreBluetoothPeripheral { +) : BasePeripheral(_cbPeripheral.identifier.toUuid()), CoreBluetoothPeripheral { + + override val cbPeripheral: CBPeripheral = _cbPeripheral + get() { + displayInternalLogWarning(logging) + return field + } private val central = CentralManager.Default - override val identifier: Identifier = cbPeripheral.identifier.toUuid() + override val identifier: Identifier = _cbPeripheral.identifier.toUuid() private val logger = Logger(logging, identifier = identifier.toString()) private val _state = MutableStateFlow(State.Disconnected()) @@ -99,7 +106,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() @@ -112,7 +120,7 @@ internal class CBPeripheralCoreBluetoothPeripheral( @ExperimentalApi override val name: String? - get() = cbPeripheral.name + get() = _cbPeripheral.name private suspend fun establishConnection(scope: CoroutineScope): CoroutineScope { central.checkBluetoothIsOn() @@ -123,7 +131,7 @@ internal class CBPeripheralCoreBluetoothPeripheral( try { connection.value = central.connectPeripheral( scope.coroutineContext, - cbPeripheral, + _cbPeripheral, createPeripheralDelegate(), _state, _services, @@ -170,13 +178,13 @@ internal class CBPeripheralCoreBluetoothPeripheral( WithResponse -> CBCharacteristicWriteWithResponse WithoutResponse -> CBCharacteristicWriteWithoutResponse } - return cbPeripheral.maximumWriteValueLengthForType(type).toInt() + return _cbPeripheral.maximumWriteValueLengthForType(type).toInt() } @ExperimentalApi // Experimental until Web Bluetooth advertisements APIs are stable. @Throws(CancellationException::class, IOException::class) override suspend fun rssi(): Int = connectionOrThrow().execute { - cbPeripheral.readRSSI() + _cbPeripheral.readRSSI() }.rssi.intValue private suspend fun discoverServices() { @@ -209,13 +217,14 @@ internal class CBPeripheralCoreBluetoothPeripheral( val platformCharacteristic = servicesOrThrow().obtain(characteristic, writeType.properties) when (writeType) { WithResponse -> connectionOrThrow().execute { - cbPeripheral.writeValue(data, platformCharacteristic, CBWithResponse) + _cbPeripheral.writeValue(data, platformCharacteristic, CBWithResponse) } + WithoutResponse -> connectionOrThrow().guard.withLock { - if (!canSendWriteWithoutResponse.updateAndGet { cbPeripheral.canSendWriteWithoutResponse }) { + if (!canSendWriteWithoutResponse.updateAndGet { _cbPeripheral.canSendWriteWithoutResponse }) { canSendWriteWithoutResponse.first { it } } - central.writeValue(cbPeripheral, data, platformCharacteristic, CBWithoutResponse) + central.writeValue(_cbPeripheral, data, platformCharacteristic, CBWithoutResponse) } } } @@ -239,8 +248,13 @@ internal class CBPeripheralCoreBluetoothPeripheral( val event = connectionOrThrow().guard.withLock { observers .characteristicChanges - .onSubscription { central.readValue(cbPeripheral, platformCharacteristic) } - .first { event -> event.isAssociatedWith(characteristic, forceCharacteristicEqualityByUuid) } + .onSubscription { central.readValue(_cbPeripheral, platformCharacteristic) } + .first { event -> + event.isAssociatedWith( + characteristic, + forceCharacteristicEqualityByUuid, + ) + } } return when (event) { @@ -361,7 +375,7 @@ internal class CBPeripheralCoreBluetoothPeripheral( private fun onStateChanged(action: (State) -> Unit) { central.delegate .connectionEvents - .filter { event -> event.identifier == cbPeripheral.identifier } + .filter { event -> event.identifier == _cbPeripheral.identifier } .map(ConnectionEvent::toState) .onEach(action) .launchIn(scope) @@ -378,22 +392,22 @@ internal class CBPeripheralCoreBluetoothPeripheral( canSendWriteWithoutResponse, observers.characteristicChanges, logging, - cbPeripheral.identifier.UUIDString, + _cbPeripheral.identifier.UUIDString, ) override fun close() { scope.cancel("$this closed") } - override fun toString(): String = "Peripheral(cbPeripheral=$cbPeripheral)" + override fun toString(): String = "Peripheral(cbPeripheral=$_cbPeripheral)" } private val CBDescriptor.isUnsignedShortValue: Boolean get() = UUID.UUIDString.let { it == CBUUIDCharacteristicExtendedPropertiesString || - it == CBUUIDClientCharacteristicConfigurationString || - it == CBUUIDServerCharacteristicConfigurationString || - it == CBUUIDL2CAPPSMCharacteristicString + it == CBUUIDClientCharacteristicConfigurationString || + it == CBUUIDServerCharacteristicConfigurationString || + it == CBUUIDL2CAPPSMCharacteristicString } private val Any?.type: String? diff --git a/kable-core/src/appleMain/kotlin/CentralManagerCoreBluetoothScanner.kt b/kable-core/src/appleMain/kotlin/CentralManagerCoreBluetoothScanner.kt index 8bf87dd84..df1d3da56 100644 --- a/kable-core/src/appleMain/kotlin/CentralManagerCoreBluetoothScanner.kt +++ b/kable-core/src/appleMain/kotlin/CentralManagerCoreBluetoothScanner.kt @@ -77,7 +77,7 @@ internal class CentralManagerCoreBluetoothScanner( ) } .map { (cbPeripheral, rssi, advertisementData) -> - CBPeripheralCoreBluetoothAdvertisement(rssi.intValue, advertisementData, cbPeripheral) + CBPeripheralCoreBluetoothAdvertisement(rssi.intValue, advertisementData, cbPeripheral, logging) } } diff --git a/kable-core/src/appleMain/kotlin/CoreBluetoothPeripheral.kt b/kable-core/src/appleMain/kotlin/CoreBluetoothPeripheral.kt index 8ce8f3c2f..dd4e12187 100644 --- a/kable-core/src/appleMain/kotlin/CoreBluetoothPeripheral.kt +++ b/kable-core/src/appleMain/kotlin/CoreBluetoothPeripheral.kt @@ -2,11 +2,15 @@ package com.juul.kable import kotlinx.coroutines.flow.Flow import kotlinx.io.IOException +import platform.CoreBluetooth.CBPeripheral import platform.Foundation.NSData import kotlin.coroutines.cancellation.CancellationException public interface CoreBluetoothPeripheral : Peripheral { + @KableInternalApi + public val cbPeripheral: CBPeripheral + @Throws(CancellationException::class, IOException::class) public suspend fun write(descriptor: Descriptor, data: NSData) diff --git a/kable-core/src/appleMain/kotlin/Peripheral.kt b/kable-core/src/appleMain/kotlin/Peripheral.kt index 7367aef43..455694b3f 100644 --- a/kable-core/src/appleMain/kotlin/Peripheral.kt +++ b/kable-core/src/appleMain/kotlin/Peripheral.kt @@ -7,7 +7,7 @@ public actual fun Peripheral( builderAction: PeripheralBuilderAction, ): Peripheral { advertisement as CBPeripheralCoreBluetoothAdvertisement - return Peripheral(advertisement.cbPeripheral, builderAction) + return Peripheral(advertisement._cbPeripheral, builderAction) } @Suppress("FunctionName") // Builder function. diff --git a/kable-core/src/appleMain/kotlin/PlatformAdvertisement.kt b/kable-core/src/appleMain/kotlin/PlatformAdvertisement.kt index a43c9a7fc..53d2dce1f 100644 --- a/kable-core/src/appleMain/kotlin/PlatformAdvertisement.kt +++ b/kable-core/src/appleMain/kotlin/PlatformAdvertisement.kt @@ -1,5 +1,6 @@ package com.juul.kable +import platform.CoreBluetooth.CBPeripheral import platform.Foundation.NSData import kotlin.uuid.Uuid @@ -7,4 +8,6 @@ public actual interface PlatformAdvertisement : Advertisement { public fun serviceDataAsNSData(uuid: Uuid): NSData? public val manufacturerDataAsNSData: NSData? public fun manufacturerDataAsNSData(companyIdentifierCode: Int): NSData? + @KableInternalApi + public val cbPeripheral: CBPeripheral } diff --git a/kable-core/src/commonMain/kotlin/KableInternalApi.kt b/kable-core/src/commonMain/kotlin/KableInternalApi.kt new file mode 100644 index 000000000..7eade6de1 --- /dev/null +++ b/kable-core/src/commonMain/kotlin/KableInternalApi.kt @@ -0,0 +1,31 @@ +package com.juul.kable + +import com.juul.kable.logs.Logger +import com.juul.kable.logs.Logging + +/** + * ¡¡¡Use At Your Own Risk!!! + * + * Marks declarations that are internal to Kable. These declarations are exposed so that users of + * the library can do more advanced or as of yet unimplemented things. Usage of these declarations + * can break the usage of Kable and therefore bugs related to the usage of these declarations should + * not be brought up to the Kable maintainers. + */ +@MustBeDocumented +@Retention(AnnotationRetention.BINARY) +@RequiresOptIn(level = RequiresOptIn.Level.ERROR) +@Target(AnnotationTarget.CLASS, AnnotationTarget.PROPERTY, AnnotationTarget.FUNCTION) +public annotation class KableInternalApi + +private var hasDisplayedInternalLogWarning = false + +internal fun displayInternalLogWarning(logging: Logging?) { + if (!hasDisplayedInternalLogWarning && logging != null) { + val logger = Logger(logging, "Kable/InternalApi", null) + logger.warn { + message = + "You are using an internal API. Make sure you know what you are doing. Incorrect usage could break internal Kable state. Bugs related to internal API usage will be deprioritized by the Kable maintainers." + } + hasDisplayedInternalLogWarning = true + } +}