Skip to content

Commit 260f0cd

Browse files
authored
Add support for handling observation exceptions (#254)
1 parent e10e9a3 commit 260f0cd

12 files changed

Lines changed: 100 additions & 16 deletions

File tree

README.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,17 @@ platform characteristic will be used to to resume streaming characteristic chang
377377

378378
Failures related to notifications/indications are propagated via the [`observe`] [`Flow`], for example, if the
379379
associated characteristic is invalid or cannot be found, then a `NoSuchElementException` is propagated via the
380-
[`observe`] [`Flow`].
380+
[`observe`] [`Flow`]. An [`ObservationExceptionHandler`] may be registered with the [`Peripheral`] to control which
381+
failures are propagated through (and terminate) the [`observe`] [`Flow`], for example:
382+
383+
```kotlin
384+
scope.peripheral(advertisement) {
385+
observationExceptionHandler { cause ->
386+
// Log failure instead of propagating associated `observe` flow.
387+
println("Observation failure suppressed: $cause")
388+
}
389+
}
390+
```
381391

382392
In scenarios where an I/O operation needs to be performed upon subscribing to the [`observe`] [`Flow`], an `onSubscribe`
383393
action may be specified:

core/api/core.api

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,10 @@ public final class com/juul/kable/NotReadyException : com/juul/kable/NotConnecte
191191
public fun <init> ()V
192192
}
193193

194+
public final class com/juul/kable/ObservationExceptionPeripheral {
195+
public final fun getState ()Lkotlinx/coroutines/flow/StateFlow;
196+
}
197+
194198
public abstract interface annotation class com/juul/kable/ObsoleteKableApi : java/lang/annotation/Annotation {
195199
}
196200

