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 61b7ee03c9
commit c97e60fcaf
15 changed files with 102 additions and 45 deletions

View file

@ -383,7 +383,16 @@ class RoomDetailsFlowNode(
knockRequestsListEntryPoint.createNode(this, buildContext)
}
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 -> {
val params = OutgoingVerificationEntryPoint.Params(

View file

@ -8,6 +8,19 @@
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)
class DefaultSecurityAndPrivacyEntryPoint : SecurityAndPrivacyEntryPoint {
override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
return parentNode.createNode<SecurityAndPrivacyFlowNode>(buildContext)
override fun createNode(
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.AssistedInject
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.root.SecurityAndPrivacyNode
import io.element.android.libraries.architecture.BackstackView
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.di.RoomScope
import kotlinx.parcelize.Parcelize
@ -47,7 +49,8 @@ class SecurityAndPrivacyFlowNode(
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 {
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.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyEntryPoint
interface SecurityAndPrivacyNavigator : Plugin {
fun onDone()
fun openEditRoomAddress()
fun closeEditRoomAddress()
}
class BackstackSecurityAndPrivacyNavigator(
private val callback: SecurityAndPrivacyEntryPoint.Callback,
private val backStack: BackStack<SecurityAndPrivacyFlowNode.NavTarget>
) : SecurityAndPrivacyNavigator {
override fun onDone() {
callback.onDone()
}
override fun openEditRoomAddress() {
backStack.push(SecurityAndPrivacyFlowNode.NavTarget.EditRoomAddress)
}

View file

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

View file

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

View file

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

View file

@ -27,7 +27,7 @@ open class SecurityAndPrivacyStateProvider : PreviewParameterProvider<SecurityAn
isSpace = false,
),
aSecurityAndPrivacyState(
confirmExitAction = AsyncAction.ConfirmingCancellation,
saveAction = AsyncAction.ConfirmingCancellation,
isSpace = false,
),
aSecurityAndPrivacyState(
@ -109,7 +109,6 @@ fun aSecurityAndPrivacyState(
homeserverName: String = "myserver.xyz",
showEncryptionConfirmation: Boolean = false,
saveAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
confirmExitAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
permissions: SecurityAndPrivacyPermissions = SecurityAndPrivacyPermissions(
canChangeRoomAccess = true,
canChangeHistoryVisibility = true,
@ -125,7 +124,6 @@ fun aSecurityAndPrivacyState(
homeserverName = homeserverName,
showEnableEncryptionConfirmation = showEncryptionConfirmation,
saveAction = saveAction,
confirmExitAction = confirmExitAction,
isKnockEnabled = isKnockEnabled,
permissions = permissions,
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.tokens.generated.CompoundIcons
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.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.components.async.AsyncActionView
@ -56,7 +57,6 @@ import kotlinx.collections.immutable.ImmutableSet
@Composable
fun SecurityAndPrivacyView(
state: SecurityAndPrivacyState,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
BackHandler {
@ -130,6 +130,16 @@ fun SecurityAndPrivacyView(
async = state.saveAction,
onSuccess = { },
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) },
progressDialog = {
AsyncActionViewDefaults.ProgressDialog(
@ -138,18 +148,6 @@ fun SecurityAndPrivacyView(
},
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)
@ -426,6 +424,5 @@ internal fun SecurityAndPrivacyViewDarkPreview(@PreviewParameter(SecurityAndPriv
private fun ContentToPreview(state: SecurityAndPrivacyState) {
SecurityAndPrivacyView(
state = state,
onBackClick = {},
)
}

View file

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

View file

@ -203,7 +203,7 @@ class SecurityAndPrivacyPresenterTest {
@Test
fun `present - edit room address`() = runTest {
val openEditRoomAddressLambda = lambdaRecorder<Unit> { }
val navigator = FakeSecurityAndPrivacyNavigator(openEditRoomAddressLambda)
val navigator = FakeSecurityAndPrivacyNavigator(openEditRoomAddressLambda = openEditRoomAddressLambda)
val presenter = createSecurityAndPrivacyPresenter(navigator = navigator)
presenter.test {
skipItems(1)
@ -231,7 +231,14 @@ class SecurityAndPrivacyPresenterTest {
updateRoomVisibilityResult = updateRoomVisibilityLambda,
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 {
skipItems(2)
with(awaitItem()) {
@ -276,6 +283,7 @@ class SecurityAndPrivacyPresenterTest {
assert(updateJoinRuleLambda).isCalledOnce()
assert(updateRoomVisibilityLambda).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.AsyncData
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.clickOn
import io.element.android.tests.testutils.pressBack
@ -50,27 +49,27 @@ class SecurityAndPrivacyViewTest {
}
@Test
fun `confirm cancellation emits the expected event`() {
fun `discard cancellation emits the expected event`() {
val recorder = EventsRecorder<SecurityAndPrivacyEvents>()
val state = aSecurityAndPrivacyState(
confirmExitAction = AsyncAction.ConfirmingCancellation,
saveAction = AsyncAction.ConfirmingCancellation,
eventSink = recorder,
)
rule.setSecurityAndPrivacyView(state)
rule.clickOn(CommonStrings.action_ok)
rule.clickOn(CommonStrings.action_discard)
recorder.assertSingle(SecurityAndPrivacyEvents.Exit)
}
@Test
fun `dismiss cancellation confirmation emits the expected event`() {
fun `save cancellation confirmation emits the expected event`() {
val recorder = EventsRecorder<SecurityAndPrivacyEvents>()
val state = aSecurityAndPrivacyState(
confirmExitAction = AsyncAction.ConfirmingCancellation,
saveAction = AsyncAction.ConfirmingCancellation,
eventSink = recorder,
)
rule.setSecurityAndPrivacyView(state)
rule.clickOn(CommonStrings.action_cancel)
recorder.assertSingle(SecurityAndPrivacyEvents.DismissExitConfirmation)
rule.clickOn(CommonStrings.action_save, inDialog = true)
recorder.assertSingle(SecurityAndPrivacyEvents.Save)
}
@Test
@ -185,12 +184,10 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setSecur
state: SecurityAndPrivacyState = aSecurityAndPrivacyState(
eventSink = EventsRecorder(expectEvents = false),
),
onBackClick: () -> Unit = EnsureNeverCalled(),
) {
setContent {
SecurityAndPrivacyView(
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
class FakeSecurityAndPrivacyEntryPoint : SecurityAndPrivacyEntryPoint {
override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
callback: SecurityAndPrivacyEntryPoint.Callback,
): Node {
lambdaError()
}
}

View file

@ -10,11 +10,14 @@ package io.element.android.tests.testutils
import androidx.activity.ComponentActivity
import androidx.annotation.StringRes
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.onFirst
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 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)
onNode(hasText(text) and hasClickAction())
onNode(
hasText(text) and hasClickAction() and if (inDialog) hasAnyAncestor(isDialog()) else trueMatcher
)
.performClick()
}