First Commit

This commit is contained in:
2025-12-18 16:28:50 +07:00
commit 8c3e4f491f
9974 changed files with 396488 additions and 0 deletions

71
appnav/build.gradle.kts Normal file
View File

@@ -0,0 +1,71 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
@file:Suppress("UnstableApiUsage")
import extension.allFeaturesApi
import extension.setupDependencyInjection
import extension.testCommonDependencies
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.appnav"
}
setupDependencyInjection()
dependencies {
allFeaturesApi(project)
implementation(projects.libraries.core)
implementation(projects.libraries.accountselect.api)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture)
implementation(projects.libraries.deeplink.api)
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.oidc.api)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.push.api)
implementation(projects.libraries.pushproviders.api)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.matrixmedia.api)
implementation(projects.libraries.uiCommon)
implementation(projects.libraries.uiStrings)
implementation(projects.features.login.api)
implementation(libs.coil)
implementation(projects.features.announcement.api)
implementation(projects.features.ftue.api)
implementation(projects.features.share.api)
implementation(projects.services.apperror.impl)
implementation(projects.services.appnavstate.api)
implementation(projects.services.analytics.api)
testCommonDependencies(libs)
testImplementation(projects.features.login.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.oidc.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.push.test)
testImplementation(projects.libraries.pushproviders.test)
testImplementation(projects.features.forward.test)
testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.features.rageshake.test)
testImplementation(projects.services.appnavstate.impl)
testImplementation(projects.services.appnavstate.test)
testImplementation(projects.services.analytics.test)
testImplementation(projects.services.toolbox.test)
}

View File

@@ -0,0 +1,34 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.navmodel.backstack.BackStack
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.
*/
fun <T : Any> BackStack<T>.safeRoot(element: T) {
val containsRoot = elements.value.any {
it.key.navTarget == element
}
if (containsRoot) return
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

@@ -0,0 +1,107 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(DelicateCoilApi::class)
package io.element.android.appnav
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import coil3.SingletonImageLoader
import coil3.annotation.DelicateCoilApi
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.node.ParentNode
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.appnav.di.SessionGraphFactory
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.DependencyInjectionGraphOwner
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
import kotlinx.parcelize.Parcelize
/**
* `LoggedInAppScopeFlowNode` is a Node responsible to set up the Session graph.
* [io.element.android.libraries.di.SessionScope]. It has only one child: [LoggedInFlowNode].
* This allow to inject objects with SessionScope in the constructor of [LoggedInFlowNode].
*/
@ContributesNode(AppScope::class)
@AssistedInject
class LoggedInAppScopeFlowNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
sessionGraphFactory: SessionGraphFactory,
private val imageLoaderHolder: ImageLoaderHolder,
) : ParentNode<LoggedInAppScopeFlowNode.NavTarget>(
navModel = PermanentNavModel(
navTargets = setOf(NavTarget),
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins
), DependencyInjectionGraphOwner {
interface Callback : Plugin {
fun navigateToBugReport()
fun navigateToAddAccount()
}
private val callback: Callback = callback()
@Parcelize
object NavTarget : Parcelable
data class Inputs(
val matrixClient: MatrixClient
) : NodeInputs
private val inputs: Inputs = inputs()
override val graph = sessionGraphFactory.create(inputs.matrixClient)
override fun onBuilt() {
super.onBuilt()
lifecycle.subscribe(
onResume = {
SingletonImageLoader.setUnsafe(imageLoaderHolder.get(inputs.matrixClient))
},
)
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
val callback = object : LoggedInFlowNode.Callback {
override fun navigateToBugReport() {
callback.navigateToBugReport()
}
override fun navigateToAddAccount() {
callback.navigateToAddAccount()
}
}
return createNode<LoggedInFlowNode>(buildContext, listOf(callback))
}
suspend fun attachSession(): LoggedInFlowNode = waitForChildAttached()
@Composable
override fun View(modifier: Modifier) {
Children(
navModel = navModel,
modifier = modifier,
)
}
}

View File

@@ -0,0 +1,62 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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 dev.zacsweers.metro.Inject
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@Inject
class LoggedInEventProcessor(
private val snackbarDispatcher: SnackbarDispatcher,
private val roomMembershipObserver: RoomMembershipObserver,
) {
private var observingJob: Job? = null
fun observeEvents(coroutineScope: CoroutineScope) {
observingJob = roomMembershipObserver.updates
.filter { !it.isUserInRoom }
.distinctUntilChanged()
.onEach { roomMemberShipUpdate ->
when (roomMemberShipUpdate.change) {
MembershipChange.LEFT -> {
displayMessage(
if (roomMemberShipUpdate.isSpace) {
CommonStrings.common_current_user_left_space
} else {
CommonStrings.common_current_user_left_room
}
)
}
MembershipChange.INVITATION_REJECTED -> displayMessage(CommonStrings.common_current_user_rejected_invite)
MembershipChange.KNOCK_RETRACTED -> displayMessage(CommonStrings.common_current_user_canceled_knock)
else -> Unit
}
}
.launchIn(coroutineScope)
}
fun stopObserving() {
observingJob?.cancel()
observingJob = null
}
private fun displayMessage(message: Int) {
snackbarDispatcher.post(SnackbarMessage(message))
}
}

View File

@@ -0,0 +1,633 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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 android.content.Intent
import android.os.Parcelable
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.composable.PermanentChild
import com.bumble.appyx.core.lifecycle.subscribe
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.model.permanent.PermanentNavModel
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.BackStack.State.ACTIVE
import com.bumble.appyx.navmodel.backstack.BackStack.State.CREATED
import com.bumble.appyx.navmodel.backstack.BackStack.State.STASHED
import com.bumble.appyx.navmodel.backstack.BackStackElement
import com.bumble.appyx.navmodel.backstack.BackStackElements
import com.bumble.appyx.navmodel.backstack.operation.BackStackOperation
import com.bumble.appyx.navmodel.backstack.operation.Push
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import com.bumble.appyx.navmodel.backstack.operation.replace
import com.bumble.appyx.navmodel.backstack.operation.singleTop
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.appnav.loggedin.LoggedInNode
import io.element.android.appnav.loggedin.MediaPreviewConfigMigration
import io.element.android.appnav.loggedin.SendQueues
import io.element.android.appnav.room.RoomFlowNode
import io.element.android.appnav.room.RoomNavigationTarget
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.api.SessionEnterpriseService
import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.features.ftue.api.state.FtueService
import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.home.api.HomeEntryPoint
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorContainer
import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint
import io.element.android.features.securebackup.api.SecureBackupEntryPoint
import io.element.android.features.share.api.ShareEntryPoint
import io.element.android.features.startchat.api.StartChatEntryPoint
import io.element.android.features.userprofile.api.UserProfileEntryPoint
import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.waitForChildAttached
import io.element.android.libraries.architecture.waitForNavTargetAttached
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.designsystem.theme.ElementThemeApp
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.MAIN_SPACE
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService
import io.element.android.libraries.ui.common.nodes.emptyNode
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.watchers.AnalyticsRoomListStateWatcher
import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import kotlinx.parcelize.Parcelize
import timber.log.Timber
import java.time.Duration
import java.time.Instant
import java.util.Optional
import java.util.UUID
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.toKotlinDuration
import im.vector.app.features.analytics.plan.JoinedRoom as JoinedRoomAnalyticsEvent
@ContributesNode(SessionScope::class)
@AssistedInject
class LoggedInFlowNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val homeEntryPoint: HomeEntryPoint,
private val preferencesEntryPoint: PreferencesEntryPoint,
private val startChatEntryPoint: StartChatEntryPoint,
private val appNavigationStateService: AppNavigationStateService,
private val secureBackupEntryPoint: SecureBackupEntryPoint,
private val userProfileEntryPoint: UserProfileEntryPoint,
private val ftueEntryPoint: FtueEntryPoint,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
private val ftueService: FtueService,
private val roomDirectoryEntryPoint: RoomDirectoryEntryPoint,
private val shareEntryPoint: ShareEntryPoint,
private val matrixClient: MatrixClient,
private val sendingQueue: SendQueues,
private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint,
private val mediaPreviewConfigMigration: MediaPreviewConfigMigration,
private val sessionEnterpriseService: SessionEnterpriseService,
private val networkMonitor: NetworkMonitor,
private val notificationConversationService: NotificationConversationService,
private val syncService: SyncService,
private val enterpriseService: EnterpriseService,
private val appPreferencesStore: AppPreferencesStore,
private val buildMeta: BuildMeta,
snackbarDispatcher: SnackbarDispatcher,
private val analyticsService: AnalyticsService,
private val analyticsRoomListStateWatcher: AnalyticsRoomListStateWatcher,
) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Placeholder,
savedStateMap = buildContext.savedStateMap,
),
permanentNavModel = PermanentNavModel(
navTargets = setOf(NavTarget.LoggedInPermanent),
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins
) {
interface Callback : Plugin {
fun navigateToBugReport()
fun navigateToAddAccount()
}
private val callback: Callback = callback()
private val loggedInFlowProcessor = LoggedInEventProcessor(
snackbarDispatcher = snackbarDispatcher,
roomMembershipObserver = matrixClient.roomMembershipObserver,
)
private val verificationListener = object : SessionVerificationServiceListener {
override fun onIncomingSessionRequest(verificationRequest: VerificationRequest.Incoming) {
// Without this launch the rendering and actual state of this Appyx node's children gets out of sync, resulting in a crash.
// This might be because this method is called back from Rust in a background thread.
lifecycleScope.launch {
val receivedAt = Instant.now()
// Wait until the app is in foreground to display the incoming verification request
appNavigationStateService.appNavigationState.first { it.isInForeground }
// TODO there should also be a timeout for > 10 minutes elapsed since the request was created, but the SDK doesn't expose that info yet
val now = Instant.now()
val elapsedTimeSinceReceived = Duration.between(receivedAt, now).toKotlinDuration()
// Discard the incoming verification request if it has timed out
if (elapsedTimeSinceReceived > 2.minutes) {
Timber.w("Incoming verification request ${verificationRequest.details.flowId} discarded due to timeout.")
return@launch
}
// Wait for the RoomList UI to be ready so the incoming verification screen can be displayed on top of it
// Otherwise, the RoomList UI may be incorrectly displayed on top
withTimeout(5.seconds) {
backstack.elements.first { elements ->
elements.any { it.key.navTarget == NavTarget.Home }
}
}
backstack.singleTop(NavTarget.IncomingVerificationRequest(verificationRequest))
}
}
}
override fun onBuilt() {
super.onBuilt()
lifecycleScope.launch {
sessionEnterpriseService.init()
}
lifecycle.subscribe(
onCreate = {
analyticsRoomListStateWatcher.start()
appNavigationStateService.onNavigateToSession(id, matrixClient.sessionId)
// TODO We do not support Space yet, so directly navigate to main space
appNavigationStateService.onNavigateToSpace(id, MAIN_SPACE)
loggedInFlowProcessor.observeEvents(sessionCoroutineScope)
matrixClient.sessionVerificationService.setListener(verificationListener)
mediaPreviewConfigMigration()
sessionCoroutineScope.launch {
// Wait for the network to be connected before pre-fetching the max file upload size
networkMonitor.connectivity.first { networkStatus -> networkStatus == NetworkStatus.Connected }
matrixClient.getMaxFileUploadSize()
}
analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.FirstRoomsDisplayed)
ftueService.state
.onEach { ftueState ->
when (ftueState) {
is FtueState.Unknown -> Unit // Nothing to do
is FtueState.Incomplete -> backstack.safeRoot(NavTarget.Ftue)
is FtueState.Complete -> backstack.safeRoot(NavTarget.Home)
}
}
.launchIn(lifecycleScope)
},
onResume = {
lifecycleScope.launch {
val availableRoomIds = matrixClient.getJoinedRoomIds().getOrNull() ?: return@launch
notificationConversationService.onAvailableRoomsChanged(sessionId = matrixClient.sessionId, roomIds = availableRoomIds)
}
},
onDestroy = {
appNavigationStateService.onLeavingSpace(id)
appNavigationStateService.onLeavingSession(id)
loggedInFlowProcessor.stopObserving()
matrixClient.sessionVerificationService.setListener(null)
analyticsRoomListStateWatcher.stop()
}
)
setupSendingQueue()
}
private fun setupSendingQueue() {
sendingQueue.launchIn(lifecycleScope)
}
sealed interface NavTarget : Parcelable {
@Parcelize
data object Placeholder : NavTarget
@Parcelize
data object LoggedInPermanent : NavTarget
@Parcelize
data object Home : NavTarget
@Parcelize
data class Room(
val roomIdOrAlias: RoomIdOrAlias,
val serverNames: List<String> = emptyList(),
val trigger: JoinedRoomAnalyticsEvent.Trigger? = null,
val roomDescription: RoomDescription? = null,
val initialElement: RoomNavigationTarget = RoomNavigationTarget.Root(),
val targetId: UUID = UUID.randomUUID(),
) : NavTarget
@Parcelize
data class UserProfile(
val userId: UserId,
) : NavTarget
@Parcelize
data class Settings(
val initialElement: PreferencesEntryPoint.InitialTarget = PreferencesEntryPoint.InitialTarget.Root
) : NavTarget
@Parcelize
data object CreateRoom : NavTarget
@Parcelize
data class SecureBackup(
val initialElement: SecureBackupEntryPoint.InitialTarget = SecureBackupEntryPoint.InitialTarget.Root
) : NavTarget
@Parcelize
data object Ftue : NavTarget
@Parcelize
data object RoomDirectory : NavTarget
@Parcelize
data class IncomingShare(val intent: Intent) : NavTarget
@Parcelize
data class IncomingVerificationRequest(val data: VerificationRequest.Incoming) : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Placeholder -> emptyNode(buildContext)
NavTarget.LoggedInPermanent -> {
val callback = object : LoggedInNode.Callback {
override fun navigateToNotificationTroubleshoot() {
backstack.push(NavTarget.Settings(PreferencesEntryPoint.InitialTarget.NotificationTroubleshoot))
}
}
createNode<LoggedInNode>(buildContext, listOf(callback))
}
NavTarget.Home -> {
val callback = object : HomeEntryPoint.Callback {
override fun navigateToRoom(roomId: RoomId, joinedRoom: JoinedRoom?) {
backstack.push(
NavTarget.Room(
roomIdOrAlias = roomId.toRoomIdOrAlias(),
initialElement = RoomNavigationTarget.Root(joinedRoom = joinedRoom)
)
)
}
override fun navigateToSettings() {
backstack.push(NavTarget.Settings())
}
override fun navigateToCreateRoom() {
backstack.push(NavTarget.CreateRoom)
}
override fun navigateToSetUpRecovery() {
backstack.push(NavTarget.SecureBackup(initialElement = SecureBackupEntryPoint.InitialTarget.Root))
}
override fun navigateToEnterRecoveryKey() {
backstack.push(NavTarget.SecureBackup(initialElement = SecureBackupEntryPoint.InitialTarget.EnterRecoveryKey))
}
override fun navigateToRoomSettings(roomId: RoomId) {
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), initialElement = RoomNavigationTarget.Details))
}
override fun navigateToBugReport() {
callback.navigateToBugReport()
}
}
homeEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
callback = callback,
)
}
is NavTarget.Room -> {
val joinedRoomCallback = object : JoinedRoomLoadedFlowNode.Callback {
override fun navigateToRoom(roomId: RoomId, serverNames: List<String>) {
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), serverNames))
}
override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) {
when (data) {
is PermalinkData.UserLink -> {
// Should not happen (handled by MessagesNode)
Timber.e("User link clicked: ${data.userId}.")
}
is PermalinkData.RoomLink -> {
val target = NavTarget.Room(
roomIdOrAlias = data.roomIdOrAlias,
serverNames = data.viaParameters,
trigger = JoinedRoomAnalyticsEvent.Trigger.Timeline,
initialElement = RoomNavigationTarget.Root(data.eventId),
)
if (pushToBackstack) {
backstack.push(target)
} else {
backstack.replace(target)
}
}
is PermalinkData.FallbackLink,
is PermalinkData.RoomEmailInviteLink -> {
// Should not happen (handled by MessagesNode)
}
}
}
override fun navigateToGlobalNotificationSettings() {
backstack.push(NavTarget.Settings(PreferencesEntryPoint.InitialTarget.NotificationSettings))
}
}
val inputs = RoomFlowNode.Inputs(
roomIdOrAlias = navTarget.roomIdOrAlias,
roomDescription = Optional.ofNullable(navTarget.roomDescription),
serverNames = navTarget.serverNames,
trigger = Optional.ofNullable(navTarget.trigger),
initialElement = navTarget.initialElement
)
createNode<RoomFlowNode>(buildContext, plugins = listOf(inputs, joinedRoomCallback))
}
is NavTarget.UserProfile -> {
val callback = object : UserProfileEntryPoint.Callback {
override fun navigateToRoom(roomId: RoomId) {
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias()))
}
}
userProfileEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = UserProfileEntryPoint.Params(userId = navTarget.userId),
callback = callback,
)
}
is NavTarget.Settings -> {
val callback = object : PreferencesEntryPoint.Callback {
override fun navigateToAddAccount() {
callback.navigateToAddAccount()
}
override fun navigateToBugReport() {
callback.navigateToBugReport()
}
override fun navigateToSecureBackup() {
backstack.push(NavTarget.SecureBackup())
}
override fun navigateToRoomNotificationSettings(roomId: RoomId) {
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), initialElement = RoomNavigationTarget.NotificationSettings))
}
override fun navigateToEvent(roomId: RoomId, eventId: EventId) {
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), initialElement = RoomNavigationTarget.Root(eventId)))
}
}
val inputs = PreferencesEntryPoint.Params(navTarget.initialElement)
preferencesEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = inputs,
callback = callback,
)
}
NavTarget.CreateRoom -> {
val callback = object : StartChatEntryPoint.Callback {
override fun onRoomCreated(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>) {
backstack.replace(NavTarget.Room(roomIdOrAlias = roomIdOrAlias, serverNames = serverNames))
}
override fun navigateToRoomDirectory() {
backstack.push(NavTarget.RoomDirectory)
}
}
startChatEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
callback = callback,
)
}
is NavTarget.SecureBackup -> {
secureBackupEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = SecureBackupEntryPoint.Params(initialElement = navTarget.initialElement),
callback = object : SecureBackupEntryPoint.Callback {
override fun onDone() {
backstack.pop()
}
},
)
}
NavTarget.Ftue -> {
ftueEntryPoint.createNode(this, buildContext)
}
NavTarget.RoomDirectory -> {
roomDirectoryEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
callback = object : RoomDirectoryEntryPoint.Callback {
override fun navigateToRoom(roomDescription: RoomDescription) {
backstack.push(
NavTarget.Room(
roomIdOrAlias = roomDescription.roomId.toRoomIdOrAlias(),
roomDescription = roomDescription,
trigger = JoinedRoomAnalyticsEvent.Trigger.RoomDirectory,
)
)
}
},
)
}
is NavTarget.IncomingShare -> {
shareEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = ShareEntryPoint.Params(intent = navTarget.intent),
callback = object : ShareEntryPoint.Callback {
override fun onDone(roomIds: List<RoomId>) {
backstack.pop()
roomIds.singleOrNull()?.let { roomId ->
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias()))
}
}
},
)
}
is NavTarget.IncomingVerificationRequest -> {
incomingVerificationEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = IncomingVerificationEntryPoint.Params(navTarget.data),
callback = object : IncomingVerificationEntryPoint.Callback {
override fun onDone() {
backstack.pop()
}
},
)
}
}
}
suspend fun attachRoom(
roomIdOrAlias: RoomIdOrAlias,
serverNames: List<String> = emptyList(),
trigger: JoinedRoomAnalyticsEvent.Trigger? = null,
eventId: EventId? = null,
clearBackstack: Boolean,
): RoomFlowNode {
waitForNavTargetAttached { navTarget ->
navTarget is NavTarget.Home
}
attachChild<RoomFlowNode> {
val roomNavTarget = NavTarget.Room(
roomIdOrAlias = roomIdOrAlias,
serverNames = serverNames,
trigger = trigger,
initialElement = RoomNavigationTarget.Root(eventId = eventId)
)
backstack.accept(AttachRoomOperation(roomNavTarget, clearBackstack))
}
// If we don't do this check, we might be returning while a previous node with the same type is still displayed
// This means we may attach some new nodes to that one, which will be quickly replaced by the one instantiated above
return waitForChildAttached<RoomFlowNode, NavTarget> {
it is NavTarget.Room &&
it.roomIdOrAlias == roomIdOrAlias &&
it.initialElement is RoomNavigationTarget.Root &&
it.initialElement.eventId == eventId
}
}
suspend fun attachUser(userId: UserId) {
waitForNavTargetAttached { navTarget ->
navTarget is NavTarget.Home
}
attachChild<Node> {
backstack.push(
NavTarget.UserProfile(
userId = userId,
)
)
}
}
internal suspend fun attachIncomingShare(intent: Intent) {
waitForNavTargetAttached { navTarget ->
navTarget is NavTarget.Home
}
attachChild<Node> {
backstack.push(
NavTarget.IncomingShare(intent)
)
}
}
@Composable
override fun View(modifier: Modifier) {
val colors by remember {
enterpriseService.semanticColorsFlow(sessionId = matrixClient.sessionId)
}.collectAsState(SemanticColorsLightDark.default)
ElementThemeApp(
appPreferencesStore = appPreferencesStore,
compoundLight = colors.light,
compoundDark = colors.dark,
buildMeta = buildMeta,
) {
val isOnline by syncService.isOnline.collectAsState()
ConnectivityIndicatorContainer(
isOnline = isOnline,
modifier = modifier,
) { contentModifier ->
Box(modifier = contentModifier) {
val ftueState by ftueService.state.collectAsState()
BackstackView()
if (ftueState is FtueState.Complete) {
PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LoggedInPermanent)
}
}
}
}
}
}
@Parcelize
private class AttachRoomOperation(
val roomTarget: LoggedInFlowNode.NavTarget.Room,
val clearBackstack: Boolean,
) : BackStackOperation<LoggedInFlowNode.NavTarget> {
override fun isApplicable(elements: NavElements<LoggedInFlowNode.NavTarget, BackStack.State>) = true
override fun invoke(elements: BackStackElements<LoggedInFlowNode.NavTarget>): BackStackElements<LoggedInFlowNode.NavTarget> {
return if (clearBackstack) {
// Makes sure the room list target is alone in the backstack and stashed
elements.mapNotNull { element ->
if (element.key.navTarget == LoggedInFlowNode.NavTarget.Home) {
element.transitionTo(STASHED, this)
} else {
null
}
} + BackStackElement(
key = NavKey(roomTarget),
fromState = CREATED,
targetState = ACTIVE,
operation = this
)
} else {
Push<LoggedInFlowNode.NavTarget>(roomTarget).invoke(elements)
}
}
}

