From f56a1f8ece6c0aacc7dfe854361f29d048508c24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Thu, 12 Feb 2026 15:51:13 +0100 Subject: [PATCH 1/6] Remove `runBlocking` call to restore sessions when the app starts Sadly, to do this we need to manually handle restoring the state from Appyx using internal values. At least it doesn't seem like they're going to change any time soon (or ever). This should take care of a few ANRs, although it may make loading the initial state a bit slower --- .../io/element/android/appnav/RootFlowNode.kt | 86 +++++++++++++++++-- .../android/appnav/di/MatrixSessionCache.kt | 11 +-- 2 files changed, 83 insertions(+), 14 deletions(-) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index 5cafa197cd6..ccd20e8ab88 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -16,9 +16,13 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.lifecycle.lifecycleScope import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.navigation.NavElements +import com.bumble.appyx.core.navigation.NavKey +import com.bumble.appyx.core.navigation.Operation import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.state.MutableSavedStateMap +import com.bumble.appyx.core.state.SavedStateMap import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.operation.pop import com.bumble.appyx.navmodel.backstack.operation.push @@ -50,6 +54,7 @@ import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.waitForChildAttached import io.element.android.libraries.core.uri.ensureProtocol import io.element.android.libraries.deeplink.api.DeeplinkData +import io.element.android.libraries.di.annotations.AppCoroutineScope import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.api.core.EventId @@ -67,6 +72,7 @@ import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.watchers.AnalyticsColdStartWatcher import io.element.android.services.appnavstate.api.ROOM_OPENED_FROM_NOTIFICATION +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -93,19 +99,27 @@ class RootFlowNode( private val announcementService: AnnouncementService, private val analyticsService: AnalyticsService, private val analyticsColdStartWatcher: AnalyticsColdStartWatcher, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, ) : BaseFlowNode( backstack = BackStack( initialElement = NavTarget.SplashScreen, - savedStateMap = buildContext.savedStateMap, + savedStateMap = null, ), buildContext = buildContext, plugins = plugins ) { override fun onBuilt() { analyticsColdStartWatcher.start() - matrixSessionCache.restoreWithSavedState(buildContext.savedStateMap) + appCoroutineScope.launch { + matrixSessionCache.restoreWithSavedState(buildContext.savedStateMap) + if (buildContext.savedStateMap != null) { + restoreSavedState(buildContext.savedStateMap) + observeNavState() + } else { + observeNavState() + } + } super.onBuilt() - observeNavState() } override fun onSaveInstanceState(state: MutableSavedStateMap) { @@ -120,10 +134,15 @@ class RootFlowNode( when (navState.loggedInState) { is LoggedInState.LoggedIn -> { if (navState.loggedInState.isTokenValid) { - tryToRestoreLatestSession( - onSuccess = { sessionId -> switchToLoggedInFlow(sessionId, navState.cacheIndex) }, - onFailure = { switchToNotLoggedInFlow(null) } - ) + val sessionId = SessionId(navState.loggedInState.sessionId) + if (matrixSessionCache.getOrNull(sessionId) != null) { + switchToLoggedInFlow(sessionId, navState.cacheIndex) + } else { + tryToRestoreLatestSession( + onSuccess = { sessionId -> switchToLoggedInFlow(sessionId, navState.cacheIndex) }, + onFailure = { switchToNotLoggedInFlow(null) } + ) + } } else { switchToSignedOutFlow(SessionId(navState.loggedInState.sessionId)) } @@ -135,6 +154,41 @@ class RootFlowNode( }.launchIn(lifecycleScope) } + /** + * Restore the saved state for navigation in the current backstack. + * + * **WARNING:** this is an unsafe operation abusing the internals of the Appyx library, but it's the only way allow async state + * restoration and not having to block the main thread when the app starts. + * + * Modify with utmost care and double check any possible Appyx updates that might break this. + */ + @Suppress("UNCHECKED_CAST") + private fun restoreSavedState(savedStateMap: SavedStateMap?) { + if (savedStateMap == null) return + + // 'NavModel' is the key used for storing the nav model state data in the map in Appyx + val savedElements = buildContext.savedStateMap?.get("NavModel") as? NavElements + if (savedElements != null) { + Timber.d("restoreSavedElements: Saved elements found, restoring them.") + backstack.accept(RestoreHistoryOperation(savedElements)) + } + } + + /** + * Extract the saved state for navigation in the [navTarget]. + * + * **WARNING:** this is an unsafe operation abusing the internals of the Appyx library, but it's the only way allow async state + * restoration and not having to block the main thread when the app starts. + * + * Modify with utmost care and double check any possible Appyx updates that might break this. + */ + @Suppress("UNCHECKED_CAST") + private fun extractSavedStateForNavTarget(navTarget: NavTarget, savedStateMap: SavedStateMap?): SavedStateMap? { + // 'ChildrenState' is the key used for storing the children state data in the map in Appyx + val childrenState = savedStateMap?.get("ChildrenState") as? Map, SavedStateMap> ?: return null + return childrenState.entries.find { (key, _) -> key.navTarget == navTarget }?.value + } + private fun switchToLoggedInFlow(sessionId: SessionId, navId: Int) { backstack.safeRoot(NavTarget.LoggedInFlow(sessionId, navId)) } @@ -203,6 +257,17 @@ class RootFlowNode( } } + @Parcelize + class RestoreHistoryOperation(private val navElements: NavElements) : Operation { + override fun isApplicable(elements: NavElements): Boolean { + return true + } + + override fun invoke(existing: NavElements): NavElements { + return navElements + } + } + sealed interface NavTarget : Parcelable { @Parcelize data object SplashScreen : NavTarget @@ -244,6 +309,13 @@ class RootFlowNode( backstack.push(NavTarget.NotLoggedInFlow(null)) } } + val savedNavState = extractSavedStateForNavTarget(navTarget, this.buildContext.savedStateMap) + val buildContext = if (savedNavState != null) { + Timber.d("Creating a $navTarget with restored saved state") + buildContext.copy(savedStateMap = savedNavState) + } else { + buildContext.copy(savedStateMap = savedNavState) + } createNode(buildContext, plugins = listOf(inputs, callback)) } is NavTarget.NotLoggedInFlow -> { diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt b/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt index c6a031921f7..11337f2e0a9 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt @@ -21,7 +21,6 @@ import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analyticsproviders.api.AnalyticsUserData -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import timber.log.Timber @@ -77,20 +76,18 @@ class MatrixSessionCache( } @Suppress("UNCHECKED_CAST") - fun restoreWithSavedState(state: SavedStateMap?) { + suspend fun restoreWithSavedState(state: SavedStateMap?) { Timber.d("Restore state") if (state == null || sessionIdsToMatrixSession.isNotEmpty()) { - Timber.w("Restore with non-empty map") + Timber.w("No need to restore saved state") return } val sessionIds = state[SAVE_INSTANCE_KEY] as? Array Timber.d("Restore matrix session keys = ${sessionIds?.map { it.value }}") if (sessionIds.isNullOrEmpty()) return // Not ideal but should only happens in case of process recreation. This ensure we restore all the active sessions before restoring the node graphs. - runBlocking { - sessionIds.forEach { sessionId -> - getOrRestore(sessionId) - } + sessionIds.forEach { sessionId -> + getOrRestore(sessionId) } } From 3542565d0ba6a696500e270e4fd3ff9623f48f24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Tue, 17 Feb 2026 12:55:14 +0100 Subject: [PATCH 2/6] Use a single `observeNavState` call It was added twice because previously both instances logged a different message --- .../src/main/kotlin/io/element/android/appnav/RootFlowNode.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index ccd20e8ab88..9bf16067386 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -114,10 +114,8 @@ class RootFlowNode( matrixSessionCache.restoreWithSavedState(buildContext.savedStateMap) if (buildContext.savedStateMap != null) { restoreSavedState(buildContext.savedStateMap) - observeNavState() - } else { - observeNavState() } + observeNavState() } super.onBuilt() } From 52b68622b1c3db51422ca823272babddf8b7e20f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Tue, 17 Feb 2026 13:03:52 +0100 Subject: [PATCH 3/6] Rename `RestoreHistoryOperation` to `ReplaceAllOperation` and move it to its own file --- .../android/appnav/ReplaceAllOperation.kt | 29 +++++++++++++++++++ .../io/element/android/appnav/RootFlowNode.kt | 14 +-------- 2 files changed, 30 insertions(+), 13 deletions(-) create mode 100644 appnav/src/main/kotlin/io/element/android/appnav/ReplaceAllOperation.kt diff --git a/appnav/src/main/kotlin/io/element/android/appnav/ReplaceAllOperation.kt b/appnav/src/main/kotlin/io/element/android/appnav/ReplaceAllOperation.kt new file mode 100644 index 00000000000..7df75355e5c --- /dev/null +++ b/appnav/src/main/kotlin/io/element/android/appnav/ReplaceAllOperation.kt @@ -0,0 +1,29 @@ +/* + * 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. + */ + +package io.element.android.appnav + +import com.bumble.appyx.core.navigation.NavElements +import com.bumble.appyx.core.navigation.Operation +import com.bumble.appyx.navmodel.backstack.BackStack +import kotlinx.parcelize.Parcelize + +/** + * Replaces all the current elements with the provided [navElements], keeping their [BackStack.State] too. + */ +@Parcelize +class ReplaceAllOperation( + private val navElements: NavElements +) : Operation { + override fun isApplicable(elements: NavElements): Boolean { + return true + } + + override fun invoke(existing: NavElements): NavElements { + return navElements + } +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index 9bf16067386..495172e438c 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -18,7 +18,6 @@ import androidx.lifecycle.lifecycleScope import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.navigation.NavElements import com.bumble.appyx.core.navigation.NavKey -import com.bumble.appyx.core.navigation.Operation import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.state.MutableSavedStateMap @@ -168,7 +167,7 @@ class RootFlowNode( val savedElements = buildContext.savedStateMap?.get("NavModel") as? NavElements if (savedElements != null) { Timber.d("restoreSavedElements: Saved elements found, restoring them.") - backstack.accept(RestoreHistoryOperation(savedElements)) + backstack.accept(ReplaceAllOperation(savedElements)) } } @@ -255,17 +254,6 @@ class RootFlowNode( } } - @Parcelize - class RestoreHistoryOperation(private val navElements: NavElements) : Operation { - override fun isApplicable(elements: NavElements): Boolean { - return true - } - - override fun invoke(existing: NavElements): NavElements { - return navElements - } - } - sealed interface NavTarget : Parcelable { @Parcelize data object SplashScreen : NavTarget From f188f398fed3211dff1e64bf170e1451a5439fd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Mon, 23 Feb 2026 15:51:08 +0100 Subject: [PATCH 4/6] Drop first item in `observeNavState` when restoring in an async way --- .../io/element/android/appnav/RootFlowNode.kt | 49 ++++++++++--------- 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index 495172e438c..1c8f2c530fb 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -73,6 +73,7 @@ import io.element.android.services.analytics.api.watchers.AnalyticsColdStartWatc import io.element.android.services.appnavstate.api.ROOM_OPENED_FROM_NOTIFICATION import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch @@ -113,8 +114,10 @@ class RootFlowNode( matrixSessionCache.restoreWithSavedState(buildContext.savedStateMap) if (buildContext.savedStateMap != null) { restoreSavedState(buildContext.savedStateMap) + observeNavState(true) + } else { + observeNavState(false) } - observeNavState() } super.onBuilt() } @@ -125,30 +128,33 @@ class RootFlowNode( navStateFlowFactory.saveIntoSavedState(state) } - private fun observeNavState() { - navStateFlowFactory.create(buildContext.savedStateMap).distinctUntilChanged().onEach { navState -> - Timber.v("navState=$navState") - when (navState.loggedInState) { - is LoggedInState.LoggedIn -> { - if (navState.loggedInState.isTokenValid) { - val sessionId = SessionId(navState.loggedInState.sessionId) - if (matrixSessionCache.getOrNull(sessionId) != null) { - switchToLoggedInFlow(sessionId, navState.cacheIndex) + private fun observeNavState(skipFirst: Boolean) { + navStateFlowFactory.create(buildContext.savedStateMap) + .distinctUntilChanged() + .drop(if (skipFirst) 1 else 0) + .onEach { navState -> + Timber.v("navState=$navState") + when (navState.loggedInState) { + is LoggedInState.LoggedIn -> { + if (navState.loggedInState.isTokenValid) { + val sessionId = SessionId(navState.loggedInState.sessionId) + if (matrixSessionCache.getOrNull(sessionId) != null) { + switchToLoggedInFlow(sessionId, navState.cacheIndex) + } else { + tryToRestoreLatestSession( + onSuccess = { sessionId -> switchToLoggedInFlow(sessionId, navState.cacheIndex) }, + onFailure = { switchToNotLoggedInFlow(null) } + ) + } } else { - tryToRestoreLatestSession( - onSuccess = { sessionId -> switchToLoggedInFlow(sessionId, navState.cacheIndex) }, - onFailure = { switchToNotLoggedInFlow(null) } - ) + switchToSignedOutFlow(SessionId(navState.loggedInState.sessionId)) } - } else { - switchToSignedOutFlow(SessionId(navState.loggedInState.sessionId)) + } + LoggedInState.NotLoggedIn -> { + switchToNotLoggedInFlow(null) } } - LoggedInState.NotLoggedIn -> { - switchToNotLoggedInFlow(null) - } - } - }.launchIn(lifecycleScope) + }.launchIn(lifecycleScope) } /** @@ -166,7 +172,6 @@ class RootFlowNode( // 'NavModel' is the key used for storing the nav model state data in the map in Appyx val savedElements = buildContext.savedStateMap?.get("NavModel") as? NavElements if (savedElements != null) { - Timber.d("restoreSavedElements: Saved elements found, restoring them.") backstack.accept(ReplaceAllOperation(savedElements)) } } From a9b9f509123586372a022d640444ad3dd4fbf427 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Tue, 24 Feb 2026 09:38:40 +0100 Subject: [PATCH 5/6] Add warning comment for Appyx dependency --- gradle/libs.versions.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3f7a29b0aa2..7440effef83 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -42,6 +42,8 @@ serialization_json = "1.10.0" coil = "3.3.0" # Rollback to 1.0.4, 1.0.5 has this issue: https://github.com/airbnb/Showkase/issues/420 showkase = "1.0.5" +# There is some custom logic in `RootFlowNode` that may break because it reuses some Appyx internal APIs. +# When upgrading this version, check state restoration still works fine. appyx = "1.7.1" sqldelight = "2.2.1" wysiwyg = "2.41.1" From 13a48e4e504c5b4e779b1d846a86bd8b25f01698 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Tue, 24 Feb 2026 10:14:07 +0100 Subject: [PATCH 6/6] Fix lint issues --- .../src/main/kotlin/io/element/android/appnav/RootFlowNode.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index 1c8f2c530fb..f18fc138c14 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -154,7 +154,8 @@ class RootFlowNode( switchToNotLoggedInFlow(null) } } - }.launchIn(lifecycleScope) + } + .launchIn(lifecycleScope) } /**