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

KeepAliveGatt EventHandler #74

Merged
merged 35 commits into from
Jul 17, 2020
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
716dea5
KeepAliveGatt now takes in a onEventAction, allowing the consumer of …
sdonn3 Jul 9, 2020
72baad9
merge changes from travis' PR
sdonn3 Jul 9, 2020
aaa739d
Some changes to the README to reflect changes in KeepAliveGatt
sdonn3 Jul 9, 2020
785c709
Changes from PR
sdonn3 Jul 10, 2020
934c824
Add API changes
sdonn3 Jul 10, 2020
fe650b1
test format
sdonn3 Jul 10, 2020
7598997
Merge branch 'develop' into steve/esc-965_onDisconnect
sdonn3 Jul 10, 2020
21f6320
fix test
sdonn3 Jul 10, 2020
0adefb9
Merge remote-tracking branch 'origin/steve/esc-965_onDisconnect' into…
sdonn3 Jul 10, 2020
1e1aa35
changes
sdonn3 Jul 13, 2020
acd9291
PR feedback requests
sdonn3 Jul 14, 2020
76b07d1
connectionAttempt fix
sdonn3 Jul 14, 2020
29109b5
Code clean up
twyatt Jul 15, 2020
2368bd6
Drop Disconnected.Info and put properties directly on Disconnected event
twyatt Jul 15, 2020
2ae24d4
API dump
twyatt Jul 15, 2020
c04b2cd
Kotlinter
twyatt Jul 15, 2020
c2e9421
Add test that assumes connection rejection should yield Disconnected …
twyatt Jul 15, 2020
4ef5191
Code clean up and add Rejected event
twyatt Jul 15, 2020
8c8b54c
Add documentation re: states and events
twyatt Jul 15, 2020
803cdad
Simplify example in documentation
twyatt Jul 15, 2020
b1f4654
API dump
twyatt Jul 15, 2020
d26fafd
Kotlinter
twyatt Jul 15, 2020
0b80726
Reduce size of state and event flow diagram
twyatt Jul 15, 2020
521af43
Merge branch 'develop' into steve/esc-965_onDisconnect
twyatt Jul 15, 2020
6421846
Fix reconnect test
twyatt Jul 15, 2020
05ba36e
Settle on disconnected state on exception in onConnected
twyatt Jul 16, 2020
bec6b43
Fix which scope is cancelled due to onConnected exception
twyatt Jul 16, 2020
fe24455
Remove test that is no longer valid
twyatt Jul 16, 2020
560802d
Remove nested scopes and launch in `establishConnection`
twyatt Jul 16, 2020
70357d7
Minor documentation updates
twyatt Jul 16, 2020
f3e0e91
couple new tests
sdonn3 Jul 16, 2020
cb9b61d
Unwrap special exception for Disconnected event
twyatt Jul 16, 2020
c0e38ee
Test clean up
twyatt Jul 16, 2020
045f845
Update state and event flow diagram
twyatt Jul 16, 2020
a89e741
Properly propagate failure through Disconnected state
twyatt Jul 17, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion keep-alive/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ fun CoroutineScope.keepAliveGatt(
| `androidContext` | The Android `Context` for establishing Bluetooth Low-Energy connections. |
| `bluetoothDevice` | `BluetoothDevice` to maintain a connection with. |
| `disconnectTimeoutMillis` | Duration (in milliseconds) to wait for connection to gracefully spin down (after `disconnect`) before forcefully closing. |
| `onConnectAction` | Actions to perform upon connection. `Connected` state is propagated _after_ `onConnectAction` completes. |
| `onEventAction ` | Actions to perform upon either connection or disconnection. `onConnect` is performed after connection, and `onDisconnect` |
| | is performed after a disconnection, either post connection-state or after a failure to connect. |

For example, to create a `KeepAliveGatt` as a child of Android's `viewModelScope`:

Expand Down
40 changes: 40 additions & 0 deletions keep-alive/api/keep-alive.api
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,44 @@ public final class com/juul/able/keepalive/BuildConfig {
public final class com/juul/able/keepalive/ConnectionRejected : java/io/IOException {
}

public final class com/juul/able/keepalive/DisconnectInfo {
public fun <init> (ZI)V
public final fun component1 ()Z
public final fun component2 ()I
public final fun copy (ZI)Lcom/juul/able/keepalive/DisconnectInfo;
public static synthetic fun copy$default (Lcom/juul/able/keepalive/DisconnectInfo;ZIILjava/lang/Object;)Lcom/juul/able/keepalive/DisconnectInfo;
public fun equals (Ljava/lang/Object;)Z
public final fun getConnectionAttempt ()I
public final fun getWasConnected ()Z
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}

public abstract class com/juul/able/keepalive/Event {
}

public final class com/juul/able/keepalive/Event$Connected : com/juul/able/keepalive/Event {
public fun <init> (Lcom/juul/able/gatt/GattIo;)V
public final fun component1 ()Lcom/juul/able/gatt/GattIo;
public final fun copy (Lcom/juul/able/gatt/GattIo;)Lcom/juul/able/keepalive/Event$Connected;
public static synthetic fun copy$default (Lcom/juul/able/keepalive/Event$Connected;Lcom/juul/able/gatt/GattIo;ILjava/lang/Object;)Lcom/juul/able/keepalive/Event$Connected;
public fun equals (Ljava/lang/Object;)Z
public final fun getGatt ()Lcom/juul/able/gatt/GattIo;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}

public final class com/juul/able/keepalive/Event$Disconnected : com/juul/able/keepalive/Event {
public fun <init> (Lcom/juul/able/keepalive/DisconnectInfo;)V
public final fun component1 ()Lcom/juul/able/keepalive/DisconnectInfo;
public final fun copy (Lcom/juul/able/keepalive/DisconnectInfo;)Lcom/juul/able/keepalive/Event$Disconnected;
public static synthetic fun copy$default (Lcom/juul/able/keepalive/Event$Disconnected;Lcom/juul/able/keepalive/DisconnectInfo;ILjava/lang/Object;)Lcom/juul/able/keepalive/Event$Disconnected;
public fun equals (Ljava/lang/Object;)Z
public final fun getDisconnectInfo ()Lcom/juul/able/keepalive/DisconnectInfo;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}

public final class com/juul/able/keepalive/KeepAliveGatt : com/juul/able/gatt/GattIo {
public final fun connect ()Z
public final fun disconnect (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
Expand All @@ -32,6 +70,8 @@ public final class com/juul/able/keepalive/KeepAliveGatt : com/juul/able/gatt/Ga
public final class com/juul/able/keepalive/KeepAliveGattKt {
public static final fun keepAliveGatt (Lkotlinx/coroutines/CoroutineScope;Landroid/content/Context;Landroid/bluetooth/BluetoothDevice;JLkotlin/jvm/functions/Function2;)Lcom/juul/able/keepalive/KeepAliveGatt;
public static synthetic fun keepAliveGatt$default (Lkotlinx/coroutines/CoroutineScope;Landroid/content/Context;Landroid/bluetooth/BluetoothDevice;JLkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lcom/juul/able/keepalive/KeepAliveGatt;
public static final fun onConnected (Lcom/juul/able/keepalive/Event;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static final fun onDisconnected (Lcom/juul/able/keepalive/Event;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
}

public final class com/juul/able/keepalive/NotReady : java/lang/IllegalStateException {
Expand Down
51 changes: 41 additions & 10 deletions keep-alive/src/main/java/KeepAliveGatt.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import com.juul.able.Able
import com.juul.able.android.connectGatt
import com.juul.able.device.ConnectGattResult.Failure
import com.juul.able.device.ConnectGattResult.Success
import com.juul.able.gatt.Gatt
import com.juul.able.gatt.GattIo
import com.juul.able.gatt.GattStatus
import com.juul.able.gatt.OnCharacteristicChanged
Expand Down Expand Up @@ -53,8 +54,6 @@ import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull

typealias ConnectAction = suspend GattIo.() -> Unit

class NotReady internal constructor(message: String) : IllegalStateException(message)
class ConnectionRejected internal constructor(cause: Throwable) : IOException(cause)

Expand All @@ -72,25 +71,43 @@ sealed class State {
override fun toString(): String = javaClass.simpleName
}

sealed class Event {
data class Connected(val gatt: GattIo) : Event()
data class Disconnected(val disconnectInfo: DisconnectInfo) : Event()
}

data class DisconnectInfo(
val wasConnected: Boolean,
val connectionAttempt: Int
)

suspend fun Event.onConnected(action: suspend (gatt: GattIo) -> Unit) {
if (this is Event.Connected) action.invoke(gatt)
}

suspend fun Event.onDisconnected(action: suspend (DisconnectInfo) -> Unit) {
if (this is Event.Disconnected) action.invoke(disconnectInfo)
}

fun CoroutineScope.keepAliveGatt(
androidContext: Context,
bluetoothDevice: BluetoothDevice,
disconnectTimeoutMillis: Long,
onConnectAction: ConnectAction? = null
onEvent: (suspend (Event) -> Unit)? = null
) = KeepAliveGatt(
parentCoroutineContext = coroutineContext,
androidContext = androidContext,
bluetoothDevice = bluetoothDevice,
disconnectTimeoutMillis = disconnectTimeoutMillis,
onConnectAction = onConnectAction
onEvent = onEvent
)

class KeepAliveGatt internal constructor(
parentCoroutineContext: CoroutineContext,
androidContext: Context,
private val bluetoothDevice: BluetoothDevice,
private val disconnectTimeoutMillis: Long,
private val onConnectAction: ConnectAction? = null
private val onEvent: (suspend (Event) -> Unit)?
) : GattIo {

private val applicationContext = androidContext.applicationContext
Expand Down Expand Up @@ -142,8 +159,17 @@ class KeepAliveGatt internal constructor(
isRunning.compareAndSet(false, true) || return false

scope.launch(CoroutineName("KeepAliveGatt@$bluetoothDevice")) {
var connectionAttempts = 1
while (isActive) {
spawnConnection()
val successfullyConnected = spawnConnection()
onEvent?.invoke(
Event.Disconnected(
DisconnectInfo(
successfullyConnected,
connectionAttempts++
)
)
)
}
}.invokeOnCompletion { isRunning.set(false) }
return true
Expand All @@ -153,16 +179,20 @@ class KeepAliveGatt internal constructor(
job.children.forEach { it.cancelAndJoin() }
}

private suspend fun spawnConnection() {
/**
* Returns a boolean indicating whether or not this function was successful at connecting
* after it is completed.
*/
private suspend fun spawnConnection(): Boolean {
try {
_state.value = Connecting

val gatt = when (val result = bluetoothDevice.connectGatt(applicationContext)) {
val gatt: Gatt = when (val result = bluetoothDevice.connectGatt(applicationContext)) {
is Success -> result.gatt
is Failure.Rejected -> throw ConnectionRejected(result.cause)
is Failure.Connection -> {
Able.error { "Failed to connect to device $bluetoothDevice due to ${result.cause}" }
return
return false
}
}

Expand All @@ -174,7 +204,7 @@ class KeepAliveGatt internal constructor(
.onEach(_onCharacteristicChanged::send)
.launchIn(this, start = UNDISPATCHED)
_gatt = gatt
onConnectAction?.invoke(gatt)
onEvent?.invoke(Event.Connected(gatt))
_state.value = Connected
}
} finally {
Expand All @@ -192,6 +222,7 @@ class KeepAliveGatt internal constructor(
}
}
_state.value = Disconnected()
return true
} catch (failure: Exception) {
_state.value = Disconnected(failure)
throw failure
Expand Down
150 changes: 150 additions & 0 deletions keep-alive/src/test/java/KeepAliveGattTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,14 @@ import com.juul.able.gatt.Gatt
import com.juul.able.gatt.OnCharacteristicChanged
import com.juul.able.gatt.OnReadRemoteRssi
import com.juul.able.keepalive.ConnectionRejected
import com.juul.able.keepalive.DisconnectInfo
import com.juul.able.keepalive.NotReady
import com.juul.able.keepalive.State.Connected
import com.juul.able.keepalive.State.Connecting
import com.juul.able.keepalive.State.Disconnected
import com.juul.able.keepalive.keepAliveGatt
import com.juul.able.keepalive.onConnected
import com.juul.able.keepalive.onDisconnected
import com.juul.able.logger.Logger
import io.mockk.coEvery
import io.mockk.coVerify
Expand Down Expand Up @@ -55,6 +58,7 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlin.test.assertNull

private const val BLUETOOTH_DEVICE_CLASS = "com.juul.able.android.BluetoothDeviceKt"
private const val MAC_ADDRESS = "00:11:22:33:FF:EE"
Expand Down Expand Up @@ -350,6 +354,152 @@ class KeepAliveGattTest {
}
}

@Test
fun `When 'connect' occurs, the onEvent is called with Connected event`() = runBlocking {
val bluetoothDevice = mockBluetoothDevice()
val gatt = mockk<Gatt> {
every { onCharacteristicChanged } returns flow { delay(Long.MAX_VALUE) }
coEvery { disconnect() } returns Unit
}

mockkStatic(BLUETOOTH_DEVICE_CLASS) {
var onConnectCalls = 0
coEvery {
bluetoothDevice.connectGatt(any())
} returns ConnectGattResult.Success(gatt)

val job = Job()
val keepAlive = CoroutineScope(job).keepAliveGatt(
androidContext = mockk(relaxed = true),
bluetoothDevice = bluetoothDevice,
disconnectTimeoutMillis = DISCONNECT_TIMEOUT,
onEvent = { event ->
event.onConnected {
onConnectCalls++
}
}
)
assertEquals(
expected = 0,
actual = onConnectCalls
)

val ready = async(start = UNDISPATCHED) {
keepAlive.state.first { it == Connected }
}
keepAlive.connect()
ready.await()

assertEquals(
expected = 1,
actual = onConnectCalls
)

job.cancelAndJoin()
}
}

@Test
fun `When connection is dropped after being connected, onDisconnect's value reflects that`() =
runBlocking {
val bluetoothDevice = mockBluetoothDevice()
val gatt1 = mockk<Gatt> {
every { onCharacteristicChanged } returns flow {
delay(1_000)
}
coEvery { disconnect() } returns Unit
}

mockkStatic(BLUETOOTH_DEVICE_CLASS) {
coEvery {
bluetoothDevice.connectGatt(any())
} returns ConnectGattResult.Success(gatt1)

val job = Job()
var disconnectInfo: DisconnectInfo? = null
val keepAlive = CoroutineScope(job).keepAliveGatt(
androidContext = mockk(relaxed = true),
bluetoothDevice = bluetoothDevice,
disconnectTimeoutMillis = DISCONNECT_TIMEOUT,
onEvent = { event ->
event.onDisconnected {
disconnectInfo = it
}
}
)

val ready = async(start = UNDISPATCHED) {
keepAlive.state.first { it == Connected }
}
keepAlive.connect()
ready.await()

assertNull(disconnectInfo)

val disconnected = async(start = UNDISPATCHED) {
keepAlive.state.first { it is Disconnected }
}
disconnected.await()

val connecting = async(start = UNDISPATCHED) {
keepAlive.state.first { it is Connecting }
}
connecting.await()

assertEquals(
expected = DisconnectInfo(true, 1),
actual = disconnectInfo
)

job.cancelAndJoin()
}
}

@Test
fun `Connection failures increment, onDisconnect's connectionAttempt value`() = runBlocking {
val connectionAttempts = 5
val bluetoothDevice = mockBluetoothDevice()

val lock = Mutex(locked = true)
val scope = CoroutineScope(Job() + CoroutineExceptionHandler { _, cause ->
if (cause is EndOfTest) lock.unlock()
})
mockkStatic(BLUETOOTH_DEVICE_CLASS) {
var attempt = 0
coEvery {
bluetoothDevice.connectGatt(any())
} answers {
if (++attempt >= connectionAttempts) throw EndOfTest()
Failure.Connection(mockk<ConnectionLost>())
}

var disconnectInfos = mutableListOf<DisconnectInfo>()

val keepAlive = scope.keepAliveGatt(
androidContext = mockk(relaxed = true),
bluetoothDevice = bluetoothDevice,
disconnectTimeoutMillis = DISCONNECT_TIMEOUT,
onEvent = { event ->
event.onDisconnected {
disconnectInfos.add(it)
}
}
)

keepAlive.connect()

runBlocking {
lock.lock()
assertEquals(
expected = listOf(1, 2, 3, 4),
actual = disconnectInfos.map {
it.connectionAttempt
}.toList()
)
}
}
}

@Test
fun `Can connect again after calling disconnect`() = runBlocking {
val bluetoothDevice = mockBluetoothDevice()
Expand Down