Skip to content

Commit ce6ae4d

Browse files
authored
Wrap Android connect permission failures (#1167)
1 parent bf096c1 commit ce6ae4d

5 files changed

Lines changed: 157 additions & 4 deletions

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package com.juul.kable
2+
3+
import android.bluetooth.BluetoothDevice
4+
import android.bluetooth.BluetoothGattCallback
5+
import android.content.Context
6+
import android.os.Build
7+
import android.os.Handler
8+
import com.juul.kable.logs.Logging
9+
import io.mockk.Runs
10+
import io.mockk.every
11+
import io.mockk.just
12+
import io.mockk.mockk
13+
import io.mockk.verify
14+
import kotlinx.coroutines.flow.MutableSharedFlow
15+
import kotlinx.coroutines.flow.MutableStateFlow
16+
import org.junit.runner.RunWith
17+
import org.robolectric.RobolectricTestRunner
18+
import org.robolectric.RuntimeEnvironment
19+
import org.robolectric.annotation.Config
20+
import kotlin.coroutines.EmptyCoroutineContext
21+
import kotlin.test.Test
22+
import kotlin.test.assertEquals
23+
import kotlin.test.assertFailsWith
24+
import kotlin.test.assertSame
25+
import kotlin.time.Duration.Companion.seconds
26+
27+
@RunWith(RobolectricTestRunner::class)
28+
@Config(sdk = [Build.VERSION_CODES.S])
29+
class BluetoothDeviceTests {
30+
31+
@Test
32+
fun connect_securityException_wrapsInIllegalStateExceptionAndReleasesThreading() {
33+
val securityException = SecurityException("Missing BLUETOOTH_CONNECT permission")
34+
val device = mockk<BluetoothDevice> {
35+
every { address } returns "00:11:22:AA:BB:CC"
36+
every {
37+
connectGatt(
38+
any<Context>(),
39+
false,
40+
any<BluetoothGattCallback>(),
41+
any<Int>(),
42+
any<Int>(),
43+
any<Handler>(),
44+
)
45+
} throws securityException
46+
}
47+
val threading = mockk<Threading.Handler> {
48+
every { handler } returns mockk()
49+
}
50+
val threadingStrategy = mockk<ThreadingStrategy> {
51+
every { acquire() } returns threading
52+
every { release(threading) } just Runs
53+
}
54+
every { threading.strategy } returns threadingStrategy
55+
56+
val exception = assertFailsWith<IllegalStateException> {
57+
device.connect(
58+
coroutineContext = EmptyCoroutineContext,
59+
context = RuntimeEnvironment.getApplication(),
60+
autoConnect = false,
61+
transport = Transport.Auto,
62+
phy = Phy.Le1M,
63+
state = MutableStateFlow(State.Disconnected()),
64+
services = MutableStateFlow<List<PlatformDiscoveredService>?>(null),
65+
mtu = MutableStateFlow<Int?>(null),
66+
onCharacteristicChanged = MutableSharedFlow<ObservationEvent<ByteArray>>(),
67+
logging = Logging(),
68+
threadingStrategy = threadingStrategy,
69+
disconnectTimeout = 1.seconds,
70+
)
71+
}
72+
73+
assertEquals(securityException.message, exception.message)
74+
assertSame(securityException, exception.cause)
75+
verify(exactly = 1) { threadingStrategy.release(threading) }
76+
}
77+
}

kable-core/src/androidMain/kotlin/BluetoothDevice.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ internal fun BluetoothDevice.connect(
5454

5555
else -> connectGattCompat(context, autoConnect, callback, transport)
5656
} ?: throw IOException("Binder remote-invocation error")
57+
} catch (e: SecurityException) {
58+
threading.release()
59+
throw IllegalStateException(e.message, e)
5760
} catch (t: Throwable) {
5861
threading.release()
5962
throw t

kable-core/src/commonMain/kotlin/SharedRepeatableAction.awaitConnect.kt

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,18 @@ package com.juul.kable
33
import kotlinx.coroutines.CoroutineScope
44
import kotlinx.coroutines.currentCoroutineContext
55
import kotlinx.coroutines.ensureActive
6+
import kotlin.coroutines.cancellation.CancellationException
67

78
internal suspend fun SharedRepeatableAction<CoroutineScope>.awaitConnect(): CoroutineScope =
89
try {
910
await()
10-
} catch (e: IllegalStateException) {
11-
// If calling code cancels we'd unintentionally swallow the `CancellationException`, as it
12-
// is a subclass of `IllegalStateException`. `ensureActive()` honors caller cancellation.
11+
} catch (e: CancellationException) {
12+
// A disconnect cancels the shared action, while caller cancellation must remain a
13+
// CancellationException. `ensureActive()` distinguishes between the two cases.
1314
// https://github.com/JuulLabs/kable/issues/1136
1415
currentCoroutineContext().ensureActive()
1516

17+
throw IllegalStateException("Cannot connect peripheral that has been cancelled", e)
18+
} catch (e: InactiveScopeException) {
1619
throw IllegalStateException("Cannot connect peripheral that has been cancelled", e)
1720
}

kable-core/src/commonMain/kotlin/SharedRepeatableAction.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ internal fun <T> CoroutineScope.sharedRepeatableAction(
1313
action: suspend (scope: CoroutineScope) -> T,
1414
) = SharedRepeatableAction(this, action)
1515

16+
internal class InactiveScopeException : IllegalStateException("Scope is not active")
17+
1618
/**
1719
* A mechanism for launching and awaiting a shared [action][State.action] ([Deferred]) repeatedly.
1820
*
@@ -80,7 +82,7 @@ internal class SharedRepeatableAction<T>(
8082
}
8183

8284
private fun create(): State<T> {
83-
check(scope.coroutineContext.job.isActive) { "Scope is not active" }
85+
if (!scope.coroutineContext.job.isActive) throw InactiveScopeException()
8486
val root = Job(scope.coroutineContext.job)
8587
val scope = CoroutineScope(scope.coroutineContext + root)
8688
val action = scope.async { action(scope) }
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package com.juul.kable
2+
3+
import kotlinx.coroutines.CoroutineScope
4+
import kotlinx.coroutines.CoroutineStart.UNDISPATCHED
5+
import kotlinx.coroutines.SupervisorJob
6+
import kotlinx.coroutines.async
7+
import kotlinx.coroutines.awaitCancellation
8+
import kotlinx.coroutines.cancel
9+
import kotlinx.coroutines.supervisorScope
10+
import kotlinx.coroutines.test.runTest
11+
import kotlin.coroutines.cancellation.CancellationException
12+
import kotlin.test.Test
13+
import kotlin.test.assertEquals
14+
import kotlin.test.assertFailsWith
15+
16+
class SharedRepeatableActionAwaitConnectTests {
17+
18+
@Test
19+
fun illegalStateExceptionFromAction_isPreserved() = runTest {
20+
val expected = IllegalStateException("Missing BLUETOOTH_CONNECT permission")
21+
val actionScope = CoroutineScope(SupervisorJob())
22+
val action: SharedRepeatableAction<CoroutineScope> = actionScope.sharedRepeatableAction {
23+
throw expected
24+
}
25+
26+
val actual = assertFailsWith<IllegalStateException> {
27+
action.awaitConnect()
28+
}
29+
30+
assertEquals(expected.message, actual.message)
31+
actionScope.cancel()
32+
}
33+
34+
@Test
35+
fun inactiveParentScope_isReportedAsCancelledPeripheral() = runTest {
36+
val parentScope = CoroutineScope(SupervisorJob())
37+
val action = parentScope.sharedRepeatableAction { it }
38+
parentScope.cancel()
39+
40+
val exception = assertFailsWith<IllegalStateException> {
41+
action.awaitConnect()
42+
}
43+
44+
assertEquals("Cannot connect peripheral that has been cancelled", exception.message)
45+
}
46+
47+
@Test
48+
fun cancelledAction_isReportedAsCancelledPeripheral() = runTest {
49+
val actionScope = CoroutineScope(coroutineContext + SupervisorJob())
50+
val action: SharedRepeatableAction<CoroutineScope> = actionScope.sharedRepeatableAction {
51+
awaitCancellation()
52+
}
53+
54+
supervisorScope {
55+
val deferred = async(start = UNDISPATCHED) {
56+
action.awaitConnect()
57+
}
58+
action.cancelAndJoin(CancellationException("Disconnect requested"))
59+
60+
val exception = assertFailsWith<IllegalStateException> {
61+
deferred.await()
62+
}
63+
64+
assertEquals("Cannot connect peripheral that has been cancelled", exception.message)
65+
}
66+
actionScope.cancel()
67+
}
68+
}

0 commit comments

Comments
 (0)