Skip to content
This repository was archived by the owner on Aug 30, 2022. It is now read-only.

Commit 3fb43b4

Browse files
authored
Add CoroutinesGattDevice and BluetoothDevice extension functions (#20)
Provides a thread safe mechanism of having a `BluetoothDevice` directly associated with a `Gatt`, so that `BluetoothDevice` can be treated as a connectable element. Via extension functions, the connection state of a "BluetoothDevice" can be queried, and the state persists across connections (transcends `Gatt` objects).
1 parent 9f88963 commit 3fb43b4

File tree

10 files changed

+525
-0
lines changed

10 files changed

+525
-0
lines changed

core/src/main/java/android/BluetoothDevice.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
* Copyright 2018 JUUL Labs, Inc.
33
*/
44

5+
@file:JvmName("BluetoothDeviceCoreKt")
6+
57
package com.juul.able.experimental.android
68

79
import android.bluetooth.BluetoothDevice

device/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/build

device/README.md

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# Device
2+
3+
Provides [`BluetoothDevice`] extension functions to simplify Android Bluetooth Low Energy usage.
4+
Allows [`BluetoothDevice`] to be used as the single access point for connectivity and communication
5+
without having to manage underlying [`BluetoothGatt`] objects (_error handling omitted for
6+
simplicity_):
7+
8+
```kotlin
9+
fun connectAndReadEveryMinute(
10+
context: Context,
11+
bluetoothDevice: BluetoothDevice,
12+
characteristic: BluetoothGattCharacteristic
13+
): Job = launch {
14+
while (isActive) {
15+
bluetoothDevice.connect(context)
16+
bluetoothDevice.discoverServices()
17+
18+
val value = bluetoothDevice.readCharacteristic(characteristic).value
19+
20+
try {
21+
bluetoothDevice.disconnect()
22+
} finally {
23+
bluetoothDevice.close()
24+
}
25+
26+
delay(60_000L)
27+
}
28+
}
29+
```
30+
31+
Under the hood, [`BluetoothDevice`]s are wrapped and stored in a `ConcurrentMap` so that connection
32+
and characteristic observation `Channel`s can be used across connections (_error handling omitted
33+
for simplicity_):
34+
35+
```kotlin
36+
fun connectionStateExample(context: Context, bluetoothDevice: BluetoothDevice) {
37+
launch {
38+
// The same `Channel` will persist across connections, no need to resubscribe on reconnect.
39+
bluetoothDevice.onConnectionStateChange.consumeEach {
40+
println("Connection state changed to $it for $bluetoothDevice")
41+
}
42+
}
43+
44+
launch {
45+
bluetoothDevice.connect(context)
46+
bluetoothDevice.discoverServices()
47+
48+
delay(10_000L)
49+
50+
bluetoothDevice.disconnect()
51+
52+
delay(5_000L)
53+
54+
bluetoothDevice.connect()
55+
56+
delay(10_000L)
57+
58+
try {
59+
bluetoothDevice.disconnect()
60+
} finally {
61+
bluetoothDevice.close()
62+
}
63+
}
64+
}
65+
```
66+
67+
When a [`BluetoothDevice`] is no longer needed, it should be disposed from the underlying
68+
`ConcurrentMap`:
69+
70+
```kotlin
71+
// Close underlying [`BluetoothGatt`] and close connection state and characteristic change Channels.
72+
CoroutinesGattDevices -= bluetoothDevice
73+
```
74+
75+
# Setup
76+
77+
## Gradle
78+
79+
[![JitPack version](https://jitpack.io/v/JUUL-OSS/able.svg)](https://jitpack.io/#JUUL-OSS/able)
80+
81+
```groovy
82+
repositories {
83+
maven { url "https://jitpack.io" }
84+
}
85+
86+
dependencies {
87+
implementation "com.github.JUUL-OSS.able:device:$version"
88+
}
89+
```
90+
91+
92+
[`BluetoothDevice`]: https://developer.android.com/reference/android/bluetooth/BluetoothDevice
93+
[`BluetoothGatt`]: https://developer.android.com/reference/android/bluetooth/BluetoothGatt

device/build.gradle

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
apply plugin: 'com.android.library'
2+
apply plugin: 'kotlin-android'
3+
apply plugin: 'com.github.dcendents.android-maven'
4+
5+
group 'com.github.JUUL-OSS.able'
6+
7+
kotlin.experimental.coroutines 'enable'
8+
9+
android {
10+
compileSdkVersion versions.compileSdk
11+
12+
defaultConfig {
13+
minSdkVersion versions.minSdk
14+
}
15+
}
16+
17+
task sourcesJar(type: Jar) {
18+
classifier = 'sources'
19+
from android.sourceSets.main.java.srcDirs
20+
}
21+
22+
artifacts {
23+
archives sourcesJar
24+
}
25+
26+
dependencies {
27+
api project(':core')
28+
}

device/src/main/AndroidManifest.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<!--
2+
~ Copyright 2018 JUUL Labs, Inc.
3+
-->
4+
5+
<manifest package="com.juul.able.experimental.device" />
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
/*
2+
* Copyright 2018 JUUL Labs, Inc.
3+
*/
4+
5+
package com.juul.able.experimental.device
6+
7+
import android.bluetooth.BluetoothDevice
8+
import android.bluetooth.BluetoothGatt
9+
import android.bluetooth.BluetoothGattCharacteristic
10+
import android.bluetooth.BluetoothGattDescriptor
11+
import android.bluetooth.BluetoothGattService
12+
import android.bluetooth.BluetoothProfile
13+
import android.content.Context
14+
import com.juul.able.experimental.Able
15+
import com.juul.able.experimental.ConnectGattResult
16+
import com.juul.able.experimental.Gatt
17+
import com.juul.able.experimental.GattState
18+
import com.juul.able.experimental.GattStatus
19+
import com.juul.able.experimental.WriteType
20+
import com.juul.able.experimental.android.connectGatt
21+
import com.juul.able.experimental.messenger.GattCallbackConfig
22+
import com.juul.able.experimental.messenger.OnCharacteristicChanged
23+
import com.juul.able.experimental.messenger.OnCharacteristicRead
24+
import com.juul.able.experimental.messenger.OnCharacteristicWrite
25+
import com.juul.able.experimental.messenger.OnConnectionStateChange
26+
import com.juul.able.experimental.messenger.OnDescriptorWrite
27+
import com.juul.able.experimental.messenger.OnMtuChanged
28+
import kotlinx.coroutines.experimental.CoroutineScope
29+
import kotlinx.coroutines.experimental.Deferred
30+
import kotlinx.coroutines.experimental.Job
31+
import kotlinx.coroutines.experimental.async
32+
import kotlinx.coroutines.experimental.channels.BroadcastChannel
33+
import kotlinx.coroutines.experimental.channels.Channel.Factory.CONFLATED
34+
import kotlinx.coroutines.experimental.channels.consumeEach
35+
import kotlinx.coroutines.experimental.launch
36+
import kotlinx.coroutines.experimental.runBlocking
37+
import kotlinx.coroutines.experimental.sync.Mutex
38+
import kotlinx.coroutines.experimental.sync.withLock
39+
import java.util.UUID
40+
import java.util.concurrent.atomic.AtomicInteger
41+
import kotlin.coroutines.experimental.CoroutineContext
42+
43+
class GattUnavailable : IllegalStateException()
44+
45+
class CoroutinesGattDevice internal constructor(
46+
private val bluetoothDevice: BluetoothDevice
47+
) : Gatt, CoroutineScope {
48+
49+
/*
50+
* Constructor must **not** have side-effects as we're relying on `ConcurrentMap<K, V>.getOrPut`
51+
* in `DeviceManager.wrapped` which uses `putIfAbsent` as an alternative to `computeIfAbsent`
52+
* (`computeIfAbsent` is only available on API >= 24).
53+
*
54+
* As stated in [Equivalent of ComputeIfAbsent in Java 7](https://stackoverflow.com/a/40665232):
55+
*
56+
* > This is pretty much functionally equivalent the `computeIfAbsent` call in Java 8, with the
57+
* > only difference being that sometimes you construct a `Value` object that never makes it
58+
* > into the map - because another thread put it in first. It never results in returning the
59+
* > wrong object or anything like that - the function consistently returns the right `Value` no
60+
* > matter what, but _if the construction of `Value` has side-effects*_, this may not be
61+
* > acceptable.
62+
*/
63+
64+
private val job = Job()
65+
override val coroutineContext: CoroutineContext
66+
get() = job
67+
68+
private val connectMutex = Mutex()
69+
private var connectDeferred: Deferred<ConnectGattResult>? = null
70+
71+
private var _gatt: Gatt? = null
72+
private val gatt: Gatt
73+
get() = _gatt ?: throw GattUnavailable()
74+
75+
private val _connectionState = AtomicInteger()
76+
fun getConnectionState(): GattState = _connectionState.get()
77+
78+
private var eventJob = Job(job)
79+
set(value) {
80+
// Prevent runaways: cancel the previous Job that we are loosing a reference to.
81+
field.cancel()
82+
field = value
83+
}
84+
85+
/**
86+
* Scopes forwarding events to (i.e. the following `Channel`s are consumed under this scope):
87+
*
88+
* - [onConnectionStateChange]
89+
* - [onCharacteristicChanged]
90+
*
91+
* @see createConnection
92+
*/
93+
private val eventCoroutineScope
94+
get() = CoroutineScope(eventJob)
95+
96+
override val onConnectionStateChange = BroadcastChannel<OnConnectionStateChange>(CONFLATED)
97+
override val onCharacteristicChanged = BroadcastChannel<OnCharacteristicChanged>(1)
98+
99+
fun isConnected(): Boolean =
100+
_gatt != null && getConnectionState() == BluetoothProfile.STATE_CONNECTED
101+
102+
suspend fun connect(
103+
context: Context,
104+
autoConnect: Boolean,
105+
callbackConfig: GattCallbackConfig
106+
): ConnectGattResult {
107+
Able.verbose { "Connection requested to bluetooth device $bluetoothDevice" }
108+
109+
val result = connectMutex.withLock {
110+
connectDeferred ?: createConnection(context, autoConnect, callbackConfig).also {
111+
connectDeferred = it
112+
}
113+
}.await()
114+
115+
Able.info { "connect ← result=$result" }
116+
return result
117+
}
118+
119+
private suspend fun cancelConnect() = connectMutex.withLock {
120+
connectDeferred?.cancel()
121+
?: Able.verbose { "No connection to cancel for bluetooth device $bluetoothDevice" }
122+
connectDeferred = null
123+
}
124+
125+
private fun createConnection(
126+
context: Context,
127+
autoConnect: Boolean,
128+
callbackConfig: GattCallbackConfig
129+
): Deferred<ConnectGattResult> = async {
130+
Able.info { "Creating connection for bluetooth device $bluetoothDevice" }
131+
val result = bluetoothDevice.connectGatt(context, autoConnect, callbackConfig)
132+
133+
if (result is ConnectGattResult.Success) {
134+
val newGatt = result.gatt
135+
_gatt = newGatt
136+
137+
eventJob = Job(job) // Prepare event Coroutine scope.
138+
139+
eventCoroutineScope.launch {
140+
Able.verbose { "onConnectionStateChange → $bluetoothDevice → Begin" }
141+
newGatt.onConnectionStateChange.consumeEach {
142+
if (it.status == BluetoothGatt.GATT_SUCCESS) {
143+
_connectionState.set(it.newState)
144+
}
145+
Able.verbose { "Forwarding $it for $bluetoothDevice" }
146+
onConnectionStateChange.send(it)
147+
}
148+
Able.verbose { "onConnectionStateChange ← $bluetoothDevice ← End" }
149+
}.invokeOnCompletion {
150+
Able.verbose { "onConnectionStateChange for $bluetoothDevice completed, cause=$it" }
151+
}
152+
153+
eventCoroutineScope.launch {
154+
Able.verbose { "onCharacteristicChanged → $bluetoothDevice → Begin" }
155+
newGatt.onCharacteristicChanged.consumeEach {
156+
Able.verbose { "Forwarding $it for $bluetoothDevice" }
157+
onCharacteristicChanged.send(it)
158+
}
159+
Able.verbose { "onCharacteristicChanged ← $bluetoothDevice ← End" }
160+
}.invokeOnCompletion {
161+
Able.verbose { "onCharacteristicChanged for $bluetoothDevice completed, cause=$it" }
162+
}
163+
164+
ConnectGattResult.Success(this@CoroutinesGattDevice)
165+
} else {
166+
result
167+
}
168+
}
169+
170+
override val services: List<BluetoothGattService>
171+
get() = gatt.services
172+
173+
override fun requestConnect(): Boolean = gatt.requestConnect()
174+
175+
override fun requestDisconnect(): Unit = gatt.requestDisconnect()
176+
177+
override fun getService(uuid: UUID): BluetoothGattService? = gatt.getService(uuid)
178+
179+
override suspend fun connect(): Boolean = gatt.connect()
180+
181+
override suspend fun disconnect() {
182+
cancelConnect()
183+
184+
Able.verbose { "Disconnecting from bluetooth device $bluetoothDevice" }
185+
_gatt?.disconnect()
186+
?: Able.warn { "Unable to disconnect from bluetooth device $bluetoothDevice" }
187+
188+
eventJob.cancel()
189+
}
190+
191+
override fun close() {
192+
Able.verbose { "close → Begin" }
193+
194+
runBlocking {
195+
cancelConnect()
196+
}
197+
198+
eventJob.cancel()
199+
200+
Able.debug { "close → Closing bluetooth device $bluetoothDevice" }
201+
_gatt?.close()
202+
_gatt = null
203+
204+
Able.verbose { "close ← End" }
205+
}
206+
207+
internal fun dispose() {
208+
Able.verbose { "dispose → Begin" }
209+
210+
job.cancel()
211+
_gatt?.close()
212+
213+
Able.verbose { "dispose ← End" }
214+
}
215+
216+
override suspend fun discoverServices(): GattStatus = gatt.discoverServices()
217+
218+
override suspend fun readCharacteristic(
219+
characteristic: BluetoothGattCharacteristic
220+
): OnCharacteristicRead = gatt.readCharacteristic(characteristic)
221+
222+
override suspend fun writeCharacteristic(
223+
characteristic: BluetoothGattCharacteristic,
224+
value: ByteArray,
225+
writeType: WriteType
226+
): OnCharacteristicWrite = gatt.writeCharacteristic(characteristic, value, writeType)
227+
228+
override suspend fun writeDescriptor(
229+
descriptor: BluetoothGattDescriptor,
230+
value: ByteArray
231+
): OnDescriptorWrite = gatt.writeDescriptor(descriptor, value)
232+
233+
override suspend fun requestMtu(mtu: Int): OnMtuChanged = gatt.requestMtu(mtu)
234+
235+
override fun setCharacteristicNotification(
236+
characteristic: BluetoothGattCharacteristic,
237+
enable: Boolean
238+
): Boolean = gatt.setCharacteristicNotification(characteristic, enable)
239+
240+
override fun toString(): String =
241+
"CoroutinesGattDevice(bluetoothDevice=$bluetoothDevice, state=${getConnectionState()})"
242+
}
243+

0 commit comments

Comments
 (0)