mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-10-31 16:22:49 +00:00
Merge pull request #7882 from element-hq/feature/bma/multiAccountShareAtRoot
Keep the incoming share displayed when the current account is signed out
This commit is contained in:
@@ -8,9 +8,15 @@
|
|||||||
|
|
||||||
package io.element.android.appnav
|
package io.element.android.appnav
|
||||||
|
|
||||||
|
import com.bumble.appyx.core.navigation.NavKey
|
||||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||||
|
import com.bumble.appyx.navmodel.backstack.BackStackElement
|
||||||
|
import com.bumble.appyx.navmodel.backstack.BackStackElements
|
||||||
|
import com.bumble.appyx.navmodel.backstack.operation.BackStackOperation
|
||||||
import com.bumble.appyx.navmodel.backstack.operation.NewRoot
|
import com.bumble.appyx.navmodel.backstack.operation.NewRoot
|
||||||
import com.bumble.appyx.navmodel.backstack.operation.Remove
|
import com.bumble.appyx.navmodel.backstack.operation.Remove
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
import kotlinx.parcelize.RawValue
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Don't process NewRoot if the nav target already exists in the stack.
|
* Don't process NewRoot if the nav target already exists in the stack.
|
||||||
@@ -32,3 +38,45 @@ fun <T : Any> BackStack<T>.removeLast(element: T) {
|
|||||||
} ?: return
|
} ?: return
|
||||||
accept(Remove(lastExpectedNavElement.key))
|
accept(Remove(lastExpectedNavElement.key))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the root element of the backstack, and keep the elements on top of it, contrary to [safeRoot] which removes all the elements.
|
||||||
|
*/
|
||||||
|
fun <T : Any> BackStack<T>.replaceRoot(element: T) {
|
||||||
|
accept(ReplaceRoot(element))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the root element of the backstack, keeping the elements on top of it.
|
||||||
|
*
|
||||||
|
* Operation:
|
||||||
|
*
|
||||||
|
* [A, B, C] + ReplaceRoot(D) = [D, B, C]
|
||||||
|
* [A] + ReplaceRoot(D) = [D]
|
||||||
|
*/
|
||||||
|
@Parcelize
|
||||||
|
internal data class ReplaceRoot<T : Any>(
|
||||||
|
private val element: @RawValue T
|
||||||
|
) : BackStackOperation<T> {
|
||||||
|
override fun isApplicable(elements: BackStackElements<T>): Boolean =
|
||||||
|
elements.rootElement()?.key?.navTarget != element
|
||||||
|
|
||||||
|
override fun invoke(elements: BackStackElements<T>): BackStackElements<T> {
|
||||||
|
val root = elements.rootElement() ?: return elements
|
||||||
|
if (root.targetState == BackStack.State.ACTIVE) {
|
||||||
|
// Nothing on top of the root, so this is the same as NewRoot
|
||||||
|
return NewRoot(element).invoke(elements)
|
||||||
|
}
|
||||||
|
// The root is stashed below other elements: drop it, as Remove does for a non active element,
|
||||||
|
// and add the new root, stashed, in place of it.
|
||||||
|
val newRoot = BackStackElement(
|
||||||
|
key = NavKey(element),
|
||||||
|
fromState = BackStack.State.STASHED,
|
||||||
|
targetState = BackStack.State.STASHED,
|
||||||
|
operation = this,
|
||||||
|
)
|
||||||
|
return elements.map { if (it == root) newRoot else it }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun BackStackElements<T>.rootElement() = firstOrNull { it.targetState != BackStack.State.DESTROYED }
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,8 +33,6 @@ import io.element.android.libraries.architecture.createNode
|
|||||||
import io.element.android.libraries.architecture.inputs
|
import io.element.android.libraries.architecture.inputs
|
||||||
import io.element.android.libraries.di.DependencyInjectionGraphOwner
|
import io.element.android.libraries.di.DependencyInjectionGraphOwner
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
|
||||||
import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder
|
import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
@@ -61,7 +59,6 @@ class LoggedInAppScopeFlowNode(
|
|||||||
interface Callback : Plugin {
|
interface Callback : Plugin {
|
||||||
fun navigateToBugReport()
|
fun navigateToBugReport()
|
||||||
fun navigateToAddAccount()
|
fun navigateToAddAccount()
|
||||||
fun switchAccountAndOpenRoom(sessionId: SessionId, roomId: RoomId?)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private val callback: Callback = callback()
|
private val callback: Callback = callback()
|
||||||
@@ -94,10 +91,6 @@ class LoggedInAppScopeFlowNode(
|
|||||||
override fun navigateToAddAccount() {
|
override fun navigateToAddAccount() {
|
||||||
callback.navigateToAddAccount()
|
callback.navigateToAddAccount()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun switchAccountAndOpenRoom(sessionId: SessionId, roomId: RoomId?) {
|
|
||||||
callback.switchAccountAndOpenRoom(sessionId, roomId)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return createNode<LoggedInFlowNode>(buildContext, listOf(callback))
|
return createNode<LoggedInFlowNode>(buildContext, listOf(callback))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,8 +67,6 @@ import io.element.android.features.preferences.api.PreferencesEntryPoint
|
|||||||
import io.element.android.features.roomdirectory.api.RoomDescription
|
import io.element.android.features.roomdirectory.api.RoomDescription
|
||||||
import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint
|
import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint
|
||||||
import io.element.android.features.securebackup.api.SecureBackupEntryPoint
|
import io.element.android.features.securebackup.api.SecureBackupEntryPoint
|
||||||
import io.element.android.features.share.api.ShareEntryPoint
|
|
||||||
import io.element.android.features.share.api.ShareIntentData
|
|
||||||
import io.element.android.features.startchat.api.StartChatEntryPoint
|
import io.element.android.features.startchat.api.StartChatEntryPoint
|
||||||
import io.element.android.features.userprofile.api.UserProfileEntryPoint
|
import io.element.android.features.userprofile.api.UserProfileEntryPoint
|
||||||
import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint
|
import io.element.android.features.verifysession.api.IncomingVerificationEntryPoint
|
||||||
@@ -88,7 +86,6 @@ import io.element.android.libraries.matrix.api.MatrixClient
|
|||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
import io.element.android.libraries.matrix.api.core.UserId
|
||||||
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
|
||||||
@@ -138,7 +135,6 @@ class LoggedInFlowNode(
|
|||||||
private val sessionCoroutineScope: CoroutineScope,
|
private val sessionCoroutineScope: CoroutineScope,
|
||||||
private val ftueService: FtueService,
|
private val ftueService: FtueService,
|
||||||
private val roomDirectoryEntryPoint: RoomDirectoryEntryPoint,
|
private val roomDirectoryEntryPoint: RoomDirectoryEntryPoint,
|
||||||
private val shareEntryPoint: ShareEntryPoint,
|
|
||||||
private val matrixClient: MatrixClient,
|
private val matrixClient: MatrixClient,
|
||||||
private val sendingQueue: SendQueues,
|
private val sendingQueue: SendQueues,
|
||||||
private val nativeCallHost: NativeCallHost,
|
private val nativeCallHost: NativeCallHost,
|
||||||
@@ -174,7 +170,6 @@ class LoggedInFlowNode(
|
|||||||
interface Callback : Plugin {
|
interface Callback : Plugin {
|
||||||
fun navigateToBugReport()
|
fun navigateToBugReport()
|
||||||
fun navigateToAddAccount()
|
fun navigateToAddAccount()
|
||||||
fun switchAccountAndOpenRoom(sessionId: SessionId, roomId: RoomId?)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private val callback: Callback = callback()
|
private val callback: Callback = callback()
|
||||||
@@ -296,9 +291,6 @@ class LoggedInFlowNode(
|
|||||||
@Parcelize
|
@Parcelize
|
||||||
data object RoomDirectory : NavTarget
|
data object RoomDirectory : NavTarget
|
||||||
|
|
||||||
@Parcelize
|
|
||||||
data class IncomingShare(val shareIntentData: ShareIntentData) : NavTarget
|
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class IncomingVerificationRequest(val data: VerificationRequest.Incoming) : NavTarget
|
data class IncomingVerificationRequest(val data: VerificationRequest.Incoming) : NavTarget
|
||||||
}
|
}
|
||||||
@@ -570,36 +562,6 @@ class LoggedInFlowNode(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is NavTarget.IncomingShare -> {
|
|
||||||
shareEntryPoint.createNode(
|
|
||||||
parentNode = this,
|
|
||||||
buildContext = buildContext,
|
|
||||||
params = ShareEntryPoint.Params(shareIntentData = navTarget.shareIntentData),
|
|
||||||
callback = object : ShareEntryPoint.Callback {
|
|
||||||
override fun onDone(sessionId: SessionId, roomIds: List<RoomId>) {
|
|
||||||
// Remove the incoming share screen
|
|
||||||
backstack.pop()
|
|
||||||
|
|
||||||
if (sessionId != matrixClient.sessionId) {
|
|
||||||
// The data has been shared using another session, switch to it
|
|
||||||
callback.switchAccountAndOpenRoom(sessionId, roomIds.singleOrNull())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigate to the room if the text/media was shared to a single one
|
|
||||||
roomIds.singleOrNull()?.let { roomId ->
|
|
||||||
lifecycleScope.launch {
|
|
||||||
// Wait until the incoming share screen is removed
|
|
||||||
backstack.elements.first { it.lastOrNull()?.key?.navTarget !is NavTarget.IncomingShare }
|
|
||||||
|
|
||||||
// Then attach the room
|
|
||||||
attachRoom(roomId.toRoomIdOrAlias(), clearBackstack = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
is NavTarget.IncomingVerificationRequest -> {
|
is NavTarget.IncomingVerificationRequest -> {
|
||||||
incomingVerificationEntryPoint.createNode(
|
incomingVerificationEntryPoint.createNode(
|
||||||
parentNode = this,
|
parentNode = this,
|
||||||
@@ -615,6 +577,15 @@ class LoggedInFlowNode(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for the home screen to be attached, i.e. for the FTUE to be complete.
|
||||||
|
*/
|
||||||
|
internal suspend fun waitForHome() {
|
||||||
|
waitForNavTargetAttached { navTarget ->
|
||||||
|
navTarget is NavTarget.Home
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun attachRoom(
|
suspend fun attachRoom(
|
||||||
roomIdOrAlias: RoomIdOrAlias,
|
roomIdOrAlias: RoomIdOrAlias,
|
||||||
serverNames: List<String> = emptyList(),
|
serverNames: List<String> = emptyList(),
|
||||||
@@ -659,17 +630,6 @@ class LoggedInFlowNode(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal suspend fun attachIncomingShare(shareIntentData: ShareIntentData) {
|
|
||||||
waitForNavTargetAttached { navTarget ->
|
|
||||||
navTarget is NavTarget.Home
|
|
||||||
}
|
|
||||||
attachChild<Node> {
|
|
||||||
backstack.push(
|
|
||||||
NavTarget.IncomingShare(shareIntentData)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
val colors by remember {
|
val colors by remember {
|
||||||
|
|||||||
@@ -43,10 +43,12 @@ import io.element.android.appnav.session.MatrixSessionCache
|
|||||||
import io.element.android.appnav.verification.IncomingVerificationRequestData
|
import io.element.android.appnav.verification.IncomingVerificationRequestData
|
||||||
import io.element.android.appnav.verification.IncomingVerificationRequestObserver
|
import io.element.android.appnav.verification.IncomingVerificationRequestObserver
|
||||||
import io.element.android.appnav.verification.OtherSessionIncomingVerificationNode
|
import io.element.android.appnav.verification.OtherSessionIncomingVerificationNode
|
||||||
|
import io.element.android.appnav.verification.OtherSessionVerificationNode
|
||||||
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
|
||||||
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
|
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
|
||||||
|
import io.element.android.features.share.api.ShareEntryPoint
|
||||||
import io.element.android.features.share.api.ShareIntentData
|
import io.element.android.features.share.api.ShareIntentData
|
||||||
import io.element.android.features.signedout.api.SignedOutEntryPoint
|
import io.element.android.features.signedout.api.SignedOutEntryPoint
|
||||||
import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint
|
import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint
|
||||||
@@ -112,6 +114,7 @@ class RootFlowNode(
|
|||||||
private val analyticsService: AnalyticsService,
|
private val analyticsService: AnalyticsService,
|
||||||
private val analyticsColdStartWatcher: AnalyticsColdStartWatcher,
|
private val analyticsColdStartWatcher: AnalyticsColdStartWatcher,
|
||||||
private val incomingVerificationRequestObserver: IncomingVerificationRequestObserver,
|
private val incomingVerificationRequestObserver: IncomingVerificationRequestObserver,
|
||||||
|
private val shareEntryPoint: ShareEntryPoint,
|
||||||
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
|
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
|
||||||
) : BaseFlowNode<RootFlowNode.NavTarget>(
|
) : BaseFlowNode<RootFlowNode.NavTarget>(
|
||||||
backstack = BackStack(
|
backstack = BackStack(
|
||||||
@@ -216,7 +219,7 @@ 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))
|
setRoot(NavTarget.LoggedInFlow(sessionId, navId))
|
||||||
restoreAllSessions()
|
restoreAllSessions()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,6 +245,8 @@ class RootFlowNode(
|
|||||||
.withPreviousValue()
|
.withPreviousValue()
|
||||||
.onEach { (previous, current) ->
|
.onEach { (previous, current) ->
|
||||||
(previous.orEmpty() - current).forEach { sessionId ->
|
(previous.orEmpty() - current).forEach { sessionId ->
|
||||||
|
// The verification of a removed session cannot continue, for instance if the user has signed out from it
|
||||||
|
backstack.removeLast(NavTarget.OtherAccountSessionVerification(sessionId))
|
||||||
Timber.d("Session $sessionId removed, remove it from the cache")
|
Timber.d("Session $sessionId removed, remove it from the cache")
|
||||||
matrixSessionCache.remove(sessionId)
|
matrixSessionCache.remove(sessionId)
|
||||||
}
|
}
|
||||||
@@ -286,7 +291,20 @@ class RootFlowNode(
|
|||||||
|
|
||||||
private fun switchToSignedOutFlow(sessionId: SessionId) {
|
private fun switchToSignedOutFlow(sessionId: SessionId) {
|
||||||
pendingLoginParams = null
|
pendingLoginParams = null
|
||||||
backstack.safeRoot(NavTarget.SignedOutFlow(sessionId))
|
setRoot(NavTarget.SignedOutFlow(sessionId))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the root of the backstack. If an incoming share is displayed, keep it on top of the new root, so that the user can
|
||||||
|
* finish sharing, for instance with another account if the current one has been signed out.
|
||||||
|
*/
|
||||||
|
private fun setRoot(navTarget: NavTarget) {
|
||||||
|
val hasIncomingShare = backstack.elements.value.any { it.key.navTarget is NavTarget.IncomingShare }
|
||||||
|
if (hasIncomingShare) {
|
||||||
|
backstack.replaceRoot(navTarget)
|
||||||
|
} else {
|
||||||
|
backstack.safeRoot(navTarget)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun restoreSessionIfNeeded(
|
private suspend fun restoreSessionIfNeeded(
|
||||||
@@ -367,10 +385,19 @@ class RootFlowNode(
|
|||||||
|
|
||||||
@Parcelize data object BugReport : NavTarget
|
@Parcelize data object BugReport : NavTarget
|
||||||
|
|
||||||
|
@Parcelize data class IncomingShare(
|
||||||
|
val sessionId: SessionId,
|
||||||
|
val shareIntentData: ShareIntentData,
|
||||||
|
) : NavTarget
|
||||||
|
|
||||||
@Parcelize data class OtherAccountIncomingVerificationRequest(
|
@Parcelize data class OtherAccountIncomingVerificationRequest(
|
||||||
val sessionId: SessionId,
|
val sessionId: SessionId,
|
||||||
val verificationRequest: VerificationRequest.Incoming,
|
val verificationRequest: VerificationRequest.Incoming,
|
||||||
) : NavTarget
|
) : NavTarget
|
||||||
|
|
||||||
|
@Parcelize data class OtherAccountSessionVerification(
|
||||||
|
val sessionId: SessionId,
|
||||||
|
) : NavTarget
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||||
@@ -389,15 +416,6 @@ class RootFlowNode(
|
|||||||
override fun navigateToAddAccount() {
|
override fun navigateToAddAccount() {
|
||||||
backstack.push(NavTarget.NotLoggedInFlow(null))
|
backstack.push(NavTarget.NotLoggedInFlow(null))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun switchAccountAndOpenRoom(sessionId: SessionId, roomId: RoomId?) {
|
|
||||||
lifecycleScope.launch {
|
|
||||||
val loggedInFlowNode = attachSession(sessionId)
|
|
||||||
roomId?.let {
|
|
||||||
loggedInFlowNode.attachRoom(it.toRoomIdOrAlias(), clearBackstack = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
val savedNavState = extractSavedStateForNavTarget(navTarget, this.buildContext.savedStateMap)
|
val savedNavState = extractSavedStateForNavTarget(navTarget, this.buildContext.savedStateMap)
|
||||||
val buildContext = if (savedNavState != null) {
|
val buildContext = if (savedNavState != null) {
|
||||||
@@ -451,6 +469,56 @@ class RootFlowNode(
|
|||||||
}
|
}
|
||||||
createNode<OtherSessionIncomingVerificationNode>(buildContext, plugins = listOf(inputs, callback))
|
createNode<OtherSessionIncomingVerificationNode>(buildContext, plugins = listOf(inputs, callback))
|
||||||
}
|
}
|
||||||
|
is NavTarget.OtherAccountSessionVerification -> {
|
||||||
|
if (matrixSessionCache.getOrNull(navTarget.sessionId) == null) {
|
||||||
|
Timber.w("Couldn't find session ${navTarget.sessionId} to verify")
|
||||||
|
lifecycleScope.launch { backstack.pop() }
|
||||||
|
return emptyNode(buildContext)
|
||||||
|
}
|
||||||
|
val inputs = OtherSessionVerificationNode.Inputs(sessionId = navTarget.sessionId)
|
||||||
|
val callback = object : OtherSessionVerificationNode.Callback {
|
||||||
|
override fun onDone() {
|
||||||
|
backstack.pop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
createNode<OtherSessionVerificationNode>(buildContext, plugins = listOf(inputs, callback))
|
||||||
|
}
|
||||||
|
is NavTarget.IncomingShare -> {
|
||||||
|
val callback = object : ShareEntryPoint.Callback {
|
||||||
|
override fun onDone(sessionId: SessionId, roomIds: List<RoomId>) {
|
||||||
|
// Remove the incoming share screen
|
||||||
|
backstack.pop()
|
||||||
|
lifecycleScope.launch {
|
||||||
|
// Switch to the session which has been used to share the data, if necessary,
|
||||||
|
// and navigate to the room if the data was shared to a single one.
|
||||||
|
val loggedInFlowNode = attachSession(sessionId)
|
||||||
|
roomIds.singleOrNull()?.let { roomId ->
|
||||||
|
loggedInFlowNode.attachRoom(roomId.toRoomIdOrAlias(), clearBackstack = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCancel() {
|
||||||
|
backstack.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSessionVerificationRequired(sessionId: SessionId) {
|
||||||
|
val navTarget = NavTarget.OtherAccountSessionVerification(sessionId)
|
||||||
|
if (backstack.elements.value.lastOrNull()?.key?.navTarget != navTarget) {
|
||||||
|
backstack.push(navTarget)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shareEntryPoint.createNode(
|
||||||
|
parentNode = this,
|
||||||
|
buildContext = buildContext,
|
||||||
|
params = ShareEntryPoint.Params(
|
||||||
|
sessionId = navTarget.sessionId,
|
||||||
|
shareIntentData = navTarget.shareIntentData,
|
||||||
|
),
|
||||||
|
callback = callback,
|
||||||
|
)
|
||||||
|
}
|
||||||
NavTarget.BugReport -> {
|
NavTarget.BugReport -> {
|
||||||
val callback = object : BugReportEntryPoint.Callback {
|
val callback = object : BugReportEntryPoint.Callback {
|
||||||
override fun onDone() {
|
override fun onDone() {
|
||||||
@@ -542,9 +610,12 @@ class RootFlowNode(
|
|||||||
// No session, open login
|
// No session, open login
|
||||||
switchToNotLoggedInFlow(null)
|
switchToNotLoggedInFlow(null)
|
||||||
} else {
|
} else {
|
||||||
// wait for the current session to be restored, then attach the incoming share node.
|
// Wait for the current session to be restored and for the FTUE to be complete, so that the session is verified,
|
||||||
// In case of multiple accounts, the user can switch account from the room select screen.
|
// then display the incoming share on top of it.
|
||||||
attachSession(latestSessionId).attachIncomingShare(shareIntentData)
|
// The incoming share does not belong to the current session: in case of multiple accounts, the user can switch account
|
||||||
|
// from the room select screen, and the share is kept if the current session is signed out in the meantime.
|
||||||
|
attachSession(latestSessionId).waitForHome()
|
||||||
|
backstack.push(NavTarget.IncomingShare(latestSessionId, shareIntentData))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+89
@@ -0,0 +1,89 @@
|
|||||||
|
/*
|
||||||
|
* 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.ftue.api.SessionVerificationEntryPoint
|
||||||
|
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 kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the verification of a session which is not the current one, without switching the current session.
|
||||||
|
* It sets up the Session graph of the session to verify.
|
||||||
|
*/
|
||||||
|
@ContributesNode(AppScope::class)
|
||||||
|
@AssistedInject
|
||||||
|
class OtherSessionVerificationNode(
|
||||||
|
@Assisted buildContext: BuildContext,
|
||||||
|
@Assisted plugins: List<Plugin>,
|
||||||
|
sessionGraphFactory: SessionGraphFactory,
|
||||||
|
matrixClientProvider: MatrixClientProvider,
|
||||||
|
private val sessionVerificationEntryPoint: SessionVerificationEntryPoint,
|
||||||
|
) : ParentNode<OtherSessionVerificationNode.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,
|
||||||
|
) : 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 sessionVerificationEntryPoint.createNode(
|
||||||
|
parentNode = this,
|
||||||
|
buildContext = buildContext,
|
||||||
|
callback = object : SessionVerificationEntryPoint.Callback {
|
||||||
|
override fun onDone() {
|
||||||
|
callback.onDone()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun View(modifier: Modifier) {
|
||||||
|
Children(
|
||||||
|
navModel = navModel,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.appnav
|
||||||
|
|
||||||
|
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||||
|
import com.bumble.appyx.navmodel.backstack.operation.pop
|
||||||
|
import com.bumble.appyx.navmodel.backstack.operation.push
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class BackstackExtTest {
|
||||||
|
@Test
|
||||||
|
fun `replaceRoot without element on top replaces the root`() {
|
||||||
|
val backStack = BackStack(initialElement = "A", savedStateMap = null)
|
||||||
|
backStack.replaceRoot("D")
|
||||||
|
assertThat(backStack.navTargets()).containsExactly("D")
|
||||||
|
assertThat(backStack.activeTarget()).isEqualTo("D")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `replaceRoot with elements on top replaces the root and keeps the elements on top`() {
|
||||||
|
val backStack = BackStack(initialElement = "A", savedStateMap = null)
|
||||||
|
backStack.push("B")
|
||||||
|
backStack.push("C")
|
||||||
|
backStack.replaceRoot("D")
|
||||||
|
assertThat(backStack.navTargets()).containsExactly("D", "B", "C").inOrder()
|
||||||
|
assertThat(backStack.activeTarget()).isEqualTo("C")
|
||||||
|
// When the elements on top are removed, the new root is displayed
|
||||||
|
backStack.pop()
|
||||||
|
backStack.pop()
|
||||||
|
assertThat(backStack.activeTarget()).isEqualTo("D")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `replaceRoot with the current root does nothing`() {
|
||||||
|
val backStack = BackStack(initialElement = "A", savedStateMap = null)
|
||||||
|
backStack.push("B")
|
||||||
|
backStack.replaceRoot("A")
|
||||||
|
assertThat(backStack.navTargets()).containsExactly("A", "B").inOrder()
|
||||||
|
assertThat(backStack.activeTarget()).isEqualTo("B")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun BackStack<String>.navTargets() = elements.value
|
||||||
|
.filter { it.targetState != BackStack.State.DESTROYED }
|
||||||
|
.map { it.key.navTarget }
|
||||||
|
|
||||||
|
private fun BackStack<String>.activeTarget() = elements.value
|
||||||
|
.single { it.targetState == BackStack.State.ACTIVE }
|
||||||
|
.key
|
||||||
|
.navTarget
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import android.net.Uri
|
|||||||
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||||
import com.bumble.appyx.core.modality.AncestryInfo
|
import com.bumble.appyx.core.modality.AncestryInfo
|
||||||
import com.bumble.appyx.core.modality.BuildContext
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
|
import com.bumble.appyx.core.node.Node
|
||||||
import com.bumble.appyx.core.node.node
|
import com.bumble.appyx.core.node.node
|
||||||
import com.bumble.appyx.navmodel.backstack.activeElement
|
import com.bumble.appyx.navmodel.backstack.activeElement
|
||||||
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
|
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
|
||||||
@@ -34,6 +35,7 @@ import io.element.android.features.login.test.FakeLoginIntentResolver
|
|||||||
import io.element.android.features.login.test.accesscontrol.FakeAccountProviderAccessControl
|
import io.element.android.features.login.test.accesscontrol.FakeAccountProviderAccessControl
|
||||||
import io.element.android.features.preferences.test.FakeCacheService
|
import io.element.android.features.preferences.test.FakeCacheService
|
||||||
import io.element.android.features.rageshake.test.FakeBugReportEntryPoint
|
import io.element.android.features.rageshake.test.FakeBugReportEntryPoint
|
||||||
|
import io.element.android.features.share.api.ShareEntryPoint
|
||||||
import io.element.android.features.share.test.FakeShareIntentHandler
|
import io.element.android.features.share.test.FakeShareIntentHandler
|
||||||
import io.element.android.features.signedout.test.FakeSignedOutEntryPoint
|
import io.element.android.features.signedout.test.FakeSignedOutEntryPoint
|
||||||
import io.element.android.libraries.accountselect.test.FakeAccountSelectEntryPoint
|
import io.element.android.libraries.accountselect.test.FakeAccountSelectEntryPoint
|
||||||
@@ -52,6 +54,7 @@ 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.services.appnavstate.test.FakeAppForegroundStateService
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaError
|
||||||
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
|
||||||
@@ -192,6 +195,14 @@ class RootFlowNodeTest : RobolectricTest() {
|
|||||||
appForegroundStateService = FakeAppForegroundStateService(),
|
appForegroundStateService = FakeAppForegroundStateService(),
|
||||||
),
|
),
|
||||||
appCoroutineScope = backgroundScope,
|
appCoroutineScope = backgroundScope,
|
||||||
|
shareEntryPoint = object : ShareEntryPoint {
|
||||||
|
override fun createNode(
|
||||||
|
parentNode: Node,
|
||||||
|
buildContext: BuildContext,
|
||||||
|
params: ShareEntryPoint.Params,
|
||||||
|
callback: ShareEntryPoint.Callback,
|
||||||
|
): Node = lambdaError()
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
@@ -72,6 +72,10 @@ class ForwardMessagesNode(
|
|||||||
override fun onCancel() {
|
override fun onCancel() {
|
||||||
callback.onDone(emptyList())
|
callback.onDone(emptyList())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onSessionVerificationRequired(sessionId: SessionId) {
|
||||||
|
// Only called in Share mode
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return roomSelectEntryPoint.createNode(
|
return roomSelectEntryPoint.createNode(
|
||||||
|
|||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
/*
|
||||||
|
* 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.features.ftue.api
|
||||||
|
|
||||||
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
|
import com.bumble.appyx.core.node.Node
|
||||||
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
|
import io.element.android.libraries.architecture.FeatureEntryPoint
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entry point to the session verification flow displayed during the FTUE, to be used outside of the FTUE.
|
||||||
|
* As in the FTUE, the user cannot go back from this flow: they have to verify the session or to sign out.
|
||||||
|
*/
|
||||||
|
interface SessionVerificationEntryPoint : FeatureEntryPoint {
|
||||||
|
fun createNode(
|
||||||
|
parentNode: Node,
|
||||||
|
buildContext: BuildContext,
|
||||||
|
callback: Callback,
|
||||||
|
): Node
|
||||||
|
|
||||||
|
interface Callback : Plugin {
|
||||||
|
/**
|
||||||
|
* Called when the session has been verified.
|
||||||
|
*/
|
||||||
|
fun onDone()
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
/*
|
||||||
|
* 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.features.ftue.impl
|
||||||
|
|
||||||
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
|
import com.bumble.appyx.core.node.Node
|
||||||
|
import dev.zacsweers.metro.AppScope
|
||||||
|
import dev.zacsweers.metro.ContributesBinding
|
||||||
|
import io.element.android.features.ftue.api.SessionVerificationEntryPoint
|
||||||
|
import io.element.android.features.ftue.impl.sessionverification.FtueSessionVerificationFlowNode
|
||||||
|
import io.element.android.libraries.architecture.createNode
|
||||||
|
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
class DefaultSessionVerificationEntryPoint : SessionVerificationEntryPoint {
|
||||||
|
override fun createNode(
|
||||||
|
parentNode: Node,
|
||||||
|
buildContext: BuildContext,
|
||||||
|
callback: SessionVerificationEntryPoint.Callback,
|
||||||
|
): Node {
|
||||||
|
val flowNodeCallback = object : FtueSessionVerificationFlowNode.Callback {
|
||||||
|
override fun onDone() {
|
||||||
|
callback.onDone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parentNode.createNode<FtueSessionVerificationFlowNode>(buildContext, listOf(flowNodeCallback))
|
||||||
|
}
|
||||||
|
}
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
/*
|
||||||
|
* 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.features.ftue.impl
|
||||||
|
|
||||||
|
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
|
||||||
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
|
import com.bumble.appyx.core.node.Node
|
||||||
|
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.features.ftue.api.SessionVerificationEntryPoint
|
||||||
|
import io.element.android.features.ftue.impl.sessionverification.FtueSessionVerificationFlowNode
|
||||||
|
import io.element.android.features.securebackup.api.SecureBackupEntryPoint
|
||||||
|
import io.element.android.features.verifysession.api.OutgoingVerificationEntryPoint
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaError
|
||||||
|
import io.element.android.tests.testutils.node.TestParentNode
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class DefaultSessionVerificationEntryPointTest {
|
||||||
|
@get:Rule
|
||||||
|
val instantTaskExecutorRule = InstantTaskExecutorRule()
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val mainDispatcherRule = MainDispatcherRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test node builder`() = runTest {
|
||||||
|
val entryPoint = DefaultSessionVerificationEntryPoint()
|
||||||
|
val parentNode = TestParentNode.create { buildContext, plugins ->
|
||||||
|
FtueSessionVerificationFlowNode(
|
||||||
|
buildContext = buildContext,
|
||||||
|
plugins = plugins,
|
||||||
|
outgoingVerificationEntryPoint = object : OutgoingVerificationEntryPoint {
|
||||||
|
override fun createNode(
|
||||||
|
parentNode: Node,
|
||||||
|
buildContext: BuildContext,
|
||||||
|
params: OutgoingVerificationEntryPoint.Params,
|
||||||
|
callback: OutgoingVerificationEntryPoint.Callback,
|
||||||
|
) = lambdaError()
|
||||||
|
},
|
||||||
|
secureBackupEntryPoint = object : SecureBackupEntryPoint {
|
||||||
|
override fun createNode(
|
||||||
|
parentNode: Node,
|
||||||
|
buildContext: BuildContext,
|
||||||
|
params: SecureBackupEntryPoint.Params,
|
||||||
|
callback: SecureBackupEntryPoint.Callback,
|
||||||
|
) = lambdaError()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val callback = object : SessionVerificationEntryPoint.Callback {
|
||||||
|
override fun onDone() = lambdaError()
|
||||||
|
}
|
||||||
|
val result = entryPoint.createNode(parentNode, BuildContext.root(null), callback)
|
||||||
|
assertThat(result).isInstanceOf(FtueSessionVerificationFlowNode::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
-3
@@ -16,7 +16,14 @@ import io.element.android.libraries.matrix.api.core.RoomId
|
|||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
|
||||||
interface ShareEntryPoint : FeatureEntryPoint {
|
interface ShareEntryPoint : FeatureEntryPoint {
|
||||||
data class Params(val shareIntentData: ShareIntentData)
|
/**
|
||||||
|
* @param sessionId the session to use initially. The user can select another session.
|
||||||
|
* @param shareIntentData the data to share.
|
||||||
|
*/
|
||||||
|
data class Params(
|
||||||
|
val sessionId: SessionId,
|
||||||
|
val shareIntentData: ShareIntentData,
|
||||||
|
)
|
||||||
|
|
||||||
fun createNode(
|
fun createNode(
|
||||||
parentNode: Node,
|
parentNode: Node,
|
||||||
@@ -27,10 +34,22 @@ interface ShareEntryPoint : FeatureEntryPoint {
|
|||||||
|
|
||||||
interface Callback : Plugin {
|
interface Callback : Plugin {
|
||||||
/**
|
/**
|
||||||
* Called when the share is done, or cancelled.
|
* Called when the data has been shared.
|
||||||
* @param sessionId the session which has been used to share the data. It can be different from the current session.
|
* @param sessionId the session which has been used to share the data. It can be different from the current session.
|
||||||
* @param roomIds the rooms the data has been shared to, empty if the share has been cancelled.
|
* @param roomIds the rooms the data has been shared to.
|
||||||
*/
|
*/
|
||||||
fun onDone(sessionId: SessionId, roomIds: List<RoomId>)
|
fun onDone(sessionId: SessionId, roomIds: List<RoomId>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the user cancels the share.
|
||||||
|
*/
|
||||||
|
fun onCancel()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the user has selected an account whose session is not verified.
|
||||||
|
* The session has to be verified before the user can share content with it.
|
||||||
|
* @param sessionId the session to verify.
|
||||||
|
*/
|
||||||
|
fun onSessionVerificationRequired(sessionId: SessionId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-3
@@ -10,12 +10,12 @@ package io.element.android.features.share.impl
|
|||||||
|
|
||||||
import com.bumble.appyx.core.modality.BuildContext
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
import com.bumble.appyx.core.node.Node
|
import com.bumble.appyx.core.node.Node
|
||||||
|
import dev.zacsweers.metro.AppScope
|
||||||
import dev.zacsweers.metro.ContributesBinding
|
import dev.zacsweers.metro.ContributesBinding
|
||||||
import io.element.android.features.share.api.ShareEntryPoint
|
import io.element.android.features.share.api.ShareEntryPoint
|
||||||
import io.element.android.libraries.architecture.createNode
|
import io.element.android.libraries.architecture.createNode
|
||||||
import io.element.android.libraries.di.SessionScope
|
|
||||||
|
|
||||||
@ContributesBinding(SessionScope::class)
|
@ContributesBinding(AppScope::class)
|
||||||
class DefaultShareEntryPoint : ShareEntryPoint {
|
class DefaultShareEntryPoint : ShareEntryPoint {
|
||||||
override fun createNode(
|
override fun createNode(
|
||||||
parentNode: Node,
|
parentNode: Node,
|
||||||
@@ -26,7 +26,10 @@ class DefaultShareEntryPoint : ShareEntryPoint {
|
|||||||
return parentNode.createNode<ShareNode>(
|
return parentNode.createNode<ShareNode>(
|
||||||
buildContext = buildContext,
|
buildContext = buildContext,
|
||||||
plugins = listOf(
|
plugins = listOf(
|
||||||
ShareNode.Inputs(shareIntentData = params.shareIntentData),
|
ShareNode.Inputs(
|
||||||
|
sessionId = params.sessionId,
|
||||||
|
shareIntentData = params.shareIntentData,
|
||||||
|
),
|
||||||
callback,
|
callback,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
+14
-8
@@ -18,6 +18,7 @@ import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel
|
|||||||
import com.bumble.appyx.core.node.Node
|
import com.bumble.appyx.core.node.Node
|
||||||
import com.bumble.appyx.core.node.ParentNode
|
import com.bumble.appyx.core.node.ParentNode
|
||||||
import com.bumble.appyx.core.plugin.Plugin
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
|
import dev.zacsweers.metro.AppScope
|
||||||
import dev.zacsweers.metro.Assisted
|
import dev.zacsweers.metro.Assisted
|
||||||
import dev.zacsweers.metro.AssistedInject
|
import dev.zacsweers.metro.AssistedInject
|
||||||
import io.element.android.annotations.ContributesNode
|
import io.element.android.annotations.ContributesNode
|
||||||
@@ -26,21 +27,19 @@ import io.element.android.features.share.api.ShareIntentData
|
|||||||
import io.element.android.libraries.architecture.NodeInputs
|
import io.element.android.libraries.architecture.NodeInputs
|
||||||
import io.element.android.libraries.architecture.callback
|
import io.element.android.libraries.architecture.callback
|
||||||
import io.element.android.libraries.architecture.inputs
|
import io.element.android.libraries.architecture.inputs
|
||||||
import io.element.android.libraries.di.SessionScope
|
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
|
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
|
||||||
import io.element.android.libraries.roomselect.api.RoomSelectMode
|
import io.element.android.libraries.roomselect.api.RoomSelectMode
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
@ContributesNode(SessionScope::class)
|
@ContributesNode(AppScope::class)
|
||||||
@AssistedInject
|
@AssistedInject
|
||||||
class ShareNode(
|
class ShareNode(
|
||||||
@Assisted buildContext: BuildContext,
|
@Assisted buildContext: BuildContext,
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
presenterFactory: SharePresenter.Factory,
|
presenterFactory: SharePresenter.Factory,
|
||||||
private val roomSelectEntryPoint: RoomSelectEntryPoint,
|
private val roomSelectEntryPoint: RoomSelectEntryPoint,
|
||||||
private val sessionId: SessionId,
|
|
||||||
) : ParentNode<ShareNode.NavTarget>(
|
) : ParentNode<ShareNode.NavTarget>(
|
||||||
navModel = PermanentNavModel(
|
navModel = PermanentNavModel(
|
||||||
navTargets = setOf(NavTarget),
|
navTargets = setOf(NavTarget),
|
||||||
@@ -52,10 +51,13 @@ class ShareNode(
|
|||||||
@Parcelize
|
@Parcelize
|
||||||
object NavTarget : Parcelable
|
object NavTarget : Parcelable
|
||||||
|
|
||||||
data class Inputs(val shareIntentData: ShareIntentData) : NodeInputs
|
data class Inputs(
|
||||||
|
val sessionId: SessionId,
|
||||||
|
val shareIntentData: ShareIntentData,
|
||||||
|
) : NodeInputs
|
||||||
|
|
||||||
private val inputs = inputs<Inputs>()
|
private val inputs = inputs<Inputs>()
|
||||||
private val presenter = presenterFactory.create(inputs.shareIntentData, sessionId)
|
private val presenter = presenterFactory.create(inputs.shareIntentData)
|
||||||
private val callback: ShareEntryPoint.Callback = callback()
|
private val callback: ShareEntryPoint.Callback = callback()
|
||||||
|
|
||||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||||
@@ -65,7 +67,11 @@ class ShareNode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onCancel() {
|
override fun onCancel() {
|
||||||
callback.onDone(sessionId, emptyList())
|
callback.onCancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSessionVerificationRequired(sessionId: SessionId) {
|
||||||
|
callback.onSessionVerificationRequired(sessionId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +79,7 @@ class ShareNode(
|
|||||||
parentNode = this,
|
parentNode = this,
|
||||||
buildContext = buildContext,
|
buildContext = buildContext,
|
||||||
params = RoomSelectEntryPoint.Params(
|
params = RoomSelectEntryPoint.Params(
|
||||||
sessionId = sessionId,
|
sessionId = inputs.sessionId,
|
||||||
mode = RoomSelectMode.Share,
|
mode = RoomSelectMode.Share,
|
||||||
maxNumberOfRooms = RoomSelectEntryPoint.DEFAULT_MAX_NUMBER_OF_ROOMS,
|
maxNumberOfRooms = RoomSelectEntryPoint.DEFAULT_MAX_NUMBER_OF_ROOMS,
|
||||||
),
|
),
|
||||||
@@ -92,7 +98,7 @@ class ShareNode(
|
|||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
ShareView(
|
ShareView(
|
||||||
state = state,
|
state = state,
|
||||||
onShareSuccess = { roomIds -> callback.onDone(state.sessionId, roomIds) },
|
onShareSuccess = { result -> callback.onDone(result.sessionId, result.roomIds) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-11
@@ -10,9 +10,7 @@ package io.element.android.features.share.impl
|
|||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import dev.zacsweers.metro.Assisted
|
import dev.zacsweers.metro.Assisted
|
||||||
import dev.zacsweers.metro.AssistedFactory
|
import dev.zacsweers.metro.AssistedFactory
|
||||||
import dev.zacsweers.metro.AssistedInject
|
import dev.zacsweers.metro.AssistedInject
|
||||||
@@ -30,7 +28,6 @@ import kotlinx.coroutines.launch
|
|||||||
@AssistedInject
|
@AssistedInject
|
||||||
class SharePresenter(
|
class SharePresenter(
|
||||||
@Assisted private val shareIntentData: ShareIntentData,
|
@Assisted private val shareIntentData: ShareIntentData,
|
||||||
@Assisted initialSessionId: SessionId,
|
|
||||||
@AppCoroutineScope
|
@AppCoroutineScope
|
||||||
private val appCoroutineScope: CoroutineScope,
|
private val appCoroutineScope: CoroutineScope,
|
||||||
private val matrixClientProvider: MatrixClientProvider,
|
private val matrixClientProvider: MatrixClientProvider,
|
||||||
@@ -38,24 +35,21 @@ class SharePresenter(
|
|||||||
) : Presenter<ShareState> {
|
) : Presenter<ShareState> {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
fun interface Factory {
|
fun interface Factory {
|
||||||
fun create(shareIntentData: ShareIntentData, initialSessionId: SessionId): SharePresenter
|
fun create(shareIntentData: ShareIntentData): SharePresenter
|
||||||
}
|
}
|
||||||
|
|
||||||
private val shareActionState: MutableState<AsyncAction<List<RoomId>>> = mutableStateOf(AsyncAction.Uninitialized)
|
private val shareActionState: MutableState<AsyncAction<ShareResult>> = mutableStateOf(AsyncAction.Uninitialized)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The session the data is shared with, it can be updated if the user selects rooms from another session.
|
* Share the data to the rooms with [roomIds], which belong to the session [sessionId]. It can be another session than the current one.
|
||||||
*/
|
*/
|
||||||
private var targetSessionId by mutableStateOf(initialSessionId)
|
|
||||||
|
|
||||||
fun onRoomSelected(sessionId: SessionId, roomIds: List<RoomId>) {
|
fun onRoomSelected(sessionId: SessionId, roomIds: List<RoomId>) {
|
||||||
if (shareActionState.value.isLoading()) return
|
if (shareActionState.value.isLoading()) return
|
||||||
targetSessionId = sessionId
|
|
||||||
appCoroutineScope.launch {
|
appCoroutineScope.launch {
|
||||||
suspend {
|
suspend {
|
||||||
val client = matrixClientProvider.getOrRestore(sessionId).getOrThrow()
|
val client = matrixClientProvider.getOrRestore(sessionId).getOrThrow()
|
||||||
shareDataSender.send(client, shareIntentData, roomIds).getOrThrow()
|
shareDataSender.send(client, shareIntentData, roomIds).getOrThrow()
|
||||||
roomIds
|
ShareResult(sessionId = sessionId, roomIds = roomIds)
|
||||||
}.runCatchingUpdatingState(shareActionState)
|
}.runCatchingUpdatingState(shareActionState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,7 +63,6 @@ class SharePresenter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return ShareState(
|
return ShareState(
|
||||||
sessionId = targetSessionId,
|
|
||||||
shareAction = shareActionState.value,
|
shareAction = shareActionState.value,
|
||||||
eventSink = ::handleEvent,
|
eventSink = ::handleEvent,
|
||||||
)
|
)
|
||||||
|
|||||||
+11
-2
@@ -13,7 +13,16 @@ import io.element.android.libraries.matrix.api.core.RoomId
|
|||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
|
||||||
data class ShareState(
|
data class ShareState(
|
||||||
val sessionId: SessionId,
|
val shareAction: AsyncAction<ShareResult>,
|
||||||
val shareAction: AsyncAction<List<RoomId>>,
|
|
||||||
val eventSink: (ShareEvent) -> Unit
|
val eventSink: (ShareEvent) -> Unit
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The result of a successful share.
|
||||||
|
* @param sessionId the session which has been used to share the data, it can be another session than the current one.
|
||||||
|
* @param roomIds the rooms the data has been shared to.
|
||||||
|
*/
|
||||||
|
data class ShareResult(
|
||||||
|
val sessionId: SessionId,
|
||||||
|
val roomIds: List<RoomId>,
|
||||||
|
)
|
||||||
|
|||||||
+11
-7
@@ -21,9 +21,7 @@ open class ShareStatePreviewParam : PreviewParameterProvider<ShareState> {
|
|||||||
shareAction = AsyncAction.Loading,
|
shareAction = AsyncAction.Loading,
|
||||||
),
|
),
|
||||||
aShareState(
|
aShareState(
|
||||||
shareAction = AsyncAction.Success(
|
shareAction = AsyncAction.Success(aShareResult())
|
||||||
listOf(RoomId("!room2:domain")),
|
|
||||||
)
|
|
||||||
),
|
),
|
||||||
aShareState(
|
aShareState(
|
||||||
shareAction = AsyncAction.Failure(RuntimeException("error")),
|
shareAction = AsyncAction.Failure(RuntimeException("error")),
|
||||||
@@ -31,12 +29,18 @@ open class ShareStatePreviewParam : PreviewParameterProvider<ShareState> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun aShareState(
|
private fun aShareState(
|
||||||
sessionId: SessionId = SessionId("@alice:server.org"),
|
shareAction: AsyncAction<ShareResult> = AsyncAction.Uninitialized,
|
||||||
shareAction: AsyncAction<List<RoomId>> = AsyncAction.Uninitialized,
|
|
||||||
eventSink: (ShareEvent) -> Unit = {}
|
eventSink: (ShareEvent) -> Unit = {}
|
||||||
) = ShareState(
|
) = ShareState(
|
||||||
sessionId = sessionId,
|
|
||||||
shareAction = shareAction,
|
shareAction = shareAction,
|
||||||
eventSink = eventSink
|
eventSink = eventSink
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun aShareResult(
|
||||||
|
sessionId: SessionId = SessionId("@alice:server.org"),
|
||||||
|
roomIds: List<RoomId> = listOf(RoomId("!room2:domain")),
|
||||||
|
) = ShareResult(
|
||||||
|
sessionId = sessionId,
|
||||||
|
roomIds = roomIds,
|
||||||
|
)
|
||||||
|
|||||||
+1
-2
@@ -13,12 +13,11 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
|||||||
import io.element.android.libraries.designsystem.components.async.AsyncActionView
|
import io.element.android.libraries.designsystem.components.async.AsyncActionView
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ShareView(
|
fun ShareView(
|
||||||
state: ShareState,
|
state: ShareState,
|
||||||
onShareSuccess: (List<RoomId>) -> Unit,
|
onShareSuccess: (ShareResult) -> Unit,
|
||||||
) {
|
) {
|
||||||
AsyncActionView(
|
AsyncActionView(
|
||||||
async = state.shareAction,
|
async = state.shareAction,
|
||||||
|
|||||||
+5
-3
@@ -39,15 +39,17 @@ class DefaultShareEntryPointTest {
|
|||||||
ShareNode(
|
ShareNode(
|
||||||
buildContext = buildContext,
|
buildContext = buildContext,
|
||||||
plugins = plugins,
|
plugins = plugins,
|
||||||
presenterFactory = { _, _ -> createSharePresenter() },
|
presenterFactory = { createSharePresenter() },
|
||||||
roomSelectEntryPoint = FakeRoomSelectEntryPoint(),
|
roomSelectEntryPoint = FakeRoomSelectEntryPoint(),
|
||||||
sessionId = A_SESSION_ID,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val callback = object : ShareEntryPoint.Callback {
|
val callback = object : ShareEntryPoint.Callback {
|
||||||
override fun onDone(sessionId: SessionId, roomIds: List<RoomId>) = lambdaError()
|
override fun onDone(sessionId: SessionId, roomIds: List<RoomId>) = lambdaError()
|
||||||
|
override fun onCancel() = lambdaError()
|
||||||
|
override fun onSessionVerificationRequired(sessionId: SessionId) = lambdaError()
|
||||||
}
|
}
|
||||||
val params = ShareEntryPoint.Params(
|
val params = ShareEntryPoint.Params(
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
shareIntentData = ShareIntentData.PlainText(A_MESSAGE),
|
shareIntentData = ShareIntentData.PlainText(A_MESSAGE),
|
||||||
)
|
)
|
||||||
val result = entryPoint.createNode(
|
val result = entryPoint.createNode(
|
||||||
@@ -57,7 +59,7 @@ class DefaultShareEntryPointTest {
|
|||||||
callback = callback,
|
callback = callback,
|
||||||
)
|
)
|
||||||
assertThat(result).isInstanceOf(ShareNode::class.java)
|
assertThat(result).isInstanceOf(ShareNode::class.java)
|
||||||
assertThat(result.plugins).contains(ShareNode.Inputs(params.shareIntentData))
|
assertThat(result.plugins).contains(ShareNode.Inputs(params.sessionId, params.shareIntentData))
|
||||||
assertThat(result.plugins).contains(callback)
|
assertThat(result.plugins).contains(callback)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-15
@@ -44,7 +44,6 @@ class SharePresenterTest {
|
|||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.sessionId).isEqualTo(A_SESSION_ID)
|
|
||||||
assertThat(initialState.shareAction.isUninitialized()).isTrue()
|
assertThat(initialState.shareAction.isUninitialized()).isTrue()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,8 +85,7 @@ class SharePresenterTest {
|
|||||||
presenter.onRoomSelected(A_SESSION_ID, listOf(A_ROOM_ID))
|
presenter.onRoomSelected(A_SESSION_ID, listOf(A_ROOM_ID))
|
||||||
assertThat(awaitItem().shareAction.isLoading()).isTrue()
|
assertThat(awaitItem().shareAction.isLoading()).isTrue()
|
||||||
val success = awaitItem()
|
val success = awaitItem()
|
||||||
assertThat(success.sessionId).isEqualTo(A_SESSION_ID)
|
assertThat(success.shareAction).isEqualTo(AsyncAction.Success(ShareResult(A_SESSION_ID, listOf(A_ROOM_ID))))
|
||||||
assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID)))
|
|
||||||
sendResult.assertions().isCalledOnce().with(value(client), value(shareIntentData), value(listOf(A_ROOM_ID)))
|
sendResult.assertions().isCalledOnce().with(value(client), value(shareIntentData), value(listOf(A_ROOM_ID)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,16 +104,11 @@ class SharePresenterTest {
|
|||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
val initialState = awaitItem()
|
skipItems(1)
|
||||||
assertThat(initialState.sessionId).isEqualTo(A_SESSION_ID)
|
|
||||||
presenter.onRoomSelected(A_SESSION_ID_2, listOf(A_ROOM_ID))
|
presenter.onRoomSelected(A_SESSION_ID_2, listOf(A_ROOM_ID))
|
||||||
val sessionChanged = awaitItem()
|
|
||||||
assertThat(sessionChanged.sessionId).isEqualTo(A_SESSION_ID_2)
|
|
||||||
assertThat(sessionChanged.shareAction.isUninitialized()).isTrue()
|
|
||||||
assertThat(awaitItem().shareAction.isLoading()).isTrue()
|
assertThat(awaitItem().shareAction.isLoading()).isTrue()
|
||||||
val success = awaitItem()
|
val success = awaitItem()
|
||||||
assertThat(success.sessionId).isEqualTo(A_SESSION_ID_2)
|
assertThat(success.shareAction).isEqualTo(AsyncAction.Success(ShareResult(A_SESSION_ID_2, listOf(A_ROOM_ID))))
|
||||||
assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID)))
|
|
||||||
getClient.assertions().isCalledOnce().with(value(A_SESSION_ID_2))
|
getClient.assertions().isCalledOnce().with(value(A_SESSION_ID_2))
|
||||||
sendResult.assertions().isCalledOnce()
|
sendResult.assertions().isCalledOnce()
|
||||||
}
|
}
|
||||||
@@ -133,7 +126,6 @@ class SharePresenterTest {
|
|||||||
}.test {
|
}.test {
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
presenter.onRoomSelected(A_SESSION_ID_2, listOf(A_ROOM_ID))
|
presenter.onRoomSelected(A_SESSION_ID_2, listOf(A_ROOM_ID))
|
||||||
assertThat(awaitItem().sessionId).isEqualTo(A_SESSION_ID_2)
|
|
||||||
assertThat(awaitItem().shareAction.isLoading()).isTrue()
|
assertThat(awaitItem().shareAction.isLoading()).isTrue()
|
||||||
assertThat(awaitItem().shareAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
|
assertThat(awaitItem().shareAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
|
||||||
sendResult.assertions().isNeverCalled()
|
sendResult.assertions().isNeverCalled()
|
||||||
@@ -161,8 +153,8 @@ class SharePresenterTest {
|
|||||||
presenter.onRoomSelected(A_SESSION_ID_2, listOf(A_ROOM_ID))
|
presenter.onRoomSelected(A_SESSION_ID_2, listOf(A_ROOM_ID))
|
||||||
completable.complete(Result.success(Unit))
|
completable.complete(Result.success(Unit))
|
||||||
val success = awaitItem()
|
val success = awaitItem()
|
||||||
assertThat(success.sessionId).isEqualTo(A_SESSION_ID)
|
// The second selection has been ignored
|
||||||
assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID)))
|
assertThat(success.shareAction).isEqualTo(AsyncAction.Success(ShareResult(A_SESSION_ID, listOf(A_ROOM_ID))))
|
||||||
sendResult.assertions().isCalledOnce()
|
sendResult.assertions().isCalledOnce()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,13 +162,11 @@ class SharePresenterTest {
|
|||||||
|
|
||||||
internal fun TestScope.createSharePresenter(
|
internal fun TestScope.createSharePresenter(
|
||||||
shareIntentData: ShareIntentData = ShareIntentData.PlainText(A_MESSAGE),
|
shareIntentData: ShareIntentData = ShareIntentData.PlainText(A_MESSAGE),
|
||||||
initialSessionId: SessionId = A_SESSION_ID,
|
|
||||||
matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
|
matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
|
||||||
shareDataSender: ShareDataSender = FakeShareDataSender(),
|
shareDataSender: ShareDataSender = FakeShareDataSender(),
|
||||||
): SharePresenter {
|
): SharePresenter {
|
||||||
return SharePresenter(
|
return SharePresenter(
|
||||||
shareIntentData = shareIntentData,
|
shareIntentData = shareIntentData,
|
||||||
initialSessionId = initialSessionId,
|
|
||||||
appCoroutineScope = this,
|
appCoroutineScope = this,
|
||||||
matrixClientProvider = matrixClientProvider,
|
matrixClientProvider = matrixClientProvider,
|
||||||
shareDataSender = shareDataSender,
|
shareDataSender = shareDataSender,
|
||||||
|
|||||||
+7
@@ -41,6 +41,13 @@ interface RoomSelectEntryPoint : FeatureEntryPoint {
|
|||||||
*/
|
*/
|
||||||
fun onRoomSelected(sessionId: SessionId, roomIds: List<RoomId>)
|
fun onRoomSelected(sessionId: SessionId, roomIds: List<RoomId>)
|
||||||
fun onCancel()
|
fun onCancel()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called in [RoomSelectMode.Share] mode when the user has selected an account whose session is not verified.
|
||||||
|
* The session has to be verified before the user can share content with it.
|
||||||
|
* @param sessionId the session to verify.
|
||||||
|
*/
|
||||||
|
fun onSessionVerificationRequired(sessionId: SessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
/*
|
||||||
|
* 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.libraries.roomselect.impl
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
|
||||||
|
fun interface RoomSelectNavigator {
|
||||||
|
/**
|
||||||
|
* Navigate to the verification of the session [sessionId], which has to be verified before content can be shared with it.
|
||||||
|
*/
|
||||||
|
fun navigateToSessionVerification(sessionId: SessionId)
|
||||||
|
}
|
||||||
+7
-2
@@ -33,7 +33,7 @@ class RoomSelectNode(
|
|||||||
@Assisted buildContext: BuildContext,
|
@Assisted buildContext: BuildContext,
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
presenterFactory: RoomSelectPresenter.Factory,
|
presenterFactory: RoomSelectPresenter.Factory,
|
||||||
) : Node(buildContext, plugins = plugins) {
|
) : Node(buildContext, plugins = plugins), RoomSelectNavigator {
|
||||||
data class Inputs(
|
data class Inputs(
|
||||||
val sessionId: SessionId,
|
val sessionId: SessionId,
|
||||||
val mode: RoomSelectMode,
|
val mode: RoomSelectMode,
|
||||||
@@ -41,13 +41,18 @@ class RoomSelectNode(
|
|||||||
) : NodeInputs
|
) : NodeInputs
|
||||||
|
|
||||||
private val inputs: Inputs = inputs()
|
private val inputs: Inputs = inputs()
|
||||||
|
private val callback: RoomSelectEntryPoint.Callback = callback()
|
||||||
private val presenter = presenterFactory.create(
|
private val presenter = presenterFactory.create(
|
||||||
initialSessionId = inputs.sessionId,
|
initialSessionId = inputs.sessionId,
|
||||||
mode = inputs.mode,
|
mode = inputs.mode,
|
||||||
maxNumberOfRooms = inputs.maxNumberOfRooms,
|
maxNumberOfRooms = inputs.maxNumberOfRooms,
|
||||||
|
navigator = this,
|
||||||
)
|
)
|
||||||
private val stateFlow = launchMolecule { presenter.present() }
|
private val stateFlow = launchMolecule { presenter.present() }
|
||||||
private val callback: RoomSelectEntryPoint.Callback = callback()
|
|
||||||
|
override fun navigateToSessionVerification(sessionId: SessionId) {
|
||||||
|
callback.onSessionVerificationRequired(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
|
|||||||
+43
-22
@@ -30,12 +30,14 @@ import io.element.android.libraries.matrix.api.MatrixClientProvider
|
|||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
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.user.MatrixUser
|
||||||
import io.element.android.libraries.matrix.api.user.toMatrixUser
|
import io.element.android.libraries.matrix.api.user.toMatrixUser
|
||||||
|
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
|
||||||
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
|
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
|
||||||
import io.element.android.libraries.roomselect.api.RoomSelectMode
|
import io.element.android.libraries.roomselect.api.RoomSelectMode
|
||||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
@@ -45,6 +47,7 @@ class RoomSelectPresenter(
|
|||||||
@Assisted private val initialSessionId: SessionId,
|
@Assisted private val initialSessionId: SessionId,
|
||||||
@Assisted private val mode: RoomSelectMode,
|
@Assisted private val mode: RoomSelectMode,
|
||||||
@Assisted private val maxNumberOfRooms: Int,
|
@Assisted private val maxNumberOfRooms: Int,
|
||||||
|
@Assisted private val navigator: RoomSelectNavigator,
|
||||||
private val dataSourceFactory: RoomSelectSearchDataSource.Factory,
|
private val dataSourceFactory: RoomSelectSearchDataSource.Factory,
|
||||||
private val matrixClientProvider: MatrixClientProvider,
|
private val matrixClientProvider: MatrixClientProvider,
|
||||||
private val sessionStore: SessionStore,
|
private val sessionStore: SessionStore,
|
||||||
@@ -55,6 +58,7 @@ class RoomSelectPresenter(
|
|||||||
initialSessionId: SessionId,
|
initialSessionId: SessionId,
|
||||||
mode: RoomSelectMode,
|
mode: RoomSelectMode,
|
||||||
maxNumberOfRooms: Int,
|
maxNumberOfRooms: Int,
|
||||||
|
navigator: RoomSelectNavigator,
|
||||||
): RoomSelectPresenter
|
): RoomSelectPresenter
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,17 +67,19 @@ class RoomSelectPresenter(
|
|||||||
var selectedRooms by remember { mutableStateOf(persistentListOf<SelectRoomInfo>()) }
|
var selectedRooms by remember { mutableStateOf(persistentListOf<SelectRoomInfo>()) }
|
||||||
val queryState = rememberTextFieldState()
|
val queryState = rememberTextFieldState()
|
||||||
var isAccountListExpanded by remember { mutableStateOf(false) }
|
var isAccountListExpanded by remember { mutableStateOf(false) }
|
||||||
|
// Null until the sessions are loaded
|
||||||
|
val sessions by remember { sessionStore.sessionsFlow() }.collectAsState(initial = null)
|
||||||
var selectedSessionId by remember { mutableStateOf(initialSessionId) }
|
var selectedSessionId by remember { mutableStateOf(initialSessionId) }
|
||||||
val sessions by remember { sessionStore.sessionsFlow() }.collectAsState(initial = emptyList())
|
|
||||||
// All the accounts are proposed, even the ones with an invalid token. Selecting them displays an error.
|
// All the accounts are proposed, even the ones with an invalid token. Selecting them displays an error.
|
||||||
val accounts by remember {
|
val accounts by remember {
|
||||||
derivedStateOf { sessions.map { it.toMatrixUser() } }
|
derivedStateOf { sessions.orEmpty().map { it.toMatrixUser() } }
|
||||||
}
|
}
|
||||||
// The selected session can have an invalid token, or be invalidated while this screen is displayed, for instance if it is
|
// The selected session can have an invalid token, or be invalidated while this screen is displayed, for instance if it is
|
||||||
// signed out from another device. The invalidation of the initial session, which is the current one, is handled by the application.
|
// signed out from another device.
|
||||||
val isSelectedSessionInvalid by remember {
|
val isSelectedSessionInvalid by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
selectedSessionId != initialSessionId && sessions.none { it.userId == selectedSessionId.value && it.isTokenValid }
|
val loadedSessions = sessions ?: return@derivedStateOf false
|
||||||
|
loadedSessions.none { it.userId == selectedSessionId.value && it.isTokenValid }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LaunchedEffect(isSelectedSessionInvalid) {
|
LaunchedEffect(isSelectedSessionInvalid) {
|
||||||
@@ -97,28 +103,43 @@ class RoomSelectPresenter(
|
|||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
// Create one data source per session. When the selected session changes, the previous
|
// Create one data source per session. When the selected session changes, the previous
|
||||||
// data source is removed from the composition and its coroutine scope is cancelled.
|
// data source is removed from the composition and its coroutine scope is cancelled.
|
||||||
val (selectedSessionDataSource, hasClientError) = key(selectedSessionId) {
|
// Also use the validity of the session as a key, so that the data source is removed if the session is invalidated, and recreated
|
||||||
val dataSourceCoroutineScope = rememberCoroutineScope()
|
// if the session becomes valid again.
|
||||||
// The sessions are restored at startup, so the client should already be in memory.
|
val (dataSource, hasClientError, isSessionNotVerified) = key(selectedSessionId, isSelectedSessionInvalid) {
|
||||||
// A null value means that the client is being restored, or that the session is invalid: its client cannot be restored,
|
val sessionId = selectedSessionId
|
||||||
// and an in-memory client would have been destroyed, so do not try to get it.
|
if (isSelectedSessionInvalid) {
|
||||||
val initialRoomListService = if (isSelectedSessionInvalid) null else matrixClientProvider.getOrNull(selectedSessionId)?.roomListService
|
// Do not try to get the client of an invalid session: it cannot be restored, and an in-memory client would have been destroyed.
|
||||||
val roomListServiceResult by produceState(initialRoomListService?.let { Result.success(it) }) {
|
Triple(null, false, false)
|
||||||
if (value == null && !isSelectedSessionInvalid) {
|
} else {
|
||||||
value = matrixClientProvider.getOrRestore(selectedSessionId)
|
val dataSourceCoroutineScope = rememberCoroutineScope()
|
||||||
.onFailure { Timber.e(it, "Failed to get the client for $selectedSessionId") }
|
// The sessions are restored at startup, so the client should already be in memory.
|
||||||
.map { it.roomListService }
|
// A null value means that the client is being restored.
|
||||||
|
val clientResult by produceState(matrixClientProvider.getOrNull(sessionId)?.let { Result.success(it) }) {
|
||||||
|
if (value == null) {
|
||||||
|
value = matrixClientProvider.getOrRestore(sessionId)
|
||||||
|
.onFailure { Timber.e(it, "Failed to get the client for $sessionId") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
val client = clientResult?.getOrNull()
|
||||||
|
val dataSource = remember(client) {
|
||||||
|
client?.let { dataSourceFactory.create(dataSourceCoroutineScope, it.roomListService) }
|
||||||
|
}
|
||||||
|
// Content cannot be shared with a session which is not verified. The initial session is verified, since the FTUE is complete.
|
||||||
|
val sessionVerifiedStatus by remember(client) {
|
||||||
|
if (mode == RoomSelectMode.Share && sessionId != initialSessionId && client != null) {
|
||||||
|
client.sessionVerificationService.sessionVerifiedStatus
|
||||||
|
} else {
|
||||||
|
MutableStateFlow(SessionVerifiedStatus.Unknown)
|
||||||
|
}
|
||||||
|
}.collectAsState()
|
||||||
|
Triple(dataSource, clientResult?.isFailure == true, sessionVerifiedStatus == SessionVerifiedStatus.NotVerified)
|
||||||
}
|
}
|
||||||
val roomListService = roomListServiceResult?.getOrNull()
|
|
||||||
val dataSource = remember(roomListService) {
|
|
||||||
roomListService?.let { dataSourceFactory.create(dataSourceCoroutineScope, it) }
|
|
||||||
}
|
|
||||||
dataSource to (roomListServiceResult?.isFailure == true)
|
|
||||||
}
|
}
|
||||||
// Do not use the data source of an invalid session, its client has been destroyed
|
|
||||||
val dataSource = selectedSessionDataSource.takeIf { !isSelectedSessionInvalid }
|
|
||||||
val hasRoomListError = hasClientError || isSelectedSessionInvalid
|
val hasRoomListError = hasClientError || isSelectedSessionInvalid
|
||||||
|
val sessionToVerify = selectedSessionId.takeIf { isSessionNotVerified }
|
||||||
|
LaunchedEffect(sessionToVerify) {
|
||||||
|
sessionToVerify?.let(navigator::navigateToSessionVerification)
|
||||||
|
}
|
||||||
|
|
||||||
val searchQuery = queryState.text.toString()
|
val searchQuery = queryState.text.toString()
|
||||||
LaunchedEffect(dataSource, searchQuery) {
|
LaunchedEffect(dataSource, searchQuery) {
|
||||||
|
|||||||
+3
-2
@@ -38,17 +38,18 @@ class DefaultRoomSelectEntryPointTest : RobolectricTest() {
|
|||||||
RoomSelectNode(
|
RoomSelectNode(
|
||||||
buildContext = buildContext,
|
buildContext = buildContext,
|
||||||
plugins = plugins,
|
plugins = plugins,
|
||||||
presenterFactory = { initialSessionId, mode, maxNumberOfRooms ->
|
presenterFactory = { initialSessionId, mode, maxNumberOfRooms, _ ->
|
||||||
assertThat(initialSessionId).isEqualTo(A_SESSION_ID)
|
assertThat(initialSessionId).isEqualTo(A_SESSION_ID)
|
||||||
assertThat(mode).isEqualTo(testMode)
|
assertThat(mode).isEqualTo(testMode)
|
||||||
assertThat(maxNumberOfRooms).isEqualTo(testMaxNumberOfRooms)
|
assertThat(maxNumberOfRooms).isEqualTo(testMaxNumberOfRooms)
|
||||||
createRoomSelectPresenter(mode, maxNumberOfRooms)
|
createRoomSelectPresenter(mode = mode, maxNumberOfRooms = maxNumberOfRooms)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val callback = object : RoomSelectEntryPoint.Callback {
|
val callback = object : RoomSelectEntryPoint.Callback {
|
||||||
override fun onRoomSelected(sessionId: SessionId, roomIds: List<RoomId>) = lambdaError()
|
override fun onRoomSelected(sessionId: SessionId, roomIds: List<RoomId>) = lambdaError()
|
||||||
override fun onCancel() = lambdaError()
|
override fun onCancel() = lambdaError()
|
||||||
|
override fun onSessionVerificationRequired(sessionId: SessionId) = lambdaError()
|
||||||
}
|
}
|
||||||
val params = RoomSelectEntryPoint.Params(
|
val params = RoomSelectEntryPoint.Params(
|
||||||
sessionId = A_SESSION_ID,
|
sessionId = A_SESSION_ID,
|
||||||
|
|||||||
+111
-3
@@ -13,9 +13,11 @@ import app.cash.turbine.Event
|
|||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||||
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
|
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
|
||||||
import io.element.android.libraries.matrix.api.roomlist.RoomListService
|
import io.element.android.libraries.matrix.api.roomlist.RoomListService
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
|
||||||
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
|
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
|
||||||
@@ -26,6 +28,7 @@ import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
|
|||||||
import io.element.android.libraries.matrix.test.room.aRoomSummary
|
import io.element.android.libraries.matrix.test.room.aRoomSummary
|
||||||
import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList
|
import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList
|
||||||
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
|
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
|
||||||
|
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
|
||||||
import io.element.android.libraries.matrix.ui.model.toSelectRoomInfo
|
import io.element.android.libraries.matrix.ui.model.toSelectRoomInfo
|
||||||
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
|
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
|
||||||
import io.element.android.libraries.roomselect.api.RoomSelectMode
|
import io.element.android.libraries.roomselect.api.RoomSelectMode
|
||||||
@@ -38,6 +41,7 @@ import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
|||||||
import io.element.android.tests.testutils.lambda.assert
|
import io.element.android.tests.testutils.lambda.assert
|
||||||
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
|
||||||
|
import io.element.android.tests.testutils.lambda.value
|
||||||
import io.element.android.tests.testutils.test
|
import io.element.android.tests.testutils.test
|
||||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
@@ -112,7 +116,7 @@ class RoomSelectPresenterTest {
|
|||||||
val presenter = createRoomSelectPresenter(
|
val presenter = createRoomSelectPresenter(
|
||||||
mode = RoomSelectMode.Share,
|
mode = RoomSelectMode.Share,
|
||||||
sessionStore = InMemorySessionStore(
|
sessionStore = InMemorySessionStore(
|
||||||
initialList = listOf(aSessionData(sessionId = A_SESSION_ID.value)),
|
initialList = listOf(aSessionData(sessionId = A_SESSION_ID.value, isTokenValid = true)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
presenter.test {
|
presenter.test {
|
||||||
@@ -207,6 +211,30 @@ class RoomSelectPresenterTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - the current account is invalidated while selected, an error is displayed`() = runTest {
|
||||||
|
val roomSummary = aRoomSummary()
|
||||||
|
val sessionStore = aSessionStoreWithTwoAccounts()
|
||||||
|
val presenter = createRoomSelectPresenter(
|
||||||
|
mode = RoomSelectMode.Share,
|
||||||
|
sessionStore = sessionStore,
|
||||||
|
roomListService = FakeRoomListService(createRoomListLambda = { FakeDynamicRoomList(summaries = MutableStateFlow(listOf(roomSummary))) }),
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
val state = consumeItemsUntilPredicate { it.resultState is SearchBarResultState.Results }.last()
|
||||||
|
state.eventSink(RoomSelectEvent.ToggleSelectedRoom(roomSummary.toSelectRoomInfo()))
|
||||||
|
assertThat(consumeItemsUntilPredicate { it.selectedRooms.isNotEmpty() }.last().selectedRooms).hasSize(1)
|
||||||
|
// The current session is signed out from another device while the screen is displayed
|
||||||
|
sessionStore.updateData(sessionStore.getSession(A_SESSION_ID.value)!!.copy(isTokenValid = false))
|
||||||
|
val errorState = consumeItemsUntilPredicate { it.hasRoomListError && it.selectedRooms.isEmpty() }.last()
|
||||||
|
assertThat(errorState.selectedAccount.userId).isEqualTo(A_SESSION_ID)
|
||||||
|
assertThat(errorState.resultState).isInstanceOf(SearchBarResultState.Initial::class.java)
|
||||||
|
// The user can switch to the other account
|
||||||
|
assertThat(errorState.otherAccounts.map { it.userId }).containsExactly(A_SESSION_ID_2)
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - another account is invalidated, it is still proposed and there is no error`() = runTest {
|
fun `present - another account is invalidated, it is still proposed and there is no error`() = runTest {
|
||||||
val sessionStore = aSessionStoreWithTwoAccounts()
|
val sessionStore = aSessionStoreWithTwoAccounts()
|
||||||
@@ -263,6 +291,80 @@ class RoomSelectPresenterTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - select another account which is not verified navigates to its verification`() = runTest {
|
||||||
|
val sessionVerificationService = FakeSessionVerificationService(SessionVerifiedStatus.NotVerified)
|
||||||
|
val navigateToSessionVerificationLambda = lambdaRecorder<SessionId, Unit> { }
|
||||||
|
val presenter = createRoomSelectPresenter(
|
||||||
|
navigator = RoomSelectNavigator(navigateToSessionVerificationLambda),
|
||||||
|
mode = RoomSelectMode.Share,
|
||||||
|
sessionStore = aSessionStoreWithTwoAccounts(),
|
||||||
|
matrixClientProvider = FakeMatrixClientProvider { sessionId ->
|
||||||
|
Result.success(
|
||||||
|
FakeMatrixClient(
|
||||||
|
sessionId = sessionId,
|
||||||
|
sessionVerificationService = if (sessionId == A_SESSION_ID_2) sessionVerificationService else FakeSessionVerificationService(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
val state = awaitLastSequentialItem()
|
||||||
|
state.eventSink(RoomSelectEvent.SelectAccount(A_SESSION_ID_2))
|
||||||
|
consumeItemsUntilPredicate { it.selectedAccount.userId == A_SESSION_ID_2 }
|
||||||
|
testScheduler.advanceUntilIdle()
|
||||||
|
navigateToSessionVerificationLambda.assertions().isCalledOnce().with(value(A_SESSION_ID_2))
|
||||||
|
// The session is verified, no new navigation
|
||||||
|
sessionVerificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||||
|
testScheduler.advanceUntilIdle()
|
||||||
|
navigateToSessionVerificationLambda.assertions().isCalledOnce()
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - the initial session is not verified, no navigation to its verification`() = runTest {
|
||||||
|
val presenter = createRoomSelectPresenter(
|
||||||
|
mode = RoomSelectMode.Share,
|
||||||
|
matrixClientProvider = FakeMatrixClientProvider { sessionId ->
|
||||||
|
Result.success(
|
||||||
|
FakeMatrixClient(
|
||||||
|
sessionId = sessionId,
|
||||||
|
sessionVerificationService = FakeSessionVerificationService(SessionVerifiedStatus.NotVerified),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
// The default navigator fails if it is invoked
|
||||||
|
awaitLastSequentialItem()
|
||||||
|
testScheduler.advanceUntilIdle()
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - forward mode, no navigation to the verification of the session`() = runTest {
|
||||||
|
val presenter = createRoomSelectPresenter(
|
||||||
|
mode = RoomSelectMode.Forward,
|
||||||
|
initialSessionId = A_SESSION_ID_2,
|
||||||
|
matrixClientProvider = FakeMatrixClientProvider { sessionId ->
|
||||||
|
Result.success(
|
||||||
|
FakeMatrixClient(
|
||||||
|
sessionId = sessionId,
|
||||||
|
sessionVerificationService = FakeSessionVerificationService(SessionVerifiedStatus.NotVerified),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
// The default navigator fails if it is invoked
|
||||||
|
awaitLastSequentialItem()
|
||||||
|
testScheduler.advanceUntilIdle()
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - select another account keeps the search query and applies it to the rooms of this account`() = runTest {
|
fun `present - select another account keeps the search query and applies it to the rooms of this account`() = runTest {
|
||||||
val roomList2 = FakeDynamicRoomList(summaries = MutableStateFlow(listOf(aRoomSummary(roomId = A_ROOM_ID_2))))
|
val roomList2 = FakeDynamicRoomList(summaries = MutableStateFlow(listOf(aRoomSummary(roomId = A_ROOM_ID_2))))
|
||||||
@@ -401,17 +503,23 @@ class RoomSelectPresenterTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
internal fun TestScope.createRoomSelectPresenter(
|
internal fun TestScope.createRoomSelectPresenter(
|
||||||
|
initialSessionId: SessionId = A_SESSION_ID,
|
||||||
mode: RoomSelectMode = RoomSelectMode.Forward,
|
mode: RoomSelectMode = RoomSelectMode.Forward,
|
||||||
maxNumberOfRooms: Int = RoomSelectEntryPoint.DEFAULT_MAX_NUMBER_OF_ROOMS,
|
maxNumberOfRooms: Int = RoomSelectEntryPoint.DEFAULT_MAX_NUMBER_OF_ROOMS,
|
||||||
roomListService: RoomListService = FakeRoomListService(),
|
roomListService: RoomListService = FakeRoomListService(),
|
||||||
sessionStore: SessionStore = InMemorySessionStore(),
|
// The current session is always in the store
|
||||||
|
sessionStore: SessionStore = InMemorySessionStore(
|
||||||
|
initialList = listOf(aSessionData(sessionId = A_SESSION_ID.value, isTokenValid = true)),
|
||||||
|
),
|
||||||
matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider { sessionId ->
|
matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider { sessionId ->
|
||||||
Result.success(FakeMatrixClient(sessionId = sessionId, roomListService = roomListService))
|
Result.success(FakeMatrixClient(sessionId = sessionId, roomListService = roomListService))
|
||||||
},
|
},
|
||||||
|
navigator: RoomSelectNavigator = RoomSelectNavigator { lambdaError() },
|
||||||
) = RoomSelectPresenter(
|
) = RoomSelectPresenter(
|
||||||
initialSessionId = A_SESSION_ID,
|
initialSessionId = initialSessionId,
|
||||||
mode = mode,
|
mode = mode,
|
||||||
maxNumberOfRooms = maxNumberOfRooms,
|
maxNumberOfRooms = maxNumberOfRooms,
|
||||||
|
navigator = navigator,
|
||||||
dataSourceFactory = object : RoomSelectSearchDataSource.Factory {
|
dataSourceFactory = object : RoomSelectSearchDataSource.Factory {
|
||||||
override fun create(coroutineScope: CoroutineScope, roomListService: RoomListService): RoomSelectSearchDataSource {
|
override fun create(coroutineScope: CoroutineScope, roomListService: RoomListService): RoomSelectSearchDataSource {
|
||||||
return RoomSelectSearchDataSource(
|
return RoomSelectSearchDataSource(
|
||||||
|
|||||||
Reference in New Issue
Block a user