Remove confirmExitAction and use AsyncAction.ConfirmingCancellation instead.

This commit is contained in:
Benoit Marty 2025-12-03 15:45:33 +01:00 committed by Benoit Marty
parent 278231f3d2
commit eec632661f
15 changed files with 102 additions and 45 deletions

View file

@ -383,7 +383,16 @@ class RoomDetailsFlowNode(
knockRequestsListEntryPoint.createNode(this, buildContext) knockRequestsListEntryPoint.createNode(this, buildContext)
} }
NavTarget.SecurityAndPrivacy -> { NavTarget.SecurityAndPrivacy -> {
securityAndPrivacyEntryPoint.createNode(this, buildContext) val callback = object : SecurityAndPrivacyEntryPoint.Callback {
override fun onDone() {
backstack.pop()
}
}
securityAndPrivacyEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
callback = callback,
)
} }
is NavTarget.VerifyUser -> { is NavTarget.VerifyUser -> {
val params = OutgoingVerificationEntryPoint.Params( val params = OutgoingVerificationEntryPoint.Params(

View file

@ -8,6 +8,19 @@
package io.element.android.features.securityandprivacy.api package io.element.android.features.securityandprivacy.api
import io.element.android.libraries.architecture.SimpleFeatureEntryPoint 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
fun interface SecurityAndPrivacyEntryPoint : SimpleFeatureEntryPoint fun interface SecurityAndPrivacyEntryPoint : FeatureEntryPoint {
interface Callback : Plugin {
fun onDone()
}
fun createNode(
parentNode: Node,
buildContext: BuildContext,
callback: Callback,
): Node
}

View file

@ -17,7 +17,11 @@ import io.element.android.libraries.di.RoomScope
@ContributesBinding(RoomScope::class) @ContributesBinding(RoomScope::class)
class DefaultSecurityAndPrivacyEntryPoint : SecurityAndPrivacyEntryPoint { class DefaultSecurityAndPrivacyEntryPoint : SecurityAndPrivacyEntryPoint {
override fun createNode(parentNode: Node, buildContext: BuildContext): Node { override fun createNode(
return parentNode.createNode<SecurityAndPrivacyFlowNode>(buildContext) parentNode: Node,
buildContext: BuildContext,
callback: SecurityAndPrivacyEntryPoint.Callback,
): Node {
return parentNode.createNode<SecurityAndPrivacyFlowNode>(buildContext, listOf(callback))
} }
} }

View file

@ -18,10 +18,12 @@ import com.bumble.appyx.navmodel.backstack.BackStack
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
import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyEntryPoint
import io.element.android.features.securityandprivacy.impl.editroomaddress.EditRoomAddressNode import io.element.android.features.securityandprivacy.impl.editroomaddress.EditRoomAddressNode
import io.element.android.features.securityandprivacy.impl.root.SecurityAndPrivacyNode import io.element.android.features.securityandprivacy.impl.root.SecurityAndPrivacyNode
import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ -47,7 +49,8 @@ class SecurityAndPrivacyFlowNode(
data object EditRoomAddress : NavTarget data object EditRoomAddress : NavTarget
} }
private val navigator = BackstackSecurityAndPrivacyNavigator(backstack) private val callback: SecurityAndPrivacyEntryPoint.Callback = callback()
private val navigator = BackstackSecurityAndPrivacyNavigator(callback, backstack)
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return when (navTarget) {

View file

@ -12,15 +12,22 @@ import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push import com.bumble.appyx.navmodel.backstack.operation.push
import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyEntryPoint
interface SecurityAndPrivacyNavigator : Plugin { interface SecurityAndPrivacyNavigator : Plugin {
fun onDone()
fun openEditRoomAddress() fun openEditRoomAddress()
fun closeEditRoomAddress() fun closeEditRoomAddress()
} }
class BackstackSecurityAndPrivacyNavigator( class BackstackSecurityAndPrivacyNavigator(
private val callback: SecurityAndPrivacyEntryPoint.Callback,
private val backStack: BackStack<SecurityAndPrivacyFlowNode.NavTarget> private val backStack: BackStack<SecurityAndPrivacyFlowNode.NavTarget>
) : SecurityAndPrivacyNavigator { ) : SecurityAndPrivacyNavigator {
override fun onDone() {
callback.onDone()
}
override fun openEditRoomAddress() { override fun openEditRoomAddress() {
backStack.push(SecurityAndPrivacyFlowNode.NavTarget.EditRoomAddress) backStack.push(SecurityAndPrivacyFlowNode.NavTarget.EditRoomAddress)
} }

View file

@ -40,7 +40,6 @@ class SecurityAndPrivacyNode(
val state by stateFlow.collectAsState() val state by stateFlow.collectAsState()
SecurityAndPrivacyView( SecurityAndPrivacyView(
state = state, state = state,
onBackClick = this::navigateUp,
modifier = modifier modifier = modifier
) )
} }

View file

@ -64,7 +64,6 @@ class SecurityAndPrivacyPresenter(
featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock) featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock)
}.collectAsState(false) }.collectAsState(false)
val saveAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) } val saveAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
var confirmExitAction by remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
val homeserverName = remember { matrixClient.userIdServerName() } val homeserverName = remember { matrixClient.userIdServerName() }
val syncUpdateFlow = room.syncUpdateFlow.collectAsState() val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val roomInfo by room.roomInfoFlow.collectAsState() val roomInfo by room.roomInfoFlow.collectAsState()
@ -150,18 +149,24 @@ class SecurityAndPrivacyPresenter(
saveAction.value = AsyncAction.Uninitialized saveAction.value = AsyncAction.Uninitialized
} }
SecurityAndPrivacyEvents.Exit -> { SecurityAndPrivacyEvents.Exit -> {
confirmExitAction = if (savedSettings == editedSettings || confirmExitAction.isConfirming()) { saveAction.value = if (savedSettings == editedSettings || saveAction.value == AsyncAction.ConfirmingCancellation) {
AsyncAction.Success(Unit) AsyncAction.Success(Unit)
} else { } else {
AsyncAction.ConfirmingNoParams AsyncAction.ConfirmingCancellation
} }
} }
SecurityAndPrivacyEvents.DismissExitConfirmation -> { SecurityAndPrivacyEvents.DismissExitConfirmation -> {
confirmExitAction = AsyncAction.Uninitialized saveAction.value = AsyncAction.Uninitialized
} }
} }
} }
LaunchedEffect(saveAction.value.isSuccess()) {
if (saveAction.value.isSuccess()) {
navigator.onDone()
}
}
val state = SecurityAndPrivacyState( val state = SecurityAndPrivacyState(
savedSettings = savedSettings, savedSettings = savedSettings,
editedSettings = editedSettings, editedSettings = editedSettings,
@ -171,7 +176,6 @@ class SecurityAndPrivacyPresenter(
saveAction = saveAction.value, saveAction = saveAction.value,
permissions = permissions, permissions = permissions,
isSpace = roomInfo.isSpace, isSpace = roomInfo.isSpace,
confirmExitAction = confirmExitAction,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )

View file

@ -22,7 +22,6 @@ data class SecurityAndPrivacyState(
val showEnableEncryptionConfirmation: Boolean, val showEnableEncryptionConfirmation: Boolean,
val isKnockEnabled: Boolean, val isKnockEnabled: Boolean,
val saveAction: AsyncAction<Unit>, val saveAction: AsyncAction<Unit>,
val confirmExitAction: AsyncAction<Unit>,
val isSpace: Boolean, val isSpace: Boolean,
private val permissions: SecurityAndPrivacyPermissions, private val permissions: SecurityAndPrivacyPermissions,
val eventSink: (SecurityAndPrivacyEvents) -> Unit val eventSink: (SecurityAndPrivacyEvents) -> Unit

View file

@ -27,7 +27,7 @@ open class SecurityAndPrivacyStateProvider : PreviewParameterProvider<SecurityAn
isSpace = false, isSpace = false,
), ),
aSecurityAndPrivacyState( aSecurityAndPrivacyState(
confirmExitAction = AsyncAction.ConfirmingCancellation, saveAction = AsyncAction.ConfirmingCancellation,
isSpace = false, isSpace = false,
), ),
aSecurityAndPrivacyState( aSecurityAndPrivacyState(
@ -109,7 +109,6 @@ fun aSecurityAndPrivacyState(
homeserverName: String = "myserver.xyz", homeserverName: String = "myserver.xyz",
showEncryptionConfirmation: Boolean = false, showEncryptionConfirmation: Boolean = false,
saveAction: AsyncAction<Unit> = AsyncAction.Uninitialized, saveAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
confirmExitAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
permissions: SecurityAndPrivacyPermissions = SecurityAndPrivacyPermissions( permissions: SecurityAndPrivacyPermissions = SecurityAndPrivacyPermissions(
canChangeRoomAccess = true, canChangeRoomAccess = true,
canChangeHistoryVisibility = true, canChangeHistoryVisibility = true,
@ -125,7 +124,6 @@ fun aSecurityAndPrivacyState(
homeserverName = homeserverName, homeserverName = homeserverName,
showEnableEncryptionConfirmation = showEncryptionConfirmation, showEnableEncryptionConfirmation = showEncryptionConfirmation,
saveAction = saveAction, saveAction = saveAction,
confirmExitAction = confirmExitAction,
isKnockEnabled = isKnockEnabled, isKnockEnabled = isKnockEnabled,
permissions = permissions, permissions = permissions,
isSpace = isSpace, isSpace = isSpace,

View file

@ -32,6 +32,7 @@ import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.securityandprivacy.impl.R import io.element.android.features.securityandprivacy.impl.R
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.components.async.AsyncActionView import io.element.android.libraries.designsystem.components.async.AsyncActionView
@ -56,7 +57,6 @@ import kotlinx.collections.immutable.ImmutableSet
@Composable @Composable
fun SecurityAndPrivacyView( fun SecurityAndPrivacyView(
state: SecurityAndPrivacyState, state: SecurityAndPrivacyState,
onBackClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
BackHandler { BackHandler {
@ -130,6 +130,16 @@ fun SecurityAndPrivacyView(
async = state.saveAction, async = state.saveAction,
onSuccess = { }, onSuccess = { },
onErrorDismiss = { state.eventSink(SecurityAndPrivacyEvents.DismissSaveError) }, onErrorDismiss = { state.eventSink(SecurityAndPrivacyEvents.DismissSaveError) },
confirmationDialog = { confirming ->
when (confirming) {
is AsyncAction.ConfirmingCancellation ->
SaveChangesDialog(
onSaveClick = { state.eventSink(SecurityAndPrivacyEvents.Save) },
onDiscardClick = { state.eventSink(SecurityAndPrivacyEvents.Exit) },
onDismiss = { state.eventSink(SecurityAndPrivacyEvents.DismissExitConfirmation) }
)
}
},
errorMessage = { stringResource(CommonStrings.error_unknown) }, errorMessage = { stringResource(CommonStrings.error_unknown) },
progressDialog = { progressDialog = {
AsyncActionViewDefaults.ProgressDialog( AsyncActionViewDefaults.ProgressDialog(
@ -138,18 +148,6 @@ fun SecurityAndPrivacyView(
}, },
onRetry = { state.eventSink(SecurityAndPrivacyEvents.Save) }, onRetry = { state.eventSink(SecurityAndPrivacyEvents.Save) },
) )
AsyncActionView(
async = state.confirmExitAction,
onSuccess = { onBackClick() },
onErrorDismiss = { },
confirmationDialog = {
SaveChangesDialog(
onSaveClick = { state.eventSink(SecurityAndPrivacyEvents.Save) },
onDiscardClick = { state.eventSink(SecurityAndPrivacyEvents.Exit) },
onDismiss = { state.eventSink(SecurityAndPrivacyEvents.DismissExitConfirmation) }
)
},
)
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@ -426,6 +424,5 @@ internal fun SecurityAndPrivacyViewDarkPreview(@PreviewParameter(SecurityAndPriv
private fun ContentToPreview(state: SecurityAndPrivacyState) { private fun ContentToPreview(state: SecurityAndPrivacyState) {
SecurityAndPrivacyView( SecurityAndPrivacyView(
state = state, state = state,
onBackClick = {},
) )
} }

View file

@ -11,9 +11,14 @@ package io.element.android.features.securityandprivacy.impl
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
class FakeSecurityAndPrivacyNavigator( class FakeSecurityAndPrivacyNavigator(
private val onDoneLambda: () -> Unit = { lambdaError() },
private val openEditRoomAddressLambda: () -> Unit = { lambdaError() }, private val openEditRoomAddressLambda: () -> Unit = { lambdaError() },
private val closeEditRoomAddressLambda: () -> Unit = { lambdaError() }, private val closeEditRoomAddressLambda: () -> Unit = { lambdaError() },
) : SecurityAndPrivacyNavigator { ) : SecurityAndPrivacyNavigator {
override fun onDone() {
onDoneLambda()
}
override fun openEditRoomAddress() { override fun openEditRoomAddress() {
openEditRoomAddressLambda() openEditRoomAddressLambda()
} }

View file

@ -203,7 +203,7 @@ class SecurityAndPrivacyPresenterTest {
@Test @Test
fun `present - edit room address`() = runTest { fun `present - edit room address`() = runTest {
val openEditRoomAddressLambda = lambdaRecorder<Unit> { } val openEditRoomAddressLambda = lambdaRecorder<Unit> { }
val navigator = FakeSecurityAndPrivacyNavigator(openEditRoomAddressLambda) val navigator = FakeSecurityAndPrivacyNavigator(openEditRoomAddressLambda = openEditRoomAddressLambda)
val presenter = createSecurityAndPrivacyPresenter(navigator = navigator) val presenter = createSecurityAndPrivacyPresenter(navigator = navigator)
presenter.test { presenter.test {
skipItems(1) skipItems(1)
@ -231,7 +231,14 @@ class SecurityAndPrivacyPresenterTest {
updateRoomVisibilityResult = updateRoomVisibilityLambda, updateRoomVisibilityResult = updateRoomVisibilityLambda,
updateRoomHistoryVisibilityResult = updateRoomHistoryVisibilityLambda, updateRoomHistoryVisibilityResult = updateRoomHistoryVisibilityLambda,
) )
val presenter = createSecurityAndPrivacyPresenter(room = room) val onDoneLambda = lambdaRecorder<Unit> { }
val navigator = FakeSecurityAndPrivacyNavigator(
onDoneLambda = onDoneLambda,
)
val presenter = createSecurityAndPrivacyPresenter(
room = room,
navigator = navigator,
)
presenter.test { presenter.test {
skipItems(2) skipItems(2)
with(awaitItem()) { with(awaitItem()) {
@ -276,6 +283,7 @@ class SecurityAndPrivacyPresenterTest {
assert(updateJoinRuleLambda).isCalledOnce() assert(updateJoinRuleLambda).isCalledOnce()
assert(updateRoomVisibilityLambda).isCalledOnce() assert(updateRoomVisibilityLambda).isCalledOnce()
assert(updateRoomHistoryVisibilityLambda).isCalledOnce() assert(updateRoomHistoryVisibilityLambda).isCalledOnce()
onDoneLambda.assertions().isCalledOnce()
} }
} }

View file

@ -24,7 +24,6 @@ import io.element.android.features.securityandprivacy.impl.root.aSecurityAndPriv
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.pressBack import io.element.android.tests.testutils.pressBack
@ -50,27 +49,27 @@ class SecurityAndPrivacyViewTest {
} }
@Test @Test
fun `confirm cancellation emits the expected event`() { fun `discard cancellation emits the expected event`() {
val recorder = EventsRecorder<SecurityAndPrivacyEvents>() val recorder = EventsRecorder<SecurityAndPrivacyEvents>()
val state = aSecurityAndPrivacyState( val state = aSecurityAndPrivacyState(
confirmExitAction = AsyncAction.ConfirmingCancellation, saveAction = AsyncAction.ConfirmingCancellation,
eventSink = recorder, eventSink = recorder,
) )
rule.setSecurityAndPrivacyView(state) rule.setSecurityAndPrivacyView(state)
rule.clickOn(CommonStrings.action_ok) rule.clickOn(CommonStrings.action_discard)
recorder.assertSingle(SecurityAndPrivacyEvents.Exit) recorder.assertSingle(SecurityAndPrivacyEvents.Exit)
} }
@Test @Test
fun `dismiss cancellation confirmation emits the expected event`() { fun `save cancellation confirmation emits the expected event`() {
val recorder = EventsRecorder<SecurityAndPrivacyEvents>() val recorder = EventsRecorder<SecurityAndPrivacyEvents>()
val state = aSecurityAndPrivacyState( val state = aSecurityAndPrivacyState(
confirmExitAction = AsyncAction.ConfirmingCancellation, saveAction = AsyncAction.ConfirmingCancellation,
eventSink = recorder, eventSink = recorder,
) )
rule.setSecurityAndPrivacyView(state) rule.setSecurityAndPrivacyView(state)
rule.clickOn(CommonStrings.action_cancel) rule.clickOn(CommonStrings.action_save, inDialog = true)
recorder.assertSingle(SecurityAndPrivacyEvents.DismissExitConfirmation) recorder.assertSingle(SecurityAndPrivacyEvents.Save)
} }
@Test @Test
@ -185,12 +184,10 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setSecur
state: SecurityAndPrivacyState = aSecurityAndPrivacyState( state: SecurityAndPrivacyState = aSecurityAndPrivacyState(
eventSink = EventsRecorder(expectEvents = false), eventSink = EventsRecorder(expectEvents = false),
), ),
onBackClick: () -> Unit = EnsureNeverCalled(),
) { ) {
setContent { setContent {
SecurityAndPrivacyView( SecurityAndPrivacyView(
state = state, state = state,
onBackClick = onBackClick,
) )
} }
} }

View file

@ -13,7 +13,11 @@ import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyEntr
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
class FakeSecurityAndPrivacyEntryPoint : SecurityAndPrivacyEntryPoint { class FakeSecurityAndPrivacyEntryPoint : SecurityAndPrivacyEntryPoint {
override fun createNode(parentNode: Node, buildContext: BuildContext): Node { override fun createNode(
parentNode: Node,
buildContext: BuildContext,
callback: SecurityAndPrivacyEntryPoint.Callback,
): Node {
lambdaError() lambdaError()
} }
} }

View file

@ -10,11 +10,14 @@ package io.element.android.tests.testutils
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasContentDescription import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
@ -22,9 +25,16 @@ import androidx.compose.ui.test.performClick
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import org.junit.rules.TestRule import org.junit.rules.TestRule
fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.clickOn(@StringRes res: Int) { val trueMatcher = SemanticsMatcher("true matcher") { true }
fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.clickOn(
@StringRes res: Int,
inDialog: Boolean = false,
) {
val text = activity.getString(res) val text = activity.getString(res)
onNode(hasText(text) and hasClickAction()) onNode(
hasText(text) and hasClickAction() and if (inDialog) hasAnyAncestor(isDialog()) else trueMatcher
)
.performClick() .performClick()
} }