diff --git a/samples/containers/thingy/build.gradle.kts b/samples/containers/thingy/build.gradle.kts
new file mode 100644
index 0000000000..84b4a6a5cd
--- /dev/null
+++ b/samples/containers/thingy/build.gradle.kts
@@ -0,0 +1,24 @@
+plugins {
+ id("com.android.application")
+ id("kotlin-android")
+ id("android-sample-app")
+ id("android-ui-tests")
+ id("kotlin-parcelize")
+}
+
+android {
+ defaultConfig {
+ applicationId = "com.squareup.sample.thingy"
+ }
+ namespace = "com.squareup.sample.thingy"
+}
+
+dependencies {
+ debugImplementation(libs.squareup.leakcanary.android)
+
+ implementation(libs.androidx.activity.ktx)
+
+ implementation(project(":samples:containers:android"))
+ implementation(project(":workflow-ui:core-android"))
+ implementation(project(":workflow-ui:core-common"))
+}
diff --git a/samples/containers/thingy/lint-baseline.xml b/samples/containers/thingy/lint-baseline.xml
new file mode 100644
index 0000000000..a1c902b19e
--- /dev/null
+++ b/samples/containers/thingy/lint-baseline.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/containers/thingy/src/main/AndroidManifest.xml b/samples/containers/thingy/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..a682370fd9
--- /dev/null
+++ b/samples/containers/thingy/src/main/AndroidManifest.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/ActionQueue.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/ActionQueue.kt
new file mode 100644
index 0000000000..96c677e2c6
--- /dev/null
+++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/ActionQueue.kt
@@ -0,0 +1,80 @@
+package com.squareup.sample.thingy
+
+import com.squareup.workflow1.NullableInitBox
+import com.squareup.workflow1.Updater
+import com.squareup.workflow1.WorkflowAction
+import com.squareup.workflow1.action
+
+internal typealias StateTransformation = (MutableList) -> Unit
+
+internal class ActionQueue {
+
+ private val lock = Any()
+
+ private val stateTransformations = mutableListOf()
+ private val outputEmissions = mutableListOf()
+
+ fun enqueueStateTransformation(transformation: StateTransformation) {
+ synchronized(lock) {
+ stateTransformations += transformation
+ }
+ }
+
+ fun enqueueOutputEmission(value: Any?) {
+ synchronized(lock) {
+ outputEmissions += value
+ }
+ }
+
+ /**
+ * @param onNextEmitOutputAction Called when the returned action is applied if there are more
+ * outputs to emit. This callback should send another action into the sink to consume those
+ * outputs.
+ */
+ fun consumeToAction(onNextEmitOutputAction: () -> Unit): WorkflowAction<*, *, *> =
+ action(name = { "ActionQueue.consumeToAction()" }) {
+ consume(onNextEmitOutputAction)
+ }
+
+ fun consumeActionsToStack(stack: MutableList) {
+ val transformations = synchronized(lock) {
+ stateTransformations.toList().also {
+ stateTransformations.clear()
+ }
+ }
+ transformations.forEach {
+ it(stack)
+ }
+ }
+
+ private fun Updater.consume(
+ onNextEmitOutputAction: () -> Unit
+ ) {
+ var transformations: List
+ var output = NullableInitBox()
+ var hasMoreOutputs = false
+
+ // The workflow runtime guarantees serialization of WorkflowActions, so we only need to guard
+ // the actual reading of the lists in this class.
+ synchronized(lock) {
+ transformations = stateTransformations.toList()
+ stateTransformations.clear()
+
+ if (outputEmissions.isNotEmpty()) {
+ // Can't use removeFirst on JVM, it resolves to too-new JVM method.
+ output = NullableInitBox(outputEmissions.removeAt(0))
+ hasMoreOutputs = outputEmissions.isNotEmpty()
+ }
+ }
+
+ if (output.isInitialized) {
+ setOutput(output)
+ }
+
+ state = state.transformStack(transformations)
+
+ if (hasMoreOutputs) {
+ onNextEmitOutputAction()
+ }
+ }
+}
diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackDispatcher.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackDispatcher.kt
new file mode 100644
index 0000000000..53522df5f6
--- /dev/null
+++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackDispatcher.kt
@@ -0,0 +1,109 @@
+package com.squareup.sample.thingy
+
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Runnable
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.currentCoroutineContext
+import kotlin.coroutines.CoroutineContext
+
+// TODO this is rough sketch, there are races
+internal class BackStackDispatcher : CoroutineDispatcher() {
+
+ private val lock = Any()
+ private val tasks = mutableListOf()
+ private var capturingTasks = false
+ private var delegate: CoroutineDispatcher? = null
+ private var onIdle: (() -> Unit)? = null
+
+ /**
+ * Runs [block] then immediately runs all dispatched tasks before returning.
+ */
+ fun runThenDispatchImmediately(block: () -> Unit) {
+ synchronized(lock) {
+ check(!capturingTasks) { "Cannot capture again" }
+ capturingTasks = true
+ }
+ try {
+ block()
+ } finally {
+ // Drain tasks before clearing capturing tasks so any tasks that dispatch are also captured.
+ drainTasks()
+ synchronized(lock) {
+ capturingTasks = false
+ }
+ // Run one last time in case tasks were enqueued while clearing the capture flag.
+ drainTasks()
+ }
+ }
+
+ /**
+ * Suspends this coroutine indefinitely and dispatches any tasks to the current dispatcher.
+ * [onIdle] is called after processing tasks when there are no more tasks to process.
+ */
+ @OptIn(ExperimentalStdlibApi::class)
+ suspend fun runDispatch(onIdle: () -> Unit): Nothing {
+ val delegate = currentCoroutineContext()[CoroutineDispatcher] ?: Dispatchers.Default
+ synchronized(lock) {
+ check(this.delegate == null) { "Expected runDispatch to only be called once concurrently" }
+ this.delegate = delegate
+ this.onIdle = onIdle
+ }
+
+ try {
+ awaitCancellation()
+ } finally {
+ synchronized(lock) {
+ this.delegate = null
+ this.onIdle = null
+ }
+ }
+ }
+
+ override fun dispatch(
+ context: CoroutineContext,
+ block: Runnable
+ ) {
+ var isCapturing: Boolean
+ var isFirstTask: Boolean
+ var delegate: CoroutineDispatcher?
+ var onIdle: (() -> Unit)?
+
+ synchronized(lock) {
+ tasks += block
+ isFirstTask = tasks.size == 1
+ isCapturing = this.capturingTasks
+ delegate = this.delegate
+ onIdle = this.onIdle
+ }
+
+ if (!isCapturing && delegate != null && onIdle != null && isFirstTask) {
+ delegate!!.dispatch(context) {
+ // Only run onIdle if work was actually done.
+ if (drainTasks()) {
+ onIdle!!()
+ }
+ }
+ }
+ }
+
+ /**
+ * Returns true if any tasks were executed.
+ */
+ private fun drainTasks(): Boolean {
+ var didAnything = false
+ var task = getNextTask()
+ while (task != null) {
+ didAnything = true
+ task.run()
+ task = getNextTask()
+ }
+ return didAnything
+ }
+
+ private fun getNextTask(): Runnable? {
+ synchronized(lock) {
+ return tasks.removeFirstOrNull()
+ }
+ }
+}
diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackFactory.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackFactory.kt
new file mode 100644
index 0000000000..61b95c3968
--- /dev/null
+++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackFactory.kt
@@ -0,0 +1,65 @@
+package com.squareup.sample.thingy
+
+import com.squareup.workflow1.ui.Screen
+import com.squareup.workflow1.ui.navigation.BackStackScreen
+import com.squareup.workflow1.ui.navigation.toBackStackScreen
+import kotlin.coroutines.AbstractCoroutineContextElement
+import kotlin.coroutines.CoroutineContext
+
+public fun interface BackStackFactory {
+
+ /**
+ * Responsible for converting a list of [Screen]s into a [BackStackScreen]. This function *must*
+ * handle the case where [screens] is empty, since [BackStackScreen] must always have at least
+ * one screen. It *should* handle the case where [isTopIdle] is true, which indicates that the
+ * top (last) screen in [screens] is doing some work that may eventually show another screen.
+ *
+ * @see toBackStackScreen
+ */
+ fun createBackStack(
+ screens: List,
+ isTopIdle: Boolean
+ ): BackStackScreen
+
+ companion object {
+ internal val ThrowOnIdle
+ get() = showLoadingScreen {
+ error("No BackStackFactory provided")
+ }
+
+ /**
+ * Returns a [BackStackFactory] that shows a [loading screen][createLoadingScreen] when
+ * [BackStackWorkflow.runBackStack] has not shown anything yet or when a workflow's output
+ * handler is idle (not showing an active screen).
+ */
+ fun showLoadingScreen(
+ name: String = "",
+ createLoadingScreen: () -> Screen
+ ): BackStackFactory = BackStackFactory { screens, isTopIdle ->
+ val mutableScreens = screens.toMutableList()
+ if (mutableScreens.isEmpty() || isTopIdle) {
+ mutableScreens += createLoadingScreen()
+ }
+ mutableScreens.toBackStackScreen(name)
+ }
+ }
+}
+
+/**
+ * Returns a [CoroutineContext.Element] that will store this [BackStackFactory] in a
+ * [CoroutineContext] to later be retrieved by [backStackFactory].
+ */
+public fun BackStackFactory.asContextElement(): CoroutineContext.Element =
+ BackStackFactoryContextElement(this)
+
+/**
+ * Looks for a [BackStackFactory] stored the current context via [asContextElement].
+ */
+public val CoroutineContext.backStackFactory: BackStackFactory?
+ get() = this[BackStackFactoryContextElement]?.factory
+
+private class BackStackFactoryContextElement(
+ val factory: BackStackFactory
+) : AbstractCoroutineContextElement(Key) {
+ companion object Key : CoroutineContext.Key
+}
diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackFrame.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackFrame.kt
new file mode 100644
index 0000000000..1865cfd25d
--- /dev/null
+++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackFrame.kt
@@ -0,0 +1,18 @@
+package com.squareup.sample.thingy
+
+import com.squareup.workflow1.StatefulWorkflow.RenderContext
+import com.squareup.workflow1.ui.Screen
+
+internal interface BackStackFrame {
+ val node: BackStackNode
+
+ val isIdle: Boolean
+ get() = false
+
+ fun withIdle(): BackStackFrame = object : BackStackFrame by this {
+ override val isIdle: Boolean
+ get() = true
+ }
+
+ fun render(context: RenderContext): Screen
+}
diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackNode.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackNode.kt
new file mode 100644
index 0000000000..d93ff29f62
--- /dev/null
+++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackNode.kt
@@ -0,0 +1,229 @@
+package com.squareup.sample.thingy
+
+import com.squareup.workflow1.StatefulWorkflow.RenderContext
+import com.squareup.workflow1.Workflow
+import com.squareup.workflow1.WorkflowAction
+import com.squareup.workflow1.ui.Screen
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.cancelChildren
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.job
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import kotlin.concurrent.atomics.AtomicInt
+import kotlin.concurrent.atomics.ExperimentalAtomicApi
+
+@OptIn(ExperimentalAtomicApi::class)
+internal class BackStackNode(
+ private val actionQueue: ActionQueue,
+ private val key: String,
+ parentJob: Job,
+ private val dispatcher: BackStackDispatcher,
+ private val onCancel: () -> Unit,
+ private val onEmitOutputAction: () -> Unit
+) : BackStackScope, BackStackScreenScope {
+
+ private val result = CompletableDeferred(parent = parentJob)
+ private val workerScope = CoroutineScope(result + dispatcher)
+
+ private val activeChildLock = Any()
+
+ /** All access must be guarded by [activeChildLock]. */
+ private var activeChild: BackStackNode? = null
+
+ private var childWorkflowKeyCounter = AtomicInt(0)
+
+ private fun createNewChildKey(): String {
+ val id = childWorkflowKeyCounter.fetchAndAdd(1)
+ return "$key.$id"
+ }
+
+ /**
+ * Tracks how many calls to [launch] are currently running. All access must be guarded by
+ * [activeChildLock].
+ *
+ * The node is idle when this value > 0 and [activeChild] is null.
+ */
+ private var workers = 0
+
+ private val isIdle: Boolean
+ get() = synchronized(activeChildLock) {
+ workers > 0 && activeChild == null
+ }
+
+ override suspend fun showWorkflow(
+ workflow: Workflow,
+ props: Flow,
+ onOutput: suspend BackStackWorkflowScope.(output: ChildOutputT) -> R
+ ): R = showNode { workflowNode ->
+ props.map { props ->
+ object : BackStackFrame {
+ override val node: BackStackNode
+ get() = workflowNode
+
+ override fun render(context: RenderContext): Screen =
+ context.renderChild(
+ child = workflow,
+ key = workflowNode.key,
+ props = props,
+ handler = { output ->
+ dispatcher.runThenDispatchImmediately {
+ workflowNode.launch {
+ val scope = object :
+ BackStackWorkflowScope,
+ BackStackScope by workflowNode,
+ CoroutineScope by this {
+
+ override suspend fun cancelWorkflow(): Nothing {
+ workflowNode.onCancel()
+ currentCoroutineContext().ensureActive()
+ error(
+ "cancelWorkflow() called from a coroutine that was not a child of the " +
+ "BackStackWorkflowScope"
+ )
+ }
+ }
+ onOutput(scope, output)
+ }
+ }
+ @Suppress("UNCHECKED_CAST")
+ actionQueue.consumeToAction(onEmitOutputAction) as
+ WorkflowAction
+ }
+ )
+ }
+ }
+ }
+
+ override suspend fun showScreen(
+ screenFactory: BackStackScreenScope.() -> Screen
+ ): R = showNode { screenNode ->
+ flow {
+ @Suppress("UNCHECKED_CAST")
+ val screen = screenFactory(screenNode as BackStackScreenScope)
+ emit(object : BackStackFrame {
+ override val node: BackStackNode
+ get() = screenNode
+
+ override fun render(context: RenderContext): Screen = screen
+ })
+ }
+ }
+
+ override fun launch(block: suspend CoroutineScope.() -> Unit) {
+ workerScope.launch {
+ synchronized(activeChildLock) { workers++ }
+ updateFrame()
+
+ try {
+ // Need a child scope here to wait for any coroutines launched inside block to finish before
+ // decrementing workers.
+ coroutineScope {
+ block()
+ }
+ } finally {
+ synchronized(activeChildLock) { workers-- }
+ updateFrame()
+ }
+ }
+ }
+
+ private suspend fun showNode(
+ block: CoroutineScope.(BackStackNode) -> Flow
+ ): R = withContext(dispatcher) {
+ val childJob = coroutineContext.job
+ val childNode = BackStackNode(
+ actionQueue = actionQueue,
+ key = createNewChildKey(),
+ parentJob = childJob,
+ dispatcher = dispatcher,
+ onCancel = result::cancelChildren,
+ onEmitOutputAction = onEmitOutputAction,
+ )
+
+ withActiveChild(childNode) {
+ val frames = block(childNode)
+ showFrames(childNode, frames, frameScope = this) {
+ @Suppress("UNCHECKED_CAST")
+ childNode.result.await() as R
+ }
+ }
+ }
+
+ private suspend inline fun showFrames(
+ childNode: BackStackNode,
+ frames: Flow,
+ frameScope: CoroutineScope,
+ crossinline block: suspend () -> R
+ ): R {
+ try {
+ frames
+ .onEach { newFrame ->
+ childNode.updateFrame { newFrame }
+ }
+ .launchIn(frameScope)
+
+ return block()
+ } finally {
+ // Remove this node's frame.
+ childNode.updateFrame { null }
+ }
+ }
+
+ override fun continueWith(value: Any?) {
+ if (!result.complete(value)) {
+ error("Tried to finish with $value but already finished")
+ }
+ }
+
+ override fun cancelScreen() {
+ onCancel()
+ }
+
+ private suspend inline fun withActiveChild(
+ child: BackStackNode,
+ block: () -> R
+ ): R {
+ val oldChild = synchronized(activeChildLock) {
+ activeChild.also { activeChild = child }
+ }
+ oldChild?.result?.cancelAndJoin()
+
+ try {
+ return block()
+ } finally {
+ synchronized(activeChildLock) {
+ // If we're being canceled by another withActiveChild call, don't overwrite the new child.
+ if (activeChild === child) {
+ activeChild = null
+ }
+ }
+ }
+ }
+
+ private fun updateFrame(
+ update: ((BackStackFrame?) -> BackStackFrame?)? = null
+ ) {
+ val isIdle = isIdle
+ actionQueue.enqueueStateTransformation { frames ->
+ val index = frames.indexOfFirst { it.node === this }
+ val previousFrame = if (index == -1) null else frames[index]
+ val newFrame = if (update != null) update(previousFrame) else previousFrame
+ if (newFrame == null) {
+ frames.removeAt(index)
+ } else {
+ frames[index] = if (isIdle) newFrame.withIdle() else newFrame
+ }
+ }
+ }
+}
diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackState.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackState.kt
new file mode 100644
index 0000000000..d262020e48
--- /dev/null
+++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackState.kt
@@ -0,0 +1,147 @@
+package com.squareup.sample.thingy
+
+import com.squareup.workflow1.StatefulWorkflow.RenderContext
+import com.squareup.workflow1.WorkflowAction
+import com.squareup.workflow1.ui.Screen
+import com.squareup.workflow1.ui.navigation.BackStackScreen
+import kotlinx.coroutines.flow.MutableStateFlow
+
+/*
+TODO: Design for coalescing state updates/output emissions and dispatching
+ --------------------------------------------------------------------
+
+ Currently when something shows a thing, it creates a WorkflowAction and sends it to the action
+ sink directly. onOutput does one trick to capture any emitOutput called from the UNDISPATCHED
+ launch, but this is brittle and only handles some cases. And since these actions are sent in
+ multiple places—i.e. replacing a workflow sends two actions: one to remove the frame when
+ cancelled and one to add the new frame.
+
+ To fix this, there is a two-part solution.
+
+ First, actions should never be sent directly. Instead, we need a special queue object that
+ accepts two things:
+ - (State) -> State functions. These are used by show*Impl calls to update the stack.
+ - Output values. These are sent only by emitOutput.
+
+ This queue can then be used to collect all queued state transformations into a single action,
+ along with the first-emitted output, if any. The BackStackWorkflowImpl can produce an action for
+ the queue whenever it needs to:
+ 1. In initialState, after launching the coroutine, to get the initial state for the first render
+ call and return from initialState.
+ 2. In onPropsUpdated, to collect any state changes effected by pushing the new props value into
+ the flow and return it from onPropsUpdated.
+ 3. In a workflow's outputHandler, to collect the immediate set of updates generated by the
+ output handler and return an action to bubble up the action cascade.
+
+ The second part is a special CoroutineDispatcher, similar to WorkStealingDispatcher, that can be
+ drained at any time. The API (in addition to basic CoroutineDispatcher stuff) should look
+ something like this:
+
+ internal class BackStackDispatcher: CoroutineDispatcher() {
+ …
+
+ /**
+ * Suspends indefinitely, handling any dispatch calls that aren't inside a [runThenDrain] by
+ * dispatching to the dispatcher from the current context. After processing at least one task,
+ * when there are no more tasks enqueued, calls [onIdle].
+ */
+ suspend fun run(onIdle: () -> Unit)
+
+ /**
+ * Runs [block] such that any tasks that are dispatched to this dispatcher by [block] are not
+ * dispatched like normal, but collected into a special queue and all ran after [block] returns
+ * but before this function returns. I.e. any coroutine work started by [block] is guaranteed to
+ * be have been run and the dispatcher will be idle when this function returns.
+ */
+ fun runThenDrain(block: () -> Unit)
+ }
+
+ This dispatcher can then be used inside the BackStackWorkflowImpl functions mentioned above to
+ ensure all coroutines run before collecting state transformations. E.g.
+
+ override fun initialState(…): BackStackState {
+ dispatcher.runThenDrain {
+ scope.launch { runBackStack(…) }
+ }
+ val initialStack = actionQueue.consumeAllStateTransformations()
+ …
+ return BackStackState(frames = initialStack, dispatcher = dispatcher, actions = actionQueue)
+ }
+
+ Inside render, `run` can be used to support normal dispatching:
+
+ context.runningSideEffect {
+ state.dispatcher.run(onIdle = {
+ // Only returns >1 action if multiple emitOutput calls happened. All state transformations
+ // will always be in the first one. Returns an empty list if no state transforms or outputs
+ // were enqueued.
+ val actions = state.actionQueue.consumeAsActions()
+ actions.forEach {
+ context.actionSink.send(it)
+ }
+ })
+ }
+
+ To ensure all show*Impl calls get processed by this idle handler, they need to always internally
+ make sure they're running on the special dispatcher.
+
+ All this dispatcher/action queue coordination should be encapsulated inside the BackStackState.
+*/
+
+// Impl note: Does some casting to avoid dealing with generics everywhere, since this is internal-
+// only.
+internal class BackStackState(
+ private val stack: List,
+ private val props: MutableStateFlow,
+ private val backStackFactory: BackStackFactory,
+ private val actionQueue: ActionQueue,
+ private val dispatcher: BackStackDispatcher,
+) {
+
+ fun setProps(props: Any?): BackStackState {
+ dispatcher.runThenDispatchImmediately {
+ this.props.value = props
+ }
+
+ val mutableStack = stack.toMutableList()
+ actionQueue.consumeActionsToStack(mutableStack)
+ return copy(stack = mutableStack)
+ }
+
+ fun renderOn(context: RenderContext): BackStackScreen {
+ context.runningSideEffect("TODO") {
+ dispatcher.runDispatch(onIdle = {
+ sendActionToSink(context)
+ })
+ }
+
+ val renderings = stack.map { frame ->
+ frame.render(context)
+ }
+ return backStackFactory.createBackStack(renderings, isTopIdle = false)
+ }
+
+ fun transformStack(transformations: List): BackStackState {
+ val mutableStack = stack.toMutableList()
+ transformations.forEach {
+ it(mutableStack)
+ }
+ return copy(stack = mutableStack)
+ }
+
+ private fun sendActionToSink(context: RenderContext) {
+ @Suppress("UNCHECKED_CAST")
+ context.actionSink.send(
+ actionQueue.consumeToAction { sendActionToSink(context) } as
+ WorkflowAction
+ )
+ }
+
+ private fun copy(stack: List = this.stack) = BackStackState(
+ stack = stack,
+ props = props,
+ backStackFactory = backStackFactory,
+ actionQueue = actionQueue,
+ dispatcher = dispatcher,
+ )
+}
diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflow.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflow.kt
new file mode 100644
index 0000000000..b6d8c13b97
--- /dev/null
+++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflow.kt
@@ -0,0 +1,361 @@
+package com.squareup.sample.thingy
+
+import com.squareup.workflow1.StatefulWorkflow
+import com.squareup.workflow1.Workflow
+import com.squareup.workflow1.ui.Screen
+import com.squareup.workflow1.ui.navigation.BackStackScreen
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.launch
+import kotlin.coroutines.CoroutineContext
+
+/**
+ * Creates a [BackStackWorkflow].
+ *
+ * @param getBackStackFactory See [BackStackWorkflow.getBackStackFactory]. If null, the default
+ * implementation is used.
+ * @param runBackStack See [BackStackWorkflow.runBackStack].
+ */
+public inline fun backStackWorkflow(
+ noinline getBackStackFactory: ((CoroutineContext) -> BackStackFactory)? = null,
+ crossinline runBackStack: suspend BackStackScope.(
+ props: StateFlow,
+ emitOutput: (OutputT) -> Unit
+ ) -> Unit
+): Workflow> =
+ object : BackStackWorkflow() {
+ override suspend fun BackStackScope.runBackStack(
+ props: StateFlow,
+ emitOutput: (OutputT) -> Unit
+ ) {
+ runBackStack(props, emitOutput)
+ }
+
+ override fun getBackStackFactory(coroutineContext: CoroutineContext): BackStackFactory =
+ if (getBackStackFactory != null) {
+ getBackStackFactory(coroutineContext)
+ } else {
+ super.getBackStackFactory(coroutineContext)
+ }
+ }
+
+/**
+ * Returns a [Workflow] that renders a [BackStackScreen] whose frames are controlled by the code
+ * in [runBackStack].
+ *
+ * [runBackStack] can show renderings and render child workflows, as well as emit outputs to this
+ * workflow's parent. See the docs on that method for more info.
+ */
+public abstract class BackStackWorkflow :
+ Workflow> {
+
+ /**
+ * Show renderings by calling [BackStackScope.showScreen]. Show child workflows by calling
+ * [BackStackScope.showWorkflow]. Emit outputs by calling [emitOutput].
+ *
+ * # Showing a screen
+ *
+ * ```
+ * backStackWorkflow { _, _ ->
+ * // Suspends until continueWith is called.
+ * val result = showScreen {
+ * MyScreenClass(
+ * // Returns "finished" from showScreen.
+ * onDoneClicked = { continueWith("finished") },
+ * )
+ * }
+ * }
+ * ```
+ *
+ * # Showing a workflow
+ *
+ * ```
+ * backStackWorkflow { _, _ ->
+ * // Suspends until an onOutput lambda returns a value.
+ * val result = showWorkflow(
+ * childWorkflow,
+ * props = flowOf(childProps)
+ * onOutput = { output ->
+ * // Returns "finished: …" from showWorkflow.
+ * return@showWorkflow "finished: $output"
+ * }
+ * )
+ * }
+ * ```
+ *
+ * # Emitting output
+ *
+ * The second parameter to the [launchRunBackStack] function is an [emitOutput] function that will send
+ * whatever you pass to it to this workflow's parent as an output.
+ * ```
+ * backStackWorkflow { _, emitOutput ->
+ * showWorkflow(
+ * childWorkflow,
+ * props = flowOf(childProps)
+ * onOutput = { output ->
+ * // Forward the output to parent.
+ * emitOutput(output)
+ * }
+ * )
+ * }
+ * ```
+ *
+ * # Nested vs serial calls
+ *
+ * The backstack is represented by _nesting_ `showWorkflow` calls. Consider this example:
+ * ```
+ * backStackWorkflow { _, _ ->
+ * showWorkflow(child1) {
+ * showWorkflow(child2) {
+ * showWorkflow(child3) {
+ * // goBack()
+ * }
+ * }
+ * }
+ * }
+ * ```
+ * This eventually represents a backstack of `[child1, child2, child3]`. `child2` will be pushed
+ * onto the stack when `child1` emits an output, and `child3` pushed when `child2` emits. The
+ * lambdas for `child2` and `child3` can call `goBack` to pop the stack and cancel the lambdas that
+ * called their `showWorkflow`, until the next output is emitted.
+ *
+ * Contrast with calls in series:
+ * ```
+ * backStackWorkflow { _, _ ->
+ * showWorkflow(child1) { finishWith(Unit) }
+ * showWorkflow(child2) { finishWith(Unit) }
+ * showWorkflow(child3) { }
+ * }
+ * ```
+ * `child1` will be shown immediately, but when it emits an output, instead of pushing `child2` onto
+ * the stack, `child1` will be removed from the stack and replaced with `child2`.
+ *
+ * These can be combined:
+ * ```
+ * backStackWorkflow { _, _ ->
+ * showWorkflow(child1) {
+ * showWorkflow(child2) {
+ * // goBack(), or
+ * finishWith(Unit)
+ * }
+ * showWorkflow(child3) {
+ * // goBack()
+ * }
+ * }
+ * }
+ * ```
+ * This code will show `child1` immediately, then when it emits an output show `child2`. When
+ * `child2` emits an output, it can decide to call `goBack` to show `child1` again, or call
+ * `finishWith` to replace itself with `child3`. `child3` can also call `goBack` to show `child`
+ * again.
+ *
+ * To push another screen on the backstack from a non-workflow screen, [launch] a coroutine:
+ * ```
+ * backStackScreen { _, _ ->
+ * showScreen {
+ * MyScreen(
+ * onEvent = {
+ * launch {
+ * showWorkflow(…)
+ * }
+ * }
+ * }
+ * }
+ * }
+ * ```
+ *
+ * # Cancelling screens
+ *
+ * Calling [BackStackScope.showScreen] or [BackStackScope.showWorkflow] suspends the caller until
+ * that workflow/screen produces a result. They handle coroutine cancellation too: if the calling
+ * coroutine is cancelled while they're showing, they are removed from the backstack.
+ *
+ * This can be used to, for example, update a screen based on a flow:
+ * ```
+ * backStackWorkflow { props, _ ->
+ * props.collectLatest { prop ->
+ * showScreen {
+ * MyScreen(message = prop)
+ * }
+ * }
+ * }
+ * ```
+ * This example shows the props received from the parent to the user via `MyScreen`. Every time
+ * the parent passes a new props, the `showScreen` call is cancelled and called again with the
+ * new props, replacing the old instance of `MyScreen` in the backstack with a new one. Since
+ * both instances of `MyScreen` are compatible, this is not a navigation event but just updates
+ * the `MyScreen` view factory.
+ *
+ * # Factoring out code
+ *
+ * You don't have to keep all the logic for your backstack in a single function. You can pull out
+ * functions, just make them extensions on [BackStackParentScope] to get access to `showScreen`
+ * and `showRendering` calls.
+ *
+ * E.g. here's a helper that performs some suspending task and shows a retry screen if it fails:
+ * ```
+ * suspend fun BackStackParentScope.userRetriable(
+ * action: suspend () -> R
+ * ): R {
+ * var result = runCatching { action() }
+ * // runCatching can catch CancellationException, so check.
+ * ensureActive()
+ *
+ * while (result.isFailure) {
+ * showScreen {
+ * RetryScreen(
+ * message = "Failed: ${result.exceptionOrNull()}",
+ * onRetryClicked = { continueWith(Unit) },
+ * onCancelClicked = { goBack() }
+ * )
+ * }
+ *
+ * // Try again.
+ * result = runCatching { action() }
+ * ensureActive()
+ * }
+ *
+ * // We only leave the loop when action succeeded.
+ * return result.getOrThrow()
+ * }
+ * ```
+ */
+ abstract suspend fun BackStackScope.runBackStack(
+ props: StateFlow,
+ emitOutput: (OutputT) -> Unit
+ )
+
+ /**
+ * Return a [BackStackFactory] used to convert the stack of screens produced by this workflow to
+ * a [BackStackScreen].
+ *
+ * The default implementation tries to find a [BackStackFactory] passed to the workflow runtime
+ * via its [CoroutineScope], and if that fails, returns an implementation that will throw whenever
+ * the stack is empty or the top screen is idle.
+ */
+ open fun getBackStackFactory(coroutineContext: CoroutineContext): BackStackFactory =
+ coroutineContext.backStackFactory ?: BackStackFactory.ThrowOnIdle
+
+ final override fun asStatefulWorkflow():
+ StatefulWorkflow> =
+ BackStackWorkflowImpl(this)
+}
+
+@DslMarker
+annotation class BackStackWorkflowDsl
+
+@BackStackWorkflowDsl
+public sealed interface BackStackParentScope {
+
+ /**
+ * Starts rendering [workflow] and pushes its rendering onto the top of the backstack.
+ *
+ * Whenever [workflow] emits an output, [onOutput] is launched into a new coroutine. If one call
+ * doesn't finish before another output is emitted, multiple callbacks can run concurrently.
+ *
+ * When [onOutput] returns a value, this workflow stops rendering, its rendering is removed from
+ * the backstack, and any running output handlers are cancelled. The calling coroutine is resumed
+ * with the value.
+ *
+ * When [onOutput] calls [BackStackWorkflowScope.cancelWorkflow], if this [showWorkflowImpl] call is nested in
+ * another, then this workflow will stop rendering, any of its still-running output handlers will
+ * be cancelled, and the output handler that called this [showWorkflowImpl] will be cancelled.
+ * If this is a top-level workflow in the [BackStackWorkflow], the whole
+ * [BackStackWorkflow.runBackStack] is cancelled and restarted, since "back" is only a concept
+ * that is relevant within a backstack, and it's not possible to know whether the parent supports
+ * back. What you probably want is to emit an output instead to tell the parent to go back.
+ *
+ * If the coroutine calling [showWorkflowImpl] is cancelled, the workflow stops being rendered and its
+ * rendering will be removed from the backstack.
+ *
+ * See [BackStackWorkflow.runBackStack] for high-level documentation about how to use this method
+ * to implement a backstack workflow.
+ *
+ * @param props The props passed to [workflow] when rendering it. [showWorkflowImpl] will suspend
+ * until the first value is emitted. Consider transforming the [BackStackWorkflow.runBackStack]
+ * props [StateFlow] or using [flowOf].
+ */
+ suspend fun showWorkflow(
+ workflow: Workflow,
+ // TODO revert this back to a single value – can use the same trick to update props as for
+ // emitting new screens.
+ props: Flow,
+ onOutput: suspend BackStackWorkflowScope.(output: ChildOutputT) -> R
+ ): R
+
+ /**
+ * Shows the screen produced by [screenFactory]. Suspends untilBackStackNestedScope.goBack] is
+ * called.
+ *
+ * If the coroutine calling [showScreen] is cancelled, the rendering will be removed from the
+ * backstack.
+ *
+ * See [BackStackWorkflow.runBackStack] for high-level documentation about how to use this method
+ * to implement a backstack workflow.
+ */
+ suspend fun showScreen(
+ screenFactory: BackStackScreenScope.() -> Screen
+ ): R
+}
+
+@BackStackWorkflowDsl
+public interface BackStackScope : BackStackParentScope
+
+/**
+ * Scope receiver used for all [showWorkflow] calls. This has all the capabilities of
+ * [BackStackScope] with the additional ability to [go back][cancelWorkflow] to its outer workflow.
+ */
+@BackStackWorkflowDsl
+public interface BackStackWorkflowScope : BackStackScope, CoroutineScope {
+
+ /**
+ * Removes all workflows started by the parent workflow's handler that invoked this [showWorkflowImpl]
+ * from the stack, and cancels that parent output handler coroutine (and thus all child workflow
+ * coroutines as well).
+ */
+ suspend fun cancelWorkflow(): Nothing
+}
+
+/**
+ * Scope receiver used for all [showScreen] calls. This has all the capabilities of
+ * [BackStackScope] with the additional ability to [go back][cancelScreen] to its outer workflow and
+ * to return from [showScreen] by calling [continueWith].
+ */
+@BackStackWorkflowDsl
+public interface BackStackScreenScope : BackStackScope {
+ /**
+ * Causes [showScreen] to return with [value].
+ */
+ fun continueWith(value: R)
+
+ fun cancelScreen()
+
+ fun launch(block: suspend CoroutineScope.() -> Unit)
+}
+
+public suspend inline fun BackStackParentScope.showWorkflow(
+ workflow: Workflow,
+ props: ChildPropsT,
+ noinline onOutput: suspend BackStackWorkflowScope.(output: ChildOutputT) -> R
+): R = showWorkflow(workflow, props = flowOf(props), onOutput = onOutput)
+
+public suspend inline fun BackStackParentScope.showWorkflow(
+ workflow: Workflow,
+ noinline onOutput: suspend BackStackWorkflowScope.(output: ChildOutputT) -> R
+): R = showWorkflow(workflow, props = flowOf(Unit), onOutput)
+
+public suspend inline fun BackStackParentScope.showWorkflow(
+ workflow: Workflow,
+ props: Flow,
+): Nothing = showWorkflow(workflow, props = props) { error("Cannot call") }
+
+public suspend inline fun BackStackParentScope.showWorkflow(
+ workflow: Workflow,
+ props: ChildPropsT,
+): Nothing = showWorkflow(workflow, props = flowOf(props)) { error("Cannot call") }
+
+public suspend inline fun BackStackParentScope.showWorkflow(
+ workflow: Workflow,
+): Nothing = showWorkflow(workflow, props = flowOf(Unit)) { error("Cannot call") }
diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflowImpl.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflowImpl.kt
new file mode 100644
index 0000000000..41d0e4c012
--- /dev/null
+++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/BackStackWorkflowImpl.kt
@@ -0,0 +1,102 @@
+package com.squareup.sample.thingy
+
+import com.squareup.workflow1.SessionWorkflow
+import com.squareup.workflow1.Snapshot
+import com.squareup.workflow1.WorkflowExperimentalApi
+import com.squareup.workflow1.identifier
+import com.squareup.workflow1.ui.Screen
+import com.squareup.workflow1.ui.navigation.BackStackScreen
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancelChildren
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.job
+import kotlinx.coroutines.launch
+
+@OptIn(WorkflowExperimentalApi::class)
+internal class BackStackWorkflowImpl(
+ private val workflow: BackStackWorkflow
+) : SessionWorkflow<
+ PropsT,
+ BackStackState,
+ OutputT,
+ BackStackScreen
+ >() {
+
+ override fun initialState(
+ props: PropsT,
+ snapshot: Snapshot?,
+ workflowScope: CoroutineScope
+ ): BackStackState {
+ val propsFlow = MutableStateFlow(props)
+ val backStackFactory = workflow.getBackStackFactory(workflowScope.coroutineContext)
+ val actionQueue = ActionQueue()
+ val dispatcher = BackStackDispatcher()
+ val rootJob = Job(parent = workflowScope.coroutineContext.job)
+ lateinit var rootNode: BackStackNode
+
+ fun launchRootNode() {
+ rootNode.launch {
+ with(workflow) {
+ rootNode.runBackStack(
+ props = propsFlow,
+ emitOutput = { output ->
+ // Launch with dispatcher to trigger onIdle and actually enqueue an action.
+ workflowScope.launch(dispatcher) {
+ actionQueue.enqueueOutputEmission(output)
+ }
+ }
+ )
+ }
+ }
+ }
+
+ rootNode = BackStackNode(
+ actionQueue = actionQueue,
+ key = workflow.identifier.toString(),
+ parentJob = workflowScope.coroutineContext.job,
+ dispatcher = dispatcher,
+ onCancel = {
+ rootJob.cancelChildren()
+ launchRootNode()
+ },
+ onEmitOutputAction = {
+ TODO("how to trigger more actions?")
+ }
+ )
+
+ dispatcher.runThenDispatchImmediately {
+ launchRootNode()
+ }
+
+ val initialStack = buildList {
+ actionQueue.consumeActionsToStack(this)
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ return BackStackState(
+ stack = initialStack,
+ props = propsFlow as MutableStateFlow,
+ backStackFactory = backStackFactory,
+ actionQueue = actionQueue,
+ dispatcher = dispatcher,
+ )
+ }
+
+ override fun onPropsChanged(
+ old: PropsT,
+ new: PropsT,
+ state: BackStackState
+ ): BackStackState = state.setProps(new)
+
+ override fun render(
+ renderProps: PropsT,
+ renderState: BackStackState,
+ context: RenderContext
+ ): BackStackScreen {
+ @Suppress("UNCHECKED_CAST")
+ return renderState.renderOn(context as RenderContext)
+ }
+
+ override fun snapshotState(state: BackStackState): Snapshot? = null
+}
diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/HelloBackButtonActivity.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/HelloBackButtonActivity.kt
new file mode 100644
index 0000000000..7211ad4a66
--- /dev/null
+++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/HelloBackButtonActivity.kt
@@ -0,0 +1,68 @@
+@file:OptIn(WorkflowExperimentalRuntime::class)
+
+package com.squareup.sample.thingy
+
+import android.os.Bundle
+import androidx.activity.viewModels
+import androidx.appcompat.app.AppCompatActivity
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.viewModelScope
+import com.squareup.sample.container.SampleContainers
+import com.squareup.workflow1.WorkflowExperimentalRuntime
+import com.squareup.workflow1.android.renderWorkflowIn
+import com.squareup.workflow1.config.AndroidRuntimeConfigTools
+import com.squareup.workflow1.ui.Screen
+import com.squareup.workflow1.ui.navigation.reportNavigation
+import com.squareup.workflow1.ui.withRegistry
+import com.squareup.workflow1.ui.workflowContentView
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+import timber.log.Timber
+
+private val viewRegistry = SampleContainers
+
+class HelloBackButtonActivity : AppCompatActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val model: HelloBackButtonModel by viewModels()
+ workflowContentView.take(lifecycle, model.renderings.map { it.withRegistry(viewRegistry) })
+
+ lifecycleScope.launch {
+ model.waitForExit()
+ finish()
+ }
+ }
+
+ companion object {
+ init {
+ Timber.plant(Timber.DebugTree())
+ }
+ }
+}
+
+class HelloBackButtonModel(savedState: SavedStateHandle) : ViewModel() {
+ private val running = Job()
+
+ val renderings: Flow by lazy {
+ renderWorkflowIn(
+ workflow = TODO("MyWorkflow()"),
+ scope = viewModelScope,
+ savedStateHandle = savedState,
+ runtimeConfig = AndroidRuntimeConfigTools.getAppWorkflowRuntimeConfig()
+ ) {
+ // This workflow handles the back button itself, so the activity can't.
+ // Instead, the workflow emits an output to signal that it's time to shut things down.
+ running.complete()
+ }.reportNavigation {
+ Timber.i("Navigated to %s", it)
+ }
+ }
+
+ /** Blocks until the workflow signals that it's time to shut things down. */
+ suspend fun waitForExit() = running.join()
+}
diff --git a/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt
new file mode 100644
index 0000000000..837ba0a6bb
--- /dev/null
+++ b/samples/containers/thingy/src/main/java/com/squareup/sample/thingy/MyWorkflow.kt
@@ -0,0 +1,86 @@
+package com.squareup.sample.thingy
+
+import com.squareup.workflow1.Workflow
+import com.squareup.workflow1.ui.Screen
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import kotlin.coroutines.CoroutineContext
+import kotlin.time.Duration.Companion.seconds
+
+enum class MyOutputs {
+ Back,
+ Done,
+}
+
+data class RetryScreen(
+ val message: String,
+ val onRetryClicked: () -> Unit,
+ val onCancelClicked: () -> Unit,
+) : Screen
+
+data object LoadingScreen : Screen
+
+@Suppress("NAME_SHADOWING")
+class MyWorkflow(
+ private val child1: Workflow,
+ private val child2: Workflow,
+ private val child3: Workflow,
+ private val networkCall: suspend (String) -> String
+) : BackStackWorkflow() {
+
+ override suspend fun BackStackScope.runBackStack(
+ props: StateFlow,
+ emitOutput: (MyOutputs) -> Unit
+ ) {
+ // Step 1
+ showWorkflow(child1) { output ->
+ when (output) {
+ "back" -> emitOutput(MyOutputs.Back)
+ "next" -> {
+ // Step 2
+ val childResult = showWorkflow(child2) { output ->
+ // Removes child2 from the stack, cancels the output handler from step 1, and just
+ // leaves child1 rendering.
+ if (output == "back") cancelWorkflow()
+ output
+ }
+
+ // Step 3 – make a network call, showing a retry screen if it fails. If the user cancels
+ // instead of retrying, we go back to showing child1.
+ val networkResult = networkCallWithRetry(childResult)
+
+ // Step 4: Show a workflow for 3 seconds then finish.
+ launch {
+ delay(3.seconds)
+ emitOutput(MyOutputs.Done)
+ }
+ showWorkflow(child3, networkResult)
+ }
+
+ else -> error("Unexpected output: $output")
+ }
+ }
+ }
+
+ override fun getBackStackFactory(coroutineContext: CoroutineContext): BackStackFactory =
+ BackStackFactory.showLoadingScreen { LoadingScreen }
+
+ private suspend fun BackStackParentScope.networkCallWithRetry(
+ request: String
+ ): String {
+ var networkResult = networkCall(request)
+ while (networkResult == "failure") {
+ showScreen {
+ RetryScreen(
+ message = networkResult,
+ onRetryClicked = { continueWith(Unit) },
+ // Go back to showing child1.
+ onCancelClicked = { cancelScreen() }
+ )
+ }
+ networkResult = networkCall(request)
+ }
+ return networkResult
+ }
+}
diff --git a/samples/containers/thingy/src/main/res/layout/hello_back_button_layout.xml b/samples/containers/thingy/src/main/res/layout/hello_back_button_layout.xml
new file mode 100644
index 0000000000..0e8b71c480
--- /dev/null
+++ b/samples/containers/thingy/src/main/res/layout/hello_back_button_layout.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
diff --git a/samples/containers/thingy/src/main/res/values/strings.xml b/samples/containers/thingy/src/main/res/values/strings.xml
new file mode 100644
index 0000000000..6e0e2822ad
--- /dev/null
+++ b/samples/containers/thingy/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ Hello Sal
+
diff --git a/samples/containers/thingy/src/main/res/values/styles.xml b/samples/containers/thingy/src/main/res/values/styles.xml
new file mode 100644
index 0000000000..e2331afcc2
--- /dev/null
+++ b/samples/containers/thingy/src/main/res/values/styles.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
diff --git a/settings.gradle.kts b/settings.gradle.kts
index ced4e51458..d09be320c6 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -42,6 +42,7 @@ include(
":samples:containers:app-raven",
":samples:containers:android",
":samples:containers:common",
+ ":samples:containers:thingy",
":samples:containers:hello-back-button",
":samples:containers:poetry",
":samples:dungeon:app",
diff --git a/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/navigation/Thingy.kt b/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/navigation/Thingy.kt
new file mode 100644
index 0000000000..d715902c01
--- /dev/null
+++ b/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/navigation/Thingy.kt
@@ -0,0 +1,3 @@
+package com.squareup.workflow1.ui.navigation
+
+public class Thingy : Stateful