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:
committed by
GitHub
parent
285b357bfe
commit
2d3e59912a
@@ -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
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ class KonsistComposableTest {
|
||||
"CompoundSemanticColorsDarkHc",
|
||||
"HorizontalFloatingToolbarItem",
|
||||
"HorizontalFloatingToolbarSeparator",
|
||||
"DebugNavStateNodeHost",
|
||||
)
|
||||
.assertTrue(
|
||||
additionalMessage =
|
||||
|
||||
Reference in New Issue
Block a user