View File

@@ -0,0 +1,115 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(DelicateCoilApi::class)
package io.element.android.appnav
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import coil3.SingletonImageLoader
import coil3.annotation.DelicateCoilApi
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.login.api.LoginEntryPoint
import io.element.android.features.login.api.LoginParams
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.designsystem.utils.ForceOrientationInMobileDevices
import io.element.android.libraries.designsystem.utils.ScreenOrientation
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
import io.element.android.services.analytics.api.watchers.AnalyticsColdStartWatcher
import kotlinx.parcelize.Parcelize
@ContributesNode(AppScope::class)
@AssistedInject
class NotLoggedInFlowNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val loginEntryPoint: LoginEntryPoint,
private val imageLoaderHolder: ImageLoaderHolder,
private val analyticsColdStartWatcher: AnalyticsColdStartWatcher,
) : BaseFlowNode<NotLoggedInFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
savedStateMap = buildContext.savedStateMap
),
buildContext = buildContext,
plugins = plugins,
) {
data class Params(
val loginParams: LoginParams?,
) : NodeInputs
interface Callback : Plugin {
fun navigateToBugReport()
fun onDone()
}
private val callback: Callback = callback()
private val inputs = inputs<Params>()
override fun onBuilt() {
super.onBuilt()
analyticsColdStartWatcher.whenLoggingIn()
lifecycle.subscribe(
onResume = {
SingletonImageLoader.setUnsafe(imageLoaderHolder.get())
},
)
}
sealed interface NavTarget : Parcelable {
@Parcelize
data object Root : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Root -> {
val callback = object : LoginEntryPoint.Callback {
override fun navigateToBugReport() {
callback.navigateToBugReport()
}
override fun onDone() {
callback.onDone()
}
}
loginEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = LoginEntryPoint.Params(
accountProvider = inputs.loginParams?.accountProvider,
loginHint = inputs.loginParams?.loginHint,
),
callback = callback,
)
}
}
}
@Composable
override fun View(modifier: Modifier) {
// The login flow doesn't support landscape mode on mobile devices yet
ForceOrientationInMobileDevices(orientation = ScreenOrientation.PORTRAIT)
BackstackView()
}
}

View File

@@ -0,0 +1,478 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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 android.content.Intent
import android.os.Parcelable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
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.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.state.MutableSavedStateMap
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackFader
import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackSlider
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.annotations.ContributesNode
import io.element.android.appnav.di.MatrixSessionCache
import io.element.android.appnav.intent.IntentResolver
import io.element.android.appnav.intent.ResolvedIntent
import io.element.android.appnav.room.RoomFlowNode
import io.element.android.appnav.root.RootNavStateFlowFactory
import io.element.android.appnav.root.RootPresenter
import io.element.android.appnav.root.RootView
import io.element.android.features.announcement.api.AnnouncementService
import io.element.android.features.login.api.LoginParams
import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
import io.element.android.features.signedout.api.SignedOutEntryPoint
import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.appyx.rememberDelegateTransitionHandler
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.featureflag.api.FeatureFlagService
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.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.asEventId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.oidc.api.OidcAction
import io.element.android.libraries.oidc.api.OidcActionFlow
import io.element.android.libraries.sessionstorage.api.LoggedInState
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.ui.common.nodes.emptyNode
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.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import timber.log.Timber
@ContributesNode(AppScope::class)
@AssistedInject
class RootFlowNode(
@Assisted val buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val sessionStore: SessionStore,
private val accountProviderAccessControl: AccountProviderAccessControl,
private val navStateFlowFactory: RootNavStateFlowFactory,
private val matrixSessionCache: MatrixSessionCache,
private val presenter: RootPresenter,
private val bugReportEntryPoint: BugReportEntryPoint,
private val signedOutEntryPoint: SignedOutEntryPoint,
private val accountSelectEntryPoint: AccountSelectEntryPoint,
private val intentResolver: IntentResolver,
private val oidcActionFlow: OidcActionFlow,
private val featureFlagService: FeatureFlagService,
private val announcementService: AnnouncementService,
private val analyticsService: AnalyticsService,
private val analyticsColdStartWatcher: AnalyticsColdStartWatcher,
) : BaseFlowNode<RootFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.SplashScreen,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins
) {
override fun onBuilt() {
analyticsColdStartWatcher.start()
matrixSessionCache.restoreWithSavedState(buildContext.savedStateMap)
super.onBuilt()
observeNavState()
}
override fun onSaveInstanceState(state: MutableSavedStateMap) {
super.onSaveInstanceState(state)
matrixSessionCache.saveIntoSavedState(state)
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) {
tryToRestoreLatestSession(
onSuccess = { sessionId -> switchToLoggedInFlow(sessionId, navState.cacheIndex) },
onFailure = { switchToNotLoggedInFlow(null) }
)
} else {
switchToSignedOutFlow(SessionId(navState.loggedInState.sessionId))
}
}
LoggedInState.NotLoggedIn -> {
switchToNotLoggedInFlow(null)
}
}
}.launchIn(lifecycleScope)
}
private fun switchToLoggedInFlow(sessionId: SessionId, navId: Int) {
backstack.safeRoot(NavTarget.LoggedInFlow(sessionId, navId))
}
private fun switchToNotLoggedInFlow(params: LoginParams?) {
matrixSessionCache.removeAll()
backstack.safeRoot(NavTarget.NotLoggedInFlow(params))
}
private fun switchToSignedOutFlow(sessionId: SessionId) {
backstack.safeRoot(NavTarget.SignedOutFlow(sessionId))
}
private suspend fun restoreSessionIfNeeded(
sessionId: SessionId,
onFailure: () -> Unit,
onSuccess: (SessionId) -> Unit,
) {
matrixSessionCache.getOrRestore(sessionId).onSuccess {
Timber.v("Succeed to restore session $sessionId")
onSuccess(sessionId)
}.onFailure {
Timber.e(it, "Failed to restore session $sessionId")
onFailure()
}
}
private suspend fun tryToRestoreLatestSession(
onSuccess: (SessionId) -> Unit, onFailure: () -> Unit
) {
val latestSessionId = sessionStore.getLatestSessionId()
if (latestSessionId == null) {
onFailure()
return
}
restoreSessionIfNeeded(latestSessionId, onFailure, onSuccess)
}
private fun onOpenBugReport() {
backstack.push(NavTarget.BugReport)
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
RootView(
state = state,
modifier = modifier,
onOpenBugReport = this::onOpenBugReport,
) {
val backstackSlider = rememberBackstackSlider<NavTarget>(
transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) },
)
val backstackFader = rememberBackstackFader<NavTarget>(
transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) },
)
val transitionHandler = rememberDelegateTransitionHandler<NavTarget, BackStack.State> { navTarget ->
when (navTarget) {
is NavTarget.SplashScreen,
is NavTarget.LoggedInFlow -> backstackFader
else -> backstackSlider
}
}
BackstackView(transitionHandler = transitionHandler)
announcementService.Render(Modifier)
}
}
sealed interface NavTarget : Parcelable {
@Parcelize data object SplashScreen : NavTarget
@Parcelize data class AccountSelect(
val currentSessionId: SessionId,
val intent: Intent?,
val permalinkData: PermalinkData?,
) : NavTarget
@Parcelize data class NotLoggedInFlow(
val params: LoginParams?
) : NavTarget
@Parcelize data class LoggedInFlow(
val sessionId: SessionId, val navId: Int
) : NavTarget
@Parcelize data class SignedOutFlow(
val sessionId: SessionId
) : NavTarget
@Parcelize data object BugReport : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
is NavTarget.LoggedInFlow -> {
val matrixClient = matrixSessionCache.getOrNull(navTarget.sessionId)
?: return emptyNode(buildContext).also {
Timber.w("Couldn't find any session, go through SplashScreen")
}
val inputs = LoggedInAppScopeFlowNode.Inputs(matrixClient)
val callback = object : LoggedInAppScopeFlowNode.Callback {
override fun navigateToBugReport() {
backstack.push(NavTarget.BugReport)
}
override fun navigateToAddAccount() {
backstack.push(NavTarget.NotLoggedInFlow(null))
}
}
createNode<LoggedInAppScopeFlowNode>(buildContext, plugins = listOf(inputs, callback))
}
is NavTarget.NotLoggedInFlow -> {
val callback = object : NotLoggedInFlowNode.Callback {
override fun navigateToBugReport() {
backstack.push(NavTarget.BugReport)
}
override fun onDone() {
backstack.pop()
}
}
val params = NotLoggedInFlowNode.Params(
loginParams = navTarget.params,
)
createNode<NotLoggedInFlowNode>(buildContext, plugins = listOf(params, callback))
}
is NavTarget.SignedOutFlow -> {
signedOutEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = SignedOutEntryPoint.Params(
sessionId = navTarget.sessionId,
),
)
}
NavTarget.SplashScreen -> emptyNode(buildContext)
NavTarget.BugReport -> {
val callback = object : BugReportEntryPoint.Callback {
override fun onDone() {
backstack.pop()
}
}
bugReportEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
callback = callback,
)
}
is NavTarget.AccountSelect -> {
val callback: AccountSelectEntryPoint.Callback = object : AccountSelectEntryPoint.Callback {
override fun onAccountSelected(sessionId: SessionId) {
lifecycleScope.launch {
if (sessionId == navTarget.currentSessionId) {
// Ensure that the account selection Node is removed from the backstack
// Do not pop when the account is changed to avoid a UI flicker.
backstack.pop()
}
attachSession(sessionId).apply {
if (navTarget.intent != null) {
attachIncomingShare(navTarget.intent)
} else if (navTarget.permalinkData != null) {
attachPermalinkData(navTarget.permalinkData)
}
}
}
}
override fun onCancel() {
backstack.pop()
}
}
accountSelectEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
callback = callback,
)
}
}
}
suspend fun handleIntent(intent: Intent) {
val resolvedIntent = intentResolver.resolve(intent) ?: return
when (resolvedIntent) {
is ResolvedIntent.Navigation -> {
val openingRoomFromNotification = intent.getBooleanExtra(ROOM_OPENED_FROM_NOTIFICATION, false)
if (openingRoomFromNotification && resolvedIntent.deeplinkData is DeeplinkData.Room) {
analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.NotificationTapOpensTimeline)
}
navigateTo(resolvedIntent.deeplinkData)
}
is ResolvedIntent.Login -> onLoginLink(resolvedIntent.params)
is ResolvedIntent.Oidc -> onOidcAction(resolvedIntent.oidcAction)
is ResolvedIntent.Permalink -> navigateTo(resolvedIntent.permalinkData)
is ResolvedIntent.IncomingShare -> onIncomingShare(resolvedIntent.intent)
}
}
private suspend fun onLoginLink(params: LoginParams) {
if (accountProviderAccessControl.isAllowedToConnectToAccountProvider(params.accountProvider.ensureProtocol())) {
// Is there a session already?
val sessions = sessionStore.getAllSessions()
if (sessions.isNotEmpty()) {
if (featureFlagService.isFeatureEnabled(FeatureFlags.MultiAccount)) {
val loginHintMatrixId = params.loginHint?.removePrefix("mxid:")
val existingAccount = sessions.find { it.userId == loginHintMatrixId }
if (existingAccount != null) {
// We have an existing account matching the login hint, ensure this is the current session
sessionStore.setLatestSession(existingAccount.userId)
} else {
val latestSessionId = sessions.maxBy { it.lastUsageIndex }.userId
attachSession(SessionId(latestSessionId))
backstack.push(NavTarget.NotLoggedInFlow(params))
}
} else {
Timber.w("Login link ignored, multi account is disabled")
}
} else {
switchToNotLoggedInFlow(params)
}
} else {
Timber.w("Login link ignored, we are not allowed to connect to the homeserver")
}
}
private suspend fun onIncomingShare(intent: Intent) {
// Is there a session already?
val latestSessionId = sessionStore.getLatestSessionId()
if (latestSessionId == null) {
// No session, open login
switchToNotLoggedInFlow(null)
} else {
// wait for the current session to be restored
val loggedInFlowNode = attachSession(latestSessionId)
if (sessionStore.numberOfSessions() > 1) {
// Several accounts, let the user choose which one to use
backstack.push(
NavTarget.AccountSelect(
currentSessionId = latestSessionId,
intent = intent,
permalinkData = null,
)
)
} else {
// Only one account, directly attach the incoming share node.
loggedInFlowNode.attachIncomingShare(intent)
}
}
}
private suspend fun navigateTo(permalinkData: PermalinkData) {
Timber.d("Navigating to $permalinkData")
// Is there a session already?
val latestSessionId = sessionStore.getLatestSessionId()
if (latestSessionId == null) {
// No session, open login
switchToNotLoggedInFlow(null)
} else {
// wait for the current session to be restored
val loggedInFlowNode = attachSession(latestSessionId)
when (permalinkData) {
is PermalinkData.FallbackLink -> Unit
is PermalinkData.RoomEmailInviteLink -> Unit
else -> {
if (sessionStore.numberOfSessions() > 1) {
// Several accounts, let the user choose which one to use
backstack.push(
NavTarget.AccountSelect(
currentSessionId = latestSessionId,
intent = null,
permalinkData = permalinkData,
)
)
} else {
// Only one account, directly attach the room or the user node.
loggedInFlowNode.attachPermalinkData(permalinkData)
}
}
}
}
}
private suspend fun LoggedInFlowNode.attachPermalinkData(permalinkData: PermalinkData) {
when (permalinkData) {
is PermalinkData.FallbackLink -> Unit
is PermalinkData.RoomEmailInviteLink -> Unit
is PermalinkData.RoomLink -> {
// If there is a thread id, focus on it in the main timeline
val focusedEventId = if (permalinkData.threadId != null) {
permalinkData.threadId?.asEventId()
} else {
permalinkData.eventId
}
attachRoom(
roomIdOrAlias = permalinkData.roomIdOrAlias,
trigger = JoinedRoom.Trigger.MobilePermalink,
serverNames = permalinkData.viaParameters,
eventId = focusedEventId,
clearBackstack = true
).maybeAttachThread(permalinkData.threadId, permalinkData.eventId)
}
is PermalinkData.UserLink -> {
attachUser(permalinkData.userId)
}
}
}
private suspend fun RoomFlowNode.maybeAttachThread(threadId: ThreadId?, focusedEventId: EventId?) {
if (threadId != null) {
attachThread(threadId, focusedEventId)
}
}
private suspend fun navigateTo(deeplinkData: DeeplinkData) {
Timber.d("Navigating to $deeplinkData")
attachSession(deeplinkData.sessionId).let { loggedInFlowNode ->
when (deeplinkData) {
is DeeplinkData.Root -> Unit // The room list will always be shown, observing FtueState
is DeeplinkData.Room -> {
loggedInFlowNode.attachRoom(
roomIdOrAlias = deeplinkData.roomId.toRoomIdOrAlias(),
eventId = if (deeplinkData.threadId != null) deeplinkData.threadId?.asEventId() else deeplinkData.eventId,
clearBackstack = true,
).maybeAttachThread(deeplinkData.threadId, deeplinkData.eventId)
}
}
}
}
private fun onOidcAction(oidcAction: OidcAction) {
oidcActionFlow.post(oidcAction)
}
private suspend fun attachSession(sessionId: SessionId): LoggedInFlowNode {
// Ensure that the session is the latest one
sessionStore.setLatestSession(sessionId.value)
return waitForChildAttached<LoggedInAppScopeFlowNode, NavTarget> { navTarget ->
navTarget is NavTarget.LoggedInFlow && navTarget.sessionId == sessionId
}.attachSession()
}
}
private suspend fun SessionStore.getLatestSessionId() = getLatestSession()?.userId?.let(::SessionId)

View File

