Keep the incoming share displayed when the current account is signed out

The incoming share was displayed in the logged in flow of the current
session, so it was destroyed when the current session was signed out, for
instance from another device. When another session was signed out, the room
select screen was kept and displayed an error if this session was selected.

To make the behavior consistent:
- The incoming share is now displayed by the RootFlowNode, on top of the
  logged in flow, and does not depend on a session anymore. The latest used
  session is passed from the RootFlowNode to the room select screen, through the
  share flow, and is used as its initial session.
- When the root of the RootFlowNode backstack changes while an incoming share
  is displayed, the new root is set below the incoming share, using the new
  replaceRoot backstack operation, instead of removing all the elements.
- The room select screen handles the invalidation of the current session like
  for any other session.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benoit Marty
2026-10-07 22:00:12 +02:00
parent 48fc09a7c3
commit 9bc7e751c4
18 changed files with 299 additions and 150 deletions
@@ -10,12 +10,12 @@ package io.element.android.features.share.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.share.api.ShareEntryPoint
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
@ContributesBinding(SessionScope::class)
@ContributesBinding(AppScope::class)
class DefaultShareEntryPoint : ShareEntryPoint {
override fun createNode(
parentNode: Node,
@@ -26,7 +26,10 @@ class DefaultShareEntryPoint : ShareEntryPoint {
return parentNode.createNode<ShareNode>(
buildContext = buildContext,
plugins = listOf(
ShareNode.Inputs(shareIntentData = params.shareIntentData),
ShareNode.Inputs(
sessionId = params.sessionId,
shareIntentData = params.shareIntentData,
),
callback,
)
)
@@ -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.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
@@ -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.callback
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.SessionId
import io.element.android.libraries.roomselect.api.RoomSelectEntryPoint
import io.element.android.libraries.roomselect.api.RoomSelectMode
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
@ContributesNode(AppScope::class)
@AssistedInject
class ShareNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: SharePresenter.Factory,
private val roomSelectEntryPoint: RoomSelectEntryPoint,
private val sessionId: SessionId,
) : ParentNode<ShareNode.NavTarget>(
navModel = PermanentNavModel(
navTargets = setOf(NavTarget),
@@ -52,10 +51,13 @@ class ShareNode(
@Parcelize
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 presenter = presenterFactory.create(inputs.shareIntentData, sessionId)
private val presenter = presenterFactory.create(inputs.shareIntentData)
private val callback: ShareEntryPoint.Callback = callback()
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@@ -65,7 +67,7 @@ class ShareNode(
}
override fun onCancel() {
callback.onDone(sessionId, emptyList())
callback.onCancel()
}
}
@@ -73,7 +75,7 @@ class ShareNode(
parentNode = this,
buildContext = buildContext,
params = RoomSelectEntryPoint.Params(
sessionId = sessionId,
sessionId = inputs.sessionId,
mode = RoomSelectMode.Share,
maxNumberOfRooms = RoomSelectEntryPoint.DEFAULT_MAX_NUMBER_OF_ROOMS,
),
@@ -92,7 +94,7 @@ class ShareNode(
val state = presenter.present()
ShareView(
state = state,
onShareSuccess = { roomIds -> callback.onDone(state.sessionId, roomIds) },
onShareSuccess = { result -> callback.onDone(result.sessionId, result.roomIds) },
)
}
}
@@ -10,9 +10,7 @@ package io.element.android.features.share.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
@@ -30,7 +28,6 @@ import kotlinx.coroutines.launch
@AssistedInject
class SharePresenter(
@Assisted private val shareIntentData: ShareIntentData,
@Assisted initialSessionId: SessionId,
@AppCoroutineScope
private val appCoroutineScope: CoroutineScope,
private val matrixClientProvider: MatrixClientProvider,
@@ -38,24 +35,21 @@ class SharePresenter(
) : Presenter<ShareState> {
@AssistedFactory
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>) {
if (shareActionState.value.isLoading()) return
targetSessionId = sessionId
appCoroutineScope.launch {
suspend {
val client = matrixClientProvider.getOrRestore(sessionId).getOrThrow()
shareDataSender.send(client, shareIntentData, roomIds).getOrThrow()
roomIds
ShareResult(sessionId = sessionId, roomIds = roomIds)
}.runCatchingUpdatingState(shareActionState)
}
}
@@ -69,7 +63,6 @@ class SharePresenter(
}
return ShareState(
sessionId = targetSessionId,
shareAction = shareActionState.value,
eventSink = ::handleEvent,
)
@@ -13,7 +13,16 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
data class ShareState(
val sessionId: SessionId,
val shareAction: AsyncAction<List<RoomId>>,
val shareAction: AsyncAction<ShareResult>,
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>,
)
@@ -21,9 +21,7 @@ open class ShareStatePreviewParam : PreviewParameterProvider<ShareState> {
shareAction = AsyncAction.Loading,
),
aShareState(
shareAction = AsyncAction.Success(
listOf(RoomId("!room2:domain")),
)
shareAction = AsyncAction.Success(aShareResult())
),
aShareState(
shareAction = AsyncAction.Failure(RuntimeException("error")),
@@ -31,12 +29,18 @@ open class ShareStatePreviewParam : PreviewParameterProvider<ShareState> {
)
}
fun aShareState(
sessionId: SessionId = SessionId("@alice:server.org"),
shareAction: AsyncAction<List<RoomId>> = AsyncAction.Uninitialized,
private fun aShareState(
shareAction: AsyncAction<ShareResult> = AsyncAction.Uninitialized,
eventSink: (ShareEvent) -> Unit = {}
) = ShareState(
sessionId = sessionId,
shareAction = shareAction,
eventSink = eventSink
)
private fun aShareResult(
sessionId: SessionId = SessionId("@alice:server.org"),
roomIds: List<RoomId> = listOf(RoomId("!room2:domain")),
) = ShareResult(
sessionId = sessionId,
roomIds = roomIds,
)
@@ -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.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.RoomId
@Composable
fun ShareView(
state: ShareState,
onShareSuccess: (List<RoomId>) -> Unit,
onShareSuccess: (ShareResult) -> Unit,
) {
AsyncActionView(
async = state.shareAction,
@@ -39,15 +39,16 @@ class DefaultShareEntryPointTest {
ShareNode(
buildContext = buildContext,
plugins = plugins,
presenterFactory = { _, _ -> createSharePresenter() },
presenterFactory = { createSharePresenter() },
roomSelectEntryPoint = FakeRoomSelectEntryPoint(),
sessionId = A_SESSION_ID,
)
}
val callback = object : ShareEntryPoint.Callback {
override fun onDone(sessionId: SessionId, roomIds: List<RoomId>) = lambdaError()
override fun onCancel() = lambdaError()
}
val params = ShareEntryPoint.Params(
sessionId = A_SESSION_ID,
shareIntentData = ShareIntentData.PlainText(A_MESSAGE),
)
val result = entryPoint.createNode(
@@ -57,7 +58,7 @@ class DefaultShareEntryPointTest {
callback = callback,
)
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)
}
}
@@ -44,7 +44,6 @@ class SharePresenterTest {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.sessionId).isEqualTo(A_SESSION_ID)
assertThat(initialState.shareAction.isUninitialized()).isTrue()
}
}
@@ -86,8 +85,7 @@ class SharePresenterTest {
presenter.onRoomSelected(A_SESSION_ID, listOf(A_ROOM_ID))
assertThat(awaitItem().shareAction.isLoading()).isTrue()
val success = awaitItem()
assertThat(success.sessionId).isEqualTo(A_SESSION_ID)
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().with(value(client), value(shareIntentData), value(listOf(A_ROOM_ID)))
}
}
@@ -106,16 +104,11 @@ class SharePresenterTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.sessionId).isEqualTo(A_SESSION_ID)
skipItems(1)
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()
val success = awaitItem()
assertThat(success.sessionId).isEqualTo(A_SESSION_ID_2)
assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID)))
assertThat(success.shareAction).isEqualTo(AsyncAction.Success(ShareResult(A_SESSION_ID_2, listOf(A_ROOM_ID))))
getClient.assertions().isCalledOnce().with(value(A_SESSION_ID_2))
sendResult.assertions().isCalledOnce()
}
@@ -133,7 +126,6 @@ class SharePresenterTest {
}.test {
skipItems(1)
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).isEqualTo(AsyncAction.Failure(AN_EXCEPTION))
sendResult.assertions().isNeverCalled()
@@ -161,8 +153,8 @@ class SharePresenterTest {
presenter.onRoomSelected(A_SESSION_ID_2, listOf(A_ROOM_ID))
completable.complete(Result.success(Unit))
val success = awaitItem()
assertThat(success.sessionId).isEqualTo(A_SESSION_ID)
assertThat(success.shareAction).isEqualTo(AsyncAction.Success(listOf(A_ROOM_ID)))
// The second selection has been ignored
assertThat(success.shareAction).isEqualTo(AsyncAction.Success(ShareResult(A_SESSION_ID, listOf(A_ROOM_ID))))
sendResult.assertions().isCalledOnce()
}
}
@@ -170,13 +162,11 @@ class SharePresenterTest {
internal fun TestScope.createSharePresenter(
shareIntentData: ShareIntentData = ShareIntentData.PlainText(A_MESSAGE),
initialSessionId: SessionId = A_SESSION_ID,
matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
shareDataSender: ShareDataSender = FakeShareDataSender(),
): SharePresenter {
return SharePresenter(
shareIntentData = shareIntentData,
initialSessionId = initialSessionId,
appCoroutineScope = this,
matrixClientProvider = matrixClientProvider,
shareDataSender = shareDataSender,