Add code to help debugging the saved nav state graph (#6295)

* Add code to help debugging the saved nav state graph: this would help us diagnose the `TransactionTooLargeException` reports we've been seeing for months.
This commit is contained in:
Jorge Martin Espinosa
2026-03-06 15:44:30 +01:00
committed by GitHub
parent 285b357bfe
commit 2d3e59912a
4 changed files with 169 additions and 3 deletions

View File

@@ -26,7 +26,6 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.bumble.appyx.core.integration.NodeHost
import com.bumble.appyx.core.integrationpoint.NodeActivity
import com.bumble.appyx.core.plugin.NodeReadyObserver
import io.element.android.compound.colors.SemanticColorsLightDark
@@ -35,6 +34,7 @@ import io.element.android.features.lockscreen.api.LockScreenEntryPoint
import io.element.android.features.lockscreen.api.LockScreenLockState
import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.features.lockscreen.api.handleSecureFlag
import io.element.android.libraries.architecture.appyx.DebugNavStateNodeHost
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.designsystem.theme.ElementThemeApp
@@ -100,7 +100,9 @@ class MainActivity : NodeActivity() {
@Composable
private fun MainNodeHost() {
NodeHost(integrationPoint = appyxV1IntegrationPoint) {
// TODO this is a temporary helper to capture the nav state in a more readable format for crash reports
// Revert to `NodeHost` once this is fixed
DebugNavStateNodeHost(integrationPoint = appyxV1IntegrationPoint) {
MainNode(
it,
plugins = listOf(
@@ -110,7 +112,7 @@ class MainActivity : NodeActivity() {
mainNode = node
mainNode.handleIntent(intent)
}
}
},
),
context = applicationContext
)

View File

@@ -9,6 +9,8 @@
package io.element.android.features.rageshake.impl.crash
import android.os.Build
import android.os.TransactionTooLargeException
import io.element.android.libraries.architecture.appyx.lastCapturedNavState
import io.element.android.libraries.core.data.tryOrNull
import timber.log.Timber
import java.io.PrintWriter
@@ -61,6 +63,13 @@ class VectorUncaughtExceptionHandler(
val sw = StringWriter()
val pw = PrintWriter(sw, true)
throwable.printStackTrace(pw)
if (throwable is RuntimeException && throwable.cause is TransactionTooLargeException) {
pw.append('\n')
pw.append(lastCapturedNavState)
Timber.v(lastCapturedNavState)
}
append(sw.buffer.toString())
}
Timber.e("FATAL EXCEPTION $bugDescription")

View File

@@ -0,0 +1,154 @@
package io.element.android.libraries.architecture.appyx
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.currentCompositeKeyHashCode
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.mapSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.bumble.appyx.core.integration.NodeFactory
import com.bumble.appyx.core.integrationpoint.IntegrationPoint
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.navigation.NavElement
import com.bumble.appyx.core.navigation.NavKey
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.build
import com.bumble.appyx.core.state.SavedStateMap
import com.bumble.appyx.utils.customisations.NodeCustomisationDirectory
import com.bumble.appyx.utils.customisations.NodeCustomisationDirectoryImpl
import timber.log.Timber
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
/**
* Contains the last captured navigation state in a human-readable format, which can be attached to crash reports to help
* with debugging `TransactionTooLargeException` crashes.
*/
var lastCapturedNavState: String = "No nav state captured yet"
private data class NodeEntry(
val navKey: Any?,
val children: List<NodeEntry> = emptyList()
) {
override fun toString(): String {
val key = navKey ?: return ""
return buildString {
append(key.javaClass.name)
if (children.isNotEmpty()) {
append("=[")
append(children.joinToString(", "))
append("]")
}
}
}
}
@Suppress("UNCHECKED_CAST")
private fun Map<String, Any?>.buildNavStateMap(): List<NodeEntry> {
val children = this["ChildrenState"] as? Map<NavKey<*>, Map<String, Any?>> ?: return emptyList()
return children.entries.map { (key, value) ->
NodeEntry(
navKey = key.navTarget,
children = value.buildNavStateMap()
)
}
}
@Suppress("UNCHECKED_CAST")
private fun Map<String, Any?>.buildNavModel(name: String): List<NodeEntry> {
val navModel = this[name] as? List<NavElement<*, *>> ?: return emptyList()
return navModel.map {
NodeEntry(
navKey = it.key.navTarget,
children = emptyList()
)
}
}
// Once we have fixed the `TransactionTooLargeException` issues, we should remove this and use the default `NodeHost` implementation
@Suppress("ComposableParamOrder") // detekt complains as 'factory' param isn't a pure lambda
@Composable
fun <N : Node> DebugNavStateNodeHost(
integrationPoint: IntegrationPoint,
modifier: Modifier = Modifier,
customisations: NodeCustomisationDirectory = remember { NodeCustomisationDirectoryImpl() },
factory: NodeFactory<N>
) {
val node by rememberNode(factory, "AppyxMainNode", customisations, integrationPoint)
DisposableEffect(node) {
onDispose { node.updateLifecycleState(Lifecycle.State.DESTROYED) }
}
node.Compose(modifier = modifier)
val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(lifecycle) {
node.updateLifecycleState(lifecycle.currentState)
val observer = LifecycleEventObserver { source, _ ->
node.updateLifecycleState(source.lifecycle.currentState)
}
lifecycle.addObserver(observer)
onDispose { lifecycle.removeObserver(observer) }
}
}
@Composable
private fun <N : Node> rememberNode(
factory: NodeFactory<N>,
key: String,
customisations: NodeCustomisationDirectory,
integrationPoint: IntegrationPoint,
): State<N> {
fun createNode(savedStateMap: SavedStateMap?): N =
factory
.create(
buildContext = BuildContext.root(
savedStateMap = savedStateMap,
customisations = customisations
),
)
.apply { this.integrationPoint = integrationPoint }
.build()
// This is deprecated because using the custom key would not make this unique, but we work around that by using the currentCompositeKeyHashCode
// as part of the key, which should be unique for each call site of rememberNode.
@Suppress("DEPRECATION")
return rememberSaveable(
inputs = arrayOf(),
key = "$key:$currentCompositeKeyHashCode",
stateSaver = mapSaver(
save = { node ->
val result = node.saveInstanceState(this)
// We want to capture the nav state in a format that's easier to read and understand in crash reports, so we build a custom map for that.
val copy = result.toMutableMap()
copy["ChildrenState"] = copy.buildNavStateMap()
val navModelKey = "NavModel"
if (copy.contains(navModelKey)) {
copy[navModelKey] = copy.buildNavModel(navModelKey)
}
val permanentNavModelKey = "com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel"
if (copy.contains(permanentNavModelKey)) {
copy[permanentNavModelKey] =
copy.buildNavModel(permanentNavModelKey)
}
Timber.d("Saving nav state: $copy")
// Store the last nav state in a global variable so that it can be attached to crash reports if the app crashes before the next save happens.
lastCapturedNavState = copy.toString()
result
},
restore = { state -> createNode(savedStateMap = state) },
),
) {
mutableStateOf(createNode(savedStateMap = null))
}
}

View File

@@ -49,6 +49,7 @@ class KonsistComposableTest {
"CompoundSemanticColorsDarkHc",
"HorizontalFloatingToolbarItem",
"HorizontalFloatingToolbarSeparator",
"DebugNavStateNodeHost",
)
.assertTrue(
additionalMessage =