From e9579fc1050aa439b99d7f8ed35eb01112888972 Mon Sep 17 00:00:00 2001 From: Wenli Cai Date: Wed, 16 Jul 2025 18:56:39 -0400 Subject: [PATCH 01/24] Log data retrieved from socket --- .../squareup/workflow1/traceviewer/Main.kt | 7 +++ .../traceviewer/util/SocketClient.kt | 51 +++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/Main.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/Main.kt index 485c98c12b..1228065da1 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/Main.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/Main.kt @@ -3,12 +3,19 @@ package com.squareup.workflow1.traceviewer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.Modifier import androidx.compose.ui.window.singleWindowApplication +import com.squareup.workflow1.traceviewer.util.SocketClient /** * Main entry point for the desktop application, see [README.md] for more details. */ fun main() { + val socket = SocketClient() + Runtime.getRuntime().addShutdownHook(Thread { + ProcessBuilder("adb", "forward", "--remove-all") + .start().waitFor() + }) singleWindowApplication(title = "Workflow Trace Viewer") { App(Modifier.fillMaxSize()) } + } diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt new file mode 100644 index 0000000000..2b26f053e8 --- /dev/null +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt @@ -0,0 +1,51 @@ +package com.squareup.workflow1.traceviewer.util + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.net.Socket +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import com.squareup.workflow1.traceviewer.model.Node + +class SocketClient { + private var socket: Socket + private val scope = CoroutineScope(Dispatchers.IO) + + init{ + val moshi = Moshi.Builder() + .add(KotlinJsonAdapterFactory()) + .build() + val workflowList = Types.newParameterizedType(List::class.java, Node::class.java) + val adapter: JsonAdapter> = moshi.adapter(workflowList) + + val process = ProcessBuilder( + "adb", "forward", "tcp:0", "localabstract:workflow-trace" + ).start() + + val port = run { + process.waitFor() + process.inputStream.bufferedReader().readText() + .trim().toInt() + } + println(port) + socket = Socket("localhost", port) + println(socket) + + println("Connected to workflow trace server on port: $port") + var str = "" + scope.launch { + val reader = socket.getInputStream().bufferedReader() + while (true) { + // val reader = socket.getInputStream().bufferedReader() + val input = reader.readLine() + str = input + println("Received: $input") + val renderpass = adapter.fromJson(str) + println(renderpass) + } + } + } +} From a121bf4e42e617529080102219222613dab714a0 Mon Sep 17 00:00:00 2001 From: Wenli Cai Date: Thu, 17 Jul 2025 09:59:25 -0400 Subject: [PATCH 02/24] Change moshi adapter to use generics Streamed data takes in a list of nodes at a time, but a trace file has lists of lists of nodes, so we use generics to differentiate them --- .../kotlin/com/squareup/workflow1/traceviewer/App.kt | 2 +- .../workflow1/traceviewer/util/JsonParser.kt | 12 +++++------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt index e8b1d19639..f7c063106b 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt @@ -26,7 +26,7 @@ import io.github.vinceglb.filekit.PlatformFile * Main composable that provides the different layers of UI. */ @Composable -public fun App( +internal fun App( modifier: Modifier = Modifier ) { var selectedTraceFile by remember { mutableStateOf(null) } diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt index 334ea59a01..b5b3e72734 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt @@ -1,5 +1,6 @@ package com.squareup.workflow1.traceviewer.util +import androidx.compose.ui.input.key.Key.Companion.T import com.squareup.moshi.JsonAdapter import com.squareup.moshi.Moshi import com.squareup.moshi.Types @@ -28,7 +29,7 @@ internal suspend fun parseTrace( file: PlatformFile, ): ParseResult { val jsonString = file.readString() - val workflowAdapter = createMoshiAdapter() + val workflowAdapter = createMoshiAdapter>() val parsedRenderPasses = try { workflowAdapter.fromJson(jsonString) ?: return ParseResult.Failure( IllegalArgumentException("Provided trace file is empty or malformed.") @@ -50,15 +51,12 @@ internal suspend fun parseTrace( /** * Creates a Moshi adapter for parsing the JSON trace file. */ -private fun createMoshiAdapter(): JsonAdapter>> { +internal inline fun createMoshiAdapter(): JsonAdapter> { val moshi = Moshi.Builder() .add(KotlinJsonAdapterFactory()) .build() - val workflowList = Types.newParameterizedType( - List::class.java, - Types.newParameterizedType(List::class.java, Node::class.java) - ) - val adapter: JsonAdapter>> = moshi.adapter(workflowList) + val workflowList = Types.newParameterizedType(List::class.java, T::class.java) + val adapter: JsonAdapter> = moshi.adapter(workflowList) return adapter } From ccdd35c102a1465542d4ec102c738a45b28862d5 Mon Sep 17 00:00:00 2001 From: Wenli Cai Date: Thu, 17 Jul 2025 11:47:06 -0400 Subject: [PATCH 03/24] Create new toggle to switch between tracing modes --- workflow-trace-viewer/README.md | 6 ++ .../com/squareup/workflow1/traceviewer/App.kt | 79 +++++++++++++++---- .../traceviewer/ui/TraceModeToggleSwitch.kt | 58 ++++++++++++++ 3 files changed, 127 insertions(+), 16 deletions(-) create mode 100644 workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/TraceModeToggleSwitch.kt diff --git a/workflow-trace-viewer/README.md b/workflow-trace-viewer/README.md index 57735624af..186d54314b 100644 --- a/workflow-trace-viewer/README.md +++ b/workflow-trace-viewer/README.md @@ -10,6 +10,12 @@ It can be run via Gradle using: ./gradlew :workflow-trace-viewer:run ``` +By Default, the app will be in file parsing mode, where you are able to select a previously recorded workflow trace file for it to visualize the data. + +By hitting the bottom switch, you are able to toggle to live stream mode, where data is directly pulled from the emulator into the visualizer. + +It is ***important*** to run the emulator first before toggling to live mode. + ### Terminology **Trace**: A trace is a file — made up of frames — that contains the execution history of a Workflow. It includes information about render passes, how states have changed within workflows, and the specific props being passed through. The data collected to generate these should be in chronological order, and allows developers to step through the process easily. diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt index f7c063106b..13bf391360 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -16,9 +17,12 @@ import androidx.compose.ui.geometry.Offset import com.squareup.workflow1.traceviewer.model.Node import com.squareup.workflow1.traceviewer.model.NodeUpdate import com.squareup.workflow1.traceviewer.ui.FrameSelectTab -import com.squareup.workflow1.traceviewer.ui.RenderDiagram +import com.squareup.workflow1.traceviewer.ui.RenderFileTrace +import com.squareup.workflow1.traceviewer.ui.RenderLiveTrace import com.squareup.workflow1.traceviewer.ui.RightInfoPanel +import com.squareup.workflow1.traceviewer.ui.TraceModeToggleSwitch import com.squareup.workflow1.traceviewer.util.SandboxBackground +import com.squareup.workflow1.traceviewer.util.SocketClient import com.squareup.workflow1.traceviewer.util.UploadFile import io.github.vinceglb.filekit.PlatformFile @@ -31,10 +35,14 @@ internal fun App( ) { var selectedTraceFile by remember { mutableStateOf(null) } var selectedNode by remember { mutableStateOf(null) } - var workflowFrames by remember { mutableStateOf>(emptyList()) } + val workflowFrames = remember { mutableStateListOf() } var frameIndex by remember { mutableIntStateOf(0) } val sandboxState = remember { SandboxState() } + // Default to file mode, and can be toggled to be in live mode. + var traceMode by remember { mutableStateOf(TraceMode.File) } + val socket = remember { SocketClient() } + LaunchedEffect(sandboxState) { snapshotFlow { frameIndex }.collect { sandboxState.reset() @@ -44,20 +52,41 @@ internal fun App( Box( modifier = modifier ) { + fun resetStates() = run { + socket.close() + selectedTraceFile = null + selectedNode = null + frameIndex = 0 + workflowFrames.clear() + } + // Main content - if (selectedTraceFile != null) { - SandboxBackground( - sandboxState = sandboxState, - ) { - RenderDiagram( + SandboxBackground( + sandboxState = sandboxState, + ) { + if (selectedTraceFile != null) { + RenderFileTrace( traceFile = selectedTraceFile!!, frameInd = frameIndex, - onFileParse = { workflowFrames = it }, + onFileParse = { workflowFrames.addAll(it) }, onNodeSelect = { node, prevNode -> selectedNode = NodeUpdate(node, prevNode) } ) } + if (traceMode is TraceMode.Live) { + socket.start() + RenderLiveTrace( + socket = socket, + frameInd = frameIndex, + onNodeSelect = { node, prevNode -> + selectedNode = NodeUpdate(node, prevNode) + }, + onNewFrame = { newFrame -> + workflowFrames.add(newFrame) + } + ) + } } FrameSelectTab( @@ -73,16 +102,29 @@ internal fun App( .align(Alignment.TopEnd) ) - // The states are reset when a new file is selected. - UploadFile( - resetOnFileSelect = { - selectedTraceFile = it - selectedNode = null - frameIndex = 0 - workflowFrames = emptyList() + TraceModeToggleSwitch( + onToggle = { + resetStates() + traceMode = if (traceMode is TraceMode.Live) { + TraceMode.File + } else { + TraceMode.Live + } }, - modifier = Modifier.align(Alignment.BottomStart) + traceMode = traceMode, + modifier = Modifier.align(Alignment.BottomCenter) ) + + // The states are reset when a new file is selected. + if (traceMode is TraceMode.File) { + UploadFile( + resetOnFileSelect = { + resetStates() + selectedTraceFile = it + }, + modifier = Modifier.align(Alignment.BottomStart) + ) + } } } @@ -95,3 +137,8 @@ internal class SandboxState { scale = 1f } } + +internal sealed interface TraceMode { + object File : TraceMode + object Live : TraceMode +} diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/TraceModeToggleSwitch.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/TraceModeToggleSwitch.kt new file mode 100644 index 0000000000..d1d75bb1ad --- /dev/null +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/TraceModeToggleSwitch.kt @@ -0,0 +1,58 @@ +package com.squareup.workflow1.traceviewer.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Switch +import androidx.compose.material.SwitchDefaults +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.squareup.workflow1.traceviewer.TraceMode + +@Composable +internal fun TraceModeToggleSwitch( + onToggle: () -> Unit, + traceMode: TraceMode, + modifier: Modifier +) { + // File mode is unchecked by default, and live mode is checked. + var checked by remember { + mutableStateOf(traceMode is TraceMode.Live) + } + + Column( + modifier = modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Switch( + checked = checked, + onCheckedChange = { + checked = it + onToggle() + }, + colors = SwitchDefaults.colors( + checkedThumbColor = Color.Black, + checkedTrackColor = Color.Black, + ) + ) + + Text( + text = if (traceMode is TraceMode.Live) { + "Live Mode" + } else { + "File Mode" + }, + fontSize = 12.sp, + fontStyle = FontStyle.Italic + ) + } +} From f678ab7917609a468aa6d93f08b08be682a1840e Mon Sep 17 00:00:00 2001 From: Wenli Cai Date: Thu, 17 Jul 2025 13:37:46 -0400 Subject: [PATCH 04/24] Fix moshi adapter Supplying generics don't seem to work and runs into error with nesting. This solution supplies a type for moshi and forces a cast to the desired type. --- .../workflow1/traceviewer/util/JsonParser.kt | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt index b5b3e72734..7165709837 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt @@ -11,6 +11,7 @@ import com.squareup.workflow1.traceviewer.model.replaceChild import io.github.vinceglb.filekit.PlatformFile import io.github.vinceglb.filekit.readString import java.util.LinkedHashMap +import java.lang.reflect.Type /* The root workflow Node uses an ID of 0, and since we are filtering childrenByParent by the @@ -25,11 +26,13 @@ const val ROOT_ID: String = "-1" * @return A [ParseResult] representing result of parsing, either an error related to the * format of the JSON, or a success and a parsed trace. */ +@Suppress("UNCHECKED_CAST") internal suspend fun parseTrace( file: PlatformFile, ): ParseResult { val jsonString = file.readString() - val workflowAdapter = createMoshiAdapter>() + val workflowAdapter = createMoshiAdapter(Types.newParameterizedType(List::class.java, Node::class.java)) + as JsonAdapter>> val parsedRenderPasses = try { workflowAdapter.fromJson(jsonString) ?: return ParseResult.Failure( IllegalArgumentException("Provided trace file is empty or malformed.") @@ -48,16 +51,30 @@ internal suspend fun parseTrace( return ParseResult.Success(parsedFrames, frameTrees, parsedRenderPasses) } +// /** +// * Creates a Moshi adapter for parsing the JSON trace file. +// */ +// internal inline fun createMoshiAdapter(): JsonAdapter> { +// val moshi = Moshi.Builder() +// .add(KotlinJsonAdapterFactory()) +// .build() +// val workflowList = Types.newParameterizedType(List::class.java, T::class.java) +// val adapter: JsonAdapter> = moshi.adapter(workflowList) +// return adapter +// } + /** * Creates a Moshi adapter for parsing the JSON trace file. */ -internal inline fun createMoshiAdapter(): JsonAdapter> { +internal fun createMoshiAdapter(nestedType: Type): JsonAdapter> { val moshi = Moshi.Builder() .add(KotlinJsonAdapterFactory()) .build() - val workflowList = Types.newParameterizedType(List::class.java, T::class.java) - val adapter: JsonAdapter> = moshi.adapter(workflowList) - return adapter + val workflowListType = Types.newParameterizedType( + List::class.java, + nestedType + ) + return moshi.adapter(workflowListType) } /** From c63c43a271a169bb1f976a3bab15029f135ccca7 Mon Sep 17 00:00:00 2001 From: Wenli Cai Date: Thu, 17 Jul 2025 14:55:59 -0400 Subject: [PATCH 05/24] Extract socket setup and reading logic. --- .../traceviewer/util/SocketClient.kt | 75 +++++++++++-------- 1 file changed, 43 insertions(+), 32 deletions(-) diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt index 2b26f053e8..1365628951 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt @@ -1,51 +1,62 @@ package com.squareup.workflow1.traceviewer.util -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch +import kotlinx.coroutines.channels.Channel import java.net.Socket -import com.squareup.moshi.JsonAdapter -import com.squareup.moshi.Moshi -import com.squareup.moshi.Types -import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory -import com.squareup.workflow1.traceviewer.model.Node -class SocketClient { - private var socket: Socket - private val scope = CoroutineScope(Dispatchers.IO) - - init{ - val moshi = Moshi.Builder() - .add(KotlinJsonAdapterFactory()) - .build() - val workflowList = Types.newParameterizedType(List::class.java, Node::class.java) - val adapter: JsonAdapter> = moshi.adapter(workflowList) +/** + * This is a client that connects to the `ActionLogger` Unix Domain Socket and listens for any new + * render passes. Since this app is on JVM and the server is on Android, we use ADB to forward the + * port onto the socket. + */ +internal class SocketClient { + private lateinit var socket: Socket + private var initialized = false + val renderPassChannel: Channel = Channel(Channel.UNLIMITED) + /** + * We use any available ports on the host machine to connect to the emulator. + * + * `workflow-trace` is the name of the unix socket created, and since Android uses + * `LocalServerSocket` -- which creates a unix socket on the linux abstract namespace -- we use + * `localabstract:` to connect to it. + */ + fun start() { + initialized = true val process = ProcessBuilder( "adb", "forward", "tcp:0", "localabstract:workflow-trace" ).start() + // The adb forward command will output the port number it picks to connect. val port = run { process.waitFor() process.inputStream.bufferedReader().readText() .trim().toInt() } - println(port) + // println(port) socket = Socket("localhost", port) - println(socket) + // println("Connected to workflow trace server on port: $port") + } + + fun close() { + if (!initialized) { + return + } + socket.close() + initialized = false + } - println("Connected to workflow trace server on port: $port") - var str = "" - scope.launch { - val reader = socket.getInputStream().bufferedReader() - while (true) { - // val reader = socket.getInputStream().bufferedReader() - val input = reader.readLine() - str = input - println("Received: $input") - val renderpass = adapter.fromJson(str) - println(renderpass) - } + /** + * This will always be called within an asynchronous call, so we do not need to block/launch a + * new coroutine here. + * + * To better separate the responsibility of reading from the socket, we use a channel for the caller + * to handle parsing and amalgamating the render passes. + */ + fun beginListen() { + val reader = socket.getInputStream().bufferedReader() + while (true) { + val input = reader.readLine() + renderPassChannel.trySend(input) } } } From d326385b21f40c75ad40722cf4073b9de16302f3 Mon Sep 17 00:00:00 2001 From: Wenli Cai Date: Thu, 17 Jul 2025 14:59:55 -0400 Subject: [PATCH 06/24] WIP --- .../com/squareup/workflow1/traceviewer/App.kt | 1 + .../workflow1/traceviewer/ui/WorkflowTree.kt | 33 +++++++++++++++++-- .../workflow1/traceviewer/util/JsonParser.kt | 20 +++++++++-- 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt index 13bf391360..139cd9009a 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt @@ -142,3 +142,4 @@ internal sealed interface TraceMode { object File : TraceMode object Live : TraceMode } + diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt index bd2c21f8a6..7d153aa883 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt @@ -21,9 +21,14 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Types import com.squareup.workflow1.traceviewer.model.Node import com.squareup.workflow1.traceviewer.util.ParseResult -import com.squareup.workflow1.traceviewer.util.parseTrace +import com.squareup.workflow1.traceviewer.util.SocketClient +import com.squareup.workflow1.traceviewer.util.createMoshiAdapter +import com.squareup.workflow1.traceviewer.util.parseFileTrace +import com.squareup.workflow1.traceviewer.util.parseLiveTrace import io.github.vinceglb.filekit.PlatformFile /** @@ -31,7 +36,7 @@ import io.github.vinceglb.filekit.PlatformFile * tabs. This will also all errors related to errors parsing a given trace JSON file. */ @Composable -internal fun RenderDiagram( +internal fun RenderFileTrace( traceFile: PlatformFile, frameInd: Int, onFileParse: (List) -> Unit, @@ -45,7 +50,7 @@ internal fun RenderDiagram( var affectedNodes = remember { mutableStateListOf>() } LaunchedEffect(traceFile) { - val parseResult = parseTrace(traceFile) + val parseResult = parseFileTrace(traceFile) when (parseResult) { is ParseResult.Failure -> { @@ -73,6 +78,28 @@ internal fun RenderDiagram( } } +@Composable +@Suppress("UNCHECKED_CAST") +internal fun RenderLiveTrace( + socket: SocketClient, + frameInd: Int, + onNodeSelect: (Node, Node?) -> Unit, + onNewFrame: (Node) -> Unit, +) { + var frames = + + val workflowAdapter = createMoshiAdapter(Types.newParameterizedType(Node::class.java)) as + JsonAdapter> + + LaunchedEffect(Unit){ + socket.beginListen() + for (renderPass in socket.renderPassChannel) { + val parseResult = parseLiveTrace(workflowAdapter, renderPass) + + } + } +} + /** * Since the workflow nodes present a tree structure, we utilize a recursive function to draw the tree * The Column holds a subtree of nodes, and the Row holds all the children of the current node diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt index 7165709837..6589a52350 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt @@ -27,7 +27,7 @@ const val ROOT_ID: String = "-1" * format of the JSON, or a success and a parsed trace. */ @Suppress("UNCHECKED_CAST") -internal suspend fun parseTrace( +internal suspend fun parseFileTrace( file: PlatformFile, ): ParseResult { val jsonString = file.readString() @@ -51,6 +51,20 @@ internal suspend fun parseTrace( return ParseResult.Success(parsedFrames, frameTrees, parsedRenderPasses) } +internal fun parseLiveTrace( + adapter: JsonAdapter>, + renderPass: String, +): ParseResult { + val parsedRenderPasses = try { + adapter.fromJson(renderPass) ?: return ParseResult.Failure( + IllegalArgumentException("Provided trace file is empty or malformed.") + ) + } catch (e: Exception) { + return ParseResult.Failure(e) + } + +} + // /** // * Creates a Moshi adapter for parsing the JSON trace file. // */ @@ -82,7 +96,7 @@ internal fun createMoshiAdapter(nestedType: Type): JsonAdapter> { * * @return Node the root node of the tree for that specific frame. */ -private fun getFrameFromRenderPass(renderPass: List): Node { +internal fun getFrameFromRenderPass(renderPass: List): Node { val childrenByParent: Map> = renderPass.groupBy { it.parentId } val root = childrenByParent[ROOT_ID]?.single() return buildTree(root!!, childrenByParent) @@ -91,7 +105,7 @@ private fun getFrameFromRenderPass(renderPass: List): Node { /** * Recursively builds a tree using each node's children. */ -private fun buildTree(node: Node, childrenByParent: Map>): Node { +internal fun buildTree(node: Node, childrenByParent: Map>): Node { val children = (childrenByParent[node.id] ?: emptyList()) .map { buildTree(it, childrenByParent) } return Node( From 6e71ae990e53aa311248ae3164efdbeb788a5def Mon Sep 17 00:00:00 2001 From: Wenli Cai Date: Thu, 17 Jul 2025 16:15:21 -0400 Subject: [PATCH 07/24] Consolidate logic to take in TraceMode rather than specific params Extend TraceMode logic by using it to store the file/socket being used in the specific mode. The logic of rendering will depend on the type of TraceMode --- .../com/squareup/workflow1/traceviewer/App.kt | 40 ++++----- .../workflow1/traceviewer/ui/WorkflowTree.kt | 89 ++++++++++--------- .../workflow1/traceviewer/util/JsonParser.kt | 25 +++--- 3 files changed, 72 insertions(+), 82 deletions(-) diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt index 139cd9009a..b9f67edcd2 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt @@ -17,8 +17,7 @@ import androidx.compose.ui.geometry.Offset import com.squareup.workflow1.traceviewer.model.Node import com.squareup.workflow1.traceviewer.model.NodeUpdate import com.squareup.workflow1.traceviewer.ui.FrameSelectTab -import com.squareup.workflow1.traceviewer.ui.RenderFileTrace -import com.squareup.workflow1.traceviewer.ui.RenderLiveTrace +import com.squareup.workflow1.traceviewer.ui.RenderTrace import com.squareup.workflow1.traceviewer.ui.RightInfoPanel import com.squareup.workflow1.traceviewer.ui.TraceModeToggleSwitch import com.squareup.workflow1.traceviewer.util.SandboxBackground @@ -33,14 +32,14 @@ import io.github.vinceglb.filekit.PlatformFile internal fun App( modifier: Modifier = Modifier ) { - var selectedTraceFile by remember { mutableStateOf(null) } var selectedNode by remember { mutableStateOf(null) } val workflowFrames = remember { mutableStateListOf() } var frameIndex by remember { mutableIntStateOf(0) } val sandboxState = remember { SandboxState() } - // Default to file mode, and can be toggled to be in live mode. - var traceMode by remember { mutableStateOf(TraceMode.File) } + // Default to File mode, and can be toggled to be in Live mode. + var traceMode by remember { mutableStateOf(TraceMode.File(null)) } + var selectedTraceFile by remember { mutableStateOf(null) } val socket = remember { SocketClient() } LaunchedEffect(sandboxState) { @@ -64,9 +63,11 @@ internal fun App( SandboxBackground( sandboxState = sandboxState, ) { - if (selectedTraceFile != null) { - RenderFileTrace( - traceFile = selectedTraceFile!!, + // if there is not a file selected and trace mode is live, then don't render anything. + val readyForFileTrace = traceMode is TraceMode.File && selectedTraceFile != null + if (readyForFileTrace || traceMode is TraceMode.Live) { + RenderTrace( + traceSource = traceMode, frameInd = frameIndex, onFileParse = { workflowFrames.addAll(it) }, onNodeSelect = { node, prevNode -> @@ -74,19 +75,6 @@ internal fun App( } ) } - if (traceMode is TraceMode.Live) { - socket.start() - RenderLiveTrace( - socket = socket, - frameInd = frameIndex, - onNodeSelect = { node, prevNode -> - selectedNode = NodeUpdate(node, prevNode) - }, - onNewFrame = { newFrame -> - workflowFrames.add(newFrame) - } - ) - } } FrameSelectTab( @@ -106,9 +94,10 @@ internal fun App( onToggle = { resetStates() traceMode = if (traceMode is TraceMode.Live) { - TraceMode.File + TraceMode.File(null) } else { - TraceMode.Live + // TODO: TraceRecorder needs to be able to take in multiple clients if this is the case + TraceMode.Live(socket) } }, traceMode = traceMode, @@ -121,6 +110,7 @@ internal fun App( resetOnFileSelect = { resetStates() selectedTraceFile = it + traceMode = TraceMode.File(it) }, modifier = Modifier.align(Alignment.BottomStart) ) @@ -139,7 +129,7 @@ internal class SandboxState { } internal sealed interface TraceMode { - object File : TraceMode - object Live : TraceMode + data class File(val file: PlatformFile?) : TraceMode + data class Live(val socket: SocketClient = SocketClient()) : TraceMode } diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt index 7d153aa883..76c6f42731 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt @@ -23,50 +23,73 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.squareup.moshi.JsonAdapter import com.squareup.moshi.Types +import com.squareup.workflow1.traceviewer.TraceMode import com.squareup.workflow1.traceviewer.model.Node import com.squareup.workflow1.traceviewer.util.ParseResult -import com.squareup.workflow1.traceviewer.util.SocketClient import com.squareup.workflow1.traceviewer.util.createMoshiAdapter import com.squareup.workflow1.traceviewer.util.parseFileTrace -import com.squareup.workflow1.traceviewer.util.parseLiveTrace -import io.github.vinceglb.filekit.PlatformFile /** * Access point for drawing the main content of the app. It will load the trace for given files and * tabs. This will also all errors related to errors parsing a given trace JSON file. + * + * This handles either File or Live trace modes, and will parse equally */ @Composable -internal fun RenderFileTrace( - traceFile: PlatformFile, +@Suppress("UNCHECKED_CAST") +internal fun RenderTrace( + traceSource: TraceMode, frameInd: Int, onFileParse: (List) -> Unit, onNodeSelect: (Node, Node?) -> Unit, modifier: Modifier = Modifier ) { - var isLoading by remember(traceFile) { mutableStateOf(true) } - var error by remember(traceFile) { mutableStateOf(null) } - var frames = remember { mutableStateListOf() } - var fullTree = remember { mutableStateListOf() } - var affectedNodes = remember { mutableStateListOf>() } + var isLoading by remember(traceSource) { mutableStateOf(true) } + var error by remember(traceSource) { mutableStateOf(null) } + val frames = remember { mutableStateListOf() } + val fullTree = remember { mutableStateListOf() } + val affectedNodes = remember { mutableStateListOf>() } + + LaunchedEffect(traceSource) { + when (traceSource) { + is TraceMode.File -> { + // We guarantee the file is null since this composable can only be called when a file is selected + val parseResult = parseFileTrace(traceSource.file!!) - LaunchedEffect(traceFile) { - val parseResult = parseFileTrace(traceFile) + when (parseResult) { + is ParseResult.Failure -> { + error = parseResult.error + } - when (parseResult) { - is ParseResult.Failure -> { - error = parseResult.error + is ParseResult.Success -> { + val parsedFrames = parseResult.trace ?: emptyList() + frames.addAll(parsedFrames) + fullTree.addAll(parseResult.trees) + affectedNodes.addAll(parseResult.affectedNodes) + onFileParse(parsedFrames) + isLoading = false + } + } } - is ParseResult.Success -> { - val parsedFrames = parseResult.trace ?: emptyList() - frames.addAll(parsedFrames) - fullTree.addAll(parseResult.trees) - affectedNodes.addAll(parseResult.affectedNodes) - onFileParse(parsedFrames) - isLoading = false + + is TraceMode.Live -> { + val socket = traceSource.socket + socket.beginListen() + val adapter: JsonAdapter> = createMoshiAdapter( + Types.newParameterizedType(Node::class.java) + ) as JsonAdapter> + + // Since channel implements ChannelIterator, we can for-loop through on the receiver end + for (renderPass in socket.renderPassChannel) { + // get the parsedRenderPass + // build the Frame + // merge Frame into full tree + } } } } + if (error != null) { Text("Error parsing file: ${error?.message}") return @@ -78,28 +101,6 @@ internal fun RenderFileTrace( } } -@Composable -@Suppress("UNCHECKED_CAST") -internal fun RenderLiveTrace( - socket: SocketClient, - frameInd: Int, - onNodeSelect: (Node, Node?) -> Unit, - onNewFrame: (Node) -> Unit, -) { - var frames = - - val workflowAdapter = createMoshiAdapter(Types.newParameterizedType(Node::class.java)) as - JsonAdapter> - - LaunchedEffect(Unit){ - socket.beginListen() - for (renderPass in socket.renderPassChannel) { - val parseResult = parseLiveTrace(workflowAdapter, renderPass) - - } - } -} - /** * Since the workflow nodes present a tree structure, we utilize a recursive function to draw the tree * The Column holds a subtree of nodes, and the Row holds all the children of the current node diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt index 6589a52350..06dce8d06e 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt @@ -51,19 +51,18 @@ internal suspend fun parseFileTrace( return ParseResult.Success(parsedFrames, frameTrees, parsedRenderPasses) } -internal fun parseLiveTrace( - adapter: JsonAdapter>, - renderPass: String, -): ParseResult { - val parsedRenderPasses = try { - adapter.fromJson(renderPass) ?: return ParseResult.Failure( - IllegalArgumentException("Provided trace file is empty or malformed.") - ) - } catch (e: Exception) { - return ParseResult.Failure(e) - } - -} +// internal fun parseLiveTrace( +// adapter: JsonAdapter>, +// renderPass: String, +// ): ParseResult { +// val parsedRenderPasses = try { +// adapter.fromJson(renderPass) ?: return ParseResult.Failure( +// IllegalArgumentException("Provided trace file is empty or malformed.") +// ) +// } catch (e: Exception) { +// return ParseResult.Failure(e) +// } +// } // /** // * Creates a Moshi adapter for parsing the JSON trace file. From f5fdb6e7b7a3b2dec4d8c818bb0f6cfdf1e5451c Mon Sep 17 00:00:00 2001 From: wenli-cai <213805610+wenli-cai@users.noreply.github.com> Date: Fri, 18 Jul 2025 13:30:43 +0000 Subject: [PATCH 08/24] Apply changes from apiDump Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- workflow-trace-viewer/api/workflow-trace-viewer.api | 4 ---- 1 file changed, 4 deletions(-) diff --git a/workflow-trace-viewer/api/workflow-trace-viewer.api b/workflow-trace-viewer/api/workflow-trace-viewer.api index 698647f649..377827f5c5 100644 --- a/workflow-trace-viewer/api/workflow-trace-viewer.api +++ b/workflow-trace-viewer/api/workflow-trace-viewer.api @@ -1,7 +1,3 @@ -public final class com/squareup/workflow1/traceviewer/AppKt { - public static final fun App (Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V -} - public final class com/squareup/workflow1/traceviewer/ComposableSingletons$MainKt { public static final field INSTANCE Lcom/squareup/workflow1/traceviewer/ComposableSingletons$MainKt; public static field lambda-1 Lkotlin/jvm/functions/Function3; From 8c1b2ddc20ca483240574932ac521ad7057554bc Mon Sep 17 00:00:00 2001 From: Wenli Cai Date: Fri, 18 Jul 2025 10:24:32 -0400 Subject: [PATCH 09/24] Stream data from emulator directly into visualizer --- .../com/squareup/workflow1/traceviewer/App.kt | 5 ++ .../workflow1/traceviewer/ui/WorkflowTree.kt | 61 +++++++++++++++---- .../workflow1/traceviewer/util/JsonParser.kt | 2 +- .../traceviewer/util/SocketClient.kt | 19 +++--- 4 files changed, 67 insertions(+), 20 deletions(-) diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt index b9f67edcd2..dd707739be 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt @@ -42,6 +42,10 @@ internal fun App( var selectedTraceFile by remember { mutableStateOf(null) } val socket = remember { SocketClient() } + Runtime.getRuntime().addShutdownHook(Thread { + socket.close() + }) + LaunchedEffect(sandboxState) { snapshotFlow { frameIndex }.collect { sandboxState.reset() @@ -97,6 +101,7 @@ internal fun App( TraceMode.File(null) } else { // TODO: TraceRecorder needs to be able to take in multiple clients if this is the case + socket.start() TraceMode.Live(socket) } }, diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt index 76c6f42731..be22453148 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt @@ -27,7 +27,11 @@ import com.squareup.workflow1.traceviewer.TraceMode import com.squareup.workflow1.traceviewer.model.Node import com.squareup.workflow1.traceviewer.util.ParseResult import com.squareup.workflow1.traceviewer.util.createMoshiAdapter +import com.squareup.workflow1.traceviewer.util.getFrameFromRenderPass +import com.squareup.workflow1.traceviewer.util.mergeFrameIntoMainTree import com.squareup.workflow1.traceviewer.util.parseFileTrace +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext /** * Access point for drawing the main content of the app. It will load the trace for given files and @@ -50,6 +54,14 @@ internal fun RenderTrace( val fullTree = remember { mutableStateListOf() } val affectedNodes = remember { mutableStateListOf>() } + fun addToStates(frame: List, tree: List, affected:List>) { + frames.addAll(frame) + fullTree.addAll(tree) + affectedNodes.addAll(affected) + isLoading = false + onFileParse(frame) + } + LaunchedEffect(traceSource) { when (traceSource) { is TraceMode.File -> { @@ -62,28 +74,52 @@ internal fun RenderTrace( } is ParseResult.Success -> { - val parsedFrames = parseResult.trace ?: emptyList() - frames.addAll(parsedFrames) - fullTree.addAll(parseResult.trees) - affectedNodes.addAll(parseResult.affectedNodes) - onFileParse(parsedFrames) - isLoading = false + addToStates( + frame = parseResult.trace, + tree = parseResult.trees, + affected = parseResult.affectedNodes + ) } } } is TraceMode.Live -> { val socket = traceSource.socket - socket.beginListen() + socket.beginListen(this) val adapter: JsonAdapter> = createMoshiAdapter( - Types.newParameterizedType(Node::class.java) + Node::class.java ) as JsonAdapter> // Since channel implements ChannelIterator, we can for-loop through on the receiver end - for (renderPass in socket.renderPassChannel) { - // get the parsedRenderPass - // build the Frame - // merge Frame into full tree + withContext(Dispatchers.IO) { + for (renderPass in socket.renderPassChannel) { + // get the parsedRenderPass + val parsedRenderPass = try { + adapter.fromJson(renderPass) ?: emptyList() + } catch (e: Exception) { + error = e + continue + } + + // build the Frame + val parsedFrame = getFrameFromRenderPass(parsedRenderPass) + + // merge Frame into full tree + val mergedTree = if (fullTree.isEmpty()) { + parsedFrame + } else { + mergeFrameIntoMainTree(parsedFrame, fullTree.last()) + } + + withContext(Dispatchers.Default){ + println("SAVING") + addToStates( + frame = listOf(parsedFrame), + tree = listOf(mergedTree), + affected = listOf(parsedRenderPass.toSet()) + ) + } + } } } } @@ -95,6 +131,7 @@ internal fun RenderTrace( return } + println("RENDERING") if (!isLoading) { val previousFrame = if (frameInd > 0) fullTree[frameInd - 1] else null DrawTree(fullTree[frameInd], previousFrame, affectedNodes[frameInd], onNodeSelect) diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt index 06dce8d06e..575f04b0d7 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt @@ -144,7 +144,7 @@ internal fun mergeFrameIntoMainTree( } internal sealed interface ParseResult { - class Success(val trace: List?, val trees: List, affectedNodes: List>) : ParseResult { + class Success(val trace: List, val trees: List, affectedNodes: List>) : ParseResult { val affectedNodes = affectedNodes.map { it.toSet() } } class Failure(val error: Throwable) : ParseResult diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt index 1365628951..cc5ef91a0c 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt @@ -1,6 +1,10 @@ package com.squareup.workflow1.traceviewer.util +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.net.Socket /** @@ -32,9 +36,7 @@ internal class SocketClient { process.inputStream.bufferedReader().readText() .trim().toInt() } - // println(port) socket = Socket("localhost", port) - // println("Connected to workflow trace server on port: $port") } fun close() { @@ -52,11 +54,14 @@ internal class SocketClient { * To better separate the responsibility of reading from the socket, we use a channel for the caller * to handle parsing and amalgamating the render passes. */ - fun beginListen() { - val reader = socket.getInputStream().bufferedReader() - while (true) { - val input = reader.readLine() - renderPassChannel.trySend(input) + fun beginListen(scope: CoroutineScope) { + scope.launch(Dispatchers.IO) { + val reader = socket.getInputStream().bufferedReader() + while (true) { + val input = reader.readLine() + println(input) + renderPassChannel.trySend(input) + } } } } From 9d50d60ad3d0c8a9c1c8964f0a8b3da876ec47e1 Mon Sep 17 00:00:00 2001 From: Wenli Cai Date: Fri, 18 Jul 2025 10:28:16 -0400 Subject: [PATCH 10/24] Return to using generics for Moshi adapter Previously, the use of types and manually casting was due to type erasure. But using kotlin's typeOf allows us to still supply the nested types. --- .../workflow1/traceviewer/ui/WorkflowTree.kt | 5 +-- .../workflow1/traceviewer/util/JsonParser.kt | 31 +++++-------------- .../traceviewer/util/SocketClient.kt | 1 - 3 files changed, 8 insertions(+), 29 deletions(-) diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt index be22453148..3065d3bc34 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt @@ -22,7 +22,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.squareup.moshi.JsonAdapter -import com.squareup.moshi.Types import com.squareup.workflow1.traceviewer.TraceMode import com.squareup.workflow1.traceviewer.model.Node import com.squareup.workflow1.traceviewer.util.ParseResult @@ -86,9 +85,7 @@ internal fun RenderTrace( is TraceMode.Live -> { val socket = traceSource.socket socket.beginListen(this) - val adapter: JsonAdapter> = createMoshiAdapter( - Node::class.java - ) as JsonAdapter> + val adapter: JsonAdapter> = createMoshiAdapter() // Since channel implements ChannelIterator, we can for-loop through on the receiver end withContext(Dispatchers.IO) { diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt index 575f04b0d7..29afe1967f 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt @@ -1,6 +1,5 @@ package com.squareup.workflow1.traceviewer.util -import androidx.compose.ui.input.key.Key.Companion.T import com.squareup.moshi.JsonAdapter import com.squareup.moshi.Moshi import com.squareup.moshi.Types @@ -10,8 +9,8 @@ import com.squareup.workflow1.traceviewer.model.addChild import com.squareup.workflow1.traceviewer.model.replaceChild import io.github.vinceglb.filekit.PlatformFile import io.github.vinceglb.filekit.readString -import java.util.LinkedHashMap -import java.lang.reflect.Type +import kotlin.reflect.jvm.javaType +import kotlin.reflect.typeOf /* The root workflow Node uses an ID of 0, and since we are filtering childrenByParent by the @@ -26,13 +25,11 @@ const val ROOT_ID: String = "-1" * @return A [ParseResult] representing result of parsing, either an error related to the * format of the JSON, or a success and a parsed trace. */ -@Suppress("UNCHECKED_CAST") internal suspend fun parseFileTrace( file: PlatformFile, ): ParseResult { val jsonString = file.readString() - val workflowAdapter = createMoshiAdapter(Types.newParameterizedType(List::class.java, Node::class.java)) - as JsonAdapter>> + val workflowAdapter = createMoshiAdapter>() val parsedRenderPasses = try { workflowAdapter.fromJson(jsonString) ?: return ParseResult.Failure( IllegalArgumentException("Provided trace file is empty or malformed.") @@ -64,30 +61,16 @@ internal suspend fun parseFileTrace( // } // } -// /** -// * Creates a Moshi adapter for parsing the JSON trace file. -// */ -// internal inline fun createMoshiAdapter(): JsonAdapter> { -// val moshi = Moshi.Builder() -// .add(KotlinJsonAdapterFactory()) -// .build() -// val workflowList = Types.newParameterizedType(List::class.java, T::class.java) -// val adapter: JsonAdapter> = moshi.adapter(workflowList) -// return adapter -// } - /** * Creates a Moshi adapter for parsing the JSON trace file. */ -internal fun createMoshiAdapter(nestedType: Type): JsonAdapter> { +internal inline fun createMoshiAdapter(): JsonAdapter> { val moshi = Moshi.Builder() .add(KotlinJsonAdapterFactory()) .build() - val workflowListType = Types.newParameterizedType( - List::class.java, - nestedType - ) - return moshi.adapter(workflowListType) + val workflowList = Types.newParameterizedType(List::class.java, typeOf().javaType) + val adapter: JsonAdapter> = moshi.adapter(workflowList) + return adapter } /** diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt index cc5ef91a0c..1c3f71b791 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt @@ -4,7 +4,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import java.net.Socket /** From 70c9b4529a1d8694365d6a4bf07fa4aeb0fe7fe3 Mon Sep 17 00:00:00 2001 From: Wenli Cai Date: Fri, 18 Jul 2025 10:51:19 -0400 Subject: [PATCH 11/24] Allow for auto scrolling during Live Mode This will always show the most recent workflow --- .../kotlin/com/squareup/workflow1/traceviewer/App.kt | 5 ++++- .../com/squareup/workflow1/traceviewer/ui/FrameSelectTab.kt | 6 +++++- .../com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt | 4 ++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt index dd707739be..064c8a0c6f 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt @@ -76,7 +76,8 @@ internal fun App( onFileParse = { workflowFrames.addAll(it) }, onNodeSelect = { node, prevNode -> selectedNode = NodeUpdate(node, prevNode) - } + }, + onNewFrame = { frameIndex += 1} ) } } @@ -98,9 +99,11 @@ internal fun App( onToggle = { resetStates() traceMode = if (traceMode is TraceMode.Live) { + frameIndex = 0 TraceMode.File(null) } else { // TODO: TraceRecorder needs to be able to take in multiple clients if this is the case + frameIndex = -1 socket.start() TraceMode.Live(socket) } diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/FrameSelectTab.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/FrameSelectTab.kt index c811e7168e..7564223846 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/FrameSelectTab.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/FrameSelectTab.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color @@ -25,7 +26,10 @@ internal fun FrameSelectTab( modifier: Modifier = Modifier ) { val state = rememberLazyListState() - + LaunchedEffect(currentIndex){ + if (currentIndex < 0) return@LaunchedEffect + state.animateScrollToItem(currentIndex) + } Surface( modifier = modifier .padding(4.dp), diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt index 3065d3bc34..af171da603 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt @@ -45,6 +45,7 @@ internal fun RenderTrace( frameInd: Int, onFileParse: (List) -> Unit, onNodeSelect: (Node, Node?) -> Unit, + onNewFrame: () -> Unit, modifier: Modifier = Modifier ) { var isLoading by remember(traceSource) { mutableStateOf(true) } @@ -109,12 +110,12 @@ internal fun RenderTrace( } withContext(Dispatchers.Default){ - println("SAVING") addToStates( frame = listOf(parsedFrame), tree = listOf(mergedTree), affected = listOf(parsedRenderPass.toSet()) ) + onNewFrame() } } } @@ -128,7 +129,6 @@ internal fun RenderTrace( return } - println("RENDERING") if (!isLoading) { val previousFrame = if (frameInd > 0) fullTree[frameInd - 1] else null DrawTree(fullTree[frameInd], previousFrame, affectedNodes[frameInd], onNodeSelect) From e233921c9cef8e261be1ff5149cd5d2573fcd2ed Mon Sep 17 00:00:00 2001 From: Wenli Cai Date: Fri, 18 Jul 2025 11:37:34 -0400 Subject: [PATCH 12/24] Fix SocketException Since reader.readLine() is a blocking call, it's difficult to pause/end when socket.close() is called. Instead we just try-catch for when the error occurs after closing. --- .../com/squareup/workflow1/traceviewer/App.kt | 2 +- .../workflow1/traceviewer/util/SocketClient.kt | 14 +++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt index 064c8a0c6f..23fd232a55 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt @@ -132,7 +132,7 @@ internal class SandboxState { fun reset() { offset = Offset.Zero - scale = 1f + // scale = 1f } } diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt index 1c3f71b791..4532d6da64 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt @@ -2,9 +2,11 @@ package com.squareup.workflow1.traceviewer.util import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch import java.net.Socket +import java.net.SocketException /** * This is a client that connects to the `ActionLogger` Unix Domain Socket and listens for any new @@ -43,7 +45,6 @@ internal class SocketClient { return } socket.close() - initialized = false } /** @@ -56,10 +57,13 @@ internal class SocketClient { fun beginListen(scope: CoroutineScope) { scope.launch(Dispatchers.IO) { val reader = socket.getInputStream().bufferedReader() - while (true) { - val input = reader.readLine() - println(input) - renderPassChannel.trySend(input) + try { + while (true) { + val input = reader.readLine() + renderPassChannel.trySend(input) + } + } catch (e: SocketException) { + println("Exiting socket listener due to: ${e.message}") } } } From f414e756bdad04916e509982ee5ad47d2cdf3a9e Mon Sep 17 00:00:00 2001 From: Wenli Cai Date: Fri, 18 Jul 2025 12:01:58 -0400 Subject: [PATCH 13/24] Fix lint violations --- workflow-trace-viewer/README.md | 4 ++-- .../com/squareup/workflow1/traceviewer/App.kt | 15 ++++++++++----- .../com/squareup/workflow1/traceviewer/Main.kt | 13 ++++++------- .../workflow1/traceviewer/ui/FrameSelectTab.kt | 2 +- .../traceviewer/ui/TraceModeToggleSwitch.kt | 2 +- .../workflow1/traceviewer/ui/WorkflowTree.kt | 6 ++---- .../workflow1/traceviewer/util/SocketClient.kt | 1 - 7 files changed, 22 insertions(+), 21 deletions(-) diff --git a/workflow-trace-viewer/README.md b/workflow-trace-viewer/README.md index 186d54314b..37dd3d3ad7 100644 --- a/workflow-trace-viewer/README.md +++ b/workflow-trace-viewer/README.md @@ -10,9 +10,9 @@ It can be run via Gradle using: ./gradlew :workflow-trace-viewer:run ``` -By Default, the app will be in file parsing mode, where you are able to select a previously recorded workflow trace file for it to visualize the data. +By Default, the app will be in file parsing mode, where you are able to select a previously recorded workflow trace file for it to visualize the data. -By hitting the bottom switch, you are able to toggle to live stream mode, where data is directly pulled from the emulator into the visualizer. +By hitting the bottom switch, you are able to toggle to live stream mode, where data is directly pulled from the emulator into the visualizer. It is ***important*** to run the emulator first before toggling to live mode. diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt index 23fd232a55..832bd8bb5c 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt @@ -42,9 +42,11 @@ internal fun App( var selectedTraceFile by remember { mutableStateOf(null) } val socket = remember { SocketClient() } - Runtime.getRuntime().addShutdownHook(Thread { - socket.close() - }) + Runtime.getRuntime().addShutdownHook( + Thread { + socket.close() + } + ) LaunchedEffect(sandboxState) { snapshotFlow { frameIndex }.collect { @@ -77,7 +79,7 @@ internal fun App( onNodeSelect = { node, prevNode -> selectedNode = NodeUpdate(node, prevNode) }, - onNewFrame = { frameIndex += 1} + onNewFrame = { frameIndex += 1 } ) } } @@ -103,6 +105,10 @@ internal fun App( TraceMode.File(null) } else { // TODO: TraceRecorder needs to be able to take in multiple clients if this is the case + /* + We set the frame to -1 here since we always increment it during Live mode as the list of + frames get populated, so we avoid off by one when indexing into the frames. + */ frameIndex = -1 socket.start() TraceMode.Live(socket) @@ -140,4 +146,3 @@ internal sealed interface TraceMode { data class File(val file: PlatformFile?) : TraceMode data class Live(val socket: SocketClient = SocketClient()) : TraceMode } - diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/Main.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/Main.kt index 1228065da1..29513b15fc 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/Main.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/Main.kt @@ -3,19 +3,18 @@ package com.squareup.workflow1.traceviewer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.Modifier import androidx.compose.ui.window.singleWindowApplication -import com.squareup.workflow1.traceviewer.util.SocketClient /** * Main entry point for the desktop application, see [README.md] for more details. */ fun main() { - val socket = SocketClient() - Runtime.getRuntime().addShutdownHook(Thread { - ProcessBuilder("adb", "forward", "--remove-all") - .start().waitFor() - }) + Runtime.getRuntime().addShutdownHook( + Thread { + ProcessBuilder("adb", "forward", "--remove-all") + .start().waitFor() + } + ) singleWindowApplication(title = "Workflow Trace Viewer") { App(Modifier.fillMaxSize()) } - } diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/FrameSelectTab.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/FrameSelectTab.kt index 7564223846..9a47de4d79 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/FrameSelectTab.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/FrameSelectTab.kt @@ -26,7 +26,7 @@ internal fun FrameSelectTab( modifier: Modifier = Modifier ) { val state = rememberLazyListState() - LaunchedEffect(currentIndex){ + LaunchedEffect(currentIndex) { if (currentIndex < 0) return@LaunchedEffect state.animateScrollToItem(currentIndex) } diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/TraceModeToggleSwitch.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/TraceModeToggleSwitch.kt index d1d75bb1ad..3863d9e80a 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/TraceModeToggleSwitch.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/TraceModeToggleSwitch.kt @@ -22,7 +22,7 @@ import com.squareup.workflow1.traceviewer.TraceMode internal fun TraceModeToggleSwitch( onToggle: () -> Unit, traceMode: TraceMode, - modifier: Modifier + modifier: Modifier = Modifier ) { // File mode is unchecked by default, and live mode is checked. var checked by remember { diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt index af171da603..4d5b931895 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt @@ -39,7 +39,6 @@ import kotlinx.coroutines.withContext * This handles either File or Live trace modes, and will parse equally */ @Composable -@Suppress("UNCHECKED_CAST") internal fun RenderTrace( traceSource: TraceMode, frameInd: Int, @@ -54,7 +53,7 @@ internal fun RenderTrace( val fullTree = remember { mutableStateListOf() } val affectedNodes = remember { mutableStateListOf>() } - fun addToStates(frame: List, tree: List, affected:List>) { + fun addToStates(frame: List, tree: List, affected: List>) { frames.addAll(frame) fullTree.addAll(tree) affectedNodes.addAll(affected) @@ -109,7 +108,7 @@ internal fun RenderTrace( mergeFrameIntoMainTree(parsedFrame, fullTree.last()) } - withContext(Dispatchers.Default){ + withContext(Dispatchers.Default) { addToStates( frame = listOf(parsedFrame), tree = listOf(mergedTree), @@ -123,7 +122,6 @@ internal fun RenderTrace( } } - if (error != null) { Text("Error parsing file: ${error?.message}") return diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt index 4532d6da64..30bb66e379 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt @@ -2,7 +2,6 @@ package com.squareup.workflow1.traceviewer.util import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch import java.net.Socket From fff11db961714bf935a651eb8997a06d2777ff8a Mon Sep 17 00:00:00 2001 From: Wenli Cai Date: Fri, 18 Jul 2025 12:40:24 -0400 Subject: [PATCH 14/24] Consolidate live and trace mode render on result logic --- .../workflow1/traceviewer/ui/WorkflowTree.kt | 78 ++++++++----------- .../workflow1/traceviewer/util/JsonParser.kt | 58 ++++++++++---- 2 files changed, 75 insertions(+), 61 deletions(-) diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt index 4d5b931895..710a64bf5e 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt @@ -26,9 +26,9 @@ import com.squareup.workflow1.traceviewer.TraceMode import com.squareup.workflow1.traceviewer.model.Node import com.squareup.workflow1.traceviewer.util.ParseResult import com.squareup.workflow1.traceviewer.util.createMoshiAdapter -import com.squareup.workflow1.traceviewer.util.getFrameFromRenderPass -import com.squareup.workflow1.traceviewer.util.mergeFrameIntoMainTree import com.squareup.workflow1.traceviewer.util.parseFileTrace +import com.squareup.workflow1.traceviewer.util.parseLiveTrace +import io.github.vinceglb.filekit.dialogs.FileKitMode.Single.parseResult import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -53,6 +53,7 @@ internal fun RenderTrace( val fullTree = remember { mutableStateListOf() } val affectedNodes = remember { mutableStateListOf>() } + // Updates current state with the new data from trace source. fun addToStates(frame: List, tree: List, affected: List>) { frames.addAll(frame) fullTree.addAll(tree) @@ -61,25 +62,35 @@ internal fun RenderTrace( onFileParse(frame) } + // Handles the result of parsing a trace, either from file or live. Live mode includes callback + // for when a new frame is received. + fun handleParseResult( + parseResult: ParseResult, + onNewFrame: (() -> Unit)? = null + ): Boolean { + return when (parseResult) { + is ParseResult.Failure -> { + error = parseResult.error + false + } + is ParseResult.Success -> { + addToStates( + frame = parseResult.trace, + tree = parseResult.trees, + affected = parseResult.affectedNodes + ) + onNewFrame?.invoke() + true + } + } + } + LaunchedEffect(traceSource) { when (traceSource) { is TraceMode.File -> { - // We guarantee the file is null since this composable can only be called when a file is selected + // We guarantee the file is null since this composable can only be called when a file is selected. val parseResult = parseFileTrace(traceSource.file!!) - - when (parseResult) { - is ParseResult.Failure -> { - error = parseResult.error - } - - is ParseResult.Success -> { - addToStates( - frame = parseResult.trace, - tree = parseResult.trees, - affected = parseResult.affectedNodes - ) - } - } + handleParseResult(parseResult) } is TraceMode.Live -> { @@ -87,35 +98,12 @@ internal fun RenderTrace( socket.beginListen(this) val adapter: JsonAdapter> = createMoshiAdapter() - // Since channel implements ChannelIterator, we can for-loop through on the receiver end withContext(Dispatchers.IO) { + // Since channel implements ChannelIterator, we can for-loop through on the receiver end. for (renderPass in socket.renderPassChannel) { - // get the parsedRenderPass - val parsedRenderPass = try { - adapter.fromJson(renderPass) ?: emptyList() - } catch (e: Exception) { - error = e - continue - } - - // build the Frame - val parsedFrame = getFrameFromRenderPass(parsedRenderPass) - - // merge Frame into full tree - val mergedTree = if (fullTree.isEmpty()) { - parsedFrame - } else { - mergeFrameIntoMainTree(parsedFrame, fullTree.last()) - } - - withContext(Dispatchers.Default) { - addToStates( - frame = listOf(parsedFrame), - tree = listOf(mergedTree), - affected = listOf(parsedRenderPass.toSet()) - ) - onNewFrame() - } + val currentTree = if (fullTree.isEmpty()) null else fullTree.last() + val parseResult = parseLiveTrace(renderPass, adapter, currentTree) + handleParseResult(parseResult, onNewFrame) } } } @@ -123,7 +111,7 @@ internal fun RenderTrace( } if (error != null) { - Text("Error parsing file: ${error?.message}") + Text("Error parsing: ${error?.message}") return } diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt index 29afe1967f..d0f4a2a095 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt @@ -45,21 +45,47 @@ internal suspend fun parseFileTrace( frameTrees.add(mergedTree) mergedTree } - return ParseResult.Success(parsedFrames, frameTrees, parsedRenderPasses) + return ParseResult.Success( + trace = parsedFrames, + trees = frameTrees, + affectedNodes = parsedRenderPasses) } -// internal fun parseLiveTrace( -// adapter: JsonAdapter>, -// renderPass: String, -// ): ParseResult { -// val parsedRenderPasses = try { -// adapter.fromJson(renderPass) ?: return ParseResult.Failure( -// IllegalArgumentException("Provided trace file is empty or malformed.") -// ) -// } catch (e: Exception) { -// return ParseResult.Failure(e) -// } -// } +/** + * Parses a single render pass from a live trace stream. + * Similar to parseFileTrace but handles one render pass at a time. + * + * @return ParseResult containing the new frame, merged tree, and current render pass nodes. + */ +internal fun parseLiveTrace( + renderPass: String, + adapter: JsonAdapter>, + currentTree: Node? = null +): ParseResult { + val parsedRenderPass = try { + adapter.fromJson(renderPass) ?: return ParseResult.Failure( + IllegalArgumentException("Provided trace data is empty or malformed.") + ) + } catch (e: Exception) { + return ParseResult.Failure(e) + } + + val parsedFrame = getFrameFromRenderPass(parsedRenderPass) + + // Merge Frame into full tree if we have an existing tree + val mergedTree = if (currentTree == null) { + parsedFrame + } else { + mergeFrameIntoMainTree(parsedFrame, currentTree) + } + + // Since live tracing handles one frame at a time, we generalize and return listOf for each. + return ParseResult.Success( + trace = listOf(parsedFrame), + trees = listOf(mergedTree), + affectedNodes = listOf(parsedRenderPass) + ) +} /** * Creates a Moshi adapter for parsing the JSON trace file. @@ -78,7 +104,7 @@ internal inline fun createMoshiAdapter(): JsonAdapter> { * * @return Node the root node of the tree for that specific frame. */ -internal fun getFrameFromRenderPass(renderPass: List): Node { +private fun getFrameFromRenderPass(renderPass: List): Node { val childrenByParent: Map> = renderPass.groupBy { it.parentId } val root = childrenByParent[ROOT_ID]?.single() return buildTree(root!!, childrenByParent) @@ -87,7 +113,7 @@ internal fun getFrameFromRenderPass(renderPass: List): Node { /** * Recursively builds a tree using each node's children. */ -internal fun buildTree(node: Node, childrenByParent: Map>): Node { +private fun buildTree(node: Node, childrenByParent: Map>): Node { val children = (childrenByParent[node.id] ?: emptyList()) .map { buildTree(it, childrenByParent) } return Node( @@ -108,7 +134,7 @@ internal fun buildTree(node: Node, childrenByParent: Map>): N * * @return Node the newly formed tree with the frame merged into it. */ -internal fun mergeFrameIntoMainTree( +private fun mergeFrameIntoMainTree( frame: Node, main: Node ): Node { From cd8b4abf7d851a0f5c35487142092c0fd3dff96d Mon Sep 17 00:00:00 2001 From: Wenli Cai Date: Fri, 18 Jul 2025 13:15:05 -0400 Subject: [PATCH 15/24] Clean up compose violations --- .../jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt | 4 +++- .../com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt | 2 -- .../com/squareup/workflow1/traceviewer/util/JsonParser.kt | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt index 832bd8bb5c..a24693fbd5 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt @@ -71,7 +71,9 @@ internal fun App( ) { // if there is not a file selected and trace mode is live, then don't render anything. val readyForFileTrace = traceMode is TraceMode.File && selectedTraceFile != null - if (readyForFileTrace || traceMode is TraceMode.Live) { + val readyForLiveTrace = traceMode is TraceMode.Live + + if (readyForFileTrace || readyForLiveTrace) { RenderTrace( traceSource = traceMode, frameInd = frameIndex, diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt index 710a64bf5e..6c09f71894 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt @@ -28,7 +28,6 @@ import com.squareup.workflow1.traceviewer.util.ParseResult import com.squareup.workflow1.traceviewer.util.createMoshiAdapter import com.squareup.workflow1.traceviewer.util.parseFileTrace import com.squareup.workflow1.traceviewer.util.parseLiveTrace -import io.github.vinceglb.filekit.dialogs.FileKitMode.Single.parseResult import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -175,7 +174,6 @@ private fun DrawNode( modifier = Modifier .background(if (isAffected) Color.Green else Color.Transparent) .clickable { - // Selecting a node will bubble back up to the main view to handle the selection onNodeSelect(node, previousNode) } .padding(10.dp) diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt index d0f4a2a095..f9a90cf251 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt @@ -48,7 +48,8 @@ internal suspend fun parseFileTrace( return ParseResult.Success( trace = parsedFrames, trees = frameTrees, - affectedNodes = parsedRenderPasses) + affectedNodes = parsedRenderPasses + ) } /** From a5d20acea69a1fa5ecc84d763cb0d4f823a3d9e6 Mon Sep 17 00:00:00 2001 From: Wenli Cai Date: Fri, 18 Jul 2025 13:54:47 -0400 Subject: [PATCH 16/24] Change function visibility for test --- .../com/squareup/workflow1/traceviewer/util/JsonParser.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt index f9a90cf251..096612c4f3 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt @@ -135,12 +135,11 @@ private fun buildTree(node: Node, childrenByParent: Map>): No * * @return Node the newly formed tree with the frame merged into it. */ -private fun mergeFrameIntoMainTree( +internal fun mergeFrameIntoMainTree( frame: Node, main: Node ): Node { require(frame.id == main.id) - val updatedNode = frame.copy(children = main.children) return frame.children.values.fold(updatedNode) { mergedTree, frameChild -> From febd6d5cabbe6471be04ea8db8f16d802cd552d9 Mon Sep 17 00:00:00 2001 From: Wenli Cai Date: Fri, 18 Jul 2025 15:19:47 -0400 Subject: [PATCH 17/24] Fix merge bug --- .../com/squareup/workflow1/traceviewer/ui/FrameSelectTab.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/FrameSelectTab.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/FrameSelectTab.kt index d97ce3c27c..b78b4c4da5 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/FrameSelectTab.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/FrameSelectTab.kt @@ -31,10 +31,10 @@ internal fun FrameSelectTab( onIndexChange: (Int) -> Unit, modifier: Modifier = Modifier ) { - val state = rememberLazyListState() + val lazyListState = rememberLazyListState() LaunchedEffect(currentIndex) { if (currentIndex < 0) return@LaunchedEffect - state.animateScrollToItem(currentIndex) + lazyListState.animateScrollToItem(currentIndex) } Surface( From 764f991926b4d80fb938507a629a34930c3eb208 Mon Sep 17 00:00:00 2001 From: Wenli Cai Date: Wed, 23 Jul 2025 12:23:08 -0400 Subject: [PATCH 18/24] Fix PR comments --- workflow-trace-viewer/README.md | 2 +- .../com/squareup/workflow1/traceviewer/App.kt | 13 ++--- .../squareup/workflow1/traceviewer/Main.kt | 2 +- .../traceviewer/ui/FrameSelectTab.kt | 7 +-- .../workflow1/traceviewer/ui/WorkflowTree.kt | 30 +++++++---- .../traceviewer/util/SocketClient.kt | 51 ++++++++++++------- 6 files changed, 62 insertions(+), 43 deletions(-) diff --git a/workflow-trace-viewer/README.md b/workflow-trace-viewer/README.md index 37dd3d3ad7..77cfc65ae0 100644 --- a/workflow-trace-viewer/README.md +++ b/workflow-trace-viewer/README.md @@ -12,7 +12,7 @@ It can be run via Gradle using: By Default, the app will be in file parsing mode, where you are able to select a previously recorded workflow trace file for it to visualize the data. -By hitting the bottom switch, you are able to toggle to live stream mode, where data is directly pulled from the emulator into the visualizer. +By hitting the bottom switch, you are able to toggle to live stream mode, where data is directly pulled from the emulator into the visualizer. A connection can only happen once. If there needs to be rerecording of the trace, the emulator must first be restarted, and then the app must be restarted as well. This is due to the fact that any open socket will consume all render pass data, meaning there is nothing to read from the emulator. It is ***important*** to run the emulator first before toggling to live mode. diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt index a24693fbd5..3305db117b 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt @@ -42,12 +42,6 @@ internal fun App( var selectedTraceFile by remember { mutableStateOf(null) } val socket = remember { SocketClient() } - Runtime.getRuntime().addShutdownHook( - Thread { - socket.close() - } - ) - LaunchedEffect(sandboxState) { snapshotFlow { frameIndex }.collect { sandboxState.reset() @@ -57,7 +51,7 @@ internal fun App( Box( modifier = modifier ) { - fun resetStates() = run { + fun resetStates() { socket.close() selectedTraceFile = null selectedNode = null @@ -112,7 +106,7 @@ internal fun App( frames get populated, so we avoid off by one when indexing into the frames. */ frameIndex = -1 - socket.start() + socket.open() TraceMode.Live(socket) } }, @@ -140,11 +134,10 @@ internal class SandboxState { fun reset() { offset = Offset.Zero - // scale = 1f } } internal sealed interface TraceMode { data class File(val file: PlatformFile?) : TraceMode - data class Live(val socket: SocketClient = SocketClient()) : TraceMode + data class Live(val socket: SocketClient) : TraceMode } diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/Main.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/Main.kt index 29513b15fc..a4734e78c5 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/Main.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/Main.kt @@ -14,7 +14,7 @@ fun main() { .start().waitFor() } ) - singleWindowApplication(title = "Workflow Trace Viewer") { + singleWindowApplication(title = "Workflow Trace Viewer", exitProcessOnExit = false) { App(Modifier.fillMaxSize()) } } diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/FrameSelectTab.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/FrameSelectTab.kt index b78b4c4da5..efe6f31c74 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/FrameSelectTab.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/FrameSelectTab.kt @@ -32,9 +32,10 @@ internal fun FrameSelectTab( modifier: Modifier = Modifier ) { val lazyListState = rememberLazyListState() - LaunchedEffect(currentIndex) { - if (currentIndex < 0) return@LaunchedEffect - lazyListState.animateScrollToItem(currentIndex) + if (currentIndex >= 0) { + LaunchedEffect(currentIndex) { + lazyListState.animateScrollToItem(currentIndex) + } } Surface( diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt index 102c0276e1..d096a1492c 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt @@ -34,7 +34,9 @@ import com.squareup.workflow1.traceviewer.util.createMoshiAdapter import com.squareup.workflow1.traceviewer.util.parseFileTrace import com.squareup.workflow1.traceviewer.util.parseLiveTrace import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.net.SocketException /** * Access point for drawing the main content of the app. It will load the trace for given files and @@ -71,11 +73,10 @@ internal fun RenderTrace( fun handleParseResult( parseResult: ParseResult, onNewFrame: (() -> Unit)? = null - ): Boolean { - return when (parseResult) { + ) { + when (parseResult) { is ParseResult.Failure -> { error = parseResult.error - false } is ParseResult.Success -> { addToStates( @@ -84,7 +85,6 @@ internal fun RenderTrace( affected = parseResult.affectedNodes ) onNewFrame?.invoke() - true } } } @@ -92,20 +92,32 @@ internal fun RenderTrace( LaunchedEffect(traceSource) { when (traceSource) { is TraceMode.File -> { - // We guarantee the file is null since this composable can only be called when a file is selected. - val parseResult = parseFileTrace(traceSource.file!!) + checkNotNull(traceSource.file){ + "TraceMode.File should have a non-null file to parse." + } + val parseResult = parseFileTrace(traceSource.file) handleParseResult(parseResult) } is TraceMode.Live -> { val socket = traceSource.socket - socket.beginListen(this) + launch { + try { + socket.pollSocket() + } catch (e: SocketException) { + error = SocketException("Socket has already been closed or is not available: ${e.message}") + return@launch + } + } + if (error != null) { + return@LaunchedEffect + } val adapter: JsonAdapter> = createMoshiAdapter() - withContext(Dispatchers.IO) { + withContext(Dispatchers.Default) { // Since channel implements ChannelIterator, we can for-loop through on the receiver end. for (renderPass in socket.renderPassChannel) { - val currentTree = if (fullTree.isEmpty()) null else fullTree.last() + val currentTree = fullTree.lastOrNull() val parseResult = parseLiveTrace(renderPass, adapter, currentTree) handleParseResult(parseResult, onNewFrame) } diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt index 30bb66e379..85ee3cb75d 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt @@ -2,20 +2,28 @@ package com.squareup.workflow1.traceviewer.util import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.yield + import java.net.Socket import java.net.SocketException /** - * This is a client that connects to the `ActionLogger` Unix Domain Socket and listens for any new - * render passes. Since this app is on JVM and the server is on Android, we use ADB to forward the - * port onto the socket. + * This is a client that can connect to any server socket that sends render pass data while using + * the Workflow framework. + * + * [start] and [close] are idempotent commands, so this socket can only be started and closed once. + * + * Since this app is on JVM and the server is on Android, we use ADB to forward the port onto the socket. */ internal class SocketClient { private lateinit var socket: Socket private var initialized = false - val renderPassChannel: Channel = Channel(Channel.UNLIMITED) + val renderPassChannel: Channel = Channel(Channel.BUFFERED) /** * We use any available ports on the host machine to connect to the emulator. @@ -24,18 +32,20 @@ internal class SocketClient { * `LocalServerSocket` -- which creates a unix socket on the linux abstract namespace -- we use * `localabstract:` to connect to it. */ - fun start() { + fun open() { + if (initialized){ + return + } initialized = true val process = ProcessBuilder( "adb", "forward", "tcp:0", "localabstract:workflow-trace" ).start() // The adb forward command will output the port number it picks to connect. - val port = run { - process.waitFor() - process.inputStream.bufferedReader().readText() + process.waitFor() + val port = process.inputStream.bufferedReader().readText() .trim().toInt() - } + socket = Socket("localhost", port) } @@ -47,22 +57,25 @@ internal class SocketClient { } /** - * This will always be called within an asynchronous call, so we do not need to block/launch a - * new coroutine here. + * Polls the socket's input stream and sends the data into [renderPassChannel]. + * The caller should handle the scope of the coroutine that this function is called in. * * To better separate the responsibility of reading from the socket, we use a channel for the caller * to handle parsing and amalgamating the render passes. */ - fun beginListen(scope: CoroutineScope) { - scope.launch(Dispatchers.IO) { + suspend fun pollSocket() { + withContext(Dispatchers.IO) { val reader = socket.getInputStream().bufferedReader() - try { - while (true) { - val input = reader.readLine() - renderPassChannel.trySend(input) + reader.use { + try { + while (true) { + val input = reader.readLine() + renderPassChannel.trySend(input) + println(input) + } + } catch (e: SocketException) { + e.printStackTrace() } - } catch (e: SocketException) { - println("Exiting socket listener due to: ${e.message}") } } } From a0f22da3b88da2723ac6b6711e6435840f8cc21a Mon Sep 17 00:00:00 2001 From: Wenli Cai Date: Wed, 23 Jul 2025 13:12:11 -0400 Subject: [PATCH 19/24] Extract parsing logic from tree rendering logic --- .../com/squareup/workflow1/traceviewer/App.kt | 2 +- .../workflow1/traceviewer/ui/WorkflowTree.kt | 123 +---------------- .../workflow1/traceviewer/util/JsonParser.kt | 2 +- .../traceviewer/util/SocketClient.kt | 11 +- .../workflow1/traceviewer/util/TraceParser.kt | 125 ++++++++++++++++++ 5 files changed, 130 insertions(+), 133 deletions(-) create mode 100644 workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/TraceParser.kt diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt index 3305db117b..f5b4e52870 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt @@ -17,7 +17,7 @@ import androidx.compose.ui.geometry.Offset import com.squareup.workflow1.traceviewer.model.Node import com.squareup.workflow1.traceviewer.model.NodeUpdate import com.squareup.workflow1.traceviewer.ui.FrameSelectTab -import com.squareup.workflow1.traceviewer.ui.RenderTrace +import com.squareup.workflow1.traceviewer.util.RenderTrace import com.squareup.workflow1.traceviewer.ui.RightInfoPanel import com.squareup.workflow1.traceviewer.ui.TraceModeToggleSwitch import com.squareup.workflow1.traceviewer.util.SandboxBackground diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt index d096a1492c..d7bf164fd7 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt @@ -11,12 +11,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier @@ -26,122 +20,7 @@ import androidx.compose.ui.input.pointer.isPrimaryPressed import androidx.compose.ui.input.pointer.isSecondaryPressed import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.unit.dp -import com.squareup.moshi.JsonAdapter -import com.squareup.workflow1.traceviewer.TraceMode import com.squareup.workflow1.traceviewer.model.Node -import com.squareup.workflow1.traceviewer.util.ParseResult -import com.squareup.workflow1.traceviewer.util.createMoshiAdapter -import com.squareup.workflow1.traceviewer.util.parseFileTrace -import com.squareup.workflow1.traceviewer.util.parseLiveTrace -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.net.SocketException - -/** - * Access point for drawing the main content of the app. It will load the trace for given files and - * tabs. This will also all errors related to errors parsing a given trace JSON file. - * - * This handles either File or Live trace modes, and will parse equally - */ -@Composable -internal fun RenderTrace( - traceSource: TraceMode, - frameInd: Int, - onFileParse: (List) -> Unit, - onNodeSelect: (Node, Node?) -> Unit, - onNewFrame: () -> Unit, - modifier: Modifier = Modifier -) { - var isLoading by remember(traceSource) { mutableStateOf(true) } - var error by remember(traceSource) { mutableStateOf(null) } - val frames = remember { mutableStateListOf() } - val fullTree = remember { mutableStateListOf() } - val affectedNodes = remember { mutableStateListOf>() } - - // Updates current state with the new data from trace source. - fun addToStates(frame: List, tree: List, affected: List>) { - frames.addAll(frame) - fullTree.addAll(tree) - affectedNodes.addAll(affected) - isLoading = false - onFileParse(frame) - } - - // Handles the result of parsing a trace, either from file or live. Live mode includes callback - // for when a new frame is received. - fun handleParseResult( - parseResult: ParseResult, - onNewFrame: (() -> Unit)? = null - ) { - when (parseResult) { - is ParseResult.Failure -> { - error = parseResult.error - } - is ParseResult.Success -> { - addToStates( - frame = parseResult.trace, - tree = parseResult.trees, - affected = parseResult.affectedNodes - ) - onNewFrame?.invoke() - } - } - } - - LaunchedEffect(traceSource) { - when (traceSource) { - is TraceMode.File -> { - checkNotNull(traceSource.file){ - "TraceMode.File should have a non-null file to parse." - } - val parseResult = parseFileTrace(traceSource.file) - handleParseResult(parseResult) - } - - is TraceMode.Live -> { - val socket = traceSource.socket - launch { - try { - socket.pollSocket() - } catch (e: SocketException) { - error = SocketException("Socket has already been closed or is not available: ${e.message}") - return@launch - } - } - if (error != null) { - return@LaunchedEffect - } - val adapter: JsonAdapter> = createMoshiAdapter() - - withContext(Dispatchers.Default) { - // Since channel implements ChannelIterator, we can for-loop through on the receiver end. - for (renderPass in socket.renderPassChannel) { - val currentTree = fullTree.lastOrNull() - val parseResult = parseLiveTrace(renderPass, adapter, currentTree) - handleParseResult(parseResult, onNewFrame) - } - } - } - } - } - - if (error != null) { - Text("Error parsing: ${error?.message}") - return - } - - if (!isLoading) { - val previousFrame = if (frameInd > 0) fullTree[frameInd - 1] else null - DrawTree( - node = fullTree[frameInd], - previousNode = previousFrame, - affectedNodes = affectedNodes[frameInd], - expandedNodes = remember(frameInd) { mutableStateMapOf() }, - onNodeSelect = onNodeSelect, - ) - } -} /** * Since the workflow nodes present a tree structure, we utilize a recursive function to draw the tree @@ -151,7 +30,7 @@ internal fun RenderTrace( * closed from user clicks. */ @Composable -private fun DrawTree( +internal fun DrawTree( node: Node, previousNode: Node?, affectedNodes: Set, diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt index 096612c4f3..abe6acaf02 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt @@ -56,7 +56,7 @@ internal suspend fun parseFileTrace( * Parses a single render pass from a live trace stream. * Similar to parseFileTrace but handles one render pass at a time. * - * @return ParseResult containing the new frame, merged tree, and current render pass nodes. + * @return [ParseResult] containing the new frame, merged tree, and current render pass nodes. */ internal fun parseLiveTrace( renderPass: String, diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt index 85ee3cb75d..096fce986e 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt @@ -1,14 +1,8 @@ package com.squareup.workflow1.traceviewer.util -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.coroutines.delay -import kotlinx.coroutines.yield - import java.net.Socket import java.net.SocketException @@ -33,7 +27,7 @@ internal class SocketClient { * `localabstract:` to connect to it. */ fun open() { - if (initialized){ + if (initialized) { return } initialized = true @@ -44,7 +38,7 @@ internal class SocketClient { // The adb forward command will output the port number it picks to connect. process.waitFor() val port = process.inputStream.bufferedReader().readText() - .trim().toInt() + .trim().toInt() socket = Socket("localhost", port) } @@ -71,7 +65,6 @@ internal class SocketClient { while (true) { val input = reader.readLine() renderPassChannel.trySend(input) - println(input) } } catch (e: SocketException) { e.printStackTrace() diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/TraceParser.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/TraceParser.kt new file mode 100644 index 0000000000..99e1a06414 --- /dev/null +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/TraceParser.kt @@ -0,0 +1,125 @@ +package com.squareup.workflow1.traceviewer.util + +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import com.squareup.moshi.JsonAdapter +import com.squareup.workflow1.traceviewer.TraceMode +import com.squareup.workflow1.traceviewer.model.Node +import com.squareup.workflow1.traceviewer.ui.DrawTree +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.net.SocketException + +/** + * Handles parsing the trace's after JsonParser has turned all render passes into frames. Also calls + * the UI composables to render the full trace. + * + * This handles either File or Live trace modes, and will parse equally + */ +@Composable +internal fun RenderTrace( + traceSource: TraceMode, + frameInd: Int, + onFileParse: (List) -> Unit, + onNodeSelect: (Node, Node?) -> Unit, + onNewFrame: () -> Unit, + modifier: Modifier = Modifier +) { + var isLoading by remember(traceSource) { mutableStateOf(true) } + var error by remember(traceSource) { mutableStateOf(null) } + val frames = remember { mutableStateListOf() } + val fullTree = remember { mutableStateListOf() } + val affectedNodes = remember { mutableStateListOf>() } + + // Updates current state with the new data from trace source. + fun addToStates(frame: List, tree: List, affected: List>) { + frames.addAll(frame) + fullTree.addAll(tree) + affectedNodes.addAll(affected) + isLoading = false + onFileParse(frame) + } + + // Handles the result of parsing a trace, either from file or live. Live mode includes callback + // for when a new frame is received. + fun handleParseResult( + parseResult: ParseResult, + onNewFrame: (() -> Unit)? = null + ) { + when (parseResult) { + is ParseResult.Failure -> { + error = parseResult.error + } + is ParseResult.Success -> { + addToStates( + frame = parseResult.trace, + tree = parseResult.trees, + affected = parseResult.affectedNodes + ) + onNewFrame?.invoke() + } + } + } + + LaunchedEffect(traceSource) { + when (traceSource) { + is TraceMode.File -> { + checkNotNull(traceSource.file) { + "TraceMode.File should have a non-null file to parse." + } + val parseResult = parseFileTrace(traceSource.file) + handleParseResult(parseResult) + } + + is TraceMode.Live -> { + val socket = traceSource.socket + launch { + try { + socket.pollSocket() + } catch (e: SocketException) { + error = SocketException("Socket has already been closed or is not available: ${e.message}") + return@launch + } + } + if (error != null) { + return@LaunchedEffect + } + val adapter: JsonAdapter> = createMoshiAdapter() + + withContext(Dispatchers.Default) { + // Since channel implements ChannelIterator, we can for-loop through on the receiver end. + for (renderPass in socket.renderPassChannel) { + val currentTree = fullTree.lastOrNull() + val parseResult = parseLiveTrace(renderPass, adapter, currentTree) + handleParseResult(parseResult, onNewFrame) + } + } + } + } + } + + if (error != null) { + Text("Error parsing: ${error?.message}") + return + } + + if (!isLoading) { + val previousFrame = if (frameInd > 0) fullTree[frameInd - 1] else null + DrawTree( + node = fullTree[frameInd], + previousNode = previousFrame, + affectedNodes = affectedNodes[frameInd], + expandedNodes = remember(frameInd) { mutableStateMapOf() }, + onNodeSelect = onNodeSelect, + ) + } +} From 2ae9051ff7bdd88c26247f318e5dad1ed946d962 Mon Sep 17 00:00:00 2001 From: Wenli Cai Date: Wed, 23 Jul 2025 21:57:07 -0400 Subject: [PATCH 20/24] Refactor SocketClient Since the SocketClient was not complex to begin with, combining the start-poll-stop lifecycle within one function is much easier to maintain cleaner, rather than having the start/stop contract be unclear. --- .../com/squareup/workflow1/traceviewer/App.kt | 11 +- .../squareup/workflow1/traceviewer/Main.kt | 6 - .../traceviewer/util/SocketClient.kt | 134 +++++++++++------- .../workflow1/traceviewer/util/TraceParser.kt | 34 ++--- 4 files changed, 96 insertions(+), 89 deletions(-) diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt index f5b4e52870..968d8108f2 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt @@ -17,11 +17,10 @@ import androidx.compose.ui.geometry.Offset import com.squareup.workflow1.traceviewer.model.Node import com.squareup.workflow1.traceviewer.model.NodeUpdate import com.squareup.workflow1.traceviewer.ui.FrameSelectTab -import com.squareup.workflow1.traceviewer.util.RenderTrace import com.squareup.workflow1.traceviewer.ui.RightInfoPanel import com.squareup.workflow1.traceviewer.ui.TraceModeToggleSwitch +import com.squareup.workflow1.traceviewer.util.RenderTrace import com.squareup.workflow1.traceviewer.util.SandboxBackground -import com.squareup.workflow1.traceviewer.util.SocketClient import com.squareup.workflow1.traceviewer.util.UploadFile import io.github.vinceglb.filekit.PlatformFile @@ -40,7 +39,6 @@ internal fun App( // Default to File mode, and can be toggled to be in Live mode. var traceMode by remember { mutableStateOf(TraceMode.File(null)) } var selectedTraceFile by remember { mutableStateOf(null) } - val socket = remember { SocketClient() } LaunchedEffect(sandboxState) { snapshotFlow { frameIndex }.collect { @@ -52,7 +50,6 @@ internal fun App( modifier = modifier ) { fun resetStates() { - socket.close() selectedTraceFile = null selectedNode = null frameIndex = 0 @@ -66,7 +63,6 @@ internal fun App( // if there is not a file selected and trace mode is live, then don't render anything. val readyForFileTrace = traceMode is TraceMode.File && selectedTraceFile != null val readyForLiveTrace = traceMode is TraceMode.Live - if (readyForFileTrace || readyForLiveTrace) { RenderTrace( traceSource = traceMode, @@ -106,8 +102,7 @@ internal fun App( frames get populated, so we avoid off by one when indexing into the frames. */ frameIndex = -1 - socket.open() - TraceMode.Live(socket) + TraceMode.Live } }, traceMode = traceMode, @@ -139,5 +134,5 @@ internal class SandboxState { internal sealed interface TraceMode { data class File(val file: PlatformFile?) : TraceMode - data class Live(val socket: SocketClient) : TraceMode + data object Live : TraceMode } diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/Main.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/Main.kt index a4734e78c5..17954f90ca 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/Main.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/Main.kt @@ -8,12 +8,6 @@ import androidx.compose.ui.window.singleWindowApplication * Main entry point for the desktop application, see [README.md] for more details. */ fun main() { - Runtime.getRuntime().addShutdownHook( - Thread { - ProcessBuilder("adb", "forward", "--remove-all") - .start().waitFor() - } - ) singleWindowApplication(title = "Workflow Trace Viewer", exitProcessOnExit = false) { App(Modifier.fillMaxSize()) } diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt index 096fce986e..4840c40690 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt @@ -1,75 +1,99 @@ package com.squareup.workflow1.traceviewer.util import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withContext +import okio.IOException import java.net.Socket -import java.net.SocketException /** - * This is a client that can connect to any server socket that sends render pass data while using - * the Workflow framework. + * Collects data from a server socket and serves them back to the caller via callback. * - * [start] and [close] are idempotent commands, so this socket can only be started and closed once. + * Two cases that are guaranteed to fail: + * 1) The app is not running + * 2) A reattempt at establishing socket connection without restarting the app * - * Since this app is on JVM and the server is on Android, we use ADB to forward the port onto the socket. + * @param onNewRenderPass is called from an arbitrary thread, so it is important to ensure that the + * caller is thread safe */ -internal class SocketClient { - private lateinit var socket: Socket - private var initialized = false - val renderPassChannel: Channel = Channel(Channel.BUFFERED) - - /** - * We use any available ports on the host machine to connect to the emulator. - * - * `workflow-trace` is the name of the unix socket created, and since Android uses - * `LocalServerSocket` -- which creates a unix socket on the linux abstract namespace -- we use - * `localabstract:` to connect to it. - */ - fun open() { - if (initialized) { - return +suspend fun pollSocket(onNewRenderPass: suspend (String) -> Unit) { + withContext(Dispatchers.IO) { + try { + runForwardingPortThroughAdb { port -> + Socket("localhost", port).useWithCancellation { socket -> + val reader = socket.getInputStream().bufferedReader() + do { + ensureActive() + val input = reader.readLine() + if (input != null) { + onNewRenderPass(input) + } + } while (input != null) + } + } + } catch (e: IOException) { + // NoOp } - initialized = true - val process = ProcessBuilder( - "adb", "forward", "tcp:0", "localabstract:workflow-trace" - ).start() + } +} - // The adb forward command will output the port number it picks to connect. - process.waitFor() - val port = process.inputStream.bufferedReader().readText() - .trim().toInt() +/** + * Force [pollSocket] to exit with exception if the coroutine is cancelled. See comment below. + */ +private suspend fun Socket.useWithCancellation(block: suspend (Socket) -> Unit) { + val socket = this + coroutineScope { + // This coroutine is responsible for forcibly closing the socket when the coroutine is + // cancelled. This causes any code reading from the socket to throw a CancellationException. + // We also need to explicitly cancel this coroutine if the block returns on its own, otherwise + // the coroutineScope will never exit. + val socketJob = launch { + socket.use { + awaitCancellation() + } + } - socket = Socket("localhost", port) + block(socket) + socketJob.cancel() } +} - fun close() { - if (!initialized) { - return - } - socket.close() +/** + * Call adb to setup a port forwarding to the server socket, and calls block with the allocated + * port number if successful. + * + * If block throws or returns on finish, the port forwarding is removed via adb (best effort). + */ +@Suppress("BlockingMethodInNonBlockingContext") +private suspend inline fun runForwardingPortThroughAdb(block: (port: Int) -> Unit) { + val process = ProcessBuilder( + "adb", "forward", "tcp:0", "localabstract:workflow-trace" + ).start() + + // The adb forward command will output the port number it picks to connect. + val forwardReturnCode = runInterruptible { + process.waitFor() + } + if (forwardReturnCode != 0) { + return } - /** - * Polls the socket's input stream and sends the data into [renderPassChannel]. - * The caller should handle the scope of the coroutine that this function is called in. - * - * To better separate the responsibility of reading from the socket, we use a channel for the caller - * to handle parsing and amalgamating the render passes. - */ - suspend fun pollSocket() { - withContext(Dispatchers.IO) { - val reader = socket.getInputStream().bufferedReader() - reader.use { - try { - while (true) { - val input = reader.readLine() - renderPassChannel.trySend(input) - } - } catch (e: SocketException) { - e.printStackTrace() - } - } + val port = process.inputStream.bufferedReader().readText() + .trim().toInt() + + try { + block(port) + } finally { + // We don't care if this fails since there's nothing we can do then anyway. It just means + // there's an extra forward left open, but that's not a big deal. + runCatching { + ProcessBuilder( + "adb", "forward", "--remove", "tcp:$port" + ).start() } } } diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/TraceParser.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/TraceParser.kt index 99e1a06414..d7c23b0c55 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/TraceParser.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/TraceParser.kt @@ -14,9 +14,8 @@ import com.squareup.moshi.JsonAdapter import com.squareup.workflow1.traceviewer.TraceMode import com.squareup.workflow1.traceviewer.model.Node import com.squareup.workflow1.traceviewer.ui.DrawTree -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import java.net.SocketException /** @@ -35,7 +34,7 @@ internal fun RenderTrace( modifier: Modifier = Modifier ) { var isLoading by remember(traceSource) { mutableStateOf(true) } - var error by remember(traceSource) { mutableStateOf(null) } + var error by remember(traceSource) { mutableStateOf(null) } val frames = remember { mutableStateListOf() } val fullTree = remember { mutableStateListOf() } val affectedNodes = remember { mutableStateListOf>() } @@ -57,7 +56,7 @@ internal fun RenderTrace( ) { when (parseResult) { is ParseResult.Failure -> { - error = parseResult.error + error = parseResult.error.toString() } is ParseResult.Success -> { addToStates( @@ -81,34 +80,29 @@ internal fun RenderTrace( } is TraceMode.Live -> { - val socket = traceSource.socket + val renderPassChannel: Channel = Channel(Channel.BUFFERED) launch { try { - socket.pollSocket() - } catch (e: SocketException) { - error = SocketException("Socket has already been closed or is not available: ${e.message}") - return@launch + pollSocket(onNewRenderPass = renderPassChannel::send) + error = "Socket has already been closed or is not available." + } finally { + renderPassChannel.close() } } - if (error != null) { - return@LaunchedEffect - } val adapter: JsonAdapter> = createMoshiAdapter() - withContext(Dispatchers.Default) { - // Since channel implements ChannelIterator, we can for-loop through on the receiver end. - for (renderPass in socket.renderPassChannel) { - val currentTree = fullTree.lastOrNull() - val parseResult = parseLiveTrace(renderPass, adapter, currentTree) - handleParseResult(parseResult, onNewFrame) - } + // Since channel implements ChannelIterator, we can for-loop through on the receiver end. + for (renderPass in renderPassChannel) { + val currentTree = fullTree.lastOrNull() + val parseResult = parseLiveTrace(renderPass, adapter, currentTree) + handleParseResult(parseResult, onNewFrame) } } } } if (error != null) { - Text("Error parsing: ${error?.message}") + Text("Error parsing: ${error}") return } From a03fcc93d7cda95e8f3142045ee9184b996c710e Mon Sep 17 00:00:00 2001 From: wenli-cai <213805610+wenli-cai@users.noreply.github.com> Date: Thu, 24 Jul 2025 13:52:56 +0000 Subject: [PATCH 21/24] Apply changes from apiDump Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- workflow-trace-viewer/api/workflow-trace-viewer.api | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/workflow-trace-viewer/api/workflow-trace-viewer.api b/workflow-trace-viewer/api/workflow-trace-viewer.api index 377827f5c5..64d5651cab 100644 --- a/workflow-trace-viewer/api/workflow-trace-viewer.api +++ b/workflow-trace-viewer/api/workflow-trace-viewer.api @@ -30,6 +30,10 @@ public final class com/squareup/workflow1/traceviewer/util/JsonParserKt { public static final field ROOT_ID Ljava/lang/String; } +public final class com/squareup/workflow1/traceviewer/util/SocketClientKt { + public static final fun pollSocket (Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + public final class com/squareup/workflow1/traceviewer/util/UploadFileKt { public static final fun UploadFile (Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V } From be3fbd1affd047a9a6c507ff3ad1efc39c764655 Mon Sep 17 00:00:00 2001 From: Wenli Cai Date: Thu, 24 Jul 2025 11:06:09 -0400 Subject: [PATCH 22/24] More socket refactoring --- .../traceviewer/util/SocketClient.kt | 20 ++++++++++++++++++- .../workflow1/traceviewer/util/TraceParser.kt | 18 +++-------------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt index 4840c40690..a681d0b264 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt @@ -1,7 +1,9 @@ package com.squareup.workflow1.traceviewer.util +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch @@ -10,6 +12,22 @@ import kotlinx.coroutines.withContext import okio.IOException import java.net.Socket +internal suspend fun streamRenderPassesFromDevice(parseOnNewRenderPass: (String) -> Unit) { + val renderPassChannel: Channel = Channel(Channel.BUFFERED) + coroutineScope { + try { + pollSocket(onNewRenderPass = renderPassChannel::send) + } finally { + renderPassChannel.close() + } + } + + // Since channel implements ChannelIterator, we can for-loop through on the receiver end. + for (renderPass in renderPassChannel) { + parseOnNewRenderPass(renderPass) + } +} + /** * Collects data from a server socket and serves them back to the caller via callback. * @@ -20,7 +38,7 @@ import java.net.Socket * @param onNewRenderPass is called from an arbitrary thread, so it is important to ensure that the * caller is thread safe */ -suspend fun pollSocket(onNewRenderPass: suspend (String) -> Unit) { +private suspend fun pollSocket(onNewRenderPass: suspend (String) -> Unit) { withContext(Dispatchers.IO) { try { runForwardingPortThroughAdb { port -> diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/TraceParser.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/TraceParser.kt index d7c23b0c55..048e28e41a 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/TraceParser.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/TraceParser.kt @@ -14,9 +14,6 @@ import com.squareup.moshi.JsonAdapter import com.squareup.workflow1.traceviewer.TraceMode import com.squareup.workflow1.traceviewer.model.Node import com.squareup.workflow1.traceviewer.ui.DrawTree -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.launch -import java.net.SocketException /** * Handles parsing the trace's after JsonParser has turned all render passes into frames. Also calls @@ -58,6 +55,7 @@ internal fun RenderTrace( is ParseResult.Failure -> { error = parseResult.error.toString() } + is ParseResult.Success -> { addToStates( frame = parseResult.trace, @@ -80,23 +78,13 @@ internal fun RenderTrace( } is TraceMode.Live -> { - val renderPassChannel: Channel = Channel(Channel.BUFFERED) - launch { - try { - pollSocket(onNewRenderPass = renderPassChannel::send) - error = "Socket has already been closed or is not available." - } finally { - renderPassChannel.close() - } - } val adapter: JsonAdapter> = createMoshiAdapter() - - // Since channel implements ChannelIterator, we can for-loop through on the receiver end. - for (renderPass in renderPassChannel) { + streamRenderPassesFromDevice { renderPass -> val currentTree = fullTree.lastOrNull() val parseResult = parseLiveTrace(renderPass, adapter, currentTree) handleParseResult(parseResult, onNewFrame) } + error = "Socket has already been closed or is not available." } } } From 9319e1db969b4342c7cdc6e46f84a83c1b147a31 Mon Sep 17 00:00:00 2001 From: wenli-cai <213805610+wenli-cai@users.noreply.github.com> Date: Thu, 24 Jul 2025 16:26:08 +0000 Subject: [PATCH 23/24] Apply changes from apiDump Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- workflow-trace-viewer/api/workflow-trace-viewer.api | 4 ---- 1 file changed, 4 deletions(-) diff --git a/workflow-trace-viewer/api/workflow-trace-viewer.api b/workflow-trace-viewer/api/workflow-trace-viewer.api index 64d5651cab..377827f5c5 100644 --- a/workflow-trace-viewer/api/workflow-trace-viewer.api +++ b/workflow-trace-viewer/api/workflow-trace-viewer.api @@ -30,10 +30,6 @@ public final class com/squareup/workflow1/traceviewer/util/JsonParserKt { public static final field ROOT_ID Ljava/lang/String; } -public final class com/squareup/workflow1/traceviewer/util/SocketClientKt { - public static final fun pollSocket (Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -} - public final class com/squareup/workflow1/traceviewer/util/UploadFileKt { public static final fun UploadFile (Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V } From 66010f9cb271e0f63de95b9ae5de337afce9f305 Mon Sep 17 00:00:00 2001 From: Wenli Cai Date: Thu, 24 Jul 2025 17:42:24 -0400 Subject: [PATCH 24/24] Fix more pr comment and coroutine bug --- .../traceviewer/ui/TraceModeToggleSwitch.kt | 12 +----------- .../traceviewer/util/SocketClient.kt | 19 ++++++++++--------- .../workflow1/traceviewer/util/TraceParser.kt | 2 +- 3 files changed, 12 insertions(+), 21 deletions(-) diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/TraceModeToggleSwitch.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/TraceModeToggleSwitch.kt index 3863d9e80a..0c88df899c 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/TraceModeToggleSwitch.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/TraceModeToggleSwitch.kt @@ -6,10 +6,6 @@ import androidx.compose.material.Switch import androidx.compose.material.SwitchDefaults import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -24,19 +20,13 @@ internal fun TraceModeToggleSwitch( traceMode: TraceMode, modifier: Modifier = Modifier ) { - // File mode is unchecked by default, and live mode is checked. - var checked by remember { - mutableStateOf(traceMode is TraceMode.Live) - } - Column( modifier = modifier.padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Switch( - checked = checked, + checked = traceMode is TraceMode.Live, onCheckedChange = { - checked = it onToggle() }, colors = SwitchDefaults.colors( diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt index a681d0b264..61fbd74d90 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt @@ -1,6 +1,5 @@ package com.squareup.workflow1.traceviewer.util -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.channels.Channel @@ -15,16 +14,18 @@ import java.net.Socket internal suspend fun streamRenderPassesFromDevice(parseOnNewRenderPass: (String) -> Unit) { val renderPassChannel: Channel = Channel(Channel.BUFFERED) coroutineScope { - try { - pollSocket(onNewRenderPass = renderPassChannel::send) - } finally { - renderPassChannel.close() + launch { + try { + pollSocket(onNewRenderPass = renderPassChannel::send) + } finally { + renderPassChannel.close() + } } - } - // Since channel implements ChannelIterator, we can for-loop through on the receiver end. - for (renderPass in renderPassChannel) { - parseOnNewRenderPass(renderPass) + // Since channel implements ChannelIterator, we can for-loop through on the receiver end. + for (renderPass in renderPassChannel) { + parseOnNewRenderPass(renderPass) + } } } diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/TraceParser.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/TraceParser.kt index 048e28e41a..55d5d00c54 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/TraceParser.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/TraceParser.kt @@ -90,7 +90,7 @@ internal fun RenderTrace( } if (error != null) { - Text("Error parsing: ${error}") + Text("Error parsing: $error") return }