diff --git a/README.md b/README.md index 7ba443ee9..1c3beff01 100644 --- a/README.md +++ b/README.md @@ -300,11 +300,9 @@ On Android targets, additional configuration options are available (all configur ```kotlin val peripheral = Peripheral(advertisement) { autoConnectIf { false } // default - onServicesDiscovered { - requestMtu(...) - } transport = Transport.Le // default phy = Phy.Le1M // default + mtu = 517 // default } ``` diff --git a/kable-core/src/androidMain/kotlin/AndroidPeripheral.kt b/kable-core/src/androidMain/kotlin/AndroidPeripheral.kt index 010481339..98ae77645 100644 --- a/kable-core/src/androidMain/kotlin/AndroidPeripheral.kt +++ b/kable-core/src/androidMain/kotlin/AndroidPeripheral.kt @@ -126,17 +126,6 @@ public interface AndroidPeripheral : Peripheral { public fun requestConnectionPriority(priority: Priority): Boolean - /** - * Requests that the current connection's MTU be changed. Suspends until the MTU changes, or failure occurs. The - * negotiated MTU value is returned, which may not be [mtu] value requested if the remote peripheral negotiated an - * alternate MTU. - * - * @throws NotConnectedException if invoked without an established [connection][Peripheral.connect]. - * @throws GattRequestRejectedException if Android was unable to fulfill the MTU change request. - * @throws GattStatusException if MTU change request failed. - */ - public suspend fun requestMtu(mtu: Int): Int - /** * @see Peripheral.write * @throws NotConnectedException if invoked without an established [connection][connect]. @@ -156,9 +145,10 @@ public interface AndroidPeripheral : Peripheral { override suspend fun write(descriptor: Descriptor, data: ByteArray) /** - * [StateFlow] of the most recently negotiated MTU. The MTU will change upon a successful request to change the MTU - * (via [requestMtu]), or if the peripheral initiates an MTU change. [StateFlow]'s `value` will be `null` until MTU - * is negotiated. + * [StateFlow] of the most recently negotiated MTU. The MTU will change upon a successful + * request to change the MTU (as configured via the [PeripheralBuilder.mtu] property), or if the + * peripheral initiates an MTU change. [StateFlow]'s `value` will be `null` until MTU is + * negotiated. */ public val mtu: StateFlow diff --git a/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt b/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt index 387ad8169..736ec708d 100644 --- a/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt +++ b/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt @@ -1,5 +1,6 @@ package com.juul.kable +import android.Manifest.permission.BLUETOOTH_CONNECT import android.bluetooth.BluetoothAdapter.STATE_OFF import android.bluetooth.BluetoothAdapter.STATE_TURNING_OFF import android.bluetooth.BluetoothDevice @@ -15,6 +16,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.RequiresPermission import com.juul.kable.AndroidPeripheral.Priority import com.juul.kable.AndroidPeripheral.Type import com.juul.kable.State.Disconnected @@ -50,12 +53,21 @@ private const val DISCOVER_SERVICES_RETRIES = 5 private const val DEFAULT_ATT_MTU = 23 private const val ATT_MTU_HEADER_SIZE = 3 +private const val MAX_ATTRIBUTE_LENGTH_API_21 = 600 // Per RxAndroidBle (Android API 21-32). +private const val MAX_ATTRIBUTE_LENGTH_API_33 = 512 + +private val MAX_ATTRIBUTE_LENGTH = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + MAX_ATTRIBUTE_LENGTH_API_33 +} else { + MAX_ATTRIBUTE_LENGTH_API_21 +} internal class BluetoothDeviceAndroidPeripheral( @InternalKableApi override val bluetoothDevice: BluetoothDevice, private val autoConnectPredicate: () -> Boolean, private val transport: Transport, private val phy: Phy, + private val desiredMtu: Int?, private val threadingStrategy: ThreadingStrategy, observationExceptionHandler: ObservationExceptionHandler, private val onServicesDiscovered: ServicesDiscoveredAction, @@ -128,6 +140,7 @@ internal class BluetoothDeviceAndroidPeripheral( ) suspendUntil() + if (desiredMtu != null) requestMtu(desiredMtu) discoverServices() configureCharacteristicObservations() } catch (e: Exception) { @@ -169,7 +182,7 @@ internal class BluetoothDeviceAndroidPeripheral( } override suspend fun maximumWriteValueLengthForType(writeType: WriteType): Int = - (mtu.value ?: DEFAULT_ATT_MTU) - ATT_MTU_HEADER_SIZE + ((mtu.value ?: DEFAULT_ATT_MTU) - ATT_MTU_HEADER_SIZE).coerceAtMost(MAX_ATTRIBUTE_LENGTH) @ExperimentalKableApi // Experimental until Web Bluetooth advertisements APIs are stable. override suspend fun rssi(): Int = @@ -184,12 +197,13 @@ internal class BluetoothDeviceAndroidPeripheral( } } - override suspend fun requestMtu(mtu: Int): Int { + @RequiresPermission(BLUETOOTH_CONNECT) + private suspend fun requestMtu(mtu: Int) { logger.debug { message = "requestMtu" detail("mtu", mtu) } - return connectionOrThrow().requestMtu(mtu) + connectionOrThrow().requestMtu(mtu) } override suspend fun write( diff --git a/kable-core/src/androidMain/kotlin/Connection.kt b/kable-core/src/androidMain/kotlin/Connection.kt index d40697a5f..69fe9f50b 100644 --- a/kable-core/src/androidMain/kotlin/Connection.kt +++ b/kable-core/src/androidMain/kotlin/Connection.kt @@ -1,8 +1,10 @@ package com.juul.kable +import android.Manifest.permission.BLUETOOTH_CONNECT import android.bluetooth.BluetoothGatt import android.bluetooth.BluetoothGatt.GATT_SUCCESS import android.os.Handler +import androidx.annotation.RequiresPermission import com.juul.kable.State.Disconnected import com.juul.kable.android.GattStatus import com.juul.kable.coroutines.childSupervisor @@ -197,6 +199,7 @@ internal class Connection( * @throws GattRequestRejectedException if underlying `BluetoothGatt` method call returns `false`. * @throws GattStatusException if response has a non-`GATT_SUCCESS` status. */ + @RequiresPermission(BLUETOOTH_CONNECT) suspend fun requestMtu(mtu: Int): Int = guard.withLock { try { withContext(dispatcher) { diff --git a/kable-core/src/androidMain/kotlin/Peripheral.deprecated.kt b/kable-core/src/androidMain/kotlin/Peripheral.deprecated.kt index 8436861f6..4ca2b564b 100644 --- a/kable-core/src/androidMain/kotlin/Peripheral.deprecated.kt +++ b/kable-core/src/androidMain/kotlin/Peripheral.deprecated.kt @@ -33,6 +33,7 @@ public fun CoroutineScope.peripheral( builder.autoConnectPredicate, builder.transport, builder.phy, + builder.mtu, builder.threadingStrategy, builder.observationExceptionHandler, builder.onServicesDiscovered, diff --git a/kable-core/src/androidMain/kotlin/Peripheral.kt b/kable-core/src/androidMain/kotlin/Peripheral.kt index c364f5c8f..34cbffeb6 100644 --- a/kable-core/src/androidMain/kotlin/Peripheral.kt +++ b/kable-core/src/androidMain/kotlin/Peripheral.kt @@ -33,6 +33,7 @@ public fun Peripheral( builder.autoConnectPredicate, builder.transport, builder.phy, + builder.mtu, builder.threadingStrategy, builder.observationExceptionHandler, builder.onServicesDiscovered, diff --git a/kable-core/src/androidMain/kotlin/PeripheralBuilder.kt b/kable-core/src/androidMain/kotlin/PeripheralBuilder.kt index 61f49838d..e73b7f16f 100644 --- a/kable-core/src/androidMain/kotlin/PeripheralBuilder.kt +++ b/kable-core/src/androidMain/kotlin/PeripheralBuilder.kt @@ -68,10 +68,6 @@ public actual class ServicesDiscoveredPeripheral internal constructor( ) { peripheral.write(descriptor, data) } - - public suspend fun requestMtu( - mtu: Int, - ): Int = peripheral.requestMtu(mtu) } public actual class PeripheralBuilder internal actual constructor() { @@ -114,6 +110,28 @@ public actual class PeripheralBuilder internal actual constructor() { /** Preferred PHY for connections to remote LE device. */ public var phy: Phy = Phy.Le1M + /** + * ATT MTU to request when establishing a connection (`null` means no MTU change request will + * be made). The request is made after connecting, but prior to performing service discovery. + * + * Note that starting from Android 14, if [mtu] is non-`null` then an MTU of `517` is requested + * regardless of what value [mtu] is configured to: + * https://developer.android.com/about/versions/14/behavior-changes-all#mtu-set-to-517 + * + * The peripheral may negotiate an MTU different from the requested [mtu]; the negotiated MTU + * is available via [AndroidPeripheral.mtu]. + * + * Must be in the range 23..517 (per `MIN_MTU` and `MAX_MTU` defined in Android's + * [BluetoothDevice](https://cs.android.com/android/platform/superproject/main/+/main:packages/modules/Bluetooth/framework/java/android/bluetooth/BluetoothDevice.java)). + */ + public var mtu: Int? = 517 + set(value) { + require(value == null || value in 23..517) { + "MTU must be in the range 23..517, was $value" + } + field = value + } + public var threadingStrategy: ThreadingStrategy = OnDemandThreadingStrategy public actual var disconnectTimeout: Duration = defaultDisconnectTimeout diff --git a/kable-core/src/commonMain/kotlin/Peripheral.kt b/kable-core/src/commonMain/kotlin/Peripheral.kt index 6ed937970..915e5f1d6 100644 --- a/kable-core/src/commonMain/kotlin/Peripheral.kt +++ b/kable-core/src/commonMain/kotlin/Peripheral.kt @@ -128,6 +128,9 @@ public interface Peripheral : AutoCloseable { * meaning any failures in launched coroutines will not fail other launched coroutines nor cause * a disconnect. * + * On Android, requires `Manifest.permission.BLUETOOTH_CONNECT` permission if [Peripheral] was + * configured with a non-`null` MTU. + * * @throws IllegalStateException when a connection request could not be made (e.g. bluetooth not supported). * @throws NotConnectedException if unable to establish connection (e.g. connection lost while discovering services). * @throws IOException (Android) if request failed due to Binder remote-invocation error. @@ -165,11 +168,19 @@ public interface Peripheral : AutoCloseable { public val services: StateFlow?> /** - * Return the current ATT MTU size, minus the size of the ATT headers (3 bytes). + * The maximum amount of data, in bytes, you can send in a single write operation. + * + * This value is based on the MTU size minus the ATT header size (3 bytes). + * + * On Android, by default an MTU of `517` will be requested unless configured otherwise (via + * the `mtu` property of the [PeripheralBuilder]). On Android 13+, the max usable write length + * is capped at `512` bytes. + * + * For Core Bluetooth, this is automatically negotiated and can also vary depending on the + * [writeType]. * - * On Android, this will be the default (23 - 3) unless you called `requestMtu` when connecting. - * For iOS, this is automatically negotiated, and can also vary depending on the writeType. - * On JavaScript, this will return the default (23 - 3) every time as there is no ATT MTU property available. + * On JavaScript, this will always return the default (23 - 3) every time as there is no ATT MTU + * property available. */ public suspend fun maximumWriteValueLengthForType(writeType: WriteType): Int