diff --git a/app/src/main/kotlin/io/element/android/x/MainActivity.kt b/app/src/main/kotlin/io/element/android/x/MainActivity.kt index 162a55c3a7..b522edd137 100644 --- a/app/src/main/kotlin/io/element/android/x/MainActivity.kt +++ b/app/src/main/kotlin/io/element/android/x/MainActivity.kt @@ -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 ) diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/crash/VectorUncaughtExceptionHandler.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/crash/VectorUncaughtExceptionHandler.kt index 0eafdf35a9..d88a05673a 100644 --- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/crash/VectorUncaughtExceptionHandler.kt +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/crash/VectorUncaughtExceptionHandler.kt @@ -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") diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/appyx/DebugNavState.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/appyx/DebugNavState.kt new file mode 100644 index 0000000000..0d6bdc5537 --- /dev/null +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/appyx/DebugNavState.kt @@ -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 = 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.buildNavStateMap(): List { + val children = this["ChildrenState"] as? Map, Map> ?: return emptyList() + return children.entries.map { (key, value) -> + NodeEntry( + navKey = key.navTarget, + children = value.buildNavStateMap() + ) + } +} + +@Suppress("UNCHECKED_CAST") +private fun Map.buildNavModel(name: String): List { + val navModel = this[name] as? List> ?: 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 DebugNavStateNodeHost( + integrationPoint: IntegrationPoint, + modifier: Modifier = Modifier, + customisations: NodeCustomisationDirectory = remember { NodeCustomisationDirectoryImpl() }, + factory: NodeFactory +) { + 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 rememberNode( + factory: NodeFactory, + key: String, + customisations: NodeCustomisationDirectory, + integrationPoint: IntegrationPoint, +): State { + 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)) + } +} diff --git a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistComposableTest.kt b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistComposableTest.kt index b37b64638f..e80b1c035f 100644 --- a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistComposableTest.kt +++ b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistComposableTest.kt @@ -49,6 +49,7 @@ class KonsistComposableTest { "CompoundSemanticColorsDarkHc", "HorizontalFloatingToolbarItem", "HorizontalFloatingToolbarSeparator", + "DebugNavStateNodeHost", ) .assertTrue( additionalMessage =