diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index dbad973..ef191bc 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -40,7 +40,7 @@ jobs:
key: grade-${{ hashFiles('**/*.gradle*') }}
- name: Check
- run: ./gradlew $GRADLE_ARGS check jacocoTestDebugUnitTestReport
+ run: ./gradlew $GRADLE_ARGS check jacocoTestReport
- name: Codecov
uses: codecov/codecov-action@v1
diff --git a/README.md b/README.md
index 44a7649..5f086d1 100644
--- a/README.md
+++ b/README.md
@@ -24,8 +24,7 @@ traditionally rely on [`BluetoothGattCallback`] calls with [suspension functions
callback: BluetoothGattCallback
): BluetoothGatt
suspend fun connectGatt(
- context: Context,
- autoConnect: Boolean = false
+ context: Context
): ConnectGattResult 1
|
@@ -37,7 +36,7 @@ traditionally rely on [`BluetoothGattCallback`] calls with [suspension functions
sealed class ConnectGattResult {
data class Success(val gatt: Gatt) : ConnectGattResult()
data class Canceled(val cause: CancellationException) : ConnectGattResult()
- data class Failure(val cause: Throwable) : ConnectGattResult()
+ data class Failure(val cause: Exception) : ConnectGattResult()
}
```
@@ -112,8 +111,7 @@ sealed class ConnectGattResult {
1 _Suspends until `STATE_CONNECTED` or non-`GATT_SUCCESS` is received._
2 _Suspends until `STATE_DISCONNECTED` or non-`GATT_SUCCESS` is received._
3 _Throws [`RemoteException`] if underlying [`BluetoothGatt`] call returns `false`._
-3 _Throws `GattClosed` if `Gatt` is closed while method is executing._
-3 _Throws `GattConnectionLost` if `Gatt` is disconnects while method is executing._
+3 _Throws `ConnectionLost` if `Gatt` is closed while method is executing._
### Details
@@ -142,11 +140,11 @@ corresponding [`BluetoothGatt`] will be closed:
```kotlin
fun connect(context: Context, device: BluetoothDevice) {
val deferred = async {
- device.connectGatt(context, autoConnect = false)
+ device.connectGatt(context)
}
launch {
- delay(1000L) // Assume, for this example, that BLE connection takes more than 1 second.
+ delay(1_000L) // Assume, for this example, that BLE connection takes more than 1 second.
// Cancels the `async` Coroutine and automatically closes the underlying `BluetoothGatt`.
deferred.cancel()
@@ -157,8 +155,8 @@ fun connect(context: Context, device: BluetoothDevice) {
```
Note that in the above example, if the BLE connection takes less than 1 second, then the
-**established** connection will **not** be cancelled (and `Gatt` will not be closed), and `result`
-will be `ConnectGattResult.Success`.
+**established** connection will **not** be cancelled and `result` will be
+`ConnectGattResult.Success`.
### `Gatt` Coroutine Scope
@@ -204,7 +202,7 @@ dependencies {
# License
```
-Copyright 2018 JUUL Labs
+Copyright 2020 JUUL Labs
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
diff --git a/build.gradle b/build.gradle
index c9f3565..f86f0e9 100644
--- a/build.gradle
+++ b/build.gradle
@@ -7,7 +7,7 @@ buildscript {
}
plugins {
- id 'org.jetbrains.kotlin.android' version '1.3.60' apply false
+ id 'org.jetbrains.kotlin.android' version '1.3.70' apply false
id 'org.jmailen.kotlinter' version '2.2.0' apply false
id 'com.android.library' version '3.6.2' apply false
id 'com.vanniktech.maven.publish' version '0.11.1' apply false
@@ -28,8 +28,7 @@ subprojects {
subprojects {
tasks.withType(Test) {
testLogging {
- // For more verbosity add: "standardOut", "standardError"
- events "passed", "skipped", "failed"
+ events "passed", "skipped", "failed", "standardOut", "standardError"
exceptionFormat "full"
showExceptions true
showStackTraces true
@@ -44,7 +43,6 @@ gradle.taskGraph.whenReady { taskGraph ->
taskGraph.getAllTasks().findAll { task ->
task.name.startsWith('installArchives') || task.name.startsWith('publishArchives')
}.forEach { task ->
- logger.error("VERSION_NAME='" + project.findProperty("VERSION_NAME") + "'")
task.doFirst {
if (!project.hasProperty("VERSION_NAME") || project.findProperty("VERSION_NAME").startsWith("unspecified")) {
logger.error("VERSION_NAME=" + project.findProperty("VERSION_NAME"))
diff --git a/codecov.yml b/codecov.yml
index f7ece7d..41f9bac 100644
--- a/codecov.yml
+++ b/codecov.yml
@@ -1,5 +1,4 @@
fixes:
- - "com/juul/able/experimental::"
- - "com/juul/able/experimental/processor::"
- - "com/juul/able/experimental/throwable::"
- - "com/juul/able/experimental/logger/timber::"
+ - "com/juul/able/logger/timber::"
+ - "com/juul/able/processor::"
+ - "com/juul/able/throwable::"
diff --git a/core/build.gradle b/core/build.gradle
index 00afa67..5639344 100644
--- a/core/build.gradle
+++ b/core/build.gradle
@@ -1,6 +1,7 @@
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
+ id 'org.jmailen.kotlinter'
id 'com.hiya.jacoco-android'
id 'com.vanniktech.maven.publish'
}
@@ -21,7 +22,7 @@ android {
dependencies {
api deps.kotlin.coroutines
- api deps.kotlin.junit
+ testImplementation deps.kotlin.junit
testImplementation deps.mockk
testImplementation deps.equalsverifier
}
diff --git a/core/src/main/AndroidManifest.xml b/core/src/main/AndroidManifest.xml
index 0c42424..a71d332 100644
--- a/core/src/main/AndroidManifest.xml
+++ b/core/src/main/AndroidManifest.xml
@@ -1,5 +1,5 @@
-
+
diff --git a/core/src/main/java/Able.kt b/core/src/main/java/Able.kt
index fdddfb4..544bf76 100644
--- a/core/src/main/java/Able.kt
+++ b/core/src/main/java/Able.kt
@@ -1,25 +1,14 @@
/*
- * Copyright 2018 JUUL Labs, Inc.
+ * Copyright 2020 JUUL Labs, Inc.
*/
-package com.juul.able.experimental
+package com.juul.able
-import android.util.Log
-
-interface Logger {
- fun isLoggable(priority: Int): Boolean
- fun log(priority: Int, throwable: Throwable? = null, message: String)
-}
+import com.juul.able.logger.AndroidLogger
object Able {
- const val VERBOSE = 2
- const val DEBUG = 3
- const val INFO = 4
- const val WARN = 5
- const val ERROR = 6
- const val ASSERT = 7
-
+ @Volatile
var logger: Logger = AndroidLogger()
inline fun assert(throwable: Throwable? = null, message: () -> String) {
@@ -48,18 +37,7 @@ object Able {
inline fun log(priority: Int, throwable: Throwable? = null, message: () -> String) {
if (logger.isLoggable(priority)) {
- logger.log(priority, throwable, message())
+ logger.log(priority, throwable, message.invoke())
}
}
}
-
-class AndroidLogger : Logger {
-
- private val tag = "Able"
-
- override fun isLoggable(priority: Int): Boolean = Log.isLoggable(tag, priority)
-
- override fun log(priority: Int, throwable: Throwable?, message: String) {
- Log.println(priority, tag, message)
- }
-}
diff --git a/core/src/main/java/ConnectionStateMonitor.kt b/core/src/main/java/ConnectionStateMonitor.kt
deleted file mode 100644
index 85adc32..0000000
--- a/core/src/main/java/ConnectionStateMonitor.kt
+++ /dev/null
@@ -1,67 +0,0 @@
-/*
- * Copyright 2018 JUUL Labs, Inc.
- */
-
-@file:Suppress("RedundantUnitReturnType")
-
-package com.juul.able.experimental
-
-import android.bluetooth.BluetoothGatt
-import android.bluetooth.BluetoothProfile
-import com.juul.able.experimental.messenger.OnConnectionStateChange
-import kotlinx.coroutines.channels.ReceiveChannel
-import kotlinx.coroutines.channels.consumeEach
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
-import java.io.Closeable
-
-class ConnectionStateMonitor(private val gatt: Gatt) : Closeable {
-
- private val connectionStateMutex = Mutex()
- private var connectionStateSubscription: ReceiveChannel? = null
-
- suspend fun suspendUntilConnectionState(state: GattState): Boolean {
- Able.debug { "Suspending until ${state.asGattStateString()}" }
-
- gatt.onConnectionStateChange.openSubscription().also { subscription ->
- if (state == BluetoothProfile.STATE_DISCONNECTED && subscription.isEmpty) {
- // When disconnecting, the channel may be empty if we've never connected before, so
- // we shouldn't wait.
- Able.info { "suspendUntilConnectionState → subscription.isEmpty, aborting" }
- subscription.cancel()
- return true
- } else {
- connectionStateMutex.withLock {
- // If another `suspendUntilConnectionState` is in progress then we abort it.
- connectionStateSubscription?.cancel()
- connectionStateSubscription = subscription
- }
-
- subscription.consumeEach { (status, newState) ->
- Able.verbose {
- val statusString = status.asGattConnectionStatusString()
- val stateString = newState.asGattStateString()
- "status = $statusString, newState = $stateString"
- }
-
- if (status != BluetoothGatt.GATT_SUCCESS) {
- Able.info { "Received ${status.asGattConnectionStatusString()}, giving up." }
- return false
- }
-
- if (state == newState) {
- Able.info { "Reached ${state.asGattStateString()}" }
- return true
- }
- }
- }
- }
-
- Able.debug { "suspendUntilConnectionState → Aborted." }
- return false
- }
-
- override fun close(): Unit {
- connectionStateSubscription?.cancel()
- }
-}
diff --git a/core/src/main/java/CoroutinesDevice.kt b/core/src/main/java/CoroutinesDevice.kt
deleted file mode 100644
index 718114d..0000000
--- a/core/src/main/java/CoroutinesDevice.kt
+++ /dev/null
@@ -1,90 +0,0 @@
-/*
- * Copyright 2018 JUUL Labs, Inc.
- */
-
-package com.juul.able.experimental
-
-import android.bluetooth.BluetoothDevice
-import android.bluetooth.BluetoothGatt
-import android.bluetooth.BluetoothGatt.STATE_CONNECTED
-import android.content.Context
-import android.os.RemoteException
-import com.juul.able.experimental.ConnectGattResult.Canceled
-import com.juul.able.experimental.ConnectGattResult.Failure
-import com.juul.able.experimental.ConnectGattResult.Success
-import com.juul.able.experimental.messenger.GattCallback
-import com.juul.able.experimental.messenger.GattCallbackConfig
-import com.juul.able.experimental.messenger.Messenger
-import kotlinx.coroutines.CancellationException
-
-class CoroutinesDevice(
- private val device: BluetoothDevice,
- private val callbackConfig: GattCallbackConfig = GattCallbackConfig()
-) : Device {
-
- /**
- * Requests that a connection to the [device] be established.
- *
- * A dedicated thread is spun up to handle interacting with the newly retrieved [BluetoothGatt]
- * and must be closed when the connection is no longer needed by invoking [Gatt.close].
- */
- private fun requestConnectGatt(context: Context, autoConnect: Boolean): CoroutinesGatt? {
- val callback = GattCallback(callbackConfig)
- val bluetoothGatt = device.connectGatt(context, autoConnect, callback) ?: return null
- val messenger = Messenger(bluetoothGatt, callback)
- return CoroutinesGatt(bluetoothGatt, messenger)
- }
-
- /**
- * Establishes a connection to the [BluetoothDevice], suspending until connection is successful
- * or error occurs.
- *
- * To cancel an in-flight connection attempt, the Coroutine from which this method was called
- * can be canceled:
- *
- * ```
- * fun connect(androidContext: Context, device: BluetoothDevice) {
- * connectJob = async {
- * device.connectGattOrNull(androidContext, autoConnect = false)
- * }
- * }
- *
- * fun cancelConnection() {
- * connectJob?.cancel() // cancels the above `connectGatt`
- * }
- * ```
- *
- * A dedicated thread is spun up to handle interacting with the underlying [BluetoothGatt], and
- * can be stopped by invoking [Gatt.close] on the returned [Gatt] object.
- *
- * If an error occurs during connection process, then [Gatt.close] is automatically called
- * (which stops the dedicated thread).
- */
- override suspend fun connectGatt(context: Context, autoConnect: Boolean): ConnectGattResult {
- val gatt = requestConnectGatt(context, autoConnect)
- ?: return Failure(
- RemoteException("`BluetoothDevice.connectGatt` returned `null`.")
- )
- val connectionStateMonitor = ConnectionStateMonitor(gatt)
-
- val didConnect = try {
- connectionStateMonitor.suspendUntilConnectionState(STATE_CONNECTED)
- } catch (e: CancellationException) {
- Able.info { "connectGatt() canceled." }
- gatt.close()
- return Canceled(e)
- } finally {
- connectionStateMonitor.close()
- }
-
- return if (didConnect) {
- Success(gatt)
- } else {
- Able.warn { "connectGatt() failed." }
- gatt.close()
- return Failure(
- IllegalStateException("Failed to connect to ${device.address}.")
- )
- }
- }
-}
diff --git a/core/src/main/java/CoroutinesGatt.kt b/core/src/main/java/CoroutinesGatt.kt
deleted file mode 100644
index 896a7e2..0000000
--- a/core/src/main/java/CoroutinesGatt.kt
+++ /dev/null
@@ -1,267 +0,0 @@
-/*
- * Copyright 2018 JUUL Labs, Inc.
- */
-
-@file:Suppress("RedundantUnitReturnType")
-
-package com.juul.able.experimental
-
-import android.bluetooth.BluetoothGatt
-import android.bluetooth.BluetoothGattCharacteristic
-import android.bluetooth.BluetoothGattDescriptor
-import android.bluetooth.BluetoothGattService
-import android.bluetooth.BluetoothProfile.STATE_CONNECTED
-import android.bluetooth.BluetoothProfile.STATE_DISCONNECTED
-import android.bluetooth.BluetoothProfile.STATE_DISCONNECTING
-import android.os.RemoteException
-import com.juul.able.experimental.messenger.Message.DiscoverServices
-import com.juul.able.experimental.messenger.Message.ReadCharacteristic
-import com.juul.able.experimental.messenger.Message.RequestMtu
-import com.juul.able.experimental.messenger.Message.WriteCharacteristic
-import com.juul.able.experimental.messenger.Message.WriteDescriptor
-import com.juul.able.experimental.messenger.Messenger
-import com.juul.able.experimental.messenger.OnCharacteristicChanged
-import com.juul.able.experimental.messenger.OnCharacteristicRead
-import com.juul.able.experimental.messenger.OnCharacteristicWrite
-import com.juul.able.experimental.messenger.OnConnectionStateChange
-import com.juul.able.experimental.messenger.OnDescriptorWrite
-import com.juul.able.experimental.messenger.OnMtuChanged
-import kotlinx.coroutines.CompletableDeferred
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.channels.BroadcastChannel
-import kotlinx.coroutines.channels.ClosedReceiveChannelException
-import kotlinx.coroutines.channels.ReceiveChannel
-import kotlinx.coroutines.channels.filter
-import kotlinx.coroutines.selects.select
-import java.util.UUID
-import kotlin.coroutines.CoroutineContext
-
-class GattClosed(message: String, cause: Throwable) : IllegalStateException(message, cause)
-class GattConnectionLost : Exception()
-
-class CoroutinesGatt(
- private val bluetoothGatt: BluetoothGatt,
- private val messenger: Messenger
-) : Gatt {
-
- private val job = Job()
- override val coroutineContext: CoroutineContext
- get() = job
-
- private val connectionStateMonitor by lazy { ConnectionStateMonitor(this) }
-
- override val onConnectionStateChange: BroadcastChannel
- get() = messenger.callback.onConnectionStateChange
-
- override val onCharacteristicChanged: BroadcastChannel
- get() = messenger.callback.onCharacteristicChanged
-
- override fun requestConnect(): Boolean = bluetoothGatt.connect()
- override fun requestDisconnect(): Unit = bluetoothGatt.disconnect()
-
- override suspend fun connect(): Boolean {
- return if (requestConnect()) {
- connectionStateMonitor.suspendUntilConnectionState(STATE_CONNECTED)
- } else {
- Able.error { "connect → BluetoothGatt.requestConnect() returned false " }
- false
- }
- }
-
- override suspend fun disconnect(): Unit {
- requestDisconnect()
- connectionStateMonitor.suspendUntilConnectionState(STATE_DISCONNECTED)
- }
-
- override fun close() {
- Able.verbose { "close → Begin" }
- job.cancel()
- connectionStateMonitor.close()
- messenger.close()
- bluetoothGatt.close()
- Able.verbose { "close → End" }
- }
-
- override val services: List get() = bluetoothGatt.services
- override fun getService(uuid: UUID): BluetoothGattService? = bluetoothGatt.getService(uuid)
-
- /**
- * @throws [RemoteException] if underlying [BluetoothGatt.discoverServices] returns `false`.
- * @throws [GattClosed] if [Gatt] is closed while method is executing.
- */
- override suspend fun discoverServices(): GattStatus {
- Able.debug { "discoverServices → send(DiscoverServices)" }
-
- val response = CompletableDeferred()
- messenger.send(DiscoverServices(response))
-
- val call = "BluetoothGatt.discoverServices()"
- Able.verbose { "discoverServices → Waiting for $call" }
- if (!response.await()) {
- throw RemoteException("$call returned false.")
- }
-
- Able.verbose { "discoverServices → Waiting for BluetoothGattCallback" }
- return try {
- messenger.callback.onServicesDiscovered.receiveRequiringConnection().also { status ->
- Able.info { "discoverServices, status=${status.asGattStatusString()}" }
- }
- } catch (e: ClosedReceiveChannelException) {
- throw GattClosed("Gatt closed during discoverServices", e)
- }
- }
-
- /**
- * @throws [RemoteException] if underlying [BluetoothGatt.readCharacteristic] returns `false`.
- * @throws [GattClosed] if [Gatt] is closed while method is executing.
- */
- override suspend fun readCharacteristic(
- characteristic: BluetoothGattCharacteristic
- ): OnCharacteristicRead {
- val uuid = characteristic.uuid
- Able.debug { "readCharacteristic → send(ReadCharacteristic[uuid=$uuid])" }
-
- val response = CompletableDeferred()
- messenger.send(ReadCharacteristic(characteristic, response))
-
- val call = "BluetoothGatt.readCharacteristic(BluetoothGattCharacteristic[uuid=$uuid])"
- Able.verbose { "readCharacteristic → Waiting for $call" }
- if (!response.await()) {
- throw RemoteException("Failed to read characteristic with UUID $uuid.")
- }
-
- Able.verbose { "readCharacteristic → Waiting for BluetoothGattCallback" }
- return try {
- messenger.callback.onCharacteristicRead.receiveRequiringConnection()
- .also { (_, value, status) ->
- Able.info {
- val bytesString = value.size.bytesString
- val statusString = status.asGattStatusString()
- "← readCharacteristic $uuid ($bytesString), status=$statusString"
- }
- }
- } catch (e: ClosedReceiveChannelException) {
- throw GattClosed("Gatt closed during readCharacteristic[uuid=$uuid]", e)
- }
- }
-
- /**
- * @param value applied to [characteristic] when characteristic is written.
- * @param writeType applied to [characteristic] when characteristic is written.
- * @throws [RemoteException] if underlying [BluetoothGatt.writeCharacteristic] returns `false`.
- * @throws [GattClosed] if [Gatt] is closed while method is executing.
- */
- override suspend fun writeCharacteristic(
- characteristic: BluetoothGattCharacteristic,
- value: ByteArray,
- writeType: WriteType
- ): OnCharacteristicWrite {
- val uuid = characteristic.uuid
- Able.debug { "writeCharacteristic → send(WriteCharacteristic[uuid=$uuid])" }
-
- val response = CompletableDeferred()
- messenger.send(WriteCharacteristic(characteristic, value, writeType, response))
-
- val call = "BluetoothGatt.writeCharacteristic(BluetoothGattCharacteristic[uuid=$uuid])"
- Able.verbose { "writeCharacteristic → Waiting for $call" }
- if (!response.await()) {
- throw RemoteException("$call returned false.")
- }
-
- Able.verbose { "writeCharacteristic → Waiting for BluetoothGattCallback" }
- return try {
- messenger.callback.onCharacteristicWrite.receiveRequiringConnection()
- .also { (_, status) ->
- Able.info {
- val bytesString = value.size.bytesString
- val typeString = writeType.asWriteTypeString()
- val statusString = status.asGattStatusString()
- "→ writeCharacteristic $uuid ($bytesString), type=$typeString, status=$statusString"
- }
- }
- } catch (e: ClosedReceiveChannelException) {
- throw GattClosed("Gatt closed during writeCharacteristic[uuid=$uuid]", e)
- }
- }
-
- /**
- * @param value applied to [descriptor] when descriptor is written.
- * @throws [RemoteException] if underlying [BluetoothGatt.writeDescriptor] returns `false`.
- * @throws [GattClosed] if [Gatt] is closed while method is executing.
- */
- override suspend fun writeDescriptor(
- descriptor: BluetoothGattDescriptor, value: ByteArray
- ): OnDescriptorWrite {
- val uuid = descriptor.uuid
- Able.debug { "writeDescriptor → send(WriteDescriptor[uuid=$uuid])" }
-
- val response = CompletableDeferred()
- messenger.send(WriteDescriptor(descriptor, value, response))
-
- val call = "BluetoothGatt.writeDescriptor(BluetoothGattDescriptor[uuid=$uuid])"
- Able.verbose { "writeDescriptor → Waiting for $call" }
- if (!response.await()) {
- throw RemoteException("$call returned false.")
- }
-
- Able.verbose { "writeDescriptor → Waiting for BluetoothGattCallback" }
- return try {
- messenger.callback.onDescriptorWrite.receiveRequiringConnection().also { (_, status) ->
- Able.info {
- val bytesString = value.size.bytesString
- val statusString = status.asGattStatusString()
- "→ writeDescriptor $uuid ($bytesString), status=$statusString"
- }
- }
- } catch (e: ClosedReceiveChannelException) {
- throw GattClosed("Gatt closed during writeDescriptor[uuid=$uuid]", e)
- }
- }
-
- /**
- * @throws [RemoteException] if underlying [BluetoothGatt.requestMtu] returns `false`.
- * @throws [GattClosed] if [Gatt] is closed while method is executing.
- */
- override suspend fun requestMtu(mtu: Int): OnMtuChanged {
- Able.debug { "requestMtu → send(RequestMtu[mtu=$mtu])" }
-
- val response = CompletableDeferred()
- messenger.send(RequestMtu(mtu, response))
-
- val call = "BluetoothGatt.requestMtu($mtu)"
- Able.verbose { "requestMtu → Waiting for $call" }
- if (!response.await()) {
- throw RemoteException("$call returned false.")
- }
-
- Able.verbose { "requestMtu → Waiting for BluetoothGattCallback" }
- return try {
- messenger.callback.onMtuChanged.receiveRequiringConnection().also { (mtu, status) ->
- Able.info { "requestMtu $mtu, status=${status.asGattStatusString()}" }
- }
- } catch (e: ClosedReceiveChannelException) {
- throw GattClosed("Gatt closed during requestMtu[mtu=$mtu]", e)
- }
- }
-
- override fun setCharacteristicNotification(
- characteristic: BluetoothGattCharacteristic,
- enable: Boolean
- ): Boolean {
- Able.info { "setCharacteristicNotification ${characteristic.uuid} enable=$enable" }
- return bluetoothGatt.setCharacteristicNotification(characteristic, enable)
- }
-
- private suspend fun ReceiveChannel.receiveRequiringConnection(): T = select {
- onReceive { it }
-
- onConnectionStateChange
- .openSubscription() // fixme: Find solution w/o subscription object creation cost.
- .filter { (_, newState) ->
- newState == STATE_DISCONNECTING || newState == STATE_DISCONNECTED
- }
- .onReceive { throw GattConnectionLost() }
- }
-}
-
-private val Int.bytesString get() = if (this == 1) "$this byte" else "$this bytes"
diff --git a/core/src/main/java/Device.kt b/core/src/main/java/Device.kt
deleted file mode 100644
index a90caaf..0000000
--- a/core/src/main/java/Device.kt
+++ /dev/null
@@ -1,36 +0,0 @@
-/*
- * Copyright 2018 JUUL Labs, Inc.
- */
-
-package com.juul.able.experimental
-
-import android.content.Context
-import com.juul.able.experimental.ConnectGattResult.Success
-import kotlinx.coroutines.CancellationException
-
-interface ConnectGattError {
- val cause: Throwable
-}
-
-sealed class ConnectGattResult {
- data class Success(val gatt: Gatt) : ConnectGattResult()
-
- data class Canceled(
- override val cause: CancellationException
- ) : ConnectGattResult(), ConnectGattError
-
- data class Failure(
- override val cause: Throwable
- ) : ConnectGattResult(), ConnectGattError
-}
-
-interface Device {
- suspend fun connectGatt(context: Context, autoConnect: Boolean): ConnectGattResult
-}
-
-suspend fun Device.connectGattOrNull(context: Context, autoConnect: Boolean): Gatt? {
- val result = connectGatt(context, autoConnect)
-
- @Suppress("IfThenToSafeAccess") // Easier to read as `if` statement.
- return if (result is Success) result.gatt else null
-}
diff --git a/core/src/main/java/Logger.kt b/core/src/main/java/Logger.kt
new file mode 100644
index 0000000..f2f02ed
--- /dev/null
+++ b/core/src/main/java/Logger.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2020 JUUL Labs, Inc.
+ */
+
+package com.juul.able
+
+/*
+ * Log values chosen to match Android's:
+ * https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/util/Log.java;l=71-99?q=Log.java&ss=android
+ */
+const val VERBOSE = 2
+const val DEBUG = 3
+const val INFO = 4
+const val WARN = 5
+const val ERROR = 6
+const val ASSERT = 7
+
+interface Logger {
+ fun isLoggable(priority: Int): Boolean
+ fun log(priority: Int, throwable: Throwable? = null, message: String)
+}
diff --git a/core/src/main/java/Uuid.kt b/core/src/main/java/Uuid.kt
deleted file mode 100644
index c2a7b65..0000000
--- a/core/src/main/java/Uuid.kt
+++ /dev/null
@@ -1,12 +0,0 @@
-/*
- * Copyright 2018 JUUL Labs, Inc.
- */
-
-package com.juul.able.experimental
-
-import java.util.UUID
-
-/**
- * @throw [IllegalArgumentException] if receiver does not conform to the string representation as described in [UUID.toString].
- */
-fun String.toUuid(): UUID = UUID.fromString(this)
diff --git a/core/src/main/java/android/BluetoothDevice.kt b/core/src/main/java/android/BluetoothDevice.kt
index 744d31e..d7af848 100644
--- a/core/src/main/java/android/BluetoothDevice.kt
+++ b/core/src/main/java/android/BluetoothDevice.kt
@@ -1,31 +1,16 @@
/*
- * Copyright 2018 JUUL Labs, Inc.
+ * Copyright 2020 JUUL Labs, Inc.
*/
-@file:JvmName("BluetoothDeviceCoreKt")
-
-package com.juul.able.experimental.android
+package com.juul.able.android
import android.bluetooth.BluetoothDevice
import android.content.Context
-import com.juul.able.experimental.ConnectGattResult
-import com.juul.able.experimental.CoroutinesDevice
-import com.juul.able.experimental.Gatt
-import com.juul.able.experimental.connectGattOrNull
-import com.juul.able.experimental.messenger.GattCallbackConfig
-
-fun BluetoothDevice.asCoroutinesDevice(
- callbackConfig: GattCallbackConfig = GattCallbackConfig()
-): CoroutinesDevice = CoroutinesDevice(this, callbackConfig)
+import com.juul.able.device.ConnectGattResult
+import com.juul.able.device.CoroutinesDevice
suspend fun BluetoothDevice.connectGatt(
- context: Context,
- autoConnect: Boolean,
- callbackConfig: GattCallbackConfig = GattCallbackConfig()
-): ConnectGattResult = asCoroutinesDevice(callbackConfig).connectGatt(context, autoConnect)
+ context: Context
+): ConnectGattResult = asCoroutinesDevice().connectGatt(context)
-suspend fun BluetoothDevice.connectGattOrNull(
- context: Context,
- autoConnect: Boolean,
- callbackConfig: GattCallbackConfig = GattCallbackConfig()
-): Gatt? = asCoroutinesDevice(callbackConfig).connectGattOrNull(context, autoConnect)
+private fun BluetoothDevice.asCoroutinesDevice() = CoroutinesDevice(this)
diff --git a/core/src/main/java/device/CoroutinesDevice.kt b/core/src/main/java/device/CoroutinesDevice.kt
new file mode 100644
index 0000000..824b332
--- /dev/null
+++ b/core/src/main/java/device/CoroutinesDevice.kt
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2020 JUUL Labs, Inc.
+ */
+
+package com.juul.able.device
+
+import android.bluetooth.BluetoothDevice
+import android.bluetooth.BluetoothGatt.STATE_CONNECTED
+import android.content.Context
+import android.os.RemoteException
+import com.juul.able.Able
+import com.juul.able.device.ConnectGattResult.Failure
+import com.juul.able.device.ConnectGattResult.Success
+import com.juul.able.gatt.CoroutinesGatt
+import com.juul.able.gatt.GattCallback
+import com.juul.able.gatt.suspendUntilConnectionState
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.newSingleThreadContext
+
+private const val DISPATCHER_NAME = "Gatt"
+
+internal class CoroutinesDevice(
+ private val device: BluetoothDevice
+) : Device {
+
+ /**
+ * Establishes a connection to the [BluetoothDevice], suspending until connection is successful
+ * or error occurs.
+ *
+ * To cancel an in-flight connection attempt, the Coroutine from which this method was called
+ * can be canceled:
+ *
+ * ```
+ * fun connect(context: Context, device: BluetoothDevice) {
+ * connectJob = async {
+ * device.connectGatt(context)
+ * }
+ * }
+ *
+ * fun cancelConnection() {
+ * connectJob?.cancel() // cancels the above `connectGatt`
+ * }
+ * ```
+ */
+ override suspend fun connectGatt(context: Context): ConnectGattResult {
+ val dispatcher = newSingleThreadContext("$DISPATCHER_NAME@$device")
+ val callback = GattCallback(dispatcher)
+ val bluetoothGatt = device.connectGatt(context, false, callback)
+ ?: return Failure(
+ RemoteException("`BluetoothDevice.connectGatt` returned `null` for device $device")
+ )
+
+ return try {
+ val gatt = CoroutinesGatt(bluetoothGatt, dispatcher, callback)
+ gatt.suspendUntilConnectionState(STATE_CONNECTED)
+ Success(gatt)
+ } catch (cancellation: CancellationException) {
+ Able.info { "connectGatt() canceled for $this" }
+ bluetoothGatt.close()
+ dispatcher.close()
+ throw cancellation
+ } catch (failure: Exception) {
+ Able.warn { "connectGatt() failed for $this" }
+ bluetoothGatt.close()
+ dispatcher.close()
+ Failure(ConnectionFailed("Failed to connect to device $device", failure))
+ }
+ }
+
+ override fun toString(): String = "CoroutinesDevice(device=$device)"
+}
diff --git a/core/src/main/java/device/Device.kt b/core/src/main/java/device/Device.kt
new file mode 100644
index 0000000..48196eb
--- /dev/null
+++ b/core/src/main/java/device/Device.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2020 JUUL Labs, Inc.
+ */
+
+package com.juul.able.device
+
+import android.content.Context
+import com.juul.able.gatt.Gatt
+
+class ConnectionFailed(message: String, cause: Throwable) : IllegalStateException(message, cause)
+
+sealed class ConnectGattResult {
+ data class Success(
+ val gatt: Gatt
+ ) : ConnectGattResult()
+
+ data class Failure(
+ val cause: Exception
+ ) : ConnectGattResult()
+}
+
+interface Device {
+ suspend fun connectGatt(context: Context): ConnectGattResult
+}
diff --git a/core/src/main/java/gatt/CoroutinesGatt.kt b/core/src/main/java/gatt/CoroutinesGatt.kt
new file mode 100644
index 0000000..33d8b80
--- /dev/null
+++ b/core/src/main/java/gatt/CoroutinesGatt.kt
@@ -0,0 +1,143 @@
+/*
+ * Copyright 2020 JUUL Labs, Inc.
+ */
+
+@file:Suppress("RedundantUnitReturnType")
+
+package com.juul.able.gatt
+
+import android.bluetooth.BluetoothGatt
+import android.bluetooth.BluetoothGattCharacteristic
+import android.bluetooth.BluetoothGattDescriptor
+import android.bluetooth.BluetoothGattService
+import android.bluetooth.BluetoothProfile.STATE_DISCONNECTED
+import android.os.RemoteException
+import com.juul.able.Able
+import java.util.UUID
+import kotlinx.coroutines.ExecutorCoroutineDispatcher
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.flow.asFlow
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+
+class OutOfOrderGattCallback(message: String) : IllegalStateException(message)
+
+class CoroutinesGatt internal constructor(
+ private val bluetoothGatt: BluetoothGatt,
+ private val dispatcher: ExecutorCoroutineDispatcher,
+ private val callback: GattCallback
+) : Gatt {
+
+ @FlowPreview
+ override val onConnectionStateChange = callback.onConnectionStateChange.asFlow()
+
+ @FlowPreview
+ override val onCharacteristicChanged = callback.onCharacteristicChanged.asFlow()
+
+ override val services: List get() = bluetoothGatt.services
+ override fun getService(uuid: UUID): BluetoothGattService? = bluetoothGatt.getService(uuid)
+
+ override suspend fun disconnect() {
+ try {
+ bluetoothGatt.disconnect()
+ suspendUntilConnectionState(STATE_DISCONNECTED)
+ } finally {
+ callback.close(bluetoothGatt)
+ }
+ }
+
+ /**
+ * @throws [RemoteException] if underlying [BluetoothGatt.discoverServices] returns `false`.
+ * @throws [ConnectionLost] if [Gatt] disconnects while method is executing.
+ */
+ override suspend fun discoverServices(): GattStatus =
+ performBluetoothAction("discoverServices") {
+ bluetoothGatt.discoverServices()
+ }
+
+ /**
+ * @throws [RemoteException] if underlying [BluetoothGatt.readCharacteristic] returns `false`.
+ * @throws [ConnectionLost] if [Gatt] disconnects while method is executing.
+ */
+ override suspend fun readCharacteristic(
+ characteristic: BluetoothGattCharacteristic
+ ): OnCharacteristicRead =
+ performBluetoothAction("readCharacteristic") {
+ bluetoothGatt.readCharacteristic(characteristic)
+ }
+
+ /**
+ * @param value applied to [characteristic] when characteristic is written.
+ * @param writeType applied to [characteristic] when characteristic is written.
+ * @throws [RemoteException] if underlying [BluetoothGatt.writeCharacteristic] returns `false`.
+ * @throws [ConnectionLost] if [Gatt] disconnects while method is executing.
+ */
+ override suspend fun writeCharacteristic(
+ characteristic: BluetoothGattCharacteristic,
+ value: ByteArray,
+ writeType: WriteType
+ ): OnCharacteristicWrite =
+ performBluetoothAction("writeCharacteristic") {
+ characteristic.value = value
+ characteristic.writeType = writeType
+ bluetoothGatt.writeCharacteristic(characteristic)
+ }
+
+ /**
+ * @param value applied to [descriptor] when descriptor is written.
+ * @throws [RemoteException] if underlying [BluetoothGatt.writeDescriptor] returns `false`.
+ * @throws [ConnectionLost] if [Gatt] disconnects while method is executing.
+ */
+ override suspend fun writeDescriptor(
+ descriptor: BluetoothGattDescriptor,
+ value: ByteArray
+ ): OnDescriptorWrite =
+ performBluetoothAction("writeDescriptor") {
+ descriptor.value = value
+ bluetoothGatt.writeDescriptor(descriptor)
+ }
+
+ /**
+ * @throws [RemoteException] if underlying [BluetoothGatt.requestMtu] returns `false`.
+ * @throws [ConnectionLost] if [Gatt] disconnects while method is executing.
+ */
+ override suspend fun requestMtu(mtu: Int): OnMtuChanged =
+ performBluetoothAction("requestMtu") {
+ bluetoothGatt.requestMtu(mtu)
+ }
+
+ override fun setCharacteristicNotification(
+ characteristic: BluetoothGattCharacteristic,
+ enable: Boolean
+ ): Boolean {
+ Able.info { "setCharacteristicNotification → uuid=${characteristic.uuid}, enable=$enable" }
+ return bluetoothGatt.setCharacteristicNotification(characteristic, enable)
+ }
+
+ private val lock = Mutex()
+
+ private suspend inline fun performBluetoothAction(
+ methodName: String,
+ crossinline action: () -> Boolean
+ ): T = lock.withLock {
+ Able.verbose { "$methodName → withContext $dispatcher" }
+ withContext(dispatcher) {
+ action.invoke() || throw RemoteException("BluetoothGatt.$methodName returned false")
+ }
+
+ Able.verbose { "$methodName ← Waiting for BluetoothGattCallback" }
+ val response = callback.onResponse.receive()
+ Able.info { "$methodName ← $response" }
+
+ // `lock` should always enforce a 1:1 matching of request to response, but if an Android
+ // `BluetoothGattCallback` method gets called out of order then we'll cast to the wrong
+ // response type.
+ response as? T
+ ?: throw OutOfOrderGattCallback(
+ "Unexpected response type ${response.javaClass.simpleName} received for $methodName"
+ )
+ }
+
+ override fun toString(): String = "CoroutinesGatt(device=${bluetoothGatt.device})"
+}
diff --git a/core/src/main/java/Debug.kt b/core/src/main/java/gatt/Debug.kt
similarity index 90%
rename from core/src/main/java/Debug.kt
rename to core/src/main/java/gatt/Debug.kt
index 05da19a..a29e78c 100644
--- a/core/src/main/java/Debug.kt
+++ b/core/src/main/java/gatt/Debug.kt
@@ -1,8 +1,8 @@
/*
- * Copyright 2018 JUUL Labs, Inc.
+ * Copyright 2020 JUUL Labs, Inc.
*/
-package com.juul.able.experimental
+package com.juul.able.gatt
import android.bluetooth.BluetoothGatt.GATT_CONNECTION_CONGESTED
import android.bluetooth.BluetoothGatt.GATT_FAILURE
@@ -39,13 +39,13 @@ private const val L2CAP_CONN_CANCEL = 256
/**
* https://android.googlesource.com/platform/external/bluetooth/bluedroid/+/lollipop-release/stack/include/gatt_api.h#106
*/
-private const val GATT_CONN_L2C_FAILURE = 1
-private const val GATT_CONN_TIMEOUT = HCI_ERR_CONNECTION_TOUT
-private const val GATT_CONN_TERMINATE_PEER_USER = HCI_ERR_PEER_USER
-private const val GATT_CONN_TERMINATE_LOCAL_HOST = HCI_ERR_CONN_CAUSE_LOCAL_HOST
-private const val GATT_CONN_FAIL_ESTABLISH = HCI_ERR_CONN_FAILED_ESTABLISHMENT
-private const val GATT_CONN_LMP_TIMEOUT = HCI_ERR_LMP_RESPONSE_TIMEOUT
-private const val GATT_CONN_CANCEL = L2CAP_CONN_CANCEL
+internal const val GATT_CONN_L2C_FAILURE = 1
+internal const val GATT_CONN_TIMEOUT = HCI_ERR_CONNECTION_TOUT
+internal const val GATT_CONN_TERMINATE_PEER_USER = HCI_ERR_PEER_USER
+internal const val GATT_CONN_TERMINATE_LOCAL_HOST = HCI_ERR_CONN_CAUSE_LOCAL_HOST
+internal const val GATT_CONN_FAIL_ESTABLISH = HCI_ERR_CONN_FAILED_ESTABLISHMENT
+internal const val GATT_CONN_LMP_TIMEOUT = HCI_ERR_LMP_RESPONSE_TIMEOUT
+internal const val GATT_CONN_CANCEL = L2CAP_CONN_CANCEL
/**
* 0xE0 ~ 0xFC reserved for future use
@@ -89,7 +89,7 @@ internal fun GattState.asGattStateString() = when (this) {
else -> "STATE_UNKNOWN"
}.let { name -> "$name($this)" }
-internal fun GattStatus.asGattConnectionStatusString() = when (this) {
+internal fun GattConnectionStatus.asGattConnectionStatusString() = when (this) {
GATT_SUCCESS -> "GATT_SUCCESS"
GATT_CONN_L2C_FAILURE -> "GATT_CONN_L2C_FAILURE"
GATT_CONN_TIMEOUT -> "GATT_CONN_TIMEOUT"
diff --git a/core/src/main/java/gatt/Events.kt b/core/src/main/java/gatt/Events.kt
new file mode 100644
index 0000000..a171e43
--- /dev/null
+++ b/core/src/main/java/gatt/Events.kt
@@ -0,0 +1,127 @@
+/*
+ * Copyright 2020 JUUL Labs, Inc.
+ */
+
+package com.juul.able.gatt
+
+import android.bluetooth.BluetoothGattCharacteristic
+import android.bluetooth.BluetoothGattDescriptor
+
+data class OnConnectionStateChange(
+ val status: GattStatus,
+ val newState: GattState
+) {
+ override fun toString(): String {
+ val connectionStatus = status.asGattConnectionStatusString()
+ val gattState = newState.asGattStateString()
+ return "OnConnectionStateChange(status=$connectionStatus, newState=$gattState)"
+ }
+}
+
+data class OnCharacteristicRead(
+ val characteristic: BluetoothGattCharacteristic,
+ val value: ByteArray,
+ val status: GattStatus
+) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+ other as OnCharacteristicRead
+
+ return status == other.status &&
+ characteristic.uuid == other.characteristic.uuid &&
+ characteristic.instanceId == other.characteristic.instanceId &&
+ value.contentEquals(other.value)
+ }
+
+ override fun hashCode(): Int {
+ var result = characteristic.uuid.hashCode()
+ result = 31 * result + characteristic.instanceId
+ result = 31 * result + value.contentHashCode()
+ result = 31 * result + status
+ return result
+ }
+
+ override fun toString(): String {
+ val uuid = characteristic.uuid
+ val instanceId = characteristic.instanceId
+ val size = value.size
+ val gattStatus = status.asGattStatusString()
+ return "OnCharacteristicRead(uuid=$uuid, instanceId=$instanceId, value=$value(size=$size), status=$gattStatus)"
+ }
+}
+
+data class OnCharacteristicWrite(
+ val characteristic: BluetoothGattCharacteristic,
+ val status: GattStatus
+) {
+ override fun toString(): String =
+ "OnCharacteristicWrite(uuid=${characteristic.uuid}, status=${status.asGattStatusString()})"
+}
+
+data class OnCharacteristicChanged(
+ val characteristic: BluetoothGattCharacteristic,
+ val value: ByteArray
+) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+ other as OnCharacteristicChanged
+
+ return characteristic.uuid == other.characteristic.uuid &&
+ characteristic.instanceId == other.characteristic.instanceId &&
+ value.contentEquals(other.value)
+ }
+
+ override fun hashCode(): Int {
+ var result = characteristic.uuid.hashCode()
+ result = 31 * result + characteristic.instanceId
+ result = 31 * result + value.contentHashCode()
+ return result
+ }
+
+ override fun toString(): String =
+ "OnCharacteristicChanged(uuid=${characteristic.uuid}, value=$value(size=${value.size}))"
+}
+
+data class OnDescriptorRead(
+ val descriptor: BluetoothGattDescriptor,
+ val value: ByteArray,
+ val status: GattStatus
+) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+ other as OnDescriptorRead
+
+ return status == other.status &&
+ descriptor.uuid == other.descriptor.uuid &&
+ value.contentEquals(other.value)
+ }
+
+ override fun hashCode(): Int {
+ var result = descriptor.uuid.hashCode()
+ result = 31 * result + value.contentHashCode()
+ result = 31 * result + status
+ return result
+ }
+
+ override fun toString(): String {
+ val uuid = descriptor.uuid
+ val gattStatus = status.asGattStatusString()
+ return "OnDescriptorRead(uuid=$uuid, value=$value(size=${value.size}), status=$gattStatus)"
+ }
+}
+
+data class OnDescriptorWrite(
+ val descriptor: BluetoothGattDescriptor,
+ val status: GattStatus
+) {
+ override fun toString(): String =
+ "OnDescriptorWrite(uuid=${descriptor.uuid}, status=${status.asGattStatusString()})"
+}
+
+data class OnMtuChanged(val mtu: Int, val status: GattStatus) {
+ override fun toString(): String =
+ "OnMtuChanged(mtu=$mtu, status=${status.asGattStatusString()})"
+}
diff --git a/core/src/main/java/Gatt.kt b/core/src/main/java/gatt/Gatt.kt
similarity index 54%
rename from core/src/main/java/Gatt.kt
rename to core/src/main/java/gatt/Gatt.kt
index 1714713..f49b540 100644
--- a/core/src/main/java/Gatt.kt
+++ b/core/src/main/java/gatt/Gatt.kt
@@ -1,27 +1,39 @@
/*
- * Copyright 2018 JUUL Labs, Inc.
+ * Copyright 2020 JUUL Labs, Inc.
*/
@file:Suppress("RedundantUnitReturnType")
-package com.juul.able.experimental
+package com.juul.able.gatt
import android.bluetooth.BluetoothGatt
+import android.bluetooth.BluetoothGatt.GATT_SUCCESS
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothGattService
import android.bluetooth.BluetoothProfile
-import com.juul.able.experimental.messenger.OnCharacteristicChanged
-import com.juul.able.experimental.messenger.OnCharacteristicRead
-import com.juul.able.experimental.messenger.OnCharacteristicWrite
-import com.juul.able.experimental.messenger.OnConnectionStateChange
-import com.juul.able.experimental.messenger.OnDescriptorWrite
-import com.juul.able.experimental.messenger.OnMtuChanged
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.channels.BroadcastChannel
-import java.io.Closeable
+import android.bluetooth.BluetoothProfile.STATE_DISCONNECTED
+import com.juul.able.Able
import java.util.UUID
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.flow.onEach
+
+/**
+ * Represents the possible GATT connection statuses as defined in the Android source code.
+ *
+ * - [GATT_SUCCESS]
+ * - [GATT_CONN_L2C_FAILURE]
+ * - [GATT_CONN_L2C_FAILURE]
+ * - [GATT_CONN_TIMEOUT]
+ * - [GATT_CONN_TERMINATE_PEER_USER]
+ * - [GATT_CONN_TERMINATE_LOCAL_HOST]
+ * - [GATT_CONN_FAIL_ESTABLISH]
+ * - [GATT_CONN_LMP_TIMEOUT]
+ * - [GATT_CONN_CANCEL]
+ */
+typealias GattConnectionStatus = Int
/**
* Represents the possible GATT statuses as defined in [BluetoothGatt]:
@@ -58,21 +70,26 @@ typealias GattState = Int
*/
typealias WriteType = Int
-interface Gatt : Closeable, CoroutineScope {
+class ConnectionLost : Exception()
- val onConnectionStateChange: BroadcastChannel
- val onCharacteristicChanged: BroadcastChannel
+class FailedToDeliverEvent(message: String) : IllegalStateException(message)
- val services: List
+class GattStatusFailure(
+ val event: OnConnectionStateChange
+) : IllegalStateException("Received $event")
- fun requestConnect(): Boolean
- fun requestDisconnect(): Unit
- fun getService(uuid: UUID): BluetoothGattService?
+interface Gatt {
+
+ val onConnectionStateChange: Flow
+ val onCharacteristicChanged: Flow
- suspend fun connect(): Boolean
- suspend fun disconnect(): Unit
suspend fun discoverServices(): GattStatus
+ val services: List
+ fun getService(uuid: UUID): BluetoothGattService?
+
+ suspend fun requestMtu(mtu: Int): OnMtuChanged
+
suspend fun readCharacteristic(
characteristic: BluetoothGattCharacteristic
): OnCharacteristicRead
@@ -88,14 +105,37 @@ interface Gatt : Closeable, CoroutineScope {
value: ByteArray
): OnDescriptorWrite
- suspend fun requestMtu(mtu: Int): OnMtuChanged
-
fun setCharacteristicNotification(
characteristic: BluetoothGattCharacteristic,
enable: Boolean
): Boolean
+
+ suspend fun disconnect(): Unit
}
suspend fun Gatt.writeCharacteristic(
- characteristic: BluetoothGattCharacteristic, value: ByteArray
+ characteristic: BluetoothGattCharacteristic,
+ value: ByteArray
): OnCharacteristicWrite = writeCharacteristic(characteristic, value, WRITE_TYPE_DEFAULT)
+
+internal suspend fun Gatt.suspendUntilConnectionState(state: GattState) {
+ Able.debug { "Suspending until ${state.asGattStateString()}" }
+ onConnectionStateChange
+ .onEach { event ->
+ Able.verbose { "← Received $event while waiting for ${state.asGattStateString()}" }
+ if (event.status != GATT_SUCCESS) throw GattStatusFailure(event)
+ }
+ .firstOrNull { (_, newState) -> newState == state }
+ .also {
+ if (it == null) { // Upstream Channel closed due to STATE_DISCONNECTED.
+ if (state == STATE_DISCONNECTED) {
+ Able.info { "Reached (implicit) STATE_DISCONNECTED" }
+ } else {
+ throw ConnectionLost()
+ }
+ }
+ }
+ ?.also { (_, newState) ->
+ Able.info { "Reached ${newState.asGattStateString()}" }
+ }
+}
diff --git a/core/src/main/java/gatt/GattCallback.kt b/core/src/main/java/gatt/GattCallback.kt
new file mode 100644
index 0000000..ce6b92a
--- /dev/null
+++ b/core/src/main/java/gatt/GattCallback.kt
@@ -0,0 +1,127 @@
+/*
+ * Copyright 2020 JUUL Labs, Inc.
+ */
+
+package com.juul.able.gatt
+
+import android.bluetooth.BluetoothGatt
+import android.bluetooth.BluetoothGattCallback
+import android.bluetooth.BluetoothGattCharacteristic
+import android.bluetooth.BluetoothGattDescriptor
+import android.bluetooth.BluetoothProfile.STATE_DISCONNECTED
+import android.bluetooth.BluetoothProfile.STATE_DISCONNECTING
+import com.juul.able.Able
+import kotlinx.coroutines.ExecutorCoroutineDispatcher
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.channels.BroadcastChannel
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.Channel.Factory.BUFFERED
+import kotlinx.coroutines.channels.Channel.Factory.CONFLATED
+import kotlinx.coroutines.channels.sendBlocking
+
+internal class GattCallback(
+ private val dispatcher: ExecutorCoroutineDispatcher
+) : BluetoothGattCallback() {
+
+ @ExperimentalCoroutinesApi
+ val onConnectionStateChange = BroadcastChannel(CONFLATED)
+
+ @ExperimentalCoroutinesApi
+ val onCharacteristicChanged = BroadcastChannel(BUFFERED)
+
+ val onResponse = Channel(CONFLATED)
+
+ private fun onDisconnecting() {
+ onCharacteristicChanged.close()
+ onResponse.close(ConnectionLost())
+ }
+
+ @ExperimentalCoroutinesApi
+ fun close(gatt: BluetoothGatt) {
+ Able.verbose { "Closing GattCallback belonging to device ${gatt.device}" }
+ onDisconnecting() // Duplicate call in case Android skips STATE_DISCONNECTING.
+ onConnectionStateChange.close()
+ gatt.close()
+
+ // todo: Remove once https://github.com/Kotlin/kotlinx.coroutines/issues/261 is fixed.
+ dispatcher.close()
+ }
+
+ override fun onConnectionStateChange(
+ gatt: BluetoothGatt,
+ status: GattConnectionStatus,
+ newState: GattState
+ ) {
+ val event = OnConnectionStateChange(status, newState)
+ Able.debug { "← $event" }
+ onConnectionStateChange.offer(event)
+
+ when (newState) {
+ STATE_DISCONNECTING -> onDisconnecting()
+ STATE_DISCONNECTED -> close(gatt)
+ }
+ }
+
+ override fun onCharacteristicChanged(
+ gatt: BluetoothGatt,
+ characteristic: BluetoothGattCharacteristic
+ ) {
+ val value = characteristic.value
+ val event = OnCharacteristicChanged(characteristic, value)
+ Able.verbose { "← $event" }
+ onCharacteristicChanged.sendBlocking(event)
+ }
+
+ override fun onServicesDiscovered(gatt: BluetoothGatt, status: GattStatus) {
+ Able.verbose { "← OnServicesDiscovered(status=${status.asGattStatusString()})" }
+ onResponse.offer(status) ||
+ throw FailedToDeliverEvent("OnServicesDiscovered(status=${status.asGattStatusString()})")
+ }
+
+ override fun onCharacteristicRead(
+ gatt: BluetoothGatt,
+ characteristic: BluetoothGattCharacteristic,
+ status: GattStatus
+ ) {
+ val value = characteristic.value
+ emitEvent(OnCharacteristicRead(characteristic, value, status))
+ }
+
+ override fun onCharacteristicWrite(
+ gatt: BluetoothGatt,
+ characteristic: BluetoothGattCharacteristic,
+ status: GattStatus
+ ) {
+ emitEvent(OnCharacteristicWrite(characteristic, status))
+ }
+
+ override fun onDescriptorRead(
+ gatt: BluetoothGatt,
+ descriptor: BluetoothGattDescriptor,
+ status: GattStatus
+ ) {
+ val value = descriptor.value
+ emitEvent(OnDescriptorRead(descriptor, value, status))
+ }
+
+ override fun onDescriptorWrite(
+ gatt: BluetoothGatt,
+ descriptor: BluetoothGattDescriptor,
+ status: GattStatus
+ ) {
+ emitEvent(OnDescriptorWrite(descriptor, status))
+ }
+
+ override fun onMtuChanged(
+ gatt: BluetoothGatt,
+ mtu: Int,
+ status: Int
+ ) {
+ emitEvent(OnMtuChanged(mtu, status))
+ }
+
+ private fun emitEvent(event: Any) {
+ Able.verbose { "← $event" }
+ onResponse.offer(event) || throw FailedToDeliverEvent(event.toString())
+ }
+}
diff --git a/core/src/main/java/logger/AndroidLogger.kt b/core/src/main/java/logger/AndroidLogger.kt
new file mode 100644
index 0000000..5df0428
--- /dev/null
+++ b/core/src/main/java/logger/AndroidLogger.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2020 JUUL Labs, Inc.
+ */
+
+package com.juul.able.logger
+
+import android.util.Log
+import com.juul.able.Logger
+
+private const val TAG = "Able"
+
+class AndroidLogger : Logger {
+
+ override fun isLoggable(
+ priority: Int
+ ): Boolean = Log.isLoggable(TAG, priority)
+
+ override fun log(
+ priority: Int,
+ throwable: Throwable?,
+ message: String
+ ) {
+ Log.println(priority, TAG, message)
+ }
+}
diff --git a/core/src/main/java/messenger/GattCallback.kt b/core/src/main/java/messenger/GattCallback.kt
deleted file mode 100644
index 68b0adc..0000000
--- a/core/src/main/java/messenger/GattCallback.kt
+++ /dev/null
@@ -1,188 +0,0 @@
-/*
- * Copyright 2018 JUUL Labs, Inc.
- */
-
-package com.juul.able.experimental.messenger
-
-import android.bluetooth.BluetoothGatt
-import android.bluetooth.BluetoothGattCallback
-import android.bluetooth.BluetoothGattCharacteristic
-import android.bluetooth.BluetoothGattDescriptor
-import com.juul.able.experimental.Able
-import com.juul.able.experimental.GattState
-import com.juul.able.experimental.GattStatus
-import com.juul.able.experimental.asGattConnectionStatusString
-import com.juul.able.experimental.asGattStateString
-import kotlinx.coroutines.channels.BroadcastChannel
-import kotlinx.coroutines.channels.Channel
-import kotlinx.coroutines.sync.Mutex
-
-data class GattCallbackConfig(
- val onCharacteristicChangedCapacity: Int = Channel.CONFLATED,
- val onServicesDiscoveredCapacity: Int = Channel.CONFLATED,
- val onCharacteristicReadCapacity: Int = Channel.CONFLATED,
- val onCharacteristicWriteCapacity: Int = Channel.CONFLATED,
- val onDescriptorReadCapacity: Int = Channel.CONFLATED,
- val onDescriptorWriteCapacity: Int = Channel.CONFLATED,
- val onReliableWriteCompletedCapacity: Int = Channel.CONFLATED,
- val onMtuChangedCapacity: Int = Channel.CONFLATED
-) {
- constructor(capacity: Int = Channel.CONFLATED) : this(
- onCharacteristicChangedCapacity = capacity,
- onServicesDiscoveredCapacity = capacity,
- onCharacteristicReadCapacity = capacity,
- onCharacteristicWriteCapacity = capacity,
- onDescriptorReadCapacity = capacity,
- onDescriptorWriteCapacity = capacity,
- onReliableWriteCompletedCapacity = capacity,
- onMtuChangedCapacity = capacity
- )
-}
-
-internal class GattCallback(config: GattCallbackConfig) : BluetoothGattCallback() {
-
- internal val onConnectionStateChange =
- BroadcastChannel(Channel.CONFLATED)
- internal val onCharacteristicChanged =
- BroadcastChannel(config.onCharacteristicChangedCapacity)
-
- internal val onServicesDiscovered =
- Channel(config.onServicesDiscoveredCapacity)
- internal val onCharacteristicRead =
- Channel(config.onCharacteristicReadCapacity)
- internal val onCharacteristicWrite =
- Channel(config.onCharacteristicWriteCapacity)
- internal val onDescriptorRead = Channel(config.onDescriptorReadCapacity)
- internal val onDescriptorWrite = Channel(config.onDescriptorWriteCapacity)
- internal val onReliableWriteCompleted =
- Channel(config.onReliableWriteCompletedCapacity)
- internal val onMtuChanged = Channel(config.onMtuChangedCapacity)
-
- private val gattLock = Mutex()
- internal suspend fun waitForGattReady() = gattLock.lock()
- internal fun notifyGattReady() {
- if (gattLock.isLocked) {
- gattLock.unlock()
- }
- }
-
- fun close() {
- Able.verbose { "close → Begin" }
-
- onConnectionStateChange.close()
- onCharacteristicChanged.close()
-
- onServicesDiscovered.close()
- onCharacteristicRead.close()
- onCharacteristicWrite.close()
- onDescriptorRead.close()
- onDescriptorWrite.close()
- onReliableWriteCompleted.close()
-
- Able.verbose { "close → End" }
- }
-
- override fun onConnectionStateChange(
- gatt: BluetoothGatt,
- status: GattStatus,
- newState: GattState
- ) {
- Able.debug {
- val statusString = status.asGattConnectionStatusString()
- val stateString = newState.asGattStateString()
- "onConnectionStateChange → status = $statusString, newState = $stateString"
- }
-
- if (!onConnectionStateChange.offer(OnConnectionStateChange(status, newState))) {
- Able.warn { "onConnectionStateChange → dropped" }
- }
- }
-
- override fun onServicesDiscovered(gatt: BluetoothGatt, status: GattStatus) {
- Able.verbose { "onServicesDiscovered → status = $status" }
- if (!onServicesDiscovered.offer(status)) {
- Able.warn { "onServicesDiscovered → dropped" }
- }
- notifyGattReady()
- }
-
- override fun onCharacteristicRead(
- gatt: BluetoothGatt,
- characteristic: BluetoothGattCharacteristic,
- status: GattStatus
- ) {
- Able.verbose { "onCharacteristicRead → uuid = ${characteristic.uuid}" }
- val event = OnCharacteristicRead(characteristic, characteristic.value, status)
- if (!onCharacteristicRead.offer(event)) {
- Able.warn { "onCharacteristicRead → dropped" }
- }
- notifyGattReady()
- }
-
- override fun onCharacteristicWrite(
- gatt: BluetoothGatt,
- characteristic: BluetoothGattCharacteristic,
- status: GattStatus
- ) {
- Able.verbose { "onCharacteristicWrite → uuid = ${characteristic.uuid}" }
- if (!onCharacteristicWrite.offer(OnCharacteristicWrite(characteristic, status))) {
- Able.warn { "onCharacteristicWrite → dropped" }
- }
- notifyGattReady()
- }
-
- override fun onCharacteristicChanged(
- gatt: BluetoothGatt,
- characteristic: BluetoothGattCharacteristic
- ) {
- Able.verbose { "onCharacteristicChanged → uuid = ${characteristic.uuid}" }
- val event = OnCharacteristicChanged(characteristic, characteristic.value)
- if (!onCharacteristicChanged.offer(event)) {
- Able.warn { "OnCharacteristicChanged → dropped" }
- }
-
- // We don't call `notifyGattReady` because `onCharacteristicChanged` is called whenever a
- // characteristic changes (after notification(s) have been enabled) so is not directly tied
- // to a specific call (or lock).
- }
-
- override fun onDescriptorRead(
- gatt: BluetoothGatt,
- descriptor: BluetoothGattDescriptor,
- status: GattStatus
- ) {
- Able.verbose { "onDescriptorRead → uuid = ${descriptor.uuid}" }
- if (!onDescriptorRead.offer(OnDescriptorRead(descriptor, descriptor.value, status))) {
- Able.warn { "onDescriptorRead → dropped" }
- }
- notifyGattReady()
- }
-
- override fun onDescriptorWrite(
- gatt: BluetoothGatt,
- descriptor: BluetoothGattDescriptor,
- status: GattStatus
- ) {
- Able.verbose { "onDescriptorWrite → uuid = ${descriptor.uuid}" }
- if (!onDescriptorWrite.offer(OnDescriptorWrite(descriptor, status))) {
- Able.warn { "onDescriptorWrite → dropped" }
- }
- notifyGattReady()
- }
-
- override fun onReliableWriteCompleted(gatt: BluetoothGatt, status: Int) {
- Able.verbose { "onReliableWriteCompleted → status = $status" }
- if (!onReliableWriteCompleted.offer(OnReliableWriteCompleted(status))) {
- Able.warn { "onReliableWriteCompleted → dropped" }
- }
- notifyGattReady()
- }
-
- override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
- Able.verbose { "onMtuChanged → status = $status" }
- if (!onMtuChanged.offer(OnMtuChanged(mtu, status))) {
- Able.warn { "onMtuChanged → dropped" }
- }
- notifyGattReady()
- }
-}
diff --git a/core/src/main/java/messenger/Messages.kt b/core/src/main/java/messenger/Messages.kt
deleted file mode 100644
index 894b02f..0000000
--- a/core/src/main/java/messenger/Messages.kt
+++ /dev/null
@@ -1,137 +0,0 @@
-/*
- * Copyright 2018 JUUL Labs, Inc.
- */
-
-package com.juul.able.experimental.messenger
-
-import android.bluetooth.BluetoothGattCharacteristic
-import android.bluetooth.BluetoothGattDescriptor
-import com.juul.able.experimental.GattState
-import com.juul.able.experimental.GattStatus
-import kotlinx.coroutines.CompletableDeferred
-import java.util.Arrays
-
-internal sealed class Message {
-
- abstract val response: CompletableDeferred
-
- internal data class DiscoverServices(
- override val response: CompletableDeferred
- ) : Message()
-
- internal data class ReadCharacteristic(
- val characteristic: BluetoothGattCharacteristic,
- override val response: CompletableDeferred
- ) : Message()
-
- internal data class WriteCharacteristic(
- val characteristic: BluetoothGattCharacteristic,
- val value: ByteArray,
- val writeType: Int,
- override val response: CompletableDeferred
- ) : Message()
-
- internal data class RequestMtu(
- val mtu: Int,
- override val response: CompletableDeferred
- ) : Message()
-
- internal data class WriteDescriptor(
- val descriptor: BluetoothGattDescriptor,
- val value: ByteArray,
- override val response: CompletableDeferred
- ) : Message()
-}
-
-data class OnConnectionStateChange(
- val status: GattStatus,
- val newState: GattState
-)
-
-data class OnCharacteristicRead(
- val characteristic: BluetoothGattCharacteristic,
- val value: ByteArray,
- val status: GattStatus
-) {
- override fun equals(other: Any?): Boolean {
- if (this === other) return true
- if (javaClass != other?.javaClass) return false
-
- @Suppress("UnsafeCast") // Safe per Java class comparison above.
- other as OnCharacteristicRead
-
- return status == other.status
- && characteristic.uuid == other.characteristic.uuid
- && Arrays.equals(value, other.value)
- }
-
- @Suppress("MagicNumber")
- override fun hashCode(): Int {
- var result = characteristic.uuid.hashCode()
- result = 31 * result + Arrays.hashCode(value)
- result = 31 * result + status
- return result
- }
-}
-
-data class OnCharacteristicWrite(
- val characteristic: BluetoothGattCharacteristic,
- val status: GattStatus
-)
-
-data class OnCharacteristicChanged(
- val characteristic: BluetoothGattCharacteristic,
- val value: ByteArray
-) {
- override fun equals(other: Any?): Boolean {
- if (this === other) return true
- if (javaClass != other?.javaClass) return false
-
- @Suppress("UnsafeCast") // Safe per Java class comparison above.
- other as OnCharacteristicChanged
-
- return characteristic.uuid == other.characteristic.uuid && Arrays.equals(value, other.value)
- }
-
- @Suppress("MagicNumber")
- override fun hashCode(): Int {
- var result = characteristic.uuid.hashCode()
- result = 31 * result + Arrays.hashCode(value)
- return result
- }
-}
-
-data class OnDescriptorRead(
- val descriptor: BluetoothGattDescriptor,
- val value: ByteArray,
- val status: GattStatus
-) {
- override fun equals(other: Any?): Boolean {
- if (this === other) return true
- if (javaClass != other?.javaClass) return false
-
- @Suppress("UnsafeCast") // Safe per Java class comparison above.
- other as OnDescriptorRead
-
- return status == other.status
- && descriptor.uuid == other.descriptor.uuid
- && Arrays.equals(value, other.value)
- }
-
- @Suppress("MagicNumber")
- override fun hashCode(): Int {
- var result = descriptor.uuid.hashCode()
- result = 31 * result + Arrays.hashCode(value)
- result = 31 * result + status
- return result
- }
-}
-
-data class OnDescriptorWrite(
- val descriptor: BluetoothGattDescriptor,
- val status: GattStatus
-)
-
-data class OnReliableWriteCompleted(val status: GattStatus)
-
-data class OnMtuChanged(val mtu: Int, val status: GattStatus)
diff --git a/core/src/main/java/messenger/Messenger.kt b/core/src/main/java/messenger/Messenger.kt
deleted file mode 100644
index b5b890d..0000000
--- a/core/src/main/java/messenger/Messenger.kt
+++ /dev/null
@@ -1,74 +0,0 @@
-/*
- * Copyright 2018 JUUL Labs, Inc.
- */
-
-package com.juul.able.experimental.messenger
-
-import android.bluetooth.BluetoothGatt
-import com.juul.able.experimental.Able
-import com.juul.able.experimental.messenger.Message.DiscoverServices
-import com.juul.able.experimental.messenger.Message.ReadCharacteristic
-import com.juul.able.experimental.messenger.Message.RequestMtu
-import com.juul.able.experimental.messenger.Message.WriteCharacteristic
-import com.juul.able.experimental.messenger.Message.WriteDescriptor
-import kotlinx.coroutines.GlobalScope
-import kotlinx.coroutines.channels.actor
-import kotlinx.coroutines.channels.consumeEach
-import kotlinx.coroutines.newSingleThreadContext
-
-class Messenger internal constructor(
- private val bluetoothGatt: BluetoothGatt,
- internal val callback: GattCallback
-) {
-
- internal suspend fun send(message: Message) = actor.send(message)
-
- private val context = newSingleThreadContext("Gatt")
- private val actor = GlobalScope.actor(context) {
- Able.verbose { "Begin" }
- consumeEach { message ->
- Able.debug { "Waiting for Gatt" }
- callback.waitForGattReady()
-
- Able.debug { "Processing ${message.javaClass.simpleName}" }
- val result: Boolean = when (message) {
- is DiscoverServices -> bluetoothGatt.discoverServices()
- is ReadCharacteristic -> bluetoothGatt.readCharacteristic(message.characteristic)
- is RequestMtu -> bluetoothGatt.requestMtu(message.mtu)
- is WriteCharacteristic -> {
- message.characteristic.value = message.value
- message.characteristic.writeType = message.writeType
- bluetoothGatt.writeCharacteristic(message.characteristic)
- }
- is WriteDescriptor -> {
- message.descriptor.value = message.value
- bluetoothGatt.writeDescriptor(message.descriptor)
- }
- }
-
- Able.debug { "Processed ${message.javaClass.simpleName}, result=$result" }
- message.response.complete(result)
-
- if (!result) {
- callback.notifyGattReady()
- }
- }
- Able.verbose { "End" }
- }
-
- fun close() {
- Able.verbose { "close → Begin" }
- callback.close()
- actor.close()
- closeContext()
- Able.verbose { "close → End" }
- }
-
- /**
- * Explicitly close context (this is only needed until #261 is fixed).
- *
- * [Kotlin Coroutines Issue #261](https://github.com/Kotlin/kotlinx.coroutines/issues/261)
- * [Coroutines actor test Gist](https://gist.github.com/twyatt/c51f81d763a6ee39657233fa725f5435)
- */
- private fun closeContext(): Unit = context.close()
-}
diff --git a/core/src/test/java/CoroutinesGattTest.kt b/core/src/test/java/CoroutinesGattTest.kt
deleted file mode 100644
index b444c89..0000000
--- a/core/src/test/java/CoroutinesGattTest.kt
+++ /dev/null
@@ -1,122 +0,0 @@
-/*
- * Copyright 2018 JUUL Labs, Inc.
- */
-
-package com.juul.able.experimental
-
-import android.bluetooth.BluetoothGatt
-import android.bluetooth.BluetoothGatt.GATT_SUCCESS
-import android.bluetooth.BluetoothGattCharacteristic
-import android.bluetooth.BluetoothProfile.STATE_CONNECTED
-import android.os.RemoteException
-import com.juul.able.experimental.messenger.GattCallback
-import com.juul.able.experimental.messenger.GattCallbackConfig
-import com.juul.able.experimental.messenger.Messenger
-import io.mockk.every
-import io.mockk.mockk
-import kotlinx.coroutines.channels.consumeEach
-import kotlinx.coroutines.runBlocking
-import kotlinx.coroutines.withTimeout
-import org.junit.BeforeClass
-import org.junit.Test
-import java.nio.ByteBuffer
-import java.util.UUID
-import kotlin.test.assertEquals
-import kotlin.test.assertFailsWith
-
-class CoroutinesGattTest {
-
- companion object {
- @BeforeClass
- @JvmStatic
- fun setUp() {
- Able.logger = NoOpLogger()
- }
- }
-
- /**
- * Verifies that [BluetoothGattCharacteristic]s that are received by the [GattCallback] remain
- * in-order when consumed from the [Gatt.onCharacteristicChanged] channel.
- */
- @Test
- fun correctOrderOfOnCharacteristicChanged() {
- val numberOfFakeCharacteristicNotifications = 10_000L
- val numberOfFakeBinderThreads = 10
- val onCharacteristicChangedCapacity = numberOfFakeCharacteristicNotifications.toInt()
-
- val bluetoothGatt = mockk()
- val callback = GattCallback(GattCallbackConfig(onCharacteristicChangedCapacity)).apply {
- onConnectionStateChange(bluetoothGatt, GATT_SUCCESS, STATE_CONNECTED)
- }
- val messenger = Messenger(bluetoothGatt, callback)
- val gatt = CoroutinesGatt(bluetoothGatt, messenger)
-
- val binderThreads = FakeBinderThreadHandler(numberOfFakeBinderThreads)
- for (i in 0..numberOfFakeCharacteristicNotifications) {
- binderThreads.enqueue {
- val characteristic = mockCharacteristic(data = i.asByteArray())
- callback.onCharacteristicChanged(bluetoothGatt, characteristic)
- }
- }
-
- runBlocking {
- var i = 0L
- gatt.onCharacteristicChanged.openSubscription().also { subscription ->
- binderThreads.start()
-
- subscription.consumeEach { (_, value) ->
- assertEquals(i++, value.longValue)
-
- if (i == numberOfFakeCharacteristicNotifications) {
- subscription.cancel()
- }
- }
- }
- assertEquals(numberOfFakeCharacteristicNotifications, i)
-
- binderThreads.stop()
- }
- }
-
- @Test
- fun readCharacteristic_bluetoothGattReturnsFalse_doesNotDeadlock() {
- val bluetoothGatt = mockk {
- every { readCharacteristic(any()) } returns false
- }
- val callback = GattCallback(GattCallbackConfig()).apply {
- onConnectionStateChange(bluetoothGatt, GATT_SUCCESS, STATE_CONNECTED)
- }
- val messenger = Messenger(bluetoothGatt, callback)
- val gatt = CoroutinesGatt(bluetoothGatt, messenger)
-
- assertFailsWith(RemoteException::class, "First invocation") {
- runBlocking {
- withTimeout(5_000L) {
- gatt.readCharacteristic(mockCharacteristic())
- }
- }
- }
-
- // Perform another read to verify that the previous failure did not deadlock `Messenger`.
- assertFailsWith(RemoteException::class, "Second invocation") {
- runBlocking {
- withTimeout(5_000L) {
- gatt.readCharacteristic(mockCharacteristic())
- }
- }
- }
- }
-}
-
-private fun mockCharacteristic(
- uuid: UUID = UUID.randomUUID(),
- data: ByteArray? = null
-): BluetoothGattCharacteristic = mockk {
- every { getUuid() } returns uuid
- every { value } returns data
-}
-
-private fun Long.asByteArray(): ByteArray = ByteBuffer.allocate(8).putLong(this).array()
-
-private val ByteArray.longValue: Long
- get() = ByteBuffer.wrap(this).long
diff --git a/core/src/test/java/EventsTest.kt b/core/src/test/java/EventsTest.kt
new file mode 100644
index 0000000..a196d8c
--- /dev/null
+++ b/core/src/test/java/EventsTest.kt
@@ -0,0 +1,139 @@
+/*
+ * Copyright 2020 JUUL Labs, Inc.
+ */
+
+package com.juul.able
+
+import android.bluetooth.BluetoothGatt.GATT_SUCCESS
+import android.bluetooth.BluetoothGattCharacteristic
+import android.bluetooth.BluetoothGattDescriptor
+import com.juul.able.gatt.FakeBluetoothGattCharacteristic as FakeCharacteristic
+import com.juul.able.gatt.FakeBluetoothGattDescriptor as FakeDescriptor
+import com.juul.able.gatt.OnCharacteristicChanged
+import com.juul.able.gatt.OnCharacteristicRead
+import com.juul.able.gatt.OnDescriptorRead
+import com.juul.able.gatt.OnMtuChanged
+import java.util.UUID
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import nl.jqno.equalsverifier.EqualsVerifier
+
+private val testUuid = "01234567-89ab-cdef-0123-456789abcdef".toUuid()
+
+class EventsTest {
+
+ @Test
+ fun `Verify equals of OnCharacteristicRead`() {
+ verifyEquals()
+ }
+
+ @Test
+ fun `Verify toString of OnCharacteristicRead`() {
+ val value = createByteArray(size = 256)
+ val event = OnCharacteristicRead(
+ characteristic = FakeCharacteristic(testUuid),
+ value = value,
+ status = GATT_SUCCESS
+ )
+
+ assertEquals(
+ expected = "OnCharacteristicRead(" +
+ "uuid=$testUuid, " +
+ "instanceId=0, " +
+ "value=[B@${value.hashCode().hex()}(size=256), " +
+ "status=GATT_SUCCESS(0)" +
+ ")",
+ actual = event.toString()
+ )
+ }
+
+ @Test
+ fun `Verify equals of OnCharacteristicChanged`() {
+ verifyEquals()
+ }
+
+ @Test
+ fun `Verify toString of OnCharacteristicChanged`() {
+ val value = createByteArray(size = 256)
+ val event = OnCharacteristicChanged(
+ characteristic = FakeCharacteristic(testUuid),
+ value = value
+ )
+
+ assertEquals(
+ expected = "OnCharacteristicChanged(" +
+ "uuid=$testUuid, " +
+ "value=[B@${value.hashCode().hex()}(size=256)" +
+ ")",
+ actual = event.toString()
+ )
+ }
+
+ @Test
+ fun `Verify equals of OnDescriptorRead`() {
+ verifyEquals()
+ }
+
+ @Test
+ fun `Verify toString of OnDescriptorRead`() {
+ val value = createByteArray(size = 256)
+ val event = OnDescriptorRead(
+ descriptor = FakeDescriptor(testUuid),
+ value = value,
+ status = GATT_SUCCESS
+ )
+
+ assertEquals(
+ expected = "OnDescriptorRead(" +
+ "uuid=$testUuid, " +
+ "value=[B@${value.hashCode().hex()}(size=256), " +
+ "status=GATT_SUCCESS(0)" +
+ ")",
+ actual = event.toString()
+ )
+ }
+
+ @Test
+ fun `Verify equals of OnMtuChanged`() {
+ verifyEquals()
+ }
+
+ @Test
+ fun `Verify toString of OnMtuChanged`() {
+ val event = OnMtuChanged(status = GATT_SUCCESS, mtu = 512)
+
+ assertEquals(
+ expected = "OnMtuChanged(mtu=512, status=GATT_SUCCESS(0))",
+ actual = event.toString()
+ )
+ }
+}
+
+private val redCharacteristic = FakeCharacteristic("63057836-0b22-4341-969a-8fee3a8be2b3".toUuid())
+private val blackCharacteristic = FakeCharacteristic("2a5346f9-1aec-4752-acec-5d269aa96e7d".toUuid())
+
+private val redDescriptor = FakeDescriptor("bce47f52-6c2a-43e9-a382-2c460fcc6f6c".toUuid())
+private val blackDescriptor = FakeDescriptor("de923c26-b18a-474a-84e0-7837300fc666".toUuid())
+
+/**
+ * Preconfigures [EqualsVerifier] for validating proper implementation of `data class`es in the
+ * `Messages.kt` file that have custom `equals` and `hashCode` implementations:
+ *
+ * > `EqualsVerifier` can be used in unit tests to verify whether the contract for the `equals` and
+ * > `hashCode` methods in a class is met.
+ */
+private inline fun verifyEquals() {
+ EqualsVerifier
+ .forClass(T::class.java)
+ .withPrefabValues(BluetoothGattDescriptor::class.java, redDescriptor, blackDescriptor)
+ .withPrefabValues(
+ BluetoothGattCharacteristic::class.java,
+ redCharacteristic,
+ blackCharacteristic
+ )
+ .verify()
+}
+
+private fun createByteArray(size: Int) = ByteArray(size) { it.toByte() }
+private fun Int.hex() = Integer.toHexString(this)
+private fun String.toUuid(): UUID = UUID.fromString(this)
diff --git a/core/src/test/java/FakeBinderThreadHandler.kt b/core/src/test/java/FakeBinderThreadHandler.kt
index a3c2ada..62dce9e 100644
--- a/core/src/test/java/FakeBinderThreadHandler.kt
+++ b/core/src/test/java/FakeBinderThreadHandler.kt
@@ -1,8 +1,8 @@
/*
- * Copyright 2018 JUUL Labs, Inc.
+ * Copyright 2020 JUUL Labs, Inc.
*/
-package com.juul.able.experimental
+package com.juul.able
import java.util.ArrayDeque
import kotlin.concurrent.thread
@@ -78,7 +78,7 @@ class FakeBinderThreadHandler(private val binderThreadCount: Int) {
operationQueue.poll()?.invoke()
}
}
- } catch (e: InterruptedException) {
+ } catch (exception: InterruptedException) {
// We've been asked to shutdown, no need to log exception.
}
}
diff --git a/core/src/test/java/MessagesTest.kt b/core/src/test/java/MessagesTest.kt
deleted file mode 100644
index c70e546..0000000
--- a/core/src/test/java/MessagesTest.kt
+++ /dev/null
@@ -1,72 +0,0 @@
-/*
- * Copyright 2018 JUUL Labs, Inc.
- */
-
-package com.juul.able.experimental
-
-import android.bluetooth.BluetoothGattCharacteristic
-import android.bluetooth.BluetoothGattDescriptor
-import com.juul.able.experimental.messenger.OnCharacteristicChanged
-import com.juul.able.experimental.messenger.OnCharacteristicRead
-import com.juul.able.experimental.messenger.OnDescriptorRead
-import io.mockk.every
-import io.mockk.mockk
-import nl.jqno.equalsverifier.EqualsVerifier
-import org.junit.Test
-import java.util.UUID
-
-private val redDescriptor = mockDescriptor("bce47f52-6c2a-43e9-a382-2c460fcc6f6c")
-private val blackDescriptor = mockDescriptor("de923c26-b18a-474a-84e0-7837300fc666")
-private val redCharacteristic = mockCharacteristic("63057836-0b22-4341-969a-8fee3a8be2b3")
-private val blackCharacteristic = mockCharacteristic("2a5346f9-1aec-4752-acec-5d269aa96e7d")
-
-class MessagesTest {
-
- @Test
- fun onCharacteristicReadEquals() {
- verifyEquals()
- }
-
- @Test
- fun onCharacteristicChangedEquals() {
- verifyEquals()
- }
-
- @Test
- fun onDescriptorReadEquals() {
- verifyEquals()
- }
-}
-
-private fun mockDescriptor(uuidString: String): BluetoothGattDescriptor {
- val uuid = UUID.fromString(uuidString)
- return mockk {
- every { getUuid() } returns uuid
- }
-}
-
-private fun mockCharacteristic(uuidString: String): BluetoothGattCharacteristic {
- val uuid = UUID.fromString(uuidString)
- return mockk {
- every { getUuid() } returns uuid
- }
-}
-
-/**
- * Preconfigures [EqualsVerifier] for validating proper implementation of `data class`es in the
- * `Messages.kt` file that have custom `equals` and `hashCode` implementations:
- *
- * > `EqualsVerifier` can be used in unit tests to verify whether the contract for the `equals` and
- * > `hashCode` methods in a class is met.
- */
-private inline fun verifyEquals() {
- EqualsVerifier
- .forClass(T::class.java)
- .withPrefabValues(BluetoothGattDescriptor::class.java, redDescriptor, blackDescriptor)
- .withPrefabValues(
- BluetoothGattCharacteristic::class.java,
- redCharacteristic,
- blackCharacteristic
- )
- .verify()
-}
diff --git a/core/src/test/java/device/CoroutinesDeviceTest.kt b/core/src/test/java/device/CoroutinesDeviceTest.kt
new file mode 100644
index 0000000..66cbf2c
--- /dev/null
+++ b/core/src/test/java/device/CoroutinesDeviceTest.kt
@@ -0,0 +1,160 @@
+/*
+ * Copyright 2020 JUUL Labs, Inc.
+ */
+
+package com.juul.able.device
+
+import android.bluetooth.BluetoothDevice
+import android.bluetooth.BluetoothGatt
+import android.bluetooth.BluetoothGatt.GATT_SUCCESS
+import android.bluetooth.BluetoothGattCallback
+import android.bluetooth.BluetoothProfile.STATE_CONNECTED
+import android.bluetooth.BluetoothProfile.STATE_CONNECTING
+import android.bluetooth.BluetoothProfile.STATE_DISCONNECTED
+import com.juul.able.device.ConnectGattResult.Failure
+import com.juul.able.device.ConnectGattResult.Success
+import com.juul.able.gatt.ConnectionLost
+import com.juul.able.gatt.GATT_CONN_CANCEL
+import com.juul.able.gatt.GattCallback
+import com.juul.able.gatt.GattStatusFailure
+import com.juul.able.gatt.OnConnectionStateChange
+import com.juul.able.logger.ConsoleLoggerTestRule
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.slot
+import io.mockk.verify
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.yield
+import org.junit.Rule
+
+class CoroutinesDeviceTest {
+
+ @get:Rule
+ val loggerRule = ConsoleLoggerTestRule()
+
+ @Test
+ fun `Non-success GATT status during connectGatt returns Failure`() = runBlocking {
+ val callbackSlot = slot()
+ lateinit var bluetoothGatt: BluetoothGatt
+ val bluetoothDevice = mockk {
+ bluetoothGatt = createBluetoothGatt(this@mockk)
+ every { connectGatt(any(), false, capture(callbackSlot)) } returns bluetoothGatt
+ every { this@mockk.toString() } returns "00:11:22:33:FF:EE"
+ }
+ val device = CoroutinesDevice(bluetoothDevice)
+
+ launch {
+ // Wait for `CoroutinesGatt` to spin up and provide us with the `GattCallback`.
+ while (!callbackSlot.isCaptured) yield()
+ val callback = callbackSlot.captured as GattCallback
+
+ callback.onConnectionStateChange(bluetoothGatt, GATT_SUCCESS, STATE_CONNECTING)
+ callback.onConnectionStateChange(bluetoothGatt, GATT_CONN_CANCEL, STATE_CONNECTED)
+ }
+
+ val failure = device.connectGatt(mockk()) as Failure
+
+ assertEquals>(
+ expected = ConnectionFailed::class.java,
+ actual = failure.cause.javaClass
+ )
+ assertEquals(
+ expected = OnConnectionStateChange(GATT_CONN_CANCEL, STATE_CONNECTED),
+ actual = (failure.cause.cause as GattStatusFailure).event
+ )
+ verify { bluetoothGatt.close() }
+ }
+
+ @Test
+ fun `Success GATT status during connectGatt returns Success`() = runBlocking {
+ val callbackSlot = slot()
+ lateinit var bluetoothGatt: BluetoothGatt
+ val bluetoothDevice = mockk {
+ bluetoothGatt = createBluetoothGatt(this@mockk)
+ every { connectGatt(any(), false, capture(callbackSlot)) } returns bluetoothGatt
+ every { this@mockk.toString() } returns "00:11:22:33:FF:EE"
+ }
+ val device = CoroutinesDevice(bluetoothDevice)
+
+ launch {
+ // Wait for `CoroutinesGatt` to spin up and provide us with the `GattCallback`.
+ while (!callbackSlot.isCaptured) yield()
+ val callback = callbackSlot.captured as GattCallback
+
+ callback.onConnectionStateChange(bluetoothGatt, GATT_SUCCESS, STATE_CONNECTING)
+ callback.onConnectionStateChange(bluetoothGatt, GATT_SUCCESS, STATE_CONNECTED)
+ }
+
+ val result = device.connectGatt(mockk())
+
+ assertEquals>(
+ expected = Success::class.java,
+ actual = result.javaClass
+ )
+ verify(exactly = 0) { bluetoothGatt.close() }
+ }
+
+ @Test
+ fun `Receive STATE_DISCONNECTED during connectGatt returns Failure`() = runBlocking {
+ val callbackSlot = slot()
+ lateinit var bluetoothGatt: BluetoothGatt
+ val bluetoothDevice = mockk {
+ bluetoothGatt = createBluetoothGatt(this@mockk)
+ every { connectGatt(any(), false, capture(callbackSlot)) } returns bluetoothGatt
+ every { this@mockk.toString() } returns "00:11:22:33:FF:EE"
+ }
+ val device = CoroutinesDevice(bluetoothDevice)
+
+ launch {
+ // Wait for `CoroutinesGatt` to spin up and provide us with the `GattCallback`.
+ while (!callbackSlot.isCaptured) yield()
+ val callback = callbackSlot.captured as GattCallback
+ callback.onConnectionStateChange(bluetoothGatt, GATT_SUCCESS, STATE_DISCONNECTED)
+ }
+
+ val failure = device.connectGatt(mockk()) as Failure
+
+ assertEquals>(
+ expected = ConnectionFailed::class.java,
+ actual = failure.cause.javaClass
+ )
+ assertEquals>(
+ expected = ConnectionLost::class.java,
+ actual = failure.cause.cause!!.javaClass
+ )
+ verify { bluetoothGatt.close() }
+ }
+
+ @Test
+ fun `Cancelling during connectGatt closes underlying BluetoothGatt`() = runBlocking {
+ val callbackSlot = slot()
+ lateinit var bluetoothGatt: BluetoothGatt
+ val bluetoothDevice = mockk {
+ bluetoothGatt = createBluetoothGatt(this@mockk)
+ every { connectGatt(any(), false, capture(callbackSlot)) } returns bluetoothGatt
+ every { this@mockk.toString() } returns "00:11:22:33:FF:EE"
+ }
+ val device = CoroutinesDevice(bluetoothDevice)
+
+ val job = launch {
+ device.connectGatt(mockk())
+ }
+
+ // Wait for `CoroutinesGatt` to spin up and provide us with the `GattCallback`.
+ while (!callbackSlot.isCaptured) yield()
+ job.cancelAndJoin()
+
+ verify { bluetoothGatt.close() }
+ }
+}
+
+private fun createBluetoothGatt(
+ bluetoothDevice: BluetoothDevice
+): BluetoothGatt = mockk {
+ every { device } returns bluetoothDevice
+ every { close() } returns Unit
+}
diff --git a/core/src/test/java/gatt/CoroutinesGattTest.kt b/core/src/test/java/gatt/CoroutinesGattTest.kt
new file mode 100644
index 0000000..851ac4d
--- /dev/null
+++ b/core/src/test/java/gatt/CoroutinesGattTest.kt
@@ -0,0 +1,451 @@
+/*
+ * Copyright 2020 JUUL Labs, Inc.
+ */
+
+package com.juul.able.gatt
+
+import android.bluetooth.BluetoothDevice
+import android.bluetooth.BluetoothGatt
+import android.bluetooth.BluetoothGatt.GATT_SUCCESS
+import android.bluetooth.BluetoothGattCharacteristic
+import android.bluetooth.BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
+import android.bluetooth.BluetoothGattDescriptor
+import android.bluetooth.BluetoothProfile.STATE_CONNECTED
+import android.bluetooth.BluetoothProfile.STATE_DISCONNECTED
+import android.bluetooth.BluetoothProfile.STATE_DISCONNECTING
+import android.os.RemoteException
+import com.juul.able.gatt.FakeBluetoothGattCharacteristic as FakeCharacteristic
+import com.juul.able.gatt.FakeBluetoothGattDescriptor as FakeDescriptor
+import com.juul.able.logger.ConsoleLoggerTestRule
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.slot
+import io.mockk.verify
+import java.util.UUID
+import java.util.concurrent.TimeUnit.SECONDS
+import java.util.concurrent.atomic.AtomicInteger
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlinx.coroutines.TimeoutCancellationException
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.toList
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.newSingleThreadContext
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeout
+import org.junit.Rule
+
+private val testUuid = UUID.fromString("01234567-89ab-cdef-0123-456789abcdef")
+
+class CoroutinesGattTest {
+
+ @get:Rule
+ val loggerRule = ConsoleLoggerTestRule()
+
+ @Test
+ fun `discoverServices propagates result`() {
+ createDispatcher().use { dispatcher ->
+ val callback = GattCallback(dispatcher)
+ val bluetoothGatt = mockk {
+ every { discoverServices() } answers {
+ callback.onServicesDiscovered(this@mockk, GATT_SUCCESS)
+ true
+ }
+ }
+
+ val gatt = CoroutinesGatt(bluetoothGatt, dispatcher, callback)
+ val response = runBlocking {
+ gatt.discoverServices()
+ }
+
+ assertEquals(
+ expected = GATT_SUCCESS,
+ actual = response
+ )
+ }
+ }
+
+ @Test
+ fun `discoverServices throws RemoteException when BluetoothGatt returns false`() {
+ createDispatcher().use { dispatcher ->
+ val callback = GattCallback(dispatcher)
+ val bluetoothGatt = mockk {
+ every { discoverServices() } returns false
+ }
+
+ val gatt = CoroutinesGatt(bluetoothGatt, dispatcher, callback)
+ assertFailsWith {
+ runBlocking {
+ gatt.discoverServices()
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `readCharacteristic propagates result`() {
+ val characteristic = FakeCharacteristic(testUuid)
+ createDispatcher().use { dispatcher ->
+ val callback = GattCallback(dispatcher)
+ val value = ByteArray(256) { it.toByte() }
+ val bluetoothGatt = mockk {
+ every { readCharacteristic(characteristic) } answers {
+ characteristic.value = value
+ callback.onCharacteristicRead(this@mockk, characteristic, GATT_SUCCESS)
+ true
+ }
+ }
+
+ val gatt = CoroutinesGatt(bluetoothGatt, dispatcher, callback)
+ val response = runBlocking {
+ gatt.readCharacteristic(characteristic)
+ }
+
+ assertEquals(
+ expected = OnCharacteristicRead(characteristic, value, GATT_SUCCESS),
+ actual = response
+ )
+ }
+ }
+
+ @Test
+ fun `readCharacteristic throws RemoteException when BluetoothGatt returns false`() {
+ createDispatcher().use { dispatcher ->
+ val callback = GattCallback(dispatcher)
+ val bluetoothGatt = mockk {
+ every { readCharacteristic(any()) } returns false
+ }
+
+ val gatt = CoroutinesGatt(bluetoothGatt, dispatcher, callback)
+
+ assertFailsWith {
+ runBlocking {
+ gatt.readCharacteristic(createCharacteristic())
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `writeCharacteristic propagates result`() {
+ val characteristic = FakeCharacteristic(testUuid)
+ val slot = slot()
+ createDispatcher().use { dispatcher ->
+ val callback = GattCallback(dispatcher)
+ val bluetoothGatt = mockk {
+ every { writeCharacteristic(capture(slot)) } answers {
+ callback.onCharacteristicWrite(this@mockk, characteristic, GATT_SUCCESS)
+ true
+ }
+ }
+
+ val gatt = CoroutinesGatt(bluetoothGatt, dispatcher, callback)
+ val response = runBlocking {
+ gatt.writeCharacteristic(characteristic, byteArrayOf(), WRITE_TYPE_DEFAULT)
+ }
+
+ assertEquals(
+ expected = OnCharacteristicWrite(characteristic, GATT_SUCCESS),
+ actual = response
+ )
+ }
+ }
+
+ @Test
+ fun `ByteArray passed as parameter of writeCharacteristic is applied to BluetoothGattCharacteristic`() {
+ val characteristic = FakeCharacteristic(testUuid)
+ val slot = slot()
+ createDispatcher().use { dispatcher ->
+ val callback = GattCallback(dispatcher)
+ val bluetoothGatt = mockk {
+ every { writeCharacteristic(capture(slot)) } answers {
+ callback.onCharacteristicWrite(this@mockk, characteristic, GATT_SUCCESS)
+ true
+ }
+ }
+
+ val gatt = CoroutinesGatt(bluetoothGatt, dispatcher, callback)
+ runBlocking {
+ gatt.writeCharacteristic(characteristic, byteArrayOf(0xF, 0x0, 0x0, 0xD))
+ }
+
+ // Convert to `List` so equality verification is done on the contents.
+ assertEquals(
+ expected = byteArrayOf(0xF, 0x0, 0x0, 0xD).toList(),
+ actual = slot.captured.value.toList()
+ )
+ }
+ }
+
+ @Test
+ fun `writeCharacteristic throws RemoteException when BluetoothGatt returns false`() {
+ createDispatcher().use { dispatcher ->
+ val callback = GattCallback(dispatcher)
+ val bluetoothGatt = mockk {
+ every { writeCharacteristic(any()) } returns false
+ }
+
+ val gatt = CoroutinesGatt(bluetoothGatt, dispatcher, callback)
+
+ assertFailsWith {
+ runBlocking {
+ gatt.writeCharacteristic(createCharacteristic(), byteArrayOf())
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `writeDescriptor propagates result`() {
+ val descriptor = FakeDescriptor(testUuid)
+ val slot = slot()
+ createDispatcher().use { dispatcher ->
+ val callback = GattCallback(dispatcher)
+ val bluetoothGatt = mockk {
+ every { writeDescriptor(capture(slot)) } answers {
+ callback.onDescriptorWrite(this@mockk, descriptor, GATT_SUCCESS)
+ true
+ }
+ }
+
+ val gatt = CoroutinesGatt(bluetoothGatt, dispatcher, callback)
+ val response = runBlocking {
+ gatt.writeDescriptor(descriptor, byteArrayOf())
+ }
+
+ assertEquals(
+ expected = OnDescriptorWrite(descriptor, GATT_SUCCESS),
+ actual = response
+ )
+ }
+ }
+
+ @Test
+ fun `ByteArray passed as parameter of writeDescriptor is applied to BluetoothGattDescriptor`() {
+ val descriptor = FakeDescriptor(testUuid)
+ val slot = slot()
+ createDispatcher().use { dispatcher ->
+ val callback = GattCallback(dispatcher)
+ val bluetoothGatt = mockk {
+ every { writeDescriptor(capture(slot)) } answers {
+ callback.onDescriptorWrite(this@mockk, descriptor, GATT_SUCCESS)
+ true
+ }
+ }
+
+ val gatt = CoroutinesGatt(bluetoothGatt, dispatcher, callback)
+ runBlocking {
+ gatt.writeDescriptor(descriptor, byteArrayOf(0xF, 0x0, 0x0, 0xD))
+ }
+
+ // Convert to `List` so equality verification is done on the contents.
+ assertEquals(
+ expected = byteArrayOf(0xF, 0x0, 0x0, 0xD).toList(),
+ actual = slot.captured.value.toList()
+ )
+ }
+ }
+
+ @Test
+ fun `writeDescriptor throws RemoteException when BluetoothGatt returns false`() {
+ createDispatcher().use { dispatcher ->
+ val callback = GattCallback(dispatcher)
+ val bluetoothGatt = mockk {
+ every { writeDescriptor(any()) } returns false
+ }
+
+ val gatt = CoroutinesGatt(bluetoothGatt, dispatcher, callback)
+
+ assertFailsWith {
+ runBlocking {
+ gatt.writeDescriptor(createDescriptor(), byteArrayOf())
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `readCharacteristic returning false does not cause a deadlock`() = runBlocking {
+ createDispatcher().use { dispatcher ->
+ val bluetoothGatt = mockk {
+ every { readCharacteristic(any()) } returns false
+ every { device } returns mockk {
+ every { this@mockk.toString() } returns "00:11:22:33:FF:EE"
+ }
+ }
+ val callback = GattCallback(dispatcher)
+ val gatt = CoroutinesGatt(bluetoothGatt, dispatcher, callback)
+
+ callback.onConnectionStateChange(bluetoothGatt, GATT_SUCCESS, STATE_CONNECTED)
+
+ withTimeout(SECONDS.toMillis(10L)) {
+ assertFailsWith("First invocation") {
+ gatt.readCharacteristic(createCharacteristic())
+ }
+
+ // Perform another read to verify that the previous failure didn't deadlock.
+ assertFailsWith("Second invocation") {
+ gatt.readCharacteristic(createCharacteristic())
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `Out-of-order BluetoothGattCallback call throws OutOfOrderGattCallback`() {
+ createDispatcher().use { dispatcher ->
+ val callback = GattCallback(dispatcher)
+ val bluetoothGatt = mockk {
+ every { readCharacteristic(any()) } answers {
+ callback.onDescriptorWrite(this@mockk, createDescriptor(), GATT_SUCCESS)
+ true
+ }
+ }
+
+ val gatt = CoroutinesGatt(bluetoothGatt, dispatcher, callback)
+ runBlocking {
+ assertFailsWith {
+ gatt.readCharacteristic(createCharacteristic())
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `BluetoothGatt is closed on disconnect`() {
+ createDispatcher().use { dispatcher ->
+ val callback = GattCallback(dispatcher)
+ val bluetoothGatt = mockk {
+ every { device } returns mockk {
+ every { close() } returns Unit
+ every { this@mockk.toString() } returns "00:11:22:33:FF:EE"
+ }
+ every { disconnect() } answers {
+ callback.onConnectionStateChange(this@mockk, GATT_SUCCESS, STATE_DISCONNECTING)
+ callback.onConnectionStateChange(this@mockk, GATT_SUCCESS, STATE_DISCONNECTED)
+ }
+ }
+
+ val gatt = CoroutinesGatt(bluetoothGatt, dispatcher, callback)
+ runBlocking {
+ gatt.disconnect()
+ }
+
+ verify { bluetoothGatt.close() }
+ }
+ }
+
+ @Test
+ fun `BluetoothGatt is closed on timeout during disconnect`() {
+ createDispatcher().use { dispatcher ->
+ val callback = GattCallback(dispatcher)
+ val bluetoothGatt = mockk {
+ every { device } returns mockk {
+ every { close() } returns Unit
+ every { this@mockk.toString() } returns "00:11:22:33:FF:EE"
+ }
+ every { disconnect() } answers {
+ callback.onConnectionStateChange(this@mockk, GATT_SUCCESS, STATE_DISCONNECTING)
+ }
+ }
+
+ val gatt = CoroutinesGatt(bluetoothGatt, dispatcher, callback)
+ runBlocking {
+ assertFailsWith {
+ withTimeout(200L) {
+ gatt.disconnect()
+ }
+ }
+ }
+
+ verify { bluetoothGatt.close() }
+ }
+ }
+
+ @Test
+ fun `On disconnect, onCharacteristicChanged subscriptions complete normally`() {
+ createDispatcher().use { dispatcher ->
+ val callback = GattCallback(dispatcher)
+ val bluetoothGatt = mockk {
+ every { device } returns mockk {
+ every { close() } returns Unit
+ every { this@mockk.toString() } returns "00:11:22:33:FF:EE"
+ }
+ }
+ val characteristic = FakeCharacteristic(testUuid, value = byteArrayOf(0xF, 0x0, 0x0, 0xD))
+ val gatt = CoroutinesGatt(bluetoothGatt, dispatcher, callback)
+
+ val events = runBlocking {
+ launch {
+ callback.onCharacteristicChanged(bluetoothGatt, characteristic)
+ delay(500L)
+ callback.onConnectionStateChange(
+ bluetoothGatt,
+ GATT_SUCCESS,
+ STATE_DISCONNECTED
+ )
+ }
+
+ gatt.onCharacteristicChanged.toList()
+ }
+
+ assertEquals(
+ expected = listOf(
+ OnCharacteristicChanged(characteristic, byteArrayOf(0xF, 0x0, 0x0, 0xD))
+ ),
+ actual = events
+ )
+
+ verify { bluetoothGatt.close() }
+ }
+ }
+
+ @Test
+ fun `When already disconnected, onCharacteristicChanged subscription completes normally`() {
+ createDispatcher().use { dispatcher ->
+ val callback = GattCallback(dispatcher)
+ val bluetoothGatt = mockk {
+ every { device } returns mockk {
+ every { close() } returns Unit
+ every { this@mockk.toString() } returns "00:11:22:33:FF:EE"
+ }
+ }
+ val gatt = CoroutinesGatt(bluetoothGatt, dispatcher, callback)
+
+ val events = runBlocking {
+ callback.onConnectionStateChange(bluetoothGatt, GATT_SUCCESS, STATE_DISCONNECTED)
+ verify { bluetoothGatt.close() }
+ gatt.onCharacteristicChanged.toList()
+ }
+
+ assertEquals(
+ expected = emptyList(),
+ actual = events
+ )
+ }
+ }
+}
+
+private val dispatcherNumber = AtomicInteger()
+private fun createDispatcher() =
+ newSingleThreadContext("MockGatt${dispatcherNumber.incrementAndGet()}")
+
+private fun createCharacteristic(
+ uuid: UUID = testUuid,
+ data: ByteArray = byteArrayOf()
+): BluetoothGattCharacteristic = mockk {
+ every { getUuid() } returns uuid
+ every { setValue(data) } returns true
+ every { writeType = any() } returns Unit
+ every { value } returns data
+}
+
+private fun createDescriptor(
+ uuid: UUID = testUuid,
+ data: ByteArray = byteArrayOf()
+): BluetoothGattDescriptor = mockk {
+ every { getUuid() } returns uuid
+ every { setValue(data) } returns true
+ every { value } returns data
+}
diff --git a/core/src/test/java/gatt/FakeBluetoothGattCharacteristic.kt b/core/src/test/java/gatt/FakeBluetoothGattCharacteristic.kt
new file mode 100644
index 0000000..3d04970
--- /dev/null
+++ b/core/src/test/java/gatt/FakeBluetoothGattCharacteristic.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2020 JUUL Labs, Inc.
+ */
+
+package com.juul.able.gatt
+
+import android.bluetooth.BluetoothGattCharacteristic
+import java.util.UUID
+
+class FakeBluetoothGattCharacteristic(
+ uuid: UUID,
+ instanceId: Int = 0,
+ value: ByteArray = byteArrayOf()
+) : BluetoothGattCharacteristic(uuid, 0, 0) {
+
+ private val fakeUuid: UUID = uuid
+ private val fakeInstanceId: Int = instanceId
+ private var fakeValue: ByteArray = value
+ private var fakeWriteType: WriteType = WRITE_TYPE_DEFAULT
+
+ override fun getUuid(): UUID = fakeUuid
+
+ override fun getInstanceId(): Int = fakeInstanceId
+
+ override fun setValue(value: ByteArray): Boolean {
+ fakeValue = value
+ return true
+ }
+ override fun getValue(): ByteArray = fakeValue
+
+ override fun setWriteType(writeType: WriteType) {
+ fakeWriteType = writeType
+ }
+ override fun getWriteType(): WriteType = fakeWriteType
+}
diff --git a/core/src/test/java/gatt/FakeBluetoothGattDescriptor.kt b/core/src/test/java/gatt/FakeBluetoothGattDescriptor.kt
new file mode 100644
index 0000000..501e4e7
--- /dev/null
+++ b/core/src/test/java/gatt/FakeBluetoothGattDescriptor.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2020 JUUL Labs, Inc.
+ */
+
+package com.juul.able.gatt
+
+import android.bluetooth.BluetoothGattDescriptor
+import java.util.UUID
+
+class FakeBluetoothGattDescriptor(
+ uuid: UUID,
+ value: ByteArray = byteArrayOf()
+) : BluetoothGattDescriptor(uuid, 0) {
+
+ private val fakeUuid: UUID = uuid
+ private var fakeValue: ByteArray = value
+
+ override fun getUuid(): UUID = fakeUuid
+ override fun setValue(value: ByteArray): Boolean {
+ fakeValue = value
+ return true
+ }
+ override fun getValue(): ByteArray = fakeValue
+}
diff --git a/core/src/test/java/logger/ConsoleLogger.kt b/core/src/test/java/logger/ConsoleLogger.kt
new file mode 100644
index 0000000..8860b52
--- /dev/null
+++ b/core/src/test/java/logger/ConsoleLogger.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2020 JUUL Labs, Inc.
+ */
+
+package com.juul.able.logger
+
+import com.juul.able.ASSERT
+import com.juul.able.ERROR
+import com.juul.able.INFO
+import com.juul.able.Logger
+import com.juul.able.VERBOSE
+import com.juul.able.WARN
+
+private const val TAG = "Able"
+
+class ConsoleLogger(
+ private val logLevel: Int = VERBOSE,
+ private inline val logHandler: (String) -> Unit = { println(it) }
+) : Logger {
+
+ private val labels = charArrayOf('V', 'D', 'I', 'W', 'E', 'A')
+
+ /**
+ * Check if the [priority] of the message to be logged is at least the [logLevel] of this
+ * [Logger]. In other words, all logs of lower [priority] than this [Logger]'s [logLevel] will
+ * not be logged.
+ *
+ * For example, if the [ConsoleLogger]'s [logLevel] is set to [INFO] (`4`), only logs of
+ * [priority] [INFO] (or higher: [WARN], [ERROR], [ASSERT]) will be logged.
+ */
+ override fun isLoggable(priority: Int) = priority >= logLevel
+
+ override fun log(priority: Int, throwable: Throwable?, message: String) {
+ // labels[0] = 'V', labels[1] = 'D', labels[2] = 'I', ...
+ // VERBOSE = 2 , DEBUG = 3 , INFO = 4 , ...
+ //
+ // We coerce requested priority within `priority` range (VERBOSE to ASSERT) then offset down
+ // to `labels` who's index starts at `0`.
+ val index = priority.coerceIn(VERBOSE, ASSERT) - VERBOSE
+
+ val label = labels[index]
+ val error = if (throwable != null) "${throwable.message}" else ""
+ logHandler.invoke("$label/$TAG: $error$message")
+ }
+}
diff --git a/core/src/test/java/logger/ConsoleLoggerTestRule.kt b/core/src/test/java/logger/ConsoleLoggerTestRule.kt
new file mode 100644
index 0000000..ddd0d8d
--- /dev/null
+++ b/core/src/test/java/logger/ConsoleLoggerTestRule.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2020 JUUL Labs, Inc.
+ */
+
+package com.juul.able.logger
+
+import com.juul.able.Able
+import com.juul.able.Logger
+import java.util.Collections
+import org.junit.rules.TestRule
+import org.junit.runner.Description
+import org.junit.runners.model.Statement
+
+/** JUnit test rule that only prints logs on test failure. */
+class ConsoleLoggerTestRule : TestRule {
+
+ override fun apply(
+ base: Statement,
+ description: Description
+ ): Statement = ConsoleLoggerStatement(base)
+}
+
+private class ConsoleLoggerStatement(
+ private val base: Statement
+) : Statement() {
+
+ private val bufferedLogger = BufferedConsoleLogger()
+
+ override fun evaluate() {
+ val previousLogger = Able.logger
+ Able.logger = bufferedLogger
+
+ try {
+ base.evaluate()
+ } catch (throwable: Throwable) {
+ bufferedLogger.flush()
+ throw throwable
+ } finally {
+ Able.logger = previousLogger
+ }
+ }
+}
+
+private class BufferedConsoleLogger private constructor(
+ private val logs: MutableList,
+ private val logger: Logger = ConsoleLogger { logs += it }
+) : Logger by logger {
+
+ constructor() : this(Collections.synchronizedList(mutableListOf()))
+
+ fun flush() {
+ logs.forEach(::println)
+ }
+}
diff --git a/core/src/test/java/NoOpLogger.kt b/core/src/test/java/logger/NoOpLogger.kt
similarity index 71%
rename from core/src/test/java/NoOpLogger.kt
rename to core/src/test/java/logger/NoOpLogger.kt
index bf9f1bc..c6a63ca 100644
--- a/core/src/test/java/NoOpLogger.kt
+++ b/core/src/test/java/logger/NoOpLogger.kt
@@ -1,8 +1,10 @@
/*
- * Copyright 2018 JUUL Labs, Inc.
+ * Copyright 2020 JUUL Labs, Inc.
*/
-package com.juul.able.experimental
+package com.juul.able.logger
+
+import com.juul.able.Logger
class NoOpLogger : Logger {
override fun isLoggable(priority: Int): Boolean = false
diff --git a/documentation/RECIPES.md b/documentation/RECIPES.md
index 402d7f5..b806d8a 100644
--- a/documentation/RECIPES.md
+++ b/documentation/RECIPES.md
@@ -10,7 +10,7 @@ val serviceUuid = "F000AA80-0451-4000-B000-000000000000".toUuid()
val characteristicUuid = "F000AA83-0451-4000-B000-000000000000".toUuid()
fun connect(context: Context, device: BluetoothDevice) = launch {
- val gatt = device.connectGatt(context, autoConnect = false).let { result ->
+ val gatt = device.connectGatt(context).let { result ->
when (result) {
is Success -> result.gatt
is Canceled -> throw IllegalStateException("Connection canceled.", result.cause)
@@ -18,21 +18,18 @@ fun connect(context: Context, device: BluetoothDevice) = launch {
}
}
- if (gatt.discoverServices() != BluetoothGatt.GATT_SUCCESS) {
- // discover services failed
- }
-
- val characteristic = gatt.getService(serviceUuid).getCharacteristic(characteristicUuid)
+ try {
+ val characteristic = gatt.getService(serviceUuid).getCharacteristic(characteristicUuid)
- val result = gatt.readCharacteristic(characteristic)
- if (result.status == BluetoothGatt.GATT_SUCCESS) {
- println("result.value = ${result.value}")
- } else {
- // read characteristic failed
+ val result = gatt.readCharacteristic(characteristic)
+ if (result.status == BluetoothGatt.GATT_SUCCESS) {
+ println("result.value = ${result.value}")
+ } else {
+ // read characteristic failed
+ }
+ } finally {
+ gatt.disconnect()
}
-
- gatt.disconnect()
- gatt.close()
}
```
@@ -65,7 +62,7 @@ class ExampleActivity : AppCompatActivity(), CoroutineScope {
launch {
// If Activity is destroyed during connection attempt, then `result` will contain
// `ConnectGattResult.Canceled`.
- val result = bluetoothDevice.connectGatt(this@ExampleActivity, autoConnect = false)
+ val result = bluetoothDevice.connectGatt(this@ExampleActivity)
// ...
}
@@ -87,25 +84,17 @@ Component's [`ViewModel`] can be scoped (via `CoroutineScope` interface) allowin
attempts to be tied to the `ViewModel`'s lifecycle:
```kotlin
-class ExampleViewModel(application: Application) : AndroidViewModel(application), CoroutineScope {
-
- private val job = Job()
- override val coroutineContext: CoroutineContext
- get() = job + Dispatchers.Main
+class ExampleViewModel(application: Application) : AndroidViewModel(application) {
fun connect(bluetoothDevice: BluetoothDevice) {
- launch {
+ viewModelScope.launch {
// If ViewModel is destroyed during connection attempt, then `result` will contain
// `ConnectGattResult.Canceled`.
- val result = bluetoothDevice.connectGatt(getApplication(), autoConnect = false)
+ val result = bluetoothDevice.connectGatt(getApplication())
// ...
}
}
-
- override fun onCleared() {
- job.cancel()
- }
}
```
@@ -115,44 +104,26 @@ Similar to the connection process, after a connection has been established, if a
cancelled then any `Gatt` operation executing within the Coroutine will be cancelled.
However, unlike the `connectGatt` cancellation handling, an established `Gatt` connection will
-**not** automatically disconnect or close when the Coroutine executing a `Gatt` operation is
-canceled. Special care must be taken to ensure that Bluetooth Low Energy connections are properly
-closed when no longer needed, for example:
+**not** automatically disconnect when the Coroutine executing a `Gatt` operation is canceled. Be
+sure and `disconnect` your `Gatt` connection when no longer needed:
```kotlin
-val gatt: Gatt = TODO("Acquire Gatt via `BluetoothDevice.connectGatt` extension function.")
-
launch {
- try {
- gatt.discoverServices()
- // todo: Assign desired characteristic to `characteristic` variable.
- val value = gatt.readCharacteristic(characteristic).value
- gatt.disconnect()
+ val gatt: Gatt = TODO("Acquire Gatt via `BluetoothDevice.connectGatt` extension function.")
+ // todo: Assign desired characteristic to `characteristic` variable.
+ val value = try {
+ gatt.readCharacteristic(characteristic).value
} finally {
- gatt.close()
- }
-}
-```
-
-The `Gatt` interface adheres to [`Closeable`] which can simplify the above example by using [`use`]:
-
-```kotlin
-val gatt: Gatt = TODO("Acquire Gatt via `BluetoothDevice.connectGatt` extension function.")
-
-launch {
- gatt.use { // Will close `gatt` if any failures or cancellation occurs.
- gatt.discoverServices()
- // todo: Assign desired characteristic to `characteristic` variable.
- val value = gatt.readCharacteristic(characteristic).value
- gatt.disconnect()
+ withContext(NonCancellable) {
+ gatt.disconnect()
+ }
}
}
```
-It may be desirable to manage Bluetooth Low Energy connections entirely manually. In which case,
-Coroutine [`GlobalScope`] can be used for the connection process. In which case, the returned `Gatt`
-object (after successful connection) can be stored and later used to disconnect **and** close the
-underlying `BluetoothGatt`.
+It may be desirable to manage Bluetooth Low Energy connections manually. In which case,
+[`GlobalScope`] can be used for the connection process. The returned `Gatt` object (after successful
+connection) can be stored and later used to disconnect.
[`BluetoothDevice`]: https://developer.android.com/reference/android/bluetooth/BluetoothDevice.html
diff --git a/gradle/dependencies.gradle b/gradle/dependencies.gradle
index 45d7f8c..f6993e1 100644
--- a/gradle/dependencies.gradle
+++ b/gradle/dependencies.gradle
@@ -6,9 +6,9 @@ ext.versions = [
ext.deps = [
kotlin: [
- stdlib: "org.jetbrains.kotlin:kotlin-stdlib:1.3.60",
- coroutines: "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.0.0",
- junit: "org.jetbrains.kotlin:kotlin-test-junit:1.3.60",
+ stdlib: "org.jetbrains.kotlin:kotlin-stdlib",
+ coroutines: "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.5",
+ junit: "org.jetbrains.kotlin:kotlin-test-junit",
],
// [Timber](https://github.com/JakeWharton/timber)
diff --git a/gradle/jacoco-android.gradle b/gradle/jacoco-android.gradle
index 4a72106..51f6f42 100644
--- a/gradle/jacoco-android.gradle
+++ b/gradle/jacoco-android.gradle
@@ -6,4 +6,6 @@ jacocoAndroidUnitTestReport {
csv.enabled false
html.enabled true
xml.enabled true
+
+ excludes += ['**/Debug*.*']
}
diff --git a/gradle/jacoco-java.gradle b/gradle/jacoco-java.gradle
new file mode 100644
index 0000000..e6ccd00
--- /dev/null
+++ b/gradle/jacoco-java.gradle
@@ -0,0 +1,12 @@
+jacoco {
+ toolVersion = "0.8.5"
+}
+
+jacocoTestReport {
+ reports {
+ csv.enabled false
+ html.enabled true
+ xml.enabled true
+ }
+ dependsOn test
+}
diff --git a/processor/build.gradle b/processor/build.gradle
index 0906c54..96db0e3 100644
--- a/processor/build.gradle
+++ b/processor/build.gradle
@@ -1,6 +1,7 @@
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
+ id 'org.jmailen.kotlinter'
id 'com.hiya.jacoco-android'
id 'com.vanniktech.maven.publish'
}
@@ -22,4 +23,5 @@ android {
dependencies {
api project(':core')
testImplementation deps.kotlin.junit
+ testImplementation deps.mockk
}
diff --git a/processor/src/main/AndroidManifest.xml b/processor/src/main/AndroidManifest.xml
index 29ab21e..11541bf 100644
--- a/processor/src/main/AndroidManifest.xml
+++ b/processor/src/main/AndroidManifest.xml
@@ -1,5 +1,5 @@
-
+
diff --git a/processor/src/main/java/GattProcessor.kt b/processor/src/main/java/GattProcessor.kt
index f900d9f..0ef2f38 100644
--- a/processor/src/main/java/GattProcessor.kt
+++ b/processor/src/main/java/GattProcessor.kt
@@ -1,16 +1,16 @@
/*
- * Copyright 2018 JUUL Labs, Inc.
+ * Copyright 2020 JUUL Labs, Inc.
*/
-package com.juul.able.experimental.processor
+package com.juul.able.processor
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
-import com.juul.able.experimental.Gatt
-import com.juul.able.experimental.WriteType
-import com.juul.able.experimental.messenger.OnCharacteristicRead
-import com.juul.able.experimental.messenger.OnCharacteristicWrite
-import com.juul.able.experimental.messenger.OnDescriptorWrite
+import com.juul.able.gatt.Gatt
+import com.juul.able.gatt.OnCharacteristicRead
+import com.juul.able.gatt.OnCharacteristicWrite
+import com.juul.able.gatt.OnDescriptorWrite
+import com.juul.able.gatt.WriteType
fun Gatt.withProcessors(vararg processors: Processor) = GattProcessor(this, processors)
diff --git a/processor/src/main/java/Processor.kt b/processor/src/main/java/Processor.kt
index 0872ab6..a80c2b8 100644
--- a/processor/src/main/java/Processor.kt
+++ b/processor/src/main/java/Processor.kt
@@ -1,12 +1,12 @@
/*
- * Copyright 2018 JUUL Labs, Inc.
+ * Copyright 2020 JUUL Labs, Inc.
*/
-package com.juul.able.experimental.processor
+package com.juul.able.processor
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
-import com.juul.able.experimental.WriteType
+import com.juul.able.gatt.WriteType
/**
* Processors add the ability to process (and optionally modify) GATT data pre-write or post-read.
diff --git a/processor/src/test/java/ExampleUnitTest.kt b/processor/src/test/java/ExampleUnitTest.kt
deleted file mode 100644
index 00614a0..0000000
--- a/processor/src/test/java/ExampleUnitTest.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-/*
- * Copyright 2020 JUUL Labs, Inc.
- */
-
-package com.juul.able.experimental.processor.test
-
-import kotlin.test.Test
-import kotlin.test.assertEquals
-
-/**
- * Example local unit test, which will execute on the development machine (host).
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-class ExampleUnitTest {
- @Test
- fun addition_isCorrect() {
- assertEquals(4, 2 + 2)
- }
-}
diff --git a/processor/src/test/java/GattProcessorTest.kt b/processor/src/test/java/GattProcessorTest.kt
new file mode 100644
index 0000000..77e11f9
--- /dev/null
+++ b/processor/src/test/java/GattProcessorTest.kt
@@ -0,0 +1,78 @@
+/*
+ * Copyright 2020 JUUL Labs, Inc.
+ */
+
+package com.juul.able.processor.test
+
+import android.bluetooth.BluetoothGatt.GATT_SUCCESS
+import android.bluetooth.BluetoothGattCharacteristic
+import android.bluetooth.BluetoothGattDescriptor
+import com.juul.able.gatt.Gatt
+import com.juul.able.gatt.OnCharacteristicRead
+import com.juul.able.gatt.WriteType
+import com.juul.able.processor.Processor
+import com.juul.able.processor.withProcessors
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.mockk
+import java.util.UUID
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlinx.coroutines.runBlocking
+
+private val testUuid = UUID.fromString("01234567-89ab-cdef-0123-456789abcdef")
+
+class GattProcessorTest {
+
+ private val reverseBytes = object : Processor {
+ override fun readCharacteristic(
+ characteristic: BluetoothGattCharacteristic,
+ value: ByteArray
+ ): ByteArray = value.reversedArray()
+
+ override fun writeCharacteristic(
+ characteristic: BluetoothGattCharacteristic,
+ value: ByteArray,
+ writeType: WriteType
+ ): ByteArray = value.reversedArray()
+
+ override fun writeDescriptor(
+ descriptor: BluetoothGattDescriptor,
+ value: ByteArray
+ ): ByteArray = value.reversedArray()
+ }
+
+ @Test
+ fun `Processor modifies readCharacteristic data`() {
+ val characteristic = mockk {
+ every { uuid } returns testUuid
+ every { instanceId } returns 0
+ }
+ val gatt = mockk {
+ coEvery { readCharacteristic(characteristic) } returns OnCharacteristicRead(
+ characteristic = characteristic,
+ value = byteArrayOf(0xF, 0x0, 0x0, 0xD),
+ status = GATT_SUCCESS
+ )
+ }
+ val withProcessors = gatt.withProcessors(reverseBytes)
+
+ val result = runBlocking {
+ withProcessors.readCharacteristic(characteristic)
+ }
+
+ val expected = OnCharacteristicRead(
+ characteristic = characteristic,
+ value = byteArrayOf(0xD, 0x0, 0x0, 0xF),
+ status = GATT_SUCCESS
+ )
+ assertEquals(
+ expected = expected.value.toList(),
+ actual = result.value.toList()
+ )
+ assertEquals(
+ expected = expected,
+ actual = result
+ )
+ }
+}
diff --git a/throw/build.gradle b/throw/build.gradle
index 0906c54..96db0e3 100644
--- a/throw/build.gradle
+++ b/throw/build.gradle
@@ -1,6 +1,7 @@
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
+ id 'org.jmailen.kotlinter'
id 'com.hiya.jacoco-android'
id 'com.vanniktech.maven.publish'
}
@@ -22,4 +23,5 @@ android {
dependencies {
api project(':core')
testImplementation deps.kotlin.junit
+ testImplementation deps.mockk
}
diff --git a/throw/src/main/AndroidManifest.xml b/throw/src/main/AndroidManifest.xml
index 86e42d1..bdaba34 100644
--- a/throw/src/main/AndroidManifest.xml
+++ b/throw/src/main/AndroidManifest.xml
@@ -1,5 +1,5 @@
-
+
diff --git a/throw/src/main/java/Device.kt b/throw/src/main/java/Device.kt
deleted file mode 100644
index 2331023..0000000
--- a/throw/src/main/java/Device.kt
+++ /dev/null
@@ -1,30 +0,0 @@
-/*
- * Copyright 2018 JUUL Labs, Inc.
- */
-
-package com.juul.able.experimental.throwable
-
-import android.bluetooth.BluetoothDevice
-import android.bluetooth.BluetoothGatt.GATT_SUCCESS
-import android.content.Context
-import com.juul.able.experimental.ConnectGattResult
-import com.juul.able.experimental.Device
-import com.juul.able.experimental.Gatt
-import kotlinx.coroutines.CancellationException
-
-class ConnectionCanceledException(cause: CancellationException) : Exception(cause)
-class ConnectionFailedException(cause: Throwable) : Exception(cause)
-
-/**
- * @throws ConnectionCanceledException if a [CancellationException] occurs during the connection process.
- * @throws ConnectionFailedException if underlying [BluetoothDevice.connectGatt] returns `null`.
- * @throws ConnectionFailedException if an error (non-[GATT_SUCCESS] status) occurs during connection process.
- */
-suspend fun Device.connectGattOrThrow(context: Context, autoConnect: Boolean): Gatt {
- val result = connectGatt(context, autoConnect)
- return when (result) {
- is ConnectGattResult.Success -> result.gatt
- is ConnectGattResult.Canceled -> throw ConnectionCanceledException(result.cause)
- is ConnectGattResult.Failure -> throw ConnectionFailedException(result.cause)
- }
-}
diff --git a/throw/src/main/java/Gatt.kt b/throw/src/main/java/Gatt.kt
index 0e6b58a..a52a0ce 100644
--- a/throw/src/main/java/Gatt.kt
+++ b/throw/src/main/java/Gatt.kt
@@ -1,31 +1,25 @@
/*
- * Copyright 2018 JUUL Labs, Inc.
+ * Copyright 2020 JUUL Labs, Inc.
*/
+@file:JvmName("GattThrowKt")
@file:Suppress("RedundantUnitReturnType")
-package com.juul.able.experimental.throwable
+package com.juul.able.throwable
import android.bluetooth.BluetoothGatt.GATT_SUCCESS
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
-import com.juul.able.experimental.Gatt
-import com.juul.able.experimental.WriteType
-
-/**
- * @throws [IllegalStateException] if [Gatt.connect] call returns `false`.
- */
-suspend fun Gatt.connectOrThrow(): Unit {
- connect() || error("connect() returned `false`.")
-}
+import com.juul.able.gatt.Gatt
+import com.juul.able.gatt.WriteType
/**
* @throws [IllegalStateException] if [Gatt.discoverServices] call does not return [GATT_SUCCESS].
*/
-suspend fun Gatt.discoverServicesOrThrow(): Unit {
+suspend fun Gatt.discoverServicesOrThrow() {
discoverServices()
.also { status ->
- check(status == android.bluetooth.BluetoothGatt.GATT_SUCCESS) {
+ check(status == GATT_SUCCESS) {
"Service discovery failed with gatt status $status."
}
}
@@ -36,29 +30,28 @@ suspend fun Gatt.discoverServicesOrThrow(): Unit {
*/
suspend fun Gatt.readCharacteristicOrThrow(
characteristic: BluetoothGattCharacteristic
-): BluetoothGattCharacteristic {
+): ByteArray {
return readCharacteristic(characteristic)
.also { (_, _, status) ->
- check(status == android.bluetooth.BluetoothGatt.GATT_SUCCESS) {
+ check(status == GATT_SUCCESS) {
"Reading characteristic ${characteristic.uuid} failed with status $status."
}
- }.characteristic
+ }.value
}
/**
* @throws [IllegalStateException] if [Gatt.setCharacteristicNotification] call returns `false`.
*/
-suspend fun Gatt.setCharacteristicNotificationOrThrow(
+fun Gatt.setCharacteristicNotificationOrThrow(
characteristic: BluetoothGattCharacteristic,
enable: Boolean
-): Unit {
+) {
setCharacteristicNotification(characteristic, enable)
.also {
check(it) { "Setting characteristic notifications to $enable failed." }
}
}
-
/**
* @throws [IllegalStateException] if [Gatt.writeCharacteristic] call does not return [GATT_SUCCESS].
*/
@@ -66,13 +59,13 @@ suspend fun Gatt.writeCharacteristicOrThrow(
characteristic: BluetoothGattCharacteristic,
value: ByteArray,
writeType: WriteType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
-): BluetoothGattCharacteristic {
- return writeCharacteristic(characteristic, value, writeType)
+) {
+ writeCharacteristic(characteristic, value, writeType)
.also { (_, status) ->
check(status == GATT_SUCCESS) {
"Writing characteristic ${characteristic.uuid} failed with status $status."
}
- }.characteristic
+ }
}
/**
@@ -81,9 +74,9 @@ suspend fun Gatt.writeCharacteristicOrThrow(
suspend fun Gatt.writeDescriptorOrThrow(
descriptor: BluetoothGattDescriptor,
value: ByteArray
-): BluetoothGattDescriptor {
- return writeDescriptor(descriptor, value)
+) {
+ writeDescriptor(descriptor, value)
.also { (_, status) ->
- check(status == GATT_SUCCESS) { "Descriptor write failed with gatt status $status." }
- }.descriptor
+ check(status == GATT_SUCCESS) { "Descriptor write failed with status $status." }
+ }
}
diff --git a/throw/src/main/java/android/BluetoothDevice.kt b/throw/src/main/java/android/BluetoothDevice.kt
index 2bfdb4d..73f9f28 100644
--- a/throw/src/main/java/android/BluetoothDevice.kt
+++ b/throw/src/main/java/android/BluetoothDevice.kt
@@ -1,27 +1,26 @@
/*
- * Copyright 2018 JUUL Labs, Inc.
+ * Copyright 2020 JUUL Labs, Inc.
*/
-package com.juul.able.experimental.throwable.android
+@file:JvmName("BluetoothDeviceThrowKt")
+
+package com.juul.able.throwable.android
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt.GATT_SUCCESS
import android.content.Context
-import com.juul.able.experimental.Gatt
-import com.juul.able.experimental.android.asCoroutinesDevice
-import com.juul.able.experimental.messenger.GattCallbackConfig
-import com.juul.able.experimental.throwable.connectGattOrThrow
-import com.juul.able.experimental.throwable.ConnectionCanceledException
-import com.juul.able.experimental.throwable.ConnectionFailedException
-import kotlinx.coroutines.CancellationException
+import com.juul.able.android.connectGatt
+import com.juul.able.device.ConnectGattResult.Failure
+import com.juul.able.device.ConnectGattResult.Success
+import com.juul.able.device.ConnectionFailed
+import com.juul.able.gatt.Gatt
/**
- * @throws ConnectionCanceledException if a [CancellationException] occurs during the connection process.
- * @throws ConnectionFailedException if underlying [BluetoothDevice.connectGatt] returns `null`.
- * @throws ConnectionFailedException if an error (non-[GATT_SUCCESS] status) occurs during connection process.
+ * @throws ConnectionFailed if underlying [BluetoothDevice.connectGatt] returns `null` or an error (non-[GATT_SUCCESS] status) occurs during connection process.
*/
suspend fun BluetoothDevice.connectGattOrThrow(
- context: Context,
- autoConnect: Boolean,
- callbackConfig: GattCallbackConfig = GattCallbackConfig()
-): Gatt = asCoroutinesDevice(callbackConfig).connectGattOrThrow(context, autoConnect)
+ context: Context
+): Gatt = when (val result = connectGatt(context)) {
+ is Success -> result.gatt
+ is Failure -> throw result.cause
+}
diff --git a/throw/src/test/java/ExampleUnitTest.kt b/throw/src/test/java/ExampleUnitTest.kt
deleted file mode 100644
index e3e7238..0000000
--- a/throw/src/test/java/ExampleUnitTest.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-/*
- * Copyright 2020 JUUL Labs, Inc.
- */
-
-package com.juul.able.experimental.throwable.test
-
-import kotlin.test.Test
-import kotlin.test.assertEquals
-
-/**
- * Example local unit test, which will execute on the development machine (host).
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-class ExampleUnitTest {
- @Test
- fun addition_isCorrect() {
- assertEquals(4, 2 + 2)
- }
-}
diff --git a/throw/src/test/java/GattTest.kt b/throw/src/test/java/GattTest.kt
new file mode 100644
index 0000000..2bd5dd3
--- /dev/null
+++ b/throw/src/test/java/GattTest.kt
@@ -0,0 +1,143 @@
+/*
+ * Copyright 2020 JUUL Labs, Inc.
+ */
+
+package com.juul.able.throwable.test
+
+import android.bluetooth.BluetoothGatt.GATT_FAILURE
+import android.bluetooth.BluetoothGatt.GATT_READ_NOT_PERMITTED
+import android.bluetooth.BluetoothGatt.GATT_SUCCESS
+import android.bluetooth.BluetoothGatt.GATT_WRITE_NOT_PERMITTED
+import android.bluetooth.BluetoothGattCharacteristic
+import android.bluetooth.BluetoothGattDescriptor
+import com.juul.able.gatt.Gatt
+import com.juul.able.gatt.OnCharacteristicRead
+import com.juul.able.gatt.OnCharacteristicWrite
+import com.juul.able.gatt.OnDescriptorWrite
+import com.juul.able.throwable.discoverServicesOrThrow
+import com.juul.able.throwable.readCharacteristicOrThrow
+import com.juul.able.throwable.setCharacteristicNotificationOrThrow
+import com.juul.able.throwable.writeCharacteristicOrThrow
+import com.juul.able.throwable.writeDescriptorOrThrow
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.mockk
+import java.util.UUID
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlinx.coroutines.runBlocking
+
+private val testUuid = UUID.fromString("01234567-89ab-cdef-0123-456789abcdef")
+
+class GattTest {
+
+ @Test
+ fun `discoverServicesOrThrow throws IllegalStateException for non-GATT_SUCCESS response`() {
+ val gatt = mockk {
+ coEvery { discoverServices() } returns GATT_FAILURE
+ }
+
+ assertFailsWith {
+ runBlocking {
+ gatt.discoverServicesOrThrow()
+ }
+ }
+ }
+
+ @Test
+ fun `readCharacteristicOrThrow throws IllegalStateException for non-GATT_SUCCESS response`() {
+ val characteristic = mockk {
+ every { uuid } returns testUuid
+ }
+ val gatt = mockk {
+ coEvery { readCharacteristic(characteristic) } returns OnCharacteristicRead(
+ characteristic = characteristic,
+ value = byteArrayOf(),
+ status = GATT_READ_NOT_PERMITTED
+ )
+ }
+
+ assertFailsWith {
+ runBlocking {
+ gatt.readCharacteristicOrThrow(characteristic)
+ }
+ }
+ }
+
+ @Test
+ fun `readCharacteristicOrThrow returns ByteArray for GATT_SUCCESS response`() {
+ val characteristic = mockk {
+ every { uuid } returns testUuid
+ }
+ val gatt = mockk {
+ coEvery { readCharacteristic(characteristic) } returns OnCharacteristicRead(
+ characteristic = characteristic,
+ value = byteArrayOf(0xF, 0x0, 0x0, 0xD),
+ status = GATT_SUCCESS
+ )
+ }
+
+ val result = runBlocking {
+ gatt.readCharacteristicOrThrow(characteristic)
+ }
+
+ // Convert to `List` so equality verification is done on the contents.
+ assertEquals(
+ expected = byteArrayOf(0xF, 0x0, 0x0, 0xD).toList(),
+ actual = result.toList()
+ )
+ }
+
+ @Test
+ fun `setCharacteristicNotification throws IllegalStateException for false return`() {
+ val gatt = mockk {
+ coEvery { setCharacteristicNotification(any(), any()) } returns false
+ }
+
+ assertFailsWith {
+ val characteristic = mockk {
+ every { uuid } returns testUuid
+ }
+ gatt.setCharacteristicNotificationOrThrow(characteristic, true)
+ }
+ }
+
+ @Test
+ fun `writeCharacteristicOrThrow throws IllegalStateException for non-GATT_SUCCESS response`() {
+ val characteristic = mockk {
+ every { uuid } returns testUuid
+ }
+ val gatt = mockk {
+ coEvery { writeCharacteristic(characteristic, any(), any()) } returns OnCharacteristicWrite(
+ characteristic = characteristic,
+ status = GATT_WRITE_NOT_PERMITTED
+ )
+ }
+
+ assertFailsWith {
+ runBlocking {
+ gatt.writeCharacteristicOrThrow(characteristic, byteArrayOf())
+ }
+ }
+ }
+
+ @Test
+ fun `writeDescriptorOrThrow throws IllegalStateException for non-GATT_SUCCESS response`() {
+ val descriptor = mockk {
+ every { uuid } returns testUuid
+ }
+ val gatt = mockk {
+ coEvery { writeDescriptor(descriptor, any()) } returns OnDescriptorWrite(
+ descriptor = descriptor,
+ status = GATT_WRITE_NOT_PERMITTED
+ )
+ }
+
+ assertFailsWith {
+ runBlocking {
+ gatt.writeDescriptorOrThrow(descriptor, byteArrayOf())
+ }
+ }
+ }
+}
diff --git a/throw/src/test/java/android/BluetoothDeviceTest.kt b/throw/src/test/java/android/BluetoothDeviceTest.kt
new file mode 100644
index 0000000..059ccf4
--- /dev/null
+++ b/throw/src/test/java/android/BluetoothDeviceTest.kt
@@ -0,0 +1,78 @@
+/*
+ * Copyright 2020 JUUL Labs, Inc.
+ */
+
+package com.juul.able.throwable.test.android
+
+import android.bluetooth.BluetoothDevice
+import android.bluetooth.BluetoothGatt.GATT_FAILURE
+import android.bluetooth.BluetoothProfile.STATE_DISCONNECTED
+import android.content.Context
+import com.juul.able.android.connectGatt
+import com.juul.able.device.ConnectGattResult
+import com.juul.able.device.ConnectGattResult.Success
+import com.juul.able.device.ConnectionFailed
+import com.juul.able.gatt.Gatt
+import com.juul.able.gatt.GattStatusFailure
+import com.juul.able.gatt.OnConnectionStateChange
+import com.juul.able.throwable.android.connectGattOrThrow
+import io.mockk.coEvery
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.unmockkStatic
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlinx.coroutines.runBlocking
+
+class BluetoothDeviceTest {
+
+ @Test
+ fun `connectGattOrThrow returns result Gatt on success`() {
+ val context = mockk()
+ val gatt = mockk()
+ val bluetoothDevice = mockk()
+
+ mockkStatic("com.juul.able.android.BluetoothDeviceKt")
+ try {
+ coEvery { bluetoothDevice.connectGatt(any()) } returns Success(gatt)
+
+ val result = runBlocking {
+ bluetoothDevice.connectGattOrThrow(context)
+ }
+
+ assertEquals(
+ expected = gatt,
+ actual = result
+ )
+ } finally {
+ unmockkStatic("com.juul.able.android.BluetoothDeviceKt")
+ }
+ }
+
+ @Test
+ fun `connectGattOrThrow throws result cause on failure`() {
+ val context = mockk()
+ val bluetoothDevice = mockk()
+ val event = OnConnectionStateChange(GATT_FAILURE, STATE_DISCONNECTED)
+ val cause = GattStatusFailure(event)
+ val failure = ConnectionFailed("Failed to connect to device 00:11:22:33:FF:EE", cause)
+ mockkStatic("com.juul.able.android.BluetoothDeviceKt")
+ try {
+ coEvery { bluetoothDevice.connectGatt(any()) } returns ConnectGattResult.Failure(failure)
+
+ val capturedCause = assertFailsWith {
+ runBlocking {
+ bluetoothDevice.connectGattOrThrow(context)
+ }
+ }.cause!!
+
+ assertEquals(
+ expected = cause,
+ actual = capturedCause
+ )
+ } finally {
+ unmockkStatic("com.juul.able.android.BluetoothDeviceKt")
+ }
+ }
+}
diff --git a/timber-logger/build.gradle b/timber-logger/build.gradle
index 43367d2..4fad649 100644
--- a/timber-logger/build.gradle
+++ b/timber-logger/build.gradle
@@ -1,6 +1,7 @@
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
+ id 'org.jmailen.kotlinter'
id 'com.hiya.jacoco-android'
id 'com.vanniktech.maven.publish'
}
diff --git a/timber-logger/src/main/AndroidManifest.xml b/timber-logger/src/main/AndroidManifest.xml
index f476f89..7754d4e 100644
--- a/timber-logger/src/main/AndroidManifest.xml
+++ b/timber-logger/src/main/AndroidManifest.xml
@@ -1,5 +1,5 @@
-
+
diff --git a/timber-logger/src/main/java/AndroidTagGenerator.kt b/timber-logger/src/main/java/AndroidTagGenerator.kt
index 8f4a85d..c2a1aaf 100644
--- a/timber-logger/src/main/java/AndroidTagGenerator.kt
+++ b/timber-logger/src/main/java/AndroidTagGenerator.kt
@@ -1,8 +1,8 @@
/*
- * Copyright 2018 JUUL Labs, Inc.
+ * Copyright 2020 JUUL Labs, Inc.
*/
-package com.juul.able.experimental.logger.timber
+package com.juul.able.logger.timber
private const val CALL_STACK_INDEX = 2
private val ANONYMOUS_CLASS_REGEX = "(\\$\\d+)+$".toRegex()
diff --git a/timber-logger/src/main/java/TimberLogger.kt b/timber-logger/src/main/java/TimberLogger.kt
index 069b4bf..75be97f 100644
--- a/timber-logger/src/main/java/TimberLogger.kt
+++ b/timber-logger/src/main/java/TimberLogger.kt
@@ -1,11 +1,11 @@
/*
- * Copyright 2018 JUUL Labs, Inc.
+ * Copyright 2020 JUUL Labs, Inc.
*/
-package com.juul.able.experimental.logger.timber
+package com.juul.able.logger.timber
import android.os.Build
-import com.juul.able.experimental.Logger
+import com.juul.able.Logger
import timber.log.Timber
private const val MAX_TAG_LENGTH = 23
diff --git a/timber-logger/src/test/java/AndroidTagGeneratorTest.kt b/timber-logger/src/test/java/AndroidTagGeneratorTest.kt
index 2130d53..e49f374 100644
--- a/timber-logger/src/test/java/AndroidTagGeneratorTest.kt
+++ b/timber-logger/src/test/java/AndroidTagGeneratorTest.kt
@@ -1,14 +1,15 @@
/*
- * Copyright 2018 JUUL Labs, Inc.
+ * Copyright 2020 JUUL Labs, Inc.
*/
-package com.juul.able.experimental.logger.timber
+package com.juul.able.logger.timber.test
-import com.juul.able.experimental.Able
-import com.juul.able.experimental.Logger
-import org.junit.BeforeClass
-import org.junit.Test
+import com.juul.able.Able
+import com.juul.able.Logger
+import com.juul.able.logger.timber.AndroidTagGenerator
+import kotlin.test.Test
import kotlin.test.assertEquals
+import org.junit.BeforeClass
class AndroidTagGeneratorTest {
@@ -26,14 +27,14 @@ class AndroidTagGeneratorTest {
}
@Test
- fun tagMatchesClassname() {
+ fun `Tag matches classname`() {
val dog = Dog()
dog.bark()
assertEquals(dog.javaClass.simpleName, logger.lastTag)
}
@Test
- fun tagFromAnonymousMethod() {
+ fun `Tag is captured from anonymous method`() {
val anonymous = object {
fun go() {
Able.info { "hello world" }
@@ -46,7 +47,7 @@ class AndroidTagGeneratorTest {
}
@Test
- fun tagFromAnonymousMethodWithinRunnable() {
+ fun `Tag is captured from anonymous method within Runnable`() {
Runnable {
object {
fun go() {