Remove runBlocking call to restore sessions when the app starts (#6193)

* 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

* Add `ReplaceAllOperation` for state restoration

* Add warning comment for Appyx dependency
This commit is contained in:
Jorge Martin Espinosa 2026-02-24 10:51:43 +01:00 committed by GitHub
parent 16b6ca1a28
commit 7ed111d0df
4 changed files with 118 additions and 26 deletions

View file

@ -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<NavTarget : Any>(
private val navElements: NavElements<NavTarget, BackStack.State>
) : Operation<NavTarget, BackStack.State> {
override fun isApplicable(elements: NavElements<NavTarget, BackStack.State>): Boolean {
return true
}
override fun invoke(existing: NavElements<NavTarget, BackStack.State>): NavElements<NavTarget, BackStack.State> {
return navElements
}
}

View file

@ -16,9 +16,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.modality.BuildContext 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.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.state.MutableSavedStateMap 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.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push import com.bumble.appyx.navmodel.backstack.operation.push
@ -50,6 +53,7 @@ import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.waitForChildAttached import io.element.android.libraries.architecture.waitForChildAttached
import io.element.android.libraries.core.uri.ensureProtocol import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.deeplink.api.DeeplinkData 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.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
@ -67,7 +71,9 @@ import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.watchers.AnalyticsColdStartWatcher import io.element.android.services.analytics.api.watchers.AnalyticsColdStartWatcher
import io.element.android.services.appnavstate.api.ROOM_OPENED_FROM_NOTIFICATION import io.element.android.services.appnavstate.api.ROOM_OPENED_FROM_NOTIFICATION
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -93,19 +99,27 @@ class RootFlowNode(
private val announcementService: AnnouncementService, private val announcementService: AnnouncementService,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val analyticsColdStartWatcher: AnalyticsColdStartWatcher, private val analyticsColdStartWatcher: AnalyticsColdStartWatcher,
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
) : BaseFlowNode<RootFlowNode.NavTarget>( ) : BaseFlowNode<RootFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.SplashScreen, initialElement = NavTarget.SplashScreen,
savedStateMap = buildContext.savedStateMap, savedStateMap = null,
), ),
buildContext = buildContext, buildContext = buildContext,
plugins = plugins plugins = plugins
) { ) {
override fun onBuilt() { override fun onBuilt() {
analyticsColdStartWatcher.start() analyticsColdStartWatcher.start()
matrixSessionCache.restoreWithSavedState(buildContext.savedStateMap) appCoroutineScope.launch {
matrixSessionCache.restoreWithSavedState(buildContext.savedStateMap)
if (buildContext.savedStateMap != null) {
restoreSavedState(buildContext.savedStateMap)
observeNavState(true)
} else {
observeNavState(false)
}
}
super.onBuilt() super.onBuilt()
observeNavState()
} }
override fun onSaveInstanceState(state: MutableSavedStateMap) { override fun onSaveInstanceState(state: MutableSavedStateMap) {
@ -114,25 +128,68 @@ class RootFlowNode(
navStateFlowFactory.saveIntoSavedState(state) navStateFlowFactory.saveIntoSavedState(state)
} }
private fun observeNavState() { private fun observeNavState(skipFirst: Boolean) {
navStateFlowFactory.create(buildContext.savedStateMap).distinctUntilChanged().onEach { navState -> navStateFlowFactory.create(buildContext.savedStateMap)
Timber.v("navState=$navState") .distinctUntilChanged()
when (navState.loggedInState) { .drop(if (skipFirst) 1 else 0)
is LoggedInState.LoggedIn -> { .onEach { navState ->
if (navState.loggedInState.isTokenValid) { Timber.v("navState=$navState")
tryToRestoreLatestSession( when (navState.loggedInState) {
onSuccess = { sessionId -> switchToLoggedInFlow(sessionId, navState.cacheIndex) }, is LoggedInState.LoggedIn -> {
onFailure = { switchToNotLoggedInFlow(null) } if (navState.loggedInState.isTokenValid) {
) val sessionId = SessionId(navState.loggedInState.sessionId)
} else { if (matrixSessionCache.getOrNull(sessionId) != null) {
switchToSignedOutFlow(SessionId(navState.loggedInState.sessionId)) switchToLoggedInFlow(sessionId, navState.cacheIndex)
} else {
tryToRestoreLatestSession(
onSuccess = { sessionId -> switchToLoggedInFlow(sessionId, navState.cacheIndex) },
onFailure = { switchToNotLoggedInFlow(null) }
)
}
} else {
switchToSignedOutFlow(SessionId(navState.loggedInState.sessionId))
}
}
LoggedInState.NotLoggedIn -> {
switchToNotLoggedInFlow(null)
} }
} }
LoggedInState.NotLoggedIn -> {
switchToNotLoggedInFlow(null)
}
} }
}.launchIn(lifecycleScope) .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<NavTarget, BackStack.State>
if (savedElements != null) {
backstack.accept(ReplaceAllOperation(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<NavKey<NavTarget>, SavedStateMap> ?: return null
return childrenState.entries.find { (key, _) -> key.navTarget == navTarget }?.value
} }
private fun switchToLoggedInFlow(sessionId: SessionId, navId: Int) { private fun switchToLoggedInFlow(sessionId: SessionId, navId: Int) {
@ -244,6 +301,13 @@ class RootFlowNode(
backstack.push(NavTarget.NotLoggedInFlow(null)) 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<LoggedInAppScopeFlowNode>(buildContext, plugins = listOf(inputs, callback)) createNode<LoggedInAppScopeFlowNode>(buildContext, plugins = listOf(inputs, callback))
} }
is NavTarget.NotLoggedInFlow -> { is NavTarget.NotLoggedInFlow -> {

View file

@ -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.libraries.matrix.api.core.SessionId
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.AnalyticsUserData import io.element.android.services.analyticsproviders.api.AnalyticsUserData
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import timber.log.Timber import timber.log.Timber
@ -77,20 +76,18 @@ class MatrixSessionCache(
} }
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
fun restoreWithSavedState(state: SavedStateMap?) { suspend fun restoreWithSavedState(state: SavedStateMap?) {
Timber.d("Restore state") Timber.d("Restore state")
if (state == null || sessionIdsToMatrixSession.isNotEmpty()) { if (state == null || sessionIdsToMatrixSession.isNotEmpty()) {
Timber.w("Restore with non-empty map") Timber.w("No need to restore saved state")
return return
} }
val sessionIds = state[SAVE_INSTANCE_KEY] as? Array<SessionId> val sessionIds = state[SAVE_INSTANCE_KEY] as? Array<SessionId>
Timber.d("Restore matrix session keys = ${sessionIds?.map { it.value }}") Timber.d("Restore matrix session keys = ${sessionIds?.map { it.value }}")
if (sessionIds.isNullOrEmpty()) return 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. // 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 ->
sessionIds.forEach { sessionId -> getOrRestore(sessionId)
getOrRestore(sessionId)
}
} }
} }

View file

@ -42,6 +42,8 @@ serialization_json = "1.10.0"
coil = "3.3.0" coil = "3.3.0"
# Rollback to 1.0.4, 1.0.5 has this issue: https://github.com/airbnb/Showkase/issues/420 # Rollback to 1.0.4, 1.0.5 has this issue: https://github.com/airbnb/Showkase/issues/420
showkase = "1.0.5" 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" appyx = "1.7.1"
sqldelight = "2.2.1" sqldelight = "2.2.1"
wysiwyg = "2.41.1" wysiwyg = "2.41.1"