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.
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
.