Skip to content

Commit 7a813e0

Browse files
Collect renderings on context specified in WorkflowLayout.take
1 parent 719cd4a commit 7a813e0

File tree

4 files changed

+116
-28
lines changed

4 files changed

+116
-28
lines changed

workflow-ui/core-android/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,6 @@ dependencies {
5252
testImplementation(libs.robolectric)
5353
testImplementation(libs.robolectric.annotations)
5454
testImplementation(libs.truth)
55+
56+
androidTestImplementation(libs.androidx.lifecycle.testing)
5557
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package com.squareup.workflow1.ui
2+
3+
import android.view.View
4+
import androidx.lifecycle.Lifecycle
5+
import androidx.lifecycle.testing.TestLifecycleOwner
6+
import androidx.test.platform.app.InstrumentationRegistry
7+
import com.google.common.truth.Truth.assertThat
8+
import kotlinx.coroutines.Dispatchers
9+
import kotlinx.coroutines.ExperimentalCoroutinesApi
10+
import kotlinx.coroutines.flow.MutableStateFlow
11+
import org.junit.Test
12+
import java.util.concurrent.CountDownLatch
13+
import java.util.concurrent.TimeUnit
14+
15+
/**
16+
* Instrumented tests for [WorkflowLayout] that require a real Android environment.
17+
* These tests verify behavior that cannot be properly tested with Robolectric,
18+
* such as the main thread requirement for collecting renderings.
19+
*/
20+
@OptIn(ExperimentalCoroutinesApi::class)
21+
internal class WorkflowLayoutInstrumentedTest {
22+
23+
@Test
24+
fun throwsWhenCollectingOnBackgroundThread() {
25+
26+
var exception: Throwable? = null
27+
val countDownLatch = CountDownLatch(1)
28+
29+
Thread.setDefaultUncaughtExceptionHandler { _, throwable ->
30+
exception = throwable
31+
countDownLatch.countDown()
32+
}
33+
34+
val testLifeCycleOwner = TestLifecycleOwner(Lifecycle.State.CREATED)
35+
val renderings = MutableStateFlow(TestScreen())
36+
37+
val nonMainThreadDispatcher = Dispatchers.IO
38+
39+
val workflowLayout = WorkflowLayout(InstrumentationRegistry.getInstrumentation().context)
40+
workflowLayout.take(
41+
lifecycle = testLifeCycleOwner.lifecycle,
42+
renderings = renderings,
43+
collectionContext = nonMainThreadDispatcher
44+
)
45+
46+
// start the lifecycle.
47+
testLifeCycleOwner.lifecycle.currentState = Lifecycle.State.STARTED
48+
49+
countDownLatch.await(30, TimeUnit.MILLISECONDS)
50+
51+
assertThat(exception).isNotNull()
52+
assertThat(exception).isInstanceOf(IllegalArgumentException::class.java)
53+
assertThat(exception?.message).contains("Collection dispatch must happen on the main thread!")
54+
}
55+
56+
/**
57+
* Simple test screen for instrumented tests.
58+
*/
59+
private class TestScreen : AndroidScreen<TestScreen> {
60+
override val viewFactory =
61+
ScreenViewFactory.fromCode<TestScreen> { _, initialEnvironment, context, _ ->
62+
ScreenViewHolder(initialEnvironment, View(context)) { _, _ -> }
63+
}
64+
}
65+
}

workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/WorkflowLayout.kt

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.squareup.workflow1.ui
22

33
import android.content.Context
44
import android.os.Build.VERSION
5+
import android.os.Looper
56
import android.os.Parcel
67
import android.os.Parcelable
78
import android.os.Parcelable.Creator
@@ -16,10 +17,10 @@ import androidx.lifecycle.coroutineScope
1617
import androidx.lifecycle.repeatOnLifecycle
1718
import com.squareup.workflow1.ui.androidx.OnBackPressedDispatcherOwnerKey
1819
import com.squareup.workflow1.ui.androidx.WorkflowAndroidXSupport.onBackPressedDispatcherOwnerOrNull
19-
import kotlinx.coroutines.CoroutineDispatcher
2020
import kotlinx.coroutines.Job
2121
import kotlinx.coroutines.flow.Flow
2222
import kotlinx.coroutines.launch
23+
import kotlinx.coroutines.withContext
2324
import kotlin.coroutines.CoroutineContext
2425
import kotlin.coroutines.EmptyCoroutineContext
2526

@@ -89,8 +90,8 @@ public class WorkflowLayout(
8990
* @param [repeatOnLifecycle] the lifecycle state in which renderings should be actively
9091
* updated. Defaults to STARTED, which is appropriate for Activity and Fragment.
9192
* @param [collectionContext] additional [CoroutineContext] we want for the coroutine that is
92-
* launched to collect the renderings. This should not override the [CoroutineDispatcher][kotlinx.coroutines.CoroutineDispatcher]
93-
* but may include some other instrumentation elements.
93+
* launched to collect the renderings, can include a different dispatcher - but we verify that
94+
* it is a main thread dispatcher since we are updating views here!
9495
*
9596
* @return the [Job] started to collect [renderings], to give callers the option to
9697
* [cancel][Job.cancel] collection -- e.g., before calling [take] again with a new
@@ -104,16 +105,15 @@ public class WorkflowLayout(
104105
repeatOnLifecycle: State = STARTED,
105106
collectionContext: CoroutineContext = EmptyCoroutineContext
106107
): Job {
107-
// We remove the dispatcher as we want to use what is provided by the lifecycle.coroutineScope.
108-
val contextWithoutDispatcher = collectionContext.minusKey(CoroutineDispatcher.Key)
109-
val lifecycleDispatcher = lifecycle.coroutineScope.coroutineContext[CoroutineDispatcher.Key]
110108
// Just like https://medium.com/androiddevelopers/a-safer-way-to-collect-flows-from-android-uis-23080b1f8bda
111-
return lifecycle.coroutineScope.launch(contextWithoutDispatcher) {
109+
return lifecycle.coroutineScope.launch {
112110
lifecycle.repeatOnLifecycle(repeatOnLifecycle) {
113-
require(coroutineContext[CoroutineDispatcher.Key] == lifecycleDispatcher) {
114-
"Collection dispatch should happen on the lifecycle's dispatcher."
111+
withContext(collectionContext) {
112+
require(Looper.myLooper() == Looper.getMainLooper()) {
113+
"Collection dispatch must happen on the main thread!"
114+
}
115+
renderings.collect { show(it) }
115116
}
116-
renderings.collect { show(it) }
117117
}
118118
}
119119
}

workflow-ui/core-android/src/test/java/com/squareup/workflow1/ui/WorkflowLayoutTest.kt

Lines changed: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,16 @@ import com.squareup.workflow1.ui.androidx.OnBackPressedDispatcherOwnerKey
1717
import com.squareup.workflow1.ui.navigation.WrappedScreen
1818
import kotlinx.coroutines.ExperimentalCoroutinesApi
1919
import kotlinx.coroutines.flow.MutableSharedFlow
20-
import kotlinx.coroutines.flow.flowOf
20+
import kotlinx.coroutines.flow.onEach
2121
import kotlinx.coroutines.test.UnconfinedTestDispatcher
2222
import kotlinx.coroutines.test.runTest
2323
import org.junit.Test
2424
import org.junit.runner.RunWith
2525
import org.robolectric.RobolectricTestRunner
2626
import org.robolectric.annotation.Config
27+
import kotlin.coroutines.AbstractCoroutineContextElement
2728
import kotlin.coroutines.CoroutineContext
29+
import kotlin.coroutines.coroutineContext
2830

2931
@RunWith(RobolectricTestRunner::class)
3032
// SDK 28 required for the four-arg constructor we use in our custom view classes.
@@ -92,23 +94,6 @@ internal class WorkflowLayoutTest {
9294
unoriginal.show(BScreen(), env)
9395
}
9496

95-
@Test fun usesLifecycleDispatcher() {
96-
val lifecycleDispatcher = UnconfinedTestDispatcher()
97-
val collectionContext: CoroutineContext = UnconfinedTestDispatcher()
98-
val testLifecycle = TestLifecycleOwner(
99-
Lifecycle.State.RESUMED,
100-
lifecycleDispatcher
101-
)
102-
103-
workflowLayout.take(
104-
lifecycle = testLifecycle.lifecycle,
105-
renderings = flowOf(WrappedScreen(), WrappedScreen()),
106-
collectionContext = collectionContext
107-
)
108-
109-
// No crash then we safely removed the dispatcher.
110-
}
111-
11297
@Test fun takes() {
11398
val lifecycleDispatcher = UnconfinedTestDispatcher()
11499
val testLifecycle = TestLifecycleOwner(
@@ -147,6 +132,35 @@ internal class WorkflowLayoutTest {
147132
}
148133
}
149134

135+
@Test fun usesProvidedCoroutineContext() {
136+
val lifecycleDispatcher = UnconfinedTestDispatcher()
137+
val testLifecycle = TestLifecycleOwner(
138+
initialState = Lifecycle.State.RESUMED,
139+
coroutineDispatcher = lifecycleDispatcher
140+
)
141+
val flow = MutableSharedFlow<Screen>()
142+
143+
val testElement = TestContextElement()
144+
145+
val trackedFlow = flow.onEach {
146+
if (coroutineContext[TestContextElement] != null) {
147+
testElement.wasUsed = true
148+
}
149+
}
150+
151+
runTest(lifecycleDispatcher) {
152+
workflowLayout.take(
153+
lifecycle = testLifecycle.lifecycle,
154+
renderings = trackedFlow,
155+
collectionContext = testElement
156+
)
157+
158+
flow.emit(WrappedScreen())
159+
160+
assertThat(testElement.wasUsed).isTrue()
161+
}
162+
}
163+
150164
private class BundleSavingView(context: Context) : View(context) {
151165
var saved = false
152166

@@ -166,4 +180,11 @@ internal class WorkflowLayoutTest {
166180
id = 42
167181
}
168182
}
183+
184+
private class TestContextElement : AbstractCoroutineContextElement(Key) {
185+
companion object Key : CoroutineContext.Key<TestContextElement>
186+
187+
@Volatile
188+
var wasUsed = false
189+
}
169190
}

0 commit comments

Comments
 (0)