diff --git a/settings.gradle.kts b/settings.gradle.kts
index 0cc8d2b26..068935921 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -64,6 +64,7 @@ include(
":workflow-config:config-jvm",
":workflow-core",
":workflow-runtime",
+ ":workflow-runtime-android",
":workflow-rx2",
":workflow-testing",
":workflow-tracing",
diff --git a/workflow-runtime-android/README.md b/workflow-runtime-android/README.md
new file mode 100644
index 000000000..0631e0aed
--- /dev/null
+++ b/workflow-runtime-android/README.md
@@ -0,0 +1,4 @@
+# Module Workflow Runtime Android
+
+This module is an Android library that is used to test the Workflow Runtime with Android specific
+coroutine dispatchers. These are headless android-tests that run on device without UI.
diff --git a/workflow-runtime-android/api/workflow-runtime-android.api b/workflow-runtime-android/api/workflow-runtime-android.api
new file mode 100644
index 000000000..e69de29bb
diff --git a/workflow-runtime-android/build.gradle.kts b/workflow-runtime-android/build.gradle.kts
new file mode 100644
index 000000000..d99bbbee8
--- /dev/null
+++ b/workflow-runtime-android/build.gradle.kts
@@ -0,0 +1,25 @@
+plugins {
+ id("com.android.library")
+ id("kotlin-android")
+ id("android-defaults")
+ id("android-ui-tests")
+}
+
+android {
+ namespace = "com.squareup.workflow1"
+ testNamespace = "$namespace.test"
+}
+
+dependencies {
+ api(project(":workflow-runtime"))
+ implementation(project(":workflow-core"))
+
+ androidTestImplementation(libs.androidx.test.core)
+ androidTestImplementation(libs.androidx.test.truth)
+ androidTestImplementation(libs.kotlin.test.core)
+ androidTestImplementation(libs.kotlin.test.jdk)
+ androidTestImplementation(libs.kotlinx.coroutines.android)
+ androidTestImplementation(libs.kotlinx.coroutines.core)
+ androidTestImplementation(libs.kotlinx.coroutines.test)
+ androidTestImplementation(libs.truth)
+}
diff --git a/workflow-runtime-android/gradle.properties b/workflow-runtime-android/gradle.properties
new file mode 100644
index 000000000..5f09c5c15
--- /dev/null
+++ b/workflow-runtime-android/gradle.properties
@@ -0,0 +1,3 @@
+POM_ARTIFACT_ID=workflow-runtime-android
+POM_NAME=Workflow Runtime Android
+POM_PACKAGING=aar
diff --git a/workflow-runtime-android/src/androidTest/AndroidManifest.xml b/workflow-runtime-android/src/androidTest/AndroidManifest.xml
new file mode 100644
index 000000000..125820472
--- /dev/null
+++ b/workflow-runtime-android/src/androidTest/AndroidManifest.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/workflow-runtime-android/src/androidTest/java/com/squareup/workflow1/AndroidRenderWorkflowInTest.kt b/workflow-runtime-android/src/androidTest/java/com/squareup/workflow1/AndroidRenderWorkflowInTest.kt
new file mode 100644
index 000000000..3758d9fa2
--- /dev/null
+++ b/workflow-runtime-android/src/androidTest/java/com/squareup/workflow1/AndroidRenderWorkflowInTest.kt
@@ -0,0 +1,121 @@
+package com.squareup.workflow1
+
+import com.squareup.workflow1.RuntimeConfigOptions.CONFLATE_STALE_RENDERINGS
+import com.squareup.workflow1.WorkflowInterceptor.RenderPassesComplete
+import com.squareup.workflow1.WorkflowInterceptor.RuntimeLoopOutcome
+import kotlinx.coroutines.CoroutineStart.UNDISPATCHED
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.plus
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+@OptIn(WorkflowExperimentalRuntime::class, ExperimentalCoroutinesApi::class)
+class AndroidRenderWorkflowInTest {
+
+ // @Ignore("#1311: Does not yet work with immediate dispatcher.")
+ @Test
+ fun with_conflate_we_conflate_stacked_actions_into_one_rendering() =
+ runTest(UnconfinedTestDispatcher()) {
+
+ var childHandlerActionExecuted = false
+ val trigger1 = Channel(capacity = 1)
+ val trigger2 = Channel(capacity = 1)
+ val emitted = mutableListOf()
+ var renderingsPassed = 0
+ val countInterceptor = object : WorkflowInterceptor {
+ override fun onRuntimeLoopTick(outcome: RuntimeLoopOutcome) {
+ if (outcome is RenderPassesComplete<*>) {
+ renderingsPassed++
+ }
+ }
+ }
+
+ val childWorkflow = Workflow.stateful(
+ initialState = "unchanging state",
+ render = { renderState ->
+ runningWorker(
+ trigger1.receiveAsFlow().asWorker()
+ ) {
+ action("") {
+ state = it
+ setOutput(it)
+ }
+ }
+ renderState
+ }
+ )
+ val workflow = Workflow.stateful(
+ initialState = "unchanging state",
+ render = { renderState ->
+ renderChild(childWorkflow) { childOutput ->
+ action("childHandler") {
+ childHandlerActionExecuted = true
+ state = childOutput
+ }
+ }
+ runningWorker(
+ trigger2.receiveAsFlow().asWorker()
+ ) {
+ action("") {
+ // Update the rendering in order to show conflation.
+ state = "$it+update"
+ setOutput(state)
+ }
+ }
+ renderState
+ }
+ )
+ val props = MutableStateFlow(Unit)
+ // Render this on the Main.immediate dispatcher from Android.
+ val renderings = renderWorkflowIn(
+ workflow = workflow,
+ scope = backgroundScope +
+ Dispatchers.Main.immediate,
+ props = props,
+ runtimeConfig = setOf(CONFLATE_STALE_RENDERINGS),
+ workflowTracer = null,
+ interceptors = listOf(countInterceptor)
+ ) { }
+
+ val renderedMutex = Mutex(locked = true)
+
+ val collectionJob = launch(context = Dispatchers.Main.immediate, start = UNDISPATCHED) {
+ // Collect this unconfined so we can get all the renderings faster than actions can
+ // be processed.
+ renderings.collect {
+ emitted += it.rendering
+ if (it.rendering == "changed state 2+update") {
+ renderedMutex.unlock()
+ }
+ }
+ }
+
+ launch(context = Dispatchers.Main.immediate, start = UNDISPATCHED) {
+ trigger1.trySend("changed state 1")
+ trigger2.trySend("changed state 2")
+ }.join()
+
+ renderedMutex.lock()
+
+ collectionJob.cancel()
+
+ // 2 renderings (initial and then the update.) Not *3* renderings.
+ assertEquals(2, emitted.size, "Expected only 2 renderings when conflating actions.")
+ assertEquals(
+ 2,
+ renderingsPassed,
+ "Expected only 2 renderings passed when conflating actions."
+ )
+ assertEquals("changed state 2+update", emitted.last())
+ assertTrue(childHandlerActionExecuted)
+ }
+}
diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/RealRenderContext.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/RealRenderContext.kt
index 593dc8b82..2bd9c9f20 100644
--- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/RealRenderContext.kt
+++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/RealRenderContext.kt
@@ -100,10 +100,23 @@ internal class RealRenderContext(
* Freezes this context so that any further calls to this context will throw.
*/
fun freeze() {
- checkNotFrozen("freeze") { "freeze" }
+ // checkNotFrozen("freeze") { "freeze" }
frozen = true
}
+ /**
+ * Freezes this context if it is not currently frozen.
+ *
+ * @return Boolean of whether or not the context was already frozen.
+ */
+ internal fun freezeIfNotFrozen(): Boolean {
+ if (!frozen) {
+ freeze()
+ return false
+ }
+ return true
+ }
+
/**
* Unfreezes when the node is about to render() again.
*/
diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowNode.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowNode.kt
index 17dc3ff7d..3f97b23bf 100644
--- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowNode.kt
+++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowNode.kt
@@ -26,7 +26,7 @@ import com.squareup.workflow1.trace
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.CoroutineStart.LAZY
+import kotlinx.coroutines.CoroutineStart.DEFAULT
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
@@ -36,7 +36,10 @@ import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import kotlinx.coroutines.selects.SelectBuilder
+import kotlin.coroutines.Continuation
+import kotlin.coroutines.ContinuationInterceptor
import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.CoroutineContext.Key
import kotlin.reflect.KType
/**
@@ -279,9 +282,6 @@ internal class WorkflowNode(
workflowTracer.trace("UpdateRuntimeTree") {
// Tear down workflows and workers that are obsolete.
subtreeManager.commitRenderedChildren()
- // Side effect jobs are launched lazily, since they can send actions to the sink, and can only
- // be started after context is frozen.
- sideEffects.forEachStaging { it.job.start() }
sideEffects.commitStaging { it.job.cancel() }
remembered.commitStaging { /* Nothing to clean up. */ }
}
@@ -351,13 +351,42 @@ internal class WorkflowNode(
}
}
+ inner class FrozenContextContinuationInterceptor : ContinuationInterceptor {
+ override val key: Key<*>
+ get() = ContinuationInterceptor.Key
+
+ private var originallyFrozen = false
+
+ override fun interceptContinuation(continuation: Continuation): Continuation =
+ object : Continuation {
+ override val context: CoroutineContext
+ get() = continuation.context
+
+ override fun resumeWith(result: Result) {
+ originallyFrozen = baseRenderContext.freezeIfNotFrozen()
+ continuation.resumeWith(result)
+ }
+ }
+
+ override fun releaseInterceptedContinuation(continuation: Continuation<*>) {
+ if (!originallyFrozen) {
+ baseRenderContext.unfreeze()
+ }
+ }
+ }
+
private fun createSideEffectNode(
key: String,
sideEffect: suspend CoroutineScope.() -> Unit
): SideEffectNode {
return workflowTracer.trace("CreateSideEffectNode") {
- val scope = this + CoroutineName("sideEffect[$key] for $id")
- val job = scope.launch(start = LAZY, block = sideEffect)
+ val scope = this +
+ CoroutineName("sideEffect[$key] for $id") +
+ // Adds the ContinuationInterceptor that freezes whenever we dispatch the continuation
+ // for the side effect. This allows us to schedule side effects during the render
+ // pass.
+ FrozenContextContinuationInterceptor()
+ val job = scope.launch(start = DEFAULT, block = sideEffect)
SideEffectNode(key, job)
}
}
diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/RenderWorkflowInTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/RenderWorkflowInTest.kt
index 504203d52..0fe4d8635 100644
--- a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/RenderWorkflowInTest.kt
+++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/RenderWorkflowInTest.kt
@@ -198,7 +198,7 @@ class RenderWorkflowInTest {
}
@Test
- fun `side_effects_from_initial_rendering_in_root_workflow_are_never_started_when_scope_cancelled_before_start`() {
+ fun `side_effects_from_initial_rendering_in_root_workflow_are_started_when_scope_cancelled_before_start`() {
runtimeTestRunner.runParametrizedTest(
paramSource = runtimeOptions,
before = ::setup,
@@ -222,13 +222,13 @@ class RenderWorkflowInTest {
) {}
advanceIfStandard(dispatcher)
- assertFalse(sideEffectWasRan)
+ assertTrue(sideEffectWasRan)
}
}
}
@Test
- fun `side_effects_from_initial_rendering_in_non_root_workflow_are_never_started_when_scope_cancelled_before_start`() {
+ fun `side_effects_from_initial_rendering_in_non_root_workflow_are_started_when_scope_cancelled_before_start`() {
runtimeTestRunner.runParametrizedTest(
paramSource = runtimeOptions,
before = ::setup,
@@ -255,7 +255,7 @@ class RenderWorkflowInTest {
) {}
advanceIfStandard(dispatcher)
- assertFalse(sideEffectWasRan)
+ assertTrue(sideEffectWasRan)
}
}
}
@@ -765,7 +765,7 @@ class RenderWorkflowInTest {
}
@Test
- fun `side_effects_from_initial_rendering_in_root_workflow_are_never_started_when_initial_render_of_root_workflow_fails`() {
+ fun `side_effects_from_initial_rendering_in_root_workflow_are_started_when_initial_render_of_root_workflow_fails`() {
runtimeTestRunner.runParametrizedTest(
paramSource = runtimeOptions,
before = ::setup,
@@ -788,7 +788,7 @@ class RenderWorkflowInTest {
workflowTracer = workflowTracer,
) {}
}
- assertFalse(sideEffectWasRan)
+ assertTrue(sideEffectWasRan)
}
}
}
@@ -838,7 +838,7 @@ class RenderWorkflowInTest {
}
@Test
- fun `side_effects_from_initial_rendering_in_non_root_workflow_are_never_started_when_initial_render_of_non_root_workflow_fails`() {
+ fun `side_effects_from_initial_rendering_in_non_root_workflow_are_started_when_initial_render_of_non_root_workflow_fails`() {
runtimeTestRunner.runParametrizedTest(
paramSource = runtimeOptions,
before = ::setup,
@@ -864,7 +864,7 @@ class RenderWorkflowInTest {
workflowTracer = workflowTracer,
) {}
}
- assertFalse(sideEffectWasRan)
+ assertTrue(sideEffectWasRan)
}
}
}
diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/RealRenderContextTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/RealRenderContextTest.kt
index 4350ab9f2..53fd3a93a 100644
--- a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/RealRenderContextTest.kt
+++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/RealRenderContextTest.kt
@@ -220,7 +220,6 @@ internal class RealRenderContextTest {
val child = Workflow.stateless { fail() }
assertFailsWith { context.renderChild(child) }
- assertFailsWith { context.freeze() }
assertFailsWith { context.remember("key", typeOf()) {} }
}
diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/WorkflowNodeTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/WorkflowNodeTest.kt
index 9b5e48476..18e2bbfee 100644
--- a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/WorkflowNodeTest.kt
+++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/WorkflowNodeTest.kt
@@ -276,13 +276,14 @@ internal class WorkflowNodeTest {
sink.send(action("") { setOutput("event2") })
}
- @Test fun sideEffect_is_not_started_until_after_render_completes() {
+ @Test fun sideEffect_is_started_immediately() {
var started = false
val workflow = Workflow.stateless {
runningSideEffect("key") {
started = true
}
- assertFalse(started)
+ // This is because context uses and Unconfined Dispatcher, so is eagerly entered.
+ assertTrue(started)
}
val node = WorkflowNode(
workflow.id(),
@@ -466,8 +467,8 @@ internal class WorkflowNodeTest {
@Test fun multiple_sideEffects_with_same_key_throws() {
val workflow = Workflow.stateless {
- runningSideEffect("same") { fail() }
- runningSideEffect("same") { fail() }
+ runningSideEffect("same") { }
+ runningSideEffect("same") { }
}
val node = WorkflowNode(
workflow.id(),