javascriptandroidiosbluetooth-lowenergykotlin-multiplatform

How do I make a BLE connection to a peripheral with Kotlin Multiplatform or native Android?


Working with BLE through the Android SDK causes a lot of pain. I would like someone to help me with a good implementation of BLE connectivity. Also, it would be nice if this code was also available on other platforms via KMP.


Solution

  • For BLE peripheral connections i'm using Kable library, which supports Android, iOS/macOS and Javascript targets. With my answer you will be able to implement your class to connect to BLE peripherals in less than hour. Pure magic!

    This library has a great non lengthy documentation, I recommend reading it to understand the features of BLE on different systems before viewing my answer.


    At the beginning, I described the possible connection states. They have been divided into three different types: the internal implementation of my main Base class will work with Usable types, the library will pass problems from the system using Unusable types, and the user interface will also have a NoPermissions type available to show proper state in one place.

    // package core.model
    
    sealed interface BluetoothConnectionStatus {
        /**
         * Only on Android and iOS. It is implied that you will use this in the UI layer:
         *
         * ```
         * val correctConnectionStatus =
         *   if (btPermissions.allPermissionsGranted) state.connectionStatus else BluetoothConnectionStatus.NoPermissions
         * ```
         */
        data object NoPermissions : BluetoothConnectionStatus
    
        enum class Unusable : BluetoothConnectionStatus {
            /** Bluetooth not available. */
            UNAVAILABLE,
    
            /**
             * Only on Android 11 and below (on older systems, Bluetooth will not work if GPS is turned off).
             *
             * To enable, enable it via statusbar, use the Google Services API or go to location settings:
             *
             * ```
             * fun Context.goToLocationSettings() = startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS))
             * ```
             */
            LOCATION_SHOULD_BE_ENABLED,
    
            /**
             * To enable (on Android), use this:
             *
             * ```
             * fun Context.isPermissionProvided(permission: String): Boolean =
             *   ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
             *
             * @SuppressLint("MissingPermission")
             * fun Context.enableBluetoothDialog() {
             *   if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
             *     !isPermissionProvided(Manifest.permission.BLUETOOTH_CONNECT)
             *   ) {
             *     return
             *   }
             *
             *   startActivity(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE))
             * }
             * ```
             */
            DISABLED,
        }
    
        enum class Usable : BluetoothConnectionStatus {
            ENABLED,
            SCANNING,
            CONNECTING,
            CONNECTED,
        }
    }
    

    Next, let's move on to the main class that will be used to describe your device through inheritance. It gives you the ability to connect and work with a single device, and if you want to connect to multiple devices or different types of devices, you will need multiple objects. In the standard case of connecting to only one device, you can get by with just one singleton.

    // package core.ble.base
    
    import com.juul.kable.Characteristic
    import com.juul.kable.ObsoleteKableApi
    import com.juul.kable.Peripheral
    import com.juul.kable.PlatformAdvertisement
    import com.juul.kable.ServicesDiscoveredPeripheral
    import com.juul.kable.State
    import com.juul.kable.WriteType
    import com.juul.kable.characteristicOf
    import com.juul.kable.logs.Logging
    import core.model.BluetoothConnectionStatus
    import io.github.aakira.napier.Napier
    import kotlinx.coroutines.CoroutineScope
    import kotlinx.coroutines.ensureActive
    import kotlinx.coroutines.flow.Flow
    import kotlinx.coroutines.flow.MutableStateFlow
    import kotlinx.coroutines.flow.combine
    import kotlinx.coroutines.flow.first
    import kotlinx.coroutines.launch
    import kotlin.coroutines.cancellation.CancellationException
    import kotlin.coroutines.coroutineContext
    
    // base UUID for predefined characteristics: 0000****-0000-1000-8000-00805f9b34fb
    
    private const val RETRY_ATTEMPTS = 7
    
    /** Used to quickly create classes to connect to BLE peripherals. */
    abstract class BaseBleDevice(
        private val serviceUuid: String,
        private val platformBluetoothManager: PlatformBluetoothManager,
        private val useKableLogging: Boolean = false,
    ) {
        private var connectedPeripheral: Peripheral? = null
        private val _connectionStatus: MutableStateFlow<BluetoothConnectionStatus.Usable> =
            MutableStateFlow(BluetoothConnectionStatus.Usable.ENABLED)
    
        /** Provides current connection status. */
        val connectionStatus: Flow<BluetoothConnectionStatus> = combine(
            platformBluetoothManager.systemBluetoothProblemStatus,
            _connectionStatus,
        ) { problemStatusOrNull, internalStatus -> problemStatusOrNull ?: internalStatus }
    
        /** Used to build public `connect` function. */
        protected suspend fun scanAndConnect(
            observeList: List<Pair<String, suspend (ByteArray) -> Unit>> = emptyList(),
            onServicesDiscovered: suspend ServicesDiscoveredPeripheral.() -> Unit = {},
            onSuccessfulConnect: suspend () -> Unit = {},
            advertisementFilter: suspend PlatformAdvertisement.() -> Boolean = { true },
            attempts: Int = RETRY_ATTEMPTS,
        ): Boolean {
            if (!platformBluetoothManager.isPermissionsProvided ||
                connectionStatus.first() != BluetoothConnectionStatus.Usable.ENABLED ||
                attempts < 1
            ) {
                return false
            }
    
            val coroutineScope = CoroutineScope(coroutineContext)
    
            val peripheral = try {
                _connectionStatus.value = BluetoothConnectionStatus.Usable.SCANNING
                platformBluetoothManager.getFirstPeripheral(
                    coroutineScope = coroutineScope,
                    serviceUuid = serviceUuid,
                    advertisementFilter = advertisementFilter,
                ) {
                    if (useKableLogging) {
                        logging {
                            @OptIn(ObsoleteKableApi::class)
                            data = Logging.DataProcessor { data, _, _, _, _ ->
                                data.joinToString { byte -> byte.toString() }
                            }
                            level = Logging.Level.Data // Data > Events > Warnings
                        }
                    }
    
                    onServicesDiscovered(action = onServicesDiscovered)
                }.also { coroutineScope.setupPeripheral(it, observeList) }
            } catch (t: Throwable) {
                Napier.e("scope.peripheral() exception caught: $t")
                _connectionStatus.value = BluetoothConnectionStatus.Usable.ENABLED
    
                coroutineContext.ensureActive()
    
                return if (t !is UnsupportedOperationException && t !is CancellationException && attempts - 1 > 0) {
                    Napier.w("Retrying...")
                    scanAndConnect(
                        observeList = observeList,
                        onServicesDiscovered = onServicesDiscovered,
                        onSuccessfulConnect = onSuccessfulConnect,
                        advertisementFilter = advertisementFilter,
                        attempts = attempts - 1,
                    )
                } else {
                    false
                }
            }
    
            return connectToPeripheral(
                peripheral = peripheral,
                attempts = attempts,
                onSuccessfulConnect = onSuccessfulConnect,
            )
        }
    
        /** Used to build public `reconnect` function.
         *
         * Use it if you need fast reconnect, which will be cancelled in few seconds if peripheral won't found. */
        protected suspend fun reconnect(onSuccessfulConnect: suspend () -> Unit = {}): Boolean {
            if (connectionStatus.first() is BluetoothConnectionStatus.Unusable) {
                return false
            }
    
            return connectedPeripheral?.let {
                connectToPeripheral(
                    peripheral = it,
                    attempts = RETRY_ATTEMPTS,
                    onSuccessfulConnect = onSuccessfulConnect,
                )
            } ?: false
        }
    
        /** Call this function to disconnect the active connection.
         *
         * To cancel in-flight connection attempts you should cancel `Job` with running `connect`.
         *
         * If you are using `Job` cancellation to disconnect the active connection then you won't
         * be able to use `reconnect` because `setupPeripheral` launches will be also cancelled.
         */
        suspend fun disconnect() {
            connectedPeripheral?.disconnect()
        }
    
        /**
         * Can be used to create specified writing functions to send some values to the device.
         *
         * Set **`waitForResponse`** to
         * * **`false`** if characteristic only supports `PROPERTY_WRITE_NO_RESPONSE`;
         * * **`true`** if characteristic supports `PROPERTY_WRITE`.
         */
        protected suspend fun writeTo(
            characteristicUuid: String,
            byteArray: ByteArray,
            waitForResponse: Boolean,
            attempts: Int = RETRY_ATTEMPTS,
        ): Boolean {
            if (connectionStatus.first() is BluetoothConnectionStatus.Unusable) {
                return false
            }
    
            connectedPeripheral?.let { currentPeripheral ->
                repeat(attempts) {
                    try {
                        currentPeripheral.write(
                            characteristic = characteristicOf(serviceUuid, characteristicUuid),
                            data = byteArray,
                            writeType = if (waitForResponse) WriteType.WithResponse else WriteType.WithoutResponse,
                        )
                        return true
                    } catch (e: Exception) {
                        Napier.e("writeTo exception caught: $e")
    
                        coroutineContext.ensureActive()
    
                        if (e is CancellationException) {
                            return false
                        } else if (it != attempts - 1) {
                            Napier.w("Retrying...")
                        }
                    }
                }
            }
    
            return false
        }
    
        /** Can be used to create specified reading functions to get some values from the device. */
        protected suspend fun readFrom(
            characteristicUuid: String,
            attempts: Int = RETRY_ATTEMPTS,
            readFunc: suspend (Characteristic) -> ByteArray? = { connectedPeripheral?.read(it) },
        ): ByteArray? {
            if (connectionStatus.first() is BluetoothConnectionStatus.Unusable) {
                return null
            }
    
            repeat(attempts) {
                try {
                    return readFunc(characteristicOf(serviceUuid, characteristicUuid))
                } catch (e: Exception) {
                    Napier.e("readFrom exception caught: $e")
    
                    coroutineContext.ensureActive()
    
                    if (e is CancellationException) {
                        return null
                    } else if (it != attempts - 1) {
                        Napier.w("Retrying...")
                    }
                }
            }
    
            return null
        }
    
        /**
         * Can be used to get some values from the device on services discovered.
         *
         * @see readFrom
         */
        protected suspend fun ServicesDiscoveredPeripheral.readFrom(
            characteristicUuid: String,
            attempts: Int = RETRY_ATTEMPTS,
        ): ByteArray? = readFrom(characteristicUuid, attempts, ::read)
    
        private fun CoroutineScope.setupPeripheral(
            peripheral: Peripheral,
            observeList: List<Pair<String, suspend (ByteArray) -> Unit>>,
        ) {
            launch {
                try {
                    var isCurrentlyStarted = true
                    peripheral.state.collect { currentState ->
                        if (currentState !is State.Disconnected || !isCurrentlyStarted) {
                            isCurrentlyStarted = false
    
                            _connectionStatus.value = when (currentState) {
                                is State.Disconnected,
                                State.Disconnecting,
                                -> BluetoothConnectionStatus.Usable.ENABLED
    
                                State.Connecting.Bluetooth,
                                State.Connecting.Services,
                                State.Connecting.Observes,
                                -> BluetoothConnectionStatus.Usable.CONNECTING
    
                                State.Connected,
                                -> BluetoothConnectionStatus.Usable.CONNECTED // or set it in connectToPeripheral()
                            }
                        }
                    }
                } catch (t: Throwable) {
                    _connectionStatus.value = BluetoothConnectionStatus.Usable.ENABLED
                    coroutineContext.ensureActive()
                }
            }
            observeList.forEach { (characteristic, observer) ->
                launch {
                    peripheral.observe(
                        characteristicOf(service = serviceUuid, characteristic = characteristic),
                    ).collect { byteArray ->
                        observer(byteArray)
                    }
                }
            }
        }
    
        private suspend fun connectToPeripheral(
            peripheral: Peripheral,
            attempts: Int,
            onSuccessfulConnect: suspend () -> Unit,
        ): Boolean {
            try {
                peripheral.connect()
                connectedPeripheral = peripheral
            } catch (t: Throwable) {
                Napier.e("connectToPeripheral exception caught: $t")
                peripheral.disconnect()
                _connectionStatus.value = BluetoothConnectionStatus.Usable.ENABLED
    
                coroutineContext.ensureActive()
    
                return if (t !is CancellationException && attempts - 1 > 0) {
                    Napier.w("Retrying...")
                    connectToPeripheral(
                        peripheral = peripheral,
                        attempts = attempts - 1,
                        onSuccessfulConnect = onSuccessfulConnect,
                    )
                } else {
                    false
                }
            }
            onSuccessfulConnect()
    //      _connectionStatus.value = BluetoothConnectionStatus.Usable.CONNECTED
            return true
        }
    }
    

    BaseBleDevice depends on PlatformBluetoothManager, which is a KMP layer for working with different systems. Expect/Actual classes are described below. If your project is Android-only, you need to copy only androidMain class and remove actual keywords from it.

    commonMain:

    // package core.ble.base
    
    import com.juul.kable.Peripheral
    import com.juul.kable.PeripheralBuilder
    import com.juul.kable.PlatformAdvertisement
    import core.model.BluetoothConnectionStatus
    import kotlinx.coroutines.CoroutineScope
    import kotlinx.coroutines.flow.Flow
    
    /** Provides system specific Bluetooth connectivity features for KMP targets. */
    expect class PlatformBluetoothManager {
        /**
         * Provides flow with advertisements that can be used to show devices, select one of them,
         * get `UUID`/`MAC` and use it in `advertisementFilter`.
         *
         * Not available in JS target.
         */
        fun getAdvertisements(serviceUuid: String): Flow<PlatformAdvertisement>
    
        /** Provides first found peripheral to connect. */
        suspend fun getFirstPeripheral(
            coroutineScope: CoroutineScope,
            serviceUuid: String,
            advertisementFilter: suspend PlatformAdvertisement.() -> Boolean = { true }, // by default the first device found
            peripheralBuilderAction: PeripheralBuilder.() -> Unit,
        ): Peripheral
    
        /** Returns the Bluetooth status of the system: `Unusable` subtypes or null if it's `Usable`. */
        val systemBluetoothProblemStatus: Flow<BluetoothConnectionStatus.Unusable?>
    
        /** Indicates whether it is possible to start scanning and connection. */
        val isPermissionsProvided: Boolean
    }
    

    androidMain:

    // package core.ble.base
    
    import android.Manifest
    import android.content.Context
    import android.content.pm.PackageManager
    import android.os.Build
    import androidx.core.content.ContextCompat
    import com.benasher44.uuid.uuidFrom
    import com.juul.kable.Bluetooth
    import com.juul.kable.Peripheral
    import com.juul.kable.PeripheralBuilder
    import com.juul.kable.PlatformAdvertisement
    import com.juul.kable.Reason
    import com.juul.kable.Scanner
    import com.juul.kable.peripheral
    import core.model.BluetoothConnectionStatus
    import kotlinx.coroutines.CoroutineScope
    import kotlinx.coroutines.flow.filter
    import kotlinx.coroutines.flow.first
    import kotlinx.coroutines.flow.map
    
    actual class PlatformBluetoothManager(private val context: Context) {
        actual fun getAdvertisements(serviceUuid: String) =
            Scanner { filters { match { services = listOf(uuidFrom(serviceUuid)) } } }.advertisements
    
        actual suspend fun getFirstPeripheral(
            coroutineScope: CoroutineScope,
            serviceUuid: String,
            advertisementFilter: suspend PlatformAdvertisement.() -> Boolean,
            peripheralBuilderAction: PeripheralBuilder.() -> Unit,
        ): Peripheral = coroutineScope.peripheral(
            advertisement = getAdvertisements(serviceUuid).filter(predicate = advertisementFilter).first(),
            builderAction = peripheralBuilderAction,
        )
    
        actual val systemBluetoothProblemStatus = Bluetooth.availability.map {
            when (it) {
                Bluetooth.Availability.Available -> null
                is Bluetooth.Availability.Unavailable -> when (it.reason) {
                    Reason.Off -> BluetoothConnectionStatus.Unusable.DISABLED
                    Reason.LocationServicesDisabled -> BluetoothConnectionStatus.Unusable.LOCATION_SHOULD_BE_ENABLED
                    else -> BluetoothConnectionStatus.Unusable.UNAVAILABLE
                }
            }
        }
    
        actual val isPermissionsProvided
            get() = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
                context.isPermissionProvided(Manifest.permission.ACCESS_FINE_LOCATION)
            } else {
                context.isPermissionProvided(Manifest.permission.BLUETOOTH_SCAN) &&
                    context.isPermissionProvided(Manifest.permission.BLUETOOTH_CONNECT)
            }
    }
    
    private fun Context.isPermissionProvided(permission: String): Boolean =
        ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
    

    jsMain:

    // package core.ble.base
    
    import com.benasher44.uuid.uuidFrom
    import com.juul.kable.Bluetooth
    import com.juul.kable.Filter
    import com.juul.kable.Options
    import com.juul.kable.Peripheral
    import com.juul.kable.PeripheralBuilder
    import com.juul.kable.PlatformAdvertisement
    import com.juul.kable.requestPeripheral
    import core.model.BluetoothConnectionStatus
    import kotlinx.coroutines.CoroutineScope
    import kotlinx.coroutines.await
    import kotlinx.coroutines.flow.Flow
    import kotlinx.coroutines.flow.map
    
    actual class PlatformBluetoothManager {
        actual fun getAdvertisements(serviceUuid: String): Flow<PlatformAdvertisement> =
            throw NotImplementedError("Web Bluetooth doesn't allow to discover nearby devices")
    
        actual suspend fun getFirstPeripheral(
            coroutineScope: CoroutineScope,
            serviceUuid: String,
            advertisementFilter: suspend PlatformAdvertisement.() -> Boolean, // not used: in JS only user can select device
            peripheralBuilderAction: PeripheralBuilder.() -> Unit,
        ): Peripheral = coroutineScope.requestPeripheral(
            options = Options(filters = listOf(Filter.Service(uuidFrom(serviceUuid)))),
            builderAction = peripheralBuilderAction,
        ).then(
            onFulfilled = { it },
            onRejected = {
                throw UnsupportedOperationException(
                    "Can't show popup because user hasn't interacted with page or user has closed pairing popup",
                )
            },
        ).await()
    
        actual val systemBluetoothProblemStatus = Bluetooth.availability.map {
            when (it) {
                is Bluetooth.Availability.Unavailable -> BluetoothConnectionStatus.Unusable.UNAVAILABLE
                Bluetooth.Availability.Available -> null
            }
        }
    
        actual val isPermissionsProvided = true
    }
    

    appleMain (since I don't have a Mac to compile and test, the class is not fully implemented):

    // package core.ble.base
    
    import com.benasher44.uuid.uuidFrom
    import com.juul.kable.Bluetooth
    import com.juul.kable.Peripheral
    import com.juul.kable.PeripheralBuilder
    import com.juul.kable.PlatformAdvertisement
    import com.juul.kable.Reason
    import com.juul.kable.Scanner
    import com.juul.kable.peripheral
    import core.model.BluetoothConnectionStatus
    import kotlinx.coroutines.CoroutineScope
    import kotlinx.coroutines.flow.filter
    import kotlinx.coroutines.flow.first
    import kotlinx.coroutines.flow.map
    
    actual class PlatformBluetoothManager {
        actual fun getAdvertisements(serviceUuid: String) =
            Scanner { filters { match { services = listOf(uuidFrom(serviceUuid)) } } }.advertisements
    
        actual suspend fun getFirstPeripheral(
            coroutineScope: CoroutineScope,
            serviceUuid: String,
            advertisementFilter: suspend PlatformAdvertisement.() -> Boolean,
            peripheralBuilderAction: PeripheralBuilder.() -> Unit,
        ): Peripheral = coroutineScope.peripheral(
            advertisement = getAdvertisements(serviceUuid).filter(predicate = advertisementFilter).first(),
            builderAction = peripheralBuilderAction,
        )
    
        actual val systemBluetoothProblemStatus = Bluetooth.availability.map {
            when (it) {
                Bluetooth.Availability.Available -> null
                is Bluetooth.Availability.Unavailable -> when (it.reason) {
                    Reason.Off -> BluetoothConnectionStatus.Unusable.DISABLED
    //              Reason.Unauthorized -> BluetoothConnectionStatus.Unusable.UNAUTHORIZED // use it only if needed
                    else -> BluetoothConnectionStatus.Unusable.UNAVAILABLE
                }
            }
        }
    
        // I don't develop apps for Apple and don't have the ability to debug the code,
        // so you'll have to add the implementation yourself.
        actual val isPermissionsProvided: Boolean
            get() = TODO()
    }
    

    This is the main part that you need to copy to yourself to quickly implement communication with BLE devices.


    Below you can see an example class to work with a custom RFID reader, which you can use as an example to create your own class. New values are passed to the listeners via SharedFlow and StateFlow. On connection we initialise listening for characteristic updates via observeList, force some of them to be read immediately after connection in onServicesDiscovered, and require to connect only to the passed macAddress via advertisementFilter. In addition, the class allows you to get connectionStatus since it inherits from BaseBleDevice and allows you to pass the time to the device via sendTime(). Please note that this class uses Android platform specific functions, is not compatible with KMP and is provided as an example only.

    // package core.ble.laundry
    
    import core.ble.base.BaseBleDevice
    import core.ble.base.PlatformBluetoothManager
    import kotlinx.coroutines.flow.MutableSharedFlow
    import kotlinx.coroutines.flow.MutableStateFlow
    import kotlinx.coroutines.flow.SharedFlow
    import kotlinx.coroutines.flow.StateFlow
    import kotlinx.datetime.Clock
    import javax.inject.Inject
    import javax.inject.Singleton
    
    private const val SERVICE_UUID = "f71381b8-c439-4b29-8256-620efaef0b4e"
    
    // here you can also use Bluetooth.BaseUuid.plus(0x2a19).toString()
    private const val BATTERY_LEVEL_UUID = "00002a19-0000-1000-8000-00805f9b34fb"
    private const val BATTERY_IS_CHARGING_UUID = "1170f274-a09b-46b3-88c5-0e1c67037861"
    private const val RFID_UUID = "3d58f98d-63f0-43d5-a7d4-54fa1ed824ba"
    private const val TIME_UUID = "016c2726-b22a-4cdc-912b-f626d1e4051e"
    
    @Singleton
    class PersonProviderDevice @Inject constructor(
        platformBluetoothManager: PlatformBluetoothManager,
    ) : BaseBleDevice(SERVICE_UUID, platformBluetoothManager) {
        private val _providedRfid = MutableSharedFlow<Long>()
        val providedRfid: SharedFlow<Long> = _providedRfid
    
        private val _batteryState = MutableStateFlow(BatteryState())
        val batteryState: StateFlow<BatteryState> = _batteryState
    
        suspend fun connect(macAddress: String?) = scanAndConnect(
            observeList = listOf(
                RFID_UUID to { bytes ->
                    bytes.toULong()?.let { _providedRfid.emit(it.toLong()) }
                },
                BATTERY_LEVEL_UUID to { bytes ->
                    _batteryState.value = _batteryState.value.copy(level = bytes.toBatteryLevel())
                },
                BATTERY_IS_CHARGING_UUID to { bytes ->
                    _batteryState.value = _batteryState.value.copy(isCharging = bytes.toIsCharging())
                },
            ),
            onServicesDiscovered = {
    //          requestMtu(512)
                val level = this.readFrom(BATTERY_LEVEL_UUID)?.toBatteryLevel()
                val isCharging = this.readFrom(BATTERY_IS_CHARGING_UUID)?.toIsCharging()
    
                _batteryState.value = BatteryState(level, isCharging ?: false)
            },
            advertisementFilter = { macAddress?.let { this.address.lowercase() == it.lowercase() } ?: true },
        )
    
        suspend fun sendTime(): Boolean {
            val byteArrayWithTime = Clock.System.now().toEpochMilliseconds().toString().toByteArray()
            return writeTo(TIME_UUID, byteArrayWithTime, true)
        }
    
        private fun ByteArray.toBatteryLevel() = this.first().toInt()
    
        private fun ByteArray.toIsCharging() = this.first().toInt() == 1
    }
    
    /**
     * Provides ULong from ByteArray encoded with little endian
     */
    fun ByteArray.toULong(size: Int = 8): ULong? =
        if (this.size != size) {
            null
        } else {
            var result: ULong = 0u
            repeat(size) {
                result = result or ((this[it].toULong() and 0xFFu) shl (it * 8))
            }
            result
        }
    
    data class BatteryState(
        val level: Int? = null,
        val isCharging: Boolean = false,
    )
    

    This class aims to work with only one device at a time. It does not provide the possibility to disconnect from the device via the standard disconnect() function, but it is possible via Job cancellation. reconnect() is also unavailable because it's required on very rare occasions.

    To work with this class, I added the following code to my ViewModel. It allows you to automatically disconnect from the device when the application is minimised, automatically connect when the application is active and it is possible to connect. You need to call onResume() and onStop() from the appropriate functions in your Activity.

        private var isLaunchingConnectionJob = false
        private var connection: Job? = null
        private var isAppResumed: Boolean = false
    
        init {
            viewModelScope.launch {
                rfidRepository.connectionStatus.collect {
                    state = state.copy(connectionStatus = it)
    
                    // used to reconnect when device was disconnected
                    connectIfResumedAndNotConnectedOrSendTime()
                }
            }
        }
    
        fun onResume() {
            isAppResumed = true
            viewModelScope.launch {
                connectIfResumedAndNotConnectedOrSendTime()
            }
        }
    
        fun onStop() {
            isAppResumed = false
            viewModelScope.launch {
                delay(2000)
                if (!isAppResumed) {
                    connection?.cancelAndJoin()
                }
            }
        }
    
        private suspend fun connectIfResumedAndNotConnectedOrSendTime() {
            if (isLaunchingConnectionJob) {
                return
            }
    
            isLaunchingConnectionJob = true
            if (isAppResumed && rfidRepository.connectionStatus.first() == BluetoothConnectionStatus.Usable.ENABLED) {
                connection?.cancelAndJoin()
                connection = viewModelScope.launch {
                    isLaunchingConnectionJob = false
                    rfidRepository.connect()
                    while (true) {
                        delay(1000)
                        val isWritten = rfidRepository.sendTime()
                        if (!isWritten) {
                            connection?.cancelAndJoin()
                        }
                    }
                }
            } else {
                isLaunchingConnectionJob = false
            }
        }
    

    So, to work with BLE devices, all you need to do is copy the BluetoothConnectionStatus, BaseBleDevice and PlatformBluetoothManager classes into your project, describe the characteristics of your device yourself by creating an inheritor of BaseBleDevice and connect to the device from the ViewModel.