Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,11 @@ import com.squareup.workflow1.WorkflowExperimentalRuntime
import com.squareup.workflow1.config.AndroidRuntimeConfigTools
import com.squareup.workflow1.ui.Screen
import com.squareup.workflow1.ui.WorkflowLayout
import com.squareup.workflow1.ui.navigation.reportNavigation
import com.squareup.workflow1.ui.renderWorkflowIn
import com.squareup.workflow1.ui.unwrap
import com.squareup.workflow1.ui.withRegistry
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import timber.log.Timber

private val viewRegistry = SampleContainers
Expand Down Expand Up @@ -53,8 +52,8 @@ class PoetryModel(savedState: SavedStateHandle) : ViewModel() {
prop = 0 to 0 to Poem.allPoems,
savedStateHandle = savedState,
runtimeConfig = AndroidRuntimeConfigTools.getAppWorkflowRuntimeConfig()
).onEach {
Timber.i("Navigated to %s", it.unwrap())
).reportNavigation {
Timber.i("Navigated to %s", it)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,12 @@ import com.squareup.workflow1.WorkflowExperimentalRuntime
import com.squareup.workflow1.config.AndroidRuntimeConfigTools
import com.squareup.workflow1.ui.Screen
import com.squareup.workflow1.ui.WorkflowLayout
import com.squareup.workflow1.ui.navigation.reportNavigation
import com.squareup.workflow1.ui.renderWorkflowIn
import com.squareup.workflow1.ui.unwrap
import com.squareup.workflow1.ui.withRegistry
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import timber.log.Timber

Expand Down Expand Up @@ -64,8 +63,8 @@ class RavenModel(savedState: SavedStateHandle) : ViewModel() {
runtimeConfig = AndroidRuntimeConfigTools.getAppWorkflowRuntimeConfig()
) {
running.complete()
}.onEach {
Timber.i("Navigated to %s", it.unwrap())
}.reportNavigation {
Timber.i("Navigated to %s", it)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,12 @@ import com.squareup.workflow1.WorkflowExperimentalRuntime
import com.squareup.workflow1.config.AndroidRuntimeConfigTools
import com.squareup.workflow1.ui.Screen
import com.squareup.workflow1.ui.WorkflowLayout
import com.squareup.workflow1.ui.navigation.reportNavigation
import com.squareup.workflow1.ui.renderWorkflowIn
import com.squareup.workflow1.ui.unwrap
import com.squareup.workflow1.ui.withRegistry
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import timber.log.Timber

Expand Down Expand Up @@ -63,8 +62,8 @@ class HelloBackButtonModel(savedState: SavedStateHandle) : ViewModel() {
// 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()
}.onEach {
Timber.i("Navigated to %s", it.unwrap())
}.reportNavigation {
Timber.i("Navigated to %s", it)
}
}

Expand Down
13 changes: 13 additions & 0 deletions workflow-ui/core-common/api/core-common.api
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ public abstract interface class com/squareup/workflow1/ui/Wrapper : com/squareup
public abstract fun asSequence ()Lkotlin/sequences/Sequence;
public abstract fun getCompatibilityKey ()Ljava/lang/String;
public abstract fun getContent ()Ljava/lang/Object;
public abstract fun getUnwrapped ()Ljava/lang/Object;
public abstract fun map (Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/ui/Wrapper;
}

Expand Down Expand Up @@ -294,6 +295,18 @@ public final class com/squareup/workflow1/ui/navigation/FullScreenModal : com/sq
public abstract interface class com/squareup/workflow1/ui/navigation/ModalOverlay : com/squareup/workflow1/ui/navigation/Overlay {
}

public final class com/squareup/workflow1/ui/navigation/NavigationMonitor {
public fun <init> ()V
public fun <init> (ZLkotlin/jvm/functions/Function1;)V
public synthetic fun <init> (ZLkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun update (Ljava/lang/Object;)V
}

public final class com/squareup/workflow1/ui/navigation/NavigationMonitorKt {
public static final fun reportNavigation (Lkotlinx/coroutines/flow/Flow;ZLkotlin/jvm/functions/Function1;)Lkotlinx/coroutines/flow/Flow;
public static synthetic fun reportNavigation$default (Lkotlinx/coroutines/flow/Flow;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow;
}

public abstract interface class com/squareup/workflow1/ui/navigation/Overlay {
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ public interface Container<CategoryT, out C : CategoryT> : Composite<C> {
public interface Wrapper<BaseT : Any, out C : BaseT> : Container<BaseT, C>, Compatible {
public val content: C

override val unwrapped: Any get() = content

/**
* Default implementation makes this [Wrapper] compatible with others of the same type,
* and which wrap compatible [content].
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ public class BackStackScreen<out StackedT : Screen> internal constructor(

override val compatibilityKey: String = keyFor(this, name)

override val unwrapped: Any get() = top

override fun asSequence(): Sequence<StackedT> = frames.asSequence()

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ public class BodyAndOverlaysScreen<B : Screen, O : Overlay>(
) : Screen, Compatible, Composite<Any> {
override val compatibilityKey: String = keyFor(this, name)

override val unwrapped: Any = overlays.lastOrNull() ?: body

override fun asSequence(): Sequence<Any> = sequenceOf(body) + overlays.asSequence()

public fun <S : Screen> mapBody(transform: (B) -> S): BodyAndOverlaysScreen<S, O> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.squareup.workflow1.ui.navigation

import com.squareup.workflow1.ui.Compatible
import com.squareup.workflow1.ui.unwrap
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.onEach

/**
* Reports navigation across a series of calls to [update], probably made
* for each rendering posted by
* [renderWorkflowIn][com.squareup.workflow1.renderWorkflowIn].
*
* Takes advantage of [unwrap()] and [Compatible.keyFor] to provide navigation
* logging by reporting the top (read: last-most, inner-most) sub-rendering,
* which conventionally is the one that is visible and accessible to the user.
*
* Reports each time the [Compatible.keyFor] the top is unequal to the previous one,
* which conventionally indicates that a new view object will replace the previous one.
*/
public class NavigationMonitor(
skipFirstScreen: Boolean = false,
private val onNavigate: (Any) -> Unit = { println(it) }
) {
@Volatile
private var lastKey: String? = if (skipFirstScreen) null else ""
Copy link
Collaborator Author

@rjrjr rjrjr May 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pyricau @steve-the-edwards We have only ever used our flavor of this on the main thread with the Main.Immediate dispatcher, so this simple implementation has been just fine. Should we stick with what we know, or should this field be marked @Volatile, or…?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My gut is to leave it this way. It's such a trivial class that anyone doing something more interesting and finding flaws could so easily fork it.

Should I put something in the kdoc warning of its limitations?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Enforce that it's single threaded.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or make it volatile it's simple enough and not that expensive.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that's the sanity check I was hoping for, "@Volatile is just fine."


/**
* Uses [unwrap] to find the topmost element of [rendering] and
* reports it with [onNavigate] if [Compatible.keyFor] reveals that
* it is of a different kind from the previous top.
*/
public fun update(rendering: Any) {
val unwrapped = rendering.unwrap()

Compatible.keyFor(unwrapped).takeIf { it != lastKey }?.let { newKey ->
if (lastKey != null) onNavigate(unwrapped)
lastKey = newKey
}
}
}

/**
* Creates a [NavigationMonitor] and [updates it][NavigationMonitor.update]
* with [each element collected][Flow.onEach] by the receiving [Flow].
*/
public fun <T : Any> Flow<T>.reportNavigation(
skipFirstScreen: Boolean = false,
onNavigate: (Any) -> Unit = { println(it) }
): Flow<T> {
val monitor = NavigationMonitor(skipFirstScreen, onNavigate)
return onEach { monitor.update(it) }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
package com.squareup.workflow1.ui.navigation

import com.squareup.workflow1.ui.Compatible
import com.squareup.workflow1.ui.Compatible.Companion.keyFor
import com.squareup.workflow1.ui.Container
import com.squareup.workflow1.ui.Screen
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertNull
import kotlin.test.assertSame

class NavigationMonitorTest {
private data class NotScreen(
val name: String,
val baggage: String = ""
) : Compatible {
override val compatibilityKey: String = keyFor(this, name)
}

private data class TestScreen(
val name: String,
val baggage: String = ""
) : Screen, Compatible {
override val compatibilityKey: String = keyFor(this, name)
}

private data class TestContainer<T : Any>(
val content: List<T>
) : Container<Any, T> {
override fun asSequence(): Sequence<T> = content.asSequence()

override fun <D : Any> map(transform: (T) -> D): Container<Any, D> = error("not relevant")
}

private class TestOverlay<T : Screen>(
override val content: T
) : ScreenOverlay<T> {
override fun <ContentU : Screen> map(transform: (T) -> ContentU) = error("not relevant")
}

private var lastTop: Any? = null
private var updates = 0

private fun onUpdate(top: Any) {
lastTop = top
updates++
}

private val monitor = NavigationMonitor(onNavigate = ::onUpdate)

@Test
fun `reports first by default`() {
val screen = TestScreen("first")
assertNull(lastTop)
monitor.update(screen)
assertSame(screen, lastTop)
}

@Test
fun `can skip first`() {
val monitor = NavigationMonitor(skipFirstScreen = true, ::onUpdate)

assertNull(lastTop)
monitor.update(TestScreen("first"))
assertNull(lastTop)

monitor.update(TestScreen("second"))
assertEquals(TestScreen("second"), lastTop)
}

@Test
fun `reports only on compatibility change`() {
val type1Instance1 = TestScreen("first")
assertEquals(0, updates)

monitor.update(type1Instance1)
assertEquals(1, updates)

val type1Instance2 = type1Instance1.copy(baggage = "baggage")
assertNotEquals(type1Instance1, type1Instance2)
monitor.update(type1Instance2)
assertEquals(1, updates)
assertSame(type1Instance1, lastTop)

val type2 = TestScreen("second")
monitor.update(type2)
assertEquals(2, updates)
assertSame(type2, lastTop)
}

@Test
fun `handles non-Screens`() {
val first = NotScreen("first")

monitor.update(first)
assertSame(first, lastTop)

monitor.update(first.copy(baggage = "fnord"))
assertSame(first, lastTop)
assertEquals(1, updates)

monitor.update(NotScreen("second", baggage = "fnord"))
assertEquals(NotScreen("second", baggage = "fnord"), lastTop)
assertEquals(2, updates)
}

@Test
fun unwraps() {
monitor.update(container(TestScreen("0"), TestScreen("1"), TestScreen("2")))
assertEquals(TestScreen("2"), lastTop)
assertEquals(1, updates)

monitor.update(container(TestScreen("0"), TestScreen("1"), TestScreen("2")))
assertEquals(TestScreen("2"), lastTop)
assertEquals(1, updates)

monitor.update(container(TestScreen("0"), TestScreen("Hidden Update"), TestScreen("2")))
assertEquals(TestScreen("2"), lastTop)
assertEquals(1, updates)

monitor.update(container(TestScreen("0"), TestScreen("Hidden Update"), TestScreen("3")))
assertEquals(TestScreen("3"), lastTop)
assertEquals(2, updates)

monitor.update(container(TestScreen("3", "baggage")))
assertEquals(TestScreen("3"), lastTop)
assertEquals(2, updates)
}

@Test
fun `stock navigation types play nice`() {
val body = TestScreen("Body")

monitor.update(bodyAndOverlays(body))
assertSame(body, lastTop)

monitor.update(bodyAndOverlays(body.copy(baggage = "updated")))
assertSame(body, lastTop)

val firstWindowBody = TestScreen("first window")
monitor.update(bodyAndOverlays(body, TestOverlay(firstWindowBody)))
assertSame(firstWindowBody, lastTop)

val wizardOne = TestScreen("wizard one")
monitor.update(
bodyAndOverlays(
body,
TestOverlay(firstWindowBody),
TestOverlay(BackStackScreen(wizardOne))
)
)
assertSame(wizardOne, lastTop)

monitor.update(
bodyAndOverlays(
body,
TestOverlay(firstWindowBody),
TestOverlay(BackStackScreen(wizardOne.copy(baggage = "updated")))
)
)
assertSame(wizardOne, lastTop)

val wizardTwo = TestScreen("wizard two")
monitor.update(
bodyAndOverlays(
body,
TestOverlay(firstWindowBody),
TestOverlay(
BackStackScreen(
wizardOne.copy(baggage = "updated"),
wizardTwo
)
)
)
)
assertSame(wizardTwo, lastTop)
}

private fun <T : Any> container(vararg elements: T): TestContainer<T> =
TestContainer(elements.toList())

private fun bodyAndOverlays(
body: Screen,
vararg overlays: Overlay
): BodyAndOverlaysScreen<*, *> {
return BodyAndOverlaysScreen(body, overlays.asList())
}
}
Loading