diff --git a/workflow-ui/core-android/api/core-android.api b/workflow-ui/core-android/api/core-android.api index 60fecdaef..fe892fc3a 100644 --- a/workflow-ui/core-android/api/core-android.api +++ b/workflow-ui/core-android/api/core-android.api @@ -200,8 +200,8 @@ public final class com/squareup/workflow1/ui/WorkflowLayout : android/widget/Fra public final fun start (Lkotlinx/coroutines/flow/Flow;Lcom/squareup/workflow1/ui/ViewRegistry;)V public static synthetic fun start$default (Lcom/squareup/workflow1/ui/WorkflowLayout;Landroidx/lifecycle/Lifecycle;Lkotlinx/coroutines/flow/Flow;Landroidx/lifecycle/Lifecycle$State;Lcom/squareup/workflow1/ui/ViewEnvironment;ILjava/lang/Object;)V public static synthetic fun start$default (Lcom/squareup/workflow1/ui/WorkflowLayout;Lkotlinx/coroutines/flow/Flow;Lcom/squareup/workflow1/ui/ViewEnvironment;ILjava/lang/Object;)V - public final fun take (Landroidx/lifecycle/Lifecycle;Lkotlinx/coroutines/flow/Flow;Landroidx/lifecycle/Lifecycle$State;)V - public static synthetic fun take$default (Lcom/squareup/workflow1/ui/WorkflowLayout;Landroidx/lifecycle/Lifecycle;Lkotlinx/coroutines/flow/Flow;Landroidx/lifecycle/Lifecycle$State;ILjava/lang/Object;)V + public final fun take (Landroidx/lifecycle/Lifecycle;Lkotlinx/coroutines/flow/Flow;Landroidx/lifecycle/Lifecycle$State;Lkotlin/coroutines/CoroutineContext;)V + public static synthetic fun take$default (Lcom/squareup/workflow1/ui/WorkflowLayout;Landroidx/lifecycle/Lifecycle;Lkotlinx/coroutines/flow/Flow;Landroidx/lifecycle/Lifecycle$State;Lkotlin/coroutines/CoroutineContext;ILjava/lang/Object;)V public final fun update (Ljava/lang/Object;Lcom/squareup/workflow1/ui/ViewEnvironment;)V } diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/WorkflowLayout.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/WorkflowLayout.kt index ab25c0338..a7ffe7a69 100644 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/WorkflowLayout.kt +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/WorkflowLayout.kt @@ -15,6 +15,7 @@ import androidx.lifecycle.Lifecycle.State import androidx.lifecycle.Lifecycle.State.STARTED import androidx.lifecycle.coroutineScope import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -22,6 +23,8 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext /** * A view that can be driven by a stream of [Screen] renderings passed to its [take] method. @@ -84,15 +87,26 @@ public class WorkflowLayout( * Typically this comes from `ComponentActivity.lifecycle` or `Fragment.lifecycle`. * @param [repeatOnLifecycle] the lifecycle state in which renderings should be actively * updated. Defaults to STARTED, which is appropriate for Activity and Fragment. + * @param [collectionContext] additional [CoroutineContext] we want for the coroutine that is + * launched to collect the renderings. This should not override the [CoroutineDispatcher][kotlinx.coroutines.CoroutineDispatcher] + * but may include some other instrumentation elements. */ + @OptIn(ExperimentalStdlibApi::class) public fun take( lifecycle: Lifecycle, renderings: Flow, - repeatOnLifecycle: State = STARTED + repeatOnLifecycle: State = STARTED, + collectionContext: CoroutineContext = EmptyCoroutineContext ) { + // We remove the dispatcher as we want to use what is provided by the lifecycle.coroutineScope. + val contextWithoutDispatcher = collectionContext.minusKey(CoroutineDispatcher.Key) + val lifecycleDispatcher = lifecycle.coroutineScope.coroutineContext[CoroutineDispatcher.Key] // Just like https://medium.com/androiddevelopers/a-safer-way-to-collect-flows-from-android-uis-23080b1f8bda - lifecycle.coroutineScope.launch { + lifecycle.coroutineScope.launch(contextWithoutDispatcher) { lifecycle.repeatOnLifecycle(repeatOnLifecycle) { + require(coroutineContext[CoroutineDispatcher.Key] == lifecycleDispatcher) { + "Collection dispatch should happen on the lifecycle's dispatcher." + } renderings.collect { show(it) } } } diff --git a/workflow-ui/core-android/src/test/java/com/squareup/workflow1/ui/WorkflowLayoutTest.kt b/workflow-ui/core-android/src/test/java/com/squareup/workflow1/ui/WorkflowLayoutTest.kt index 932aa7605..c450df83f 100644 --- a/workflow-ui/core-android/src/test/java/com/squareup/workflow1/ui/WorkflowLayoutTest.kt +++ b/workflow-ui/core-android/src/test/java/com/squareup/workflow1/ui/WorkflowLayoutTest.kt @@ -5,17 +5,24 @@ import android.os.Bundle import android.os.Parcelable import android.util.SparseArray import android.view.View +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.testing.TestLifecycleOwner import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat +import com.squareup.workflow1.ui.container.WrappedScreen +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import kotlin.coroutines.CoroutineContext @RunWith(RobolectricTestRunner::class) // SDK 28 required for the four-arg constructor we use in our custom view classes. @Config(manifest = Config.NONE, sdk = [28]) -@OptIn(WorkflowUiExperimentalApi::class) +@OptIn(WorkflowUiExperimentalApi::class, ExperimentalCoroutinesApi::class) internal class WorkflowLayoutTest { private val context: Context = ApplicationProvider.getApplicationContext() @@ -38,4 +45,21 @@ internal class WorkflowLayoutTest { workflowLayout.restoreHierarchyState(viewState) // No crash, no bug. } + + @Test fun usesLifecycleDispatcher() { + val lifecycleDispatcher = UnconfinedTestDispatcher() + val collectionContext: CoroutineContext = UnconfinedTestDispatcher() + val testLifecycle = TestLifecycleOwner( + Lifecycle.State.RESUMED, + lifecycleDispatcher + ) + + workflowLayout.take( + lifecycle = testLifecycle.lifecycle, + renderings = flowOf(WrappedScreen(), WrappedScreen()), + collectionContext = collectionContext + ) + + // No crash then we safely removed the dispatcher. + } }