diff --git a/android/conventions/src/main/kotlin/ribs.spotless-convention.gradle.kts b/android/conventions/src/main/kotlin/ribs.spotless-convention.gradle.kts index bf1fbe417..f231d2864 100644 --- a/android/conventions/src/main/kotlin/ribs.spotless-convention.gradle.kts +++ b/android/conventions/src/main/kotlin/ribs.spotless-convention.gradle.kts @@ -26,6 +26,7 @@ configure { endWithNewline() } kotlin { + toggleOffOn() target("**/*.kt") ktlint(libs.versions.ktlint.get()).editorConfigOverride( mapOf( diff --git a/android/libraries/rib-android/src/main/kotlin/com/uber/rib/core/RibActivity.kt b/android/libraries/rib-android/src/main/kotlin/com/uber/rib/core/RibActivity.kt index 04400d736..5d67ce6b1 100644 --- a/android/libraries/rib-android/src/main/kotlin/com/uber/rib/core/RibActivity.kt +++ b/android/libraries/rib-android/src/main/kotlin/com/uber/rib/core/RibActivity.kt @@ -60,7 +60,7 @@ abstract class RibActivity : @Volatile private var _lifecycleObservable: Observable? = null private val lifecycleObservable - get() = ::_lifecycleObservable.setIfNullAndGet { lifecycleFlow.asObservable() } + get() = ::_lifecycleObservable.setIfNullAndGet { lifecycleFlow.asObservable(DirectDispatcher) } private val _callbacksFlow = MutableSharedFlow(0, 1, BufferOverflow.DROP_OLDEST) @@ -69,7 +69,7 @@ abstract class RibActivity : @Volatile private var _callbacksObservable: Observable? = null private val callbacksObservable - get() = ::_callbacksObservable.setIfNullAndGet { callbacksFlow.asObservable() } + get() = ::_callbacksObservable.setIfNullAndGet { callbacksFlow.asObservable(DirectDispatcher) } /** @return an observable of this activity's lifecycle events. */ final override fun lifecycle(): Observable = lifecycleObservable diff --git a/android/libraries/rib-base/src/main/kotlin/com/uber/rib/core/FlowAsScope.kt b/android/libraries/rib-base/src/main/kotlin/com/uber/rib/core/FlowAsScope.kt index 546a6ebe5..8afa58692 100644 --- a/android/libraries/rib-base/src/main/kotlin/com/uber/rib/core/FlowAsScope.kt +++ b/android/libraries/rib-base/src/main/kotlin/com/uber/rib/core/FlowAsScope.kt @@ -14,6 +14,7 @@ * limitations under the License. */ @file:JvmSynthetic +@file:Suppress("invisible_reference", "invisible_member") package com.uber.rib.core @@ -41,7 +42,7 @@ internal fun > SharedFlow.asScopeCompletable( context: CoroutineContext = EmptyCoroutineContext, ): CompletableSource { ensureAlive(range) - return rxCompletable(RibDispatchers.Unconfined + context) { + return rxCompletable(DirectDispatcher + context) { takeWhile { it < range.endInclusive }.collect() } } diff --git a/android/libraries/rib-base/src/main/kotlin/com/uber/rib/core/Interactor.kt b/android/libraries/rib-base/src/main/kotlin/com/uber/rib/core/Interactor.kt index a705fbd2a..d9cc1ad10 100644 --- a/android/libraries/rib-base/src/main/kotlin/com/uber/rib/core/Interactor.kt +++ b/android/libraries/rib-base/src/main/kotlin/com/uber/rib/core/Interactor.kt @@ -13,6 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +@file:Suppress("invisible_reference", "invisible_member") + package com.uber.rib.core import androidx.annotation.CallSuper @@ -46,7 +48,7 @@ public abstract class Interactor

>() : InteractorType, Rib @Volatile private var _lifecycleObservable: Observable? = null private val lifecycleObservable - get() = ::_lifecycleObservable.setIfNullAndGet { lifecycleFlow.asObservable() } + get() = ::_lifecycleObservable.setIfNullAndGet { lifecycleFlow.asObservable(DirectDispatcher) } private val routerDelegate = InitOnceProperty() diff --git a/android/libraries/rib-base/src/main/kotlin/com/uber/rib/core/Presenter.kt b/android/libraries/rib-base/src/main/kotlin/com/uber/rib/core/Presenter.kt index d1f74821f..0131d5705 100644 --- a/android/libraries/rib-base/src/main/kotlin/com/uber/rib/core/Presenter.kt +++ b/android/libraries/rib-base/src/main/kotlin/com/uber/rib/core/Presenter.kt @@ -40,7 +40,7 @@ public abstract class Presenter : ScopeProvider, RibActionEmitter { @Volatile private var _lifecycleObservable: Observable? = null private val lifecycleObservable - get() = ::_lifecycleObservable.setIfNullAndGet { lifecycleFlow.asObservable() } + get() = ::_lifecycleObservable.setIfNullAndGet { lifecycleFlow.asObservable(DirectDispatcher) } /** @return `true` if the presenter is loaded, `false` if not. */ protected var isLoaded: Boolean = false diff --git a/android/libraries/rib-base/src/test/kotlin/com/uber/rib/core/InteractorTest.kt b/android/libraries/rib-base/src/test/kotlin/com/uber/rib/core/InteractorTest.kt new file mode 100644 index 000000000..bc1f6b420 --- /dev/null +++ b/android/libraries/rib-base/src/test/kotlin/com/uber/rib/core/InteractorTest.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2023. Uber Technologies + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.uber.rib.core + +import com.uber.rib.core.lifecycle.InteractorEvent +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class InteractorTest { + @Test + fun assertInteractorEventEagerness() = runTest { + assertLifecycleEventEagerness(InteractorEvent.ACTIVE) { + val interactor = object : Interactor>() {} + interactor.apply { + setPresenter(Unit) + dispatchAttach(null) + } + } + } +} diff --git a/android/libraries/rib-base/src/test/kotlin/com/uber/rib/core/LifecycleEventEagerness.kt b/android/libraries/rib-base/src/test/kotlin/com/uber/rib/core/LifecycleEventEagerness.kt new file mode 100644 index 000000000..8ccac22a4 --- /dev/null +++ b/android/libraries/rib-base/src/test/kotlin/com/uber/rib/core/LifecycleEventEagerness.kt @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2023. Uber Technologies + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.uber.rib.core + +import com.google.common.truth.Truth.assertThat +import com.uber.autodispose.lifecycle.LifecycleScopeProvider +import io.reactivex.disposables.Disposable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle + +@OptIn(ExperimentalCoroutinesApi::class) +internal inline fun TestScope.assertLifecycleEventEagerness( + event: T, + crossinline lifecycleScopeProviderProducer: () -> LifecycleScopeProvider, +) { + val events = mutableListOf>() + var disposable: Disposable? = null + launch(Dispatchers.Unconfined) { + disposable = + lifecycleScopeProviderProducer().lifecycle().subscribe { lifecycleEvent -> + events.add(Event.Lifecycle(lifecycleEvent)) + } + events.add(Event.AfterSubscription) + } + advanceUntilIdle() + assertThat(events).isEqualTo(listOf(Event.Lifecycle(event), Event.AfterSubscription)) + disposable?.dispose() +} + +@PublishedApi +internal sealed interface Event { + data class Lifecycle(val event: T) : Event + object AfterSubscription : Event +} diff --git a/android/libraries/rib-coroutines/src/main/kotlin/com/uber/rib/core/DirectDispatcher.kt b/android/libraries/rib-coroutines/src/main/kotlin/com/uber/rib/core/DirectDispatcher.kt new file mode 100644 index 000000000..8459526cf --- /dev/null +++ b/android/libraries/rib-coroutines/src/main/kotlin/com/uber/rib/core/DirectDispatcher.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2023. Uber Technologies + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.uber.rib.core + +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Runnable + +/** + * A dispatcher that immediately executes the [Runnable] on the same stack frame, without + * potentially forming event loops like [Unconfined][kotlinx.coroutines.Dispatchers.Unconfined] or + * [Main.immediate][kotlinx.coroutines.MainCoroutineDispatcher.immediate] in case of nested + * coroutines. + * + * For more context, see the following issues on `kotlinx.coroutines` GitHub repository: + * + * 1. [Immediate dispatchers can cause spooky action at a distance](https://github.com/Kotlin/kotlinx.coroutines/issues/3760) + * 2. [Chaining rxSingle calls that use Unconfined dispatcher and blockingGet results in deadlock #3458](https://github.com/Kotlin/kotlinx.coroutines/issues/3458) + * 3. [Coroutines/Flow vs add/remove listener (synchronous execution) #3506](https://github.com/Kotlin/kotlinx.coroutines/issues/3506) + * + */ +internal object DirectDispatcher : CoroutineDispatcher() { + override fun dispatch(context: CoroutineContext, block: Runnable) { + block.run() + } +} diff --git a/android/libraries/rib-router-navigator/src/main/kotlin/com/uber/rib/core/RouterNavigatorEvents.kt b/android/libraries/rib-router-navigator/src/main/kotlin/com/uber/rib/core/RouterNavigatorEvents.kt index 0791a5b01..ae2d1b4b5 100644 --- a/android/libraries/rib-router-navigator/src/main/kotlin/com/uber/rib/core/RouterNavigatorEvents.kt +++ b/android/libraries/rib-router-navigator/src/main/kotlin/com/uber/rib/core/RouterNavigatorEvents.kt @@ -13,6 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +@file:Suppress("invisible_reference", "invisible_member") + package com.uber.rib.core import io.reactivex.Observable @@ -26,7 +28,7 @@ public class RouterNavigatorEvents private constructor() { private val _events = MutableSharedFlow(0, 1, BufferOverflow.DROP_OLDEST) /** @return the stream which can be subcribed to listen for [RouterNavigatorEvent] */ - public val events: Observable = _events.asObservable() + public val events: Observable = _events.asObservable(DirectDispatcher) @JvmSynthetic // Hide from Java consumers. In Java, `getEvents` resolves to the `events` property. @JvmName("_getEvents") diff --git a/android/libraries/rib-screen-stack-base/build.gradle b/android/libraries/rib-screen-stack-base/build.gradle index 4641140e9..35a8e0200 100644 --- a/android/libraries/rib-screen-stack-base/build.gradle +++ b/android/libraries/rib-screen-stack-base/build.gradle @@ -27,6 +27,7 @@ dependencies { api(libs.rxjava2) api(libs.rxrelay2) api(libs.rxbinding) + implementation(project(":libraries:rib-coroutines")) implementation(libs.annotation) implementation(libs.autodispose.coroutines) implementation(libs.coroutines.android) diff --git a/android/libraries/rib-screen-stack-base/src/main/kotlin/com/uber/rib/core/screenstack/ViewProvider.kt b/android/libraries/rib-screen-stack-base/src/main/kotlin/com/uber/rib/core/screenstack/ViewProvider.kt index 848bebcf3..74ed73b5b 100644 --- a/android/libraries/rib-screen-stack-base/src/main/kotlin/com/uber/rib/core/screenstack/ViewProvider.kt +++ b/android/libraries/rib-screen-stack-base/src/main/kotlin/com/uber/rib/core/screenstack/ViewProvider.kt @@ -13,11 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +@file:Suppress("invisible_reference", "invisible_member") + package com.uber.rib.core.screenstack import android.view.View import android.view.ViewGroup import androidx.annotation.CallSuper +import com.uber.rib.core.DirectDispatcher import com.uber.rib.core.screenstack.lifecycle.ScreenStackEvent import io.reactivex.Observable import kotlinx.coroutines.channels.BufferOverflow @@ -45,7 +48,7 @@ abstract class ViewProvider { abstract fun buildView(parentView: ViewGroup): View /** @return an observable that emits events for this view provider's lifecycle. */ - fun lifecycle(): Observable = lifecycleFlow.asObservable() + fun lifecycle(): Observable = lifecycleFlow.asObservable(DirectDispatcher) /** * Callers can implement this in order to complete additional work when a call to