@@ -219,6 +223,7 @@ public final class com/juul/kable/PeripheralBuilder {
219223
public final fun getPhy ()Lcom/juul/kable/Phy;
220224
public final fun getTransport ()Lcom/juul/kable/Transport;
221225
public final fun logging (Lkotlin/jvm/functions/Function1;)V
226+
public final fun observationExceptionHandler (Lkotlin/jvm/functions/Function3;)V
222227
public final fun onServicesDiscovered (Lkotlin/jvm/functions/Function2;)V
223228
public final fun setPhy (Lcom/juul/kable/Phy;)V
224229
public final fun setTransport (Lcom/juul/kable/Transport;)V

core/src/androidMain/kotlin/Peripheral.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ public fun CoroutineScope.peripheral(
117117
bluetoothDevice,
118118
builder.transport,
119119
builder.phy,
120+
builder.observationExceptionHandler,
120121
builder.onServicesDiscovered,
121122
builder.logging,
122123
)
@@ -129,6 +130,7 @@ public class AndroidPeripheral internal constructor(
129130
private val bluetoothDevice: BluetoothDevice,
130131
private val transport: Transport,
131132
private val phy: Phy,
133+
observationExceptionHandler: ObservationExceptionHandler,
132134
private val onServicesDiscovered: ServicesDiscoveredAction,
133135
private val logging: Logging,
134136
) : Peripheral {
@@ -167,7 +169,7 @@ public class AndroidPeripheral internal constructor(
167169
*/
168170
public val mtu: StateFlow<Int?> = _mtu.asStateFlow()
169171

170-
private val observers = Observers<ByteArray>(this, logging)
172+
private val observers = Observers<ByteArray>(this, logging, exceptionHandler = observationExceptionHandler)
171173

172174
@Volatile
173175
private var _discoveredServices: List<DiscoveredService>? = null

core/src/androidMain/kotlin/PeripheralBuilder.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@ public actual class PeripheralBuilder internal actual constructor() {
8787
onServicesDiscovered = action
8888
}
8989

90+
internal var observationExceptionHandler: ObservationExceptionHandler = { cause -> throw cause }
91+
public actual fun observationExceptionHandler(handler: ObservationExceptionHandler) {
92+
observationExceptionHandler = handler
93+
}
94+
9095
/** Preferred transport for GATT connections to remote dual-mode devices. */
9196
public var transport: Transport = Transport.Le
9297

core/src/appleMain/kotlin/Peripheral.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ public actual fun CoroutineScope.peripheral(
8181
return ApplePeripheral(
8282
coroutineContext,
8383
advertisement.cbPeripheral,
84+
builder.observationExceptionHandler,
8485
builder.onServicesDiscovered,
8586
builder.logging,
8687
)
@@ -90,6 +91,7 @@ public actual fun CoroutineScope.peripheral(
9091
public class ApplePeripheral internal constructor(
9192
parentCoroutineContext: CoroutineContext,
9293
private val cbPeripheral: CBPeripheral,
94+
observationExceptionHandler: ObservationExceptionHandler,
9395
private val onServicesDiscovered: ServicesDiscoveredAction,
9496
private val logging: Logging,
9597
) : Peripheral {
@@ -105,7 +107,7 @@ public class ApplePeripheral internal constructor(
105107
private val _state = MutableStateFlow<State>(State.Disconnected())
106108
override val state: StateFlow<State> = _state.asStateFlow()
107109

108-
private val observers = Observers<NSData>(this, logging)
110+
private val observers = Observers<NSData>(this, logging, exceptionHandler = observationExceptionHandler)
109111

110112
internal val platformIdentifier = cbPeripheral.identifier.UUIDString
111113

core/src/appleMain/kotlin/PeripheralBuilder.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,9 @@ public actual class PeripheralBuilder internal actual constructor() {
4949
public actual fun onServicesDiscovered(action: ServicesDiscoveredAction) {
5050
onServicesDiscovered = action
5151
}
52+
53+
internal var observationExceptionHandler: ObservationExceptionHandler = { cause -> throw cause }
54+
public actual fun observationExceptionHandler(handler: ObservationExceptionHandler) {
55+
observationExceptionHandler = handler
56+
}
5257
}

core/src/commonMain/kotlin/ObservationEvent.kt

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,10 @@ internal sealed class ObservationEvent<T> {
1111

1212
data class Error<T>(
1313
override val characteristic: Characteristic,
14-
val cause: Throwable,
14+
val cause: Exception,
1515
) : ObservationEvent<T>()
1616
}
1717

18-
internal fun <T> dematerialize(event: ObservationEvent<T>): T = when (event) {
19-
is ObservationEvent.Error -> throw event.cause
20-
is ObservationEvent.CharacteristicChange -> event.data
21-
}
22-
2318
internal fun <T> ObservationEvent<T>.isAssociatedWith(characteristic: Characteristic): Boolean =
2419
this.characteristic.characteristicUuid == characteristic.characteristicUuid &&
2520
this.characteristic.serviceUuid == characteristic.serviceUuid

core/src/commonMain/kotlin/Observers.kt

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@ package com.juul.kable
22

33
import co.touchlab.stately.collections.IsoMutableList
44
import co.touchlab.stately.isolate.IsolateState
5+
import com.juul.kable.ObservationEvent.CharacteristicChange
6+
import com.juul.kable.ObservationEvent.Error
57
import com.juul.kable.logs.Logging
68
import kotlinx.coroutines.flow.Flow
79
import kotlinx.coroutines.flow.MutableSharedFlow
810
import kotlinx.coroutines.flow.filter
9-
import kotlinx.coroutines.flow.map
11+
import kotlinx.coroutines.flow.mapNotNull
1012
import kotlinx.coroutines.flow.onCompletion
13+
import kotlinx.coroutines.flow.onEach
1114
import kotlinx.coroutines.flow.onSubscription
1215
import kotlin.coroutines.cancellation.CancellationException
1316

@@ -38,6 +41,7 @@ internal class Observers<T>(
3841
private val peripheral: Peripheral,
3942
private val logging: Logging,
4043
extraBufferCapacity: Int = 0,
44+
private val exceptionHandler: ObservationExceptionHandler,
4145
) {
4246

4347
val characteristicChanges = MutableSharedFlow<ObservationEvent<T>>(extraBufferCapacity = extraBufferCapacity)
@@ -60,10 +64,26 @@ internal class Observers<T>(
6064
}
6165

6266
return characteristicChanges
63-
.onSubscription { observation.onSubscription(onSubscription) }
67+
.onSubscription {
68+
try {
69+
observation.onSubscription(onSubscription)
70+
} catch (e: Exception) {
71+
exceptionHandler(ObservationExceptionPeripheral(peripheral), e)
72+
}
73+
}
6474
.filter { event -> event.isAssociatedWith(characteristic) }
65-
.map(::dematerialize)
66-
.onCompletion { observation.onCompletion(onSubscription) }
75+
.onEach { event ->
76+
if (event is Error)
77+
exceptionHandler(ObservationExceptionPeripheral(peripheral), event.cause)
78+
}
79+
.mapNotNull { event -> (event as? CharacteristicChange)?.data }
80+
.onCompletion {
81+
try {
82+
observation.onCompletion(onSubscription)
83+
} catch (e: Exception) {
84+
exceptionHandler(ObservationExceptionPeripheral(peripheral), e)
85+
}
86+
}
6787
}
6888

6989
suspend fun onConnected() {
@@ -74,7 +94,7 @@ internal class Observers<T>(
7494
} catch (cancellation: CancellationException) {
7595
throw cancellation
7696
} catch (e: Exception) {
77-
characteristicChanges.emit(ObservationEvent.Error(characteristic, e))
97+
characteristicChanges.emit(Error(characteristic, e))
7898
}
7999
}
80100
}

core/src/commonMain/kotlin/Peripheral.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,8 +176,12 @@ public interface Peripheral {
176176
* **indication** (if [characteristic] supports both notifications and indications, then only **notification** is
177177
* used).
178178
*
179-
* Failures related to notifications are propagated via the returned [observe] [Flow], for example, if the specified
179+
* Failures related to observations are propagated via the returned [observe] [Flow], for example, if the specified
180180
* [characteristic] is invalid or cannot be found then returned [Flow] terminates with a [NoSuchElementException].
181+
* An [ObservationExceptionHandler] may be registered with the [Peripheral] to control which failures are propagated
182+
* through (and terminate) the observation [Flow]. When registered, only exceptions thrown from the
183+
* [ObservationExceptionHandler] are propagated (and terminate) the returned observation [Flow]. See
184+
* [PeripheralBuilder.observationExceptionHandler] for more details.
181185
*
182186
* The optional [onSubscription] parameter is functionally identical to using the
183187
* [onSubscription][kotlinx.coroutines.flow.onSubscription] operator on the returned [Flow] except it has the

core/src/commonMain/kotlin/PeripheralBuilder.kt

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.juul.kable
22

33
import com.juul.kable.logs.LoggingBuilder
4+
import kotlinx.coroutines.flow.StateFlow
45

56
public expect class ServicesDiscoveredPeripheral {
67

@@ -24,9 +25,37 @@ public expect class ServicesDiscoveredPeripheral {
2425
): Unit
2526
}
2627

28+
public class ObservationExceptionPeripheral internal constructor(peripheral: Peripheral) {
29+
public val state: StateFlow<State> = peripheral.state
30+
}
31+
2732
internal typealias ServicesDiscoveredAction = suspend ServicesDiscoveredPeripheral.() -> Unit
33+
internal typealias ObservationExceptionHandler = suspend ObservationExceptionPeripheral.(cause: Exception) -> Unit
2834

2935
public expect class PeripheralBuilder internal constructor() {
3036
public fun logging(init: LoggingBuilder)
3137
public fun onServicesDiscovered(action: ServicesDiscoveredAction)
38+
39+
/**
40+
* Registers an [ObservationExceptionHandler] for the [Peripheral]. When registered, observation failures are
41+
* passed to the [ObservationExceptionHandler] instead of through [observation][Peripheral.observe] flows. Any
42+
* exceptions in the [ObservationExceptionHandler] will propagate through (and terminate) the associated
43+
* [observation][Peripheral.observe] flow.
44+
*
45+
* Some failures are due to connection drops before the connection state has propagated from the system, the
46+
* [ObservationExceptionHandler] can be useful for ignoring failures that precursor a connection drop:
47+
*
48+
* ```
49+
* scope.peripheral(advertisement) {
50+
* observationExceptionHandler { cause ->
51+
* // Only propagate failure if we don't see a disconnect within a second.
52+
* withTimeoutOrNull(1_000L) {
53+
* state.first { it is Disconnected }
54+
* } ?: throw IllegalStateException("Observation failure occurred.", cause)
55+
* println("Ignored failure associated with disconnect: $cause")
56+
* }
57+
* }
58+
* ```
59+
*/
60+
public fun observationExceptionHandler(handler: ObservationExceptionHandler)
3261
}

0 commit comments

Comments
 (0)