Skip to content

Kotlin Multiplatform BLE with Kable, Koin, and Compose

Android BLE is BluetoothGatt callbacks firing on arbitrary threads. iOS BLE is CBCentralManager delegates with its own threading model. Kable, a KMP BLE library by JUUL Labs, wraps both behind a coroutines and Flow API. This post shows how the AtomBrick motor controller app uses Kable alongside Koin and Compose Multiplatform to scan, connect, and send commands to an ESP32 peripheral from a single shared codebase.

Prerequisites

  • Kotlin Multiplatform project with Android and iOS targets configured
  • Android minSdk 24, targetSdk 35
  • Xcode 15 or later for iOS builds
  • Familiarity with Kotlin coroutines, Flow, and StateFlow
  • Basic Koin knowledge: module {}, viewModelOf, startKoin

Versions used in this post

Kotlin 2.2.0 · Compose Multiplatform 1.8.2 · Kable 0.39.1 · Koin 4.1.0 · Kermit 2.0.0

Background

Kable's Scanner returns a Flow<Advertisement>. Peripheral exposes connect() as a suspend function and state changes as a Flow<State>. The result is a BLE client that fits naturally into a ViewModel with no callbacks.

The expect/actual boundary in this project is deliberately narrow. The scan-connect-write loop is fully shared in commonMain. Only PairedDevicesProvider has platform implementations, because Android and iOS expose completely different APIs for querying bonded devices.

iOS has no bonded device API

Android's BluetoothAdapter.bondedDevices returns all previously paired devices. iOS exposes no equivalent. CBCentralManager can retrieve peripherals by UUID only if your app connected to them in the same session or you stored the UUID elsewhere. Plan for this before writing the expect declaration.

Implementation

1. Add dependencies

gradle/libs.versions.toml
[versions]
kable = "0.39.1"
koin  = "4.1.0"

[libraries]
kable             = { module = "com.juul.kable:kable-core",               version.ref = "kable" }
kable-permissions = { module = "com.juul.kable:kable-default-permissions", version.ref = "kable" }
koin-core         = { module = "io.insert-koin:koin-core",                 version.ref = "koin" }
koin-android      = { module = "io.insert-koin:koin-android",              version.ref = "koin" }
composeApp/build.gradle.kts
kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation(libs.kable)
            implementation(libs.koin.core)
        }
        androidMain.dependencies {
            implementation(libs.koin.android)
            implementation(libs.kable.permissions)
        }
    }
}

2. Configure the scanner

The BleManager singleton owns the Scanner so it is created once and shared across ViewModels. A name prefix filter avoids collecting every advertisement in range.

data/BLEManager.kt
object BleManager {

    val scanner = Scanner {
        filters {
            match { name = Filter.Name.Prefix("ATOM") }
        }
        logging {
            engine = SystemLogEngine
            level  = Logging.Level.Events
        }
    }

    fun convertAdvertisementToDevice(adv: Advertisement): BluetoothDevice =
        BluetoothDevice(id = adv.identifier, name = adv.name, rssi = adv.rssi, advertisement = adv)

    fun updateDevicesList(
        newAdv: Advertisement,
        current: List<BluetoothDevice> = emptyList()
    ): List<BluetoothDevice> {
        val list = current.toMutableList()
        val idx  = list.indexOfFirst { it.id == newAdv.identifier }
        return if (idx >= 0) {
            list[idx] = convertAdvertisementToDevice(newAdv)
            list
        } else {
            list + convertAdvertisementToDevice(newAdv)
        }
    }
}

updateDevicesList deduplicates by identifier (MAC address on Android, UUID on iOS) and updates RSSI for already-known devices. Without this, the list grows a new entry for every advertisement from the same device.

3. Define the Motor model with characteristic UUIDs

Each motor maps to one BLE characteristic. Bluetooth.BaseUuid + 0x2A31 constructs the full 128-bit UUID from the 16-bit short form that matches the ESP32 firmware registration.

data/model/Motor.kt
@OptIn(ExperimentalUuidApi::class)
data class Motor(
    val id:             Int,
    val name:           String,
    val characteristic: Uuid    = Bluetooth.BaseUuid + 0x1815,
    val isOn:           Boolean = false,
    val speed:          Float   = 0.0F
)

MotorState initialises the five motors with their actual characteristic UUIDs:

