Skip to content

Commit db24454

Browse files
WIP: Add the ability to update the input for a WorkflowHost.
Closes #247.
1 parent 40766df commit db24454

File tree

14 files changed

+284
-71
lines changed

14 files changed

+284
-71
lines changed

kotlin/samples/tictactoe/common/src/test/java/com/squareup/sample/gameworkflow/TakeTurnsWorkflowTest.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,6 @@ class TakeTurnsWorkflowTest {
6969
}
7070
}
7171

72-
private fun WorkflowTester<*, GamePlayScreen>.takeSquare(event: TakeSquare) {
72+
private fun WorkflowTester<*, *, GamePlayScreen>.takeSquare(event: TakeSquare) {
7373
withNextRendering { it.onEvent(event) }
7474
}

kotlin/settings.gradle

+1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ include ':samples:tictactoe:common'
2323
include ':workflow-core'
2424
include ':workflow-runtime'
2525
include ':workflow-rx2'
26+
include ':workflow-rx2-runtime'
2627
include ':workflow-testing'
2728
include ':workflow-ui-core'
2829
include ':workflow-ui-android'

kotlin/workflow-runtime/src/main/java/com/squareup/workflow/WorkflowHost.kt

+91-46
Original file line numberDiff line numberDiff line change
@@ -19,25 +19,28 @@ package com.squareup.workflow
1919

2020
import com.squareup.workflow.WorkflowHost.Factory
2121
import com.squareup.workflow.WorkflowHost.Update
22-
import com.squareup.workflow.internal.WorkflowId
2322
import com.squareup.workflow.internal.WorkflowNode
2423
import com.squareup.workflow.internal.id
2524
import kotlinx.coroutines.CancellationException
25+
import kotlinx.coroutines.CoroutineScope
26+
import kotlinx.coroutines.GlobalScope
2627
import kotlinx.coroutines.cancel
28+
import kotlinx.coroutines.channels.Channel
2729
import kotlinx.coroutines.channels.ReceiveChannel
2830
import kotlinx.coroutines.channels.produce
29-
import kotlinx.coroutines.isActive
31+
import kotlinx.coroutines.ensureActive
3032
import kotlinx.coroutines.selects.select
3133
import org.jetbrains.annotations.TestOnly
3234
import kotlin.coroutines.CoroutineContext
3335
import kotlin.coroutines.EmptyCoroutineContext
36+
import kotlin.coroutines.coroutineContext
3437

3538
/**
3639
* Provides a stream of [updates][Update] from a tree of [Workflow]s.
3740
*
3841
* Create these by injecting a [Factory] and calling [run][Factory.run].
3942
*/
40-
interface WorkflowHost<out OutputT : Any, out RenderingT : Any> {
43+
interface WorkflowHost<in InputT : Any, out OutputT : Any, out RenderingT : Any> {
4144

4245
/**
4346
* Output from a [WorkflowHost]. Emitted from [WorkflowHost.updates] after every compose pass.
@@ -75,76 +78,118 @@ interface WorkflowHost<out OutputT : Any, out RenderingT : Any> {
7578
*/
7679
fun <InputT : Any, OutputT : Any, RenderingT : Any> run(
7780
workflow: Workflow<InputT, OutputT, RenderingT>,
78-
input: InputT,
81+
inputs: ReceiveChannel<InputT>,
7982
snapshot: Snapshot? = null,
8083
context: CoroutineContext = EmptyCoroutineContext
81-
): WorkflowHost<OutputT, RenderingT> = run(workflow.id(), workflow, input, snapshot, context)
84+
): WorkflowHost<InputT, OutputT, RenderingT> =
85+
object : WorkflowHost<InputT, OutputT, RenderingT> {
86+
private val scope = CoroutineScope(context)
87+
88+
override val updates: ReceiveChannel<Update<OutputT, RenderingT>> =
89+
scope.produce(capacity = 0) {
90+
runWorkflowTree(
91+
workflow = workflow.asStatefulWorkflow(),
92+
inputs = inputs,
93+
initialSnapshot = snapshot,
94+
onUpdate = ::send
95+
)
96+
}
97+
}
8298

8399
fun <OutputT : Any, RenderingT : Any> run(
84100
workflow: Workflow<Unit, OutputT, RenderingT>,
85101
snapshot: Snapshot? = null,
86102
context: CoroutineContext = EmptyCoroutineContext
87-
): WorkflowHost<OutputT, RenderingT> = run(workflow.id(), workflow, Unit, snapshot, context)
103+
): WorkflowHost<Unit, OutputT, RenderingT> = run(workflow, channelOf(Unit), snapshot, context)
88104

89105
/**
90106
* Creates a [WorkflowHost] that runs [workflow] starting from [initialState].
91107
*
92108
* **Don't call this directly.**
93109
*
94-
* Instead, your module should have a test dependency on `pure-v2-testing` and you should call the
95-
* testing extension method defined there on your workflow itself.
110+
* Instead, your module should have a test dependency on `pure-v2-testing` and you should call
111+
* the testing extension method defined there on your workflow itself.
96112
*/
97113
@TestOnly
98114
fun <InputT : Any, StateT : Any, OutputT : Any, RenderingT : Any> runTestFromState(
99115
workflow: StatefulWorkflow<InputT, StateT, OutputT, RenderingT>,
100116
input: InputT,
101117
initialState: StateT
102-
): WorkflowHost<OutputT, RenderingT> {
103-
val workflowId = workflow.id()
104-
return object : WorkflowHost<OutputT, RenderingT> {
105-
val node = WorkflowNode(workflowId, workflow, input, null, baseContext, initialState)
118+
): WorkflowHost<InputT, OutputT, RenderingT> =
119+
object : WorkflowHost<InputT, OutputT, RenderingT> {
106120
override val updates: ReceiveChannel<Update<OutputT, RenderingT>> =
107-
node.start(workflow, input)
121+
GlobalScope.produce(capacity = 0, context = baseContext) {
122+
runWorkflowTree(
123+
workflow = workflow.asStatefulWorkflow(),
124+
inputs = channelOf(input),
125+
initialSnapshot = null,
126+
initialState = initialState,
127+
onUpdate = ::send
128+
)
129+
}
108130
}
109-
}
110131

111-
internal fun <InputT : Any, OutputT : Any, RenderingT : Any> run(
112-
id: WorkflowId<InputT, OutputT, RenderingT>,
113-
workflow: Workflow<InputT, OutputT, RenderingT>,
114-
input: InputT,
115-
snapshot: Snapshot?,
116-
context: CoroutineContext
117-
): WorkflowHost<OutputT, RenderingT> = object : WorkflowHost<OutputT, RenderingT> {
118-
val node = WorkflowNode(
119-
id = id,
120-
workflow = workflow.asStatefulWorkflow(),
121-
initialInput = input,
122-
snapshot = snapshot,
123-
baseContext = baseContext + context
124-
)
125-
override val updates: ReceiveChannel<Update<OutputT, RenderingT>> =
126-
node.start(workflow.asStatefulWorkflow(), input)
127-
}
132+
private fun <T> channelOf(value: T) = Channel<T>(capacity = 1)
133+
.apply { offer(value) }
128134
}
129135
}
130136

