Skip to content

Commit

Permalink
Expose connection scope on State.Connected
Browse files Browse the repository at this point in the history
  • Loading branch information
twyatt committed Feb 5, 2025
1 parent 213aed5 commit 257a942
Show file tree
Hide file tree
Showing 10 changed files with 73 additions and 36 deletions.
16 changes: 11 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,7 @@ val peripheral = Peripheral {
autoConnectIf { autoConnect.value }
}

while (peripheral.state.value != Connected) {
while (peripheral.state.value !is Connected) {
autoConnect.value = try {
peripheral.connect()
false
Expand Down Expand Up @@ -383,15 +383,21 @@ connected, services have been discovered, and observations (if any) have been re
automatically upon connection._

> [!TIP]
> _Multiple concurrent calls to [`connect`] will all suspend until connection is ready._
> Multiple concurrent calls to [`connect`] will all suspend until connection is ready.
```kotlin
peripheral.connect()
```

The [`connect`] function returns a [`CoroutineScope`] that can be used to `launch` tasks that should run until peripheral
disconnects. When [`disconnect`] is called, any coroutines [`launch`]ed from the [`CoroutineScope`] returned by [`connect`]
will be cancelled prior to performing the underlying disconnect process.
> [!TIP]
> The [`connect`] function returns a [`CoroutineScope`] that can be used to `launch` tasks that
> should run until peripheral disconnects. When [`disconnect`] is called, any coroutines
> `launch`ed from the [`CoroutineScope`] returned by [`connect`] will be cancelled prior to
> performing the underlying disconnect process.
> [!TIP]
> The connection [`CoroutineScope`] is also available as the `scope` property on the [`Connected`]
> [`State`][connection-state].
To disconnect, the [`disconnect`] function will disconnect an active connection, or cancel an in-flight connection
attempt. The [`disconnect`] function suspends until the peripheral has settled on a disconnected state.
Expand Down
9 changes: 8 additions & 1 deletion kable-core/api/android/kable-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -449,7 +449,14 @@ public abstract class com/juul/kable/State {
}

public final class com/juul/kable/State$Connected : com/juul/kable/State {
public static final field INSTANCE Lcom/juul/kable/State$Connected;
public fun <init> (Lkotlinx/coroutines/CoroutineScope;)V
public final fun component1 ()Lkotlinx/coroutines/CoroutineScope;
public final fun copy (Lkotlinx/coroutines/CoroutineScope;)Lcom/juul/kable/State$Connected;
public static synthetic fun copy$default (Lcom/juul/kable/State$Connected;Lkotlinx/coroutines/CoroutineScope;ILjava/lang/Object;)Lcom/juul/kable/State$Connected;
public fun equals (Ljava/lang/Object;)Z
public final fun getScope ()Lkotlinx/coroutines/CoroutineScope;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}

public abstract class com/juul/kable/State$Connecting : com/juul/kable/State {
Expand Down
9 changes: 8 additions & 1 deletion kable-core/api/jvm/kable-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,14 @@ public abstract class com/juul/kable/State {
}

public final class com/juul/kable/State$Connected : com/juul/kable/State {
public static final field INSTANCE Lcom/juul/kable/State$Connected;
public fun <init> (Lkotlinx/coroutines/CoroutineScope;)V
public final fun component1 ()Lkotlinx/coroutines/CoroutineScope;
public final fun copy (Lkotlinx/coroutines/CoroutineScope;)Lcom/juul/kable/State$Connected;
public static synthetic fun copy$default (Lcom/juul/kable/State$Connected;Lkotlinx/coroutines/CoroutineScope;ILjava/lang/Object;)Lcom/juul/kable/State$Connected;
public fun equals (Ljava/lang/Object;)Z
public final fun getScope ()Lkotlinx/coroutines/CoroutineScope;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}

public abstract class com/juul/kable/State$Connecting : com/juul/kable/State {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,10 +136,11 @@ internal class BluetoothDeviceAndroidPeripheral(
throw failure
}

val connectionScope = connectionOrThrow().taskScope
logger.info { message = "Connected" }
_state.value = State.Connected
_state.value = State.Connected(connectionScope)

return connectionOrThrow().taskScope
return connectionScope
}

private suspend fun configureCharacteristicObservations() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,10 +126,11 @@ internal class CBPeripheralCoreBluetoothPeripheral(
throw failure
}

val connectionScope = connectionOrThrow().taskScope
logger.info { message = "Connected" }
_state.value = State.Connected
_state.value = State.Connected(connectionScope)

return connectionOrThrow().taskScope
return connectionScope
}

private suspend fun configureCharacteristicObservations() {
Expand Down
15 changes: 8 additions & 7 deletions kable-core/src/commonMain/kotlin/Peripheral.kt
Original file line number Diff line number Diff line change
Expand Up @@ -124,14 +124,14 @@ public interface Peripheral : AutoCloseable {
* returns immediately.
*
* The returned [CoroutineScope] can be used to launch coroutines, and is cancelled upon
* disconnect or [Peripheral] [cancellation][Peripheral.cancel]. The [CoroutineScope] is a
* supervisor scope, meaning any failures in launched coroutines will not fail other launched
* coroutines nor cause a disconnect.
* [disconnect] or [closure][Peripheral.close]. The [CoroutineScope] is a supervisor scope,
* meaning any failures in launched coroutines will not fail other launched coroutines nor cause
* a disconnect.
*
* @throws IllegalStateException when a connection request could not be made (e.g. bluetooth not supported).
* @throws NotConnectedException if unable to establish connection (e.g. connection lost while discovering services).
* @throws IOException (Android) if request failed due to Binder remote-invocation error.
* @throws CancellationException if [Peripheral]'s [CoroutineScope] has been [cancelled][Peripheral.cancel].
* @throws CancellationException if [Peripheral]'s [CoroutineScope] has been [closed][Peripheral.close].
*/
public suspend fun connect(): CoroutineScope

Expand All @@ -141,10 +141,11 @@ public interface Peripheral : AutoCloseable {
*
* Multiple concurrent invocations will all suspend until disconnected (or failure occurs).
*
* Any coroutines launched from [connect] will be spun down prior to closing underlying
* peripheral connection.
* Any coroutines launched from connection [scope][CoroutineScope] (i.e. [CoroutineScope]
* returned by [connect] or [State.Connected.scope]) will be spun down prior to closing
* underlying peripheral connection.
*
* @throws CancellationException if [Peripheral]'s [CoroutineScope] has been [cancelled][Peripheral.cancel].
* @throws CancellationException if [Peripheral]'s [CoroutineScope] has been [closed][Peripheral.close].
*/
public suspend fun disconnect(): Unit

Expand Down
18 changes: 15 additions & 3 deletions kable-core/src/commonMain/kotlin/State.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.juul.kable

import kotlinx.coroutines.CoroutineScope

public sealed class State {

public sealed class Connecting : State() {
Expand Down Expand Up @@ -30,7 +32,17 @@ public sealed class State {
/**
* [Peripheral] is ready (i.e. has connected, discovered services and wired up [observers][Peripheral.observe]).
*/
public object Connected : State()
public data class Connected(

/**
* Connection [scope][CoroutineScope] that can be used to launch coroutines, and is
* cancelled upon connection loss, [disconnect][Peripheral.disconnect] or
* [closure][Peripheral.close]. The [CoroutineScope] is a supervisor scope, meaning any
* failures in launched coroutines will not fail other launched coroutines nor cause a
* disconnect.
*/
val scope: CoroutineScope,
) : State()

public object Disconnecting : State()

Expand Down Expand Up @@ -130,7 +142,7 @@ public sealed class State {
}

override fun toString(): String = when (this) {
Connected -> "Connected"
is Connected -> "Connected"
Connecting.Bluetooth -> "Connecting.Bluetooth"
Connecting.Observes -> "Connecting.Observes"
Connecting.Services -> "Connecting.Services"
Expand All @@ -156,7 +168,7 @@ internal inline fun <reified T : State> State.isAtLeast(): Boolean {
State.Connecting.Bluetooth -> 2
State.Connecting.Services -> 3
State.Connecting.Observes -> 4
State.Connected -> 5
is State.Connected -> 5
}
val targetState = when (T::class) {
State.Disconnected::class -> 0
Expand Down
14 changes: 7 additions & 7 deletions kable-core/src/commonTest/kotlin/ObservationTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ class ObservationTest {

@Test
fun manySubscribers_startsObservationOnce() = runTest {
val state = MutableStateFlow<State>(Connected)
val state = MutableStateFlow<State>(Connected(this))
val characteristic = generateCharacteristic()
val counter = ObservationCounter(characteristic)
val observation = Observation(state, counter, characteristic, logging, identifier = "test")
Expand All @@ -66,7 +66,7 @@ class ObservationTest {
val observation = Observation(state, counter, characteristic, logging, identifier = "test")
val onSubscriptionActions = List(10) { suspend { } }

state.value = Connected
state.value = Connected(this)
onSubscriptionActions.forEach { action ->
observation.onSubscription(action)
}
Expand All @@ -86,7 +86,7 @@ class ObservationTest {

@Test
fun subscribersGoesToZero_whileDisconnected_doesNotStopObservation() = runTest {
val state = MutableStateFlow<State>(Connected)
val state = MutableStateFlow<State>(Connected(this))
val characteristic = generateCharacteristic()
val counter = ObservationCounter(characteristic)
val observation = Observation(state, counter, characteristic, logging, identifier = "test")
Expand All @@ -112,7 +112,7 @@ class ObservationTest {

@Test
fun hasSubscribers_reconnects_reObservesOnce() = runTest {
val state = MutableStateFlow<State>(Connected)
val state = MutableStateFlow<State>(Connected(this))
val characteristic = generateCharacteristic()
val counter = ObservationCounter(characteristic)
val observation = Observation(state, counter, characteristic, logging, identifier = "test")
Expand Down Expand Up @@ -160,7 +160,7 @@ class ObservationTest {
repeat(5) {
observation.onSubscription { }
}
state.value = Connected
state.value = Connected(this)
repeat(5) {
observation.onSubscription { }
}
Expand Down Expand Up @@ -311,7 +311,7 @@ class ObservationTest {

@Test
fun failureDuringStopObservation_propagates() = runTest {
val state = MutableStateFlow<State>(Connected)
val state = MutableStateFlow<State>(Connected(this))
val characteristic = generateCharacteristic()
val handler = object : Observation.Handler {
override suspend fun startObservation(characteristic: Characteristic) {}
Expand All @@ -332,7 +332,7 @@ class ObservationTest {

@Test
fun failureInSubscriptionAction_propagates() = runTest {
val state = MutableStateFlow<State>(Connected)
val state = MutableStateFlow<State>(Connected(this))
val characteristic = generateCharacteristic()
val counter = ObservationCounter(characteristic)
val observation = Observation(state, counter, characteristic, logging, identifier = "test")
Expand Down
13 changes: 7 additions & 6 deletions kable-core/src/commonTest/kotlin/StateIsAtLeastTests.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.juul.kable

import kotlinx.coroutines.GlobalScope
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
Expand Down Expand Up @@ -158,31 +159,31 @@ public class StateIsAtLeastTests {

@Test
fun connected_isAtLeast_disconnected_is_true() {
assertTrue(State.Connected.isAtLeast<State.Disconnected>())
assertTrue(State.Connected(GlobalScope).isAtLeast<State.Disconnected>())
}

@Test
fun connected_isAtLeast_disconnecting_is_true() {
assertTrue(State.Connected.isAtLeast<State.Disconnecting>())
assertTrue(State.Connected(GlobalScope).isAtLeast<State.Disconnecting>())
}

@Test
fun connected_isAtLeast_connectingBluetooth_is_true() {
assertTrue(State.Connected.isAtLeast<State.Connecting.Bluetooth>())
assertTrue(State.Connected(GlobalScope).isAtLeast<State.Connecting.Bluetooth>())
}

@Test
fun connected_isAtLeast_connectingServices_is_true() {
assertTrue(State.Connected.isAtLeast<State.Connecting.Services>())
assertTrue(State.Connected(GlobalScope).isAtLeast<State.Connecting.Services>())
}

@Test
fun connected_isAtLeast_connectingObserves_is_true() {
assertTrue(State.Connected.isAtLeast<State.Connecting.Observes>())
assertTrue(State.Connected(GlobalScope).isAtLeast<State.Connecting.Observes>())
}

@Test
fun connected_isAtLeast_connected_is_true() {
assertTrue(State.Connected.isAtLeast<State.Connected>())
assertTrue(State.Connected(GlobalScope).isAtLeast<State.Connected>())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,11 @@ internal class BluetoothDeviceWebBluetoothPeripheral(
throw failure
}

val connectionScope = connectionOrThrow().taskScope
logger.info { message = "Connected" }
_state.value = State.Connected
_state.value = State.Connected(connectionScope)

return connectionOrThrow().taskScope
return connectionScope
}

private suspend fun discoverServices() {
Expand Down

0 comments on commit 257a942

Please sign in to comment.