diff --git a/android/conventions/src/main/kotlin/ribs.kotlin-android-library-conventions.gradle.kts b/android/conventions/src/main/kotlin/ribs.kotlin-android-library-conventions.gradle.kts index 19a2336a2..3a325be41 100644 --- a/android/conventions/src/main/kotlin/ribs.kotlin-android-library-conventions.gradle.kts +++ b/android/conventions/src/main/kotlin/ribs.kotlin-android-library-conventions.gradle.kts @@ -70,7 +70,6 @@ tasks.withType().configureEach { freeCompilerArgs.addAll( "-Xexplicit-api=warning", "-Xjvm-default=enable", - "-opt-in=kotlin.RequiresOptIn", ) } } 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 493144c04..4049cc9c9 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 @@ -14,6 +14,7 @@ * limitations under the License. */ @file:Suppress("invisible_reference", "invisible_member") +@file:OptIn(InternalRibsApi::class) package com.uber.rib.core @@ -24,7 +25,6 @@ import android.view.ViewGroup import androidx.annotation.CallSuper import com.uber.autodispose.lifecycle.CorrespondingEventsFunction import com.uber.autodispose.lifecycle.LifecycleEndedException -import com.uber.autodispose.lifecycle.LifecycleNotStartedException import com.uber.autodispose.lifecycle.LifecycleScopeProvider import com.uber.rib.core.lifecycle.ActivityCallbackEvent import com.uber.rib.core.lifecycle.ActivityCallbackEvent.Companion.create @@ -37,6 +37,11 @@ import com.uber.rib.core.lifecycle.ActivityCallbackEvent.Companion.createWindowF import com.uber.rib.core.lifecycle.ActivityLifecycleEvent import com.uber.rib.core.lifecycle.ActivityLifecycleEvent.Companion.create import com.uber.rib.core.lifecycle.ActivityLifecycleEvent.Companion.createOnCreateEvent +import com.uber.rib.core.lifecycle.RibLifecycle +import com.uber.rib.core.lifecycle.RibLifecycleOwner +import com.uber.rib.core.lifecycle.internal.InternalRibLifecycle +import com.uber.rib.core.lifecycle.internal.actualRibLifecycle +import com.uber.rib.core.lifecycle.internal.asScopeCompletable import io.reactivex.CompletableSource import io.reactivex.Observable import kotlinx.coroutines.channels.BufferOverflow @@ -48,19 +53,27 @@ import kotlinx.coroutines.rx2.asObservable abstract class RibActivity : CoreAppCompatActivity(), ActivityStarter, + RibLifecycleOwner, LifecycleScopeProvider, RxActivityEvents { private var router: ViewRouter<*, *>? = null - private val _lifecycleFlow = - MutableSharedFlow(1, 0, BufferOverflow.DROP_OLDEST) + private val _ribLifecycle = InternalRibLifecycle(LIFECYCLE_RANGE) + override val ribLifecycle: RibLifecycle + get() = _ribLifecycle - open val lifecycleFlow: SharedFlow - get() = _lifecycleFlow + @Volatile private var mockedRibLifecycleRef: RibLifecycle? = null + + @Deprecated("This field should never be used on real code", level = DeprecationLevel.ERROR) + final override val actualRibLifecycle: RibLifecycle + get() = actualRibLifecycle(::mockedRibLifecycleRef, LIFECYCLE_RANGE) @Volatile private var _lifecycleObservable: Observable? = null + + @Suppress("DEPRECATION_ERROR") private val lifecycleObservable - get() = ::_lifecycleObservable.setIfNullAndGet { lifecycleFlow.asObservable() } + get() = + ::_lifecycleObservable.setIfNullAndGet { actualRibLifecycle.lifecycleFlow.asObservable() } private val _callbacksFlow = MutableSharedFlow(0, 1, BufferOverflow.DROP_OLDEST) @@ -84,14 +97,14 @@ abstract class RibActivity : lifecycleFlow.replayCache.lastOrNull() final override fun requestScope(): CompletableSource = - lifecycleFlow.asScopeCompletable(lifecycleRange) + lifecycleFlow.asScopeCompletable(LIFECYCLE_RANGE) @Initializer @CallSuper override fun onCreate(savedInstanceState: android.os.Bundle?) { super.onCreate(savedInstanceState) val rootViewGroup = findViewById(android.R.id.content) - _lifecycleFlow.tryEmit(createOnCreateEvent(savedInstanceState)) + _ribLifecycle.lifecycleFlow.tryEmit(createOnCreateEvent(savedInstanceState)) val wrappedBundle: Bundle? = if (savedInstanceState != null) Bundle(savedInstanceState) else null router = createRouter(rootViewGroup) @@ -113,13 +126,13 @@ abstract class RibActivity : @CallSuper override fun onStart() { super.onStart() - _lifecycleFlow.tryEmit(create(ActivityLifecycleEvent.Type.START)) + _ribLifecycle.lifecycleFlow.tryEmit(create(ActivityLifecycleEvent.Type.START)) } @CallSuper override fun onResume() { super.onResume() - _lifecycleFlow.tryEmit(create(ActivityLifecycleEvent.Type.RESUME)) + _ribLifecycle.lifecycleFlow.tryEmit(create(ActivityLifecycleEvent.Type.RESUME)) } @CallSuper @@ -136,19 +149,19 @@ abstract class RibActivity : @CallSuper override fun onPause() { - _lifecycleFlow.tryEmit(create(ActivityLifecycleEvent.Type.PAUSE)) + _ribLifecycle.lifecycleFlow.tryEmit(create(ActivityLifecycleEvent.Type.PAUSE)) super.onPause() } @CallSuper override fun onStop() { - _lifecycleFlow.tryEmit(create(ActivityLifecycleEvent.Type.STOP)) + _ribLifecycle.lifecycleFlow.tryEmit(create(ActivityLifecycleEvent.Type.STOP)) super.onStop() } @CallSuper override fun onDestroy() { - _lifecycleFlow.tryEmit(create(ActivityLifecycleEvent.Type.DESTROY)) + _ribLifecycle.lifecycleFlow.tryEmit(create(ActivityLifecycleEvent.Type.DESTROY)) router?.let { it.dispatchDetach() RibEvents.getInstance().emitEvent(RibEventType.DETACHED, it, null) @@ -196,7 +209,7 @@ abstract class RibActivity : } override fun onUserLeaveHint() { - _lifecycleFlow.tryEmit(create(ActivityLifecycleEvent.Type.USER_LEAVING)) + _ribLifecycle.lifecycleFlow.tryEmit(create(ActivityLifecycleEvent.Type.USER_LEAVING)) super.onUserLeaveHint() } @@ -251,12 +264,8 @@ abstract class RibActivity : ) } } - } -} -private val > LifecycleScopeProvider.lifecycleRange: ClosedRange - get() { - val lastEmittedEvent = peekLifecycle() ?: throw LifecycleNotStartedException() - val finishingEvent = correspondingEvents().apply(lastEmittedEvent) - return lastEmittedEvent..finishingEvent + private val LIFECYCLE_RANGE = + createOnCreateEvent(null)..create(ActivityLifecycleEvent.Type.DESTROY) } +} diff --git a/android/libraries/rib-base/src/main/kotlin/com/uber/rib/core/Annotations.kt b/android/libraries/rib-base/src/main/kotlin/com/uber/rib/core/Annotations.kt new file mode 100644 index 000000000..f089a43b8 --- /dev/null +++ b/android/libraries/rib-base/src/main/kotlin/com/uber/rib/core/Annotations.kt @@ -0,0 +1,31 @@ +/* + * 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. + */ +@file:Suppress("MatchingDeclarationName", "ktlint:filename") + +package com.uber.rib.core + +@Retention(value = AnnotationRetention.BINARY) +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.FUNCTION, + AnnotationTarget.TYPEALIAS, + AnnotationTarget.PROPERTY, +) +@RequiresOptIn( + level = RequiresOptIn.Level.ERROR, + message = "This is an internal RIBs API that should not be used by the public.", +) +public annotation class InternalRibsApi 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 bb5d95e43..e043d2cfe 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 @@ -20,14 +20,17 @@ import androidx.annotation.VisibleForTesting import com.uber.autodispose.lifecycle.CorrespondingEventsFunction import com.uber.autodispose.lifecycle.LifecycleEndedException import com.uber.rib.core.lifecycle.InteractorEvent +import com.uber.rib.core.lifecycle.RibLifecycle +import com.uber.rib.core.lifecycle.coroutineScope as lifecycleCoroutineScope +import com.uber.rib.core.lifecycle.internal.InternalRibLifecycle +import com.uber.rib.core.lifecycle.internal.actualRibLifecycle +import com.uber.rib.core.lifecycle.internal.asScopeCompletable import io.reactivex.CompletableSource import io.reactivex.Observable import javax.inject.Inject import kotlin.properties.ReadWriteProperty import kotlin.reflect.KProperty -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.rx2.asObservable /** @@ -36,17 +39,11 @@ import kotlinx.coroutines.rx2.asObservable * @param

the type of [Presenter]. * @param the type of [Router]. */ +@OptIn(InternalRibsApi::class) public abstract class Interactor

