The intention of this project is to demonstrate a non-trivial Bluetooth LE app using Kable and app architecture best practices.
You can find a decent number of open-source projects on GitHub and elsewhere that implement BLE functionality. So far I have not found one that I am completely happy with. In my experience, there is always at least one thing wrong with each project. Some common issues I have seen:
- Lack of any structure whatsoever (e.g. all the logic in the Activity). Common in examples from chip vendors.
- Callback hell (instead of using something like coroutines)
- "Only" implementing scanning - it is relatively straightforward to structure an app that just scans for BLE peripherals. There are many examples on GitHub. Much harder (and from my searching, all but non-existent on GitHub) are apps that connect to those peripherals and actually do something1, 2.
- BLE functionality in the ViewModel which goes against best practices
1 Bonus points if you can find an example of an app that demonstrates maintaining a connection to a peripheral across multiple fragments. I haven't found any.
2 Yes, I realize this app currently doesn't do much more than scanning. I want to fix the fundamental issues listed below before I put effort into other areas.
Currently the app implements scanning and displaying the results. If you click on a result, a connection will be initiated and you'll switch to a different fragment.
Something is wrong the view model lifecycle. If you rotate the screen while scanning, the screen will be cleared and the scan will no longer be running.Fixed by this commit- I'm not entirely sure if the service binding strategy I have chosen is sound/optimal.
Scanning takes place in BluetoothLeService
which is a relatively straightforward LifecycleService. Detected devices are exposed by the service as a StateFlow. The service also exposes a scanStatus
StateFlow
.
Connecting the service to the view model (BleViewModel
) is a little tricky. I wanted to avoid having to make BluetoothLeService
a singleton. Instead, I wrote a small annotation processor (the processor
module) which takes the service class and generates a wrapper class like below.
The wrapper acts as a proxy around the service and forwards state from the service (when it is bound). The view model can observe the wrapper's flows, regardless of whether the underlying service is bound or not. This makes the lifecycles easier.
@Singleton
public open class BluetoothLeServiceWrapperBase(
private val applicationContext: Context
) : LifecycleObserver {
private val _advertisements: MutableStateFlow<List<Advertisement>> =
MutableStateFlow(emptyList())
public val advertisements: StateFlow<List<Advertisement>> = _advertisements.asStateFlow()
private val _connectState: MutableStateFlow<ConnectState?> = MutableStateFlow(null)
public val connectState: StateFlow<ConnectState?> = _connectState.asStateFlow()
private val _scanStatus: MutableStateFlow<ScanStatus?> = MutableStateFlow(null)
public val scanStatus: StateFlow<ScanStatus?> = _scanStatus.asStateFlow()
protected lateinit var _service: BluetoothLeService
private var _bound: Boolean = false
public val _connection: ServiceConnection = object : ServiceConnection {
public override fun onServiceConnected(className: ComponentName, service: IBinder) {
val binder = service as BluetoothLeService.LocalBinder
_service = binder.getService()
_bound = true
this@BluetoothLeServiceWrapperBase.onServiceConnected(_service)
_service.lifecycleScope.launch {
launch {
_advertisements.emitAll(_service.advertisements)
}
launch {
_connectState.emitAll(_service.connectState)
}
launch {
_scanStatus.emitAll(_service.scanStatus)
}
}
}
public override fun onServiceDisconnected(className: ComponentName) {
_bound = false
}
}
@OnLifecycleEvent(Lifecycle.Event.ON_START)
public fun handleLifecycleStart() {
Intent(applicationContext, BluetoothLeService::class.java).also { intent ->
applicationContext.bindService(intent, _connection, Context.BIND_AUTO_CREATE)
}
}
public open fun onServiceConnected(service: BluetoothLeService) { }
}