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

Commit

Permalink
Make constructor internal and improve documentation
Browse files Browse the repository at this point in the history
  • Loading branch information
twyatt committed May 25, 2020
1 parent 45c12e3 commit fa1430e
Show file tree
Hide file tree
Showing 6 changed files with 117 additions and 133 deletions.
136 changes: 63 additions & 73 deletions keep-alive/README.md
Original file line number Diff line number Diff line change
@@ -1,51 +1,84 @@
# Keep-alive

A keep-alive GATT may be created via the `KeepAliveGatt` constructor, which has the following
signature:
Provides keep-alive (reconnects if connection drops) GATT communication.

## Structured Concurrency

A keep-alive GATT is created by calling the `keepAliveGatt` extension function on CoroutineScope`,
which has the following signature:

```kotlin
KeepAliveGatt(
parentCoroutineContext: CoroutineContext = EmptyCoroutineContext,
fun CoroutineScope.keepAliveGatt(
androidContext: Context,
bluetoothDevice: BluetoothDevice,
disconnectTimeoutMillis: Long,
onConnectAction: ConnectAction? = null
)
): KeepAliveGatt
```

| Parameter | Description |
|---------------------------|-------------------------------------------------------------------------------------------------------------------------------------|
| `parentCoroutineContext` | The [`CoroutineContext`] that `KeepAliveGatt` shall operate in (see [Structured Concurrency](#structured-concurrency) for details). |
| `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 cancelling. |
| `onConnectAction` | Actions to perform upon connection. `Connected` state is propagated _after_ `onConnectAction` completes. |
| Parameter | Description |
|---------------------------|---------------------------------------------------------------------------------------------------------------------------|
| `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. |

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

```kotlin
class ExampleViewModel(application: Application) : AndroidViewModel(application) {

private const val MAC_ADDRESS = ...

private val gatt = viewModelScope.keepAliveGatt(
application,
bluetoothAdapter.getRemoteDevice(MAC_ADDRESS),
disconnectTimeoutMillis = 5_000L // 5 seconds
) {
// Actions to perform on initial connect *and* subsequent reconnects:
discoverServicesOrThrow()
}

fun connect() {
gatt.connect()
}
}
```

When the parent [`CoroutineScope`] (`viewModelScope` in the above example) cancels, the
`KeepAliveGatt` also cancels (and disconnects).

When cancelled, a `KeepAliveGatt` will end in a `Cancelled` `State`. Once a `KeepAliveGatt` is
`Cancelled` it **cannot** be reconnected (calls to `connect` will throw `IllegalStateException`); a
new `KeepAliveGatt` must be created.

## Connection Handling

A `KeepAliveGatt` will start in a `Disconnected` state. When `connect` is called, `KeepAliveGatt`
will attempt to establish a connection (`Connecting`). If a connection cannot be established then
`KeepAliveGatt` will retry indefinitely (unless either the underlying
[`BluetoothDevice.connectGatt`] returns `null` or `disconnect` is called on the `KeepAliveGatt`; see
[Error Handling](#error-handling) for details regarding failure states).
will attempt to establish a connection (`Connecting`). If the connection is rejected (e.g. BLE is
turned off), then `KeepAliveGatt` will settle at `Disconnected` state. The `connect` function can be
called again to re-attempt to establish a connection.

![Connection rejected](artwork/connect-reject.png)

If a connection cannot be established (e.g. BLE device out-of-range) then `KeepAliveGatt` will retry
indefinitely.

![Connection failure](artwork/connect-failure.png)

Once `Connected`, if the connection drops, then `KeepAliveGatt` will automatically reconnect.

![Reconnect on connection drop](artwork/connection-drop)

To disconnect an established connection or cancel an in-flight connection attempt, `disconnect` can
be called (it will suspend until underlying [`BluetoothGatt`] has disconnected).

### Connection State

`KeepAliveGatt` can be in one of the following `State`s:

- `Disconnected`
- `Connecting`
- `Connected`
- `Disconnecting`
- `Cancelled`

The state can be monitored via the `state` [`Flow`] property:

```kotlin
val gatt = KeepAliveGatt(...)
val gatt = scope.keepAliveGatt(...)
gatt.state.collect { println("State: $it") }
```

Expand All @@ -58,7 +91,7 @@ unable to be performed due to a GATT connection being unavailable (i.e. current
It is the responsibility of the caller to handle retrying, for example:

```kotlin
class GattClosed : Exception()
class GattCancelled : Exception()