>() : InteractorType { + @Inject public lateinit var injectedPresenter: P internal var actualPresenter: P? = null - private val _lifecycleFlow = MutableSharedFlow(1, 0, BufferOverflow.DROP_OLDEST) - public open val lifecycleFlow: SharedFlow - get() = _lifecycleFlow - - @Volatile private var _lifecycleObservable: Observable? = null - private val lifecycleObservable - get() = ::_lifecycleObservable.setIfNullAndGet { lifecycleFlow.asObservable() } - private val routerDelegate = InitOnceProperty() /** @return the router for this interactor. */ @@ -57,22 +54,43 @@ public abstract class Interactor

>() : InteractorType { this.actualPresenter = presenter } + private val _ribLifecycle = InternalRibLifecycle(lifecycleRange) + override val ribLifecycle: RibLifecycle + get() = _ribLifecycle + + // For retro compatibility + + @Volatile private var mockedRibLifecycleRef: RibLifecycle? = null + + @Deprecated("This field should never be used on real code", level = DeprecationLevel.ERROR) + final override val actualRibLifecycle: RibLifecycle + get() = actualRibLifecycle(::mockedRibLifecycleRef, lifecycleRange) + + @Volatile private var _lifecycleObservable: Observable? = null + + @Suppress("DEPRECATION_ERROR") + private val lifecycleObservable + get() = + ::_lifecycleObservable.setIfNullAndGet { actualRibLifecycle.lifecycleFlow.asObservable() } + // ---- LifecycleScopeProvider overrides ---- // final override fun lifecycle(): Observable = lifecycleObservable - final override fun correspondingEvents(): CorrespondingEventsFunction = LIFECYCLE_MAP_FUNCTION - final override fun peekLifecycle(): InteractorEvent? = lifecycleFlow.replayCache.lastOrNull() + @Suppress("DEPRECATION_ERROR") + final override fun peekLifecycle(): InteractorEvent? = + actualRibLifecycle.lifecycleFlow.replayCache.lastOrNull() + @Suppress("DEPRECATION_ERROR") final override fun requestScope(): CompletableSource = - lifecycleFlow.asScopeCompletable(lifecycleRange) + actualRibLifecycle.lifecycleFlow.asScopeCompletable(lifecycleRange) // ---- InteractorType overrides ---- // override fun isAttached(): Boolean = - _lifecycleFlow.replayCache.lastOrNull() == InteractorEvent.ACTIVE + ribLifecycle.lifecycleFlow.replayCache.lastOrNull() == InteractorEvent.ACTIVE override fun handleBackPress(): Boolean = false @@ -101,7 +119,7 @@ public abstract class Interactor

>() : InteractorType { protected open fun onSaveInstanceState(outState: Bundle) {} public open fun dispatchAttach(savedInstanceState: Bundle?) { - _lifecycleFlow.tryEmit(InteractorEvent.ACTIVE) + _ribLifecycle.lifecycleFlow.tryEmit(InteractorEvent.ACTIVE) (getPresenter() as? Presenter)?.dispatchLoad() didBecomeActive(savedInstanceState) } @@ -109,7 +127,7 @@ public abstract class Interactor

>() : InteractorType { public open fun dispatchDetach(): P { (getPresenter() as? Presenter)?.dispatchUnload() willResignActive() - _lifecycleFlow.tryEmit(InteractorEvent.INACTIVE) + _ribLifecycle.lifecycleFlow.tryEmit(InteractorEvent.INACTIVE) return getPresenter() } @@ -161,8 +179,7 @@ public abstract class Interactor

>() : InteractorType { } public companion object { - @get:JvmSynthetic internal val lifecycleRange = InteractorEvent.ACTIVE..InteractorEvent.INACTIVE - + private val lifecycleRange = InteractorEvent.ACTIVE..InteractorEvent.INACTIVE private val LIFECYCLE_MAP_FUNCTION = CorrespondingEventsFunction { interactorEvent: InteractorEvent -> when (interactorEvent) { @@ -172,3 +189,9 @@ public abstract class Interactor

>() : InteractorType { } } } + +@Deprecated( + "Replace the 'com.uber.core.coroutineScope' import with 'com.uber.core.lifecycle.coroutineScope'", +) +public val Interactor<*, *>.coroutineScope: CoroutineScope + get() = this.lifecycleCoroutineScope diff --git a/android/libraries/rib-base/src/main/kotlin/com/uber/rib/core/InteractorType.kt b/android/libraries/rib-base/src/main/kotlin/com/uber/rib/core/InteractorType.kt index 4e5247bb5..ea175e92c 100644 --- a/android/libraries/rib-base/src/main/kotlin/com/uber/rib/core/InteractorType.kt +++ b/android/libraries/rib-base/src/main/kotlin/com/uber/rib/core/InteractorType.kt @@ -17,12 +17,14 @@ package com.uber.rib.core import com.uber.autodispose.lifecycle.LifecycleScopeProvider import com.uber.rib.core.lifecycle.InteractorEvent +import com.uber.rib.core.lifecycle.RibLifecycleOwner /** * An interface used as the upper bound of the generic used by [Router]s to avoid cyclic generic * types */ -public interface InteractorType : LifecycleScopeProvider { +public interface InteractorType : + RibLifecycleOwner, LifecycleScopeProvider { /** @return `true` if the controller is attached, `false` if not. */ public fun isAttached(): Boolean 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 4d5c03265..b47e42f72 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 @@ -18,11 +18,13 @@ package com.uber.rib.core import androidx.annotation.CallSuper import com.uber.autodispose.ScopeProvider import com.uber.rib.core.lifecycle.PresenterEvent +import com.uber.rib.core.lifecycle.RibLifecycle +import com.uber.rib.core.lifecycle.RibLifecycleOwner +import com.uber.rib.core.lifecycle.internal.InternalRibLifecycle +import com.uber.rib.core.lifecycle.internal.actualRibLifecycle +import com.uber.rib.core.lifecycle.internal.asScopeCompletable import io.reactivex.CompletableSource import io.reactivex.Observable -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.rx2.asObservable import org.checkerframework.checker.guieffect.qual.UIEffect @@ -33,14 +35,25 @@ import org.checkerframework.checker.guieffect.qual.UIEffect * practice this caused confusion: if both a presenter and interactor can perform complex rx logic * it becomes unclear where you should write your bussiness logic. */ -public abstract class Presenter : ScopeProvider { - private val _lifecycleFlow = MutableSharedFlow(1, 0, BufferOverflow.DROP_OLDEST) - public open val lifecycleFlow: SharedFlow - get() = _lifecycleFlow +@OptIn(InternalRibsApi::class) +public abstract class Presenter : RibLifecycleOwner, ScopeProvider { + + private val _ribLifecycle = InternalRibLifecycle(LIFECYCLE_RANGE) + override val ribLifecycle: RibLifecycle + get() = _ribLifecycle + + @Volatile private var mockedRibLifecycleRef: RibLifecycle? = null + + @Deprecated("This field should never be used on real code", level = DeprecationLevel.ERROR) + final override val actualRibLifecycle: RibLifecycle + get() = actualRibLifecycle(::mockedRibLifecycleRef, LIFECYCLE_RANGE) @Volatile private var _lifecycleObservable: Observable? = null + + @Suppress("DEPRECATION_ERROR") private val lifecycleObservable - get() = ::_lifecycleObservable.setIfNullAndGet { lifecycleFlow.asObservable() } + get() = + ::_lifecycleObservable.setIfNullAndGet { actualRibLifecycle.lifecycleFlow.asObservable() } /** @return `true` if the presenter is loaded, `false` if not. */ protected var isLoaded: Boolean = false @@ -48,14 +61,14 @@ public abstract class Presenter : ScopeProvider { public open fun dispatchLoad() { isLoaded = true - _lifecycleFlow.tryEmit(PresenterEvent.LOADED) + _ribLifecycle.lifecycleFlow.tryEmit(PresenterEvent.LOADED) didLoad() } public open fun dispatchUnload() { isLoaded = false willUnload() - _lifecycleFlow.tryEmit(PresenterEvent.UNLOADED) + _ribLifecycle.lifecycleFlow.tryEmit(PresenterEvent.UNLOADED) } /** Tells the presenter that it has finished loading. */ @@ -71,9 +84,9 @@ public abstract class Presenter : ScopeProvider { public fun lifecycle(): Observable = lifecycleObservable final override fun requestScope(): CompletableSource = - lifecycleFlow.asScopeCompletable(lifecycleRange) + lifecycleFlow.asScopeCompletable(LIFECYCLE_RANGE) - internal companion object { - @get:JvmSynthetic internal val lifecycleRange = PresenterEvent.LOADED..PresenterEvent.UNLOADED + private companion object { + private val LIFECYCLE_RANGE = PresenterEvent.LOADED..PresenterEvent.UNLOADED } } diff --git a/android/libraries/rib-base/src/main/kotlin/com/uber/rib/core/WorkerBinder.kt b/android/libraries/rib-base/src/main/kotlin/com/uber/rib/core/WorkerBinder.kt index ad17417d6..f330013d5 100644 --- a/android/libraries/rib-base/src/main/kotlin/com/uber/rib/core/WorkerBinder.kt +++ b/android/libraries/rib-base/src/main/kotlin/com/uber/rib/core/WorkerBinder.kt @@ -20,6 +20,7 @@ import com.uber.autodispose.ScopeProvider import com.uber.autodispose.lifecycle.LifecycleScopeProvider import com.uber.rib.core.lifecycle.InteractorEvent import com.uber.rib.core.lifecycle.PresenterEvent +import com.uber.rib.core.lifecycle.RibLifecycleOwner import com.uber.rib.core.lifecycle.WorkerEvent import io.reactivex.Observable import io.reactivex.subjects.CompletableSubject @@ -31,7 +32,6 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.launch @@ -78,8 +78,7 @@ public object WorkerBinder { dispatcherAtBinder: CoroutineDispatcher = RibDispatchers.Unconfined, ): WorkerUnbinder = worker.bind( - interactor.lifecycleFlow, - Interactor.lifecycleRange, + interactor, dispatcherAtBinder, workerBinderListenerWeakRef, ) @@ -126,8 +125,7 @@ public object WorkerBinder { dispatcherAtBinder: CoroutineDispatcher = RibDispatchers.Unconfined, ): WorkerUnbinder = worker.bind( - presenter.lifecycleFlow, - Presenter.lifecycleRange, + presenter, dispatcherAtBinder, workerBinderListenerWeakRef, ) @@ -296,8 +294,7 @@ private fun getJobCoroutineContext( } private fun > Worker.bind( - lifecycle: SharedFlow, - lifecycleRange: ClosedRange, + lifecycleOwner: RibLifecycleOwner, dispatcherAtBinder: CoroutineDispatcher = RibDispatchers.Unconfined, workerDurationListenerWeakRef: WeakReference?, ): WorkerUnbinder { @@ -330,8 +327,8 @@ private fun > Worker.bind( coroutineContext, start = coroutineStart, ) { - lifecycle - .takeWhile { it < lifecycleRange.endInclusive } + lifecycleOwner.ribLifecycle.lifecycleFlow + .takeWhile { it < lifecycleOwner.ribLifecycle.lifecycleRange.endInclusive } .onCompletion { bindAndReportWorkerInfo( workerDurationListenerWeakRef, diff --git a/android/libraries/rib-base/src/main/kotlin/com/uber/rib/core/lifecycle/CoroutineScope.kt b/android/libraries/rib-base/src/main/kotlin/com/uber/rib/core/lifecycle/CoroutineScope.kt new file mode 100644 index 000000000..b5786eea4 --- /dev/null +++ b/android/libraries/rib-base/src/main/kotlin/com/uber/rib/core/lifecycle/CoroutineScope.kt @@ -0,0 +1,80 @@ +/* + * 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.lifecycle + +import com.uber.autodispose.lifecycle.LifecycleEndedException +import com.uber.autodispose.lifecycle.LifecycleNotStartedException +import com.uber.rib.core.InternalRibsApi +import com.uber.rib.core.RibCoroutinesConfig +import com.uber.rib.core.RibDispatchers +import com.uber.rib.core.lifecycle.internal.CloseableOwner +import com.uber.rib.core.lifecycle.internal.ensureAlive +import com.uber.rib.core.lifecycle.internal.getCloseableOrPut +import java.io.Closeable +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel + +private const val SCOPE_REF_KEY = "com.uber.rib.core.lifecycle.coroutineScope" + +/** + * A [CoroutineScope] whose lifecycle matches the lifecycle of the RIB component. When the lifecycle + * completes, the scope is cancelled. + * + * If this property is called before the lifecycle is active, it throws + * [LifecycleNotStartedException]. Likewise, if called after lifecycle is ended, it throws + * [LifecycleEndedException]. When calling this property on a worker thread, it is possible the + * scope gets cancelled before being delivered to the caller. + * + * ### Caching policy + * + * The [CoroutineScope] instance is cached. Calls to this property either creates and caches a new + * instance, or returns the previously cached instance. + * + * When the scope cancels, it is cleared from the cache. + */ +@OptIn(InternalRibsApi::class) +public val RibLifecycle<*>.coroutineScope: CoroutineScope + get() { + require(this is CloseableOwner) { + "RibLifecycle.coroutineScope cannot be used by custom implementations of RibLifecycle" + } + ensureAlive() + return getCloseableOrPut(SCOPE_REF_KEY) { + CloseableCoroutineScope( + SupervisorJob() + + RibDispatchers.Main.immediate + + CoroutineName("${this::class.simpleName}:coroutineScope") + + (RibCoroutinesConfig.exceptionHandler ?: EmptyCoroutineContext), + ) + } + } + +/** An alias for [RibLifecycle.coroutineScope], for convenience. */ +@Suppress("DEPRECATION_ERROR") +@OptIn(InternalRibsApi::class) +public val RibLifecycleOwner<*>.coroutineScope: CoroutineScope + get() = actualRibLifecycle.coroutineScope + +private class CloseableCoroutineScope(override val coroutineContext: CoroutineContext) : + CoroutineScope, Closeable { + override fun close() { + cancel() + } +} diff --git a/android/libraries/rib-base/src/main/kotlin/com/uber/rib/core/lifecycle/RibLifecycle.kt b/android/libraries/rib-base/src/main/kotlin/com/uber/rib/core/lifecycle/RibLifecycle.kt new file mode 100644 index 000000000..2b48666ad --- /dev/null +++ b/android/libraries/rib-base/src/main/kotlin/com/uber/rib/core/lifecycle/RibLifecycle.kt @@ -0,0 +1,48 @@ +/* + * 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.lifecycle + +import com.uber.rib.core.InternalRibsApi +import kotlinx.coroutines.flow.SharedFlow + +/** An owner of a [RibLifecycle]. */ +public interface RibLifecycleOwner> { + public val ribLifecycle: RibLifecycle + + // For retro-compatibility + + @InternalRibsApi + @Deprecated("This field should never be used on real code", level = DeprecationLevel.ERROR) + public val actualRibLifecycle: RibLifecycle + + @Deprecated( + message = + """Use 'RibLifecycle.lifecycleFlow'. When mocking in tests, mock 'ribLifecycle' and return + an instance of 'TestRibLifecycle' from the 'rib-test' module.""", + replaceWith = ReplaceWith("ribLifecycle.lifecycleFlow"), + ) + public val lifecycleFlow: SharedFlow + get() = ribLifecycle.lifecycleFlow +} + +/** A RIB component that has a lifecycle. */ +public interface RibLifecycle> { + /** A flow of lifecycle events. */ + public val lifecycleFlow: SharedFlow + + /** The range at which the lifecycle is considered active. */ + public val lifecycleRange: ClosedRange +} 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/lifecycle/internal/Completable.kt similarity index 74% rename from android/libraries/rib-base/src/main/kotlin/com/uber/rib/core/FlowAsScope.kt rename to android/libraries/rib-base/src/main/kotlin/com/uber/rib/core/lifecycle/internal/Completable.kt index 546a6ebe5..edb766c10 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/lifecycle/internal/Completable.kt @@ -13,13 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -@file:JvmSynthetic - -package com.uber.rib.core +package com.uber.rib.core.lifecycle.internal import com.uber.autodispose.lifecycle.LifecycleEndedException import com.uber.autodispose.lifecycle.LifecycleNotStartedException -import io.reactivex.CompletableSource +import io.reactivex.Completable import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext import kotlinx.coroutines.flow.SharedFlow @@ -28,28 +26,21 @@ import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.rx2.rxCompletable /** - * Converts a [SharedFlow] of lifecycle events into a [CompletableSource] that completes once the - * flow emits the ending event. + * Converts a [SharedFlow] of lifecycle events into a [Completable] that completes once the flow + * emits the ending event. * * The lifecycle start and end events are defined by [range], and this function will throw either: * 1. [LifecycleNotStartedException], if the last emitted event is not in range, or * 2. [LifecycleEndedException], if the last emitted event is in the end (inclusive) or beyond * [range]. */ +@JvmSynthetic internal fun > SharedFlow.asScopeCompletable( range: ClosedRange, context: CoroutineContext = EmptyCoroutineContext, -): CompletableSource { +): Completable { ensureAlive(range) - return rxCompletable(RibDispatchers.Unconfined + context) { + return rxCompletable(DirectDispatcher + context) { takeWhile { it < range.endInclusive }.collect() } } - -private fun > SharedFlow.ensureAlive(range: ClosedRange) { - val lastEmitted = replayCache.lastOrNull() - when { - lastEmitted == null || lastEmitted < range.start -> throw LifecycleNotStartedException() - lastEmitted >= range.endInclusive -> throw LifecycleEndedException() - } -} diff --git a/android/libraries/rib-base/src/main/kotlin/com/uber/rib/core/lifecycle/internal/DirectDispatcher.kt b/android/libraries/rib-base/src/main/kotlin/com/uber/rib/core/lifecycle/internal/DirectDispatcher.kt new file mode 100644 index 000000000..fed31ecda --- /dev/null +++ b/android/libraries/rib-base/src/main/kotlin/com/uber/rib/core/lifecycle/internal/DirectDispatcher.kt @@ -0,0 +1,37 @@ +/* + * 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.lifecycle.internal + +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineDispatcher + +/** + * 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. [Chaining rxSingle calls that use Unconfined dispatcher and blockingGet results in + * deadlock #3458](https://github.com/Kotlin/kotlinx.coroutines/issues/3458) + * 2. [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-base/src/main/kotlin/com/uber/rib/core/lifecycle/internal/InternalRibLifecycle.kt b/android/libraries/rib-base/src/main/kotlin/com/uber/rib/core/lifecycle/internal/InternalRibLifecycle.kt new file mode 100644 index 000000000..44866a2dd --- /dev/null +++ b/android/libraries/rib-base/src/main/kotlin/com/uber/rib/core/lifecycle/internal/InternalRibLifecycle.kt @@ -0,0 +1,109 @@ +/* + * 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.lifecycle.internal + +import com.uber.autodispose.lifecycle.LifecycleEndedException +import com.uber.autodispose.lifecycle.LifecycleNotStartedException +import com.uber.rib.core.InternalRibsApi +import com.uber.rib.core.lifecycle.RibLifecycle +import com.uber.rib.core.lifecycle.RibLifecycleOwner +import com.uber.rib.core.setIfNullAndGet +import java.io.Closeable +import kotlin.reflect.KMutableProperty0 +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.takeWhile +import kotlinx.coroutines.launch + +/** An internal implementation of [RibLifecycle] and [CloseableOwner]. */ +@InternalRibsApi +public open class InternalRibLifecycle>( + override val lifecycleRange: ClosedRange, +) : RibLifecycle, CloseableOwner { + override val lifecycleFlow: MutableSharedFlow = + MutableSharedFlow(1, 0, BufferOverflow.DROP_OLDEST) + override val closeables: MutableMap = mutableMapOf() +} + +@Suppress("NOTHING_TO_INLINE", "USELESS_ELVIS", "DEPRECATION", "RedundantRequireNotNullCall") +@InternalRibsApi +internal inline fun > RibLifecycleOwner.actualRibLifecycle( + prop: KMutableProperty0?>, + range: ClosedRange, +): RibLifecycle = + // open fields will be null in mocking, unless mocked. + // fields initialized in constructor will also be null, even when type is non-null. + ribLifecycle + ?: run { + checkNotNull(lifecycleFlow) { "When mocking, mock 'ribLifecycle'" } + prop.setIfNullAndGet { + object : RibLifecycle, CloseableOwner { + override val lifecycleFlow = this@actualRibLifecycle.lifecycleFlow + override val lifecycleRange = range + override val closeables: MutableMap = mutableMapOf() + } + } + } + +@InternalRibsApi +public interface CloseableOwner { + public val closeables: MutableMap +} + +@InternalRibsApi +internal inline fun R.getCloseableOrPut( + key: String, + closeable: () -> T, +): T where R : CloseableOwner, R : RibLifecycle<*> { + val result = synchronized(closeables) { closeables.getOrPut(key, closeable) } + check(result is T) { + "Closeables map already had a value for key $key, but it was not of type ${T::class.simpleName}" + } + invokeOnLifecycleEnded { result.close() } + return result +} + +@InternalRibsApi +@OptIn(DelicateCoroutinesApi::class) +private inline fun > RibLifecycle.invokeOnLifecycleEnded( + crossinline block: () -> Unit, +) { + GlobalScope.launch(DirectDispatcher) { + try { + lifecycleFlow.takeWhile { it < lifecycleRange.endInclusive }.collect() + } finally { + block() + } + } +} + +@JvmSynthetic +internal fun > RibLifecycle.ensureAlive() { + lifecycleFlow.ensureAlive(lifecycleRange) +} + +@JvmSynthetic +internal fun > SharedFlow.ensureAlive(range: ClosedRange) { + val lastEmitted = replayCache.lastOrNull() + when { + lastEmitted == null || lastEmitted < range.start -> throw LifecycleNotStartedException() + lastEmitted >= range.endInclusive -> throw LifecycleEndedException() + } +} diff --git a/android/libraries/rib-base/src/test/kotlin/com/uber/rib/core/InteractorAndRouterTest.kt b/android/libraries/rib-base/src/test/kotlin/com/uber/rib/core/InteractorAndRouterTest.kt index 4faa01c6a..e5242f7f9 100644 --- a/android/libraries/rib-base/src/test/kotlin/com/uber/rib/core/InteractorAndRouterTest.kt +++ b/android/libraries/rib-base/src/test/kotlin/com/uber/rib/core/InteractorAndRouterTest.kt @@ -19,6 +19,16 @@ import com.google.common.truth.Truth import com.uber.autodispose.lifecycle.LifecycleEndedException import com.uber.rib.core.RibRefWatcher.Companion.getInstance import com.uber.rib.core.lifecycle.InteractorEvent +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancel +import kotlinx.coroutines.job +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test import org.mockito.kotlin.any @@ -47,6 +57,23 @@ class InteractorAndRouterTest { router = TestRouter(interactor, component) } + @Test + fun test() = + runTest(UnconfinedTestDispatcher()) { + val scope = CoroutineScope(coroutineContext + SupervisorJob(coroutineContext.job)) + scope.launch(DirectDispatcher) { + try { + awaitCancellation() + } finally { + println("finishing") + } + } + launch { + scope.cancel() + println("cancelled") + } + } + @Test fun attach_shouldAttachChildController() { // When. @@ -267,3 +294,9 @@ class InteractorAndRouterTest { private const val TEST_VALUE = "test_value" } } + +private object DirectDispatcher : CoroutineDispatcher() { + override fun dispatch(context: CoroutineContext, block: Runnable) { + block.run() + } +} diff --git a/android/libraries/rib-base/src/test/kotlin/com/uber/rib/core/InteractorMockingTest.kt b/android/libraries/rib-base/src/test/kotlin/com/uber/rib/core/InteractorMockingTest.kt new file mode 100644 index 000000000..3e54ab0b7 --- /dev/null +++ b/android/libraries/rib-base/src/test/kotlin/com/uber/rib/core/InteractorMockingTest.kt @@ -0,0 +1,74 @@ +/* + * 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. + */ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.uber.rib.core + +import com.google.common.truth.Truth.assertThat +import com.uber.rib.core.lifecycle.InteractorEvent +import com.uber.rib.core.lifecycle.TestRibLifecycle +import com.uber.rib.core.lifecycle.coroutineScope +import com.uber.rib.core.lifecycle.emit +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock + +class InteractorMockingTest { + @get:Rule val rule = RibCoroutinesRule() + private val lifecycleFlow = MutableSharedFlow(1, 0, BufferOverflow.DROP_OLDEST) + private val ribLifecycle = TestRibLifecycle(InteractorEvent.ACTIVE..InteractorEvent.INACTIVE) + private val mock1 = mock> { on { this.lifecycleFlow } doReturn lifecycleFlow } + private val mock2 = mock> { on { this.ribLifecycle } doReturn ribLifecycle } + + @Test + fun testMock1() = runTest { + lifecycleFlow.tryEmit(InteractorEvent.ACTIVE) + test1(mock1) { lifecycleFlow.tryEmit(InteractorEvent.INACTIVE) } + } + + @Test + fun testMock2() = runTest { + ribLifecycle.emit(InteractorEvent.ACTIVE) + test1(mock2) { ribLifecycle.emit(InteractorEvent.INACTIVE) } + } +} + +private fun TestScope.test1(mock: Interactor<*, *>, cancel: () -> Unit) { + var started = false + var completed = false + mock.coroutineScope.launch { + try { + started = true + awaitCancellation() + } finally { + completed = true + } + } + runCurrent() + assertThat(started).isTrue() + cancel() + runCurrent() + assertThat(completed).isTrue() +} diff --git a/android/libraries/rib-base/src/test/kotlin/com/uber/rib/core/lifecycle/RibLifecycleTest.kt b/android/libraries/rib-base/src/test/kotlin/com/uber/rib/core/lifecycle/RibLifecycleTest.kt new file mode 100644 index 000000000..3423d58a0 --- /dev/null +++ b/android/libraries/rib-base/src/test/kotlin/com/uber/rib/core/lifecycle/RibLifecycleTest.kt @@ -0,0 +1,75 @@ +/* + * 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.lifecycle + +import com.google.common.truth.Truth.assertThat +import com.uber.autodispose.lifecycle.LifecycleEndedException +import com.uber.autodispose.lifecycle.LifecycleNotStartedException +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.job +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class RibLifecycleTest { + private val lifecycle = TestRibLifecycle(LifecycleEvent.ACTIVE..LifecycleEvent.INACTIVE) + + @Test(expected = LifecycleNotStartedException::class) + fun `when getting coroutineScope before lifecycle started, throws exception`() = runTest { + lifecycle.coroutineScope + } + + @Test(expected = LifecycleEndedException::class) + fun `when getting coroutineScope after lifecycle ended, throws exception`() = runTest { + lifecycle.active() + lifecycle.inactive() + lifecycle.coroutineScope + } + + @Test + fun `when lifecycle ends, coroutineScope is cancelled`() = runTest { + lifecycle.active() + val scope = lifecycle.coroutineScope + lifecycle.inactive() + assertThat(scope.coroutineContext.job.isCancelled).isTrue() + } + + @Test + fun `when getting coroutineScope multiple times, instances are the same`() = runTest { + lifecycle.active() + val set = List(50) { lifecycle.coroutineScope }.toSet() + assertThat(set).hasSize(1) + lifecycle.inactive() + } + + @Test + fun `verify CoroutineName`() = runTest { + lifecycle.active() + val name = lifecycle.coroutineScope.coroutineContext[CoroutineName]?.name + assertThat(name).isEqualTo("TestRibLifecycle:coroutineScope") + lifecycle.inactive() + } +} + +enum class LifecycleEvent { + ACTIVE, + INACTIVE, +} + +private fun TestRibLifecycle.active() = emit(LifecycleEvent.ACTIVE) + +private fun TestRibLifecycle.inactive() = emit(LifecycleEvent.INACTIVE) diff --git a/android/libraries/rib-test/build.gradle b/android/libraries/rib-test/build.gradle index 2b1cf8981..195a134d0 100644 --- a/android/libraries/rib-test/build.gradle +++ b/android/libraries/rib-test/build.gradle @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - plugins { id("ribs.kotlin-library-conventions") alias(libs.plugins.mavenPublish) @@ -26,5 +25,6 @@ dependencies { api(testLibs.junit) api(testLibs.truth) api(testLibs.mockito) + api(testLibs.coroutines.test) implementation(testLibs.mockitoKotlin) } diff --git a/android/libraries/rib-test/src/main/kotlin/com/uber/rib/core/lifecycle/TestRibLifecycle.kt b/android/libraries/rib-test/src/main/kotlin/com/uber/rib/core/lifecycle/TestRibLifecycle.kt new file mode 100644 index 000000000..33eb2b9e2 --- /dev/null +++ b/android/libraries/rib-test/src/main/kotlin/com/uber/rib/core/lifecycle/TestRibLifecycle.kt @@ -0,0 +1,38 @@ +/* + * 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. + */ +@file:OptIn(InternalRibsApi::class) + +package com.uber.rib.core.lifecycle + +import com.uber.rib.core.InternalRibsApi +import com.uber.rib.core.lifecycle.internal.CloseableOwner +import java.io.Closeable +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow + +/** A test implementation of [RibLifecycle]. */ +public class TestRibLifecycle>( + override val lifecycleRange: ClosedRange, +) : RibLifecycle, CloseableOwner { + override val lifecycleFlow: MutableSharedFlow = + MutableSharedFlow(1, 0, BufferOverflow.DROP_OLDEST) + override val closeables: MutableMap = mutableMapOf() +} + +/** Emits [event] into the lifecycle. */ +public fun > TestRibLifecycle.emit(event: T) { + lifecycleFlow.tryEmit(event) +}