mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-10-31 16:22:49 +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.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
|
||||
|
||||
+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.designsystem)
|
||||
implementation(projects.libraries.preferences.api)
|
||||
implementation(projects.libraries.sessionStorage.api)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.features.logout.api)
|
||||
api(libs.statemachine)
|
||||
@@ -44,4 +45,5 @@ dependencies {
|
||||
testImplementation(projects.libraries.dateformatter.test)
|
||||
testImplementation(projects.libraries.matrix.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.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
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.DateFormatterMode
|
||||
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.SessionVerificationService
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
@@ -45,6 +50,8 @@ class IncomingVerificationPresenter(
|
||||
@Assisted private val navigator: IncomingVerificationNavigator,
|
||||
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
|
||||
private val sessionVerificationService: SessionVerificationService,
|
||||
private val sessionId: SessionId,
|
||||
private val sessionStore: SessionStore,
|
||||
private val stateMachine: IncomingVerificationStateMachine,
|
||||
private val dateFormatter: DateFormatter,
|
||||
) : Presenter<IncomingVerificationState> {
|
||||
@@ -92,9 +99,24 @@ class IncomingVerificationPresenter(
|
||||
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 {
|
||||
derivedStateOf {
|
||||
stateAndDispatch.state.value.toVerificationStep(
|
||||
currentUser = currentUser,
|
||||
sessionVerificationRequestDetails = verificationRequest.details,
|
||||
formattedSignInTime = formattedSignInTime,
|
||||
)
|
||||
@@ -147,6 +169,7 @@ class IncomingVerificationPresenter(
|
||||
}
|
||||
|
||||
private fun StateMachineState?.toVerificationStep(
|
||||
currentUser: MatrixUser,
|
||||
sessionVerificationRequestDetails: SessionVerificationRequestDetails,
|
||||
formattedSignInTime: String,
|
||||
): Step =
|
||||
@@ -156,6 +179,7 @@ class IncomingVerificationPresenter(
|
||||
StateMachineState.RejectingIncomingVerification,
|
||||
null -> {
|
||||
Step.Initial(
|
||||
currentUser = currentUser,
|
||||
deviceDisplayName = sessionVerificationRequestDetails.deviceDisplayName,
|
||||
deviceId = sessionVerificationRequestDetails.deviceId,
|
||||
formattedSignInTime = formattedSignInTime,
|
||||
|
||||
+2
@@ -10,6 +10,7 @@ package io.element.android.features.verifysession.impl.incoming
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
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.VerificationRequest
|
||||
|
||||
@@ -21,6 +22,7 @@ data class IncomingVerificationState(
|
||||
@Stable
|
||||
sealed interface Step {
|
||||
data class Initial(
|
||||
val currentUser: MatrixUser,
|
||||
val deviceDisplayName: String?,
|
||||
val deviceId: DeviceId,
|
||||
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.aEmojisSessionVerificationData
|
||||
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.FlowId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
@@ -50,6 +51,11 @@ open class IncomingVerificationStatePreviewParam : PreviewParameterProvider<Inco
|
||||
internal fun aStepInitial(
|
||||
isWaiting: Boolean = false,
|
||||
) = Step.Initial(
|
||||
currentUser = MatrixUser(
|
||||
userId = UserId("@bob:example.com"),
|
||||
displayName = USER_NAME_BOB,
|
||||
avatarUrl = null,
|
||||
),
|
||||
deviceDisplayName = "Element X Android",
|
||||
deviceId = DeviceId("ILAKNDNASDLK"),
|
||||
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.features.verifysession.impl.R
|
||||
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.VerificationContentVerifying
|
||||
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.matrix.api.verification.SessionVerificationData
|
||||
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
|
||||
|
||||
/**
|
||||
@@ -173,10 +175,15 @@ private fun ContentInitial(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
SessionDetailsView(
|
||||
deviceName = stepInitial.deviceDisplayName,
|
||||
deviceId = stepInitial.deviceId,
|
||||
signInFormattedTimestamp = stepInitial.formattedSignInTime,
|
||||
AccountInfoCard(
|
||||
state = AccountInfoCardState(
|
||||
matrixUser = stepInitial.currentUser,
|
||||
mode = AccountInfoCardMode.DeviceVerification(
|
||||
deviceName = stepInitial.deviceDisplayName,
|
||||
deviceId = stepInitial.deviceId,
|
||||
signInFormattedTimestamp = stepInitial.formattedSignInTime,
|
||||
),
|
||||
),
|
||||
)
|
||||
Text(
|
||||
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.VerificationFlowState
|
||||
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_SESSION_ID
|
||||
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_NAME
|
||||
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.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
@@ -41,6 +47,39 @@ class IncomingVerificationPresenterTest {
|
||||
@get:Rule
|
||||
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
|
||||
fun `present - nominal case - incoming verification successful`() = runTest {
|
||||
val acknowledgeVerificationRequestLambda = lambdaRecorder<VerificationRequest.Incoming, Unit> { _ -> }
|
||||
@@ -59,6 +98,7 @@ class IncomingVerificationPresenterTest {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.step).isEqualTo(
|
||||
IncomingVerificationState.Step.Initial(
|
||||
currentUser = MatrixUser(A_SESSION_ID),
|
||||
deviceDisplayName = "a device name",
|
||||
deviceId = A_DEVICE_ID,
|
||||
formattedSignInTime = "567 TimeOrDate false",
|
||||
@@ -128,6 +168,7 @@ class IncomingVerificationPresenterTest {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.step).isEqualTo(
|
||||
IncomingVerificationState.Step.Initial(
|
||||
currentUser = MatrixUser(A_SESSION_ID),
|
||||
deviceDisplayName = "a device name",
|
||||
deviceId = A_DEVICE_ID,
|
||||
formattedSignInTime = "567 TimeOrDate false",
|
||||
@@ -193,6 +234,7 @@ class IncomingVerificationPresenterTest {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.step).isEqualTo(
|
||||
IncomingVerificationState.Step.Initial(
|
||||
currentUser = MatrixUser(A_SESSION_ID),
|
||||
deviceDisplayName = "a device name",
|
||||
deviceId = A_DEVICE_ID,
|
||||
formattedSignInTime = "567 TimeOrDate false",
|
||||
@@ -228,6 +270,7 @@ class IncomingVerificationPresenterTest {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.step).isEqualTo(
|
||||
IncomingVerificationState.Step.Initial(
|
||||
currentUser = MatrixUser(A_SESSION_ID),
|
||||
deviceDisplayName = "a device name",
|
||||
deviceId = A_DEVICE_ID,
|
||||
formattedSignInTime = "567 TimeOrDate false",
|
||||
@@ -315,10 +358,13 @@ internal fun TestScope.createPresenter(
|
||||
navigator: IncomingVerificationNavigator = IncomingVerificationNavigator { lambdaError() },
|
||||
service: SessionVerificationService = FakeSessionVerificationService(),
|
||||
dateFormatter: DateFormatter = FakeDateFormatter(),
|
||||
sessionStore: SessionStore = InMemorySessionStore(),
|
||||
) = IncomingVerificationPresenter(
|
||||
verificationRequest = verificationRequest,
|
||||
navigator = navigator,
|
||||
sessionVerificationService = service,
|
||||
sessionId = A_SESSION_ID,
|
||||
sessionStore = sessionStore,
|
||||
stateMachine = IncomingVerificationStateMachine(service),
|
||||
dateFormatter = dateFormatter,
|
||||
sessionCoroutineScope = backgroundScope,
|
||||
|
||||
+1
@@ -245,6 +245,7 @@ class RustSessionVerificationService(
|
||||
|
||||
fun destroy() {
|
||||
Timber.d("Destroying RustSessionVerificationService")
|
||||
listener = null
|
||||
verificationStateListenerTaskHandle.cancelAndDestroy()
|
||||
recoveryStateListenerTaskHandle.cancelAndDestroy()
|
||||
if (this::verificationController.isInitialized) {
|
||||
|
||||
+1
-2
@@ -127,8 +127,7 @@ private fun DeviceInfoRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
TextWithLabelMolecule(
|
||||
// TODO i18n
|
||||
label = "Signed in",
|
||||
label = stringResource(CommonStrings.common_signed_in),
|
||||
text = mode.signInFormattedTimestamp,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
|
||||
-1
@@ -24,7 +24,6 @@ sealed interface AccountInfoCardMode {
|
||||
val otherUser: MatrixUser,
|
||||
) : AccountInfoCardMode
|
||||
|
||||
// TODO Replace SessionDetailsView with this component
|
||||
data class DeviceVerification(
|
||||
val deviceName: String?,
|
||||
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_location">"Shared location"</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_something_went_wrong">"Something went wrong"</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