@@ -0,0 +1,126 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.di
import androidx.annotation.VisibleForTesting
import com.bumble.appyx.core.state.MutableSavedStateMap
import com.bumble.appyx.core.state.SavedStateMap
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap
private const val SAVE_INSTANCE_KEY = "io.element.android.x.di.MatrixClientsHolder.SaveInstanceKey"
/**
* In-memory cache for logged in Matrix sessions.
*
* This component contains both the [MatrixClient] and the [SyncOrchestrator] for each session.
*/
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class MatrixSessionCache(
private val authenticationService: MatrixAuthenticationService,
private val syncOrchestratorFactory: SyncOrchestrator.Factory,
) : MatrixClientProvider {
private val sessionIdsToMatrixSession = ConcurrentHashMap<SessionId, InMemoryMatrixSession>()
private val restoreMutex = Mutex()
init {
authenticationService.listenToNewMatrixClients { matrixClient ->
onNewMatrixClient(matrixClient)
}
}
fun removeAll() {
sessionIdsToMatrixSession.clear()
}
fun remove(sessionId: SessionId) {
sessionIdsToMatrixSession.remove(sessionId)
}
override fun getOrNull(sessionId: SessionId): MatrixClient? {
return sessionIdsToMatrixSession[sessionId]?.matrixClient
}
override suspend fun getOrRestore(sessionId: SessionId): Result<MatrixClient> {
return restoreMutex.withLock {
when (val cached = getOrNull(sessionId)) {
null -> restore(sessionId)
else -> Result.success(cached)
}
}
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun getSyncOrchestrator(sessionId: SessionId): SyncOrchestrator? {
return sessionIdsToMatrixSession[sessionId]?.syncOrchestrator
}
@Suppress("UNCHECKED_CAST")
fun restoreWithSavedState(state: SavedStateMap?) {
Timber.d("Restore state")
if (state == null || sessionIdsToMatrixSession.isNotEmpty()) {
Timber.w("Restore with non-empty map")
return
}
val sessionIds = state[SAVE_INSTANCE_KEY] as? Array<SessionId>
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)
}
}
}
fun saveIntoSavedState(state: MutableSavedStateMap) {
val sessionKeys = sessionIdsToMatrixSession.keys.toTypedArray()
Timber.d("Save matrix session keys = ${sessionKeys.map { it.value }}")
state[SAVE_INSTANCE_KEY] = sessionKeys
}
private suspend fun restore(sessionId: SessionId): Result<MatrixClient> {
Timber.d("Restore matrix session: $sessionId")
return authenticationService.restoreSession(sessionId)
.onSuccess { matrixClient ->
onNewMatrixClient(matrixClient)
}
.onFailure {
Timber.e(it, "Fail to restore session")
}
}
private fun onNewMatrixClient(matrixClient: MatrixClient) {
val syncOrchestrator = syncOrchestratorFactory.create(
syncService = matrixClient.syncService,
sessionCoroutineScope = matrixClient.sessionCoroutineScope,
)
sessionIdsToMatrixSession[matrixClient.sessionId] = InMemoryMatrixSession(
matrixClient = matrixClient,
syncOrchestrator = syncOrchestrator,
)
syncOrchestrator.start()
}
}
private data class InMemoryMatrixSession(
val matrixClient: MatrixClient,
val syncOrchestrator: SyncOrchestrator,
)

View File

@@ -0,0 +1,15 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.di
import io.element.android.libraries.matrix.api.room.JoinedRoom
fun interface RoomGraphFactory {
fun create(room: JoinedRoom): Any
}

View File

@@ -0,0 +1,15 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.di
import io.element.android.libraries.matrix.api.MatrixClient
interface SessionGraphFactory {
fun create(client: MatrixClient): Any
}

View File

@@ -0,0 +1,144 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector 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.di
import androidx.annotation.VisibleForTesting
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.childScope
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.recordTransaction
import io.element.android.services.appnavstate.api.AppForegroundStateService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@AssistedInject
class SyncOrchestrator(
@Assisted private val syncService: SyncService,
@Assisted sessionCoroutineScope: CoroutineScope,
private val appForegroundStateService: AppForegroundStateService,
private val networkMonitor: NetworkMonitor,
dispatchers: CoroutineDispatchers,
private val analyticsService: AnalyticsService,
) {
@AssistedFactory
interface Factory {
fun create(
syncService: SyncService,
sessionCoroutineScope: CoroutineScope,
): SyncOrchestrator
}
private val tag = "SyncOrchestrator"
private val coroutineScope = sessionCoroutineScope.childScope(dispatchers.io, tag)
private val started = AtomicBoolean(false)
/**
* Starting observing the app state and network state to start/stop the sync service.
*
* Before observing the state, a first attempt at starting the sync service will happen if it's not already running.
*/
fun start() {
if (!started.compareAndSet(false, true)) {
Timber.tag(tag).d("already started, exiting early")
return
}
coroutineScope.launch {
// Perform an initial sync if the sync service is not running, to check whether the homeserver is accessible
// Otherwise, if the device is offline the sync service will never start and the SyncState will be Idle, not Offline
Timber.tag(tag).d("performing initial sync attempt")
analyticsService.recordTransaction("First sync", "syncService.startSync()") { transaction ->
syncService.startSync()
// Wait until the sync service is not idle, either it will be running or in error/offline state
val firstState = syncService.syncState.first { it != SyncState.Idle }
transaction.setData("first_sync_state", firstState.name)
}
observeStates()
}
}
@OptIn(FlowPreview::class)
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun observeStates() = coroutineScope.launch {
Timber.tag(tag).d("start observing the app and network state")
val isAppActiveFlow = combine(
appForegroundStateService.isInForeground,
appForegroundStateService.isInCall,
appForegroundStateService.isSyncingNotificationEvent,
appForegroundStateService.hasRingingCall,
) { isInForeground, isInCall, isSyncingNotificationEvent, hasRingingCall ->
isInForeground || isInCall || isSyncingNotificationEvent || hasRingingCall
}
combine(
// small debounce to avoid spamming startSync when the state is changing quickly in case of error.
syncService.syncState.debounce(100.milliseconds),
networkMonitor.connectivity,
isAppActiveFlow,
) { syncState, networkState, isAppActive ->
val isNetworkAvailable = networkState == NetworkStatus.Connected
Timber.tag(tag).d("isAppActive=$isAppActive, isNetworkAvailable=$isNetworkAvailable")
if (syncState == SyncState.Running && !isAppActive) {
SyncStateAction.StopSync
} else if (syncState == SyncState.Idle && isAppActive && isNetworkAvailable) {
SyncStateAction.StartSync
} else {
SyncStateAction.NoOp
}
}
.distinctUntilChanged()
.debounce { action ->
// Don't stop the sync immediately, wait a bit to avoid starting/stopping the sync too often
if (action == SyncStateAction.StopSync) 3.seconds else 0.seconds
}
.onCompletion {
Timber.tag(tag).d("has been stopped")
}
.collect { action ->
when (action) {
SyncStateAction.StartSync -> {
syncService.startSync()
}
SyncStateAction.StopSync -> {
syncService.stopSync()
}
SyncStateAction.NoOp -> Unit
}
}
}
}
private enum class SyncStateAction {
StartSync,
StopSync,
NoOp,
}

View File

@@ -0,0 +1,17 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector 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.di
import io.element.android.features.messages.api.pinned.PinnedEventsTimelineProvider
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
interface TimelineBindings {
val timelineProvider: TimelineProvider
val pinnedEventsTimelineProvider: PinnedEventsTimelineProvider
}

View File

@@ -0,0 +1,77 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.intent
import android.content.Intent
import dev.zacsweers.metro.Inject
import io.element.android.features.login.api.LoginIntentResolver
import io.element.android.features.login.api.LoginParams
import io.element.android.libraries.deeplink.api.DeeplinkData
import io.element.android.libraries.deeplink.api.DeeplinkParser
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.oidc.api.OidcAction
import io.element.android.libraries.oidc.api.OidcIntentResolver
import timber.log.Timber
sealed interface ResolvedIntent {
data class Navigation(val deeplinkData: DeeplinkData) : ResolvedIntent
data class Oidc(val oidcAction: OidcAction) : ResolvedIntent
data class Permalink(val permalinkData: PermalinkData) : ResolvedIntent
data class Login(val params: LoginParams) : ResolvedIntent
data class IncomingShare(val intent: Intent) : ResolvedIntent
}
@Inject
class IntentResolver(
private val deeplinkParser: DeeplinkParser,
private val loginIntentResolver: LoginIntentResolver,
private val oidcIntentResolver: OidcIntentResolver,
private val permalinkParser: PermalinkParser,
) {
fun resolve(intent: Intent): ResolvedIntent? {
if (intent.canBeIgnored()) return null
// Coming from a notification?
val deepLinkData = deeplinkParser.getFromIntent(intent)
if (deepLinkData != null) return ResolvedIntent.Navigation(deepLinkData)
// Coming during login using Oidc?
val oidcAction = oidcIntentResolver.resolve(intent)
if (oidcAction != null) return ResolvedIntent.Oidc(oidcAction)
val actionViewData = intent
.takeIf { it.action == Intent.ACTION_VIEW }
?.dataString
// Mobile configuration link clicked? (mobile.element.io)
val mobileLoginData = actionViewData
?.let { loginIntentResolver.parse(it) }
if (mobileLoginData != null) return ResolvedIntent.Login(mobileLoginData)
// External link clicked? (matrix.to, element.io, etc.)
val permalinkData = actionViewData
?.let { permalinkParser.parse(it) }
?.takeIf { it !is PermalinkData.FallbackLink }
if (permalinkData != null) return ResolvedIntent.Permalink(permalinkData)
if (intent.action == Intent.ACTION_SEND || intent.action == Intent.ACTION_SEND_MULTIPLE) {
return ResolvedIntent.IncomingShare(intent)
}
// Unknown intent
Timber.w("Unknown intent")
return null
}
}
private fun Intent.canBeIgnored(): Boolean {
return action == Intent.ACTION_MAIN &&
categories?.contains(Intent.CATEGORY_LAUNCHER) == true
}

View File

@@ -0,0 +1,51 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector 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.loggedin
import im.vector.app.features.analytics.plan.CryptoSessionStateChange
import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
fun SessionVerifiedStatus.toAnalyticsUserPropertyValue(): UserProperties.VerificationState? {
return when (this) {
// we don't need to report transient states
SessionVerifiedStatus.Unknown -> null
SessionVerifiedStatus.NotVerified -> UserProperties.VerificationState.NotVerified
SessionVerifiedStatus.Verified -> UserProperties.VerificationState.Verified
}
}
fun RecoveryState.toAnalyticsUserPropertyValue(): UserProperties.RecoveryState? {
return when (this) {
RecoveryState.ENABLED -> UserProperties.RecoveryState.Enabled
RecoveryState.DISABLED -> UserProperties.RecoveryState.Disabled
RecoveryState.INCOMPLETE -> UserProperties.RecoveryState.Incomplete
// we don't need to report transient states
else -> null
}
}
fun SessionVerifiedStatus.toAnalyticsStateChangeValue(): CryptoSessionStateChange.VerificationState? {
return when (this) {
// we don't need to report transient states
SessionVerifiedStatus.Unknown -> null
SessionVerifiedStatus.NotVerified -> CryptoSessionStateChange.VerificationState.NotVerified
SessionVerifiedStatus.Verified -> CryptoSessionStateChange.VerificationState.Verified
}
}
fun RecoveryState.toAnalyticsStateChangeValue(): CryptoSessionStateChange.RecoveryState? {
return when (this) {
RecoveryState.ENABLED -> CryptoSessionStateChange.RecoveryState.Enabled
RecoveryState.DISABLED -> CryptoSessionStateChange.RecoveryState.Disabled
RecoveryState.INCOMPLETE -> CryptoSessionStateChange.RecoveryState.Incomplete
// we don't need to report transient states
else -> null
}
}

View File

@@ -0,0 +1,15 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector 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.loggedin
sealed interface LoggedInEvents {
data class CloseErrorDialog(val doNotShowAgain: Boolean) : LoggedInEvents
data object CheckSlidingSyncProxyAvailability : LoggedInEvents
data object LogoutAndMigrateToNativeSlidingSync : LoggedInEvents
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.loggedin
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
@AssistedInject
class LoggedInNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val loggedInPresenter: LoggedInPresenter,
) : Node(
buildContext = buildContext,
plugins = plugins
) {
interface Callback : Plugin {
fun navigateToNotificationTroubleshoot()
}
private val callback: Callback = callback()
@Composable
override fun View(modifier: Modifier) {
val loggedInState = loggedInPresenter.present()
LoggedInView(
state = loggedInState,
navigateToNotificationTroubleshoot = callback::navigateToNotificationTroubleshoot,
modifier = modifier
)
}
}

View File

@@ -0,0 +1,172 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.loggedin
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import im.vector.app.features.analytics.plan.CryptoSessionStateChange
import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.push.api.PushService
import io.element.android.libraries.push.api.PusherRegistrationFailure
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import timber.log.Timber
private val pusherTag = LoggerTag("Pusher", LoggerTag.PushLoggerTag)
@Inject
class LoggedInPresenter(
private val matrixClient: MatrixClient,
private val syncService: SyncService,
private val pushService: PushService,
private val sessionVerificationService: SessionVerificationService,
private val analyticsService: AnalyticsService,
private val encryptionService: EncryptionService,
private val buildMeta: BuildMeta,
) : Presenter<LoggedInState> {
@Composable
override fun present(): LoggedInState {
val coroutineScope = rememberCoroutineScope()
val ignoreRegistrationError by remember {
pushService.ignoreRegistrationError(matrixClient.sessionId)
}.collectAsState(initial = false)
val pusherRegistrationState = remember<MutableState<AsyncData<Unit>>> { mutableStateOf(AsyncData.Uninitialized) }
LaunchedEffect(Unit) { preloadAccountManagementUrl() }
LaunchedEffect(Unit) {
sessionVerificationService.sessionVerifiedStatus
.onEach { sessionVerifiedStatus ->
when (sessionVerifiedStatus) {
SessionVerifiedStatus.Unknown -> Unit
SessionVerifiedStatus.Verified -> {
Timber.tag(pusherTag.value).d("Ensure pusher is registered")
pushService.ensurePusherIsRegistered(matrixClient).fold(
onSuccess = {
Timber.tag(pusherTag.value).d("Pusher registered")
pusherRegistrationState.value = AsyncData.Success(Unit)
},
onFailure = {
Timber.tag(pusherTag.value).e(it, "Failed to register pusher")
pusherRegistrationState.value = AsyncData.Failure(it)
},
)
}
SessionVerifiedStatus.NotVerified -> {
pusherRegistrationState.value = AsyncData.Failure(PusherRegistrationFailure.AccountNotVerified())
}
}
}
.launchIn(this)
}
val syncIndicator by matrixClient.roomListService.syncIndicator.collectAsState()
val isOnline by syncService.isOnline.collectAsState()
val showSyncSpinner by remember {
derivedStateOf {
isOnline && syncIndicator == RoomListService.SyncIndicator.Show
}
}
var forceNativeSlidingSyncMigration by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
combine(
sessionVerificationService.sessionVerifiedStatus,
encryptionService.recoveryStateStateFlow
) { verificationState, recoveryState ->
reportCryptoStatusToAnalytics(verificationState, recoveryState)
}.launchIn(this)
}
fun handleEvent(event: LoggedInEvents) {
when (event) {
is LoggedInEvents.CloseErrorDialog -> {
pusherRegistrationState.value = AsyncData.Uninitialized
if (event.doNotShowAgain) {
coroutineScope.launch {
pushService.setIgnoreRegistrationError(matrixClient.sessionId, true)
}
}
}
LoggedInEvents.CheckSlidingSyncProxyAvailability -> coroutineScope.launch {
forceNativeSlidingSyncMigration = matrixClient.needsForcedNativeSlidingSyncMigration().getOrDefault(false)
}
LoggedInEvents.LogoutAndMigrateToNativeSlidingSync -> coroutineScope.launch {
// Force the logout since Native Sliding Sync is already enforced by the SDK
matrixClient.logout(userInitiated = true, ignoreSdkError = true)
}
}
}
return LoggedInState(
showSyncSpinner = showSyncSpinner,
pusherRegistrationState = pusherRegistrationState.value,
ignoreRegistrationError = ignoreRegistrationError,
forceNativeSlidingSyncMigration = forceNativeSlidingSyncMigration,
appName = buildMeta.applicationName,
eventSink = ::handleEvent,
)
}
// Force the user to log out if they were using the proxy sliding sync as it's no longer supported by the SDK
private suspend fun MatrixClient.needsForcedNativeSlidingSyncMigration(): Result<Boolean> = runCatchingExceptions {
val currentSlidingSyncVersion = currentSlidingSyncVersion().getOrThrow()
currentSlidingSyncVersion == SlidingSyncVersion.Proxy
}
private fun reportCryptoStatusToAnalytics(verificationState: SessionVerifiedStatus, recoveryState: RecoveryState) {
// Update first the user property, to store the current status for that posthog user
val userVerificationState = verificationState.toAnalyticsUserPropertyValue()
val userRecoveryState = recoveryState.toAnalyticsUserPropertyValue()
if (userRecoveryState != null && userVerificationState != null) {
// we want to report when both value are known (if one is unknown we wait until we have them both)
analyticsService.updateUserProperties(
UserProperties(
verificationState = userVerificationState,
recoveryState = userRecoveryState
)
)
}
// Also report when there is a change in the state, to be able to track the changes
val changeVerificationState = verificationState.toAnalyticsStateChangeValue()
val changeRecoveryState = recoveryState.toAnalyticsStateChangeValue()
if (changeVerificationState != null && changeRecoveryState != null) {
analyticsService.capture(CryptoSessionStateChange(changeRecoveryState, changeVerificationState))
}
}
private fun CoroutineScope.preloadAccountManagementUrl() = launch {
matrixClient.getAccountManagementUrl(AccountManagementAction.Profile)
matrixClient.getAccountManagementUrl(AccountManagementAction.SessionsList)
}
}

View File

@@ -0,0 +1,20 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.loggedin
import io.element.android.libraries.architecture.AsyncData
data class LoggedInState(
val showSyncSpinner: Boolean,
val pusherRegistrationState: AsyncData<Unit>,
val ignoreRegistrationError: Boolean,
val forceNativeSlidingSyncMigration: Boolean,
val appName: String,
val eventSink: (LoggedInEvents) -> Unit,
)

View File

@@ -0,0 +1,37 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.loggedin
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.push.api.PusherRegistrationFailure
open class LoggedInStateProvider : PreviewParameterProvider<LoggedInState> {
override val values: Sequence<LoggedInState>
get() = sequenceOf(
aLoggedInState(),
aLoggedInState(showSyncSpinner = true),
aLoggedInState(pusherRegistrationState = AsyncData.Failure(PusherRegistrationFailure.NoDistributorsAvailable())),
aLoggedInState(forceNativeSlidingSyncMigration = true),
)
}
fun aLoggedInState(
showSyncSpinner: Boolean = false,
pusherRegistrationState: AsyncData<Unit> = AsyncData.Uninitialized,
forceNativeSlidingSyncMigration: Boolean = false,
appName: String = "Element X",
) = LoggedInState(
showSyncSpinner = showSyncSpinner,
pusherRegistrationState = pusherRegistrationState,
ignoreRegistrationError = false,
forceNativeSlidingSyncMigration = forceNativeSlidingSyncMigration,
appName = appName,
eventSink = {},
)

View File

