mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 12:07:28 +00:00
Merge branch 'develop' into feature/fga/timeline-multi-forward
This commit is contained in:
@@ -93,7 +93,6 @@ 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
|
||||
@@ -111,14 +110,10 @@ 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.milliseconds
|
||||
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
|
||||
|
||||
// The maximum number of room nodes that should be kept in the backstack at the same time.
|
||||
@@ -185,36 +180,20 @@ class LoggedInFlowNode(
|
||||
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
|
||||
/**
|
||||
* Display the incoming verification request, the app is expected to be in foreground.
|
||||
*/
|
||||
fun onIncomingVerificationRequest(verificationRequest: VerificationRequest.Incoming) {
|
||||
lifecycleScope.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 }
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
|
||||
backstack.singleTop(NavTarget.IncomingVerificationRequest(verificationRequest))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,7 +208,6 @@ class LoggedInFlowNode(
|
||||
analyticsRoomListStateWatcher.start()
|
||||
appNavigationStateService.onNavigateToSession(id, matrixClient.sessionId)
|
||||
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
|
||||
@@ -258,7 +236,6 @@ class LoggedInFlowNode(
|
||||
onDestroy = {
|
||||
appNavigationStateService.onLeavingSession(id)
|
||||
loggedInFlowProcessor.stopObserving()
|
||||
matrixClient.sessionVerificationService.setListener(null)
|
||||
analyticsRoomListStateWatcher.stop()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -40,6 +40,9 @@ 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.appnav.session.MatrixSessionCache
|
||||
import io.element.android.appnav.verification.IncomingVerificationRequestData
|
||||
import io.element.android.appnav.verification.IncomingVerificationRequestObserver
|
||||
import io.element.android.appnav.verification.OtherSessionIncomingVerificationNode
|
||||
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
|
||||
@@ -52,6 +55,7 @@ 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.coroutine.withPreviousValue
|
||||
import io.element.android.libraries.deeplink.api.DeeplinkData
|
||||
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
@@ -63,6 +67,7 @@ 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.matrix.api.verification.VerificationRequest
|
||||
import io.element.android.libraries.oauth.api.OAuthAction
|
||||
import io.element.android.libraries.oauth.api.OAuthActionFlow
|
||||
import io.element.android.libraries.sessionstorage.api.LoggedInState
|
||||
@@ -75,11 +80,16 @@ import io.element.android.services.appnavstate.api.ROOM_OPENED_FROM_NOTIFICATION
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import timber.log.Timber
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@ContributesNode(AppScope::class)
|
||||
@AssistedInject
|
||||
@@ -100,6 +110,7 @@ class RootFlowNode(
|
||||
private val announcementService: AnnouncementService,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val analyticsColdStartWatcher: AnalyticsColdStartWatcher,
|
||||
private val incomingVerificationRequestObserver: IncomingVerificationRequestObserver,
|
||||
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
|
||||
) : BaseFlowNode<RootFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
@@ -127,6 +138,8 @@ class RootFlowNode(
|
||||
observeNavState(false)
|
||||
}
|
||||
}
|
||||
observeIncomingVerificationRequests()
|
||||
observeRemovedSessions()
|
||||
super.onBuilt()
|
||||
}
|
||||
|
||||
@@ -203,6 +216,65 @@ class RootFlowNode(
|
||||
private fun switchToLoggedInFlow(sessionId: SessionId, navId: Int) {
|
||||
pendingLoginParams = null
|
||||
backstack.safeRoot(NavTarget.LoggedInFlow(sessionId, navId))
|
||||
restoreAllSessions()
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore all the sessions with a valid token, so that they sync and can receive incoming verification requests.
|
||||
*/
|
||||
private fun restoreAllSessions() = lifecycleScope.launch {
|
||||
sessionStore.getAllSessions()
|
||||
.filter { it.isTokenValid }
|
||||
.forEach { sessionData ->
|
||||
matrixSessionCache.getOrRestore(SessionId(sessionData.userId)).onFailure {
|
||||
Timber.e(it, "Failed to restore session ${sessionData.userId}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove from the cache the sessions which have been removed from the session store, for instance after a logout.
|
||||
*/
|
||||
private fun observeRemovedSessions() {
|
||||
sessionStore.sessionsFlow()
|
||||
.map { sessions -> sessions.map { SessionId(it.userId) }.toSet() }
|
||||
.withPreviousValue()
|
||||
.onEach { (previous, current) ->
|
||||
(previous.orEmpty() - current).forEach { sessionId ->
|
||||
Timber.d("Session $sessionId removed, remove it from the cache")
|
||||
matrixSessionCache.remove(sessionId)
|
||||
}
|
||||
}
|
||||
.launchIn(lifecycleScope)
|
||||
}
|
||||
|
||||
private fun observeIncomingVerificationRequests() {
|
||||
incomingVerificationRequestObserver.incomingVerificationRequests()
|
||||
.onEach(::onIncomingVerificationRequest)
|
||||
.launchIn(lifecycleScope)
|
||||
}
|
||||
|
||||
private fun onIncomingVerificationRequest(data: IncomingVerificationRequestData) = lifecycleScope.launch {
|
||||
// Wait for a logged in flow to be displayed
|
||||
val currentSessionId = withTimeoutOrNull(5.seconds) {
|
||||
backstack.elements.map { elements ->
|
||||
elements.firstNotNullOfOrNull { (it.key.navTarget as? NavTarget.LoggedInFlow)?.sessionId }
|
||||
}.filterNotNull().first()
|
||||
}
|
||||
if (currentSessionId == null) {
|
||||
Timber.w("Incoming verification request ${data.verificationRequest.details.flowId} discarded, no logged in flow.")
|
||||
return@launch
|
||||
}
|
||||
if (currentSessionId == data.sessionId) {
|
||||
waitForChildAttached<LoggedInAppScopeFlowNode, NavTarget> { navTarget ->
|
||||
navTarget is NavTarget.LoggedInFlow && navTarget.sessionId == currentSessionId
|
||||
}
|
||||
.attachSession()
|
||||
.onIncomingVerificationRequest(data.verificationRequest)
|
||||
} else {
|
||||
// Display the request on top of the current session, without switching session
|
||||
backstack.push(NavTarget.OtherAccountIncomingVerificationRequest(data.sessionId, data.verificationRequest))
|
||||
}
|
||||
}
|
||||
|
||||
private fun switchToNotLoggedInFlow(params: LoginParams?) {
|
||||
@@ -294,6 +366,11 @@ class RootFlowNode(
|
||||
) : NavTarget
|
||||
|
||||
@Parcelize data object BugReport : NavTarget
|
||||
|
||||
@Parcelize data class OtherAccountIncomingVerificationRequest(
|
||||
val sessionId: SessionId,
|
||||
val verificationRequest: VerificationRequest.Incoming,
|
||||
) : NavTarget
|
||||
}
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
@@ -348,6 +425,23 @@ class RootFlowNode(
|
||||
)
|
||||
}
|
||||
NavTarget.SplashScreen -> emptyNode(buildContext)
|
||||
is NavTarget.OtherAccountIncomingVerificationRequest -> {
|
||||
if (matrixSessionCache.getOrNull(navTarget.sessionId) == null) {
|
||||
Timber.w("Couldn't find session ${navTarget.sessionId} for the incoming verification request")
|
||||
lifecycleScope.launch { backstack.pop() }
|
||||
return emptyNode(buildContext)
|
||||
}
|
||||
val inputs = OtherSessionIncomingVerificationNode.Inputs(
|
||||
sessionId = navTarget.sessionId,
|
||||
verificationRequest = navTarget.verificationRequest,
|
||||
)
|
||||
val callback = object : OtherSessionIncomingVerificationNode.Callback {
|
||||
override fun onDone() {
|
||||
backstack.pop()
|
||||
}
|
||||
}
|
||||
createNode<OtherSessionIncomingVerificationNode>(buildContext, plugins = listOf(inputs, callback))
|
||||
}
|
||||
NavTarget.BugReport -> {
|
||||
val callback = object : BugReportEntryPoint.Callback {
|
||||
override fun onDone() {
|
||||
|
||||
@@ -21,6 +21,9 @@ import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.analyticsproviders.api.AnalyticsUserData
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import timber.log.Timber
|
||||
@@ -41,6 +44,12 @@ class MatrixSessionCache(
|
||||
private val analyticsService: AnalyticsService,
|
||||
) : MatrixClientProvider {
|
||||
private val sessionIdsToMatrixSession = ConcurrentHashMap<SessionId, InMemoryMatrixSession>()
|
||||
private val _matrixClients = MutableStateFlow<List<MatrixClient>>(emptyList())
|
||||
|
||||
/**
|
||||
* The [MatrixClient]s currently in the cache.
|
||||
*/
|
||||
val matrixClients: StateFlow<List<MatrixClient>> = _matrixClients.asStateFlow()
|
||||
private val restoreMutex = Mutex()
|
||||
|
||||
init {
|
||||
@@ -51,10 +60,12 @@ class MatrixSessionCache(
|
||||
|
||||
fun removeAll() {
|
||||
sessionIdsToMatrixSession.clear()
|
||||
updateMatrixClients()
|
||||
}
|
||||
|
||||
fun remove(sessionId: SessionId) {
|
||||
sessionIdsToMatrixSession.remove(sessionId)
|
||||
updateMatrixClients()
|
||||
}
|
||||
|
||||
override fun getOrNull(sessionId: SessionId): MatrixClient? {
|
||||
@@ -122,8 +133,13 @@ class MatrixSessionCache(
|
||||
matrixClient = matrixClient,
|
||||
syncOrchestrator = syncOrchestrator,
|
||||
)
|
||||
updateMatrixClients()
|
||||
syncOrchestrator.start()
|
||||
}
|
||||
|
||||
private fun updateMatrixClients() {
|
||||
_matrixClients.value = sessionIdsToMatrixSession.values.map { it.matrixClient }
|
||||
}
|
||||
}
|
||||
|
||||
private data class InMemoryMatrixSession(
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* 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.verification
|
||||
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.appnav.session.MatrixSessionCache
|
||||
import io.element.android.libraries.core.coroutine.withPreviousValue
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationServiceListener
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
||||
import io.element.android.services.appnavstate.api.AppForegroundStateService
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import timber.log.Timber
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
data class IncomingVerificationRequestData(
|
||||
val sessionId: SessionId,
|
||||
val verificationRequest: VerificationRequest.Incoming,
|
||||
)
|
||||
|
||||
/**
|
||||
* Listens to the incoming verification requests of all the sessions in [MatrixSessionCache], not only the current one.
|
||||
*/
|
||||
@Inject
|
||||
class IncomingVerificationRequestObserver(
|
||||
private val matrixSessionCache: MatrixSessionCache,
|
||||
private val appForegroundStateService: AppForegroundStateService,
|
||||
) {
|
||||
/**
|
||||
* Emits the incoming verification requests once the app is in foreground.
|
||||
* Requests received while the app is in background for too long are discarded.
|
||||
*/
|
||||
fun incomingVerificationRequests(): Flow<IncomingVerificationRequestData> = channelFlow {
|
||||
launch {
|
||||
matrixSessionCache.matrixClients.withPreviousValue().collect { (previousMatrixClients, matrixClients) ->
|
||||
val previousMatrixClientSet = previousMatrixClients.orEmpty().toSet()
|
||||
// Remove the listener of the clients which are not in the cache anymore
|
||||
(previousMatrixClientSet - matrixClients.toSet()).forEach { matrixClient ->
|
||||
matrixClient.sessionVerificationService.setListener(null)
|
||||
}
|
||||
(matrixClients - previousMatrixClientSet).forEach { matrixClient ->
|
||||
val sessionId = matrixClient.sessionId
|
||||
matrixClient.sessionVerificationService.setListener(
|
||||
object : SessionVerificationServiceListener {
|
||||
override fun onIncomingSessionRequest(verificationRequest: VerificationRequest.Incoming) {
|
||||
launch {
|
||||
// Wait until the app is in foreground to display the incoming verification request
|
||||
// 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 isInForeground = withTimeoutOrNull(2.minutes) {
|
||||
appForegroundStateService.isInForeground.first { it }
|
||||
}
|
||||
if (isInForeground == null) {
|
||||
Timber.w("Incoming verification request ${verificationRequest.details.flowId} discarded due to timeout.")
|
||||
} else {
|
||||
send(IncomingVerificationRequestData(sessionId, verificationRequest))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
awaitClose {
|
||||
matrixSessionCache.matrixClients.value.forEach { matrixClient ->
|
||||
matrixClient.sessionVerificationService.setListener(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* 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.verification
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.composable.Children
|
||||
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.features.verifysession.api.IncomingVerificationEntryPoint
|
||||
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.di.DependencyInjectionGraphOwner
|
||||
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
/**
|
||||
* Display an incoming verification request received by a session which is not the current one, without switching the current session.
|
||||
* It sets up the Session graph of the session receiving the request.
|
||||
*/
|
||||
@ContributesNode(AppScope::class)
|
||||
@AssistedInject
|
||||
class OtherSessionIncomingVerificationNode(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
sessionGraphFactory: SessionGraphFactory,
|
||||
matrixClientProvider: MatrixClientProvider,
|
||||
private val incomingVerificationEntryPoint: IncomingVerificationEntryPoint,
|
||||
) : ParentNode<OtherSessionIncomingVerificationNode.NavTarget>(
|
||||
navModel = PermanentNavModel(
|
||||
navTargets = setOf(NavTarget),
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
buildContext = buildContext,
|
||||
plugins = plugins
|
||||
), DependencyInjectionGraphOwner {
|
||||
interface Callback : Plugin {
|
||||
fun onDone()
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
object NavTarget : Parcelable
|
||||
|
||||
data class Inputs(
|
||||
val sessionId: SessionId,
|
||||
val verificationRequest: VerificationRequest.Incoming,
|
||||
) : NodeInputs
|
||||
|
||||
private val callback: Callback = callback()
|
||||
private val inputs: Inputs = inputs()
|
||||
override val graph = sessionGraphFactory.create(
|
||||
requireNotNull(matrixClientProvider.getOrNull(inputs.sessionId)) { "No MatrixClient for session ${inputs.sessionId}" }
|
||||
)
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
return incomingVerificationEntryPoint.createNode(
|
||||
parentNode = this,
|
||||
buildContext = buildContext,
|
||||
params = IncomingVerificationEntryPoint.Params(inputs.verificationRequest),
|
||||
callback = object : IncomingVerificationEntryPoint.Callback {
|
||||
override fun onDone() {
|
||||
callback.onDone()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
Children(
|
||||
navModel = navModel,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import io.element.android.appnav.root.RootNavStateFlowFactory
|
||||
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.login.api.LoginEntryPoint
|
||||
import io.element.android.features.login.api.LoginParams
|
||||
import io.element.android.features.login.test.FakeLoginEntryPoint
|
||||
@@ -50,6 +51,7 @@ import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.services.analytics.test.watchers.FakeAnalyticsColdStartWatcher
|
||||
import io.element.android.services.apperror.test.FakeAppErrorStateService
|
||||
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
|
||||
import io.element.android.tests.testutils.node.FakeNodeFactoriesBindings
|
||||
import io.element.android.tests.testutils.node.FakeParentNode
|
||||
import io.element.android.tests.testutils.presenter.NotUsedPresenter
|
||||
@@ -183,6 +185,10 @@ class RootFlowNodeTest : RobolectricTest() {
|
||||
announcementService = FakeAnnouncementService(),
|
||||
analyticsService = FakeAnalyticsService(),
|
||||
analyticsColdStartWatcher = FakeAnalyticsColdStartWatcher(),
|
||||
incomingVerificationRequestObserver = IncomingVerificationRequestObserver(
|
||||
matrixSessionCache = matrixSessionCache,
|
||||
appForegroundStateService = FakeAppForegroundStateService(),
|
||||
),
|
||||
appCoroutineScope = backgroundScope,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -70,9 +70,11 @@ class MatrixSessionCacheTest {
|
||||
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
|
||||
assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
|
||||
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
|
||||
assertThat(matrixSessionCache.matrixClients.value).containsExactly(fakeMatrixClient)
|
||||
// Remove
|
||||
matrixSessionCache.remove(A_SESSION_ID)
|
||||
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
|
||||
assertThat(matrixSessionCache.matrixClients.value).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -83,9 +85,11 @@ class MatrixSessionCacheTest {
|
||||
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
|
||||
assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
|
||||
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
|
||||
assertThat(matrixSessionCache.matrixClients.value).containsExactly(fakeMatrixClient)
|
||||
// Remove all
|
||||
matrixSessionCache.removeAll()
|
||||
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
|
||||
assertThat(matrixSessionCache.matrixClients.value).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
* 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.verification
|
||||
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.appnav.session.MatrixSessionCache
|
||||
import io.element.android.appnav.session.SyncOrchestrator
|
||||
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.DeviceId
|
||||
import io.element.android.libraries.matrix.api.core.FlowId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_3
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
|
||||
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
|
||||
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.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class IncomingVerificationRequestObserverTest {
|
||||
private val sessionVerificationService1 = FakeSessionVerificationService()
|
||||
private val sessionVerificationService2 = FakeSessionVerificationService()
|
||||
|
||||
@Test
|
||||
fun `incoming verification requests of all the sessions are emitted with their session id`() = runTest {
|
||||
val matrixSessionCache = createMatrixSessionCache()
|
||||
val observer = IncomingVerificationRequestObserver(
|
||||
matrixSessionCache = matrixSessionCache,
|
||||
appForegroundStateService = FakeAppForegroundStateService(),
|
||||
)
|
||||
observer.incomingVerificationRequests().test {
|
||||
matrixSessionCache.getOrRestore(A_SESSION_ID)
|
||||
matrixSessionCache.getOrRestore(A_SESSION_ID_2)
|
||||
runCurrent()
|
||||
val request = anIncomingVerificationRequest()
|
||||
sessionVerificationService2.listener!!.onIncomingSessionRequest(request)
|
||||
assertThat(awaitItem()).isEqualTo(IncomingVerificationRequestData(A_SESSION_ID_2, request))
|
||||
sessionVerificationService1.listener!!.onIncomingSessionRequest(request)
|
||||
assertThat(awaitItem()).isEqualTo(IncomingVerificationRequestData(A_SESSION_ID, request))
|
||||
}
|
||||
// The listeners are removed when the flow is not collected anymore
|
||||
assertThat(sessionVerificationService1.listener).isNull()
|
||||
assertThat(sessionVerificationService2.listener).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `listener is removed when the session is removed from the cache`() = runTest {
|
||||
val matrixSessionCache = createMatrixSessionCache()
|
||||
val observer = IncomingVerificationRequestObserver(
|
||||
matrixSessionCache = matrixSessionCache,
|
||||
appForegroundStateService = FakeAppForegroundStateService(),
|
||||
)
|
||||
observer.incomingVerificationRequests().test {
|
||||
matrixSessionCache.getOrRestore(A_SESSION_ID)
|
||||
matrixSessionCache.getOrRestore(A_SESSION_ID_2)
|
||||
runCurrent()
|
||||
assertThat(sessionVerificationService1.listener).isNotNull()
|
||||
assertThat(sessionVerificationService2.listener).isNotNull()
|
||||
matrixSessionCache.remove(A_SESSION_ID)
|
||||
runCurrent()
|
||||
assertThat(sessionVerificationService1.listener).isNull()
|
||||
assertThat(sessionVerificationService2.listener).isNotNull()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `incoming user verification request of another session is emitted with its session id`() = runTest {
|
||||
val matrixSessionCache = createMatrixSessionCache()
|
||||
val observer = IncomingVerificationRequestObserver(
|
||||
matrixSessionCache = matrixSessionCache,
|
||||
appForegroundStateService = FakeAppForegroundStateService(),
|
||||
)
|
||||
observer.incomingVerificationRequests().test {
|
||||
matrixSessionCache.getOrRestore(A_SESSION_ID)
|
||||
matrixSessionCache.getOrRestore(A_SESSION_ID_2)
|
||||
runCurrent()
|
||||
val request = anIncomingUserVerificationRequest()
|
||||
sessionVerificationService2.listener!!.onIncomingSessionRequest(request)
|
||||
assertThat(awaitItem()).isEqualTo(IncomingVerificationRequestData(A_SESSION_ID_2, request))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `incoming verification request is emitted when the app goes to foreground`() = runTest {
|
||||
val matrixSessionCache = createMatrixSessionCache()
|
||||
val appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = false)
|
||||
val observer = IncomingVerificationRequestObserver(
|
||||
matrixSessionCache = matrixSessionCache,
|
||||
appForegroundStateService = appForegroundStateService,
|
||||
)
|
||||
observer.incomingVerificationRequests().test {
|
||||
matrixSessionCache.getOrRestore(A_SESSION_ID)
|
||||
runCurrent()
|
||||
val request = anIncomingVerificationRequest()
|
||||
sessionVerificationService1.listener!!.onIncomingSessionRequest(request)
|
||||
advanceTimeBy(30.seconds)
|
||||
expectNoEvents()
|
||||
appForegroundStateService.givenIsInForeground(true)
|
||||
assertThat(awaitItem()).isEqualTo(IncomingVerificationRequestData(A_SESSION_ID, request))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `incoming verification request is discarded if the app does not go to foreground in time`() = runTest {
|
||||
val matrixSessionCache = createMatrixSessionCache()
|
||||
val appForegroundStateService = FakeAppForegroundStateService(initialForegroundValue = false)
|
||||
val observer = IncomingVerificationRequestObserver(
|
||||
matrixSessionCache = matrixSessionCache,
|
||||
appForegroundStateService = appForegroundStateService,
|
||||
)
|
||||
observer.incomingVerificationRequests().test {
|
||||
matrixSessionCache.getOrRestore(A_SESSION_ID)
|
||||
runCurrent()
|
||||
sessionVerificationService1.listener!!.onIncomingSessionRequest(anIncomingVerificationRequest())
|
||||
advanceTimeBy(3.minutes)
|
||||
appForegroundStateService.givenIsInForeground(true)
|
||||
runCurrent()
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
private fun TestScope.createMatrixSessionCache(): MatrixSessionCache {
|
||||
val matrixClient1 = FakeMatrixClient(
|
||||
sessionId = A_SESSION_ID,
|
||||
sessionCoroutineScope = backgroundScope,
|
||||
sessionVerificationService = sessionVerificationService1,
|
||||
userIdServerNameLambda = { "server" },
|
||||
)
|
||||
val matrixClient2 = FakeMatrixClient(
|
||||
sessionId = A_SESSION_ID_2,
|
||||
sessionCoroutineScope = backgroundScope,
|
||||
sessionVerificationService = sessionVerificationService2,
|
||||
userIdServerNameLambda = { "server" },
|
||||
)
|
||||
return MatrixSessionCache(
|
||||
authenticationService = FakeMatrixAuthenticationService(
|
||||
matrixClientResult = { sessionId ->
|
||||
Result.success(if (sessionId == A_SESSION_ID) matrixClient1 else matrixClient2)
|
||||
},
|
||||
),
|
||||
syncOrchestratorFactory = object : SyncOrchestrator.Factory {
|
||||
override fun create(matrixClient: MatrixClient, sessionCoroutineScope: CoroutineScope) = SyncOrchestrator(
|
||||
matrixClient = matrixClient,
|
||||
sessionCoroutineScope = sessionCoroutineScope,
|
||||
appForegroundStateService = FakeAppForegroundStateService(),
|
||||
networkMonitor = FakeNetworkMonitor(),
|
||||
dispatchers = testCoroutineDispatchers(),
|
||||
analyticsService = FakeAnalyticsService(),
|
||||
)
|
||||
},
|
||||
analyticsService = FakeAnalyticsService(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun anIncomingVerificationRequest() = VerificationRequest.Incoming.OtherSession(
|
||||
details = aSessionVerificationRequestDetails(senderProfile = MatrixUser(A_SESSION_ID)),
|
||||
)
|
||||
|
||||
private fun anIncomingUserVerificationRequest() = VerificationRequest.Incoming.User(
|
||||
details = aSessionVerificationRequestDetails(senderProfile = MatrixUser(A_USER_ID_3)),
|
||||
)
|
||||
|
||||
private fun aSessionVerificationRequestDetails(senderProfile: MatrixUser) = SessionVerificationRequestDetails(
|
||||
senderProfile = senderProfile,
|
||||
flowId = FlowId("flowId"),
|
||||
deviceId = DeviceId("deviceId"),
|
||||
deviceDisplayName = "a device name",
|
||||
firstSeenTimestamp = 0,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user