131137
/**
132-
* Starts the coroutine that runs the coroutine loop.
138+
* Loops forever, or until the coroutine is cancelled, processing the workflow tree and emitting
139+
* updates by calling [onUpdate].
140+
*
141+
* This function is the lowest-level entry point into the runtime. Don't call this directly, instead
142+
* use [WorkflowHost.Factory] to create a [WorkflowHost], or one of the stream operators for your
143+
* favorite Rx library to map a stream of [InputT]s into [Update]s.
133144
*/
134-
internal fun <I : Any, O : Any, R : Any> WorkflowNode<I, *, O, R>.start(
135-
workflow: StatefulWorkflow<I, *, O, R>,
136-
input: I
137-
): ReceiveChannel<Update<O, R>> = produce(capacity = 0) {
145+
suspend fun <InputT : Any, StateT : Any, OutputT : Any, RenderingT : Any> runWorkflowTree(
146+
workflow: StatefulWorkflow<InputT, StateT, OutputT, RenderingT>,
147+
inputs: ReceiveChannel<InputT>,
148+
initialSnapshot: Snapshot?,
149+
initialState: StateT? = null,
150+
onUpdate: suspend (Update<OutputT, RenderingT>) -> Unit
151+
): Nothing {
152+
var output: OutputT? = null
153+
var input: InputT = inputs.receive()
154+
var inputsClosed = false
155+
val workflowNode = WorkflowNode(
156+
id = workflow.id(),
157+
workflow = workflow,
158+
initialInput = input,
159+
snapshot = initialSnapshot,
160+
baseContext = coroutineContext,
161+
initialState = initialState
162+
)
163+
138164
try {
139-
var output: O? = null
140-
while (isActive) {
141-
val rendering = compose(workflow, input)
142-
val snapshot = snapshot(workflow)
143-
send(Update(rendering, snapshot, output))
165+
while (true) {
166+
coroutineContext.ensureActive()
167+
168+
val rendering = workflowNode.compose(workflow, input)
169+
val snapshot = workflowNode.snapshot(workflow)
170+
171+
onUpdate(Update(rendering, snapshot, output))
172+
144173
// Tick _might_ return an output, but if it returns null, it means the state or a child
145174
// probably changed, so we should re-compose/snapshot and emit again.
146175
output = select {
147-
tick(this) { it }
176+
// Stop trying to read from the inputs channel after it's closed.
177+
if (!inputsClosed) {
178+
@Suppress("EXPERIMENTAL_API_USAGE")
179+
inputs.onReceiveOrNull { newInput ->
180+
if (newInput == null) {
181+
inputsClosed = true
182+
} else {
183+
input = newInput
184+
}
185+
// No output. Returning from the select will go to the top of the loop to do another
186+
// compose pass.
187+
return@onReceiveOrNull null
188+
}
189+
}
190+
191+
// Tick the workflow tree.
192+
workflowNode.tick(this) { it }
148193
}
149194
}
150195
} catch (e: Throwable) {
@@ -153,9 +198,9 @@ internal fun <I : Any, O : Any, R : Any> WorkflowNode<I, *, O, R>.start(
153198
coroutineContext.cancel(if (e is CancellationException) e else CancellationException(null, e))
154199
throw e
155200
} finally {
156-
// There's a potential race condition if the producer coroutine is cancelled before it has a chance
157-
// to enter the try block, since we can't use CoroutineStart.ATOMIC. However, until we actually
158-
// see this cause problems, I'm not too worried about it.
159-
cancel()
201+
// There's a potential race condition if the producer coroutine is cancelled before it has a
202+
// chance to enter the try block, since we can't use CoroutineStart.ATOMIC. However, until we
203+
// actually see this cause problems, I'm not too worried about it.
204+
workflowNode.cancel()
160205
}
161206
}

kotlin/workflow-rx2-runtime/README.md

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# workflow-rx2-runtime
2+
3+
This module contains adapters to use the Workflow runtime with RxJava2.
+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/*
2+
* Copyright 2019 Square Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
apply plugin: 'java-library'
17+
apply plugin: 'kotlin'
18+
apply plugin: 'com.vanniktech.maven.publish'
19+
apply plugin: 'org.jetbrains.dokka'
20+
21+
sourceCompatibility = JavaVersion.VERSION_1_7
22+
targetCompatibility = JavaVersion.VERSION_1_7
23+
24+
dokka rootProject.ext.defaultDokkaConfig
25+
26+
dependencies {
27+
compileOnly deps.annotations.intellij
28+
29+
api project(':workflow-runtime')
30+
api deps.kotlin.stdLib.jdk6
31+
api deps.kotlin.coroutines.core
32+
api deps.rxjava2.rxjava2
33+
34+
implementation deps.kotlin.coroutines.rx2
35+
36+
testImplementation project(':workflow-testing')
37+
testImplementation deps.kotlin.test.jdk
38+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
#
2+
# Copyright 2019 Square Inc.
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
#
16+
POM_ARTIFACT_ID=workflow-rx2-runtime
17+
POM_NAME=Workflow RxJava2 Runtime
18+
POM_PACKAGING=jar
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/*
2+
* Copyright 2019 Square Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
@file:Suppress("EXPERIMENTAL_API_USAGE")
17+
18+
package com.squareup.workflow.rx2
19+
20+
import com.squareup.workflow.Snapshot
21+
import com.squareup.workflow.Workflow
22+
import com.squareup.workflow.WorkflowHost.Update
23+
import com.squareup.workflow.runWorkflowTree
24+
import io.reactivex.Flowable
25+
import io.reactivex.Observable
26+
import kotlinx.coroutines.Dispatchers
27+
import kotlinx.coroutines.GlobalScope
28+
import kotlinx.coroutines.channels.consume
29+
import kotlinx.coroutines.reactive.openSubscription
30+
import kotlinx.coroutines.rx2.rxFlowable
31+
import kotlin.DeprecationLevel.ERROR
32+
33+
/**
34+
* Given a stream of [InputT] values to use as inputs for the top-level [workflow], returns a
35+
* [Flowable] that, when subscribed to, will start [workflow] and emit its [Update]s. When the
36+
* subscription is disposed, the workflow will be terminated.
37+
*
38+
* The returned [Flowable] is _not_ multicasted – **every subscription will start a new workflow
39+
* session.** It is recommended to use a multicasting operator on the resulting stream, such as
40+
* [Flowable.replay], to share the updates from a single workflow session.
41+
*
42+
* The workflow's logic will run in whatever threading context the source [Flowable] is being
43+
* observed on.
44+
*
45+
* This operator is an alternative to using
46+
* [WorkflowHost.Factory][com.squareup.workflow.WorkflowHost.Factory] that is more convenient to
47+
* use with a stream of inputs.
48+
*
49+
* This function operates on [Flowable] instead of [Observable] because the workflow runtime
50+
* inherently supports backpressure. [Flowable] supports backpressure, [Observable] does not.
51+
* RxJava provides operators to adapt between [Flowable]s and [Observable]s by explicitly specifying
52+
* how to use backpressure. By operating on [Flowable], this operator leaves it up to the caller to
53+
* specify strategies for handling backpressure, instead of assuming any particular behavior.
54+
*/
55+
fun <InputT : Any, OutputT : Any, RenderingT : Any> Flowable<InputT>.flatMapWorkflow(
56+
workflow: Workflow<InputT, OutputT, RenderingT>,
57+
initialSnapshot: Snapshot? = null
58+
): Flowable<Update<OutputT, RenderingT>> = GlobalScope.rxFlowable(
59+
// We use the Unconfined dispatcher here because we want to execute on whatever Rx scheduler the
60+
// source Flowable is being observed on.
61+
context = Dispatchers.Unconfined
62+
) {
63+
// Convert the input stream into a channel.
64+
openSubscription().consume {
65+
runWorkflowTree(
66+
workflow = workflow.asStatefulWorkflow(),
67+
inputs = this,
68+
initialSnapshot = initialSnapshot,
69+
onUpdate = ::send
70+
)
71+
}
72+
}
73+
74+
/**
75+
* Use [Flowable.flatMapWorkflow] instead. This function is just defined to be discoverable when
76+
* working with [Observable]s instead of [Flowable]s.
77+
*
78+
* See [Observable.toFlowable] and [Flowable.toObservable] to convert between stream types.
79+
*/
80+
@Suppress("DeprecatedCallableAddReplaceWith", "unused")
81+
@Deprecated("Use Flowable.flatMapWorkflow instead.", level = ERROR)
82+
fun Observable<*>.flatMapWorkflow(): Nothing =
83+
throw UnsupportedOperationException("Use Flowable.flatMapWorkflow instead.")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/*
2+
* Copyright 2019 Square Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.squareup.workflow.rx2
17+
18+
import kotlin.test.Test
19+
import kotlin.test.fail
20+
21+
class FlatMapWorkflowTest {
22+
23+
@Test fun stuff() {
24+
fail()
25+
}
26+
}

0 commit comments

Comments
 (0)