ui/motor/MotorState.kt
data class MotorState(
    val motors: List<Motor> = listOf(
        Motor(id = 1, name = "Motor 1", characteristic = Bluetooth.BaseUuid + 0x2A31),
        Motor(id = 2, name = "Motor 2", characteristic = Bluetooth.BaseUuid + 0x2A32),
        Motor(id = 3, name = "Motor 3", characteristic = Bluetooth.BaseUuid + 0x2A33),
        Motor(id = 4, name = "Motor 4", characteristic = Bluetooth.BaseUuid + 0x2A34),
        Motor(id = 5, name = "Motor 5", characteristic = Bluetooth.BaseUuid + 0x2A35),
    )
)

4. Scan and connect in MotorViewModel

The ViewModel owns the Peripheral reference and the scan Job. Both live in viewModelScope so they cancel automatically when the ViewModel is cleared.

ui/motor/MotorViewModel.kt
class MotorViewModel : ViewModel() {
    private val _motorState = MutableStateFlow(MotorState())
    val motorState: StateFlow<MotorState> = _motorState.asStateFlow()

    private var peripheral: Peripheral? = null
    private var scanJob:    Job?        = null

    fun startScanner() {
        if (bluetoothState.isScanning) return
        scanJob = scanner.advertisements
            .onEach { adv ->
                bluetoothState = bluetoothState.copy(
                    availableDevices = BleManager.updateDevicesList(adv, bluetoothState.availableDevices)
                )
            }
            .catch { error -> Logger.d { "Scanner error: $error" } }
            .launchIn(viewModelScope)
    }

    fun pairDevice(device: BluetoothDevice) {
        viewModelScope.launch {
            scanJob?.cancel()
            peripheral = Peripheral(device.advertisement ?: error("No advertisement"))
            withTimeout(20_000) { peripheral?.connect() }

            peripheral?.state?.collect { state ->
                when (state) {
                    is State.Connected    -> Logger.d { "Connected: ${peripheral?.name}" }
                    is State.Disconnected -> peripheral = null
                    else                 -> {}
                }
            }
        }
    }

    override fun onCleared() {
        super.onCleared()
        scanJob?.cancel()
    }
}

bluetoothState is a Compose mutableStateOf, not a StateFlow. Screens that only need snapshot reads avoid the collectAsStateWithLifecycle boilerplate.

5. Write motor commands

Every command is a two-byte payload: [command, data]. toggleMotor updates the UI optimistically and reverts on write failure.

ui/motor/MotorViewModel.kt
fun toggleMotor(motor: Motor, isOn: Boolean) {
    viewModelScope.launch {
        _motorState.update { state ->
            state.copy(motors = state.motors.map {
                if (it.id == motor.id) motor.copy(isOn = isOn) else it
            })
        }
        try {
            peripheral?.services?.collect { services ->
                services?.let {
                    peripheral?.write(
                        characteristic = characteristicOf(services[2].serviceUuid, motor.characteristic),
                        data           = byteArrayOf(0x01, if (isOn) 0x01 else 0x00),
                        writeType      = WriteType.WithResponse
                    )
                }
            }
        } catch (e: Exception) {
            _motorState.update { state ->
                state.copy(motors = state.motors.map {
                    if (it.id == motor.id) motor.copy(isOn = !isOn) else it
                })
            }
        }
    }
}

Speed control maps positive values to forward (0x02) and negative to reverse (0x03), clamped to the signed byte range the MX1508 driver accepts:

ui/motor/MotorViewModel.kt
fun updateMotorSpeed(motor: Motor, speed: Float) {
    viewModelScope.launch {
        val clamped  = speed.toInt().coerceIn(-127, 127)
        val speedByte = if (clamped < 0) (128 + clamped).toByte() else clamped.toByte()
        val command: Byte = if (speed > 0) 0x02 else 0x03

        peripheral?.services?.collect { services ->
            services?.let {
                peripheral?.write(
                    characteristic = characteristicOf(services[2].serviceUuid, motor.characteristic),
                    data           = byteArrayOf(command, speedByte),
                    writeType      = WriteType.WithResponse
                )
            }
        }
    }
}

6. Platform-specific PairedDevicesProvider

The expect declaration lives in commonMain:

commonMain/declarations/PairedDevicesProvider.kt
expect class PairedDevicesProvider {
    fun getPairedDevices(): List<BluetoothDevice>
    fun trackPairedDevice(peripheral: Any)
    fun initialize()
}

Android actual reads bonded devices directly from the system adapter:

