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
4 changes: 1 addition & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
```

Expand Down
18 changes: 4 additions & 14 deletions kable-core/src/androidMain/kotlin/AndroidPeripheral.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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].
Expand All @@ -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<Int?>

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -128,6 +140,7 @@ internal class BluetoothDeviceAndroidPeripheral(
)

suspendUntil<State.Connecting.Services>()
if (desiredMtu != null) requestMtu(desiredMtu)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If requestMtu() fails at this point (throwing GattRequestRejectedException), is it cancelling the connection? Shouldn't it be able to continue the connection with the default MTU?

@twyatt twyatt Jul 22, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it would cancel the connection.
I believe that is desirable though.

The requestMtu is just that: requesting an MTU. Assuming properly behaving firmware, the firmware will respond with the MTU to settle on. Hence why Android 14+ always (no matter what value you specify) requests the max (517) and lets the firmware respond with what it actually supports.

If the MTU request fails, then I'd imagine there is a bigger problem at hand, as the firmware should really always be OK with any MTU request and simply respond with what it supports. My guess is that any failure seen at this stage will be because of a faulty connection or misbehaving firmware.

Core Bluetooth always negotiates an MTU on connection (you can't disable that). I'm guessing (I have not verified though — it would be very difficult to test), that a failure at the time of requesting an MTU, it would also result in either the firmware telling it what MTU it will accept or a loss of connection.

discoverServices()
configureCharacteristicObservations()
} catch (e: Exception) {
Expand Down Expand Up @@ -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 =
Expand All @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions kable-core/src/androidMain/kotlin/Connection.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public fun CoroutineScope.peripheral(
builder.autoConnectPredicate,
builder.transport,
builder.phy,
builder.mtu,
builder.threadingStrategy,
builder.observationExceptionHandler,
builder.onServicesDiscovered,
Expand Down
1 change: 1 addition & 0 deletions kable-core/src/androidMain/kotlin/Peripheral.kt
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public fun Peripheral(
builder.autoConnectPredicate,
builder.transport,
builder.phy,
builder.mtu,
builder.threadingStrategy,
builder.observationExceptionHandler,
builder.onServicesDiscovered,
Expand Down
26 changes: 22 additions & 4 deletions kable-core/src/androidMain/kotlin/PeripheralBuilder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Expand Down
19 changes: 15 additions & 4 deletions kable-core/src/commonMain/kotlin/Peripheral.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -165,11 +168,19 @@ public interface Peripheral : AutoCloseable {
public val services: StateFlow<List<DiscoveredService>?>

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

Expand Down
Loading