mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-03 06:55:41 +00:00
Merge pull request #7823 from element-hq/feature/bma/multiAccountIncomingVerification
Multi account incoming device verification
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.permalink.PermalinkData
|
||||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
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.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.matrix.api.verification.VerificationRequest
|
||||||
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||||
import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService
|
import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService
|
||||||
@@ -111,14 +110,10 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.time.Duration
|
|
||||||
import java.time.Instant
|
|
||||||
import java.util.Optional
|
import java.util.Optional
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
import kotlin.time.Duration.Companion.minutes
|
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
import kotlin.time.toKotlinDuration
|
|
||||||
import im.vector.app.features.analytics.plan.JoinedRoom as JoinedRoomAnalyticsEvent
|
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.
|
// 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,
|
roomMembershipObserver = matrixClient.roomMembershipObserver,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val verificationListener = object : SessionVerificationServiceListener {
|
/**
|
||||||
override fun onIncomingSessionRequest(verificationRequest: VerificationRequest.Incoming) {
|
* Display the incoming verification request, the app is expected to be in foreground.
|
||||||
// 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.
|
fun onIncomingVerificationRequest(verificationRequest: VerificationRequest.Incoming) {
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
val receivedAt = Instant.now()
|
// 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
|
||||||
// Wait until the app is in foreground to display the incoming verification request
|
withTimeout(5.seconds) {
|
||||||
appNavigationStateService.appNavigationState.first { it.isInForeground }
|
backstack.elements.first { elements ->
|
||||||
|
elements.any { it.key.navTarget == NavTarget.Home }
|
||||||
// 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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
backstack.singleTop(NavTarget.IncomingVerificationRequest(verificationRequest))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,7 +208,6 @@ class LoggedInFlowNode(
|
|||||||
analyticsRoomListStateWatcher.start()
|
analyticsRoomListStateWatcher.start()
|
||||||
appNavigationStateService.onNavigateToSession(id, matrixClient.sessionId)
|
appNavigationStateService.onNavigateToSession(id, matrixClient.sessionId)
|
||||||
loggedInFlowProcessor.observeEvents(sessionCoroutineScope)
|
loggedInFlowProcessor.observeEvents(sessionCoroutineScope)
|
||||||
matrixClient.sessionVerificationService.setListener(verificationListener)
|
|
||||||
mediaPreviewConfigMigration()
|
mediaPreviewConfigMigration()
|
||||||
sessionCoroutineScope.launch {
|
sessionCoroutineScope.launch {
|
||||||
// Wait for the network to be connected before pre-fetching the max file upload size
|
// Wait for the network to be connected before pre-fetching the max file upload size
|
||||||
@@ -258,7 +236,6 @@ class LoggedInFlowNode(
|
|||||||
onDestroy = {
|
onDestroy = {
|
||||||
appNavigationStateService.onLeavingSession(id)
|
appNavigationStateService.onLeavingSession(id)
|
||||||
loggedInFlowProcessor.stopObserving()
|
loggedInFlowProcessor.stopObserving()
|
||||||
matrixClient.sessionVerificationService.setListener(null)
|
|
||||||
analyticsRoomListStateWatcher.stop()
|
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.RootPresenter
|
||||||
import io.element.android.appnav.root.RootView
|
import io.element.android.appnav.root.RootView
|
||||||
import io.element.android.appnav.session.MatrixSessionCache
|
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.announcement.api.AnnouncementService
|
||||||
import io.element.android.features.login.api.LoginParams
|
import io.element.android.features.login.api.LoginParams
|
||||||
import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl
|
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.appyx.rememberDelegateTransitionHandler
|
||||||
import io.element.android.libraries.architecture.createNode
|
import io.element.android.libraries.architecture.createNode
|
||||||
import io.element.android.libraries.architecture.waitForChildAttached
|
import io.element.android.libraries.architecture.waitForChildAttached
|
||||||
|
import io.element.android.libraries.core.coroutine.withPreviousValue
|
||||||
import io.element.android.libraries.deeplink.api.DeeplinkData
|
import io.element.android.libraries.deeplink.api.DeeplinkData
|
||||||
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
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.asEventId
|
||||||
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
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.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.OAuthAction
|
||||||
import io.element.android.libraries.oauth.api.OAuthActionFlow
|
import io.element.android.libraries.oauth.api.OAuthActionFlow
|
||||||
import io.element.android.libraries.sessionstorage.api.LoggedInState
|
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.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.drop
|
import kotlinx.coroutines.flow.drop
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@ContributesNode(AppScope::class)
|
@ContributesNode(AppScope::class)
|
||||||
@AssistedInject
|
@AssistedInject
|
||||||
@@ -100,6 +110,7 @@ class RootFlowNode(
|
|||||||
private val announcementService: AnnouncementService,
|
private val announcementService: AnnouncementService,
|
||||||
private val analyticsService: AnalyticsService,
|
private val analyticsService: AnalyticsService,
|
||||||
private val analyticsColdStartWatcher: AnalyticsColdStartWatcher,
|
private val analyticsColdStartWatcher: AnalyticsColdStartWatcher,
|
||||||
|
private val incomingVerificationRequestObserver: IncomingVerificationRequestObserver,
|
||||||
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
|
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
|
||||||
) : BaseFlowNode<RootFlowNode.NavTarget>(
|
) : BaseFlowNode<RootFlowNode.NavTarget>(
|
||||||
backstack = BackStack(
|
backstack = BackStack(
|
||||||
@@ -127,6 +138,8 @@ class RootFlowNode(
|
|||||||
observeNavState(false)
|
observeNavState(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
observeIncomingVerificationRequests()
|
||||||
|
observeRemovedSessions()
|
||||||
super.onBuilt()
|
super.onBuilt()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,6 +216,65 @@ class RootFlowNode(
|
|||||||
private fun switchToLoggedInFlow(sessionId: SessionId, navId: Int) {
|
private fun switchToLoggedInFlow(sessionId: SessionId, navId: Int) {
|
||||||
pendingLoginParams = null
|
pendingLoginParams = null
|
||||||
backstack.safeRoot(NavTarget.LoggedInFlow(sessionId, navId))
|
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?) {
|
private fun switchToNotLoggedInFlow(params: LoginParams?) {
|
||||||
@@ -294,6 +366,11 @@ class RootFlowNode(
|
|||||||
) : NavTarget
|
) : NavTarget
|
||||||
|
|
||||||
@Parcelize data object BugReport : 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 {
|
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||||
@@ -348,6 +425,23 @@ class RootFlowNode(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
NavTarget.SplashScreen -> emptyNode(buildContext)
|
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 -> {
|
NavTarget.BugReport -> {
|
||||||
val callback = object : BugReportEntryPoint.Callback {
|
val callback = object : BugReportEntryPoint.Callback {
|
||||||
override fun onDone() {
|
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.libraries.matrix.api.core.SessionId
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
import io.element.android.services.analyticsproviders.api.AnalyticsUserData
|
import io.element.android.services.analyticsproviders.api.AnalyticsUserData
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
@@ -41,6 +44,12 @@ class MatrixSessionCache(
|
|||||||
private val analyticsService: AnalyticsService,
|
private val analyticsService: AnalyticsService,
|
||||||
) : MatrixClientProvider {
|
) : MatrixClientProvider {
|
||||||
private val sessionIdsToMatrixSession = ConcurrentHashMap<SessionId, InMemoryMatrixSession>()
|
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()
|
private val restoreMutex = Mutex()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -51,10 +60,12 @@ class MatrixSessionCache(
|
|||||||
|
|
||||||
fun removeAll() {
|
fun removeAll() {
|
||||||
sessionIdsToMatrixSession.clear()
|
sessionIdsToMatrixSession.clear()
|
||||||
|
updateMatrixClients()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun remove(sessionId: SessionId) {
|
fun remove(sessionId: SessionId) {
|
||||||
sessionIdsToMatrixSession.remove(sessionId)
|
sessionIdsToMatrixSession.remove(sessionId)
|
||||||
|
updateMatrixClients()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getOrNull(sessionId: SessionId): MatrixClient? {
|
override fun getOrNull(sessionId: SessionId): MatrixClient? {
|
||||||
@@ -122,8 +133,13 @@ class MatrixSessionCache(
|
|||||||
matrixClient = matrixClient,
|
matrixClient = matrixClient,
|
||||||
syncOrchestrator = syncOrchestrator,
|
syncOrchestrator = syncOrchestrator,
|
||||||
)
|
)
|
||||||
|
updateMatrixClients()
|
||||||
syncOrchestrator.start()
|
syncOrchestrator.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun updateMatrixClients() {
|
||||||
|
_matrixClients.value = sessionIdsToMatrixSession.values.map { it.matrixClient }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class InMemoryMatrixSession(
|
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.root.RootPresenter
|
||||||
import io.element.android.appnav.session.FakeSyncOrchestratorFactory
|
import io.element.android.appnav.session.FakeSyncOrchestratorFactory
|
||||||
import io.element.android.appnav.session.MatrixSessionCache
|
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.LoginEntryPoint
|
||||||
import io.element.android.features.login.api.LoginParams
|
import io.element.android.features.login.api.LoginParams
|
||||||
import io.element.android.features.login.test.FakeLoginEntryPoint
|
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.FakeAnalyticsService
|
||||||
import io.element.android.services.analytics.test.watchers.FakeAnalyticsColdStartWatcher
|
import io.element.android.services.analytics.test.watchers.FakeAnalyticsColdStartWatcher
|
||||||
import io.element.android.services.apperror.test.FakeAppErrorStateService
|
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.FakeNodeFactoriesBindings
|
||||||
import io.element.android.tests.testutils.node.FakeParentNode
|
import io.element.android.tests.testutils.node.FakeParentNode
|
||||||
import io.element.android.tests.testutils.presenter.NotUsedPresenter
|
import io.element.android.tests.testutils.presenter.NotUsedPresenter
|
||||||
@@ -183,6 +185,10 @@ class RootFlowNodeTest : RobolectricTest() {
|
|||||||
announcementService = FakeAnnouncementService(),
|
announcementService = FakeAnnouncementService(),
|
||||||
analyticsService = FakeAnalyticsService(),
|
analyticsService = FakeAnalyticsService(),
|
||||||
analyticsColdStartWatcher = FakeAnalyticsColdStartWatcher(),
|
analyticsColdStartWatcher = FakeAnalyticsColdStartWatcher(),
|
||||||
|
incomingVerificationRequestObserver = IncomingVerificationRequestObserver(
|
||||||
|
matrixSessionCache = matrixSessionCache,
|
||||||
|
appForegroundStateService = FakeAppForegroundStateService(),
|
||||||
|
),
|
||||||
appCoroutineScope = backgroundScope,
|
appCoroutineScope = backgroundScope,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,9 +70,11 @@ class MatrixSessionCacheTest {
|
|||||||
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
|
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
|
||||||
assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
|
assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
|
||||||
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
|
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
|
||||||
|
assertThat(matrixSessionCache.matrixClients.value).containsExactly(fakeMatrixClient)
|
||||||
// Remove
|
// Remove
|
||||||
matrixSessionCache.remove(A_SESSION_ID)
|
matrixSessionCache.remove(A_SESSION_ID)
|
||||||
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
|
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
|
||||||
|
assertThat(matrixSessionCache.matrixClients.value).isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -83,9 +85,11 @@ class MatrixSessionCacheTest {
|
|||||||
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
|
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
|
||||||
assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
|
assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
|
||||||
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
|
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
|
||||||
|
assertThat(matrixSessionCache.matrixClients.value).containsExactly(fakeMatrixClient)
|
||||||
// Remove all
|
// Remove all
|
||||||
matrixSessionCache.removeAll()
|
matrixSessionCache.removeAll()
|
||||||
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
|
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
|
||||||
|
assertThat(matrixSessionCache.matrixClients.value).isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+167
@@ -0,0 +1,167 @@
|
|||||||
|
/*
|
||||||
|
* 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.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 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 = SessionVerificationRequestDetails(
|
||||||
|
senderProfile = MatrixUser(A_SESSION_ID),
|
||||||
|
flowId = FlowId("flowId"),
|
||||||
|
deviceId = DeviceId("deviceId"),
|
||||||
|
deviceDisplayName = "a device name",
|
||||||
|
firstSeenTimestamp = 0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ dependencies {
|
|||||||
implementation(projects.libraries.matrixui)
|
implementation(projects.libraries.matrixui)
|
||||||
implementation(projects.libraries.designsystem)
|
implementation(projects.libraries.designsystem)
|
||||||
implementation(projects.libraries.preferences.api)
|
implementation(projects.libraries.preferences.api)
|
||||||
|
implementation(projects.libraries.sessionStorage.api)
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
implementation(projects.features.logout.api)
|
implementation(projects.features.logout.api)
|
||||||
api(libs.statemachine)
|
api(libs.statemachine)
|
||||||
@@ -44,4 +45,5 @@ dependencies {
|
|||||||
testImplementation(projects.libraries.dateformatter.test)
|
testImplementation(projects.libraries.dateformatter.test)
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
testImplementation(projects.libraries.preferences.test)
|
testImplementation(projects.libraries.preferences.test)
|
||||||
|
testImplementation(projects.libraries.sessionStorage.test)
|
||||||
}
|
}
|
||||||
|
|||||||
+24
@@ -13,6 +13,7 @@ package io.element.android.features.verifysession.impl.incoming
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -26,13 +27,17 @@ import io.element.android.libraries.architecture.Presenter
|
|||||||
import io.element.android.libraries.dateformatter.api.DateFormatter
|
import io.element.android.libraries.dateformatter.api.DateFormatter
|
||||||
import io.element.android.libraries.dateformatter.api.DateFormatterMode
|
import io.element.android.libraries.dateformatter.api.DateFormatterMode
|
||||||
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
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.SessionVerificationRequestDetails
|
||||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||||
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
|
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
|
||||||
import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
||||||
|
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.mapNotNull
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
@@ -45,6 +50,8 @@ class IncomingVerificationPresenter(
|
|||||||
@Assisted private val navigator: IncomingVerificationNavigator,
|
@Assisted private val navigator: IncomingVerificationNavigator,
|
||||||
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
|
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
|
||||||
private val sessionVerificationService: SessionVerificationService,
|
private val sessionVerificationService: SessionVerificationService,
|
||||||
|
private val sessionId: SessionId,
|
||||||
|
private val sessionStore: SessionStore,
|
||||||
private val stateMachine: IncomingVerificationStateMachine,
|
private val stateMachine: IncomingVerificationStateMachine,
|
||||||
private val dateFormatter: DateFormatter,
|
private val dateFormatter: DateFormatter,
|
||||||
) : Presenter<IncomingVerificationState> {
|
) : Presenter<IncomingVerificationState> {
|
||||||
@@ -92,9 +99,24 @@ class IncomingVerificationPresenter(
|
|||||||
mode = DateFormatterMode.TimeOrDate,
|
mode = DateFormatterMode.TimeOrDate,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
val currentUser by remember {
|
||||||
|
sessionStore.sessionsFlow().mapNotNull { sessions ->
|
||||||
|
sessions.find { it.userId == sessionId.value }?.let { sessionData ->
|
||||||
|
MatrixUser(
|
||||||
|
userId = sessionId,
|
||||||
|
displayName = sessionData.userDisplayName,
|
||||||
|
avatarUrl = sessionData.userAvatarUrl,
|
||||||
|
// Locally stored avatar, used as a fallback when the avatar cannot be loaded,
|
||||||
|
// for instance for a session which is not the current one
|
||||||
|
avatarThumbnail = sessionData.userAvatarData,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.collectAsState(MatrixUser(sessionId))
|
||||||
val step by remember {
|
val step by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
stateAndDispatch.state.value.toVerificationStep(
|
stateAndDispatch.state.value.toVerificationStep(
|
||||||
|
currentUser = currentUser,
|
||||||
sessionVerificationRequestDetails = verificationRequest.details,
|
sessionVerificationRequestDetails = verificationRequest.details,
|
||||||
formattedSignInTime = formattedSignInTime,
|
formattedSignInTime = formattedSignInTime,
|
||||||
)
|
)
|
||||||
@@ -147,6 +169,7 @@ class IncomingVerificationPresenter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun StateMachineState?.toVerificationStep(
|
private fun StateMachineState?.toVerificationStep(
|
||||||
|
currentUser: MatrixUser,
|
||||||
sessionVerificationRequestDetails: SessionVerificationRequestDetails,
|
sessionVerificationRequestDetails: SessionVerificationRequestDetails,
|
||||||
formattedSignInTime: String,
|
formattedSignInTime: String,
|
||||||
): Step =
|
): Step =
|
||||||
@@ -156,6 +179,7 @@ class IncomingVerificationPresenter(
|
|||||||
StateMachineState.RejectingIncomingVerification,
|
StateMachineState.RejectingIncomingVerification,
|
||||||
null -> {
|
null -> {
|
||||||
Step.Initial(
|
Step.Initial(
|
||||||
|
currentUser = currentUser,
|
||||||
deviceDisplayName = sessionVerificationRequestDetails.deviceDisplayName,
|
deviceDisplayName = sessionVerificationRequestDetails.deviceDisplayName,
|
||||||
deviceId = sessionVerificationRequestDetails.deviceId,
|
deviceId = sessionVerificationRequestDetails.deviceId,
|
||||||
formattedSignInTime = formattedSignInTime,
|
formattedSignInTime = formattedSignInTime,
|
||||||
|
|||||||
+2
@@ -10,6 +10,7 @@ package io.element.android.features.verifysession.impl.incoming
|
|||||||
|
|
||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
import io.element.android.libraries.matrix.api.core.DeviceId
|
import io.element.android.libraries.matrix.api.core.DeviceId
|
||||||
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
|
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
|
||||||
import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ data class IncomingVerificationState(
|
|||||||
@Stable
|
@Stable
|
||||||
sealed interface Step {
|
sealed interface Step {
|
||||||
data class Initial(
|
data class Initial(
|
||||||
|
val currentUser: MatrixUser,
|
||||||
val deviceDisplayName: String?,
|
val deviceDisplayName: String?,
|
||||||
val deviceId: DeviceId,
|
val deviceId: DeviceId,
|
||||||
val formattedSignInTime: String,
|
val formattedSignInTime: String,
|
||||||
|
|||||||
+6
@@ -13,6 +13,7 @@ import io.element.android.features.verifysession.impl.incoming.IncomingVerificat
|
|||||||
import io.element.android.features.verifysession.impl.ui.aDecimalsSessionVerificationData
|
import io.element.android.features.verifysession.impl.ui.aDecimalsSessionVerificationData
|
||||||
import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData
|
import io.element.android.features.verifysession.impl.ui.aEmojisSessionVerificationData
|
||||||
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
|
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
|
||||||
|
import io.element.android.libraries.designsystem.preview.USER_NAME_BOB
|
||||||
import io.element.android.libraries.matrix.api.core.DeviceId
|
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.core.FlowId
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
@@ -50,6 +51,11 @@ open class IncomingVerificationStatePreviewParam : PreviewParameterProvider<Inco
|
|||||||
internal fun aStepInitial(
|
internal fun aStepInitial(
|
||||||
isWaiting: Boolean = false,
|
isWaiting: Boolean = false,
|
||||||
) = Step.Initial(
|
) = Step.Initial(
|
||||||
|
currentUser = MatrixUser(
|
||||||
|
userId = UserId("@bob:example.com"),
|
||||||
|
displayName = USER_NAME_BOB,
|
||||||
|
avatarUrl = null,
|
||||||
|
),
|
||||||
deviceDisplayName = "Element X Android",
|
deviceDisplayName = "Element X Android",
|
||||||
deviceId = DeviceId("ILAKNDNASDLK"),
|
deviceId = DeviceId("ILAKNDNASDLK"),
|
||||||
formattedSignInTime = "12:34",
|
formattedSignInTime = "12:34",
|
||||||
|
|||||||
+12
-5
@@ -31,7 +31,6 @@ import io.element.android.compound.theme.ElementTheme
|
|||||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
import io.element.android.features.verifysession.impl.R
|
import io.element.android.features.verifysession.impl.R
|
||||||
import io.element.android.features.verifysession.impl.incoming.IncomingVerificationState.Step
|
import io.element.android.features.verifysession.impl.incoming.IncomingVerificationState.Step
|
||||||
import io.element.android.features.verifysession.impl.incoming.ui.SessionDetailsView
|
|
||||||
import io.element.android.features.verifysession.impl.ui.VerificationBottomMenu
|
import io.element.android.features.verifysession.impl.ui.VerificationBottomMenu
|
||||||
import io.element.android.features.verifysession.impl.ui.VerificationContentVerifying
|
import io.element.android.features.verifysession.impl.ui.VerificationContentVerifying
|
||||||
import io.element.android.features.verifysession.impl.ui.VerificationUserProfileContent
|
import io.element.android.features.verifysession.impl.ui.VerificationUserProfileContent
|
||||||
@@ -47,6 +46,9 @@ import io.element.android.libraries.designsystem.theme.components.TextButton
|
|||||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
|
import io.element.android.libraries.matrix.api.verification.SessionVerificationData
|
||||||
import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
||||||
|
import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCard
|
||||||
|
import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCardMode
|
||||||
|
import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCardState
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -173,10 +175,15 @@ private fun ContentInitial(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
) {
|
) {
|
||||||
SessionDetailsView(
|
AccountInfoCard(
|
||||||
deviceName = stepInitial.deviceDisplayName,
|
state = AccountInfoCardState(
|
||||||
deviceId = stepInitial.deviceId,
|
matrixUser = stepInitial.currentUser,
|
||||||
signInFormattedTimestamp = stepInitial.formattedSignInTime,
|
mode = AccountInfoCardMode.DeviceVerification(
|
||||||
|
deviceName = stepInitial.deviceDisplayName,
|
||||||
|
deviceId = stepInitial.deviceId,
|
||||||
|
signInFormattedTimestamp = stepInitial.formattedSignInTime,
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
-103
@@ -1,103 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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.features.verifysession.impl.incoming.ui
|
|
||||||
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
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.semantics.semantics
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import io.element.android.compound.theme.ElementTheme
|
|
||||||
import io.element.android.features.verifysession.impl.R
|
|
||||||
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom
|
|
||||||
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize
|
|
||||||
import io.element.android.libraries.designsystem.atomic.molecules.TextWithLabelMolecule
|
|
||||||
import io.element.android.libraries.designsystem.icons.CompoundDrawables
|
|
||||||
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.libraries.matrix.api.core.DeviceId
|
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SessionDetailsView(
|
|
||||||
deviceName: String?,
|
|
||||||
deviceId: DeviceId,
|
|
||||||
signInFormattedTimestamp: String,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.border(
|
|
||||||
width = 1.dp,
|
|
||||||
color = ElementTheme.colors.borderDisabled,
|
|
||||||
shape = RoundedCornerShape(8.dp)
|
|
||||||
)
|
|
||||||
.padding(24.dp)
|
|
||||||
.semantics(mergeDescendants = true) {},
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
RoundedIconAtom(
|
|
||||||
modifier = Modifier,
|
|
||||||
size = RoundedIconAtomSize.Big,
|
|
||||||
resourceId = CompoundDrawables.ic_compound_devices
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = deviceName ?: deviceId.value,
|
|
||||||
style = ElementTheme.typography.fontBodyMdMedium,
|
|
||||||
color = ElementTheme.colors.textPrimary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
TextWithLabelMolecule(
|
|
||||||
label = stringResource(R.string.screen_session_verification_request_details_timestamp),
|
|
||||||
text = signInFormattedTimestamp,
|
|
||||||
modifier = Modifier.weight(2f),
|
|
||||||
)
|
|
||||||
TextWithLabelMolecule(
|
|
||||||
label = stringResource(CommonStrings.common_device_id),
|
|
||||||
text = deviceId.value,
|
|
||||||
modifier = Modifier.weight(5f),
|
|
||||||
spellText = true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PreviewsDayNight
|
|
||||||
@Composable
|
|
||||||
internal fun SessionDetailsViewPreview() = ElementPreview {
|
|
||||||
Column {
|
|
||||||
SessionDetailsView(
|
|
||||||
deviceName = "Element X Android",
|
|
||||||
deviceId = DeviceId("ILAKNDNASDLK"),
|
|
||||||
signInFormattedTimestamp = "12:34",
|
|
||||||
)
|
|
||||||
SessionDetailsView(
|
|
||||||
deviceName = null,
|
|
||||||
deviceId = DeviceId("ILAKNDNASDLK"),
|
|
||||||
signInFormattedTimestamp = "12:34",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+46
@@ -18,10 +18,16 @@ import io.element.android.libraries.matrix.api.verification.SessionVerificationR
|
|||||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||||
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
|
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
|
||||||
import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
||||||
|
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
||||||
import io.element.android.libraries.matrix.test.A_DEVICE_ID
|
import io.element.android.libraries.matrix.test.A_DEVICE_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
import io.element.android.libraries.matrix.test.A_TIMESTAMP
|
import io.element.android.libraries.matrix.test.A_TIMESTAMP
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||||
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
|
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
|
||||||
|
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||||
|
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
|
||||||
|
import io.element.android.libraries.sessionstorage.test.aSessionData
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
import io.element.android.tests.testutils.lambda.lambdaError
|
import io.element.android.tests.testutils.lambda.lambdaError
|
||||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
@@ -41,6 +47,39 @@ class IncomingVerificationPresenterTest {
|
|||||||
@get:Rule
|
@get:Rule
|
||||||
val warmUpRule = WarmUpRule()
|
val warmUpRule = WarmUpRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - the current user is built from the stored session`() = runTest {
|
||||||
|
createPresenter(
|
||||||
|
service = FakeSessionVerificationService(
|
||||||
|
acknowledgeVerificationRequestLambda = { },
|
||||||
|
resetLambda = { },
|
||||||
|
),
|
||||||
|
sessionStore = InMemorySessionStore(
|
||||||
|
initialList = listOf(
|
||||||
|
aSessionData(
|
||||||
|
sessionId = A_SESSION_ID.value,
|
||||||
|
userDisplayName = A_USER_NAME,
|
||||||
|
userAvatarUrl = AN_AVATAR_URL,
|
||||||
|
userAvatarData = "avatarData",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).test {
|
||||||
|
val step = awaitItem().step as IncomingVerificationState.Step.Initial
|
||||||
|
assertThat(step.currentUser).isEqualTo(MatrixUser(A_SESSION_ID))
|
||||||
|
val stepWithUser = awaitItem().step as IncomingVerificationState.Step.Initial
|
||||||
|
assertThat(stepWithUser.currentUser).isEqualTo(
|
||||||
|
MatrixUser(
|
||||||
|
userId = A_SESSION_ID,
|
||||||
|
displayName = A_USER_NAME,
|
||||||
|
avatarUrl = AN_AVATAR_URL,
|
||||||
|
avatarThumbnail = "avatarData",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - nominal case - incoming verification successful`() = runTest {
|
fun `present - nominal case - incoming verification successful`() = runTest {
|
||||||
val acknowledgeVerificationRequestLambda = lambdaRecorder<VerificationRequest.Incoming, Unit> { _ -> }
|
val acknowledgeVerificationRequestLambda = lambdaRecorder<VerificationRequest.Incoming, Unit> { _ -> }
|
||||||
@@ -59,6 +98,7 @@ class IncomingVerificationPresenterTest {
|
|||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.step).isEqualTo(
|
assertThat(initialState.step).isEqualTo(
|
||||||
IncomingVerificationState.Step.Initial(
|
IncomingVerificationState.Step.Initial(
|
||||||
|
currentUser = MatrixUser(A_SESSION_ID),
|
||||||
deviceDisplayName = "a device name",
|
deviceDisplayName = "a device name",
|
||||||
deviceId = A_DEVICE_ID,
|
deviceId = A_DEVICE_ID,
|
||||||
formattedSignInTime = "567 TimeOrDate false",
|
formattedSignInTime = "567 TimeOrDate false",
|
||||||
@@ -128,6 +168,7 @@ class IncomingVerificationPresenterTest {
|
|||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.step).isEqualTo(
|
assertThat(initialState.step).isEqualTo(
|
||||||
IncomingVerificationState.Step.Initial(
|
IncomingVerificationState.Step.Initial(
|
||||||
|
currentUser = MatrixUser(A_SESSION_ID),
|
||||||
deviceDisplayName = "a device name",
|
deviceDisplayName = "a device name",
|
||||||
deviceId = A_DEVICE_ID,
|
deviceId = A_DEVICE_ID,
|
||||||
formattedSignInTime = "567 TimeOrDate false",
|
formattedSignInTime = "567 TimeOrDate false",
|
||||||
@@ -193,6 +234,7 @@ class IncomingVerificationPresenterTest {
|
|||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.step).isEqualTo(
|
assertThat(initialState.step).isEqualTo(
|
||||||
IncomingVerificationState.Step.Initial(
|
IncomingVerificationState.Step.Initial(
|
||||||
|
currentUser = MatrixUser(A_SESSION_ID),
|
||||||
deviceDisplayName = "a device name",
|
deviceDisplayName = "a device name",
|
||||||
deviceId = A_DEVICE_ID,
|
deviceId = A_DEVICE_ID,
|
||||||
formattedSignInTime = "567 TimeOrDate false",
|
formattedSignInTime = "567 TimeOrDate false",
|
||||||
@@ -228,6 +270,7 @@ class IncomingVerificationPresenterTest {
|
|||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.step).isEqualTo(
|
assertThat(initialState.step).isEqualTo(
|
||||||
IncomingVerificationState.Step.Initial(
|
IncomingVerificationState.Step.Initial(
|
||||||
|
currentUser = MatrixUser(A_SESSION_ID),
|
||||||
deviceDisplayName = "a device name",
|
deviceDisplayName = "a device name",
|
||||||
deviceId = A_DEVICE_ID,
|
deviceId = A_DEVICE_ID,
|
||||||
formattedSignInTime = "567 TimeOrDate false",
|
formattedSignInTime = "567 TimeOrDate false",
|
||||||
@@ -315,10 +358,13 @@ internal fun TestScope.createPresenter(
|
|||||||
navigator: IncomingVerificationNavigator = IncomingVerificationNavigator { lambdaError() },
|
navigator: IncomingVerificationNavigator = IncomingVerificationNavigator { lambdaError() },
|
||||||
service: SessionVerificationService = FakeSessionVerificationService(),
|
service: SessionVerificationService = FakeSessionVerificationService(),
|
||||||
dateFormatter: DateFormatter = FakeDateFormatter(),
|
dateFormatter: DateFormatter = FakeDateFormatter(),
|
||||||
|
sessionStore: SessionStore = InMemorySessionStore(),
|
||||||
) = IncomingVerificationPresenter(
|
) = IncomingVerificationPresenter(
|
||||||
verificationRequest = verificationRequest,
|
verificationRequest = verificationRequest,
|
||||||
navigator = navigator,
|
navigator = navigator,
|
||||||
sessionVerificationService = service,
|
sessionVerificationService = service,
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
sessionStore = sessionStore,
|
||||||
stateMachine = IncomingVerificationStateMachine(service),
|
stateMachine = IncomingVerificationStateMachine(service),
|
||||||
dateFormatter = dateFormatter,
|
dateFormatter = dateFormatter,
|
||||||
sessionCoroutineScope = backgroundScope,
|
sessionCoroutineScope = backgroundScope,
|
||||||
|
|||||||
+1
@@ -245,6 +245,7 @@ class RustSessionVerificationService(
|
|||||||
|
|
||||||
fun destroy() {
|
fun destroy() {
|
||||||
Timber.d("Destroying RustSessionVerificationService")
|
Timber.d("Destroying RustSessionVerificationService")
|
||||||
|
listener = null
|
||||||
verificationStateListenerTaskHandle.cancelAndDestroy()
|
verificationStateListenerTaskHandle.cancelAndDestroy()
|
||||||
recoveryStateListenerTaskHandle.cancelAndDestroy()
|
recoveryStateListenerTaskHandle.cancelAndDestroy()
|
||||||
if (this::verificationController.isInitialized) {
|
if (this::verificationController.isInitialized) {
|
||||||
|
|||||||
+1
-2
@@ -127,8 +127,7 @@ private fun DeviceInfoRow(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
TextWithLabelMolecule(
|
TextWithLabelMolecule(
|
||||||
// TODO i18n
|
label = stringResource(CommonStrings.common_signed_in),
|
||||||
label = "Signed in",
|
|
||||||
text = mode.signInFormattedTimestamp,
|
text = mode.signInFormattedTimestamp,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
|
|||||||
-1
@@ -24,7 +24,6 @@ sealed interface AccountInfoCardMode {
|
|||||||
val otherUser: MatrixUser,
|
val otherUser: MatrixUser,
|
||||||
) : AccountInfoCardMode
|
) : AccountInfoCardMode
|
||||||
|
|
||||||
// TODO Replace SessionDetailsView with this component
|
|
||||||
data class DeviceVerification(
|
data class DeviceVerification(
|
||||||
val deviceName: String?,
|
val deviceName: String?,
|
||||||
val deviceId: DeviceId,
|
val deviceId: DeviceId,
|
||||||
|
|||||||
@@ -413,6 +413,7 @@ Reason: %1$s."</string>
|
|||||||
<string name="common_shared_live_location">"Shared live location"</string>
|
<string name="common_shared_live_location">"Shared live location"</string>
|
||||||
<string name="common_shared_location">"Shared location"</string>
|
<string name="common_shared_location">"Shared location"</string>
|
||||||
<string name="common_shared_space">"Shared space"</string>
|
<string name="common_shared_space">"Shared space"</string>
|
||||||
|
<string name="common_signed_in">"Signed in"</string>
|
||||||
<string name="common_signing_out">"Removing device"</string>
|
<string name="common_signing_out">"Removing device"</string>
|
||||||
<string name="common_something_went_wrong">"Something went wrong"</string>
|
<string name="common_something_went_wrong">"Something went wrong"</string>
|
||||||
<string name="common_something_went_wrong_message">"We encountered an issue. Please try again."</string>
|
<string name="common_something_went_wrong_message">"We encountered an issue. Please try again."</string>
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Reference in New Issue
Block a user