Skip to content

Commit dfe2496

Browse files
committed
Change ScopeProvider.coroutineScope throwing behavior when accessing it before scope is active, or after scope is inactive.
1 parent d20a015 commit dfe2496

File tree

4 files changed

+165
-21
lines changed

4 files changed

+165
-21
lines changed

libraries/rib-coroutines/build.gradle.kts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,22 +13,23 @@
1313
* See the License for the specific language governing permissions and
1414
* limitations under the License.
1515
*/
16-
1716
plugins {
1817
id("ribs.kotlin.library")
1918
alias(libs.plugins.maven.publish)
2019
}
2120

2221
dependencies {
23-
2422
api(libs.autodispose.coroutines)
2523
api(libs.kotlinx.coroutines.android)
2624
api(libs.kotlinx.coroutines.rx2)
2725

2826
compileOnly(libs.android.api)
2927

28+
implementation(libs.autodispose.lifecycle)
29+
3030
testImplementation(project(":libraries:rib-base"))
3131
testImplementation(project(":libraries:rib-test"))
32+
testImplementation(project(":libraries:rib-coroutines-test"))
3233
testImplementation(testLibs.junit)
3334
testImplementation(testLibs.mockito)
3435
testImplementation(testLibs.mockito.kotlin)

libraries/rib-coroutines/src/main/kotlin/com/uber/rib/core/RibCoroutineScopes.kt

Lines changed: 56 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,35 +17,48 @@ package com.uber.rib.core
1717

1818
import android.app.Application
1919
import com.uber.autodispose.ScopeProvider
20-
import com.uber.autodispose.coroutinesinterop.asCoroutineScope
20+
import com.uber.autodispose.lifecycle.LifecycleEndedException
2121
import com.uber.rib.core.internal.CoroutinesFriendModuleApi
22+
import io.reactivex.CompletableObserver
23+
import io.reactivex.disposables.Disposable
2224
import java.util.WeakHashMap
2325
import kotlin.coroutines.CoroutineContext
2426
import kotlin.coroutines.EmptyCoroutineContext
2527
import kotlin.reflect.KProperty
2628
import kotlinx.coroutines.CoroutineName
2729
import kotlinx.coroutines.CoroutineScope
2830
import kotlinx.coroutines.SupervisorJob
31+
import kotlinx.coroutines.cancel
2932
import kotlinx.coroutines.job
3033

3134
/**
3235
* [CoroutineScope] tied to this [ScopeProvider]. This scope will be canceled when ScopeProvider is
3336
* completed
3437
*
3538
* This scope is bound to
36-
* [RibDispatchers.Main.immediate][kotlinx.coroutines.MainCoroutineDispatcher.immediate]
39+
* [RibDispatchers.Main.immediate][kotlinx.coroutines.MainCoroutineDispatcher.immediate].
40+
*
41+
* Calling this property outside of the lifecycle of the [ScopeProvider] will throw
42+
* [OutsideScopeException][com.uber.autodispose.OutsideScopeException]. By setting
43+
* [RibCoroutinesConfig.shouldCoroutineScopeFailSilentlyOnLifecycleEnded] to `true`, accessing this
44+
* property after the [ScopeProvider] has completed will instead return a [CoroutineScope] that is
45+
* immediately cancelled.
3746
*/
3847
@OptIn(CoroutinesFriendModuleApi::class)
39-
public val ScopeProvider.coroutineScope: CoroutineScope by
40-
LazyCoroutineScope<ScopeProvider> {
41-
val context: CoroutineContext =
42-
SupervisorJob() +
43-
RibDispatchers.Main.immediate +
44-
CoroutineName("${this::class.simpleName}:coroutineScope") +
45-
(RibCoroutinesConfig.exceptionHandler ?: EmptyCoroutineContext)
46-
47-
asCoroutineScope(context)
48+
public val ScopeProvider.coroutineScope: CoroutineScope by LazyCoroutineScope {
49+
val context = createCoroutineContext()
50+
try {
51+
ScopeProviderCoroutineScope(this, context)
52+
} catch (e: LifecycleEndedException) {
53+
if (RibCoroutinesConfig.shouldCoroutineScopeFailSilentlyOnLifecycleEnded) {
54+
CoroutineScope(context).also {
55+
it.cancel("ScopeProvider is outside of scope. context = $context", e)
56+
}
57+
} else {
58+
throw e
59+
}
4860
}
61+
}
4962

5063
/**
5164
* [CoroutineScope] tied to this [Application]. This scope will not be cancelled, it lives for the
@@ -55,16 +68,40 @@ public val ScopeProvider.coroutineScope: CoroutineScope by
5568
* [RibDispatchers.Main.immediate][kotlinx.coroutines.MainCoroutineDispatcher.immediate]
5669
*/
5770
@OptIn(CoroutinesFriendModuleApi::class)
58-
public val Application.coroutineScope: CoroutineScope by
59-
LazyCoroutineScope<Application> {
60-
val context: CoroutineContext =
61-
SupervisorJob() +
62-
RibDispatchers.Main.immediate +
63-
CoroutineName("${this::class.simpleName}:coroutineScope") +
64-
(RibCoroutinesConfig.exceptionHandler ?: EmptyCoroutineContext)
71+
public val Application.coroutineScope: CoroutineScope by LazyCoroutineScope {
72+
CoroutineScope(createCoroutineContext())
73+
}
74+
75+
private fun Any.createCoroutineContext() =
76+
SupervisorJob() +
77+
RibDispatchers.Main.immediate +
78+
CoroutineName("${this::class.simpleName}:coroutineScope") +
79+
(RibCoroutinesConfig.exceptionHandler ?: EmptyCoroutineContext)
6580

66-
CoroutineScope(context)
81+
private class ScopeProviderCoroutineScope(
82+
scopeProvider: ScopeProvider,
83+
coroutineContext: CoroutineContext,
84+
) :
85+
ScopeProvider by scopeProvider,
86+
CoroutineScope by CoroutineScope(coroutineContext),
87+
CompletableObserver {
88+
89+
init {
90+
requestScope().subscribe(this)
91+
}
92+
93+
override fun onSubscribe(d: Disposable) {
94+
coroutineContext.job.invokeOnCompletion { d.dispose() }
95+
}
96+
97+
override fun onComplete() {
98+
cancel()
99+
}
100+
101+
override fun onError(e: Throwable) {
102+
cancel("ScopeProvider completed with error", e)
67103
}
104+
}
68105

69106
@CoroutinesFriendModuleApi
70107
public class LazyCoroutineScope<This : Any>(private val initializer: This.() -> CoroutineScope) {

libraries/rib-coroutines/src/main/kotlin/com/uber/rib/core/RibCoroutinesConfig.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@ public object RibCoroutinesConfig {
3838
*/
3939
@JvmStatic public var exceptionHandler: CoroutineExceptionHandler? = null
4040

41+
/**
42+
* When set, the `coroutineScope` extension property will fail silently (i.e. not throw) when
43+
* accessed after the scope has completed.
44+
*
45+
* Defaults to `false`.
46+
*/
47+
@JvmStatic public var shouldCoroutineScopeFailSilentlyOnLifecycleEnded: Boolean = false
48+
4149
/**
4250
* Specify the [CoroutineDispatcher] to be used while binding a [com.uber.rib.Worker] via
4351
* [WorkerBinder]
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/*
2+
* Copyright (C) 2024. Uber Technologies
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.uber.rib.core
17+
18+
import com.google.common.truth.Truth.assertThat
19+
import com.uber.autodispose.lifecycle.LifecycleEndedException
20+
import com.uber.autodispose.lifecycle.LifecycleNotStartedException
21+
import kotlin.contracts.ExperimentalContracts
22+
import kotlin.contracts.InvocationKind
23+
import kotlin.contracts.contract
24+
import kotlinx.coroutines.ExperimentalCoroutinesApi
25+
import kotlinx.coroutines.Job
26+
import kotlinx.coroutines.awaitCancellation
27+
import kotlinx.coroutines.isActive
28+
import kotlinx.coroutines.launch
29+
import kotlinx.coroutines.test.runCurrent
30+
import kotlinx.coroutines.test.runTest
31+
import org.junit.Assert.assertThrows
32+
import org.junit.Before
33+
import org.junit.Rule
34+
import org.junit.Test
35+
import org.junit.runner.RunWith
36+
import org.junit.runners.Parameterized
37+
import org.mockito.kotlin.mock
38+
39+
@OptIn(ExperimentalCoroutinesApi::class)
40+
@RunWith(Parameterized::class)
41+
class RibCoroutineScopesTest(private val failSilentlyOnLifecycleEnded: Boolean) {
42+
@get:Rule val ribCoroutinesRule = RibCoroutinesRule()
43+
private val interactor = TestInteractor()
44+
45+
@Before
46+
fun setUp() {
47+
RibCoroutinesConfig.shouldCoroutineScopeFailSilentlyOnLifecycleEnded =
48+
failSilentlyOnLifecycleEnded
49+
}
50+
51+
@Test
52+
fun coroutineScope_whenCalledBeforeActive_throws() {
53+
assertThrows(LifecycleNotStartedException::class.java) { interactor.coroutineScope }
54+
}
55+
56+
@Test
57+
fun coroutineScope_whenCalledAfterInactive_throws() {
58+
interactor.attachAndDetach {}
59+
if (failSilentlyOnLifecycleEnded) {
60+
assertThat(interactor.coroutineScope.isActive).isFalse()
61+
} else {
62+
assertThrows(LifecycleEndedException::class.java) { interactor.coroutineScope }
63+
}
64+
}
65+
66+
@Test
67+
fun coroutineScope_whenCalledWhileActive_cancelsWhenInactive() = runTest {
68+
var launched = false
69+
val job: Job
70+
interactor.attachAndDetach {
71+
job =
72+
coroutineScope.launch {
73+
launched = true
74+
awaitCancellation()
75+
}
76+
runCurrent()
77+
assertThat(launched).isTrue()
78+
assertThat(job.isActive).isTrue()
79+
}
80+
assertThat(job.isCancelled).isTrue()
81+
}
82+
83+
companion object {
84+
@JvmStatic
85+
@Parameterized.Parameters(name = "failSilentlyOnLifecycleEnded = {0}")
86+
fun data() = listOf(arrayOf(true), arrayOf(false))
87+
}
88+
}
89+
90+
private class TestInteractor : Interactor<Unit, Router<*>>()
91+
92+
@OptIn(ExperimentalContracts::class)
93+
private inline fun TestInteractor.attachAndDetach(block: TestInteractor.() -> Unit) {
94+
contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) }
95+
InteractorHelper.attach(this, Unit, mock(), null)
96+
block()
97+
InteractorHelper.detach(this)
98+
}

0 commit comments

Comments
 (0)