@@ -0,0 +1,125 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.loggedin
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.lifecycle.Lifecycle
import io.element.android.appnav.R
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialogWithDoNotShowAgain
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.matrix.api.exception.isNetworkError
import io.element.android.libraries.push.api.PusherRegistrationFailure
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun LoggedInView(
state: LoggedInState,
navigateToNotificationTroubleshoot: () -> Unit,
modifier: Modifier = Modifier
) {
OnLifecycleEvent { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
state.eventSink(LoggedInEvents.CheckSlidingSyncProxyAvailability)
}
}
Box(
modifier = modifier
.fillMaxSize()
.systemBarsPadding()
) {
SyncStateView(
modifier = Modifier.align(Alignment.TopCenter),
isVisible = state.showSyncSpinner,
)
}
when (state.pusherRegistrationState) {
is AsyncData.Uninitialized,
is AsyncData.Loading,
is AsyncData.Success -> Unit
is AsyncData.Failure -> {
state.pusherRegistrationState.errorOrNull()
?.takeIf { !state.ignoreRegistrationError }
?.getReason()
?.let { reason ->
ErrorDialogWithDoNotShowAgain(
content = stringResource(id = CommonStrings.common_error_registering_pusher_android, reason),
cancelText = stringResource(id = CommonStrings.common_settings),
onDismiss = {
state.eventSink(LoggedInEvents.CloseErrorDialog(it))
},
onCancel = {
state.eventSink(LoggedInEvents.CloseErrorDialog(false))
navigateToNotificationTroubleshoot()
}
)
}
}
}
// Set the force migration dialog here so it's always displayed over every screen
if (state.forceNativeSlidingSyncMigration) {
ForceNativeSlidingSyncMigrationDialog(
appName = state.appName,
onSubmit = {
state.eventSink(LoggedInEvents.LogoutAndMigrateToNativeSlidingSync)
}
)
}
}
private fun Throwable.getReason(): String? {
return when (this) {
is PusherRegistrationFailure.RegistrationFailure -> {
if (isRegisteringAgain && clientException.isNetworkError()) {
// When registering again, ignore network error
null
} else {
clientException.message ?: "Unknown error"
}
}
is PusherRegistrationFailure.AccountNotVerified -> null
is PusherRegistrationFailure.NoDistributorsAvailable -> "No distributors available"
is PusherRegistrationFailure.NoProvidersAvailable -> "No providers available"
else -> "Other error: $message"
}
}
@Composable
private fun ForceNativeSlidingSyncMigrationDialog(
appName: String,
onSubmit: () -> Unit,
) {
ErrorDialog(
title = null,
content = stringResource(R.string.banner_migrate_to_native_sliding_sync_app_force_logout_title, appName),
submitText = stringResource(R.string.banner_migrate_to_native_sliding_sync_action),
onSubmit = onSubmit,
canDismiss = false,
)
}
@PreviewsDayNight
@Composable
internal fun LoggedInViewPreview(@PreviewParameter(LoggedInStateProvider::class) state: LoggedInState) = ElementPreview {
LoggedInView(
state = state,
navigateToNotificationTroubleshoot = {},
)
}

View File

@@ -0,0 +1,60 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector 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.loggedin
import dev.zacsweers.metro.Inject
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.media.MediaPreviewService
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import timber.log.Timber
/**
* This migration is temporary, will be safe to remove after some time.
* The goal is to set the server config if it's not set, and remove the local data.
*/
@Inject
class MediaPreviewConfigMigration(
private val mediaPreviewService: MediaPreviewService,
private val appPreferencesStore: AppPreferencesStore,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
) {
@Suppress("DEPRECATION")
operator fun invoke() = sessionCoroutineScope.launch {
val hideInviteAvatars = appPreferencesStore.getHideInviteAvatarsFlow().first()
val mediaPreviewValue = appPreferencesStore.getTimelineMediaPreviewValueFlow().first()
if (hideInviteAvatars == null && mediaPreviewValue == null) {
// No local data, abort.
return@launch
}
mediaPreviewService
.fetchMediaPreviewConfig()
.onSuccess { config ->
if (config != null) {
appPreferencesStore.setHideInviteAvatars(null)
appPreferencesStore.setTimelineMediaPreviewValue(null)
} else {
if (hideInviteAvatars != null) {
mediaPreviewService.setHideInviteAvatars(hideInviteAvatars)
appPreferencesStore.setHideInviteAvatars(null)
}
if (mediaPreviewValue != null) {
mediaPreviewService.setMediaPreviewValue(mediaPreviewValue)
appPreferencesStore.setTimelineMediaPreviewValue(null)
}
}
}
.onFailure {
Timber.e(it, "Couldn't perform migration, failed to fetch media preview config.")
}
}
}

View File

@@ -0,0 +1,56 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector 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.loggedin
import androidx.annotation.VisibleForTesting
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.sync.SyncState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import timber.log.Timber
@VisibleForTesting
const val SEND_QUEUES_RETRY_DELAY_MILLIS = 500L
@SingleIn(SessionScope::class)
@Inject
class SendQueues(
private val matrixClient: MatrixClient,
private val syncService: SyncService,
) {
/**
* Launches the send queues retry mechanism in the given [coroutineScope].
* Makes sure to re-enable all send queues when the network status is [NetworkStatus.Connected].
*/
@OptIn(FlowPreview::class)
fun launchIn(coroutineScope: CoroutineScope) {
combine(
syncService.syncState,
matrixClient.sendQueueDisabledFlow(),
) { syncState, _ -> syncState }
.debounce(SEND_QUEUES_RETRY_DELAY_MILLIS)
.onEach { syncState ->
Timber.tag("SendQueues").d("Sync state changed: $syncState")
if (syncState == SyncState.Running) {
Timber.tag("SendQueues").d("Enabling send queues again")
matrixClient.setAllSendQueuesEnabled(enabled = true)
}
}
.launchIn(coroutineScope)
}
}

View File

@@ -0,0 +1,52 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.loggedin
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.spring
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.async.AsyncIndicator
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun SyncStateView(
isVisible: Boolean,
modifier: Modifier = Modifier
) {
AnimatedVisibility(
visible = isVisible,
modifier = modifier,
enter = fadeIn(spring(stiffness = 500F)),
exit = fadeOut(spring(stiffness = 500F)),
) {
AsyncIndicator.Loading(
text = stringResource(id = CommonStrings.common_syncing),
)
}
}
@PreviewsDayNight
@Composable
internal fun SyncStateViewPreview() = ElementPreview {
// Add a box to see the shadow
Box(modifier = Modifier.padding(24.dp)) {
SyncStateView(
isVisible = true
)
}
}

View File

@@ -0,0 +1,259 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector 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.room
import android.os.Parcelable
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.transition.JumpToEndTransitionHandler
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.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.active
import com.bumble.appyx.navmodel.backstack.operation.newRoot
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.appnav.room.joined.JoinedRoomFlowNode
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
import io.element.android.appnav.room.joined.LoadingRoomNodeView
import io.element.android.features.joinroom.api.JoinRoomEntryPoint
import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint
import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint.Params
import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.core.coroutine.withPreviousValue
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import io.element.android.libraries.matrix.ui.room.LoadingRoomState
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.LoadJoinedRoomFlow
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.NotificationTapOpensTimeline
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.OpenRoom
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import timber.log.Timber
import java.util.Optional
import kotlin.jvm.optionals.getOrNull
import im.vector.app.features.analytics.plan.JoinedRoom as JoinedRoomAnalyticsEvent
import io.element.android.libraries.matrix.api.room.JoinedRoom as JoinedRoomInstance
@ContributesNode(SessionScope::class)
@AssistedInject
class RoomFlowNode(
@Assisted val buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val client: MatrixClient,
private val joinRoomEntryPoint: JoinRoomEntryPoint,
private val roomAliasResolverEntryPoint: RoomAliasResolverEntryPoint,
private val membershipObserver: RoomMembershipObserver,
private val analyticsService: AnalyticsService,
) : BaseFlowNode<RoomFlowNode.NavTarget>(
backstack = BackStack(
initialElement = run {
val joinedRoom = (plugins.filterIsInstance<Inputs>().first().initialElement as? RoomNavigationTarget.Root)?.joinedRoom
if (joinedRoom != null) {
NavTarget.JoinedRoom(joinedRoom)
} else {
NavTarget.Loading
}
},
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins
) {
data class Inputs(
val roomIdOrAlias: RoomIdOrAlias,
val roomDescription: Optional<RoomDescription>,
val serverNames: List<String>,
val trigger: Optional<JoinedRoomAnalyticsEvent.Trigger>,
val initialElement: RoomNavigationTarget,
) : NodeInputs
private val inputs: Inputs = inputs()
sealed interface NavTarget : Parcelable {
@Parcelize
data object Loading : NavTarget
@Parcelize
data class Resolving(val roomAlias: RoomAlias) : NavTarget
@Parcelize
data class JoinRoom(
val roomId: RoomId,
val serverNames: List<String>,
val trigger: JoinedRoomAnalyticsEvent.Trigger,
) : NavTarget
@Parcelize
data class JoinedRoom(
val roomId: RoomId,
@IgnoredOnParcel val joinedRoom: JoinedRoomInstance? = null,
) : NavTarget {
constructor(joinedRoom: JoinedRoomInstance) : this(joinedRoom.roomId, joinedRoom)
}
}
override fun onBuilt() {
super.onBuilt()
val parentTransaction = analyticsService.getLongRunningTransaction(NotificationTapOpensTimeline)
val openRoomTransaction = analyticsService.startLongRunningTransaction(OpenRoom, parentTransaction)
analyticsService.startLongRunningTransaction(LoadJoinedRoomFlow, openRoomTransaction)
resolveRoomId()
}
private fun resolveRoomId() {
lifecycleScope.launch {
when (val i = inputs.roomIdOrAlias) {
is RoomIdOrAlias.Alias -> {
backstack.newRoot(NavTarget.Resolving(i.roomAlias))
}
is RoomIdOrAlias.Id -> {
subscribeToRoomInfoFlow(i.roomId, inputs.serverNames)
}
}
}
}
private fun subscribeToRoomInfoFlow(roomId: RoomId, serverNames: List<String>) {
val joinedRoom = (inputs.initialElement as? RoomNavigationTarget.Root)?.joinedRoom
val roomInfoFlow = joinedRoom?.roomInfoFlow?.map { Optional.of(it) }
?: client.getRoomInfoFlow(roomId)
// This observes the local membership changes for the room
val membershipUpdateFlow = membershipObserver.updates
.filter { it.roomId == roomId }
.distinctUntilChanged()
// We add a replay so we can check the last local membership update
.shareIn(lifecycleScope, started = SharingStarted.Eagerly, replay = 1)
val currentMembershipFlow = roomInfoFlow
.map { it.getOrNull()?.currentUserMembership }
.distinctUntilChanged()
.withPreviousValue()
currentMembershipFlow.onEach { (previousMembership, membership) ->
Timber.d("Room membership: $membership")
if (membership == CurrentUserMembership.JOINED) {
val currentNavTarget = backstack.active?.key?.navTarget
if (currentNavTarget is NavTarget.JoinedRoom && currentNavTarget.roomId == roomId) {
Timber.d("Already in JoinedRoom $roomId, do nothing")
return@onEach
}
backstack.newRoot(NavTarget.JoinedRoom(roomId))
} else {
val leavingFromCurrentDevice =
membership == CurrentUserMembership.LEFT &&
previousMembership == CurrentUserMembership.JOINED &&
membershipUpdateFlow.replayCache.lastOrNull()?.isUserInRoom == false
if (leavingFromCurrentDevice) {
navigateUp()
} else {
backstack.newRoot(
NavTarget.JoinRoom(
roomId = roomId,
serverNames = serverNames,
trigger = inputs.trigger.getOrNull() ?: JoinedRoomAnalyticsEvent.Trigger.Invite,
)
)
}
}
}.launchIn(lifecycleScope)
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
is NavTarget.Loading -> loadingNode(buildContext)
is NavTarget.Resolving -> {
val callback = object : RoomAliasResolverEntryPoint.Callback {
override fun onAliasResolved(data: ResolvedRoomAlias) {
subscribeToRoomInfoFlow(
roomId = data.roomId,
serverNames = data.servers,
)
}
}
val params = Params(navTarget.roomAlias)
roomAliasResolverEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = params,
callback = callback,
)
}
is NavTarget.JoinRoom -> {
val inputs = JoinRoomEntryPoint.Inputs(
roomId = navTarget.roomId,
roomIdOrAlias = inputs.roomIdOrAlias,
roomDescription = inputs.roomDescription,
serverNames = navTarget.serverNames,
trigger = navTarget.trigger,
)
joinRoomEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
inputs = inputs,
)
}
is NavTarget.JoinedRoom -> {
val roomFlowNodeCallback = plugins<JoinedRoomLoadedFlowNode.Callback>()
val inputs = JoinedRoomFlowNode.Inputs(
roomId = navTarget.roomId,
initialElement = inputs.initialElement,
joinedRoom = navTarget.joinedRoom,
)
createNode<JoinedRoomFlowNode>(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback)
}
}
}
suspend fun attachThread(threadId: ThreadId, focusedEventId: EventId?) {
waitForChildAttached<JoinedRoomFlowNode>()
.attachThread(threadId, focusedEventId)
}
private fun loadingNode(buildContext: BuildContext) = node(buildContext) { modifier ->
LoadingRoomNodeView(
state = LoadingRoomState.Loading,
onBackClick = { navigateUp() },
modifier = modifier,
)
}
@Composable
override fun View(modifier: Modifier) {
BackstackView(transitionHandler = JumpToEndTransitionHandler())
}
}

View File

@@ -0,0 +1,29 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector 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.room
import android.os.Parcelable
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
sealed interface RoomNavigationTarget : Parcelable {
@Parcelize
data class Root(
val eventId: EventId? = null,
@IgnoredOnParcel val joinedRoom: JoinedRoom? = null,
) : RoomNavigationTarget
@Parcelize
data object Details : RoomNavigationTarget
@Parcelize
data object NotificationSettings : RoomNavigationTarget
}

View File

@@ -0,0 +1,140 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.appnav.room.joined
import android.os.Parcelable
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.navigation.transition.JumpToEndTransitionHandler
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.plugins
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.newRoot
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.appnav.room.RoomNavigationTarget
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.ui.room.LoadingRoomState
import io.element.android.libraries.matrix.ui.room.LoadingRoomStateFlowFactory
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
@AssistedInject
class JoinedRoomFlowNode(
@Assisted val buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
loadingRoomStateFlowFactory: LoadingRoomStateFlowFactory,
) :
BaseFlowNode<JoinedRoomFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Loading,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins
) {
data class Inputs(
val roomId: RoomId,
val joinedRoom: JoinedRoom?,
val initialElement: RoomNavigationTarget,
) : NodeInputs
private val inputs: Inputs = inputs()
private val loadingRoomStateStateFlow = loadingRoomStateFlowFactory.create(lifecycleScope, inputs.roomId, inputs.joinedRoom)
sealed interface NavTarget : Parcelable {
@Parcelize
data object Loading : NavTarget
@Parcelize
data object Loaded : NavTarget
}
override fun onBuilt() {
super.onBuilt()
loadingRoomStateStateFlow
.map {
it is LoadingRoomState.Loaded
}
.distinctUntilChanged()
.onEach { isLoaded ->
if (isLoaded) {
backstack.newRoot(NavTarget.Loaded)
} else {
backstack.newRoot(NavTarget.Loading)
}
}
.launchIn(lifecycleScope)
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Loaded -> {
val roomFlowNodeCallback = plugins<JoinedRoomLoadedFlowNode.Callback>()
val awaitRoomState = loadingRoomStateStateFlow.value
if (awaitRoomState is LoadingRoomState.Loaded) {
val inputs = JoinedRoomLoadedFlowNode.Inputs(
room = awaitRoomState.room,
initialElement = inputs.initialElement
)
createNode<JoinedRoomLoadedFlowNode>(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback)
} else {
loadingNode(buildContext, this::navigateUp)
}
}
NavTarget.Loading -> {
loadingNode(buildContext, this::navigateUp)
}
}
}
private fun loadingNode(buildContext: BuildContext, onBackClick: () -> Unit) = node(buildContext) { modifier ->
val loadingRoomState by loadingRoomStateStateFlow.collectAsState()
LoadingRoomNodeView(
state = loadingRoomState,
onBackClick = onBackClick,
modifier = modifier
)
}
suspend fun attachThread(threadId: ThreadId, focusedEventId: EventId?) {
waitForChildAttached<JoinedRoomLoadedFlowNode>()
.attachThread(threadId, focusedEventId)
}
@Composable
override fun View(modifier: Modifier) {
BackstackView(
transitionHandler = JumpToEndTransitionHandler(),
)
}
}

View File

@@ -0,0 +1,309 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector 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.room.joined
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.appnav.di.RoomGraphFactory
import io.element.android.appnav.di.TimelineBindings
import io.element.android.appnav.room.RoomNavigationTarget
import io.element.android.features.forward.api.ForwardEntryPoint
import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.architecture.waitForChildAttached
import io.element.android.libraries.di.DependencyInjectionGraphOwner
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.LoadJoinedRoomFlow
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.LoadMessagesUi
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.OpenRoom
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.finishLongRunningTransaction
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import timber.log.Timber
@ContributesNode(SessionScope::class)
@AssistedInject
class JoinedRoomLoadedFlowNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val messagesEntryPoint: MessagesEntryPoint,
private val roomDetailsEntryPoint: RoomDetailsEntryPoint,
private val spaceEntryPoint: SpaceEntryPoint,
private val forwardEntryPoint: ForwardEntryPoint,
private val appNavigationStateService: AppNavigationStateService,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
private val matrixClient: MatrixClient,
private val activeRoomsHolder: ActiveRoomsHolder,
private val analyticsService: AnalyticsService,
roomGraphFactory: RoomGraphFactory,
) : BaseFlowNode<JoinedRoomLoadedFlowNode.NavTarget>(
backstack = BackStack(
initialElement = initialElement(plugins),
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins,
), DependencyInjectionGraphOwner {
interface Callback : Plugin {
fun navigateToRoom(roomId: RoomId, serverNames: List<String>)
fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean)
fun navigateToGlobalNotificationSettings()
}
data class Inputs(
val room: JoinedRoom,
val initialElement: RoomNavigationTarget,
) : NodeInputs
private val inputs: Inputs = inputs()
private val callback: Callback = callback()
override val graph = roomGraphFactory.create(inputs.room)
init {
lifecycle.subscribe(
onCreate = {
val parent = analyticsService.getLongRunningTransaction(OpenRoom)
analyticsService.startLongRunningTransaction(LoadMessagesUi, parent)
Timber.v("OnCreate => ${inputs.room.roomId}")
appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId)
activeRoomsHolder.addRoom(inputs.room)
fetchRoomMembers()
trackVisitedRoom()
},
onResume = {
analyticsService.finishLongRunningTransaction(LoadJoinedRoomFlow)
sessionCoroutineScope.launch {
inputs.room.subscribeToSync()
}
},
onDestroy = {
Timber.v("OnDestroy")
activeRoomsHolder.removeRoom(inputs.room.sessionId, inputs.room.roomId)
inputs.room.destroy()
appNavigationStateService.onLeavingRoom(id)
}
)
}
private fun trackVisitedRoom() = lifecycleScope.launch {
matrixClient.trackRecentlyVisitedRoom(inputs.room.roomId)
}
private fun fetchRoomMembers() = lifecycleScope.launch {
inputs.room.updateMembers()
}
private fun createRoomDetailsNode(buildContext: BuildContext, initialTarget: RoomDetailsEntryPoint.InitialTarget): Node {
val callback = object : RoomDetailsEntryPoint.Callback {
override fun navigateToGlobalNotificationSettings() {
callback.navigateToGlobalNotificationSettings()
}
override fun navigateToRoom(roomId: RoomId, serverNames: List<String>) {
callback.navigateToRoom(roomId, serverNames)
}
override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) {
callback.handlePermalinkClick(data, pushToBackstack)
}
override fun startForwardEventFlow(eventId: EventId, fromPinnedEvents: Boolean) {
backstack.push(NavTarget.ForwardEvent(eventId, fromPinnedEvents))
}
}
return roomDetailsEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = RoomDetailsEntryPoint.Params(initialTarget),
callback = callback,
)
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
is NavTarget.Messages -> {
createMessagesNode(buildContext, navTarget)
}
NavTarget.RoomDetails -> {
createRoomDetailsNode(buildContext, RoomDetailsEntryPoint.InitialTarget.RoomDetails)
}
is NavTarget.RoomMemberDetails -> {
createRoomDetailsNode(buildContext, RoomDetailsEntryPoint.InitialTarget.RoomMemberDetails(navTarget.userId))
}
NavTarget.RoomNotificationSettings -> {
createRoomDetailsNode(buildContext, RoomDetailsEntryPoint.InitialTarget.RoomNotificationSettings)
}
NavTarget.RoomMemberList -> {
createRoomDetailsNode(buildContext, RoomDetailsEntryPoint.InitialTarget.RoomMemberList)
}
NavTarget.Space -> {
createSpaceNode(buildContext)
}
is NavTarget.ForwardEvent -> {
val timelineProvider = if (navTarget.fromPinnedEvents) {
(graph as TimelineBindings).pinnedEventsTimelineProvider
} else {
(graph as TimelineBindings).timelineProvider
}
val params = ForwardEntryPoint.Params(navTarget.eventId, timelineProvider)
val callback = object : ForwardEntryPoint.Callback {
override fun onDone(roomIds: List<RoomId>) {
backstack.pop()
roomIds.singleOrNull()?.let { roomId ->
callback.navigateToRoom(roomId, emptyList())
}
}
}
forwardEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = params,
callback = callback,
)
}
}
}
private fun createSpaceNode(buildContext: BuildContext): Node {
val callback = object : SpaceEntryPoint.Callback {
override fun navigateToRoom(roomId: RoomId, viaParameters: List<String>) {
callback.navigateToRoom(roomId, viaParameters)
}
override fun navigateToRoomMemberList() {
backstack.push(NavTarget.RoomMemberList)
}
}
return spaceEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
inputs = SpaceEntryPoint.Inputs(roomId = inputs.room.roomId),
callback = callback,
)
}
private fun createMessagesNode(
buildContext: BuildContext,
navTarget: NavTarget.Messages,
): Node {
val callback = object : MessagesEntryPoint.Callback {
override fun navigateToRoomDetails() {
backstack.push(NavTarget.RoomDetails)
}
override fun navigateToRoomMemberDetails(userId: UserId) {
backstack.push(NavTarget.RoomMemberDetails(userId))
}
override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) {
callback.handlePermalinkClick(data, pushToBackstack)
}
override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) {
backstack.push(NavTarget.ForwardEvent(eventId, fromPinnedEvents))
}
override fun navigateToRoom(roomId: RoomId) {
callback.navigateToRoom(roomId, emptyList())
}
}
val params = MessagesEntryPoint.Params(
MessagesEntryPoint.InitialTarget.Messages(navTarget.focusedEventId)
)
return messagesEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = params,
callback = callback,
)
}
sealed interface NavTarget : Parcelable {
@Parcelize
data object Space : NavTarget
@Parcelize
data class Messages(
val focusedEventId: EventId? = null,
) : NavTarget
@Parcelize
data object RoomDetails : NavTarget
@Parcelize
data object RoomMemberList : NavTarget
@Parcelize
data class RoomMemberDetails(val userId: UserId) : NavTarget
@Parcelize
data class ForwardEvent(val eventId: EventId, val fromPinnedEvents: Boolean) : NavTarget
@Parcelize
data object RoomNotificationSettings : NavTarget
}
suspend fun attachThread(threadId: ThreadId, focusedEventId: EventId?) {
val messageNode = waitForChildAttached<Node, NavTarget> { navTarget ->
navTarget is NavTarget.Messages
}
(messageNode as? MessagesEntryPoint.NodeProxy)?.attachThread(threadId, focusedEventId)
}
@Composable
override fun View(modifier: Modifier) {
BackstackView()
}
}
private fun initialElement(plugins: List<Plugin>): JoinedRoomLoadedFlowNode.NavTarget {
val input = plugins.filterIsInstance<JoinedRoomLoadedFlowNode.Inputs>().single()
return when (input.initialElement) {
is RoomNavigationTarget.Root -> {
if (input.room.roomInfoFlow.value.isSpace) {
JoinedRoomLoadedFlowNode.NavTarget.Space
} else {
JoinedRoomLoadedFlowNode.NavTarget.Messages(input.initialElement.eventId)
}
}
RoomNavigationTarget.Details -> JoinedRoomLoadedFlowNode.NavTarget.RoomDetails
RoomNavigationTarget.NotificationSettings -> JoinedRoomLoadedFlowNode.NavTarget.RoomNotificationSettings
}
}