suspend fun KeepAliveGatt.readCharacteristicWithRetry(
characteristic: BluetoothGattCharacteristic,
Expand All @@ -77,7 +110,7 @@ suspend fun KeepAliveGatt.readCharacteristicWithRetry(

private suspend fun KeepAliveGatt.suspendUntilConnected() {
state
.onEach { if (it is Closed) throw GattClosed() }
.onEach { if (it is Cancelled) throw GattCancelled() }
.first { it == Connected }
}
```
Expand All @@ -91,7 +124,7 @@ events from being lost, be sure to setup subscribers **before** calling `KeepAli
example:

```kotlin
val gatt = KeepAliveGatt(...)
val gatt = scope.keepAliveGatt(...)

fun connect() {
// `CoroutineStart.UNDISPATCHED` executes within `launch` up to the `collect` (then suspends),
Expand All @@ -109,48 +142,12 @@ fun connect() {
If the underlying [`BluetoothGatt`] connection is dropped, the characteristic change event stream
remains open (and all subscriptions will continue to `collect`). When a new [`BluetoothGatt`]
connection is established, all it's characteristic change events are automatically routed to the
existing `KeepAliveGatt` subscribers.

## Structured Concurrency

The `CoroutineScope.keepAliveGatt` extension function may be used to create a `KeepAliveGatt` that
is a child of the current [`CoroutineScope`], for example:

```kotlin
class ExampleViewModel(application: Application) : AndroidViewModel(application) {

private const val MAC_ADDRESS = ...

private val gatt = viewModelScope.keepAliveGatt(
application,
bluetoothAdapter.getRemoteDevice(MAC_ADDRESS),
disconnectTimeoutMillis = 5_000L // 5 seconds
) {
// Actions to perform on initial connect *and* subsequent reconnects:
discoverServicesOrThrow()
}

fun connect() {
gatt.connect()
}
}
```

When the parent [`CoroutineScope`] (`viewModelScope` in the above example) cancels, the
`KeepAliveGatt` automatically disconnects. You may _optionally_ manually `disconnect`.

If `KeepAliveGatt` is not configured with a parent [`CoroutineContext`] (e.g. via
`parentCoroutineContext` constructor argument or `CoroutineScope.keepAliveGatt` extension function)
then it should be cancelled when no longer needed using the `cancel` or `cancelAndJoin` function.

When it a `KeepAliveGatt` is cancelled, it will end in a `Cancelled` `State`. Once a `KeepAliveGatt`
is `Cancelled` it **cannot** be reconnected (calls to `connect` will throw `IllegalStateException`);
a new `KeepAliveGatt` must be created.
existing subscribers of the `KeepAliveGatt`.

## Error Handling

When connection failures occur, the corresponding `Exception`s are propagated to `KeepAliveGatt`'s
parent [`CoroutineContext`] and can be inspected via [`CoroutineExceptionHandler`]:
parent [`CoroutineScope`] and can be inspected via [`CoroutineExceptionHandler`]:

```kotlin
val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
Expand All @@ -160,13 +157,6 @@ val scope = CoroutineScope(Job() + exceptionHandler)
val gatt = scope.keepAliveGatt(...)
```

When a failure occurs during the connection sequence, `KeepAliveGatt` will disconnect the in-flight
connection and reconnect. If a connection attempt results in `GattConnectResult.Rejected`, then the
failure is considered unrecoverable (e.g. BLE is off) and `KeepAliveGatt` will settle on
`Disconnected` `State`. Additionally, a `ConnectionRejected` Exception is propagated to the parent
[`CoroutineContext`]. The `connect` function may be used to attempt to establish connection again.


# Setup

## Gradle
Expand Down
Binary file added keep-alive/artwork/connect-failure.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added keep-alive/artwork/connect-reject.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added keep-alive/artwork/connection-drop.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
31 changes: 12 additions & 19 deletions keep-alive/src/main/java/KeepAliveGatt.kt
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,8 @@ fun CoroutineScope.keepAliveGatt(
onConnectAction = onConnectAction
)

class KeepAliveGatt(
parentCoroutineContext: CoroutineContext = EmptyCoroutineContext,
class KeepAliveGatt internal constructor(
parentCoroutineContext: CoroutineContext,
androidContext: Context,
private val bluetoothDevice: BluetoothDevice,
private val disconnectTimeoutMillis: Long,
Expand All @@ -96,11 +96,14 @@ class KeepAliveGatt(
private val applicationContext = androidContext.applicationContext

private val job = SupervisorJob(parentCoroutineContext[Job]).apply {
invokeOnCompletion { cause -> _state.value = Cancelled(cause) }
invokeOnCompletion { cause ->
_state.value = Cancelled(cause)
_onCharacteristicChanged.cancel()
}
}
private val scope = CoroutineScope(parentCoroutineContext + job)

internal val isRunning = AtomicBoolean()
private val isRunning = AtomicBoolean()

@Volatile
private var _gatt: GattIo? = null
Expand Down Expand Up @@ -148,19 +151,9 @@ class KeepAliveGatt(
job.children.forEach { it.cancelAndJoin() }
}

fun cancel() {
job.cancel()
_onCharacteristicChanged.cancel()
}

suspend fun cancelAndJoin() {
job.cancelAndJoin()
_onCharacteristicChanged.cancel()
}

private suspend fun spawnConnection() {
try {
_state.value = Connecting.also(::println)
_state.value = Connecting

val gatt = when (val result = bluetoothDevice.connectGatt(applicationContext)) {
is Success -> result.gatt
Expand All @@ -180,11 +173,11 @@ class KeepAliveGatt(
.launchIn(this, start = UNDISPATCHED)
onConnectAction?.invoke(gatt)
_gatt = gatt
_state.value = Connected.also(::println)
_state.value = Connected
}
} finally {
_gatt = null
_state.value = State.Disconnecting.also(::println)
_state.value = State.Disconnecting

withContext(NonCancellable) {
withTimeoutOrNull(disconnectTimeoutMillis) {
Expand All @@ -196,9 +189,9 @@ class KeepAliveGatt(
}
}
}
_state.value = Disconnected().also(::println)
_state.value = Disconnected()
} catch (failure: Exception) {
_state.value = Disconnected(failure).also(::println)
_state.value = Disconnected(failure)
throw failure
}
}
Expand Down
Loading

0 comments on commit fa1430e

Please sign in to comment.