Android quick settings tile that [dis]connects a Bluetooth headset
11/22/2020, 8:43:23 AM
Motivation
I stopped writing code for Android and for iOS sometime ago because my job doesn't need them at the moment. I started to feel wary of my not being able to write code for apps as well and as spontaneous as I used to be. I needed to write code in my spare time.
Contributing code to an open source project
I think I first heard about Signal a few years ago but it caught my attention again lately when I read a story or two about the situation in Hong Kong. I went to check it out and found it is being built open source and pull requests are being merged from time to time.
Since then I am looking at its code regularly, both iOS and Android and submitted a handful of pull requests. Some of them are merged which felt good.
But it's not my code. I won't submit a pull request to Signal written in Kotlin because I am pretty sure that it doesn't help the project. If I think about my next job interview, I believe I should be able to write Android apps in Kotlin, smoothly. It requires a practice. So let's practice.
The three virtues of a good programmer
I was looking for a topic to write code for; you know, the three virtues of a good programmer. I found one; I am using Sony WI-1000x Bluetooth headset every day and quite a few times I have been having to disconnect it from my phone to connect it to my laptop, and the other way around.
The problem is that in order to disconnect a headset from Android without killing Bluetooth (the Covid-19 app needs it being turned on), you have multiple steps to do. A few times a day. Annoying.
App requirements
These are the requirements that I had in mind while I was writing the app:
- A quick settings tile that allows you to connect and to disconnect simply by tapping the quick settings icon
- The app remembers the device to connect and to disconnect
- You can select a device for the app to remember in a simple list UI
- Don't turn off Bluetooth itself; connect/disconnect only the specific device
Also, these are the non-feature requirements, that are the reasons why I wanted to write code of the app:
- Use Kotlin
- Use Kotlin Coroutine for async operations
- Use AndroidX ViewModel
- May not use a DI framework but make the modules as friendly as possible to write automated tests
- i.e. Avoid fat activity
Coding highlights
I found a few interesting points while writing the app.
1. Both A2DP and HEADSET had to be disconnected to disconnect the headset
I am not sure if it is with WI-1000x or it is the way for all Bluetooth headphones, but I found that you can connect to the headset by calling
connect
method only to the HEADSET profile, but you must call disconnect
method both to HEADSET profile and to A2DP profile in order to disconnect the headphones from the phone.Because the code to receive a profile object is done asynchronously, I ended up writing code like this (more code here) below to wait for both profiles before moving on to allow the app to intereact with the headphone. I feel there must be a better way of writing this type of wait all events to happen before moving on kind of code like
Promise.all
in JavaScript but wasn't able to find it atm.suspend fun onReady() {
coroutineScope {
while (true) {
delay(1000L)
if (headsetProxy != null && a2dpProxy != null) break
}
}
}
2. You must release the profile objects before leaving
Android Studio was kind enough to let me know that the profile objects are leaking when I implemented the TileService for the app. I was kind of wondering the necessity of doing it but it was not obvious when I was writing the Activity for the app.
But surely you need to clear profile objects before you leave.
3. You cannot [dis]connect Bluetooth headphones by Android SDK
!!! Surprise! I wasn't able to find a simple and easy way to connect or to disconnect the headphones in Android SDK. Well there may be one, but simple web searching and browsing SDK website did not show me a
connect
or disconnect
method to do it.I figured that folks out there who wanted to do that were calling the connect method and the disconnect method that were marked as
@UnsupportedAppUsage
through reflection. It worked ok, and I have no intention to publish this app for anybody to use, so I just settled with it.val connect = BluetoothHeadset::class.java.declaredMethods.findLast {
it.name.equals("connect")
}
connect?.setAccessible(true)
connect?.invoke(headsetProxy, device)
4. You cannot collect two StateFlows in a launch block
collect
method of StateFlow
blocks? I was not able to run this code as intended:selectedIndexJob = CoroutineScope(Dispatchers.Main).launch {
viewModel.selectedIndex.collect { index ->
if (index < 0) return@collect
listAdapter.notifyItemChanged(index)
sharedPreferencesAdapter.setLastSelectedAddress(viewModel.getDevice(index).address)
}
viewModel.previousIndex.collect { index ->
if (index < 0) return@collect
listAdapter.notifyItemChanged(index)
}
}
... because the second
collect
(or the block of it) was never called even when the state was updated. I had to write like this:selectedIndexJob = CoroutineScope(Dispatchers.Main).launch {
viewModel.selectedIndex.collect { index ->
if (index < 0) return@collect
listAdapter.notifyItemChanged(index)
sharedPreferencesAdapter.setLastSelectedAddress(viewModel.getDevice(index).address)
}
}
previousIndexJob = CoroutineScope(Dispatchers.Main).launch {
viewModel.previousIndex.collect { index ->
if (index < 0) return@collect
listAdapter.notifyItemChanged(index)
}
}
... that is fine, but had to struggle a little bit of why the second block was not executed.
5. AndroidX ViewModel and constructor injection
by viewModels()
is an easy way to get a view model instance but it requires the view model to have a default constructor. You have to create your own ViewModelProvider.Factory
that instantiates a new object if you want to do constructor injection.6. lifecycleScope.launchWhenStarted ???
I wrote the app on Saturday, November 21 2020. I'm writing this part the following day, and one of my favorite news letters Android Dagashi issued a new post. In it was the link to the SDK document about Kotlin Flows, and I learned that there is this thing called
launchWhenStarted
. The description goes:"In the previous example that used launchWhenStarted to collect the flow, when the coroutine that triggers the flow collection suspends as the View goes to the background, the underlying producers remain active."
Oh, so with that you don't have to keep a
Job
object, launch it and cancel it because it's handled by the context? Huh. I was doing it manually but you probably don't need this code any more (and the code I wrote may be a wrong way to do it in the first place?). I'll update the code when I have bandwidth.Conclusion
What ended up being implemented is here: BTToggle app in GitHub/fumiakiy
My life is now a little bit easier with this app and I am satisfied.