View File

@@ -0,0 +1,92 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector 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.room.joined
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.DelayedVisibility
import io.element.android.libraries.matrix.ui.room.LoadingRoomState
import io.element.android.libraries.matrix.ui.room.LoadingRoomStateProvider
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun LoadingRoomNodeView(
state: LoadingRoomState,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
topBar = {
LoadingRoomTopBar(onBackClick)
},
content = { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.consumeWindowInsets(padding)
.padding(16.dp),
contentAlignment = Alignment.Center
) {
if (state is LoadingRoomState.Error) {
Text(
text = stringResource(id = CommonStrings.error_unknown),
color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodyMdRegular,
)
} else {
DelayedVisibility {
CircularProgressIndicator()
}
}
}
},
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun LoadingRoomTopBar(
onBackClick: () -> Unit,
) {
TopAppBar(
navigationIcon = {
BackButton(onClick = onBackClick)
},
title = {
},
)
}
@PreviewsDayNight
@Composable
internal fun LoadingRoomNodeViewPreview(@PreviewParameter(LoadingRoomStateProvider::class) state: LoadingRoomState) = ElementPreview {
LoadingRoomNodeView(
state = state,
onBackClick = {}
)
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.root
import io.element.android.libraries.sessionstorage.api.LoggedInState
/**
* [RootNavState] produced by [RootNavStateFlowFactory].
*/
data class RootNavState(
/**
* This value is incremented when a clear cache is done.
* Can be useful to track to force ui state to re-render
*/
val cacheIndex: Int,
/**
* LoggedInState.
*/
val loggedInState: LoggedInState,
)

View File

@@ -0,0 +1,89 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.root
import com.bumble.appyx.core.state.MutableSavedStateMap
import com.bumble.appyx.core.state.SavedStateMap
import dev.zacsweers.metro.Inject
import io.element.android.appnav.di.MatrixSessionCache
import io.element.android.features.preferences.api.CacheService
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
import io.element.android.libraries.preferences.api.store.SessionPreferencesStoreFactory
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.onEach
private const val SAVE_INSTANCE_KEY = "io.element.android.x.RootNavStateFlowFactory.SAVE_INSTANCE_KEY"
/**
* This class is responsible for creating a flow of [RootNavState].
* It gathers data from multiple datasource and creates a unique one.
*/
@Inject
class RootNavStateFlowFactory(
private val sessionStore: SessionStore,
private val cacheService: CacheService,
private val matrixSessionCache: MatrixSessionCache,
private val imageLoaderHolder: ImageLoaderHolder,
private val sessionPreferencesStoreFactory: SessionPreferencesStoreFactory,
) {
private var currentCacheIndex = 0
fun create(savedStateMap: SavedStateMap?): Flow<RootNavState> {
return combine(
cacheIndexFlow(savedStateMap),
sessionStore.loggedInStateFlow(),
) { cacheIndex, loggedInState ->
RootNavState(
cacheIndex = cacheIndex,
loggedInState = loggedInState,
)
}
}
fun saveIntoSavedState(stateMap: MutableSavedStateMap) {
stateMap[SAVE_INSTANCE_KEY] = currentCacheIndex
}
/**
* @return a flow of integer, where each time a clear cache is done, we have a new incremented value.
*/
private fun cacheIndexFlow(savedStateMap: SavedStateMap?): Flow<Int> {
val initialCacheIndex = savedStateMap.getCacheIndexOrDefault()
return cacheService.clearedCacheEventFlow
.onEach { sessionId ->
matrixSessionCache.remove(sessionId)
// Ensure image loader will be recreated with the new MatrixClient
imageLoaderHolder.remove(sessionId)
// Also remove cached value for SessionPreferencesStore
sessionPreferencesStoreFactory.remove(sessionId)
}
.toIndexFlow(initialCacheIndex)
.onEach { cacheIndex ->
currentCacheIndex = cacheIndex
}
}
/**
* @return a flow of integer that increments the value by one each time a new element is emitted upstream.
*/
private fun Flow<Any>.toIndexFlow(initialValue: Int): Flow<Int> = flow {
var index = initialValue
emit(initialValue)
collect {
emit(++index)
}
}
private fun SavedStateMap?.getCacheIndexOrDefault(): Int {
return this?.get(SAVE_INSTANCE_KEY) as? Int ?: 0
}
}

View File

@@ -0,0 +1,54 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.root
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import dev.zacsweers.metro.Inject
import im.vector.app.features.analytics.plan.SuperProperties
import io.element.android.features.rageshake.api.crash.CrashDetectionState
import io.element.android.features.rageshake.api.detection.RageshakeDetectionState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.SdkMetadata
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.apperror.api.AppErrorStateService
@Inject
class RootPresenter(
private val crashDetectionPresenter: Presenter<CrashDetectionState>,
private val rageshakeDetectionPresenter: Presenter<RageshakeDetectionState>,
private val appErrorStateService: AppErrorStateService,
private val analyticsService: AnalyticsService,
private val sdkMetadata: SdkMetadata,
) : Presenter<RootState> {
@Composable
override fun present(): RootState {
val rageshakeDetectionState = rageshakeDetectionPresenter.present()
val crashDetectionState = crashDetectionPresenter.present()
val appErrorState by appErrorStateService.appErrorStateFlow.collectAsState()
LaunchedEffect(Unit) {
analyticsService.updateSuperProperties(
SuperProperties(
cryptoSDK = SuperProperties.CryptoSDK.Rust,
appPlatform = SuperProperties.AppPlatform.EXA,
cryptoSDKVersion = sdkMetadata.sdkGitSha,
)
)
}
return RootState(
rageshakeDetectionState = rageshakeDetectionState,
crashDetectionState = crashDetectionState,
errorState = appErrorState,
)
}
}

View File

@@ -0,0 +1,19 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.root
import io.element.android.features.rageshake.api.crash.CrashDetectionState
import io.element.android.features.rageshake.api.detection.RageshakeDetectionState
import io.element.android.services.apperror.api.AppErrorState
data class RootState(
val rageshakeDetectionState: RageshakeDetectionState,
val crashDetectionState: CrashDetectionState,
val errorState: AppErrorState,
)

View File

@@ -0,0 +1,38 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.rageshake.api.crash.aCrashDetectionState
import io.element.android.features.rageshake.api.detection.aRageshakeDetectionState
import io.element.android.services.apperror.api.AppErrorState
import io.element.android.services.apperror.api.aAppErrorState
open class RootStateProvider : PreviewParameterProvider<RootState> {
override val values: Sequence<RootState>
get() = sequenceOf(
aRootState().copy(
rageshakeDetectionState = aRageshakeDetectionState().copy(showDialog = false),
crashDetectionState = aCrashDetectionState().copy(crashDetected = true),
),
aRootState().copy(
rageshakeDetectionState = aRageshakeDetectionState().copy(showDialog = true),
crashDetectionState = aCrashDetectionState().copy(crashDetected = false),
),
aRootState().copy(
errorState = aAppErrorState(),
)
)
}
fun aRootState() = RootState(
rageshakeDetectionState = aRageshakeDetectionState(),
crashDetectionState = aCrashDetectionState(),
errorState = AppErrorState.NoError,
)

View File

@@ -0,0 +1,70 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.root
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.rageshake.api.crash.CrashDetectionEvents
import io.element.android.features.rageshake.api.crash.CrashDetectionView
import io.element.android.features.rageshake.api.detection.RageshakeDetectionEvents
import io.element.android.features.rageshake.api.detection.RageshakeDetectionView
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.services.apperror.impl.AppErrorView
@Composable
fun RootView(
state: RootState,
onOpenBugReport: () -> Unit,
modifier: Modifier = Modifier,
children: @Composable BoxScope.() -> Unit,
) {
Box(
modifier = modifier
.fillMaxSize(),
contentAlignment = Alignment.TopCenter,
) {
children()
fun onOpenBugReport() {
state.crashDetectionState.eventSink(CrashDetectionEvents.ResetAppHasCrashed)
state.rageshakeDetectionState.eventSink(RageshakeDetectionEvents.Dismiss)
onOpenBugReport.invoke()
}
RageshakeDetectionView(
state = state.rageshakeDetectionState,
onOpenBugReport = ::onOpenBugReport,
)
CrashDetectionView(
state = state.crashDetectionState,
onOpenBugReport = ::onOpenBugReport,
)
AppErrorView(
state = state.errorState,
)
}
}
@PreviewsDayNight
@Composable
internal fun RootViewPreview(@PreviewParameter(RootStateProvider::class) rootState: RootState) = ElementPreview {
RootView(
state = rootState,
onOpenBugReport = {},
) {
Text("Children")
}
}

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Выйсці і абнавіць"</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Ваш хатні сервер больш не падтрымлівае стары пратакол. Калі ласка, выйдзіце і ўвайдзіце зноў, каб працягнуць выкарыстанне праграмы."</string>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Odhlásit se a upgradovat"</string>
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s již nepodporuje starý protokol. Odhlaste se a znovu přihlaste, abyste mohli pokračovat v používání aplikace."</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Váš domovský server již nepodporuje starý protokol. Chcete-li pokračovat v používání aplikace, odhlaste se a znovu se přihlaste."</string>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Allgofnodi ac Uwchraddio"</string>
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"Nid yw %1$s bellach yn cefnogi\'r hen brotocol. Allgofnodwch a mewngofnodi\'n ôl i barhau i ddefnyddio\'r ap."</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Nid yw eich gweinydd cartref yn cefnogi\'r hen brotocol mwyach. Allgofnodwch a mewngofnodi yn ôl i barhau i ddefnyddio\'r ap."</string>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Log ud og opgradér"</string>
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s understøtter ikke længere den gamle protokol. Log ud og log ind igen for at fortsætte med at bruge appen."</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Din hjemmeserver understøtter ikke længere den gamle protokol. Log ud og log ind igen for at fortsætte med at bruge appen."</string>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Abmelden und aktualisieren"</string>
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s unterstützt das alte Protokoll nicht mehr. Bitte melde dich ab und wieder an, um die App weiter nutzen zu können."</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Dein Homeserver unterstützt das alte Protokoll nicht mehr. Bitte logge dich aus und melde dich wieder an, um die App weiter zu nutzen."</string>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Αποσύνδεση &amp; Αναβάθμιση"</string>
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"Το %1$s δεν υποστηρίζει πλέον το παλιό πρωτόκολλο. Αποσυνδεθείτε και συνδεθείτε ξανά για να συνεχίσετε να χρησιμοποιείτε την εφαρμογή."</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Ο οικιακός διακομιστής σου δεν υποστηρίζει πλέον το παλιό πρωτόκολλο. Αποσυνδέσου και συνδέσου ξανά για να συνεχίσεις να χρησιμοποιείς την εφαρμογή."</string>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Cerrar sesión y actualizar"</string>
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s ya no es compatible con el antiguo protocolo. Cierra sesión y vuelve a iniciarla para seguir usando la aplicación."</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Tu servidor base ya no es compatible con el protocolo anterior. Cierra sesión y vuelve a iniciarla para seguir usando la aplicación."</string>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Logi välja ja uuenda"</string>
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s enam ei toeta vana protokolli. Kui soovid rakendust edasi kasutada, siis logi korraks temast välja ning seejärel tagasi."</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Sinu koduserver enam ei toeta vana protokolli. Jätkamaks rakenduse kasutamist palun logi välja ning seejärel tagasi."</string>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Amaitu saioa eta bertsio-berritu"</string>
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s(e)k ez da bateragarria lehengo protokoloarekin. Amaitu saioa eta hasi berriro aplikazioa erabiltzen jarraitzeko."</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Zure zerbitzaria ez da bateragarria protokolo zaharrarekin. Amaitu saioa eta hasi berriro aplikazioa erabiltzen jarraitzeko."</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"خروج و ارتقا"</string>
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s دیگر از شیوه‌نامهٔ قدیمی پشتیبانی نمی‌کند. لطفاً برای ادامهٔ استفاده از کاره، خارج شده و دوباره وارد شوید."</string>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Kirjaudu Ulos &amp; Päivitä"</string>
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s ei enää tue vanhaa protokollaa. Kirjaudu ulos ja takaisin sisään jatkaaksesi sovelluksen käyttöä."</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Kotipalvelimesi ei enää tue vanhaa protokollaa. Kirjaudu ulos ja takaisin sisään jatkaaksesi sovelluksen käyttöä."</string>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Déconnecter et mettre à niveau"</string>
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s ne prend plus en charge lancien protocole. Veuillez vous déconnecter puis vous reconnecter pour continuer à utiliser lapplication."</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Votre serveur daccueil ne prend plus en charge lancien protocole. Veuillez vous déconnecter puis vous reconnecter pour continuer à utiliser lapplication."</string>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Kijelentkezés és frissítés"</string>
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s már nem támogatja a régi protokollt. Kérjük, jelentkezzen ki és jelentkezzen be újra az alkalmazás használatának folytatásához."</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"A Matrix-kiszolgáló már nem támogatja a régi protokollt. Az alkalmazás további használatához jelentkezzen ki és be."</string>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Keluar &amp; Tingkatkan"</string>
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s tidak lagi mendukung protokol lama. Silakan keluar dan masuk kembali untuk terus menggunakan aplikasi."</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Homeserver Anda tidak lagi mendukung protokol lama. Silakan keluar dan masuk kembali untuk terus menggunakan aplikasi."</string>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Esci e aggiorna"</string>
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s non supporta più il vecchio protocollo. Esci e accedi nuovamente per continuare a utilizzare l\'app."</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Il tuo homeserver non supporta più il vecchio protocollo. Esci e rientra per continuare a usare l\'app."</string>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"로그아웃 및 업그레이드"</string>
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s 더 이상 이전 프로토콜을 지원하지 않습니다. 계속 사용하려면 로그아웃 후 다시 로그인해 주세요."</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"귀하의 홈서버는 더 이상 이전 프로토콜을 지원하지 않습니다. 앱을 계속 사용하려면 로그아웃한 후 다시 로그인하세요."</string>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Logg ut og oppgrader"</string>
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s støtter ikke lenger den gamle protokollen. Logg ut og logg inn igjen for å fortsette å bruke appen."</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Hjemmeserveren din støtter ikke lenger den gamle protokollen. Vennligst logg ut og inn igjen for å fortsette å bruke appen."</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Uitloggen &amp; Upgraden"</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Je homeserver ondersteunt het oude protocol niet meer. Log uit en log opnieuw in om de app te blijven gebruiken."</string>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Wyloguj się i zaktualizuj"</string>
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s już nie wspiera starego protokołu. Zaloguj się ponownie, aby dalej korzystać z aplikacji."</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Twój serwer domowy już nie wspiera starego protokołu. Zaloguj się ponownie, aby kontynuować korzystanie z aplikacji."</string>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Sair e atualizar"</string>
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s não tem mais suporte ao protocolo antigo. Saia da sua conta e entre novamente para continuar utilizando o aplicativo."</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Seu servidor-casa não é mais compatível com o protocolo antigo. Saia da sua conta e entre novamente para continuar usando o aplicativo."</string>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Sair &amp; Atualizar"</string>
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s já não suporta o protocolo antigo. Termina a sessão e volta a iniciar sessão para continuares a utilizar a aplicação."</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"O teu servidor já não permite o protocolo antigo. Termine sessão e volte a iniciá-la para continuar a utilizar a aplicação."</string>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Deconectați-vă și faceți upgrade"</string>
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s nu mai acceptă vechiul protocol. Vă rugăm să vă deconectați și să vă reconectați pentru a continua utilizarea aplicației."</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Serverul dvs. de acasă nu mai acceptă vechiul protocol. Vă rugăm să vă deconectați și să vă conectați din nou pentru a continua să utilizați aplicația."</string>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Выйти и обновить"</string>
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s больше не поддерживает старый протокол. Пожалуйста, выйдите из системы и войдите снова, чтобы продолжить использование приложения."</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Ваш домашний сервер больше не поддерживает старый протокол. Пожалуйста, выйдите и войдите в свою учётную запись снова, чтобы продолжить использование приложения."</string>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Odhlásiť sa a aktualizovať"</string>
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s už nepodporuje starý protokol. Odhláste sa a znova prihláste, aby ste mohli pokračovať v používaní aplikácie."</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Váš domovský server už nepodporuje starý protokol. Ak chcete pokračovať v používaní aplikácie, odhláste sa a znova sa prihláste."</string>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Logga ut och uppgradera"</string>
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s stöder inte längre det gamla protokollet. Logga ut och logga in igen för att fortsätta använda appen."</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Din hemserver stöder inte längre det gamla protokollet. Logga ut och logga in igen för att fortsätta använda appen."</string>
</resources>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">ıkış Yap ve Yükselt"</string>
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s artık eski protokolü destekleniyor. Uygulamayı kullanmaya devam etmek için lütfen çıkış yapın ve tekrar giriş yapın
"</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Ana sunucunuz artık eski protokolü desteklemiyor. Lütfen oturumu kapatın ve uygulamayı kullanmaya devam etmek için tekrar oturum açın."</string>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Вийти та оновити"</string>
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s більше не підтримує старий протокол. Вийдіть і знов увійдіть, щоб продовжити користуватися застосунком."</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Ваш домашній сервер більше не підтримує старий протокол. Будь ласка, вийдіть і увійдіть знову, щоб продовжити використання програми."</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"لاگ آؤٹ اور اپ گریڈ کریں"</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"آپ کا homeserver اب پرانے پروٹوکول کو سپورٹ نہیں کرتا ہے۔ براہ کرم لاگ آؤٹ کریں اور ایپ کا استعمال جاری رکھنے کے لیے دوبارہ لاگ ان کریں۔"</string>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Tizmdan chiqish va yangilash"</string>
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s endi eski protokolni qoʻllab-quvvatlamaydi. Iltimos, ilovadan foydalanishni davom ettirish uchun tizimdan chiqing va qayta kiring."</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Sizning uy serveringiz endi eski protokolni qoʻllab-quvvatlamaydi. Iltimos, ilovadan foydalanishni davom ettirish uchun tizimdan qayta chiqib-kiring."</string>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"登出並升級"</string>
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s 不再支援舊版通訊協定。請登出並重新登入以繼續使用應用程式。"</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"您的家伺服器不再支援舊協定。請登出並重新登入以繼續使用應用程式。"</string>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"登出并升级"</string>
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s 不再支持旧协议。请注销并重新登录以继续使用该应用程序。"</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"您的服务器不再支持旧协议。请登出并重新登录以继续使用此应用。"</string>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_migrate_to_native_sliding_sync_action">"Log Out &amp; Upgrade"</string>
<string name="banner_migrate_to_native_sliding_sync_app_force_logout_title">"%1$s no longer supports the old protocol. Please log out and log back in to continue using the app."</string>
<string name="banner_migrate_to_native_sliding_sync_force_logout_title">"Your homeserver no longer supports the old protocol. Please log out and log back in to continue using the app."</string>
</resources>

View File

@@ -0,0 +1,239 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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 androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.lifecycle.Lifecycle
import com.bumble.appyx.core.modality.BuildContext
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.navmodel.backstack.activeElement
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.bumble.appyx.testing.unit.common.helper.parentNodeTestHelper
import com.google.common.truth.Truth.assertThat
import io.element.android.appnav.di.RoomGraphFactory
import io.element.android.appnav.room.RoomNavigationTarget
import io.element.android.appnav.room.joined.FakeJoinedRoomLoadedFlowNodeCallback
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
import io.element.android.features.forward.api.ForwardEntryPoint
import io.element.android.features.forward.test.FakeForwardEntryPoint
import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
import io.element.android.features.space.api.SpaceEntryPoint
import io.element.android.libraries.architecture.childNode
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import io.element.android.services.appnavstate.impl.DefaultActiveRoomsHolder
import io.element.android.services.appnavstate.test.FakeAppNavigationStateService
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class JoinedRoomLoadedFlowNodeTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private class FakeMessagesEntryPoint : MessagesEntryPoint {
var nodeId: String? = null
var parameters: MessagesEntryPoint.Params? = null
var callback: MessagesEntryPoint.Callback? = null
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
params: MessagesEntryPoint.Params,
callback: MessagesEntryPoint.Callback,
): Node {
parameters = params
this.callback = callback
return node(buildContext) {}.also {
nodeId = it.id
}
}
}
private class FakeRoomGraphFactory : RoomGraphFactory {
override fun create(room: JoinedRoom): Any {
return Unit
}
}
private class FakeRoomDetailsEntryPoint : RoomDetailsEntryPoint {
var nodeId: String? = null
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
params: RoomDetailsEntryPoint.Params,
callback: RoomDetailsEntryPoint.Callback,
) = node(buildContext) {}.also {
nodeId = it.id
}
}
private class FakeSpaceEntryPoint : SpaceEntryPoint {
var nodeId: String? = null
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
inputs: SpaceEntryPoint.Inputs,
callback: SpaceEntryPoint.Callback,
) = node(buildContext) {}.also {
nodeId = it.id
}
}
private fun TestScope.createJoinedRoomLoadedFlowNode(
plugins: List<Plugin>,
messagesEntryPoint: MessagesEntryPoint = FakeMessagesEntryPoint(),
roomDetailsEntryPoint: RoomDetailsEntryPoint = FakeRoomDetailsEntryPoint(),
spaceEntryPoint: SpaceEntryPoint = FakeSpaceEntryPoint(),
forwardEntryPoint: ForwardEntryPoint = FakeForwardEntryPoint(),
activeRoomsHolder: ActiveRoomsHolder = DefaultActiveRoomsHolder(),
matrixClient: FakeMatrixClient = FakeMatrixClient(),
) = JoinedRoomLoadedFlowNode(
buildContext = BuildContext.root(savedStateMap = null),
plugins = plugins,
messagesEntryPoint = messagesEntryPoint,
roomDetailsEntryPoint = roomDetailsEntryPoint,
spaceEntryPoint = spaceEntryPoint,
forwardEntryPoint = forwardEntryPoint,
appNavigationStateService = FakeAppNavigationStateService(),
sessionCoroutineScope = backgroundScope,
roomGraphFactory = FakeRoomGraphFactory(),
matrixClient = matrixClient,
activeRoomsHolder = activeRoomsHolder,
analyticsService = FakeAnalyticsService(),
)
@Test
fun `given a room flow node when initialized then it loads messages entry point if room is not space`() = runTest {
// GIVEN
val room = FakeJoinedRoom(baseRoom = FakeBaseRoom(updateMembersResult = {}, initialRoomInfo = aRoomInfo(isSpace = false)))
val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
val inputs = JoinedRoomLoadedFlowNode.Inputs(room, RoomNavigationTarget.Root())
val roomFlowNode = createJoinedRoomLoadedFlowNode(
plugins = listOf(inputs, FakeJoinedRoomLoadedFlowNodeCallback()),
messagesEntryPoint = fakeMessagesEntryPoint,
)
// WHEN
val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper()
// THEN
assertThat(roomFlowNode.backstack.activeElement).isEqualTo(JoinedRoomLoadedFlowNode.NavTarget.Messages())
roomFlowNodeTestHelper.assertChildHasLifecycle(JoinedRoomLoadedFlowNode.NavTarget.Messages(), Lifecycle.State.CREATED)
val messagesNode = roomFlowNode.childNode(JoinedRoomLoadedFlowNode.NavTarget.Messages())!!
assertThat(messagesNode.id).isEqualTo(fakeMessagesEntryPoint.nodeId)
}
@Test
fun `given a room flow node when initialized then it loads space entry point if room is space`() = runTest {
// GIVEN
val room = FakeJoinedRoom(baseRoom = FakeBaseRoom(updateMembersResult = {}, initialRoomInfo = aRoomInfo(isSpace = true)))
val spaceEntryPoint = FakeSpaceEntryPoint()
val inputs = JoinedRoomLoadedFlowNode.Inputs(room, RoomNavigationTarget.Root())
val roomFlowNode = createJoinedRoomLoadedFlowNode(
plugins = listOf(inputs, FakeJoinedRoomLoadedFlowNodeCallback()),
spaceEntryPoint = spaceEntryPoint,
)
// WHEN
val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper()
// THEN
assertThat(roomFlowNode.backstack.activeElement).isEqualTo(JoinedRoomLoadedFlowNode.NavTarget.Space)
roomFlowNodeTestHelper.assertChildHasLifecycle(JoinedRoomLoadedFlowNode.NavTarget.Space, Lifecycle.State.CREATED)
val spaceNode = roomFlowNode.childNode(JoinedRoomLoadedFlowNode.NavTarget.Space)!!
assertThat(spaceNode.id).isEqualTo(spaceEntryPoint.nodeId)
}
@Test
fun `given a room flow node when callback on room details is triggered then it loads room details entry point`() = runTest {
// GIVEN
val room = FakeJoinedRoom(baseRoom = FakeBaseRoom(updateMembersResult = {}))
val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
val fakeRoomDetailsEntryPoint = FakeRoomDetailsEntryPoint()
val inputs = JoinedRoomLoadedFlowNode.Inputs(room, RoomNavigationTarget.Root())
val roomFlowNode = createJoinedRoomLoadedFlowNode(
plugins = listOf(inputs, FakeJoinedRoomLoadedFlowNodeCallback()),
messagesEntryPoint = fakeMessagesEntryPoint,
roomDetailsEntryPoint = fakeRoomDetailsEntryPoint,
)
val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper()
// WHEN
fakeMessagesEntryPoint.callback?.navigateToRoomDetails()
// THEN
roomFlowNodeTestHelper.assertChildHasLifecycle(JoinedRoomLoadedFlowNode.NavTarget.RoomDetails, Lifecycle.State.CREATED)
val roomDetailsNode = roomFlowNode.childNode(JoinedRoomLoadedFlowNode.NavTarget.RoomDetails)!!
assertThat(roomDetailsNode.id).isEqualTo(fakeRoomDetailsEntryPoint.nodeId)
}
@Test
fun `the ActiveRoomsHolder will be updated with the loaded room on create`() = runTest {
// GIVEN
val room = FakeJoinedRoom(baseRoom = FakeBaseRoom(updateMembersResult = {}))
val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
val fakeRoomDetailsEntryPoint = FakeRoomDetailsEntryPoint()
val inputs = JoinedRoomLoadedFlowNode.Inputs(room, RoomNavigationTarget.Root())
val activeRoomsHolder = DefaultActiveRoomsHolder()
val roomFlowNode = createJoinedRoomLoadedFlowNode(
plugins = listOf(inputs, FakeJoinedRoomLoadedFlowNodeCallback()),
messagesEntryPoint = fakeMessagesEntryPoint,
roomDetailsEntryPoint = fakeRoomDetailsEntryPoint,
activeRoomsHolder = activeRoomsHolder,
)
assertThat(activeRoomsHolder.getActiveRoom(A_SESSION_ID)).isNull()
val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper()
// WHEN
roomFlowNodeTestHelper.assertChildHasLifecycle(JoinedRoomLoadedFlowNode.NavTarget.Messages(null), Lifecycle.State.CREATED)
// THEN
assertThat(activeRoomsHolder.getActiveRoom(A_SESSION_ID)).isNotNull()
}
@Test
fun `the ActiveRoomsHolder will be removed on destroy`() = runTest {
// GIVEN
val room = FakeJoinedRoom(baseRoom = FakeBaseRoom(updateMembersResult = {}))
val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
val fakeRoomDetailsEntryPoint = FakeRoomDetailsEntryPoint()
val inputs = JoinedRoomLoadedFlowNode.Inputs(room, RoomNavigationTarget.Root())
val activeRoomsHolder = DefaultActiveRoomsHolder().apply {
addRoom(room)
}
val roomFlowNode = createJoinedRoomLoadedFlowNode(
plugins = listOf(inputs, FakeJoinedRoomLoadedFlowNodeCallback()),
messagesEntryPoint = fakeMessagesEntryPoint,
roomDetailsEntryPoint = fakeRoomDetailsEntryPoint,
activeRoomsHolder = activeRoomsHolder,
)
val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper()
roomFlowNodeTestHelper.assertChildHasLifecycle(JoinedRoomLoadedFlowNode.NavTarget.Messages(null), Lifecycle.State.CREATED)
assertThat(activeRoomsHolder.getActiveRoom(A_SESSION_ID)).isNotNull()
// WHEN
roomFlowNode.updateLifecycleState(Lifecycle.State.DESTROYED)
// THEN
roomFlowNodeTestHelper.assertChildHasLifecycle(JoinedRoomLoadedFlowNode.NavTarget.Messages(null), Lifecycle.State.DESTROYED)
assertThat(activeRoomsHolder.getActiveRoom(A_SESSION_ID)).isNull()
}
}

View File

@@ -0,0 +1,80 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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 app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.appnav.root.RootPresenter
import io.element.android.features.rageshake.api.crash.aCrashDetectionState
import io.element.android.features.rageshake.api.detection.aRageshakeDetectionState
import io.element.android.libraries.matrix.test.FakeSdkMetadata
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.apperror.api.AppErrorState
import io.element.android.services.apperror.api.AppErrorStateService
import io.element.android.services.apperror.impl.DefaultAppErrorStateService
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class RootPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val presenter = createRootPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.crashDetectionState.crashDetected).isFalse()
}
}
@Test
fun `present - passes app error state`() = runTest {
val presenter = createRootPresenter(
appErrorService = DefaultAppErrorStateService(
stringProvider = FakeStringProvider(),
).apply {
showError("Bad news", "Something bad happened")
}
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.errorState).isInstanceOf(AppErrorState.Error::class.java)
val initialErrorState = initialState.errorState as AppErrorState.Error
assertThat(initialErrorState.title).isEqualTo("Bad news")
assertThat(initialErrorState.body).isEqualTo("Something bad happened")
initialErrorState.dismiss()
assertThat(awaitItem().errorState).isInstanceOf(AppErrorState.NoError::class.java)
}
}
private fun createRootPresenter(
appErrorService: AppErrorStateService = DefaultAppErrorStateService(
stringProvider = FakeStringProvider(),
),
): RootPresenter {
return RootPresenter(
crashDetectionPresenter = { aCrashDetectionState() },
rageshakeDetectionPresenter = { aRageshakeDetectionState() },
appErrorStateService = appErrorService,
analyticsService = FakeAnalyticsService(),
sdkMetadata = FakeSdkMetadata("sha")
)
}
}

View File

@@ -0,0 +1,396 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector 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 io.element.android.appnav.di.SyncOrchestrator
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@OptIn(ExperimentalCoroutinesApi::class)
class SyncOrchestratorTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `when the sync wasn't running before, an initial sync will take place, even with no network`() = runTest {
val startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val syncService = FakeSyncService(initialSyncState = SyncState.Idle).apply {
startSyncLambda = startSyncRecorder
}
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected)
val syncOrchestrator = createSyncOrchestrator(
syncService = syncService,
networkMonitor = networkMonitor,
)
// We start observing with an initial sync
syncOrchestrator.start()
// Advance the time just enough to make sure the initial sync has run
advanceTimeBy(1.milliseconds)
startSyncRecorder.assertions().isCalledOnce()
}
@Test
fun `when the sync wasn't running before, an initial sync will take place`() = runTest {
val startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val syncService = FakeSyncService(initialSyncState = SyncState.Idle).apply {
startSyncLambda = startSyncRecorder
}
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
val syncOrchestrator = createSyncOrchestrator(
syncService = syncService,
networkMonitor = networkMonitor,
)
// We start observing with an initial sync
syncOrchestrator.start()
// Advance the time just enough to make sure the initial sync has run
advanceTimeBy(1.milliseconds)
startSyncRecorder.assertions().isCalledOnce()
// If we wait for a while, the sync will not be started again by the observer since it's already running
advanceTimeBy(10.seconds)
startSyncRecorder.assertions().isCalledOnce()
}
@Test
fun `when the app goes to background and the sync was running, it will be stopped after a delay`() = runTest {
val stopSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val syncService = FakeSyncService(initialSyncState = SyncState.Running).apply {
stopSyncLambda = stopSyncRecorder
}
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
val appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true)
val syncOrchestrator = createSyncOrchestrator(
syncService = syncService,
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
)
// We start observing
syncOrchestrator.observeStates()
// Advance the time to make sure the orchestrator has had time to start processing the inputs
advanceTimeBy(100.milliseconds)
// Stop sync was never called
stopSyncRecorder.assertions().isNeverCalled()
// Now we send the app to background
appForegroundStateService.isInForeground.value = false
// Stop sync will be called after some delay
stopSyncRecorder.assertions().isNeverCalled()
advanceTimeBy(10.seconds)
stopSyncRecorder.assertions().isCalledOnce()
}
@Test
fun `when the app state changes several times in a short while, stop sync is only called once`() = runTest {
val stopSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val syncService = FakeSyncService(initialSyncState = SyncState.Running).apply {
stopSyncLambda = stopSyncRecorder
}
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
val appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = true)
val syncOrchestrator = createSyncOrchestrator(
syncService = syncService,
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
)
// We start observing
syncOrchestrator.observeStates()
// Advance the time to make sure the orchestrator has had time to start processing the inputs
advanceTimeBy(100.milliseconds)
// Stop sync was never called
stopSyncRecorder.assertions().isNeverCalled()
// Now we send the app to background
appForegroundStateService.isInForeground.value = false
// Ensure the stop action wasn't called yet
stopSyncRecorder.assertions().isNeverCalled()
advanceTimeBy(1.seconds)
appForegroundStateService.isInForeground.value = true
advanceTimeBy(1.seconds)
// Ensure the stop action wasn't called yet either, since we didn't give it enough time to emit after the expected delay
stopSyncRecorder.assertions().isNeverCalled()
// Now change it again and wait for enough time
appForegroundStateService.isInForeground.value = false
advanceTimeBy(4.seconds)
// And confirm it's now called
stopSyncRecorder.assertions().isCalledOnce()
}
@Test
fun `when the app was in background and we receive a notification, a sync will be started then stopped`() = runTest {
val startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val stopSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val syncService = FakeSyncService(initialSyncState = SyncState.Idle).apply {
startSyncLambda = startSyncRecorder
stopSyncLambda = stopSyncRecorder
}
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
val appForegroundStateService = FakeAppForegroundStateService(
initialForegroundValue = false,
initialIsSyncingNotificationEventValue = false,
)
val syncOrchestrator = createSyncOrchestrator(
syncService = syncService,
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
)
// We start observing
syncOrchestrator.observeStates()
// Advance the time to make sure the orchestrator has had time to start processing the inputs
advanceTimeBy(100.milliseconds)
// Start sync was never called
startSyncRecorder.assertions().isNeverCalled()
// Now we receive a notification and need to sync
appForegroundStateService.updateIsSyncingNotificationEvent(true)
// Start sync will be called shortly after
advanceTimeBy(1.milliseconds)
startSyncRecorder.assertions().isCalledOnce()
// If the sync is running and we mark the notification sync as no longer necessary, the sync stops after a delay
syncService.emitSyncState(SyncState.Running)
appForegroundStateService.updateIsSyncingNotificationEvent(false)
advanceTimeBy(10.seconds)
stopSyncRecorder.assertions().isCalledOnce()
}
@Test
fun `when the app was in background and we join a call, a sync will be started`() = runTest {
val startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val stopSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val syncService = FakeSyncService(initialSyncState = SyncState.Idle).apply {
startSyncLambda = startSyncRecorder
stopSyncLambda = stopSyncRecorder
}
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
val appForegroundStateService = FakeAppForegroundStateService(
initialForegroundValue = false,
initialIsSyncingNotificationEventValue = false,
)
val syncOrchestrator = createSyncOrchestrator(
syncService = syncService,
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
)
// We start observing
syncOrchestrator.observeStates()
// Advance the time to make sure the orchestrator has had time to start processing the inputs
advanceTimeBy(100.milliseconds)
// Start sync was never called
startSyncRecorder.assertions().isNeverCalled()
// Now we join a call
appForegroundStateService.updateIsInCallState(true)
// Start sync will be called shortly after
advanceTimeBy(1.milliseconds)
startSyncRecorder.assertions().isCalledOnce()
// If the sync is running and we mark the in-call state as false, the sync stops after a delay
syncService.emitSyncState(SyncState.Running)
appForegroundStateService.updateIsInCallState(false)
advanceTimeBy(10.seconds)
stopSyncRecorder.assertions().isCalledOnce()
}
@Test
fun `when the app was in background and we have an incoming ringing call, a sync will be started`() = runTest {
val startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val stopSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val syncService = FakeSyncService(initialSyncState = SyncState.Idle).apply {
startSyncLambda = startSyncRecorder
stopSyncLambda = stopSyncRecorder
}
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
val appForegroundStateService = FakeAppForegroundStateService(
initialForegroundValue = false,
initialIsSyncingNotificationEventValue = false,
initialHasRingingCall = false,
)
val syncOrchestrator = createSyncOrchestrator(
syncService = syncService,
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
)
// We start observing
syncOrchestrator.observeStates()
// Advance the time to make sure the orchestrator has had time to start processing the inputs
advanceTimeBy(100.milliseconds)
// Start sync was never called
startSyncRecorder.assertions().isNeverCalled()
// Now we receive a ringing call
appForegroundStateService.updateHasRingingCall(true)
// Start sync will be called shortly after
advanceTimeBy(1.milliseconds)
startSyncRecorder.assertions().isCalledOnce()
// If the sync is running and the ringing call notification is now over, the sync stops after a delay
syncService.emitSyncState(SyncState.Running)
appForegroundStateService.updateHasRingingCall(false)
advanceTimeBy(10.seconds)
stopSyncRecorder.assertions().isCalledOnce()
}
@Test
fun `when the app is in foreground, we sync for a notification and a call is ongoing, the sync will only stop when all conditions are false`() = runTest {
val startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val stopSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val syncService = FakeSyncService(initialSyncState = SyncState.Running).apply {
startSyncLambda = startSyncRecorder
stopSyncLambda = stopSyncRecorder
}
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
val appForegroundStateService = FakeAppForegroundStateService(
initialForegroundValue = true,
initialIsSyncingNotificationEventValue = true,
initialIsInCallValue = true,
)
val syncOrchestrator = createSyncOrchestrator(
syncService = syncService,
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
)
// We start observing
syncOrchestrator.observeStates()
// Advance the time to make sure the orchestrator has had time to start processing the inputs
advanceTimeBy(100.milliseconds)
// Start sync was never called
startSyncRecorder.assertions().isNeverCalled()
// We send the app to background, it's still syncing
appForegroundStateService.givenIsInForeground(false)
advanceTimeBy(10.seconds)
stopSyncRecorder.assertions().isNeverCalled()
// We stop the notification sync, it's still syncing
appForegroundStateService.updateIsSyncingNotificationEvent(false)
advanceTimeBy(10.seconds)
stopSyncRecorder.assertions().isNeverCalled()
// We set the in-call state to false, now it stops syncing after a delay
appForegroundStateService.updateIsInCallState(false)
advanceTimeBy(10.seconds)
stopSyncRecorder.assertions().isCalledOnce()
}
@Test
fun `if the sync was running, it's set to be stopped but something triggers a sync again, the sync is not stopped`() = runTest {
val stopSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val syncService = FakeSyncService(initialSyncState = SyncState.Running).apply {
stopSyncLambda = stopSyncRecorder
}
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Connected)
val appForegroundStateService = FakeAppForegroundStateService(
initialForegroundValue = true,
initialIsSyncingNotificationEventValue = false,
initialIsInCallValue = false,
)
val syncOrchestrator = createSyncOrchestrator(
syncService = syncService,
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
)
// We start observing
syncOrchestrator.observeStates()
// Advance the time to make sure the orchestrator has had time to start processing the inputs
advanceTimeBy(100.milliseconds)
// This will set the sync to stop
appForegroundStateService.givenIsInForeground(false)
// But if we reset it quickly before the stop sync takes place, the sync is not stopped
advanceTimeBy(2.seconds)
appForegroundStateService.givenIsInForeground(true)
advanceTimeBy(10.seconds)
stopSyncRecorder.assertions().isNeverCalled()
}
@Test
fun `when network is offline, sync service should not start`() = runTest {
val startSyncRecorder = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val syncService = FakeSyncService(initialSyncState = SyncState.Idle).apply {
startSyncLambda = startSyncRecorder
}
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected)
val syncOrchestrator = createSyncOrchestrator(
syncService = syncService,
networkMonitor = networkMonitor,
)
// We start observing
syncOrchestrator.observeStates()
// This should still not trigger a sync, since there is no network
advanceTimeBy(10.seconds)
startSyncRecorder.assertions().isNeverCalled()
}
private fun TestScope.createSyncOrchestrator(
syncService: FakeSyncService = FakeSyncService(),
networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(),
appForegroundStateService: FakeAppForegroundStateService = FakeAppForegroundStateService(),
) = SyncOrchestrator(
syncService = syncService,
sessionCoroutineScope = backgroundScope,
networkMonitor = networkMonitor,
appForegroundStateService = appForegroundStateService,
dispatchers = testCoroutineDispatchers(),
analyticsService = FakeAnalyticsService(),
)
}

