Edit user profile cancel confirmation (#5788)

* Show not save dialog when exiting user profile edition with unsaved changes

* Use test extension

* Add unit tests

* Add preview

* Add unit test on EditUserProfileView

* Avoid using navigateUp.

* Update screenshots

* Remove BaseCallback, it's actually not ideal when looking for usage.

* Remove BaseNavigator, it's actually not ideal when looking for usage.

* Fix crash when clicking fast on back key on the Add account screen.

* Fix crash when clicking fast on back key on the Labs screen.

* Fix crash when clicking fast on back key on the Developer settings screen.

* Fix compilation issue in test

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Benoit Marty 2025-11-24 09:38:15 +01:00 committed by GitHub
commit df7640a0a3
20 changed files with 374 additions and 76 deletions

View file

@ -57,6 +57,7 @@ class NotLoggedInFlowNode(
interface Callback : Plugin { interface Callback : Plugin {
fun navigateToBugReport() fun navigateToBugReport()
fun onDone()
} }
private val callback: Callback = callback() private val callback: Callback = callback()
@ -83,6 +84,10 @@ class NotLoggedInFlowNode(
override fun navigateToBugReport() { override fun navigateToBugReport() {
callback.navigateToBugReport() callback.navigateToBugReport()
} }
override fun onDone() {
callback.onDone()
}
} }
loginEntryPoint.createNode( loginEntryPoint.createNode(
parentNode = this, parentNode = this,

View file

@ -243,6 +243,10 @@ class RootFlowNode(
override fun navigateToBugReport() { override fun navigateToBugReport() {
backstack.push(NavTarget.BugReport) backstack.push(NavTarget.BugReport)
} }
override fun onDone() {
backstack.pop()
}
} }
val params = NotLoggedInFlowNode.Params( val params = NotLoggedInFlowNode.Params(
loginParams = navTarget.params, loginParams = navTarget.params,

View file

@ -21,6 +21,7 @@ interface LoginEntryPoint : FeatureEntryPoint {
interface Callback : Plugin { interface Callback : Plugin {
fun navigateToBugReport() fun navigateToBugReport()
fun onDone()
} }
fun createNode( fun createNode(

View file

@ -167,6 +167,10 @@ class LoginFlowNode(
override fun navigateToLoginPassword() { override fun navigateToLoginPassword() {
backstack.push(NavTarget.LoginPassword) backstack.push(NavTarget.LoginPassword)
} }
override fun onDone() {
callback.onDone()
}
} }
val params = inputs<Params>() val params = inputs<Params>()
val inputs = OnBoardingNode.Params( val inputs = OnBoardingNode.Params(

View file

@ -42,6 +42,7 @@ class OnBoardingNode(
fun navigateToLoginPassword() fun navigateToLoginPassword()
fun navigateToOidc(oidcDetails: OidcDetails) fun navigateToOidc(oidcDetails: OidcDetails)
fun navigateToCreateAccount(url: String) fun navigateToCreateAccount(url: String)
fun onDone()
} }
data class Params( data class Params(
@ -71,7 +72,7 @@ class OnBoardingNode(
onNeedLoginPassword = callback::navigateToLoginPassword, onNeedLoginPassword = callback::navigateToLoginPassword,
onLearnMoreClick = { openLearnMorePage(context) }, onLearnMoreClick = { openLearnMorePage(context) },
onCreateAccountContinue = callback::navigateToCreateAccount, onCreateAccountContinue = callback::navigateToCreateAccount,
onBackClick = ::navigateUp, onBackClick = callback::onDone,
) )
} }
} }

View file

@ -43,6 +43,7 @@ class DefaultLoginEntryPointTest {
} }
val callback = object : LoginEntryPoint.Callback { val callback = object : LoginEntryPoint.Callback {
override fun navigateToBugReport() = lambdaError() override fun navigateToBugReport() = lambdaError()
override fun onDone() = lambdaError()
} }
val params = LoginEntryPoint.Params( val params = LoginEntryPoint.Params(
accountProvider = "ac", accountProvider = "ac",

View file

@ -186,11 +186,20 @@ class PreferencesFlowNode(
override fun navigateToPushHistory() { override fun navigateToPushHistory() {
backstack.push(NavTarget.PushHistory) backstack.push(NavTarget.PushHistory)
} }
override fun onDone() {
backstack.pop()
}
} }
createNode<DeveloperSettingsNode>(buildContext, listOf(developerSettingsCallback)) createNode<DeveloperSettingsNode>(buildContext, listOf(developerSettingsCallback))
} }
NavTarget.Labs -> { NavTarget.Labs -> {
createNode<LabsNode>(buildContext) val callback = object : LabsNode.Callback {
override fun onDone() {
backstack.pop()
}
}
createNode<LabsNode>(buildContext, listOf(callback))
} }
NavTarget.About -> { NavTarget.About -> {
val callback = object : AboutNode.Callback { val callback = object : AboutNode.Callback {
@ -267,7 +276,12 @@ class PreferencesFlowNode(
} }
is NavTarget.UserProfile -> { is NavTarget.UserProfile -> {
val inputs = EditUserProfileNode.Inputs(navTarget.matrixUser) val inputs = EditUserProfileNode.Inputs(navTarget.matrixUser)
createNode<EditUserProfileNode>(buildContext, listOf(inputs)) val callback = object : EditUserProfileNode.Callback {
override fun onDone() {
backstack.pop()
}
}
createNode<EditUserProfileNode>(buildContext, listOf(inputs, callback))
} }
NavTarget.LockScreenSettings -> { NavTarget.LockScreenSettings -> {
lockScreenEntryPoint.createNode( lockScreenEntryPoint.createNode(

View file

@ -31,6 +31,7 @@ class DeveloperSettingsNode(
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin { interface Callback : Plugin {
fun navigateToPushHistory() fun navigateToPushHistory()
fun onDone()
} }
private val callback: Callback = callback() private val callback: Callback = callback()
@ -49,7 +50,7 @@ class DeveloperSettingsNode(
modifier = modifier, modifier = modifier,
onOpenShowkase = ::openShowkase, onOpenShowkase = ::openShowkase,
onPushHistoryClick = callback::navigateToPushHistory, onPushHistoryClick = callback::navigateToPushHistory,
onBackClick = ::navigateUp onBackClick = callback::onDone,
) )
} }
} }

View file

@ -16,6 +16,7 @@ import com.bumble.appyx.core.plugin.Plugin
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.libraries.architecture.callback
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
@ -25,9 +26,18 @@ class LabsNode(
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val presenter: LabsPresenter, private val presenter: LabsPresenter,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onDone()
}
val callback: Callback = callback()
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
LabsView(state = state, onBack = ::navigateUp) LabsView(
state = state,
onBack = callback::onDone,
)
} }
} }

View file

@ -13,6 +13,7 @@ import io.element.android.libraries.matrix.ui.media.AvatarAction
sealed interface EditUserProfileEvents { sealed interface EditUserProfileEvents {
data class HandleAvatarAction(val action: AvatarAction) : EditUserProfileEvents data class HandleAvatarAction(val action: AvatarAction) : EditUserProfileEvents
data class UpdateDisplayName(val name: String) : EditUserProfileEvents data class UpdateDisplayName(val name: String) : EditUserProfileEvents
data object Exit : EditUserProfileEvents
data object Save : EditUserProfileEvents data object Save : EditUserProfileEvents
data object CancelSaveChanges : EditUserProfileEvents data object CloseDialog : EditUserProfileEvents
} }

View file

@ -0,0 +1,12 @@
/*
* Copyright (c) 2025 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.preferences.impl.user.editprofile
interface EditUserProfileNavigator {
fun close()
}

View file

@ -17,6 +17,7 @@ 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.libraries.architecture.NodeInputs 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.architecture.inputs
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
@ -27,22 +28,32 @@ class EditUserProfileNode(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
presenterFactory: EditUserProfilePresenter.Factory, presenterFactory: EditUserProfilePresenter.Factory,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins),
EditUserProfileNavigator {
data class Inputs( data class Inputs(
val matrixUser: MatrixUser val matrixUser: MatrixUser
) : NodeInputs ) : NodeInputs
interface Callback : Plugin {
fun onDone()
}
val matrixUser = inputs<Inputs>().matrixUser val matrixUser = inputs<Inputs>().matrixUser
val presenter = presenterFactory.create(matrixUser) val callback: Callback = callback()
val presenter = presenterFactory.create(
matrixUser = matrixUser,
navigator = this,
)
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
EditUserProfileView( EditUserProfileView(
state = state, state = state,
onBackClick = ::navigateUp, onEditProfileSuccess = ::close,
onEditProfileSuccess = ::navigateUp,
modifier = modifier modifier = modifier
) )
} }
override fun close() = callback.onDone()
} }

View file

@ -45,6 +45,7 @@ import timber.log.Timber
@AssistedInject @AssistedInject
class EditUserProfilePresenter( class EditUserProfilePresenter(
@Assisted private val matrixUser: MatrixUser, @Assisted private val matrixUser: MatrixUser,
@Assisted private val navigator: EditUserProfileNavigator,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val mediaPickerProvider: PickerProvider, private val mediaPickerProvider: PickerProvider,
private val mediaPreProcessor: MediaPreProcessor, private val mediaPreProcessor: MediaPreProcessor,
@ -57,7 +58,10 @@ class EditUserProfilePresenter(
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create(matrixUser: MatrixUser): EditUserProfilePresenter fun create(
matrixUser: MatrixUser,
navigator: EditUserProfileNavigator,
): EditUserProfilePresenter
} }
@Composable @Composable
@ -101,6 +105,13 @@ class EditUserProfilePresenter(
val saveAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) } val saveAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
val canSave = remember(userDisplayName, userAvatarUri) {
val hasProfileChanged = hasDisplayNameChanged(userDisplayName, matrixUser) ||
hasAvatarUrlChanged(userAvatarUri, matrixUser)
!userDisplayName.isNullOrBlank() && hasProfileChanged
}
fun handleEvent(event: EditUserProfileEvents) { fun handleEvent(event: EditUserProfileEvents) {
when (event) { when (event) {
is EditUserProfileEvents.Save -> localCoroutineScope.saveChanges( is EditUserProfileEvents.Save -> localCoroutineScope.saveChanges(
@ -124,18 +135,32 @@ class EditUserProfilePresenter(
} }
} }
} }
is EditUserProfileEvents.UpdateDisplayName -> userDisplayName = event.name is EditUserProfileEvents.UpdateDisplayName -> userDisplayName = event.name
EditUserProfileEvents.CancelSaveChanges -> saveAction.value = AsyncAction.Uninitialized EditUserProfileEvents.Exit -> {
when (saveAction.value) {
is AsyncAction.Confirming -> {
// Close the dialog right now
saveAction.value = AsyncAction.Uninitialized
navigator.close()
}
AsyncAction.Loading -> Unit
is AsyncAction.Failure,
is AsyncAction.Success -> {
// Should not happen
}
AsyncAction.Uninitialized -> {
if (canSave) {
saveAction.value = AsyncAction.ConfirmingCancellation
} else {
navigator.close()
}
}
}
}
EditUserProfileEvents.CloseDialog -> saveAction.value = AsyncAction.Uninitialized
} }
} }
val canSave = remember(userDisplayName, userAvatarUri) {
val hasProfileChanged = hasDisplayNameChanged(userDisplayName, matrixUser) ||
hasAvatarUrlChanged(userAvatarUri, matrixUser)
!userDisplayName.isNullOrBlank() && hasProfileChanged
}
return EditUserProfileState( return EditUserProfileState(
userId = matrixUser.userId, userId = matrixUser.userId,
displayName = userDisplayName.orEmpty(), displayName = userDisplayName.orEmpty(),

View file

@ -11,27 +11,36 @@ package io.element.android.features.preferences.impl.user.editprofile
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.ui.media.AvatarAction
import io.element.android.libraries.permissions.api.PermissionsState
import io.element.android.libraries.permissions.api.aPermissionsState import io.element.android.libraries.permissions.api.aPermissionsState
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList
open class EditUserProfileStateProvider : PreviewParameterProvider<EditUserProfileState> { open class EditUserProfileStateProvider : PreviewParameterProvider<EditUserProfileState> {
override val values: Sequence<EditUserProfileState> override val values: Sequence<EditUserProfileState>
get() = sequenceOf( get() = sequenceOf(
aEditUserProfileState(), aEditUserProfileState(),
aEditUserProfileState(userAvatarUrl = "example://uri"), aEditUserProfileState(userAvatarUrl = "example://uri"),
// Add other states here aEditUserProfileState(saveAction = AsyncAction.ConfirmingCancellation),
) )
} }
fun aEditUserProfileState( fun aEditUserProfileState(
userId: UserId = UserId("@john.doe:matrix.org"),
displayName: String = "John Doe",
userAvatarUrl: String? = null, userAvatarUrl: String? = null,
avatarActions: List<AvatarAction> = emptyList(),
saveButtonEnabled: Boolean = true,
saveAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
cameraPermissionState: PermissionsState = aPermissionsState(showDialog = false),
eventSink: (EditUserProfileEvents) -> Unit = {},
) = EditUserProfileState( ) = EditUserProfileState(
userId = UserId("@john.doe:matrix.org"), userId = userId,
displayName = "John Doe", displayName = displayName,
userAvatarUrl = userAvatarUrl, userAvatarUrl = userAvatarUrl,
avatarActions = persistentListOf(), avatarActions = avatarActions.toImmutableList(),
saveAction = AsyncAction.Uninitialized, saveButtonEnabled = saveButtonEnabled,
saveButtonEnabled = true, saveAction = saveAction,
cameraPermissionState = aPermissionsState(showDialog = false), cameraPermissionState = cameraPermissionState,
eventSink = {} eventSink = eventSink,
) )

View file

@ -8,6 +8,7 @@
package io.element.android.features.preferences.impl.user.editprofile package io.element.android.features.preferences.impl.user.editprofile
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@ -30,11 +31,13 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.features.preferences.impl.R import io.element.android.features.preferences.impl.R
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.async.AsyncActionView import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.SaveChangesDialog
import io.element.android.libraries.designsystem.modifiers.clearFocusOnTap import io.element.android.libraries.designsystem.modifiers.clearFocusOnTap
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
@ -52,7 +55,6 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun EditUserProfileView( fun EditUserProfileView(
state: EditUserProfileState, state: EditUserProfileState,
onBackClick: () -> Unit,
onEditProfileSuccess: () -> Unit, onEditProfileSuccess: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -64,12 +66,21 @@ fun EditUserProfileView(
isAvatarActionsSheetVisible.value = true isAvatarActionsSheetVisible.value = true
} }
fun onBackClick() {
focusManager.clearFocus()
state.eventSink(EditUserProfileEvents.Exit)
}
BackHandler(
enabled = true,
::onBackClick,
)
Scaffold( Scaffold(
modifier = modifier.clearFocusOnTap(focusManager), modifier = modifier.clearFocusOnTap(focusManager),
topBar = { topBar = {
TopAppBar( TopAppBar(
titleStr = stringResource(R.string.screen_edit_profile_title), titleStr = stringResource(R.string.screen_edit_profile_title),
navigationIcon = { BackButton(onClick = onBackClick) }, navigationIcon = { BackButton(::onBackClick) },
actions = { actions = {
TextButton( TextButton(
text = stringResource(CommonStrings.action_save), text = stringResource(CommonStrings.action_save),
@ -132,10 +143,20 @@ fun EditUserProfileView(
progressText = stringResource(R.string.screen_edit_profile_updating_details), progressText = stringResource(R.string.screen_edit_profile_updating_details),
) )
}, },
confirmationDialog = { confirming ->
when (confirming) {
is AsyncAction.ConfirmingCancellation -> {
SaveChangesDialog(
onSubmitClick = { state.eventSink(EditUserProfileEvents.Exit) },
onDismiss = { state.eventSink(EditUserProfileEvents.CloseDialog) }
)
}
}
},
onSuccess = { onEditProfileSuccess() }, onSuccess = { onEditProfileSuccess() },
errorTitle = { stringResource(R.string.screen_edit_profile_error_title) }, errorTitle = { stringResource(R.string.screen_edit_profile_error_title) },
errorMessage = { stringResource(R.string.screen_edit_profile_error) }, errorMessage = { stringResource(R.string.screen_edit_profile_error) },
onErrorDismiss = { state.eventSink(EditUserProfileEvents.CancelSaveChanges) }, onErrorDismiss = { state.eventSink(EditUserProfileEvents.CloseDialog) },
) )
} }
PermissionsView( PermissionsView(
@ -148,7 +169,6 @@ fun EditUserProfileView(
internal fun EditUserProfileViewPreview(@PreviewParameter(EditUserProfileStateProvider::class) state: EditUserProfileState) = internal fun EditUserProfileViewPreview(@PreviewParameter(EditUserProfileStateProvider::class) state: EditUserProfileState) =
ElementPreview { ElementPreview {
EditUserProfileView( EditUserProfileView(
onBackClick = {},
onEditProfileSuccess = {}, onEditProfileSuccess = {},
state = state, state = state,
) )

View file

@ -9,9 +9,6 @@
package io.element.android.features.preferences.impl.user.editprofile package io.element.android.features.preferences.impl.user.editprofile
import android.net.Uri import android.net.Uri
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
@ -35,6 +32,7 @@ import io.element.android.tests.testutils.consumeItemsUntilTimeout
import io.element.android.tests.testutils.fake.FakeTemporaryUriDeleter import io.element.android.tests.testutils.fake.FakeTemporaryUriDeleter
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.lambda.value
import io.element.android.tests.testutils.test
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkStatic import io.mockk.mockkStatic
@ -79,6 +77,7 @@ class EditUserProfilePresenterTest {
private fun createEditUserProfilePresenter( private fun createEditUserProfilePresenter(
matrixClient: MatrixClient = FakeMatrixClient(), matrixClient: MatrixClient = FakeMatrixClient(),
navigator: EditUserProfileNavigator = FakeEditUserProfileNavigator(),
matrixUser: MatrixUser = aMatrixUser(), matrixUser: MatrixUser = aMatrixUser(),
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(), permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(), temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(),
@ -86,6 +85,7 @@ class EditUserProfilePresenterTest {
): EditUserProfilePresenter { ): EditUserProfilePresenter {
return EditUserProfilePresenter( return EditUserProfilePresenter(
matrixClient = matrixClient, matrixClient = matrixClient,
navigator = navigator,
matrixUser = matrixUser, matrixUser = matrixUser,
mediaPickerProvider = fakePickerProvider, mediaPickerProvider = fakePickerProvider,
mediaPreProcessor = fakeMediaPreProcessor, mediaPreProcessor = fakeMediaPreProcessor,
@ -99,9 +99,7 @@ class EditUserProfilePresenterTest {
fun `present - initial state is created from user info`() = runTest { fun `present - initial state is created from user info`() = runTest {
val user = aMatrixUser(avatarUrl = AN_AVATAR_URL) val user = aMatrixUser(avatarUrl = AN_AVATAR_URL)
val presenter = createEditUserProfilePresenter(matrixUser = user) val presenter = createEditUserProfilePresenter(matrixUser = user)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.userId).isEqualTo(user.userId) assertThat(initialState.userId).isEqualTo(user.userId)
assertThat(initialState.displayName).isEqualTo(user.displayName) assertThat(initialState.displayName).isEqualTo(user.displayName)
@ -116,6 +114,53 @@ class EditUserProfilePresenterTest {
} }
} }
@Test
fun `present - exit invokes the expected callback`() = runTest {
val user = aMatrixUser(avatarUrl = AN_AVATAR_URL)
val closeLambda = lambdaRecorder<Unit> {}
val presenter = createEditUserProfilePresenter(
matrixUser = user,
navigator = FakeEditUserProfileNavigator(closeLambda),
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(EditUserProfileEvents.Exit)
closeLambda.assertions().isCalledOnce()
}
}
@Test
fun `present - exit without unsaved changes`() = runTest {
val user = aMatrixUser(avatarUrl = AN_AVATAR_URL)
val closeLambda = lambdaRecorder<Unit> {}
val presenter = createEditUserProfilePresenter(
matrixUser = user,
navigator = FakeEditUserProfileNavigator(closeLambda),
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("New name"))
val withUpdatedName = awaitItem()
withUpdatedName.eventSink(EditUserProfileEvents.Exit)
val withConfirmation = awaitItem()
assertThat(withConfirmation.saveAction).isEqualTo(AsyncAction.ConfirmingCancellation)
// Cancel
withConfirmation.eventSink(EditUserProfileEvents.CloseDialog)
val afterCancel = awaitItem()
assertThat(afterCancel.saveAction).isEqualTo(AsyncAction.Uninitialized)
// Try again and confirm
afterCancel.eventSink(EditUserProfileEvents.Exit)
val withConfirmation2 = awaitItem()
assertThat(withConfirmation2.saveAction).isEqualTo(AsyncAction.ConfirmingCancellation)
closeLambda.assertions().isNeverCalled()
withConfirmation2.eventSink(EditUserProfileEvents.Exit)
// Dialog is closed
val finalState = awaitItem()
assertThat(finalState.saveAction).isEqualTo(AsyncAction.Uninitialized)
closeLambda.assertions().isCalledOnce()
}
}
@Test @Test
fun `present - updates state in response to changes`() = runTest { fun `present - updates state in response to changes`() = runTest {
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
@ -125,9 +170,7 @@ class EditUserProfilePresenterTest {
deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) } deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) }
), ),
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.displayName).isEqualTo("Name") assertThat(initialState.displayName).isEqualTo("Name")
assertThat(initialState.userAvatarUrl).isEqualTo(AN_AVATAR_URL) assertThat(initialState.userAvatarUrl).isEqualTo(AN_AVATAR_URL)
@ -159,9 +202,7 @@ class EditUserProfilePresenterTest {
deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) } deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) }
), ),
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.userAvatarUrl).isEqualTo(AN_AVATAR_URL) assertThat(initialState.userAvatarUrl).isEqualTo(AN_AVATAR_URL)
initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
@ -184,9 +225,7 @@ class EditUserProfilePresenterTest {
deleteLambda = deleteCallback, deleteLambda = deleteCallback,
), ),
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.userAvatarUrl).isEqualTo(AN_AVATAR_URL) assertThat(initialState.userAvatarUrl).isEqualTo(AN_AVATAR_URL)
assertThat(initialState.cameraPermissionState.permissionGranted).isFalse() assertThat(initialState.cameraPermissionState.permissionGranted).isFalse()
@ -221,9 +260,7 @@ class EditUserProfilePresenterTest {
deleteLambda = deleteCallback deleteLambda = deleteCallback
), ),
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.saveButtonEnabled).isFalse() assertThat(initialState.saveButtonEnabled).isFalse()
// Once a change is made, the save button is enabled // Once a change is made, the save button is enabled
@ -264,9 +301,7 @@ class EditUserProfilePresenterTest {
deleteLambda = deleteCallback deleteLambda = deleteCallback
), ),
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.saveButtonEnabled).isFalse() assertThat(initialState.saveButtonEnabled).isFalse()
// Once a change is made, the save button is enabled // Once a change is made, the save button is enabled
@ -307,9 +342,7 @@ class EditUserProfilePresenterTest {
deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) } deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) }
), ),
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("New name")) initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("New name"))
initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove)) initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove))
@ -330,9 +363,7 @@ class EditUserProfilePresenterTest {
matrixClient = matrixClient, matrixClient = matrixClient,
matrixUser = user matrixUser = user
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(EditUserProfileEvents.UpdateDisplayName(" Name ")) initialState.eventSink(EditUserProfileEvents.UpdateDisplayName(" Name "))
initialState.eventSink(EditUserProfileEvents.Save) initialState.eventSink(EditUserProfileEvents.Save)
@ -351,9 +382,7 @@ class EditUserProfilePresenterTest {
matrixClient = matrixClient, matrixClient = matrixClient,
matrixUser = user matrixUser = user
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("")) initialState.eventSink(EditUserProfileEvents.UpdateDisplayName(""))
initialState.eventSink(EditUserProfileEvents.Save) initialState.eventSink(EditUserProfileEvents.Save)
@ -376,9 +405,7 @@ class EditUserProfilePresenterTest {
deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) } deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) }
), ),
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
initialState.eventSink(EditUserProfileEvents.Save) initialState.eventSink(EditUserProfileEvents.Save)
@ -400,9 +427,7 @@ class EditUserProfilePresenterTest {
) )
fakePickerProvider.givenResult(anotherAvatarUri) fakePickerProvider.givenResult(anotherAvatarUri)
fakeMediaPreProcessor.givenResult(Result.failure(RuntimeException("Oh no"))) fakeMediaPreProcessor.givenResult(Result.failure(RuntimeException("Oh no")))
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
initialState.eventSink(EditUserProfileEvents.Save) initialState.eventSink(EditUserProfileEvents.Save)
@ -441,22 +466,20 @@ class EditUserProfilePresenterTest {
} }
@Test @Test
fun `present - CancelSaveChanges resets save action state`() = runTest { fun `present - CloseDialog resets save action state`() = runTest {
givenPickerReturnsFile() givenPickerReturnsFile()
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
val matrixClient = FakeMatrixClient().apply { val matrixClient = FakeMatrixClient().apply {
givenSetDisplayNameResult(Result.failure(RuntimeException("!"))) givenSetDisplayNameResult(Result.failure(RuntimeException("!")))
} }
val presenter = createEditUserProfilePresenter(matrixUser = user, matrixClient = matrixClient) val presenter = createEditUserProfilePresenter(matrixUser = user, matrixClient = matrixClient)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("foo")) initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("foo"))
initialState.eventSink(EditUserProfileEvents.Save) initialState.eventSink(EditUserProfileEvents.Save)
skipItems(2) skipItems(2)
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java) assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java)
initialState.eventSink(EditUserProfileEvents.CancelSaveChanges) initialState.eventSink(EditUserProfileEvents.CloseDialog)
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Uninitialized::class.java) assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
} }
} }
@ -469,9 +492,7 @@ class EditUserProfilePresenterTest {
deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) } deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) }
), ),
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(event) initialState.eventSink(event)
initialState.eventSink(EditUserProfileEvents.Save) initialState.eventSink(EditUserProfileEvents.Save)

View file

@ -0,0 +1,136 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.preferences.impl.user.editprofile
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.ui.media.AvatarAction
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.ensureCalledOnce
import io.element.android.tests.testutils.pressBack
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class EditUserProfileViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on back emits the expected event`() {
val eventsRecorder = EventsRecorder<EditUserProfileEvents>()
rule.setEditUserProfileView(
aEditUserProfileState(
eventSink = eventsRecorder,
),
)
rule.pressBack()
eventsRecorder.assertSingle(EditUserProfileEvents.Exit)
}
@Test
fun `clicking on cancel exit emits the expected event`() {
val eventsRecorder = EventsRecorder<EditUserProfileEvents>()
rule.setEditUserProfileView(
aEditUserProfileState(
saveAction = AsyncAction.ConfirmingCancellation,
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(EditUserProfileEvents.CloseDialog)
}
@Test
fun `clicking on OK exit emits the expected event`() {
val eventsRecorder = EventsRecorder<EditUserProfileEvents>()
rule.setEditUserProfileView(
aEditUserProfileState(
saveAction = AsyncAction.ConfirmingCancellation,
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_ok)
eventsRecorder.assertSingle(EditUserProfileEvents.Exit)
}
@Test
fun `clicking on save emits the expected event`() {
val eventsRecorder = EventsRecorder<EditUserProfileEvents>()
rule.setEditUserProfileView(
aEditUserProfileState(
saveButtonEnabled = true,
saveAction = AsyncAction.Uninitialized,
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_save)
eventsRecorder.assertSingle(EditUserProfileEvents.Save)
}
@Test
fun `clicking on avatar opens the bottom sheet dialog`() {
val eventsRecorder = EventsRecorder<EditUserProfileEvents>()
val actions = listOf(
AvatarAction.TakePhoto,
AvatarAction.ChoosePhoto,
AvatarAction.Remove,
)
rule.setEditUserProfileView(
aEditUserProfileState(
saveAction = AsyncAction.Uninitialized,
avatarActions = actions,
eventSink = eventsRecorder,
),
)
val contentDescription = rule.activity.getString(CommonStrings.a11y_avatar)
rule.onNodeWithContentDescription(contentDescription).performClick()
// Assert that the actions are displayed
actions.forEach { action ->
val text = rule.activity.getString(action.titleResId)
rule.onNodeWithText(text).assertExists()
}
}
@Test
fun `success invokes the expected callback`() {
val eventsRecorder = EventsRecorder<EditUserProfileEvents>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setEditUserProfileView(
aEditUserProfileState(
saveAction = AsyncAction.Success(Unit),
eventSink = eventsRecorder,
),
onEditProfileSuccess = callback,
)
}
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setEditUserProfileView(
state: EditUserProfileState,
onEditProfileSuccess: () -> Unit = EnsureNeverCalled(),
) {
setContent {
EditUserProfileView(
state = state,
onEditProfileSuccess = onEditProfileSuccess,
)
}
}

View file

@ -0,0 +1,16 @@
/*
* Copyright (c) 2025 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.preferences.impl.user.editprofile
import io.element.android.tests.testutils.lambda.lambdaError
class FakeEditUserProfileNavigator(
val closeLambda: () -> Unit = { lambdaError() }
) : EditUserProfileNavigator {
override fun close() = closeLambda()
}

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:da49d0867fdaa28fbe7db7dc21c5de09268e7bb3afdb0b2289df372d537c2abb
size 33570

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:60fe1926cf8d66a8086268f3e4e1b6b1616f860668e1e5f4dc670d5c57ec4d24
size 31943