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

Latest commit

 

History

History

keep-alive

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 
 
 

Keep-alive

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:

fun CoroutineScope.keepAliveGatt(
    androidContext: Context,
    bluetoothDevice: BluetoothDevice,
    disconnectTimeoutMillis: Long
): KeepAliveGatt
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.

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

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
    )

    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 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

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

Connection failure

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

Reconnect on 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).

Status

The status of a KeepAliveGatt can be monitored via either Events or States. The major distinction between the two is:

State: States are propagated over conflated data streams. If states are changing quickly, then some States may be missed (skipped over). For this reason, they're useful for informing a user of the current state of the connection; as missing a state is acceptable since subsequent states will overwrite the currently reflected state anyways. States should not be used if a specific condition (e.g. Connected) needs to trigger an action (use Event instead).

Event: Events allow a developer to integrate actions into the connection process. If consumers are slow to collect events, then the connection handling process pauses (suspends) until consumers are ready to collect more events.

States and Events occur in the following order:

State and event flow

Events

Events can be collected via the events Flow, for example:

val keepAliveGatt = GlobalScope.keepAliveGatt(...)

viewModelScope.launch {
    keepAliveGatt.events.collect { event ->
        event.onConnected {
            // Actions to perform on initial connect *and* subsequent reconnects:
            discoverServicesOrThrow()
        }
        event.onDisconnected {
            // todo: retry strategy (e.g. exponentially increasing delay)
        }
    }
}

For example, if is desired to retry connection if a failure occurs while setting up a connection, simply call disconnect() and KeepAliveGatt will (as usual) attempt to reconnect the lost connection:

keepAliveGatt.events.collect { event ->
    event.onConnected { // `this` is the underlying `Gatt`.
        try {
            // todo: On connect actions.
        } catch (e: Exception) {
            disconnect() // Instructs underlying Gatt to disconnect.
            // KeepAliveGatt will react by attempting another connection.
        }
    }
}

Alternatively, if you want to cancel the connection process (and settle on a Disconnect state) you can instruct the KeepAliveGatt to disconnect():

keepAliveGatt.events.collect { event ->
    event.onConnected {
        try {
            // todo: On connect actions.
        } catch (e: Exception) {
            keepAliveGatt.disconnect() // Instructs `KeepAliveGatt` to settle on a `Disconnected` state.
        }
    }
}

State

Connection state can be monitored via the state Flow property:

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

I/O

If a Gatt operation (e.g. discoverServices, writeCharacteristic, readCharacteristic, etc) is unable to be performed due to a GATT connection being unavailable (i.e. current State is not Connected), then it will immediately throw NotReadyException.

It is the responsibility of the caller to handle retrying, for example:

class GattCancelledException : Exception()

suspend fun KeepAliveGatt.readCharacteristicWithRetry(
    characteristic: BluetoothGattCharacteristic,
    retryCount: Int = Int.MAX_VALUE
): OnCharacteristicRead {
    repeat(retryCount) {
        suspendUntilConnected()
        try {
            return readCharacteristicOrThrow(characteristic)
        } catch (exception: Exception) {
            // todo: retry strategy (e.g. exponentially increasing delay)
        }
    }
    error("Failed to read characteristic $characteristic")
}

private suspend fun KeepAliveGatt.suspendUntilConnected() {
    state
        .onEach { if (it is Cancelled) throw GattCancelledException() }
        .first { it == Connected }
}

Characteristic Changes

When a KeepAliveGatt is created, it immediately provides a Flow for incoming characteristic changes (onCharacteristicChange property). The Flow is a hot stream, so characteristic change events emitted before subscribers have subscribed are dropped. To prevent characteristic change events from being lost, be sure to setup subscribers before calling KeepAliveGatt.connect, for example:

val gatt = scope.keepAliveGatt(...)

fun connect() {
    // `CoroutineStart.UNDISPATCHED` executes within `launch` up to the `collect` (then suspends),
    // before allowing continued execution of `gatt.connect()` (below).
    launch(start = CoroutineStart.UNDISPATCHED) {
        gatt.onCharacteristicChange.collect {
            println("Characteristic changed: $it")
        }
    }

    gatt.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 subscribers of the KeepAliveGatt.

Error Handling

When connection failures occur, the corresponding Exceptions are propagated to KeepAliveGatt's parent CoroutineScope and can be inspected via CoroutineExceptionHandler:

val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
    println(throwable)
}
val scope = CoroutineScope(Job() + exceptionHandler)
val gatt = scope.keepAliveGatt(...)

Setup

Gradle

Maven Central

repositories {
    jcenter() // or mavenCentral()
}

dependencies {
    implementation "com.juul.able:keep-alive:$version"
}