Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions samples/containers/thingy/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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"))
}
26 changes: 26 additions & 0 deletions samples/containers/thingy/lint-baseline.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<issues format="6" by="lint 7.4.1" type="baseline" client="gradle" dependencies="false" name="AGP (7.4.1)" variant="all" version="7.4.1">

<issue
id="UnusedAttribute"
message="Attribute `autoSizeTextType` is only used in API level 26 and higher (current min is 21)"
errorLine1=" android:autoSizeTextType=&quot;uniform&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/layout/hello_back_button_layout.xml"
line="12"
column="7"/>
</issue>

<issue
id="DataExtractionRules"
message="The attribute `android:allowBackup` is deprecated from Android 12 and higher and may be removed in future versions. Consider adding the attribute `android:dataExtractionRules` specifying an `@xml` resource which configures cloud backups and device transfers on Android 12 and higher."
errorLine1=" android:allowBackup=&quot;false&quot;"
errorLine2=" ~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="6"
column="28"/>
</issue>

</issues>
23 changes: 23 additions & 0 deletions samples/containers/thingy/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<application
android:allowBackup="false"
android:label="@string/app_name"
android:theme="@style/AppTheme"
tools:ignore="GoogleAppIndexingWarning,MissingApplicationIcon"
>

<activity android:name=".HelloBackButtonActivity"
android:exported="true">

<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>

</activity>

</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -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<BackStackFrame>) -> Unit

internal class ActionQueue {

private val lock = Any()

private val stateTransformations = mutableListOf<StateTransformation>()
private val outputEmissions = mutableListOf<Any?>()

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<BackStackFrame>) {
val transformations = synchronized(lock) {
stateTransformations.toList().also {
stateTransformations.clear()
}
}
transformations.forEach {
it(stack)
}
}

private fun Updater<Any?, BackStackState, Any?>.consume(
onNextEmitOutputAction: () -> Unit
) {
var transformations: List<StateTransformation>
var output = NullableInitBox<Any?>()
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()
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Runnable>()
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()
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Screen>,
isTopIdle: Boolean
): BackStackScreen<Screen>

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<BackStackFactoryContextElement>
}
Original file line number Diff line number Diff line change
@@ -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<Any?, BackStackState, Any?>): Screen
}
Loading
Loading