androidMain/declarations/PairedDevicesProvider.kt
actual class PairedDevicesProvider(private val context: Context) {

    @RequiresPermission(allOf = ["android.permission.BLUETOOTH_CONNECT"])
    actual fun getPairedDevices(): List<BluetoothDevice> {
        val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
        return manager.adapter?.bondedDevices?.map { device ->
            BluetoothDevice(name = device.name ?: "Unknown", id = device.address, rssi = 0, advertisement = null)
        } ?: emptyList()
    }

    actual fun trackPairedDevice(peripheral: Any) { }
    actual fun initialize() { }
}

iOS actual initialises CBCentralManager and tracks connections at runtime since the OS has no bonded device query:

iosMain/declarations/PairedDevicesProvider.kt
actual class PairedDevicesProvider {
    private var centralManager: CBCentralManager? = null
    private var pairedDevices:  List<BluetoothDevice> = emptyList()

    actual fun getPairedDevices():            List<BluetoothDevice> = pairedDevices
    actual fun trackPairedDevice(peripheral: Any) { /* track on connect */ }

    actual fun initialize() {
        centralManager = CBCentralManager(delegate = null, queue = null, options = null)
    }
}

7. Wire Koin

di/ViewModelModule.kt
val viewModelModule = module {
    viewModelOf(::MotorViewModel)
    viewModelOf(::BluetoothViewModel)
}
di/KoinApp.kt
fun initKoin(extraModules: List<Module> = emptyList(), config: KoinAppDeclaration? = null) {
    startKoin {
        includes(config)
        modules(extraModules + appModule + viewModelModule)
    }
}

initKoin is called from Application.onCreate on Android and MainViewController on iOS, each passing platform-specific modules for context-dependent dependencies like PairedDevicesProvider.

8. Consume state in Compose

ui/motor/MotorScreen.kt
@Composable
fun MotorScreen(onMenuClick: () -> Unit) {
    val viewModel  = koinViewModel<MotorViewModel>()
    val motorState by viewModel.motorState.collectAsStateWithLifecycle()

    Scaffold(
        topBar = {
            CenterAlignedTopAppBar(
                title = { Text("Motors") },
                actions = {
                    IconButton(onClick = { viewModel.startScanner() }) {
                        Icon(FontAwesomeIcons.Brands.BluetoothB, contentDescription = null)
                    }
                }
            )
        }
    ) { padding ->
        LazyColumn(modifier = Modifier.padding(padding)) {
            items(motorState.motors) { motor ->
                MotorListItem(
                    motor    = motor,
                    onToggle = { isOn  -> viewModel.toggleMotor(motor, isOn) },
                    onSpeed  = { speed -> viewModel.updateMotorSpeed(motor, speed) },
                    onTimer  = { time  -> viewModel.updateMotorTimer(motor, time) }
                )
            }
        }
    }
}

Testing and Verification

On Android, declare the required permissions in the manifest and request them at runtime before scanning:

androidMain/AndroidManifest.xml
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
    android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />

On iOS, add NSBluetoothAlwaysUsageDescription to Info.plist.

Expected logcat on a successful connect-and-write:

D/Kable: Event: Connected
D/MotorViewModel: Connected: ATOMBRICK_SERVER
D/MotorViewModel: Successfully wrote motor state: true for motor 1

If peripheral?.services emits null, service discovery has not finished. The connect() call returns before discovery completes. Wait for State.Connected before issuing the first write.

Pitfalls

Collecting services inside each command leaks coroutines

peripheral?.services?.collect { ... } inside a launch block runs until the peripheral disconnects. Calling it in toggleMotor, updateMotorSpeed, and updateMotorTimer launches three separate indefinitely running collectors. Cache the service reference once in pairDevice after connection and reuse it.

services[2] is brittle

Indexing by position assumes the peripheral always enumerates services in the same order. Find the service by UUID instead:

ui/motor/MotorViewModel.kt
val service = services?.firstOrNull { it.serviceUuid == TARGET_SERVICE_UUID }

Wrapping Up

The full shared BLE layer, scanner, peripheral, and characteristic writes, is under 200 lines of Kotlin in commonMain. The expect/actual surface covers only the bonded device query. Kable handles threading, the connection state machine, and GATT operation sequencing on both platforms.

The next problem is reconnection. When the peripheral drops, peripheral?.state emits Disconnected and the Peripheral object is dead. You need to re-create it from the original Advertisement and call connect() again with exponential backoff. If you did not cache the Advertisement, a new scan is required first.


Comments