change(room details edit): move to his own module

This commit is contained in:
ganfra 2025-12-03 14:08:14 +01:00
parent 15cb6431c6
commit 2e7355e789
54 changed files with 475 additions and 29 deletions

View file

@ -0,0 +1,23 @@
/*
* 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.roomdetailsedit.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.roomdetailsedit.api.RoomDetailsEditEntryPoint
import io.element.android.libraries.architecture.createNode
@ContributesBinding(AppScope::class)
class DefaultRoomDetailsEditEntryPoint : RoomDetailsEditEntryPoint {
override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
return parentNode.createNode<RoomDetailsEditNode>(buildContext)
}
}

View file

@ -0,0 +1,20 @@
/*
* 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.roomdetailsedit.impl
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
}

View file

@ -0,0 +1,49 @@
/*
* 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.roomdetailsedit.impl
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,
)
}
}

View file

@ -0,0 +1,243 @@
/*
* 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.roomdetailsedit.impl
import android.Manifest
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(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()
}
}
}
}

View file

@ -0,0 +1,32 @@
/*
* 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.roomdetailsedit.impl
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
)

View file

@ -0,0 +1,63 @@
/*
* 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.roomdetailsedit.impl
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,
)

View file

@ -0,0 +1,182 @@
/*
* 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.roomdetailsedit.impl
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.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 = CommonStrings.common_room_name),
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 = {},
)
}

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_details_edit_room_title">"Рэдагаваць пакой"</string>
<string name="screen_room_details_edition_error">"Адбылася невядомая памылка, і інфармацыю нельга было змяніць."</string>
<string name="screen_room_details_edition_error_title">"Немагчыма абнавіць пакой"</string>
<string name="screen_room_details_updating_room">"Ідзе абнаўленне пакоя…"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_details_edit_room_title">"Редактиране на стаята"</string>
<string name="screen_room_details_edition_error">"Възникна неизвестна грешка и информацията не можа да бъде променена."</string>
<string name="screen_room_details_edition_error_title">"Не може да се обнови стаята"</string>
<string name="screen_room_details_updating_room">"Обновяване на стаята…"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_details_edit_room_title">"Upravit podrobnosti"</string>
<string name="screen_room_details_edition_error">"Došlo k neznámé chybě a informace nebylo možné změnit."</string>
<string name="screen_room_details_edition_error_title">"Nelze aktualizovat místnost"</string>
<string name="screen_room_details_updating_room">"Aktualizace místnosti…"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_details_edit_room_title">"Ystafell Golygu"</string>
<string name="screen_room_details_edition_error">"Roedd gwall anhysbys ac nid oedd modd newid y manylion."</string>
<string name="screen_room_details_edition_error_title">"Methu diweddaru\'r ystafell"</string>
<string name="screen_room_details_updating_room">"Wrthi\'n diweddaru ystafell…"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_details_edit_room_title">"Rediger rum"</string>
<string name="screen_room_details_edition_error">"Der opstod en ukendt fejl, og oplysningerne kunne ikke ændres."</string>
<string name="screen_room_details_edition_error_title">"Rummet kunne ikke opdateres"</string>
<string name="screen_room_details_updating_room">"Opdaterer rum…"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_details_edit_room_title">"Chat bearbeiten"</string>
<string name="screen_room_details_edition_error">"Es ist ein unbekannter Fehler aufgetreten und die Informationen konnten nicht geändert werden."</string>
<string name="screen_room_details_edition_error_title">"Chat kann nicht aktualisiert werden"</string>
<string name="screen_room_details_updating_room">"Chat wird aktualisiert…"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_details_edit_room_title">"Επεξεργασία Αίθουσας"</string>
<string name="screen_room_details_edition_error">"Υπήρξε ένα άγνωστο σφάλμα και οι πληροφορίες δεν μπορούσαν να αλλάξουν."</string>
<string name="screen_room_details_edition_error_title">"Αδυναμία ενημέρωσης αίθουσας"</string>
<string name="screen_room_details_updating_room">"Ενημέρωση αίθουσας…"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_details_edit_room_title">"Editar sala"</string>
<string name="screen_room_details_edition_error">"Se ha producido un error desconocido y no se ha podido cambiar la información."</string>
<string name="screen_room_details_edition_error_title">"No se puede actualizar la sala"</string>
<string name="screen_room_details_updating_room">"Actualizando la sala…"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_details_edit_room_title">"Muuda üksikasju"</string>
<string name="screen_room_details_edition_error">"Tekkis tundmatu viga ja andmed jäid muutmata."</string>
<string name="screen_room_details_edition_error_title">"Jututoa andmete muutmine ei õnnestu"</string>
<string name="screen_room_details_updating_room">"Uuendame jututuba…"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_details_edit_room_title">"Editatu gela"</string>
<string name="screen_room_details_edition_error">"Errore ezezaguna gertatu da eta ezin izan da informazioa aldatu."</string>
<string name="screen_room_details_edition_error_title">"Ezin da gela eguneratu"</string>
<string name="screen_room_details_updating_room">"Gela eguneratzen…"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_details_edit_room_title">"ویرایش اتاق"</string>
<string name="screen_room_details_edition_error">"خطایی ناشناخته رخ داد و اطّلاعات قابل تغییر نبودند."</string>
<string name="screen_room_details_edition_error_title">"ناتوان در به‌روز رسانی اتاق"</string>
<string name="screen_room_details_updating_room">"به‌روز کردن اتاق…"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_details_edit_room_title">"Muokkaa tietoja"</string>
<string name="screen_room_details_edition_error">"Tuntematon virhe tapahtui, eikä tietoja voitu muuttaa."</string>
<string name="screen_room_details_edition_error_title">"Huoneen muokkaaminen ei onnistunut"</string>
<string name="screen_room_details_updating_room">"Muokataan huonetta…"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_details_edit_room_title">"Modifier les détails"</string>
<string name="screen_room_details_edition_error">"Une erreur inconnue sest produite et les informations nont pas pu être modifiées."</string>
<string name="screen_room_details_edition_error_title">"Impossible de mettre à jour le salon"</string>
<string name="screen_room_details_updating_room">"Mise à jour du salon…"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_details_edit_room_title">"Részletek szerkesztése"</string>
<string name="screen_room_details_edition_error">"Ismeretlen hiba történt, és az információkat nem lehetett megváltoztatni."</string>
<string name="screen_room_details_edition_error_title">"Nem sikerült frissíteni a szobát"</string>
<string name="screen_room_details_updating_room">"Szoba frissítése…"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_details_edit_room_title">"Sunting Ruangan"</string>
<string name="screen_room_details_edition_error">"Terjadi kesalahan yang tidak diketahui dan informasinya tidak dapat diubah."</string>
<string name="screen_room_details_edition_error_title">"Tidak dapat memperbarui ruangan"</string>
<string name="screen_room_details_updating_room">"Memperbarui ruangan…"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_details_edit_room_title">"Modifica dettagli"</string>
<string name="screen_room_details_edition_error">"Si è verificato un errore sconosciuto e non è stato possibile modificare le informazioni."</string>
<string name="screen_room_details_edition_error_title">"Impossibile aggiornare la stanza"</string>
<string name="screen_room_details_updating_room">"Aggiornamento della stanza…"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_details_edit_room_title">"ოთახის რედაქტირება"</string>
<string name="screen_room_details_edition_error">"უცნობი შეცდომა მოხდა. ინფორმაციის შეცვლა ვერ მოხერხდა."</string>
<string name="screen_room_details_edition_error_title">"ოთახის განახლება შეუძლებელია"</string>
<string name="screen_room_details_updating_room">"ოთახის განახლება…"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_details_edit_room_title">"방 편집"</string>
<string name="screen_room_details_edition_error">"알 수 없는 오류가 발생하여 정보를 변경할 수 없습니다."</string>
<string name="screen_room_details_edition_error_title">"방을 업데이트할 수 없습니다."</string>
<string name="screen_room_details_updating_room">"방 업데이트 중…"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_details_edit_room_title">"Redaguoti kambarį"</string>
<string name="screen_room_details_edition_error">"Įvyko nežinoma klaida ir informacijos pakeisti nepavyko."</string>
<string name="screen_room_details_edition_error_title">"Nepavyko atnaujinti kambario"</string>
<string name="screen_room_details_updating_room">"Atnaujinamas kambarys…"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_details_edit_room_title">"Rediger rom"</string>
<string name="screen_room_details_edition_error">"Det oppstod en ukjent feil, og informasjonen kunne ikke endres."</string>
<string name="screen_room_details_edition_error_title">"Kan ikke oppdatere rommet"</string>
<string name="screen_room_details_updating_room">"Oppdaterer rommet …"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_details_edit_room_title">"Kamer bewerken"</string>
<string name="screen_room_details_edition_error">"Er is een onbekende fout opgetreden en de informatie kon niet worden gewijzigd."</string>
<string name="screen_room_details_edition_error_title">"Kan kamer niet bijwerken"</string>
<string name="screen_room_details_updating_room">"Kamer bijwerken…"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_details_edit_room_title">"Edytuj pokój"</string>
<string name="screen_room_details_edition_error">"Wystąpił nieznany błąd i nie można było zmienić informacji."</string>
<string name="screen_room_details_edition_error_title">"Nie można zaktualizować pokoju"</string>
<string name="screen_room_details_updating_room">"Aktualizuję pokój…"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_details_edit_room_title">"Editar detalhes"</string>
<string name="screen_room_details_edition_error">"Ocorreu um erro desconhecido e as informações não puderam ser alteradas."</string>
<string name="screen_room_details_edition_error_title">"Não foi possível atualizar a sala"</string>
<string name="screen_room_details_updating_room">"Atualizando a sala…"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_details_edit_room_title">"Editar sala"</string>
<string name="screen_room_details_edition_error">"Ocorreu um erro desconhecido e não foi possível alterar a informação."</string>
<string name="screen_room_details_edition_error_title">"Não foi possível atualizar a sala"</string>
<string name="screen_room_details_updating_room">"A atualizar sala…"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_details_edit_room_title">"Editați camera"</string>
<string name="screen_room_details_edition_error">"A apărut o eroare la actualizarea detaliilor camerei"</string>
<string name="screen_room_details_edition_error_title">"Nu s-a putut actualiza camera"</string>
<string name="screen_room_details_updating_room">"Se actualizează camera…"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_details_edit_room_title">"Редактировать комнату"</string>
<string name="screen_room_details_edition_error">"Произошла неизвестная ошибка и информацию не удалось изменить."</string>
<string name="screen_room_details_edition_error_title">"Не удалось обновить комнату"</string>
<string name="screen_room_details_updating_room">"Обновление комнаты…"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_details_edit_room_title">"Upraviť podrobnosti"</string>
<string name="screen_room_details_edition_error">"Vyskytla sa neznáma chyba a informácie nebolo možné zmeniť."</string>
<string name="screen_room_details_edition_error_title">"Nepodarilo sa aktualizovať miestnosť"</string>
<string name="screen_room_details_updating_room">"Aktualizácia miestnosti…"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_details_edit_room_title">"Redigera rummet"</string>
<string name="screen_room_details_edition_error">"Ett okänt fel uppstod och informationen kunde inte ändras."</string>
<string name="screen_room_details_edition_error_title">"Kunde inte uppdatera rummet"</string>
<string name="screen_room_details_updating_room">"Uppdaterar rummet …"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_details_edit_room_title">"Odayı Düzenle"</string>
<string name="screen_room_details_edition_error">"Bilinmeyen bir hata oluştu ve bilgiler değiştirilemedi."</string>
<string name="screen_room_details_edition_error_title">"Oda güncellenemiyor"</string>
<string name="screen_room_details_updating_room">"Oda güncelleniyor…"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_details_edit_room_title">"Редагувати кімнату"</string>
<string name="screen_room_details_edition_error">"Сталася невідома помилка, й інформацію не вдалося змінити."</string>
<string name="screen_room_details_edition_error_title">"Не вдалося оновити кімнату"</string>
<string name="screen_room_details_updating_room">"Оновлення кімнати…"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_details_edit_room_title">"کمرے میں ترمیم کریں"</string>
<string name="screen_room_details_edition_error">"ایک نامعلوم خلل تھا اور معلومات تبدیل نہیں ہوسکی۔"</string>
<string name="screen_room_details_edition_error_title">"کمرے کی تجدید کرنے سے قاصر"</string>
<string name="screen_room_details_updating_room">"کمرے کی تجدید کر رہا ہے…"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_details_edit_room_title">"Xonani tahrirlash"</string>
<string name="screen_room_details_edition_error">"Nomaʼlum xatolik yuz berdi va maʼlumotni oʻzgartirib boʻlmadi."</string>
<string name="screen_room_details_edition_error_title">"Xonani yangilab bolmadi"</string>
<string name="screen_room_details_updating_room">"Xona yangilanmoqda…"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_details_edit_room_title">"編輯詳細資訊"</string>
<string name="screen_room_details_edition_error">"發生未知錯誤,無法變更資訊。"</string>
<string name="screen_room_details_edition_error_title">"無法更新聊天室"</string>
<string name="screen_room_details_updating_room">"正在更新聊天室…"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_details_edit_room_title">"编辑聊天室"</string>
<string name="screen_room_details_edition_error">"出现未知错误,无法更改信息。"</string>
<string name="screen_room_details_edition_error_title">"无法更新聊天室"</string>
<string name="screen_room_details_updating_room">"正在更新聊天室……"</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_room_details_edit_room_title">"Edit details"</string>
<string name="screen_room_details_edition_error">"There was an unknown error and the information couldn\'t be changed."</string>
<string name="screen_room_details_edition_error_title">"Unable to update room"</string>
<string name="screen_room_details_updating_room">"Updating room…"</string>
</resources>

View file

@ -0,0 +1,812 @@
/*
* 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.roomdetailsedit.impl
import android.net.Uri
import app.cash.turbine.ReceiveTurbine
import com.google.common.truth.Truth.assertThat
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.core.UserId
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.test.A_ROOM_TOPIC
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
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 = FakeJoinedRoom(
FakeBaseRoom(
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(),
)
)
)
}
private fun aJoinedRoom(
avatarUrl: String? = AN_AVATAR_URL,
displayName: String = A_ROOM_NAME,
rawName: String = displayName,
topic: String? = A_ROOM_TOPIC,
setNameResult: (String) -> Result<Unit> = { Result.success(Unit) },
setTopicResult: (String) -> Result<Unit> = { Result.success(Unit) },
updateAvatarResult: (String, ByteArray) -> Result<Unit> = { _, _ -> Result.success(Unit) },
removeAvatarResult: () -> Result<Unit> = { Result.success(Unit) },
canSendStateResult: (UserId, StateEventType) -> Result<Boolean>,
): JoinedRoom {
return FakeJoinedRoom(
baseRoom = FakeBaseRoom(
canSendStateResult = canSendStateResult,
initialRoomInfo = aRoomInfo(
name = displayName,
topic = topic,
avatarUrl = avatarUrl,
rawName = rawName
)
),
setNameResult = setNameResult,
setTopicResult = setTopicResult,
updateAvatarResult = updateAvatarResult,
removeAvatarResult = removeAvatarResult,
)
}
companion object {
private const val ANOTHER_AVATAR_URL = "example://camera/foo.jpg"
}
}
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
skipItems(1)
return awaitItem()
}

View file

@ -0,0 +1,250 @@
/*
* 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.roomdetailsedit.impl
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,
)
}
}