View File

@@ -0,0 +1,137 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.di
import com.bumble.appyx.core.state.MutableSavedStateMapImpl
import com.google.common.truth.Truth.assertThat
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
class MatrixSessionCacheTest {
@Test
fun `test getOrNull`() = runTest {
val fakeAuthenticationService = FakeMatrixAuthenticationService()
val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
}
@Test
fun `test getSyncOrchestratorOrNull`() = runTest {
val fakeAuthenticationService = FakeMatrixAuthenticationService()
val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
// With no matrix client there is no sync orchestrator
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
assertThat(matrixSessionCache.getSyncOrchestrator(A_SESSION_ID)).isNull()
// But as soon as we receive a client, we can get the sync orchestrator
val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope)
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
assertThat(matrixSessionCache.getSyncOrchestrator(A_SESSION_ID)).isNotNull()
}
@Test
fun `test getOrRestore`() = runTest {
val fakeAuthenticationService = FakeMatrixAuthenticationService()
val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope)
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
// Do it again to hit the cache
assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
}
@Test
fun `test remove`() = runTest {
val fakeAuthenticationService = FakeMatrixAuthenticationService()
val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope)
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
// Remove
matrixSessionCache.remove(A_SESSION_ID)
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
}
@Test
fun `test remove all`() = runTest {
val fakeAuthenticationService = FakeMatrixAuthenticationService()
val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope)
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
// Remove all
matrixSessionCache.removeAll()
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
}
@Test
fun `test save and restore`() = runTest {
val fakeAuthenticationService = FakeMatrixAuthenticationService()
val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope)
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
matrixSessionCache.getOrRestore(A_SESSION_ID)
val savedStateMap = MutableSavedStateMapImpl { true }
matrixSessionCache.saveIntoSavedState(savedStateMap)
assertThat(savedStateMap.size).isEqualTo(1)
// Test Restore with non-empty map
matrixSessionCache.restoreWithSavedState(savedStateMap)
// Empty the map
matrixSessionCache.removeAll()
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
// Restore again
matrixSessionCache.restoreWithSavedState(savedStateMap)
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
}
@Test
fun `test AuthenticationService listenToNewMatrixClients emits a Client value and we save it`() = runTest {
val fakeAuthenticationService = FakeMatrixAuthenticationService()
val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory())
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
fakeAuthenticationService.givenMatrixClient(FakeMatrixClient(sessionId = A_SESSION_ID, sessionCoroutineScope = backgroundScope))
val loginSucceeded = fakeAuthenticationService.login("user", "pass")
assertThat(loginSucceeded.isSuccess).isTrue()
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNotNull()
}
private fun TestScope.createSyncOrchestratorFactory() = object : SyncOrchestrator.Factory {
override fun create(
syncService: SyncService,
sessionCoroutineScope: CoroutineScope,
): SyncOrchestrator {
return SyncOrchestrator(
syncService = syncService,
sessionCoroutineScope = sessionCoroutineScope,
appForegroundStateService = FakeAppForegroundStateService(),
networkMonitor = FakeNetworkMonitor(),
dispatchers = testCoroutineDispatchers(),
analyticsService = FakeAnalyticsService(),
)
}
}
}

View File

@@ -0,0 +1,313 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.intent
import android.app.Activity
import android.content.Intent
import android.net.Uri
import androidx.core.net.toUri
import com.google.common.truth.Truth.assertThat
import io.element.android.features.login.api.LoginParams
import io.element.android.features.login.test.FakeLoginIntentResolver
import io.element.android.libraries.deeplink.api.DeeplinkData
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_THREAD_ID
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.oidc.api.OidcAction
import io.element.android.libraries.oidc.test.FakeOidcIntentResolver
import io.element.android.tests.testutils.lambda.lambdaError
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
@RunWith(RobolectricTestRunner::class)
class IntentResolverTest {
@Test
fun `resolve launcher intent should return null`() {
val sut = createIntentResolver()
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_MAIN
addCategory(Intent.CATEGORY_LAUNCHER)
}
val result = sut.resolve(intent)
assertThat(result).isNull()
}
@Test
fun `test resolve navigation intent root`() {
val sut = createIntentResolver(
deeplinkParserResult = DeeplinkData.Root(A_SESSION_ID)
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW
}
val result = sut.resolve(intent)
assertThat(result).isEqualTo(
ResolvedIntent.Navigation(
deeplinkData = DeeplinkData.Root(
sessionId = A_SESSION_ID,
)
)
)
}
@Test
fun `test resolve navigation intent room`() {
val sut = createIntentResolver(
deeplinkParserResult = DeeplinkData.Room(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
threadId = null,
eventId = null,
)
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW
}
val result = sut.resolve(intent)
assertThat(result).isEqualTo(
ResolvedIntent.Navigation(
deeplinkData = DeeplinkData.Room(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
threadId = null,
eventId = null,
)
)
)
}
@Test
fun `test resolve navigation intent thread`() {
val sut = createIntentResolver(
deeplinkParserResult = DeeplinkData.Room(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
threadId = A_THREAD_ID,
eventId = null,
)
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW
}
val result = sut.resolve(intent)
assertThat(result).isEqualTo(
ResolvedIntent.Navigation(
deeplinkData = DeeplinkData.Room(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
threadId = A_THREAD_ID,
eventId = null,
)
)
)
}
@Test
fun `test resolve navigation intent event`() {
val sut = createIntentResolver(
deeplinkParserResult = DeeplinkData.Room(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
threadId = null,
eventId = AN_EVENT_ID,
)
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW
}
val result = sut.resolve(intent)
assertThat(result).isEqualTo(
ResolvedIntent.Navigation(
deeplinkData = DeeplinkData.Room(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
threadId = null,
eventId = AN_EVENT_ID,
)
)
)
}
@Test
fun `test resolve navigation intent thread and event`() {
val sut = createIntentResolver(
deeplinkParserResult = DeeplinkData.Room(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
threadId = A_THREAD_ID,
eventId = AN_EVENT_ID,
)
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW
}
val result = sut.resolve(intent)
assertThat(result).isEqualTo(
ResolvedIntent.Navigation(
deeplinkData = DeeplinkData.Room(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
threadId = A_THREAD_ID,
eventId = AN_EVENT_ID,
)
)
)
}
@Test
fun `test resolve oidc`() {
val sut = createIntentResolver(
oidcIntentResolverResult = { OidcAction.GoBack() },
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW
data = "io.element.android:/?error=access_denied&state=IFF1UETGye2ZA8pO".toUri()
}
val result = sut.resolve(intent)
assertThat(result).isEqualTo(
ResolvedIntent.Oidc(
oidcAction = OidcAction.GoBack()
)
)
}
@Test
fun `test resolve external permalink`() {
val permalinkData = PermalinkData.UserLink(
userId = UserId("@alice:matrix.org")
)
val sut = createIntentResolver(
loginIntentResolverResult = { null },
permalinkParserResult = { permalinkData },
oidcIntentResolverResult = { null },
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW
data = "https://matrix.to/#/@alice:matrix.org".toUri()
}
val result = sut.resolve(intent)
assertThat(result).isEqualTo(
ResolvedIntent.Permalink(
permalinkData = permalinkData
)
)
}
@Test
fun `test resolve external permalink, FallbackLink should be ignored`() {
val sut = createIntentResolver(
permalinkParserResult = { PermalinkData.FallbackLink(Uri.parse("https://matrix.org")) },
loginIntentResolverResult = { null },
oidcIntentResolverResult = { null },
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW
data = "https://matrix.to/#/@alice:matrix.org".toUri()
}
val result = sut.resolve(intent)
assertThat(result).isNull()
}
@Test
fun `test resolve external permalink, invalid action`() {
val permalinkData = PermalinkData.UserLink(
userId = UserId("@alice:matrix.org")
)
val sut = createIntentResolver(
permalinkParserResult = { permalinkData },
oidcIntentResolverResult = { null },
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_BATTERY_LOW
data = "https://matrix.to/invalid".toUri()
}
val result = sut.resolve(intent)
assertThat(result).isNull()
}
@Test
fun `test incoming share simple`() {
val sut = createIntentResolver(
oidcIntentResolverResult = { null },
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_SEND
}
val result = sut.resolve(intent)
assertThat(result).isEqualTo(ResolvedIntent.IncomingShare(intent = intent))
}
@Test
fun `test incoming share multiple`() {
val sut = createIntentResolver(
oidcIntentResolverResult = { null },
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_SEND_MULTIPLE
}
val result = sut.resolve(intent)
assertThat(result).isEqualTo(ResolvedIntent.IncomingShare(intent = intent))
}
@Test
fun `test resolve invalid`() {
val sut = createIntentResolver(
permalinkParserResult = { PermalinkData.FallbackLink(Uri.parse("https://matrix.org")) },
loginIntentResolverResult = { null },
oidcIntentResolverResult = { null },
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW
data = "io.element:/invalid".toUri()
}
val result = sut.resolve(intent)
assertThat(result).isNull()
}
@Test
fun `test resolve login param`() {
val aLoginParams = LoginParams("accountProvider", null)
val sut = createIntentResolver(
loginIntentResolverResult = { aLoginParams },
oidcIntentResolverResult = { null },
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW
data = "".toUri()
}
val result = sut.resolve(intent)
assertThat(result).isEqualTo(ResolvedIntent.Login(aLoginParams))
}
private fun createIntentResolver(
deeplinkParserResult: DeeplinkData? = null,
permalinkParserResult: (String) -> PermalinkData = { lambdaError() },
loginIntentResolverResult: (String) -> LoginParams? = { lambdaError() },
oidcIntentResolverResult: (Intent) -> OidcAction? = { lambdaError() },
): IntentResolver {
return IntentResolver(
deeplinkParser = { deeplinkParserResult },
loginIntentResolver = FakeLoginIntentResolver(
parseResult = loginIntentResolverResult,
),
oidcIntentResolver = FakeOidcIntentResolver(
resolveResult = oidcIntentResolverResult,
),
permalinkParser = FakePermalinkParser(
result = permalinkParserResult
),
)
}
}

View File

@@ -0,0 +1,62 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector 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.loggedin
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.CryptoSessionStateChange
import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class AnalyticsVerificationStateMappingTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `Test verification Mappings`() = runTest {
assertThat(SessionVerifiedStatus.Verified.toAnalyticsUserPropertyValue())
.isEqualTo(UserProperties.VerificationState.Verified)
assertThat(SessionVerifiedStatus.NotVerified.toAnalyticsUserPropertyValue())
.isEqualTo(UserProperties.VerificationState.NotVerified)
assertThat(SessionVerifiedStatus.Verified.toAnalyticsStateChangeValue())
.isEqualTo(CryptoSessionStateChange.VerificationState.Verified)
assertThat(SessionVerifiedStatus.NotVerified.toAnalyticsStateChangeValue())
.isEqualTo(CryptoSessionStateChange.VerificationState.NotVerified)
}
@Test
fun `Test recovery state Mappings`() = runTest {
assertThat(RecoveryState.UNKNOWN.toAnalyticsUserPropertyValue())
.isNull()
assertThat(RecoveryState.WAITING_FOR_SYNC.toAnalyticsUserPropertyValue())
.isNull()
assertThat(RecoveryState.INCOMPLETE.toAnalyticsUserPropertyValue())
.isEqualTo(UserProperties.RecoveryState.Incomplete)
assertThat(RecoveryState.ENABLED.toAnalyticsUserPropertyValue())
.isEqualTo(UserProperties.RecoveryState.Enabled)
assertThat(RecoveryState.DISABLED.toAnalyticsUserPropertyValue())
.isEqualTo(UserProperties.RecoveryState.Disabled)
assertThat(RecoveryState.UNKNOWN.toAnalyticsStateChangeValue())
.isNull()
assertThat(RecoveryState.WAITING_FOR_SYNC.toAnalyticsStateChangeValue())
.isNull()
assertThat(RecoveryState.INCOMPLETE.toAnalyticsStateChangeValue())
.isEqualTo(CryptoSessionStateChange.RecoveryState.Incomplete)
assertThat(RecoveryState.ENABLED.toAnalyticsStateChangeValue())
.isEqualTo(CryptoSessionStateChange.RecoveryState.Enabled)
assertThat(RecoveryState.DISABLED.toAnalyticsStateChangeValue())
.isEqualTo(CryptoSessionStateChange.RecoveryState.Disabled)
}
}

View File

