Merge branch 'develop' into feature/fga/timeline-multi-forward

This commit is contained in:
ganfra
2026-10-08 17:38:14 +02:00
432 changed files with 4950 additions and 2279 deletions
-1
View File
@@ -44,7 +44,6 @@ dependencies {
implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.uiCommon)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.wellknown.api)
implementation(projects.features.login.api)
implementation(libs.coil)
@@ -33,7 +33,9 @@ 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 io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder
import kotlinx.parcelize.Parcelize
/**
@@ -59,6 +61,7 @@ class LoggedInAppScopeFlowNode(
interface Callback : Plugin {
fun navigateToBugReport()
fun navigateToAddAccount()
fun switchAccountAndOpenRoom(sessionId: SessionId, roomId: RoomId?)
}
private val callback: Callback = callback()
@@ -91,6 +94,10 @@ class LoggedInAppScopeFlowNode(
override fun navigateToAddAccount() {
callback.navigateToAddAccount()
}
override fun switchAccountAndOpenRoom(sessionId: SessionId, roomId: RoomId?) {
callback.switchAccountAndOpenRoom(sessionId, roomId)
}
}
return createNode<LoggedInFlowNode>(buildContext, listOf(callback))
}
@@ -8,28 +8,51 @@
package io.element.android.appnav
import dev.zacsweers.metro.Inject
import androidx.annotation.VisibleForTesting
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.childScope
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.core.SessionId
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
import io.element.android.libraries.push.api.notifications.NotificationCleaner
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@Inject
@AssistedInject
class LoggedInEventProcessor(
private val snackbarDispatcher: SnackbarDispatcher,
@Assisted private val snackbarDispatcher: SnackbarDispatcher,
private val roomMembershipObserver: RoomMembershipObserver,
private val sessionId: SessionId,
private val roomListService: RoomListService,
private val notificationCleaner: NotificationCleaner,
private val dispatchers: CoroutineDispatchers
) {
private var observingJob: Job? = null
@AssistedFactory
interface Factory {
fun create(snackbarDispatcher: SnackbarDispatcher): LoggedInEventProcessor
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal var currentChildScope: CoroutineScope? = null
fun observeEvents(coroutineScope: CoroutineScope) {
observingJob = roomMembershipObserver.updates
if (currentChildScope != null) return
val childScope = coroutineScope.childScope(dispatchers.computation, "LoggedInEventProcessor")
.also { currentChildScope = it }
roomMembershipObserver.updates
.filter { !it.isUserInRoom }
.distinctUntilChanged()
.onEach { roomMemberShipUpdate ->
@@ -48,12 +71,20 @@ class LoggedInEventProcessor(
else -> Unit
}
}
.launchIn(coroutineScope)
.launchIn(childScope)
// Use the room list service state as a 'heartbeat' update to check existing notifications
roomListService.state
.filter { it == RoomListService.State.Running }
.onEach {
notificationCleaner.clearReadRoomsNotifications(sessionId)
}
.launchIn(childScope)
}
fun stopObserving() {
observingJob?.cancel()
observingJob = null
currentChildScope?.cancel()
currentChildScope = null
}
private fun displayMessage(message: Int) {
@@ -88,6 +88,7 @@ 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.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.SessionId
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
@@ -157,6 +158,7 @@ class LoggedInFlowNode(
private val createRoomEntryPoint: CreateRoomEntryPoint,
private val activeLiveLocationShareManager: ActiveLiveLocationShareManager,
private val customMapTilerConfigProvider: CustomMapTilerConfigProvider,
private val loggedInEventProcessorFactory: LoggedInEventProcessor.Factory,
) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Placeholder,
@@ -172,13 +174,11 @@ class LoggedInFlowNode(
interface Callback : Plugin {
fun navigateToBugReport()
fun navigateToAddAccount()
fun switchAccountAndOpenRoom(sessionId: SessionId, roomId: RoomId?)
}
private val callback: Callback = callback()
private val loggedInFlowProcessor = LoggedInEventProcessor(
snackbarDispatcher = snackbarDispatcher,
roomMembershipObserver = matrixClient.roomMembershipObserver,
)
private val loggedInFlowProcessor = loggedInEventProcessorFactory.create(snackbarDispatcher)
/**
* Display the incoming verification request, the app is expected to be in foreground.
@@ -361,6 +361,10 @@ class LoggedInFlowNode(
override fun navigateToBugReport() {
callback.navigateToBugReport()
}
override fun navigateToAddAccount() {
callback.navigateToAddAccount()
}
}
homeEntryPoint.createNode(
parentNode = this,
@@ -572,10 +576,16 @@ class LoggedInFlowNode(
buildContext = buildContext,
params = ShareEntryPoint.Params(shareIntentData = navTarget.shareIntentData),
callback = object : ShareEntryPoint.Callback {
override fun onDone(roomIds: List<RoomId>) {
override fun onDone(sessionId: SessionId, roomIds: List<RoomId>) {
// Remove the incoming share screen
backstack.pop()
if (sessionId != matrixClient.sessionId) {
// The data has been shared using another session, switch to it
callback.switchAccountAndOpenRoom(sessionId, roomIds.singleOrNull())
return
}
// Navigate to the room if the text/media was shared to a single one
roomIds.singleOrNull()?.let { roomId ->
lifecycleScope.launch {
@@ -13,6 +13,7 @@ package io.element.android.appnav
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import coil3.SingletonImageLoader
import coil3.annotation.DelicateCoilApi
import com.bumble.appyx.core.lifecycle.subscribe
@@ -24,6 +25,8 @@ 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.announcement.api.Announcement
import io.element.android.features.announcement.api.AnnouncementService
import io.element.android.features.login.api.LoginEntryPoint
import io.element.android.features.login.api.LoginParams
import io.element.android.libraries.architecture.BackstackView
@@ -33,8 +36,11 @@ 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.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder
import io.element.android.services.analytics.api.watchers.AnalyticsColdStartWatcher
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
@ContributesNode(AppScope::class)
@@ -45,6 +51,8 @@ class NotLoggedInFlowNode(
private val loginEntryPoint: LoginEntryPoint,
private val imageLoaderHolder: ImageLoaderHolder,
private val analyticsColdStartWatcher: AnalyticsColdStartWatcher,
private val featureFlagService: FeatureFlagService,
private val announcementService: AnnouncementService,
) : BaseFlowNode<NotLoggedInFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
@@ -68,6 +76,13 @@ class NotLoggedInFlowNode(
override fun onBuilt() {
super.onBuilt()
analyticsColdStartWatcher.whenLoggingIn()
lifecycleScope.launch {
// Users who enter the login flow while the feature is available, either for the first time or to add
// another account, do not need to be informed about the multi-account feature.
if (featureFlagService.isFeatureEnabled(FeatureFlags.MultiAccount)) {
announcementService.onAnnouncementDismissed(Announcement.MultiAccount)
}
}
lifecycle.subscribe(
onResume = {
SingletonImageLoader.setUnsafe(imageLoaderHolder.get())
@@ -62,6 +62,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
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.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.asEventId
@@ -349,8 +350,7 @@ class RootFlowNode(
@Parcelize data class AccountSelect(
val currentSessionId: SessionId,
val shareIntentData: ShareIntentData?,
val permalinkData: PermalinkData?,
val permalinkData: PermalinkData,
) : NavTarget
@Parcelize data class NotLoggedInFlow(
@@ -389,6 +389,15 @@ class RootFlowNode(
override fun navigateToAddAccount() {
backstack.push(NavTarget.NotLoggedInFlow(null))
}
override fun switchAccountAndOpenRoom(sessionId: SessionId, roomId: RoomId?) {
lifecycleScope.launch {
val loggedInFlowNode = attachSession(sessionId)
roomId?.let {
loggedInFlowNode.attachRoom(it.toRoomIdOrAlias(), clearBackstack = false)
}
}
}
}
val savedNavState = extractSavedStateForNavTarget(navTarget, this.buildContext.savedStateMap)
val buildContext = if (savedNavState != null) {
@@ -463,13 +472,7 @@ class RootFlowNode(
// Do not pop when the account is changed to avoid a UI flicker.
backstack.pop()
}
attachSession(sessionId).apply {
if (navTarget.shareIntentData != null) {
attachIncomingShare(navTarget.shareIntentData)
} else if (navTarget.permalinkData != null) {
attachPermalinkData(navTarget.permalinkData)
}
}
attachSession(sessionId).attachPermalinkData(navTarget.permalinkData)
}
}
@@ -539,21 +542,9 @@ class RootFlowNode(
// 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,
shareIntentData = shareIntentData,
permalinkData = null,
)
)
} else {
// Only one account, directly attach the incoming share node.
loggedInFlowNode.attachIncomingShare(shareIntentData)
}
// wait for the current session to be restored, then attach the incoming share node.
// In case of multiple accounts, the user can switch account from the room select screen.
attachSession(latestSessionId).attachIncomingShare(shareIntentData)
}
}
@@ -576,7 +567,6 @@ class RootFlowNode(
backstack.push(
NavTarget.AccountSelect(
currentSessionId = latestSessionId,
shareIntentData = null,
permalinkData = permalinkData,
)
)
@@ -28,8 +28,8 @@ 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.roomaliasresolver.api.RoomAliasResolverEntryPoint
import io.element.android.features.roomaliasresolver.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
@@ -13,7 +13,7 @@ import com.bumble.appyx.core.state.SavedStateMap
import dev.zacsweers.metro.Inject
import io.element.android.appnav.session.MatrixSessionCache
import io.element.android.features.preferences.api.CacheService
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder
import io.element.android.libraries.preferences.api.store.SessionPreferencesStoreFactory
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.flow.Flow
@@ -0,0 +1,197 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.appnav
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.matrix.api.core.SessionId
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.roomlist.RoomListService
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.roomlist.FakeRoomListService
import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
class LoggedInEventProcessorTest {
@Test
fun `test observeEvents with left membership change`() = runTest {
val snackbarDispatcher = SnackbarDispatcher()
val roomMembershipObserver = RoomMembershipObserver()
val loggedInEventProcessor = createLoggedInEventProcessor(
snackbarDispatcher = snackbarDispatcher,
roomMembershipObserver = roomMembershipObserver,
)
loggedInEventProcessor.observeEvents(backgroundScope)
testScheduler.runCurrent()
roomMembershipObserver.notifyUserLeftRoom(
roomId = A_ROOM_ID,
isSpace = false,
membershipBeforeLeft = CurrentUserMembership.JOINED,
)
testScheduler.runCurrent()
// Verify that the snackbar message was displayed
assertThat(snackbarDispatcher.snackbarMessage.first()).isNotNull()
}
@Test
fun `test observeEvents with left membership change for space`() = runTest {
val snackbarDispatcher = SnackbarDispatcher()
val roomMembershipObserver = RoomMembershipObserver()
val loggedInEventProcessor = createLoggedInEventProcessor(
snackbarDispatcher = snackbarDispatcher,
roomMembershipObserver = roomMembershipObserver,
)
loggedInEventProcessor.observeEvents(backgroundScope)
testScheduler.runCurrent()
roomMembershipObserver.notifyUserLeftRoom(
roomId = A_ROOM_ID,
isSpace = true,
membershipBeforeLeft = CurrentUserMembership.JOINED,
)
testScheduler.runCurrent()
// Verify that the snackbar message was displayed
assertThat(snackbarDispatcher.snackbarMessage.first()).isNotNull()
}
@Test
fun `test observeEvents with invitation rejected membership change`() = runTest {
val snackbarDispatcher = SnackbarDispatcher()
val roomMembershipObserver = RoomMembershipObserver()
val loggedInEventProcessor = createLoggedInEventProcessor(
snackbarDispatcher = snackbarDispatcher,
roomMembershipObserver = roomMembershipObserver,
)
loggedInEventProcessor.observeEvents(backgroundScope)
testScheduler.runCurrent()
roomMembershipObserver.notifyUserLeftRoom(
roomId = A_ROOM_ID,
isSpace = false,
membershipBeforeLeft = CurrentUserMembership.INVITED,
)
testScheduler.runCurrent()
// Verify that the snackbar message was displayed
assertThat(snackbarDispatcher.snackbarMessage.first()).isNotNull()
}
@Test
fun `test observeEvents with knock retracted membership change`() = runTest {
val snackbarDispatcher = SnackbarDispatcher()
val roomMembershipObserver = RoomMembershipObserver()
val loggedInEventProcessor = createLoggedInEventProcessor(
snackbarDispatcher = snackbarDispatcher,
roomMembershipObserver = roomMembershipObserver,
)
loggedInEventProcessor.observeEvents(backgroundScope)
testScheduler.runCurrent()
roomMembershipObserver.notifyUserLeftRoom(
roomId = A_ROOM_ID,
isSpace = false,
membershipBeforeLeft = CurrentUserMembership.KNOCKED,
)
testScheduler.runCurrent()
// Verify that the snackbar message was displayed
assertThat(snackbarDispatcher.snackbarMessage.first()).isNotNull()
}
@Test
fun `test stopObserving cancels the child scope`() = runTest {
val snackbarDispatcher = SnackbarDispatcher()
val roomMembershipObserver = RoomMembershipObserver()
val loggedInEventProcessor = createLoggedInEventProcessor(
snackbarDispatcher = snackbarDispatcher,
roomMembershipObserver = roomMembershipObserver,
)
loggedInEventProcessor.observeEvents(backgroundScope)
assertThat(loggedInEventProcessor.currentChildScope?.isActive).isTrue()
testScheduler.runCurrent()
// Stop observing events
loggedInEventProcessor.stopObserving()
// Verify that the child scope is cancelled (the scope is null)
assertThat(loggedInEventProcessor.currentChildScope).isNull()
}
@Test
fun `test notifications are cleared when room list service state is running`() = runTest {
val clearReadNotificationsLambda = lambdaRecorder { _: SessionId -> }
val snackbarDispatcher = SnackbarDispatcher()
val notificationCleaner = FakeNotificationCleaner(clearReadRoomsNotificationsLambda = clearReadNotificationsLambda)
val roomListService = FakeRoomListService()
val loggedInEventProcessor = createLoggedInEventProcessor(
snackbarDispatcher = snackbarDispatcher,
notificationCleaner = notificationCleaner,
roomListService = roomListService,
)
loggedInEventProcessor.observeEvents(backgroundScope)
testScheduler.runCurrent()
// Simulate the room list service state changing to Running
roomListService.postState(RoomListService.State.Running)
testScheduler.runCurrent()
// Verify that the notifications were cleared
clearReadNotificationsLambda.assertions().isCalledOnce().with(value(A_SESSION_ID))
}
private fun TestScope.createLoggedInEventProcessor(
snackbarDispatcher: SnackbarDispatcher,
roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(),
sessionId: SessionId = A_SESSION_ID,
roomListService: FakeRoomListService = FakeRoomListService(),
notificationCleaner: FakeNotificationCleaner = FakeNotificationCleaner(),
) = LoggedInEventProcessor(
snackbarDispatcher = snackbarDispatcher,
roomMembershipObserver = roomMembershipObserver,
sessionId = sessionId,
roomListService = roomListService,
notificationCleaner = notificationCleaner,
dispatchers = testCoroutineDispatchers(),
)
}
@@ -26,6 +26,7 @@ import io.element.android.appnav.root.RootPresenter
import io.element.android.appnav.session.FakeSyncOrchestratorFactory
import io.element.android.appnav.session.MatrixSessionCache
import io.element.android.appnav.verification.IncomingVerificationRequestObserver
import io.element.android.features.announcement.test.FakeAnnouncementService
import io.element.android.features.login.api.LoginEntryPoint
import io.element.android.features.login.api.LoginParams
import io.element.android.features.login.test.FakeLoginEntryPoint
@@ -33,7 +34,6 @@ import io.element.android.features.login.test.FakeLoginIntentResolver
import io.element.android.features.login.test.accesscontrol.FakeAccountProviderAccessControl
import io.element.android.features.preferences.test.FakeCacheService
import io.element.android.features.rageshake.test.FakeBugReportEntryPoint
import io.element.android.features.rageshake.test.logs.FakeAnnouncementService
import io.element.android.features.share.test.FakeShareIntentHandler
import io.element.android.features.signedout.test.FakeSignedOutEntryPoint
import io.element.android.libraries.accountselect.test.FakeAccountSelectEntryPoint
@@ -42,7 +42,7 @@ import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.test.FakeSdkMetadata
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder
import io.element.android.libraries.matrixmedia.test.FakeImageLoaderHolder
import io.element.android.libraries.oauth.test.FakeOAuthActionFlow
import io.element.android.libraries.oauth.test.FakeOAuthIntentResolver
import io.element.android.libraries.preferences.test.FakeSessionPreferencesStoreFactory
@@ -141,6 +141,8 @@ class RootFlowNodeTest : RobolectricTest() {
loginEntryPoint = loginEntryPoint,
imageLoaderHolder = FakeImageLoaderHolder(),
analyticsColdStartWatcher = FakeAnalyticsColdStartWatcher(),
featureFlagService = FakeFeatureFlagService(),
announcementService = FakeAnnouncementService(),
)
}
)