Merge pull request #803 from vector-im/feature/fga/app_nav_node_fixes

Feature/fga/app nav node fixes
This commit is contained in:
ganfra 2023-07-07 10:26:59 +02:00 committed by GitHub
commit b70fbb24a6
8 changed files with 111 additions and 77 deletions

View file

@ -18,6 +18,7 @@ package io.element.android.appnav
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.NewRoot import com.bumble.appyx.navmodel.backstack.operation.NewRoot
import com.bumble.appyx.navmodel.backstack.operation.Remove
/** /**
* Don't process NewRoot if the nav target already exists in the stack. * Don't process NewRoot if the nav target already exists in the stack.
@ -29,3 +30,14 @@ fun <T : Any> BackStack<T>.safeRoot(element: T) {
if (containsRoot) return if (containsRoot) return
accept(NewRoot(element)) accept(NewRoot(element))
} }
/**
* Remove the last element on the backstack equals to the given one.
*/
fun <T : Any> BackStack<T>.removeLast(element: T) {
val lastExpectedNavElement = elements.value.lastOrNull {
it.key.navTarget == element
} ?: return
accept(Remove(lastExpectedNavElement.key))
}

View file

@ -56,7 +56,6 @@ import io.element.android.libraries.architecture.animation.rememberDefaultTransi
import io.element.android.libraries.architecture.bindings import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
@ -92,7 +91,7 @@ class LoggedInFlowNode @AssistedInject constructor(
snackbarDispatcher: SnackbarDispatcher, snackbarDispatcher: SnackbarDispatcher,
) : BackstackNode<LoggedInFlowNode.NavTarget>( ) : BackstackNode<LoggedInFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.SplashScreen, initialElement = NavTarget.RoomList,
savedStateMap = buildContext.savedStateMap, savedStateMap = buildContext.savedStateMap,
), ),
buildContext = buildContext, buildContext = buildContext,
@ -104,22 +103,14 @@ class LoggedInFlowNode @AssistedInject constructor(
.distinctUntilChanged() .distinctUntilChanged()
.onEach { isConsentAsked -> .onEach { isConsentAsked ->
if (isConsentAsked) { if (isConsentAsked) {
switchToRoomList() backstack.removeLast(NavTarget.AnalyticsOptIn)
} else { } else {
switchToAnalytics() backstack.push(NavTarget.AnalyticsOptIn)
} }
} }
.launchIn(lifecycleScope) .launchIn(lifecycleScope)
} }
private fun switchToRoomList() {
backstack.safeRoot(NavTarget.RoomList)
}
private fun switchToAnalytics() {
backstack.safeRoot(NavTarget.AnalyticsSettings)
}
interface Callback : Plugin { interface Callback : Plugin {
fun onOpenBugReport() = Unit fun onOpenBugReport() = Unit
} }
@ -194,9 +185,6 @@ class LoggedInFlowNode @AssistedInject constructor(
} }
sealed interface NavTarget : Parcelable { sealed interface NavTarget : Parcelable {
@Parcelize
object SplashScreen : NavTarget
@Parcelize @Parcelize
object Permanent : NavTarget object Permanent : NavTarget
@ -222,12 +210,11 @@ class LoggedInFlowNode @AssistedInject constructor(
object InviteList : NavTarget object InviteList : NavTarget
@Parcelize @Parcelize
object AnalyticsSettings : NavTarget object AnalyticsOptIn : NavTarget
} }
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return when (navTarget) {
NavTarget.SplashScreen -> splashNode(buildContext)
NavTarget.Permanent -> { NavTarget.Permanent -> {
createNode<LoggedInNode>(buildContext) createNode<LoggedInNode>(buildContext)
} }
@ -330,7 +317,7 @@ class LoggedInFlowNode @AssistedInject constructor(
.callback(callback) .callback(callback)
.build() .build()
} }
NavTarget.AnalyticsSettings -> { NavTarget.AnalyticsOptIn -> {
analyticsOptInEntryPoint.createNode(this, buildContext) analyticsOptInEntryPoint.createNode(this, buildContext)
} }
} }
@ -349,12 +336,6 @@ class LoggedInFlowNode @AssistedInject constructor(
} }
} }
private fun splashNode(buildContext: BuildContext) = node(buildContext) {
Box(modifier = it.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
Box(modifier = modifier) { Box(modifier = modifier) {

View file

@ -31,7 +31,6 @@ 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.plugin.plugins import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.newRoot
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
import dagger.assisted.Assisted import dagger.assisted.Assisted
@ -50,20 +49,23 @@ import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
import io.element.android.libraries.architecture.BackstackNode import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.waitForChildAttached
import io.element.android.libraries.deeplink.DeeplinkData import io.element.android.libraries.deeplink.DeeplinkData
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService 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.libraries.matrix.api.core.UserId import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import timber.log.Timber import timber.log.Timber
import java.util.UUID
@ContributesNode(AppScope::class) @ContributesNode(AppScope::class)
class RootFlowNode @AssistedInject constructor( class RootFlowNode @AssistedInject constructor(
@ -93,20 +95,15 @@ class RootFlowNode @AssistedInject constructor(
} }
private fun observeLoggedInState() { private fun observeLoggedInState() {
isUserLoggedInFlow() combine(
.combine( cacheService.onClearedCacheEventFlow(),
cacheService.cacheIndex().onEach { isUserLoggedInFlow(),
Timber.v("cacheIndex=$it") ) { _, isLoggedIn -> isLoggedIn }
matrixClientsHolder.removeAll() .onEach { isLoggedIn ->
} Timber.v("isLoggedIn=$isLoggedIn")
) { isLoggedIn, cacheIdx -> isLoggedIn to cacheIdx }
.onEach { pair ->
val isLoggedIn = pair.first
val cacheIndex = pair.second
Timber.v("isLoggedIn=$isLoggedIn, cacheIndex=$cacheIndex")
if (isLoggedIn) { if (isLoggedIn) {
tryToRestoreLatestSession( tryToRestoreLatestSession(
onSuccess = { switchToLoggedInFlow(it, cacheIndex) }, onSuccess = { switchToLoggedInFlow(it) },
onFailure = { switchToNotLoggedInFlow() } onFailure = { switchToNotLoggedInFlow() }
) )
} else { } else {
@ -116,6 +113,11 @@ class RootFlowNode @AssistedInject constructor(
.launchIn(lifecycleScope) .launchIn(lifecycleScope)
} }
private fun switchToLoggedInFlow(sessionId: SessionId) {
backstack.safeRoot(NavTarget.LoggedInFlow(sessionId))
}
private fun isUserLoggedInFlow(): Flow<Boolean> { private fun isUserLoggedInFlow(): Flow<Boolean> {
return combine( return combine(
authenticationService.isLoggedIn(), authenticationService.isLoggedIn(),
@ -126,37 +128,43 @@ class RootFlowNode @AssistedInject constructor(
.distinctUntilChanged() .distinctUntilChanged()
} }
private fun switchToLoggedInFlow(sessionId: SessionId, cacheIndex: Int) {
backstack.safeRoot(NavTarget.LoggedInFlow(sessionId, cacheIndex))
}
private fun switchToNotLoggedInFlow() { private fun switchToNotLoggedInFlow() {
matrixClientsHolder.removeAll() matrixClientsHolder.removeAll()
backstack.safeRoot(NavTarget.NotLoggedInFlow) backstack.safeRoot(NavTarget.NotLoggedInFlow)
} }
private suspend fun restoreSessionIfNeeded(
sessionId: SessionId,
onFailure: () -> Unit = {},
onSuccess: (SessionId) -> Unit = {},
) {
// If the session is already known it'll be restored by the node hierarchy
if (matrixClientsHolder.knowSession(sessionId)) {
Timber.v("Session $sessionId already alive, no need to restore.")
return
}
authenticationService.restoreSession(sessionId)
.onSuccess { matrixClient ->
matrixClientsHolder.add(matrixClient)
Timber.v("Succeed to restore session $sessionId")
onSuccess(sessionId)
}
.onFailure {
Timber.v("Failed to restore session $sessionId")
onFailure()
}
}
private suspend fun tryToRestoreLatestSession( private suspend fun tryToRestoreLatestSession(
onSuccess: (UserId) -> Unit = {}, onSuccess: (SessionId) -> Unit = {},
onFailure: () -> Unit = {} onFailure: () -> Unit = {}
) { ) {
val latestKnownUserId = authenticationService.getLatestSessionId() val latestSessionId = authenticationService.getLatestSessionId()
if (latestKnownUserId == null) { if (latestSessionId == null) {
onFailure() onFailure()
return return
} }
if (matrixClientsHolder.knowSession(latestKnownUserId)) { restoreSessionIfNeeded(latestSessionId, onFailure, onSuccess)
onSuccess(latestKnownUserId)
return
}
authenticationService.restoreSession(UserId(latestKnownUserId.value))
.onSuccess { matrixClient ->
matrixClientsHolder.add(matrixClient)
onSuccess(matrixClient.sessionId)
}
.onFailure {
Timber.v("Failed to restore session...")
onFailure()
}
} }
private fun onOpenBugReport() { private fun onOpenBugReport() {
@ -187,7 +195,10 @@ class RootFlowNode @AssistedInject constructor(
object NotLoggedInFlow : NavTarget object NotLoggedInFlow : NavTarget
@Parcelize @Parcelize
data class LoggedInFlow(val sessionId: SessionId, val cacheIndex: Int) : NavTarget data class LoggedInFlow(
val sessionId: SessionId,
val navId: UUID = UUID.randomUUID(),
) : NavTarget
@Parcelize @Parcelize
object BugReport : NavTarget object BugReport : NavTarget
@ -198,7 +209,6 @@ class RootFlowNode @AssistedInject constructor(
is NavTarget.LoggedInFlow -> { is NavTarget.LoggedInFlow -> {
val matrixClient = matrixClientsHolder.getOrNull(navTarget.sessionId) ?: return splashNode(buildContext).also { val matrixClient = matrixClientsHolder.getOrNull(navTarget.sessionId) ?: return splashNode(buildContext).also {
Timber.w("Couldn't find any session, go through SplashScreen") Timber.w("Couldn't find any session, go through SplashScreen")
backstack.newRoot(NavTarget.SplashScreen)
} }
val inputs = LoggedInFlowNode.Inputs(matrixClient) val inputs = LoggedInFlowNode.Inputs(matrixClient)
val callback = object : LoggedInFlowNode.Callback { val callback = object : LoggedInFlowNode.Callback {
@ -259,9 +269,16 @@ class RootFlowNode @AssistedInject constructor(
} }
private suspend fun attachSession(sessionId: SessionId): LoggedInFlowNode { private suspend fun attachSession(sessionId: SessionId): LoggedInFlowNode {
val cacheIndex = cacheService.cacheIndex().first() //TODO handle multi-session
return attachChild { return waitForChildAttached { navTarget ->
backstack.newRoot(NavTarget.LoggedInFlow(sessionId, cacheIndex)) navTarget is NavTarget.LoggedInFlow && navTarget.sessionId == sessionId
} }
} }
private fun CacheService.onClearedCacheEventFlow(): Flow<Unit> {
return clearedCacheEventFlow
.onEach { sessionId -> matrixClientsHolder.remove(sessionId) }
.map { }
.onStart { emit((Unit)) }
}
} }

View file

@ -23,4 +23,5 @@ android {
dependencies { dependencies {
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
api(projects.libraries.matrix.api)
} }

View file

@ -16,13 +16,13 @@
package io.element.android.features.preferences.api package io.element.android.features.preferences.api
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
interface CacheService { interface CacheService {
/** /**
* Returns a flow of the current cache index, can let the app to know when the * A flow of [SessionId], can let the app to know when the
* cache has been cleared, for instance to restart the app. * cache has been cleared for a given session, for instance to restart the app.
* Will be a flow of Int, starting from 0, and incrementing each time the cache is cleared.
*/ */
fun cacheIndex(): Flow<Int> val clearedCacheEventFlow: Flow<SessionId>
} }

View file

@ -20,20 +20,19 @@ import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.preferences.api.CacheService import io.element.android.features.preferences.api.CacheService
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableSharedFlow
import javax.inject.Inject import javax.inject.Inject
@SingleIn(AppScope::class) @SingleIn(AppScope::class)
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultCacheService @Inject constructor() : CacheService { class DefaultCacheService @Inject constructor() : CacheService {
private val cacheIndexState = MutableStateFlow(0)
override fun cacheIndex(): Flow<Int> { private val _clearedCacheEventFlow = MutableSharedFlow<SessionId>(0)
return cacheIndexState override val clearedCacheEventFlow: Flow<SessionId> = _clearedCacheEventFlow
}
fun incrementCacheIndex() { suspend fun onClearedCache(sessionId: SessionId) {
cacheIndexState.value++ _clearedCacheEventFlow.emit(sessionId)
} }
} }

View file

@ -27,6 +27,7 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import javax.inject.Inject import javax.inject.Inject
@ -57,6 +58,6 @@ class DefaultClearCacheUseCase @Inject constructor(
// Clear app cache // Clear app cache
context.cacheDir.deleteRecursively() context.cacheDir.deleteRecursively()
// Ensure the app is restarted // Ensure the app is restarted
defaultCacheIndexProvider.incrementCacheIndex() defaultCacheIndexProvider.onClearedCache(matrixClient.sessionId)
} }
} }

View file

@ -16,12 +16,35 @@
package io.element.android.libraries.architecture package io.element.android.libraries.architecture
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.children.nodeOrNull import com.bumble.appyx.core.children.nodeOrNull
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.ParentNode import com.bumble.appyx.core.node.ParentNode
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
fun <NavTarget : Any> ParentNode<NavTarget>.childNode(navTarget: NavTarget): Node? { fun <NavTarget : Any> ParentNode<NavTarget>.childNode(navTarget: NavTarget): Node? {
val childMap = children.value val childMap = children.value
val key = childMap.keys.find { it.navTarget == navTarget } val key = childMap.keys.find { it.navTarget == navTarget }
return childMap[key]?.nodeOrNull return childMap[key]?.nodeOrNull
} }
suspend inline fun <reified N : Node, NavTarget : Any> ParentNode<NavTarget>.waitForChildAttached(crossinline predicate: (NavTarget) -> Boolean): N =
suspendCancellableCoroutine { continuation ->
lifecycleScope.launch {
children.collect { childMap ->
val expectedChildNode = childMap.entries
.map { it.key.navTarget }
.lastOrNull(predicate)
?.let {
childNode(it) as? N
}
if (expectedChildNode != null && !continuation.isCompleted) {
continuation.resume(expectedChildNode)
}
}
}.invokeOnCompletion {
continuation.cancel()
}
}