@@ -0,0 +1,348 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.appnav.loggedin
import app.cash.turbine.ReceiveTurbine
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.CryptoSessionStateChange
import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.libraries.push.api.PushService
import io.element.android.libraries.push.api.PusherRegistrationFailure
import io.element.android.libraries.push.test.FakePushService
import io.element.android.libraries.pushproviders.api.Distributor
import io.element.android.libraries.pushproviders.api.PushProvider
import io.element.android.libraries.pushproviders.test.FakePushProvider
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class LoggedInPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
createLoggedInPresenter().test {
val initialState = awaitItem()
assertThat(initialState.showSyncSpinner).isFalse()
assertThat(initialState.pusherRegistrationState.isUninitialized()).isTrue()
assertThat(initialState.ignoreRegistrationError).isFalse()
}
}
@Test
fun `present - ensure that account urls are preloaded`() = runTest {
val accountManagementUrlResult = lambdaRecorder<AccountManagementAction?, Result<String?>> { Result.success("aUrl") }
val matrixClient = FakeMatrixClient(
accountManagementUrlResult = accountManagementUrlResult,
)
createLoggedInPresenter(
matrixClient = matrixClient,
).test {
awaitItem()
advanceUntilIdle()
accountManagementUrlResult.assertions().isCalledExactly(2)
.withSequence(
listOf(value(AccountManagementAction.Profile)),
listOf(value(AccountManagementAction.SessionsList)),
)
}
}
@Test
fun `present - show sync spinner`() = runTest {
val roomListService = FakeRoomListService()
createLoggedInPresenter(
syncState = SyncState.Running,
matrixClient = FakeMatrixClient(roomListService = roomListService),
).test {
val initialState = awaitItem()
assertThat(initialState.showSyncSpinner).isFalse()
roomListService.postSyncIndicator(RoomListService.SyncIndicator.Show)
consumeItemsUntilPredicate { it.showSyncSpinner }
roomListService.postSyncIndicator(RoomListService.SyncIndicator.Hide)
consumeItemsUntilPredicate { !it.showSyncSpinner }
}
}
@Test
fun `present - report crypto status analytics`() = runTest {
val analyticsService = FakeAnalyticsService()
val roomListService = FakeRoomListService()
val verificationService = FakeSessionVerificationService()
val encryptionService = FakeEncryptionService()
val buildMeta = aBuildMeta()
LoggedInPresenter(
matrixClient = FakeMatrixClient(
roomListService = roomListService,
encryptionService = encryptionService,
),
syncService = FakeSyncService(initialSyncState = SyncState.Running),
pushService = FakePushService(
ensurePusherIsRegisteredResult = { Result.success(Unit) },
),
sessionVerificationService = verificationService,
analyticsService = analyticsService,
encryptionService = encryptionService,
buildMeta = buildMeta,
).test {
encryptionService.emitRecoveryState(RecoveryState.UNKNOWN)
encryptionService.emitRecoveryState(RecoveryState.INCOMPLETE)
verificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
skipItems(2)
assertThat(analyticsService.capturedEvents.size).isEqualTo(1)
assertThat(analyticsService.capturedEvents[0]).isInstanceOf(CryptoSessionStateChange::class.java)
assertThat(analyticsService.capturedUserProperties.size).isEqualTo(1)
assertThat(analyticsService.capturedUserProperties[0].recoveryState).isEqualTo(UserProperties.RecoveryState.Incomplete)
assertThat(analyticsService.capturedUserProperties[0].verificationState).isEqualTo(UserProperties.VerificationState.Verified)
// ensure a sync status change does not trigger a new capture
roomListService.postSyncIndicator(RoomListService.SyncIndicator.Show)
skipItems(1)
assertThat(analyticsService.capturedEvents.size).isEqualTo(1)
}
}
@Test
fun `present - ensure default pusher is not registered if session is not verified`() = runTest {
val lambda = lambdaRecorder<Result<Unit>> {
Result.success(Unit)
}
val pushService = createFakePushService(ensurePusherIsRegisteredResult = lambda)
val verificationService = FakeSessionVerificationService(
initialSessionVerifiedStatus = SessionVerifiedStatus.NotVerified
)
createLoggedInPresenter(
pushService = pushService,
sessionVerificationService = verificationService,
).test {
val finalState = awaitFirstItem()
assertThat(finalState.pusherRegistrationState.errorOrNull())
.isInstanceOf(PusherRegistrationFailure.AccountNotVerified::class.java)
lambda.assertions().isNeverCalled()
}
}
@Test
fun `present - ensure default pusher is registered with default provider`() = runTest {
val lambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val sessionVerificationService = FakeSessionVerificationService(
initialSessionVerifiedStatus = SessionVerifiedStatus.Verified
)
val pushService = createFakePushService(
ensurePusherIsRegisteredResult = lambda,
)
createLoggedInPresenter(
pushService = pushService,
sessionVerificationService = sessionVerificationService,
matrixClient = FakeMatrixClient(
accountManagementUrlResult = { Result.success(null) },
),
).test {
val finalState = awaitFirstItem()
assertThat(finalState.pusherRegistrationState.isSuccess()).isTrue()
lambda.assertions()
.isCalledOnce()
}
}
@Test
fun `present - ensure default pusher is registered with default provider - fail to register`() = runTest {
val lambda = lambdaRecorder<Result<Unit>> { Result.failure(AN_EXCEPTION) }
val sessionVerificationService = FakeSessionVerificationService(
initialSessionVerifiedStatus = SessionVerifiedStatus.Verified
)
val pushService = createFakePushService(
ensurePusherIsRegisteredResult = lambda,
)
createLoggedInPresenter(
pushService = pushService,
sessionVerificationService = sessionVerificationService,
matrixClient = FakeMatrixClient(
accountManagementUrlResult = { Result.success(null) },
),
).test {
val finalState = awaitFirstItem()
assertThat(finalState.pusherRegistrationState.isFailure()).isTrue()
lambda.assertions()
.isCalledOnce()
// Reset the error and do not show again
finalState.eventSink(LoggedInEvents.CloseErrorDialog(doNotShowAgain = false))
val lastState = awaitItem()
assertThat(lastState.pusherRegistrationState.isUninitialized()).isTrue()
assertThat(lastState.ignoreRegistrationError).isFalse()
}
}
@Test
fun `present - ensure default pusher is registered with default provider - fail to register - do not show again`() = runTest {
val lambda = lambdaRecorder<Result<Unit>> { Result.failure(AN_EXCEPTION) }
val setIgnoreRegistrationErrorLambda = lambdaRecorder<SessionId, Boolean, Unit> { _, _ -> }
val sessionVerificationService = FakeSessionVerificationService(
initialSessionVerifiedStatus = SessionVerifiedStatus.Verified
)
val pushService = createFakePushService(
ensurePusherIsRegisteredResult = lambda,
setIgnoreRegistrationErrorLambda = setIgnoreRegistrationErrorLambda,
)
createLoggedInPresenter(
pushService = pushService,
sessionVerificationService = sessionVerificationService,
matrixClient = FakeMatrixClient(
accountManagementUrlResult = { Result.success(null) },
),
).test {
val finalState = awaitFirstItem()
assertThat(finalState.pusherRegistrationState.isFailure()).isTrue()
lambda.assertions()
.isCalledOnce()
// Reset the error and do not show again
finalState.eventSink(LoggedInEvents.CloseErrorDialog(doNotShowAgain = true))
skipItems(1)
setIgnoreRegistrationErrorLambda.assertions()
.isCalledOnce()
.with(
// SessionId
value(A_SESSION_ID),
// Ignore
value(true),
)
val lastState = awaitItem()
assertThat(lastState.pusherRegistrationState.isUninitialized()).isTrue()
assertThat(lastState.ignoreRegistrationError).isTrue()
}
}
private fun createFakePushService(
pushProvider0: PushProvider? = FakePushProvider(
index = 0,
name = "aFakePushProvider0",
distributors = listOf(Distributor("aDistributorValue0", "aDistributorName0")),
currentDistributor = { null },
),
pushProvider1: PushProvider? = FakePushProvider(
index = 1,
name = "aFakePushProvider1",
distributors = listOf(Distributor("aDistributorValue1", "aDistributorName1")),
currentDistributor = { null },
),
ensurePusherIsRegisteredResult: () -> Result<Unit> = {
Result.success(Unit)
},
selectPushProviderLambda: (SessionId, PushProvider) -> Unit = { _, _ -> lambdaError() },
currentPushProvider: (SessionId) -> PushProvider? = { null },
setIgnoreRegistrationErrorLambda: (SessionId, Boolean) -> Unit = { _, _ -> lambdaError() },
): PushService {
return FakePushService(
availablePushProviders = listOfNotNull(pushProvider0, pushProvider1),
ensurePusherIsRegisteredResult = ensurePusherIsRegisteredResult,
currentPushProvider = currentPushProvider,
selectPushProviderLambda = selectPushProviderLambda,
setIgnoreRegistrationErrorLambda = setIgnoreRegistrationErrorLambda,
)
}
@Test
fun `present - CheckSlidingSyncProxyAvailability forces the sliding sync migration under the right circumstances`() = runTest {
// The migration will be forced if the user is not using the native sliding sync
val matrixClient = FakeMatrixClient(
currentSlidingSyncVersionLambda = { Result.success(SlidingSyncVersion.Proxy) },
)
createLoggedInPresenter(
matrixClient = matrixClient,
).test {
val initialState = awaitItem()
assertThat(initialState.forceNativeSlidingSyncMigration).isFalse()
initialState.eventSink(LoggedInEvents.CheckSlidingSyncProxyAvailability)
assertThat(awaitItem().forceNativeSlidingSyncMigration).isTrue()
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - LogoutAndMigrateToNativeSlidingSync logs out the user`() = runTest {
val logoutLambda = lambdaRecorder<Boolean, Boolean, Unit> { userInitiated, ignoreSdkError ->
assertThat(userInitiated).isTrue()
assertThat(ignoreSdkError).isTrue()
}
val matrixClient = FakeMatrixClient(
accountManagementUrlResult = { Result.success(null) },
).apply {
this.logoutLambda = logoutLambda
}
createLoggedInPresenter(
matrixClient = matrixClient,
).test {
val initialState = awaitItem()
initialState.eventSink(LoggedInEvents.LogoutAndMigrateToNativeSlidingSync)
advanceUntilIdle()
assertThat(logoutLambda.assertions().isCalledOnce())
}
}
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
skipItems(1)
return awaitItem()
}
private fun createLoggedInPresenter(
syncState: SyncState = SyncState.Running,
analyticsService: AnalyticsService = FakeAnalyticsService(),
sessionVerificationService: SessionVerificationService = FakeSessionVerificationService(),
encryptionService: EncryptionService = FakeEncryptionService(),
pushService: PushService = FakePushService(),
matrixClient: MatrixClient = FakeMatrixClient(
accountManagementUrlResult = { Result.success(null) },
),
buildMeta: BuildMeta = aBuildMeta(),
): LoggedInPresenter {
return LoggedInPresenter(
matrixClient = matrixClient,
syncService = FakeSyncService(initialSyncState = syncState),
pushService = pushService,
sessionVerificationService = sessionVerificationService,
analyticsService = analyticsService,
encryptionService = encryptionService,
buildMeta = buildMeta,
)
}
}

View File

@@ -0,0 +1,163 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
@file:Suppress("DEPRECATION")
package io.element.android.appnav.loggedin
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.media.MediaPreviewConfig
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
import io.element.android.libraries.matrix.test.media.FakeMediaPreviewService
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
class MediaPreviewConfigMigrationTest {
@Test
fun `when no local data exists, migration does nothing`() = runTest {
val appPreferencesStore = InMemoryAppPreferencesStore()
val mediaPreviewService = FakeMediaPreviewService(
fetchMediaPreviewConfigResult = { Result.success(null) }
)
val migration = createMigration(appPreferencesStore, mediaPreviewService)
migration().join()
// Verify no calls were made to set server config
// since there's nothing to migrate
}
@Test
fun `when local data exists and server has config, clears local data`() = runTest {
val appPreferencesStore = InMemoryAppPreferencesStore().apply {
setHideInviteAvatars(true)
setTimelineMediaPreviewValue(MediaPreviewValue.Private)
}
val serverConfig = MediaPreviewConfig(
hideInviteAvatar = false,
mediaPreviewValue = MediaPreviewValue.On
)
val mediaPreviewService = FakeMediaPreviewService(
fetchMediaPreviewConfigResult = { Result.success(serverConfig) }
)
val migration = createMigration(appPreferencesStore, mediaPreviewService)
migration().join()
// Verify local data was cleared
assertThat(appPreferencesStore.getHideInviteAvatarsFlow().first()).isNull()
assertThat(appPreferencesStore.getTimelineMediaPreviewValueFlow().first()).isNull()
}
@Test
fun `when local hideInviteAvatars exists and server has no config, migrates to server`() = runTest {
val appPreferencesStore = InMemoryAppPreferencesStore().apply {
setHideInviteAvatars(true)
}
var setHideInviteAvatarsValue: Boolean? = null
val mediaPreviewService = FakeMediaPreviewService(
fetchMediaPreviewConfigResult = { Result.success(null) },
setHideInviteAvatarsResult = { value ->
setHideInviteAvatarsValue = value
Result.success(Unit)
}
)
val migration = createMigration(appPreferencesStore, mediaPreviewService)
migration().join()
// Verify server was updated with local value
assertThat(setHideInviteAvatarsValue).isTrue()
// Verify local data was cleared
assertThat(appPreferencesStore.getHideInviteAvatarsFlow().first()).isNull()
}
@Test
fun `when local mediaPreviewValue exists and server has no config, migrates to server`() = runTest {
val appPreferencesStore = InMemoryAppPreferencesStore().apply {
setTimelineMediaPreviewValue(MediaPreviewValue.Private)
}
var setMediaPreviewValue: MediaPreviewValue? = null
val mediaPreviewService = FakeMediaPreviewService(
fetchMediaPreviewConfigResult = { Result.success(null) },
setMediaPreviewValueResult = { value ->
setMediaPreviewValue = value
Result.success(Unit)
}
)
val migration = createMigration(appPreferencesStore, mediaPreviewService)
migration().join()
// Verify server was updated with local value
assertThat(setMediaPreviewValue).isEqualTo(MediaPreviewValue.Private)
// Verify local data was cleared
assertThat(appPreferencesStore.getTimelineMediaPreviewValueFlow().first()).isNull()
}
@Test
fun `when both local values exist and server has no config, migrates both to server`() = runTest {
val appPreferencesStore = InMemoryAppPreferencesStore().apply {
setHideInviteAvatars(true)
setTimelineMediaPreviewValue(MediaPreviewValue.Off)
}
var setHideInviteAvatarsValue: Boolean? = null
var setMediaPreviewValue: MediaPreviewValue? = null
val mediaPreviewService = FakeMediaPreviewService(
fetchMediaPreviewConfigResult = { Result.success(null) },
setHideInviteAvatarsResult = { value ->
setHideInviteAvatarsValue = value
Result.success(Unit)
},
setMediaPreviewValueResult = { value ->
setMediaPreviewValue = value
Result.success(Unit)
}
)
val migration = createMigration(appPreferencesStore, mediaPreviewService)
migration().join()
// Verify server was updated with both local values
assertThat(setHideInviteAvatarsValue).isTrue()
assertThat(setMediaPreviewValue).isEqualTo(MediaPreviewValue.Off)
// Verify local data was cleared
assertThat(appPreferencesStore.getHideInviteAvatarsFlow().first()).isNull()
assertThat(appPreferencesStore.getTimelineMediaPreviewValueFlow().first()).isNull()
}
@Test
fun `when fetch config fails, migration does nothing`() = runTest {
val appPreferencesStore = InMemoryAppPreferencesStore().apply {
setHideInviteAvatars(true)
setTimelineMediaPreviewValue(MediaPreviewValue.Private)
}
val mediaPreviewService = FakeMediaPreviewService(
fetchMediaPreviewConfigResult = { Result.failure(Exception("Network error")) }
)
val migration = createMigration(appPreferencesStore, mediaPreviewService)
migration().join()
// Verify local data was not cleared since migration failed
assertThat(appPreferencesStore.getHideInviteAvatarsFlow().first()).isTrue()
assertThat(appPreferencesStore.getTimelineMediaPreviewValueFlow().first()).isEqualTo(MediaPreviewValue.Private)
}
private fun TestScope.createMigration(
appPreferencesStore: InMemoryAppPreferencesStore,
mediaPreviewService: FakeMediaPreviewService
) = MediaPreviewConfigMigration(
mediaPreviewService = mediaPreviewService,
appPreferencesStore = appPreferencesStore,
sessionCoroutineScope = this
)
}

View File

@@ -0,0 +1,79 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector 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.loggedin
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class SendQueuesTest {
private val matrixClient = FakeMatrixClient()
private val syncService = FakeSyncService(initialSyncState = SyncState.Running)
private val sut = SendQueues(matrixClient, syncService)
@Test
fun `test network status online and sending queue failed`() = runTest {
val sendQueueDisabledFlow = MutableSharedFlow<RoomId>(replay = 1)
val setAllSendQueuesEnabledLambda = lambdaRecorder { _: Boolean -> }
matrixClient.sendQueueDisabledFlow = sendQueueDisabledFlow
matrixClient.setAllSendQueuesEnabledLambda = setAllSendQueuesEnabledLambda
val setRoomSendQueueEnabledLambda = lambdaRecorder { _: Boolean -> }
val room = FakeJoinedRoom(
setSendQueueEnabledResult = setRoomSendQueueEnabledLambda
)
matrixClient.givenGetRoomResult(room.roomId, room)
sut.launchIn(backgroundScope)
sendQueueDisabledFlow.emit(room.roomId)
advanceTimeBy(SEND_QUEUES_RETRY_DELAY_MILLIS)
runCurrent()
assert(setAllSendQueuesEnabledLambda)
.isCalledOnce()
.with(value(true))
assert(setRoomSendQueueEnabledLambda).isNeverCalled()
}
@Test
fun `test sync state offline and sending queue failed`() = runTest {
val sendQueueDisabledFlow = MutableSharedFlow<RoomId>(replay = 1)
val setAllSendQueuesEnabledLambda = lambdaRecorder { _: Boolean -> }
matrixClient.sendQueueDisabledFlow = sendQueueDisabledFlow
matrixClient.setAllSendQueuesEnabledLambda = setAllSendQueuesEnabledLambda
syncService.emitSyncState(SyncState.Offline)
val setRoomSendQueueEnabledLambda = lambdaRecorder { _: Boolean -> }
val room = FakeJoinedRoom(
setSendQueueEnabledResult = setRoomSendQueueEnabledLambda
)
matrixClient.givenGetRoomResult(room.roomId, room)
sut.launchIn(backgroundScope)
sendQueueDisabledFlow.emit(room.roomId)
advanceTimeBy(SEND_QUEUES_RETRY_DELAY_MILLIS)
runCurrent()
assert(setAllSendQueuesEnabledLambda).isNeverCalled()
assert(setRoomSendQueueEnabledLambda).isNeverCalled()
}
}

View File

@@ -0,0 +1,83 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.room
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.ui.room.LoadingRoomState
import io.element.android.libraries.matrix.ui.room.LoadingRoomStateFlowFactory
import kotlinx.coroutines.test.runTest
import org.junit.Test
class LoadingBaseRoomStateFlowFactoryTest {
@Test
fun `flow should emit only Loaded when we already pass a JoinedRoom`() = runTest {
val room = FakeJoinedRoom(baseRoom = FakeBaseRoom(sessionId = A_SESSION_ID, roomId = A_ROOM_ID))
val matrixClient = FakeMatrixClient(A_SESSION_ID)
val flowFactory = LoadingRoomStateFlowFactory(matrixClient)
flowFactory
.create(lifecycleScope = this, roomId = A_ROOM_ID, joinedRoom = room)
.test {
assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loaded(room))
ensureAllEventsConsumed()
}
}
@Test
fun `flow should emit Loading and then Loaded when there is a room in cache`() = runTest {
val room = FakeJoinedRoom(baseRoom = FakeBaseRoom(sessionId = A_SESSION_ID, roomId = A_ROOM_ID))
val matrixClient = FakeMatrixClient(A_SESSION_ID).apply {
givenGetRoomResult(A_ROOM_ID, room)
}
val flowFactory = LoadingRoomStateFlowFactory(matrixClient)
flowFactory
.create(lifecycleScope = this, roomId = A_ROOM_ID, joinedRoom = null)
.test {
assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading)
assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loaded(room))
}
}
@Test
fun `flow should emit Loading and then Loaded when there is a room in cache after SS is loaded`() = runTest {
val room = FakeJoinedRoom(baseRoom = FakeBaseRoom(sessionId = A_SESSION_ID, roomId = A_ROOM_ID))
val roomListService = FakeRoomListService()
val matrixClient = FakeMatrixClient(A_SESSION_ID, roomListService = roomListService)
val flowFactory = LoadingRoomStateFlowFactory(matrixClient)
flowFactory
.create(lifecycleScope = this, roomId = A_ROOM_ID, joinedRoom = null)
.test {
assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading)
matrixClient.givenGetRoomResult(A_ROOM_ID, room)
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loaded(room))
}
}
@Test
fun `flow should emit Loading and then Error when there is no room in cache after SS is loaded`() = runTest {
val roomListService = FakeRoomListService()
val matrixClient = FakeMatrixClient(A_SESSION_ID, roomListService = roomListService)
val flowFactory = LoadingRoomStateFlowFactory(matrixClient)
flowFactory
.create(lifecycleScope = this, roomId = A_ROOM_ID, joinedRoom = null)
.test {
assertThat(awaitItem()).isEqualTo(LoadingRoomState.Loading)
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
assertThat(awaitItem()).isEqualTo(LoadingRoomState.Error)
}
}
}

View File

@@ -0,0 +1,19 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector 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.room.joined
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.tests.testutils.lambda.lambdaError
class FakeJoinedRoomLoadedFlowNodeCallback : JoinedRoomLoadedFlowNode.Callback {
override fun navigateToRoom(roomId: RoomId, serverNames: List<String>) = lambdaError()
override fun handlePermalinkClick(data: PermalinkData, pushToBackstack: Boolean) = lambdaError()
override fun navigateToGlobalNotificationSettings() = lambdaError()
}