change(room details edit): move to his own module
This commit is contained in:
parent
15cb6431c6
commit
2e7355e789
54 changed files with 475 additions and 29 deletions
|
|
@ -35,11 +35,11 @@ import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRoles
|
|||
import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesListType
|
||||
import io.element.android.features.rolesandpermissions.api.RolesAndPermissionsEntryPoint
|
||||
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
|
||||
import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditNode
|
||||
import io.element.android.features.roomdetails.impl.invite.RoomInviteMembersNode
|
||||
import io.element.android.features.roomdetails.impl.members.RoomMemberListNode
|
||||
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsNode
|
||||
import io.element.android.features.roomdetails.impl.notificationsettings.RoomNotificationSettingsNode
|
||||
import io.element.android.features.roomdetailsedit.api.RoomDetailsEditEntryPoint
|
||||
import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyEntryPoint
|
||||
import io.element.android.features.userprofile.shared.UserProfileNodeHelper
|
||||
import io.element.android.features.verifysession.api.OutgoingVerificationEntryPoint
|
||||
|
|
@ -85,6 +85,7 @@ class RoomDetailsFlowNode(
|
|||
private val changeRoomMemberRolesEntryPoint: ChangeRoomMemberRolesEntryPoint,
|
||||
private val rolesAndPermissionsEntryPoint: RolesAndPermissionsEntryPoint,
|
||||
private val securityAndPrivacyEntryPoint: SecurityAndPrivacyEntryPoint,
|
||||
private val roomDetailsEditEntryPoint: RoomDetailsEditEntryPoint,
|
||||
) : BaseFlowNode<RoomDetailsFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = plugins.filterIsInstance<RoomDetailsEntryPoint.Params>().first().initialElement.toNavTarget(),
|
||||
|
|
@ -256,7 +257,7 @@ class RoomDetailsFlowNode(
|
|||
}
|
||||
|
||||
NavTarget.RoomDetailsEdit -> {
|
||||
createNode<RoomDetailsEditNode>(buildContext)
|
||||
roomDetailsEditEntryPoint.createNode(this, buildContext)
|
||||
}
|
||||
|
||||
NavTarget.InviteMembers -> {
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
/*
|
||||
* 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.roomdetails.impl.edit
|
||||
|
||||
import io.element.android.libraries.matrix.ui.media.AvatarAction
|
||||
|
||||
sealed interface RoomDetailsEditEvents {
|
||||
data class HandleAvatarAction(val action: AvatarAction) : RoomDetailsEditEvents
|
||||
data class UpdateRoomName(val name: String) : RoomDetailsEditEvents
|
||||
data class UpdateRoomTopic(val topic: String) : RoomDetailsEditEvents
|
||||
data object OnBackPress : RoomDetailsEditEvents
|
||||
data object Save : RoomDetailsEditEvents
|
||||
data object CloseDialog : RoomDetailsEditEvents
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
/*
|
||||
* 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.roomdetails.impl.edit
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.lifecycle.subscribe
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import im.vector.app.features.analytics.plan.MobileScreen
|
||||
import io.element.android.annotations.ContributesNode
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
|
||||
@ContributesNode(RoomScope::class)
|
||||
@AssistedInject
|
||||
class RoomDetailsEditNode(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val presenter: RoomDetailsEditPresenter,
|
||||
private val analyticsService: AnalyticsService,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
init {
|
||||
lifecycle.subscribe(
|
||||
onResume = {
|
||||
analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.RoomSettings))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
RoomDetailsEditView(
|
||||
state = state,
|
||||
onDone = ::navigateUp,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,242 +0,0 @@
|
|||
/*
|
||||
* 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.roomdetails.impl.edit
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.core.net.toUri
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import io.element.android.libraries.matrix.api.room.StateEventType
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.canSendState
|
||||
import io.element.android.libraries.matrix.ui.media.AvatarAction
|
||||
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider
|
||||
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
||||
import io.element.android.libraries.permissions.api.PermissionsEvents
|
||||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
@Inject
|
||||
class RoomDetailsEditPresenter(
|
||||
private val room: JoinedRoom,
|
||||
private val mediaPickerProvider: PickerProvider,
|
||||
private val mediaPreProcessor: MediaPreProcessor,
|
||||
private val temporaryUriDeleter: TemporaryUriDeleter,
|
||||
permissionsPresenterFactory: PermissionsPresenter.Factory,
|
||||
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
|
||||
) : Presenter<RoomDetailsEditState> {
|
||||
private val cameraPermissionPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
|
||||
private var pendingPermissionRequest = false
|
||||
|
||||
@Composable
|
||||
override fun present(): RoomDetailsEditState {
|
||||
val cameraPermissionState = cameraPermissionPresenter.present()
|
||||
val roomSyncUpdateFlow = room.syncUpdateFlow.collectAsState()
|
||||
val roomInfo by room.roomInfoFlow.collectAsState()
|
||||
val roomAvatarUri = roomInfo.avatarUrl
|
||||
var roomAvatarUriEdited by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
LaunchedEffect(roomAvatarUri) {
|
||||
// Every time the roomAvatar change (from sync), we can set the new avatar.
|
||||
temporaryUriDeleter.delete(roomAvatarUriEdited?.toUri())
|
||||
roomAvatarUriEdited = roomAvatarUri
|
||||
}
|
||||
|
||||
val roomRawNameTrimmed = roomInfo.rawName.orEmpty().trim()
|
||||
var roomRawNameEdited by rememberSaveable { mutableStateOf("") }
|
||||
LaunchedEffect(roomRawNameTrimmed) {
|
||||
// Every time the rawName change (from sync), we can set the new name.
|
||||
roomRawNameEdited = roomRawNameTrimmed
|
||||
}
|
||||
val roomTopicTrimmed = roomInfo.topic.orEmpty().trim()
|
||||
var roomTopicEdited by rememberSaveable { mutableStateOf("") }
|
||||
LaunchedEffect(roomTopicTrimmed) {
|
||||
// Every time the topic change (from sync), we can set the new topic.
|
||||
roomTopicEdited = roomTopicTrimmed
|
||||
}
|
||||
|
||||
val saveButtonEnabled by remember(
|
||||
roomRawNameTrimmed,
|
||||
roomTopicTrimmed,
|
||||
roomAvatarUri,
|
||||
) {
|
||||
derivedStateOf {
|
||||
roomRawNameTrimmed != roomRawNameEdited.trim() ||
|
||||
roomTopicTrimmed != roomTopicEdited.trim() ||
|
||||
roomAvatarUri != roomAvatarUriEdited
|
||||
}
|
||||
}
|
||||
|
||||
var canChangeName by remember { mutableStateOf(false) }
|
||||
var canChangeTopic by remember { mutableStateOf(false) }
|
||||
var canChangeAvatar by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(roomSyncUpdateFlow.value) {
|
||||
canChangeName = room.canSendState(StateEventType.ROOM_NAME).getOrElse { false }
|
||||
canChangeTopic = room.canSendState(StateEventType.ROOM_TOPIC).getOrElse { false }
|
||||
canChangeAvatar = room.canSendState(StateEventType.ROOM_AVATAR).getOrElse { false }
|
||||
}
|
||||
|
||||
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(
|
||||
onResult = { uri ->
|
||||
if (uri != null) {
|
||||
temporaryUriDeleter.delete(roomAvatarUriEdited?.toUri())
|
||||
roomAvatarUriEdited = uri.toString()
|
||||
}
|
||||
}
|
||||
)
|
||||
val galleryImagePicker = mediaPickerProvider.registerGalleryImagePicker(
|
||||
onResult = { uri ->
|
||||
if (uri != null) {
|
||||
temporaryUriDeleter.delete(roomAvatarUriEdited?.toUri())
|
||||
roomAvatarUriEdited = uri.toString()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
LaunchedEffect(cameraPermissionState.permissionGranted) {
|
||||
if (cameraPermissionState.permissionGranted && pendingPermissionRequest) {
|
||||
pendingPermissionRequest = false
|
||||
cameraPhotoPicker.launch()
|
||||
}
|
||||
}
|
||||
|
||||
val avatarActions by remember(roomAvatarUriEdited) {
|
||||
derivedStateOf {
|
||||
listOfNotNull(
|
||||
AvatarAction.TakePhoto,
|
||||
AvatarAction.ChoosePhoto,
|
||||
AvatarAction.Remove.takeIf { roomAvatarUriEdited != null },
|
||||
).toImmutableList()
|
||||
}
|
||||
}
|
||||
|
||||
val saveAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
fun handleEvent(event: RoomDetailsEditEvents) {
|
||||
when (event) {
|
||||
is RoomDetailsEditEvents.Save -> localCoroutineScope.saveChanges(
|
||||
currentNameTrimmed = roomRawNameTrimmed,
|
||||
newNameTrimmed = roomRawNameEdited.trim(),
|
||||
currentTopicTrimmed = roomTopicTrimmed,
|
||||
newTopicTrimmed = roomTopicEdited.trim(),
|
||||
currentAvatar = roomAvatarUri?.toUri(),
|
||||
newAvatarUri = roomAvatarUriEdited?.toUri(),
|
||||
action = saveAction,
|
||||
)
|
||||
is RoomDetailsEditEvents.HandleAvatarAction -> {
|
||||
when (event.action) {
|
||||
AvatarAction.ChoosePhoto -> galleryImagePicker.launch()
|
||||
AvatarAction.TakePhoto -> if (cameraPermissionState.permissionGranted) {
|
||||
cameraPhotoPicker.launch()
|
||||
} else {
|
||||
pendingPermissionRequest = true
|
||||
cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions)
|
||||
}
|
||||
AvatarAction.Remove -> {
|
||||
temporaryUriDeleter.delete(roomAvatarUriEdited?.toUri())
|
||||
roomAvatarUriEdited = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is RoomDetailsEditEvents.UpdateRoomName -> roomRawNameEdited = event.name
|
||||
is RoomDetailsEditEvents.UpdateRoomTopic -> roomTopicEdited = event.topic
|
||||
RoomDetailsEditEvents.CloseDialog -> saveAction.value = AsyncAction.Uninitialized
|
||||
RoomDetailsEditEvents.OnBackPress -> if (saveButtonEnabled.not() || saveAction.value == AsyncAction.ConfirmingCancellation) {
|
||||
// No changes to save or already confirming exit without saving
|
||||
saveAction.value = AsyncAction.Success(Unit)
|
||||
} else {
|
||||
saveAction.value = AsyncAction.ConfirmingCancellation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return RoomDetailsEditState(
|
||||
roomId = room.roomId,
|
||||
roomRawName = roomRawNameEdited,
|
||||
canChangeName = canChangeName,
|
||||
roomTopic = roomTopicEdited,
|
||||
canChangeTopic = canChangeTopic,
|
||||
roomAvatarUrl = roomAvatarUriEdited,
|
||||
canChangeAvatar = canChangeAvatar,
|
||||
avatarActions = avatarActions,
|
||||
saveButtonEnabled = saveButtonEnabled,
|
||||
saveAction = saveAction.value,
|
||||
cameraPermissionState = cameraPermissionState,
|
||||
isSpace = roomInfo.isSpace,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.saveChanges(
|
||||
currentNameTrimmed: String,
|
||||
newNameTrimmed: String,
|
||||
currentTopicTrimmed: String,
|
||||
newTopicTrimmed: String,
|
||||
currentAvatar: Uri?,
|
||||
newAvatarUri: Uri?,
|
||||
action: MutableState<AsyncAction<Unit>>,
|
||||
) = launch {
|
||||
val results = mutableListOf<Result<Unit>>()
|
||||
suspend {
|
||||
if (newTopicTrimmed != currentTopicTrimmed) {
|
||||
results.add(room.setTopic(newTopicTrimmed).onFailure {
|
||||
Timber.e(it, "Failed to set room topic")
|
||||
})
|
||||
}
|
||||
if (newNameTrimmed.isNotEmpty() && newNameTrimmed != currentNameTrimmed) {
|
||||
results.add(room.setName(newNameTrimmed).onFailure {
|
||||
Timber.e(it, "Failed to set room name")
|
||||
})
|
||||
}
|
||||
if (newAvatarUri != currentAvatar) {
|
||||
results.add(updateAvatar(newAvatarUri).onFailure {
|
||||
Timber.e(it, "Failed to update avatar")
|
||||
})
|
||||
}
|
||||
if (results.all { it.isSuccess }) Unit else results.first { it.isFailure }.getOrThrow()
|
||||
}.runCatchingUpdatingState(action)
|
||||
}
|
||||
|
||||
private suspend fun updateAvatar(avatarUri: Uri?): Result<Unit> {
|
||||
return runCatchingExceptions {
|
||||
if (avatarUri != null) {
|
||||
val preprocessed = mediaPreProcessor.process(
|
||||
uri = avatarUri,
|
||||
mimeType = MimeTypes.Jpeg,
|
||||
deleteOriginal = false,
|
||||
mediaOptimizationConfig = mediaOptimizationConfigProvider.get(),
|
||||
).getOrThrow()
|
||||
room.updateAvatar(MimeTypes.Jpeg, preprocessed.file.readBytes()).getOrThrow()
|
||||
} else {
|
||||
room.removeAvatar().getOrThrow()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
/*
|
||||
* 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.roomdetails.impl.edit
|
||||
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.ui.media.AvatarAction
|
||||
import io.element.android.libraries.permissions.api.PermissionsState
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
data class RoomDetailsEditState(
|
||||
val roomId: RoomId,
|
||||
/** The raw room name (i.e. the room name from the state event `m.room.name`), not the display name. */
|
||||
val roomRawName: String,
|
||||
val canChangeName: Boolean,
|
||||
val roomTopic: String,
|
||||
val canChangeTopic: Boolean,
|
||||
val roomAvatarUrl: String?,
|
||||
val canChangeAvatar: Boolean,
|
||||
val avatarActions: ImmutableList<AvatarAction>,
|
||||
val saveButtonEnabled: Boolean,
|
||||
val saveAction: AsyncAction<Unit>,
|
||||
val cameraPermissionState: PermissionsState,
|
||||
val isSpace: Boolean,
|
||||
val eventSink: (RoomDetailsEditEvents) -> Unit
|
||||
)
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
/*
|
||||
* 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.roomdetails.impl.edit
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
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 kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
open class RoomDetailsEditStateProvider : PreviewParameterProvider<RoomDetailsEditState> {
|
||||
override val values: Sequence<RoomDetailsEditState>
|
||||
get() = sequenceOf(
|
||||
aRoomDetailsEditState(),
|
||||
aRoomDetailsEditState(roomTopic = ""),
|
||||
aRoomDetailsEditState(roomRawName = ""),
|
||||
aRoomDetailsEditState(roomAvatarUrl = "example://uri"),
|
||||
aRoomDetailsEditState(roomAvatarUrl = "example://uri", isSpace = true),
|
||||
aRoomDetailsEditState(canChangeName = true, canChangeTopic = false, canChangeAvatar = true, saveButtonEnabled = false),
|
||||
aRoomDetailsEditState(canChangeName = false, canChangeTopic = true, canChangeAvatar = false, saveButtonEnabled = false),
|
||||
aRoomDetailsEditState(saveAction = AsyncAction.Loading),
|
||||
aRoomDetailsEditState(saveAction = AsyncAction.Failure(RuntimeException("Whelp"))),
|
||||
aRoomDetailsEditState(saveAction = AsyncAction.ConfirmingCancellation),
|
||||
)
|
||||
}
|
||||
|
||||
fun aRoomDetailsEditState(
|
||||
roomId: RoomId = RoomId("!aRoomId:aDomain"),
|
||||
roomRawName: String = "Marketing",
|
||||
canChangeName: Boolean = true,
|
||||
roomTopic: String = "a room topic that is quite long so should wrap onto multiple lines",
|
||||
canChangeTopic: Boolean = true,
|
||||
roomAvatarUrl: String? = null,
|
||||
canChangeAvatar: Boolean = true,
|
||||
avatarActions: List<AvatarAction> = emptyList(),
|
||||
saveButtonEnabled: Boolean = true,
|
||||
saveAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
cameraPermissionState: PermissionsState = aPermissionsState(showDialog = false),
|
||||
isSpace: Boolean = false,
|
||||
eventSink: (RoomDetailsEditEvents) -> Unit = {},
|
||||
) = RoomDetailsEditState(
|
||||
roomId = roomId,
|
||||
roomRawName = roomRawName,
|
||||
canChangeName = canChangeName,
|
||||
roomTopic = roomTopic,
|
||||
canChangeTopic = canChangeTopic,
|
||||
roomAvatarUrl = roomAvatarUrl,
|
||||
canChangeAvatar = canChangeAvatar,
|
||||
avatarActions = avatarActions.toImmutableList(),
|
||||
saveButtonEnabled = saveButtonEnabled,
|
||||
saveAction = saveAction,
|
||||
cameraPermissionState = cameraPermissionState,
|
||||
isSpace = isSpace,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
|
@ -1,183 +0,0 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
package io.element.android.features.roomdetails.impl.edit
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.roomdetails.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.AsyncActionViewDefaults
|
||||
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.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.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
import io.element.android.libraries.designsystem.theme.components.TextField
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet
|
||||
import io.element.android.libraries.matrix.ui.components.EditableAvatarView
|
||||
import io.element.android.libraries.permissions.api.PermissionsView
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun RoomDetailsEditView(
|
||||
state: RoomDetailsEditState,
|
||||
onDone: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
val isAvatarActionsSheetVisible = remember { mutableStateOf(false) }
|
||||
|
||||
fun onAvatarClick() {
|
||||
focusManager.clearFocus()
|
||||
isAvatarActionsSheetVisible.value = true
|
||||
}
|
||||
|
||||
BackHandler {
|
||||
state.eventSink(RoomDetailsEditEvents.OnBackPress)
|
||||
}
|
||||
Scaffold(
|
||||
modifier = modifier.clearFocusOnTap(focusManager),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
titleStr = stringResource(id = R.string.screen_room_details_edit_room_title),
|
||||
navigationIcon = {
|
||||
BackButton(
|
||||
onClick = {
|
||||
state.eventSink(RoomDetailsEditEvents.OnBackPress)
|
||||
}
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
TextButton(
|
||||
text = stringResource(CommonStrings.action_save),
|
||||
enabled = state.saveButtonEnabled,
|
||||
onClick = {
|
||||
focusManager.clearFocus()
|
||||
state.eventSink(RoomDetailsEditEvents.Save)
|
||||
},
|
||||
)
|
||||
}
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(padding)
|
||||
.padding(horizontal = 16.dp)
|
||||
.navigationBarsPadding()
|
||||
.imePadding()
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
EditableAvatarView(
|
||||
matrixId = state.roomId.value,
|
||||
// As per Element Web, we use the raw name for the avatar as well
|
||||
displayName = state.roomRawName,
|
||||
avatarUrl = state.roomAvatarUrl,
|
||||
avatarSize = AvatarSize.EditRoomDetails,
|
||||
avatarType = if(state.isSpace){
|
||||
AvatarType.Space()
|
||||
}else {
|
||||
AvatarType.Room()
|
||||
},
|
||||
onAvatarClick = ::onAvatarClick,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(60.dp))
|
||||
|
||||
TextField(
|
||||
label = stringResource(id = R.string.screen_room_details_room_name_label),
|
||||
value = state.roomRawName,
|
||||
placeholder = stringResource(CommonStrings.common_room_name_placeholder),
|
||||
singleLine = true,
|
||||
readOnly = !state.canChangeName,
|
||||
onValueChange = { state.eventSink(RoomDetailsEditEvents.UpdateRoomName(it)) },
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(28.dp))
|
||||
|
||||
TextField(
|
||||
label = stringResource(CommonStrings.common_topic),
|
||||
value = state.roomTopic,
|
||||
placeholder = stringResource(CommonStrings.common_topic_placeholder),
|
||||
maxLines = 10,
|
||||
readOnly = !state.canChangeTopic,
|
||||
onValueChange = { state.eventSink(RoomDetailsEditEvents.UpdateRoomTopic(it)) },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.Sentences,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
AvatarActionBottomSheet(
|
||||
actions = state.avatarActions,
|
||||
isVisible = isAvatarActionsSheetVisible.value,
|
||||
onDismiss = { isAvatarActionsSheetVisible.value = false },
|
||||
onSelectAction = { state.eventSink(RoomDetailsEditEvents.HandleAvatarAction(it)) }
|
||||
)
|
||||
AsyncActionView(
|
||||
async = state.saveAction,
|
||||
progressDialog = {
|
||||
AsyncActionViewDefaults.ProgressDialog(
|
||||
progressText = stringResource(R.string.screen_room_details_updating_room),
|
||||
)
|
||||
},
|
||||
confirmationDialog = {
|
||||
if (state.saveAction == AsyncAction.ConfirmingCancellation) {
|
||||
SaveChangesDialog(
|
||||
onSubmitClick = { state.eventSink(RoomDetailsEditEvents.OnBackPress) },
|
||||
onDismiss = { state.eventSink(RoomDetailsEditEvents.CloseDialog) }
|
||||
)
|
||||
}
|
||||
},
|
||||
onSuccess = { onDone() },
|
||||
errorMessage = { stringResource(R.string.screen_room_details_edition_error) },
|
||||
onErrorDismiss = { state.eventSink(RoomDetailsEditEvents.CloseDialog) }
|
||||
)
|
||||
|
||||
PermissionsView(
|
||||
state = state.cameraPermissionState,
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun RoomDetailsEditViewPreview(@PreviewParameter(RoomDetailsEditStateProvider::class) state: RoomDetailsEditState) = ElementPreview {
|
||||
RoomDetailsEditView(
|
||||
state = state,
|
||||
onDone = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ import io.element.android.features.messages.test.FakeMessagesEntryPoint
|
|||
import io.element.android.features.poll.test.history.FakePollHistoryEntryPoint
|
||||
import io.element.android.features.reportroom.test.FakeReportRoomEntryPoint
|
||||
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
|
||||
import io.element.android.features.roomdetailsedit.test.FakeRoomDetailsEditEntryPoint
|
||||
import io.element.android.features.securityandprivacy.test.FakeSecurityAndPrivacyEntryPoint
|
||||
import io.element.android.features.verifysession.test.FakeOutgoingVerificationEntryPoint
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
|
|
@ -63,6 +64,7 @@ class DefaultRoomDetailsEntryPointTest {
|
|||
changeRoomMemberRolesEntryPoint = FakeChangeRoomMemberRolesEntryPoint(),
|
||||
rolesAndPermissionsEntryPoint = FakeRolesAndPermissionsEntryPoint(),
|
||||
securityAndPrivacyEntryPoint = FakeSecurityAndPrivacyEntryPoint(),
|
||||
roomDetailsEditEntryPoint = FakeRoomDetailsEditEntryPoint(),
|
||||
)
|
||||
}
|
||||
val callback = object : RoomDetailsEntryPoint.Callback {
|
||||
|
|
|
|||
|
|
@ -1,780 +0,0 @@
|
|||
/*
|
||||
* 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.roomdetails.impl.edit
|
||||
|
||||
import android.net.Uri
|
||||
import app.cash.turbine.ReceiveTurbine
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.roomdetails.impl.aJoinedRoom
|
||||
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import io.element.android.libraries.matrix.api.room.StateEventType
|
||||
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_NAME
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_RAW_NAME
|
||||
import io.element.android.libraries.matrix.ui.media.AvatarAction
|
||||
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
||||
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
||||
import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider
|
||||
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
||||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.fake.FakeTemporaryUriDeleter
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
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.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkAll
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
@Suppress("LargeClass")
|
||||
@ExperimentalCoroutinesApi
|
||||
class RoomDetailsEditPresenterTest {
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
private lateinit var fakePickerProvider: FakePickerProvider
|
||||
private lateinit var fakeMediaPreProcessor: FakeMediaPreProcessor
|
||||
|
||||
private val roomAvatarUri: Uri = mockk()
|
||||
private val anotherAvatarUri: Uri = mockk()
|
||||
|
||||
private val fakeFileContents = ByteArray(2)
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
fakePickerProvider = FakePickerProvider()
|
||||
fakeMediaPreProcessor = FakeMediaPreProcessor()
|
||||
mockkStatic(Uri::class)
|
||||
|
||||
every { Uri.parse(AN_AVATAR_URL) } returns roomAvatarUri
|
||||
every { roomAvatarUri.toString() } returns AN_AVATAR_URL
|
||||
every { Uri.parse(ANOTHER_AVATAR_URL) } returns anotherAvatarUri
|
||||
every { anotherAvatarUri.toString() } returns ANOTHER_AVATAR_URL
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
private fun createRoomDetailsEditPresenter(
|
||||
room: JoinedRoom,
|
||||
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
|
||||
temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(),
|
||||
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
|
||||
): RoomDetailsEditPresenter {
|
||||
return RoomDetailsEditPresenter(
|
||||
room = room,
|
||||
mediaPickerProvider = fakePickerProvider,
|
||||
mediaPreProcessor = fakeMediaPreProcessor,
|
||||
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),
|
||||
temporaryUriDeleter = temporaryUriDeleter,
|
||||
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - initial state is created from room info`() = runTest {
|
||||
val room = aJoinedRoom(
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
displayName = A_ROOM_NAME,
|
||||
rawName = A_ROOM_RAW_NAME,
|
||||
canSendStateResult = { _, _ -> Result.success(true) }
|
||||
)
|
||||
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||
val presenter = createRoomDetailsEditPresenter(
|
||||
room = room,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.roomId).isEqualTo(room.roomId)
|
||||
assertThat(initialState.roomRawName).isEqualTo(A_ROOM_RAW_NAME)
|
||||
assertThat(initialState.roomAvatarUrl).isEqualTo(AN_AVATAR_URL)
|
||||
assertThat(initialState.roomTopic).isEqualTo(room.info().topic.orEmpty())
|
||||
assertThat(initialState.avatarActions).containsExactly(
|
||||
AvatarAction.ChoosePhoto,
|
||||
AvatarAction.TakePhoto,
|
||||
AvatarAction.Remove
|
||||
)
|
||||
assertThat(initialState.saveButtonEnabled).isFalse()
|
||||
assertThat(initialState.saveAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - sets canChangeName if user has permission`() = runTest {
|
||||
val room = aJoinedRoom(
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
canSendStateResult = { _, stateEventType ->
|
||||
when (stateEventType) {
|
||||
StateEventType.ROOM_NAME -> Result.success(true)
|
||||
StateEventType.ROOM_AVATAR -> Result.success(false)
|
||||
StateEventType.ROOM_TOPIC -> Result.failure(RuntimeException("Oops"))
|
||||
else -> lambdaError()
|
||||
}
|
||||
},
|
||||
)
|
||||
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||
val presenter = createRoomDetailsEditPresenter(
|
||||
room = room,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||
)
|
||||
presenter.test {
|
||||
// Initially false
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.canChangeName).isFalse()
|
||||
assertThat(initialState.canChangeAvatar).isFalse()
|
||||
assertThat(initialState.canChangeTopic).isFalse()
|
||||
// When the asynchronous check completes, the single field we can edit is true
|
||||
val settledState = awaitItem()
|
||||
assertThat(settledState.canChangeName).isTrue()
|
||||
assertThat(settledState.canChangeAvatar).isFalse()
|
||||
assertThat(settledState.canChangeTopic).isFalse()
|
||||
deleteCallback.assertions().isCalledOnce().with(value(null))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - sets canChangeAvatar if user has permission`() = runTest {
|
||||
val room = aJoinedRoom(
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
canSendStateResult = { _, stateEventType ->
|
||||
when (stateEventType) {
|
||||
StateEventType.ROOM_NAME -> Result.success(false)
|
||||
StateEventType.ROOM_AVATAR -> Result.success(true)
|
||||
StateEventType.ROOM_TOPIC -> Result.failure(RuntimeException("Oops"))
|
||||
else -> lambdaError()
|
||||
}
|
||||
}
|
||||
)
|
||||
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||
val presenter = createRoomDetailsEditPresenter(
|
||||
room = room,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||
)
|
||||
presenter.test {
|
||||
// Initially false
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.canChangeName).isFalse()
|
||||
assertThat(initialState.canChangeAvatar).isFalse()
|
||||
assertThat(initialState.canChangeTopic).isFalse()
|
||||
// When the asynchronous check completes, the single field we can edit is true
|
||||
val settledState = awaitItem()
|
||||
assertThat(settledState.canChangeName).isFalse()
|
||||
assertThat(settledState.canChangeAvatar).isTrue()
|
||||
assertThat(settledState.canChangeTopic).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - sets canChangeTopic if user has permission`() = runTest {
|
||||
val room = aJoinedRoom(
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
canSendStateResult = { _, stateEventType ->
|
||||
when (stateEventType) {
|
||||
StateEventType.ROOM_NAME -> Result.success(false)
|
||||
StateEventType.ROOM_AVATAR -> Result.failure(RuntimeException("Oops"))
|
||||
StateEventType.ROOM_TOPIC -> Result.success(true)
|
||||
else -> lambdaError()
|
||||
}
|
||||
}
|
||||
)
|
||||
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||
val presenter = createRoomDetailsEditPresenter(
|
||||
room = room,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||
)
|
||||
presenter.test {
|
||||
// Initially false
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.canChangeName).isFalse()
|
||||
assertThat(initialState.canChangeAvatar).isFalse()
|
||||
assertThat(initialState.canChangeTopic).isFalse()
|
||||
// When the asynchronous check completes, the single field we can edit is true
|
||||
val settledState = awaitItem()
|
||||
assertThat(settledState.canChangeName).isFalse()
|
||||
assertThat(settledState.canChangeAvatar).isFalse()
|
||||
assertThat(settledState.canChangeTopic).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - updates state in response to changes`() = runTest {
|
||||
val room = aJoinedRoom(
|
||||
topic = "My topic",
|
||||
displayName = "Name",
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
canSendStateResult = { _, _ -> Result.success(true) }
|
||||
)
|
||||
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||
val presenter = createRoomDetailsEditPresenter(
|
||||
room = room,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.roomTopic).isEqualTo("My topic")
|
||||
assertThat(initialState.roomRawName).isEqualTo("Name")
|
||||
assertThat(initialState.roomAvatarUrl).isEqualTo(AN_AVATAR_URL)
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name II"))
|
||||
awaitItem().apply {
|
||||
assertThat(roomTopic).isEqualTo("My topic")
|
||||
assertThat(roomRawName).isEqualTo("Name II")
|
||||
assertThat(roomAvatarUrl).isEqualTo(AN_AVATAR_URL)
|
||||
}
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name III"))
|
||||
awaitItem().apply {
|
||||
assertThat(roomTopic).isEqualTo("My topic")
|
||||
assertThat(roomRawName).isEqualTo("Name III")
|
||||
assertThat(roomAvatarUrl).isEqualTo(AN_AVATAR_URL)
|
||||
}
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("Another topic"))
|
||||
awaitItem().apply {
|
||||
assertThat(roomTopic).isEqualTo("Another topic")
|
||||
assertThat(roomRawName).isEqualTo("Name III")
|
||||
assertThat(roomAvatarUrl).isEqualTo(AN_AVATAR_URL)
|
||||
}
|
||||
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove))
|
||||
awaitItem().apply {
|
||||
assertThat(roomTopic).isEqualTo("Another topic")
|
||||
assertThat(roomRawName).isEqualTo("Name III")
|
||||
assertThat(roomAvatarUrl).isNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - obtains avatar uris from gallery`() = runTest {
|
||||
val room = aJoinedRoom(
|
||||
topic = "My topic",
|
||||
displayName = "Name",
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
canSendStateResult = { _, _ -> Result.success(true) }
|
||||
)
|
||||
fakePickerProvider.givenResult(anotherAvatarUri)
|
||||
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||
val presenter = createRoomDetailsEditPresenter(
|
||||
room = room,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.roomAvatarUrl).isEqualTo(AN_AVATAR_URL)
|
||||
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
|
||||
awaitItem().apply {
|
||||
assertThat(roomAvatarUrl).isEqualTo(anotherAvatarUri.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - obtains avatar uris from camera`() = runTest {
|
||||
val room = aJoinedRoom(
|
||||
topic = "My topic",
|
||||
displayName = "Name",
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
canSendStateResult = { _, _ -> Result.success(true) }
|
||||
)
|
||||
fakePickerProvider.givenResult(anotherAvatarUri)
|
||||
val fakePermissionsPresenter = FakePermissionsPresenter()
|
||||
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||
val presenter = createRoomDetailsEditPresenter(
|
||||
room = room,
|
||||
permissionsPresenter = fakePermissionsPresenter,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.roomAvatarUrl).isEqualTo(AN_AVATAR_URL)
|
||||
assertThat(initialState.cameraPermissionState.permissionGranted).isFalse()
|
||||
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.TakePhoto))
|
||||
val stateWithAskingPermission = awaitItem()
|
||||
assertThat(stateWithAskingPermission.cameraPermissionState.showDialog).isTrue()
|
||||
fakePermissionsPresenter.setPermissionGranted()
|
||||
val stateWithPermission = awaitItem()
|
||||
assertThat(stateWithPermission.cameraPermissionState.permissionGranted).isTrue()
|
||||
val stateWithNewAvatar = awaitItem()
|
||||
assertThat(stateWithNewAvatar.roomAvatarUrl).isEqualTo(anotherAvatarUri.toString())
|
||||
// Do it again, no permission is requested
|
||||
fakePickerProvider.givenResult(roomAvatarUri)
|
||||
stateWithNewAvatar.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.TakePhoto))
|
||||
val stateWithNewAvatar2 = awaitItem()
|
||||
assertThat(stateWithNewAvatar2.roomAvatarUrl).isEqualTo(AN_AVATAR_URL)
|
||||
deleteCallback.assertions().isCalledExactly(3).withSequence(
|
||||
listOf(value(null)),
|
||||
listOf(value(roomAvatarUri)),
|
||||
listOf(value(anotherAvatarUri)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - updates save button state`() = runTest {
|
||||
val room = aJoinedRoom(
|
||||
topic = "My topic",
|
||||
displayName = "Name",
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
canSendStateResult = { _, _ -> Result.success(true) }
|
||||
)
|
||||
fakePickerProvider.givenResult(roomAvatarUri)
|
||||
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||
val presenter = createRoomDetailsEditPresenter(
|
||||
room = room,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.saveButtonEnabled).isFalse()
|
||||
// Once a change is made, the save button is enabled
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name II"))
|
||||
awaitItem().apply {
|
||||
assertThat(saveButtonEnabled).isTrue()
|
||||
}
|
||||
// If it's reverted then the save disables again
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name"))
|
||||
awaitItem().apply {
|
||||
assertThat(saveButtonEnabled).isFalse()
|
||||
}
|
||||
// Make a change...
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("Another topic"))
|
||||
awaitItem().apply {
|
||||
assertThat(saveButtonEnabled).isTrue()
|
||||
}
|
||||
// Revert it...
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("My topic"))
|
||||
awaitItem().apply {
|
||||
assertThat(saveButtonEnabled).isFalse()
|
||||
}
|
||||
// Make a change...
|
||||
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove))
|
||||
awaitItem().apply {
|
||||
assertThat(saveButtonEnabled).isTrue()
|
||||
}
|
||||
// Revert it...
|
||||
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
|
||||
awaitItem().apply {
|
||||
assertThat(saveButtonEnabled).isFalse()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - updates save button state when initial values are null`() = runTest {
|
||||
val room = aJoinedRoom(
|
||||
topic = null,
|
||||
displayName = "fallback",
|
||||
avatarUrl = null,
|
||||
canSendStateResult = { _, _ -> Result.success(true) }
|
||||
)
|
||||
fakePickerProvider.givenResult(roomAvatarUri)
|
||||
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||
val presenter = createRoomDetailsEditPresenter(
|
||||
room = room,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.saveButtonEnabled).isFalse()
|
||||
// Once a change is made, the save button is enabled
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name II"))
|
||||
awaitItem().apply {
|
||||
assertThat(saveButtonEnabled).isTrue()
|
||||
}
|
||||
// If it's reverted then the save disables again
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("fallback"))
|
||||
awaitItem().apply {
|
||||
assertThat(saveButtonEnabled).isFalse()
|
||||
}
|
||||
// Make a change...
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("Another topic"))
|
||||
awaitItem().apply {
|
||||
assertThat(saveButtonEnabled).isTrue()
|
||||
}
|
||||
// Revert it...
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic(""))
|
||||
awaitItem().apply {
|
||||
assertThat(saveButtonEnabled).isFalse()
|
||||
}
|
||||
// Make a change...
|
||||
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
|
||||
awaitItem().apply {
|
||||
assertThat(saveButtonEnabled).isTrue()
|
||||
}
|
||||
// Revert it...
|
||||
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove))
|
||||
awaitItem().apply {
|
||||
assertThat(saveButtonEnabled).isFalse()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - save changes room details if different`() = runTest {
|
||||
val setNameResult = lambdaRecorder { _: String -> Result.success(Unit) }
|
||||
val setTopicResult = lambdaRecorder { _: String -> Result.success(Unit) }
|
||||
val removeAvatarResult = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||
val room = aJoinedRoom(
|
||||
topic = "My topic",
|
||||
displayName = "Name",
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
setNameResult = setNameResult,
|
||||
setTopicResult = setTopicResult,
|
||||
removeAvatarResult = removeAvatarResult,
|
||||
canSendStateResult = { _, _ -> Result.success(true) }
|
||||
)
|
||||
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||
val presenter = createRoomDetailsEditPresenter(
|
||||
room = room,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("New name"))
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("New topic"))
|
||||
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove))
|
||||
initialState.eventSink(RoomDetailsEditEvents.Save)
|
||||
skipItems(5)
|
||||
setNameResult.assertions().isCalledOnce().with(value("New name"))
|
||||
setTopicResult.assertions().isCalledOnce().with(value("New topic"))
|
||||
removeAvatarResult.assertions().isCalledOnce()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - save doesn't change room details if they're the same trimmed`() = runTest {
|
||||
val room = aJoinedRoom(
|
||||
topic = "My topic",
|
||||
displayName = "Name",
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
canSendStateResult = { _, _ -> Result.success(true) }
|
||||
)
|
||||
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||
val presenter = createRoomDetailsEditPresenter(
|
||||
room = room,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName(" Name "))
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic(" My topic "))
|
||||
initialState.eventSink(RoomDetailsEditEvents.Save)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - save doesn't change topic if it was unset and is now blank`() = runTest {
|
||||
val room = aJoinedRoom(
|
||||
topic = null,
|
||||
displayName = "Name",
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
canSendStateResult = { _, _ -> Result.success(true) }
|
||||
)
|
||||
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||
val presenter = createRoomDetailsEditPresenter(
|
||||
room = room,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic(""))
|
||||
initialState.eventSink(RoomDetailsEditEvents.Save)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
deleteCallback.assertions().isCalledOnce().with(value(null))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - save doesn't change name if it's now empty`() = runTest {
|
||||
val room = aJoinedRoom(
|
||||
topic = "My topic",
|
||||
displayName = "Name",
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
canSendStateResult = { _, _ -> Result.success(true) }
|
||||
)
|
||||
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||
val presenter = createRoomDetailsEditPresenter(
|
||||
room = room,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName(""))
|
||||
initialState.eventSink(RoomDetailsEditEvents.Save)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
deleteCallback.assertions().isCalledOnce().with(value(null))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - save processes and sets avatar when processor returns successfully`() = runTest {
|
||||
val updateAvatarResult = lambdaRecorder { _: String, _: ByteArray -> Result.success(Unit) }
|
||||
val room = aJoinedRoom(
|
||||
topic = "My topic",
|
||||
displayName = "Name",
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
updateAvatarResult = updateAvatarResult,
|
||||
canSendStateResult = { _, _ -> Result.success(true) }
|
||||
)
|
||||
givenPickerReturnsFile()
|
||||
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||
val presenter = createRoomDetailsEditPresenter(
|
||||
room = room,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
|
||||
initialState.eventSink(RoomDetailsEditEvents.Save)
|
||||
skipItems(4)
|
||||
updateAvatarResult.assertions().isCalledOnce().with(value(MimeTypes.Jpeg), value(fakeFileContents))
|
||||
deleteCallback.assertions().isCalledExactly(2).withSequence(
|
||||
listOf(value(null)),
|
||||
listOf(value(roomAvatarUri)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - save does not set avatar data if processor fails`() = runTest {
|
||||
val room = aJoinedRoom(
|
||||
topic = "My topic",
|
||||
displayName = "Name",
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
canSendStateResult = { _, _ -> Result.success(true) }
|
||||
)
|
||||
fakePickerProvider.givenResult(anotherAvatarUri)
|
||||
fakeMediaPreProcessor.givenResult(Result.failure(RuntimeException("Oh no")))
|
||||
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||
val presenter = createRoomDetailsEditPresenter(
|
||||
room = room,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
|
||||
initialState.eventSink(RoomDetailsEditEvents.Save)
|
||||
skipItems(3)
|
||||
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - sets save action to failure if name update fails`() = runTest {
|
||||
val room = aJoinedRoom(
|
||||
topic = "My topic",
|
||||
displayName = "Name",
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
setNameResult = { Result.failure(RuntimeException("!")) },
|
||||
canSendStateResult = { _, _ -> Result.success(true) }
|
||||
)
|
||||
saveAndAssertFailure(room, RoomDetailsEditEvents.UpdateRoomName("New name"), deleteCallbackNumberOfInvocation = 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - sets save action to failure if topic update fails`() = runTest {
|
||||
val room = aJoinedRoom(
|
||||
topic = "My topic",
|
||||
displayName = "Name",
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
setTopicResult = { Result.failure(RuntimeException("!")) },
|
||||
canSendStateResult = { _, _ -> Result.success(true) }
|
||||
)
|
||||
saveAndAssertFailure(room, RoomDetailsEditEvents.UpdateRoomTopic("New topic"), deleteCallbackNumberOfInvocation = 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - sets save action to failure if removing avatar fails`() = runTest {
|
||||
val room = aJoinedRoom(
|
||||
topic = "My topic",
|
||||
displayName = "Name",
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
removeAvatarResult = { Result.failure(RuntimeException("!")) },
|
||||
canSendStateResult = { _, _ -> Result.success(true) }
|
||||
)
|
||||
saveAndAssertFailure(room, RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove), deleteCallbackNumberOfInvocation = 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - sets save action to failure if setting avatar fails`() = runTest {
|
||||
givenPickerReturnsFile()
|
||||
val room = aJoinedRoom(
|
||||
topic = "My topic",
|
||||
displayName = "Name",
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
updateAvatarResult = { _, _ -> Result.failure(RuntimeException("!")) },
|
||||
canSendStateResult = { _, _ -> Result.success(true) }
|
||||
)
|
||||
saveAndAssertFailure(room, RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto), deleteCallbackNumberOfInvocation = 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - CancelSaveChanges resets save action state`() = runTest {
|
||||
givenPickerReturnsFile()
|
||||
val room = aJoinedRoom(
|
||||
topic = "My topic",
|
||||
displayName = "Name",
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
setTopicResult = { Result.failure(RuntimeException("!")) },
|
||||
canSendStateResult = { _, _ -> Result.success(true) }
|
||||
)
|
||||
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||
val presenter = createRoomDetailsEditPresenter(
|
||||
room = room,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("foo"))
|
||||
initialState.eventSink(RoomDetailsEditEvents.Save)
|
||||
skipItems(3)
|
||||
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java)
|
||||
initialState.eventSink(RoomDetailsEditEvents.CloseDialog)
|
||||
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - leave without saving - cancel`() = runTest {
|
||||
val room = aJoinedRoom(
|
||||
displayName = "Name",
|
||||
canSendStateResult = { _, _ -> Result.success(true) }
|
||||
)
|
||||
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||
val presenter = createRoomDetailsEditPresenter(
|
||||
room = room,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.saveButtonEnabled).isFalse()
|
||||
// Once a change is made, the save button is enabled
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name edited"))
|
||||
awaitItem().apply {
|
||||
assertThat(saveButtonEnabled).isTrue()
|
||||
eventSink(RoomDetailsEditEvents.OnBackPress)
|
||||
}
|
||||
awaitItem().apply {
|
||||
assertThat(saveAction).isEqualTo(AsyncAction.ConfirmingCancellation)
|
||||
eventSink(RoomDetailsEditEvents.CloseDialog)
|
||||
}
|
||||
awaitItem().apply {
|
||||
assertThat(saveAction).isEqualTo(AsyncAction.Uninitialized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - leave no changes, no confirmation`() = runTest {
|
||||
val room = aJoinedRoom(
|
||||
displayName = "Name",
|
||||
canSendStateResult = { _, _ -> Result.success(true) }
|
||||
)
|
||||
val presenter = createRoomDetailsEditPresenter(
|
||||
room = room,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter {},
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.saveButtonEnabled).isFalse()
|
||||
initialState.eventSink(RoomDetailsEditEvents.OnBackPress)
|
||||
assertThat(awaitItem().saveAction).isEqualTo(AsyncAction.Success(Unit))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - leave without saving - confirm`() = runTest {
|
||||
val room = aJoinedRoom(
|
||||
displayName = "Name",
|
||||
canSendStateResult = { _, _ -> Result.success(true) }
|
||||
)
|
||||
val presenter = createRoomDetailsEditPresenter(
|
||||
room = room,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter({}),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
assertThat(initialState.saveButtonEnabled).isFalse()
|
||||
// Once a change is made, the save button is enabled
|
||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name edited"))
|
||||
awaitItem().apply {
|
||||
assertThat(saveButtonEnabled).isTrue()
|
||||
eventSink(RoomDetailsEditEvents.OnBackPress)
|
||||
}
|
||||
awaitItem().apply {
|
||||
assertThat(saveAction).isEqualTo(AsyncAction.ConfirmingCancellation)
|
||||
eventSink(RoomDetailsEditEvents.OnBackPress)
|
||||
}
|
||||
awaitItem().apply {
|
||||
assertThat(saveAction).isEqualTo(AsyncAction.Success(Unit))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun saveAndAssertFailure(
|
||||
room: JoinedRoom,
|
||||
event: RoomDetailsEditEvents,
|
||||
deleteCallbackNumberOfInvocation: Int = 2,
|
||||
) {
|
||||
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||
val presenter = createRoomDetailsEditPresenter(
|
||||
room = room,
|
||||
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||
)
|
||||
presenter.test {
|
||||
val initialState = awaitFirstItem()
|
||||
initialState.eventSink(event)
|
||||
initialState.eventSink(RoomDetailsEditEvents.Save)
|
||||
skipItems(1)
|
||||
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Loading::class.java)
|
||||
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java)
|
||||
deleteCallback.assertions().isCalledExactly(deleteCallbackNumberOfInvocation)
|
||||
}
|
||||
}
|
||||
|
||||
private fun givenPickerReturnsFile() {
|
||||
mockkStatic(File::readBytes)
|
||||
val processedFile: File = mockk {
|
||||
every { readBytes() } returns fakeFileContents
|
||||
}
|
||||
fakePickerProvider.givenResult(anotherAvatarUri)
|
||||
fakeMediaPreProcessor.givenResult(
|
||||
Result.success(
|
||||
MediaUploadInfo.AnyFile(
|
||||
file = processedFile,
|
||||
fileInfo = mockk(),
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ANOTHER_AVATAR_URL = "example://camera/foo.jpg"
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
|
||||
skipItems(1)
|
||||
return awaitItem()
|
||||
}
|
||||
|
|
@ -1,251 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2024, 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.roomdetails.impl.edit
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.ui.test.assert
|
||||
import androidx.compose.ui.test.hasTestTag
|
||||
import androidx.compose.ui.test.isEditable
|
||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
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.testtags.TestTags
|
||||
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.Ignore
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class RoomDetailsEditViewTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun `clicking on back emits the expected Event`() {
|
||||
val eventsRecorder = EventsRecorder<RoomDetailsEditEvents>()
|
||||
rule.setRoomDetailsEditView(
|
||||
aRoomDetailsEditState(
|
||||
eventSink = eventsRecorder
|
||||
),
|
||||
)
|
||||
rule.pressBack()
|
||||
eventsRecorder.assertSingle(RoomDetailsEditEvents.OnBackPress)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on OK when confirming exit emits the expected Event`() {
|
||||
val eventsRecorder = EventsRecorder<RoomDetailsEditEvents>()
|
||||
rule.setRoomDetailsEditView(
|
||||
aRoomDetailsEditState(
|
||||
saveAction = AsyncAction.ConfirmingCancellation,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_ok)
|
||||
eventsRecorder.assertSingle(RoomDetailsEditEvents.OnBackPress)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on cancel when confirming exit emits the expected Event`() {
|
||||
val eventsRecorder = EventsRecorder<RoomDetailsEditEvents>()
|
||||
rule.setRoomDetailsEditView(
|
||||
aRoomDetailsEditState(
|
||||
saveAction = AsyncAction.ConfirmingCancellation,
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_cancel)
|
||||
eventsRecorder.assertSingle(RoomDetailsEditEvents.CloseDialog)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when edition is successful, the expected callback is invoked`() {
|
||||
val eventsRecorder = EventsRecorder<RoomDetailsEditEvents>(expectEvents = false)
|
||||
ensureCalledOnce { callback ->
|
||||
rule.setRoomDetailsEditView(
|
||||
aRoomDetailsEditState(
|
||||
eventSink = eventsRecorder,
|
||||
saveAction = AsyncAction.Success(Unit)
|
||||
),
|
||||
onDone = callback,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when name is changed, the expected Event is emitted`() {
|
||||
val eventsRecorder = EventsRecorder<RoomDetailsEditEvents>()
|
||||
rule.setRoomDetailsEditView(
|
||||
aRoomDetailsEditState(
|
||||
eventSink = eventsRecorder,
|
||||
roomRawName = "Marketing",
|
||||
),
|
||||
)
|
||||
rule.onNodeWithText("Marketing").performTextInput("A")
|
||||
eventsRecorder.assertSingle(RoomDetailsEditEvents.UpdateRoomName("AMarketing"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when user cannot change name, nothing happen`() {
|
||||
val eventsRecorder = EventsRecorder<RoomDetailsEditEvents>(expectEvents = false)
|
||||
rule.setRoomDetailsEditView(
|
||||
aRoomDetailsEditState(
|
||||
eventSink = eventsRecorder,
|
||||
roomRawName = "Marketing",
|
||||
canChangeName = false,
|
||||
),
|
||||
)
|
||||
rule.onNodeWithText("Marketing").assert(!isEditable())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when topic is changed, the expected Event is emitted`() {
|
||||
val eventsRecorder = EventsRecorder<RoomDetailsEditEvents>()
|
||||
rule.setRoomDetailsEditView(
|
||||
aRoomDetailsEditState(
|
||||
eventSink = eventsRecorder,
|
||||
roomTopic = "My Topic",
|
||||
),
|
||||
)
|
||||
rule.onNodeWithText("My Topic").performTextInput("A")
|
||||
eventsRecorder.assertSingle(RoomDetailsEditEvents.UpdateRoomTopic("AMy Topic"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when user cannot change topic, nothing happen`() {
|
||||
val eventsRecorder = EventsRecorder<RoomDetailsEditEvents>(expectEvents = false)
|
||||
rule.setRoomDetailsEditView(
|
||||
aRoomDetailsEditState(
|
||||
eventSink = eventsRecorder,
|
||||
roomTopic = "My Topic",
|
||||
canChangeTopic = false,
|
||||
),
|
||||
)
|
||||
rule.onNodeWithText("My Topic").assert(!isEditable())
|
||||
}
|
||||
|
||||
@Ignore("This test is failing because the bottom sheet does not open")
|
||||
@Test
|
||||
fun `when avatar is changed with action to take photo, the expected Event is emitted`() {
|
||||
testAvatarChange(
|
||||
stringActionRes = CommonStrings.action_take_photo,
|
||||
expectedEvent = RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.TakePhoto),
|
||||
)
|
||||
}
|
||||
|
||||
@Ignore("This test is failing because the bottom sheet does not open")
|
||||
@Test
|
||||
fun `when avatar is changed with action to choose photo, the expected Event is emitted`() {
|
||||
testAvatarChange(
|
||||
stringActionRes = CommonStrings.action_choose_photo,
|
||||
expectedEvent = RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto),
|
||||
)
|
||||
}
|
||||
|
||||
@Ignore("This test is failing because the bottom sheet does not open")
|
||||
@Test
|
||||
fun `when avatar is changed with action to remove photo, the expected Event is emitted`() {
|
||||
testAvatarChange(
|
||||
stringActionRes = CommonStrings.action_remove,
|
||||
expectedEvent = RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove),
|
||||
)
|
||||
}
|
||||
|
||||
private fun testAvatarChange(
|
||||
@StringRes stringActionRes: Int,
|
||||
expectedEvent: RoomDetailsEditEvents.HandleAvatarAction,
|
||||
) {
|
||||
val eventsRecorder = EventsRecorder<RoomDetailsEditEvents>()
|
||||
rule.setRoomDetailsEditView(
|
||||
aRoomDetailsEditState(
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
// Open the bottom sheet
|
||||
rule.onNode(hasTestTag(TestTags.editAvatar.value)).performClick()
|
||||
rule.onNodeWithText(rule.activity.getString(stringActionRes)).assertExists()
|
||||
rule.clickOn(stringActionRes)
|
||||
eventsRecorder.assertSingle(expectedEvent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when user cannot change avatar, nothing happen`() {
|
||||
val eventsRecorder = EventsRecorder<RoomDetailsEditEvents>(expectEvents = false)
|
||||
rule.setRoomDetailsEditView(
|
||||
aRoomDetailsEditState(
|
||||
eventSink = eventsRecorder,
|
||||
canChangeAvatar = false,
|
||||
),
|
||||
)
|
||||
rule.onNode(hasTestTag(TestTags.editAvatar.value)).performClick()
|
||||
rule.onNodeWithText(rule.activity.getString(CommonStrings.action_take_photo)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when save is clicked, the expected Event is emitted`() {
|
||||
val eventsRecorder = EventsRecorder<RoomDetailsEditEvents>()
|
||||
rule.setRoomDetailsEditView(
|
||||
aRoomDetailsEditState(
|
||||
eventSink = eventsRecorder,
|
||||
saveButtonEnabled = true,
|
||||
),
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_save)
|
||||
eventsRecorder.assertSingle(RoomDetailsEditEvents.Save)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when save is clicked, but nothing need to be saved, nothing happens`() {
|
||||
val eventsRecorder = EventsRecorder<RoomDetailsEditEvents>(expectEvents = false)
|
||||
rule.setRoomDetailsEditView(
|
||||
aRoomDetailsEditState(
|
||||
eventSink = eventsRecorder,
|
||||
saveButtonEnabled = false,
|
||||
),
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_save)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when error is shown, closing the dialog emit the expected Event`() {
|
||||
val eventsRecorder = EventsRecorder<RoomDetailsEditEvents>()
|
||||
rule.setRoomDetailsEditView(
|
||||
aRoomDetailsEditState(
|
||||
eventSink = eventsRecorder,
|
||||
saveAction = AsyncAction.Failure(RuntimeException("Whelp")),
|
||||
),
|
||||
)
|
||||
rule.clickOn(CommonStrings.action_ok)
|
||||
eventsRecorder.assertSingle(RoomDetailsEditEvents.CloseDialog)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomDetailsEditView(
|
||||
state: RoomDetailsEditState,
|
||||
onDone: () -> Unit = EnsureNeverCalled(),
|
||||
) {
|
||||
setContent {
|
||||
RoomDetailsEditView(
|
||||
state = state,
|
||||
onDone = onDone,
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue