diff --git a/changelog.d/122.feature b/changelog.d/122.feature new file mode 100644 index 0000000000..c163e6d53b --- /dev/null +++ b/changelog.d/122.feature @@ -0,0 +1 @@ +[Create and join rooms] Select a media from the camera diff --git a/changelog.d/123.feature b/changelog.d/123.feature new file mode 100644 index 0000000000..f8b4d36f2d --- /dev/null +++ b/changelog.d/123.feature @@ -0,0 +1 @@ +[Create and join rooms] Select a media from the gallery diff --git a/features/createroom/impl/build.gradle.kts b/features/createroom/impl/build.gradle.kts index 0515bbbfc7..2026ac5214 100644 --- a/features/createroom/impl/build.gradle.kts +++ b/features/createroom/impl/build.gradle.kts @@ -46,10 +46,13 @@ dependencies { implementation(projects.libraries.elementresources) implementation(projects.libraries.uiStrings) implementation(projects.features.userlist.api) + implementation(projects.libraries.mediapickers.api) + implementation(projects.libraries.mediaupload.api) + implementation(libs.coil.compose) api(projects.features.createroom.api) - implementation(libs.coil.compose) // FIXME temp testImplementation(libs.test.junit) + testImplementation(libs.test.mockk) testImplementation(libs.coroutines.test) testImplementation(libs.molecule.runtime) testImplementation(libs.test.truth) @@ -58,6 +61,8 @@ dependencies { testImplementation(projects.libraries.matrix.test) testImplementation(projects.features.userlist.impl) testImplementation(projects.features.userlist.test) + testImplementation(projects.libraries.mediapickers.test) + testImplementation(projects.libraries.mediaupload.test) androidTestImplementation(libs.test.junitext) diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomConfig.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomConfig.kt index 21662fdb64..23b6f3239d 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomConfig.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomConfig.kt @@ -16,6 +16,7 @@ package io.element.android.features.createroom.impl +import android.net.Uri import io.element.android.features.createroom.impl.configureroom.RoomPrivacy import io.element.android.libraries.matrix.api.user.MatrixUser import kotlinx.collections.immutable.ImmutableList @@ -24,7 +25,7 @@ import kotlinx.collections.immutable.persistentListOf data class CreateRoomConfig( val roomName: String? = null, val topic: String? = null, - val avatarUrl: String? = null, + val avatarUri: Uri? = null, val invites: ImmutableList = persistentListOf(), val privacy: RoomPrivacy? = null, ) diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomDataStore.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomDataStore.kt index e55fca755d..3c594a0f1e 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomDataStore.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomDataStore.kt @@ -16,6 +16,7 @@ package io.element.android.features.createroom.impl +import android.net.Uri import io.element.android.features.createroom.impl.configureroom.RoomPrivacy import io.element.android.features.createroom.impl.di.CreateRoomScope import io.element.android.features.userlist.api.UserListDataStore @@ -24,6 +25,7 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine +import java.io.File import javax.inject.Inject @SingleIn(CreateRoomScope::class) @@ -32,6 +34,11 @@ class CreateRoomDataStore @Inject constructor( ) { private val createRoomConfigFlow: MutableStateFlow = MutableStateFlow(CreateRoomConfig()) + private var cachedAvatarUri: Uri? = null + set(value) { + field?.path?.let { File(it) }?.delete() + field = value + } fun getCreateRoomConfig(): Flow = combine( selectedUserListDataStore.selectedUsers(), @@ -48,11 +55,16 @@ class CreateRoomDataStore @Inject constructor( createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(topic = topic?.takeIf { it.isNotEmpty() })) } - fun setAvatarUrl(avatarUrl: String?) { - createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(avatarUrl = avatarUrl)) + fun setAvatarUri(uri: Uri?, cached: Boolean = false) { + cachedAvatarUri = uri.takeIf { cached } + createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(avatarUri = uri)) } fun setPrivacy(privacy: RoomPrivacy?) { createRoomConfigFlow.tryEmit(createRoomConfigFlow.value.copy(privacy = privacy)) } + + fun clearCachedData() { + cachedAvatarUri = null + } } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomEvents.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomEvents.kt index f2c454656c..52389d97fb 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomEvents.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomEvents.kt @@ -16,16 +16,16 @@ package io.element.android.features.createroom.impl.configureroom -import android.net.Uri import io.element.android.features.createroom.impl.CreateRoomConfig +import io.element.android.features.createroom.impl.configureroom.avatar.AvatarAction import io.element.android.libraries.matrix.api.user.MatrixUser sealed interface ConfigureRoomEvents { data class RoomNameChanged(val name: String) : ConfigureRoomEvents data class TopicChanged(val topic: String) : ConfigureRoomEvents - data class AvatarUriChanged(val uri: Uri?) : ConfigureRoomEvents data class RoomPrivacyChanged(val privacy: RoomPrivacy?) : ConfigureRoomEvents data class RemoveFromSelection(val matrixUser: MatrixUser) : ConfigureRoomEvents data class CreateRoom(val config: CreateRoomConfig) : ConfigureRoomEvents + data class HandleAvatarAction(val action: AvatarAction) : ConfigureRoomEvents object CancelCreateRoom : ConfigureRoomEvents } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt index fa61c7f643..7659226bd9 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt @@ -16,6 +16,7 @@ package io.element.android.features.createroom.impl.configureroom +import android.net.Uri import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState @@ -26,14 +27,21 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import io.element.android.features.createroom.impl.CreateRoomConfig import io.element.android.features.createroom.impl.CreateRoomDataStore +import io.element.android.features.createroom.impl.configureroom.avatar.AvatarAction import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.execute +import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters import io.element.android.libraries.matrix.api.createroom.RoomPreset import io.element.android.libraries.matrix.api.createroom.RoomVisibility +import io.element.android.libraries.mediapickers.api.PickerProvider +import io.element.android.libraries.mediaupload.api.MediaPreProcessor +import io.element.android.libraries.mediaupload.api.MediaType +import io.element.android.libraries.mediaupload.api.MediaUploadInfo +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import javax.inject.Inject @@ -41,6 +49,8 @@ import javax.inject.Inject class ConfigureRoomPresenter @Inject constructor( private val dataStore: CreateRoomDataStore, private val matrixClient: MatrixClient, + private val mediaPickerProvider: PickerProvider, + private val mediaPreProcessor: MediaPreProcessor, ) : Presenter { @Composable @@ -52,6 +62,23 @@ class ConfigureRoomPresenter @Inject constructor( } } + val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker( + onResult = { uri -> if (uri != null) dataStore.setAvatarUri(uri = uri, cached = true) }, + ) + val galleryImagePicker = mediaPickerProvider.registerGalleryImagePicker( + onResult = { uri -> if (uri != null) dataStore.setAvatarUri(uri = uri) } + ) + + val avatarActions by remember(createRoomConfig.value.avatarUri) { + derivedStateOf { + listOfNotNull( + AvatarAction.TakePhoto, + AvatarAction.ChoosePhoto, + AvatarAction.Remove.takeIf { createRoomConfig.value.avatarUri != null }, + ).toImmutableList() + } + } + val localCoroutineScope = rememberCoroutineScope() val createRoomAction: MutableState> = remember { mutableStateOf(Async.Uninitialized) } @@ -62,12 +89,19 @@ class ConfigureRoomPresenter @Inject constructor( fun handleEvents(event: ConfigureRoomEvents) { when (event) { - is ConfigureRoomEvents.AvatarUriChanged -> dataStore.setAvatarUrl(event.uri?.toString()) is ConfigureRoomEvents.RoomNameChanged -> dataStore.setRoomName(event.name) is ConfigureRoomEvents.TopicChanged -> dataStore.setTopic(event.topic) is ConfigureRoomEvents.RoomPrivacyChanged -> dataStore.setPrivacy(event.privacy) is ConfigureRoomEvents.RemoveFromSelection -> dataStore.selectedUserListDataStore.removeUserFromSelection(event.matrixUser) is ConfigureRoomEvents.CreateRoom -> createRoom(event.config) + is ConfigureRoomEvents.HandleAvatarAction -> { + when (event.action) { + AvatarAction.ChoosePhoto -> galleryImagePicker.launch() + AvatarAction.TakePhoto -> cameraPhotoPicker.launch() + AvatarAction.Remove -> dataStore.setAvatarUri(uri = null) + } + } + ConfigureRoomEvents.CancelCreateRoom -> createRoomAction.value = Async.Uninitialized } } @@ -75,13 +109,18 @@ class ConfigureRoomPresenter @Inject constructor( return ConfigureRoomState( config = createRoomConfig.value, isCreateButtonEnabled = isCreateButtonEnabled, + avatarActions = avatarActions, createRoomAction = createRoomAction.value, eventSink = ::handleEvents, ) } - private fun CoroutineScope.createRoom(config: CreateRoomConfig, createRoomAction: MutableState>) = launch { + private fun CoroutineScope.createRoom( + config: CreateRoomConfig, + createRoomAction: MutableState> + ) = launch { suspend { + val mxc = config.avatarUri?.let { uploadAvatar(it) } val params = CreateRoomParameters( name = config.roomName, topic = config.topic, @@ -90,9 +129,16 @@ class ConfigureRoomPresenter @Inject constructor( visibility = if (config.privacy == RoomPrivacy.Public) RoomVisibility.PUBLIC else RoomVisibility.PRIVATE, preset = if (config.privacy == RoomPrivacy.Public) RoomPreset.PUBLIC_CHAT else RoomPreset.PRIVATE_CHAT, invite = config.invites.map { it.userId }, - avatar = config.avatarUrl, + avatar = mxc, ) matrixClient.createRoom(params).getOrThrow() + .also { dataStore.clearCachedData() } }.execute(createRoomAction) } + + private suspend fun uploadAvatar(avatarUri: Uri): String? { + val preprocessed = mediaPreProcessor.process(avatarUri, MediaType.Image).getOrThrow() as? MediaUploadInfo.Image + val byteArray = preprocessed?.file?.readBytes() + return byteArray?.let { matrixClient.uploadMedia(MimeTypes.Jpeg, it) }?.getOrThrow() + } } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomState.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomState.kt index b5f804fcde..0e0c465b52 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomState.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomState.kt @@ -17,12 +17,15 @@ package io.element.android.features.createroom.impl.configureroom import io.element.android.features.createroom.impl.CreateRoomConfig +import io.element.android.features.createroom.impl.configureroom.avatar.AvatarAction import io.element.android.libraries.architecture.Async import io.element.android.libraries.matrix.api.core.RoomId +import kotlinx.collections.immutable.ImmutableList data class ConfigureRoomState( val config: CreateRoomConfig, val isCreateButtonEnabled: Boolean, + val avatarActions: ImmutableList, val createRoomAction: Async, val eventSink: (ConfigureRoomEvents) -> Unit ) diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt index 67e0b91bba..2dd4854f3a 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.createroom.impl.CreateRoomConfig import io.element.android.features.userlist.api.aListOfSelectedUsers import io.element.android.libraries.architecture.Async +import kotlinx.collections.immutable.persistentListOf open class ConfigureRoomStateProvider : PreviewParameterProvider { override val values: Sequence @@ -40,6 +41,7 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider Unit = {}, onRoomCreated: (RoomId) -> Unit = {}, ) { + val coroutineScope = rememberCoroutineScope() + val itemActionsBottomSheetState = rememberModalBottomSheetState( + initialValue = ModalBottomSheetValue.Hidden, + ) + if (state.createRoomAction is Async.Success) { LaunchedEffect(state.createRoomAction) { onRoomCreated(state.createRoomAction.state) } } - val context = LocalContext.current + fun onAvatarClicked() { + coroutineScope.launch { + itemActionsBottomSheetState.show() + } + } + Scaffold( modifier = modifier, topBar = { @@ -88,9 +102,9 @@ fun ConfigureRoomView( ) { RoomNameWithAvatar( modifier = Modifier.padding(horizontal = 16.dp), - avatarUri = state.config.avatarUrl?.toUri(), + avatarUri = state.config.avatarUri, roomName = state.config.roomName.orEmpty(), - onAvatarClick = { Toast.makeText(context, "not implemented yet", Toast.LENGTH_SHORT).show() }, + onAvatarClick = ::onAvatarClicked, onRoomNameChanged = { state.eventSink(ConfigureRoomEvents.RoomNameChanged(it)) }, ) RoomTopic( @@ -112,10 +126,17 @@ fun ConfigureRoomView( } } + AvatarActionListView( + actions = state.avatarActions, + modalBottomSheetState = itemActionsBottomSheetState, + onActionSelected = { state.eventSink(ConfigureRoomEvents.HandleAvatarAction(it)) } + ) + when (state.createRoomAction) { is Async.Loading -> { ProgressDialog(text = stringResource(StringR.string.common_creating_room)) } + is Async.Failure -> { RetryDialog( content = stringResource(R.string.screen_create_room_error_creating_room), @@ -123,6 +144,7 @@ fun ConfigureRoomView( onRetry = { state.eventSink(ConfigureRoomEvents.CreateRoom(state.config)) }, ) } + else -> Unit } } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/avatar/AvatarAction.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/avatar/AvatarAction.kt new file mode 100644 index 0000000000..25ecc2b3db --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/avatar/AvatarAction.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.createroom.impl.configureroom.avatar + +import androidx.annotation.StringRes +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.PhotoCamera +import androidx.compose.material.icons.outlined.PhotoLibrary +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.vector.ImageVector +import io.element.android.libraries.ui.strings.R + +@Immutable +sealed class AvatarAction( + @StringRes val titleResId: Int, + val icon: ImageVector, + val destructive: Boolean = false, +) { + object TakePhoto : AvatarAction(titleResId = R.string.action_take_photo, icon = Icons.Outlined.PhotoCamera) + object ChoosePhoto : AvatarAction(titleResId = R.string.action_choose_photo, icon = Icons.Outlined.PhotoLibrary) + object Remove : AvatarAction(titleResId = R.string.action_remove, icon = Icons.Outlined.Delete, destructive = true) +} diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/avatar/AvatarActionListView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/avatar/AvatarActionListView.kt new file mode 100644 index 0000000000..422a187290 --- /dev/null +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/avatar/AvatarActionListView.kt @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalMaterialApi::class) + +package io.element.android.features.createroom.impl.configureroom.avatar + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.ModalBottomSheetState +import androidx.compose.material.ModalBottomSheetValue +import androidx.compose.material.Text +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.ModalBottomSheetLayout +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.launch + +@Composable +fun AvatarActionListView( + actions: ImmutableList, + modalBottomSheetState: ModalBottomSheetState, + modifier: Modifier = Modifier, + onActionSelected: (action: AvatarAction) -> Unit = {}, +) { + val coroutineScope = rememberCoroutineScope() + fun onItemActionClicked(itemAction: AvatarAction) { + onActionSelected(itemAction) + coroutineScope.launch { + modalBottomSheetState.hide() + } + } + + ModalBottomSheetLayout( + modifier = modifier, + sheetState = modalBottomSheetState, + sheetContent = { + SheetContent( + actions = actions, + onActionClicked = ::onItemActionClicked, + modifier = Modifier + .navigationBarsPadding() + .imePadding() + ) + } + ) +} + +@Composable +private fun SheetContent( + actions: ImmutableList, + modifier: Modifier = Modifier, + onActionClicked: (AvatarAction) -> Unit = { }, +) { + LazyColumn( + modifier = modifier.fillMaxWidth() + ) { + items( + items = actions, + ) { action -> + ListItem( + modifier = Modifier.clickable { onActionClicked(action) }, + headlineContent = { + Text( + text = stringResource(action.titleResId), + color = if (action.destructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, + ) + }, + leadingContent = { + Icon( + imageVector = action.icon, + contentDescription = stringResource(action.titleResId), + tint = if (action.destructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, + ) + } + ) + } + } +} + +@Preview +@Composable +fun SheetContentLightPreview() = + ElementPreviewLight { ContentToPreview() } + +@Preview +@Composable +fun SheetContentDarkPreview() = + ElementPreviewDark { ContentToPreview() } + +@Composable +private fun ContentToPreview() { + AvatarActionListView( + actions = persistentListOf(AvatarAction.TakePhoto, AvatarAction.ChoosePhoto, AvatarAction.Remove), + modalBottomSheetState = ModalBottomSheetState( + initialValue = ModalBottomSheetValue.Expanded + ), + ) +} diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterTests.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterTests.kt index 24076bcd55..b4632ab0e8 100644 --- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterTests.kt +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenterTests.kt @@ -23,6 +23,7 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.features.createroom.impl.CreateRoomConfig import io.element.android.features.createroom.impl.CreateRoomDataStore +import io.element.android.features.createroom.impl.configureroom.avatar.AvatarAction import io.element.android.features.userlist.api.UserListDataStore import io.element.android.libraries.architecture.Async import io.element.android.libraries.matrix.api.core.RoomId @@ -32,29 +33,57 @@ import io.element.android.libraries.matrix.test.A_ROOM_NAME import io.element.android.libraries.matrix.test.A_THROWABLE import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.ui.components.aMatrixUser +import io.element.android.libraries.mediapickers.test.FakePickerProvider +import io.element.android.libraries.mediaupload.api.MediaUploadInfo +import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.test.runTest +import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import java.io.File + +private const val AN_URI_FROM_CAMERA = "content://uri_from_camera" +private const val AN_URI_FROM_GALLERY = "content://uri_from_gallery" @RunWith(RobolectricTestRunner::class) class ConfigureRoomPresenterTests { private lateinit var presenter: ConfigureRoomPresenter private lateinit var userListDataStore: UserListDataStore + private lateinit var createRoomDataStore: CreateRoomDataStore private lateinit var fakeMatrixClient: FakeMatrixClient + private lateinit var fakePickerProvider: FakePickerProvider + private lateinit var fakeMediaPreProcessor: FakeMediaPreProcessor @Before fun setup() { fakeMatrixClient = FakeMatrixClient() userListDataStore = UserListDataStore() + createRoomDataStore = CreateRoomDataStore(userListDataStore) + fakePickerProvider = FakePickerProvider() + fakeMediaPreProcessor = FakeMediaPreProcessor() presenter = ConfigureRoomPresenter( - dataStore = CreateRoomDataStore(userListDataStore), - matrixClient = fakeMatrixClient + dataStore = createRoomDataStore, + matrixClient = fakeMatrixClient, + mediaPickerProvider = fakePickerProvider, + mediaPreProcessor = fakeMediaPreProcessor, ) + + mockkStatic(File::readBytes) + every { any().readBytes() } returns byteArrayOf() + } + + @After + fun tearDown() { + unmockkAll() } @Test @@ -67,7 +96,7 @@ class ConfigureRoomPresenterTests { assertThat(initialState.config.roomName).isNull() assertThat(initialState.config.topic).isNull() assertThat(initialState.config.invites).isEmpty() - assertThat(initialState.config.avatarUrl).isNull() + assertThat(initialState.config.avatarUri).isNull() assertThat(initialState.config.privacy).isNull() } } @@ -136,10 +165,28 @@ class ConfigureRoomPresenterTests { assertThat(newState.config).isEqualTo(expectedConfig) // Room avatar - val anUri = Uri.parse(AN_AVATAR_URL) - newState.eventSink(ConfigureRoomEvents.AvatarUriChanged(anUri)) + // Pick avatar + fakePickerProvider.givenResult(null) + newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) + newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto)) + // From gallery + val uriFromGallery = Uri.parse(AN_URI_FROM_GALLERY) + fakePickerProvider.givenResult(uriFromGallery) + newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) newState = awaitItem() - expectedConfig = expectedConfig.copy(avatarUrl = anUri.toString()) + expectedConfig = expectedConfig.copy(avatarUri = uriFromGallery) + assertThat(newState.config).isEqualTo(expectedConfig) + // From camera + val uriFromCamera = Uri.parse(AN_URI_FROM_CAMERA) + fakePickerProvider.givenResult(uriFromCamera) + newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.TakePhoto)) + newState = awaitItem() + expectedConfig = expectedConfig.copy(avatarUri = uriFromCamera) + assertThat(newState.config).isEqualTo(expectedConfig) + // Remove + newState.eventSink(ConfigureRoomEvents.HandleAvatarAction(AvatarAction.Remove)) + newState = awaitItem() + expectedConfig = expectedConfig.copy(avatarUri = null) assertThat(newState.config).isEqualTo(expectedConfig) // Room privacy @@ -174,6 +221,30 @@ class ConfigureRoomPresenterTests { } } + @Test + fun `present - trigger create room with upload error and retry`() = runTest { + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + skipItems(1) + createRoomDataStore.setAvatarUri(Uri.parse(AN_URI_FROM_GALLERY)) + fakeMediaPreProcessor.givenResult(Result.success(MediaUploadInfo.Image(mockk(), mockk(), mockk()))) + fakeMatrixClient.givenUploadMediaResult(Result.failure(A_THROWABLE)) + + val initialState = awaitItem() + initialState.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config)) + val stateAfterCreateRoom = awaitItem() + assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(Async.Failure::class.java) + + fakeMatrixClient.givenUploadMediaResult(Result.success(AN_AVATAR_URL)) + stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom(initialState.config)) + assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Uninitialized::class.java) + assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Loading::class.java) + assertThat(awaitItem().createRoomAction).isInstanceOf(Async.Success::class.java) + + } + } + @Test fun `present - trigger retry and cancel actions`() = runTest { moleculeFlow(RecompositionClock.Immediate) { diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index dce2b15485..6d357ad912 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -42,6 +42,7 @@ interface MatrixClient : Closeable { suspend fun createRoom(createRoomParams: CreateRoomParameters): Result suspend fun createDM(userId: UserId): Result suspend fun getProfile(userId: UserId): Result + suspend fun searchUsers(searchTerm: String, limit: Long): Result fun startSync() fun stopSync() fun mediaResolver(): MediaResolver @@ -58,9 +59,10 @@ interface MatrixClient : Closeable { height: Long ): Result + suspend fun uploadMedia(mimeType: String, data: ByteArray): Result + fun onSlidingSyncUpdate() fun roomMembershipObserver(): RoomMembershipObserver - suspend fun searchUsers(searchTerm: String, limit: Long): Result } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 7f1b78ebf7..57dc8c2fc9 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -279,6 +279,13 @@ class RustMatrixClient constructor( } } + override suspend fun searchUsers(searchTerm: String, limit: Long): Result = + withContext(dispatchers.io) { + runCatching { + client.searchUsers(searchTerm, limit.toULong()).let(UserSearchResultMapper::map) + } + } + override fun mediaResolver(): MediaResolver = mediaResolver override fun sessionVerificationService(): SessionVerificationService = verificationService @@ -366,6 +373,13 @@ class RustMatrixClient constructor( } } + @OptIn(ExperimentalUnsignedTypes::class) + override suspend fun uploadMedia(mimeType: String, data: ByteArray): Result = withContext(dispatchers.io) { + runCatching { + client.uploadMedia(mimeType, data.toUByteArray().toList()) + } + } + override fun onSlidingSyncUpdate() { if (!verificationService.isReady.value) { try { @@ -378,13 +392,6 @@ class RustMatrixClient constructor( override fun roomMembershipObserver(): RoomMembershipObserver = roomMembershipObserver - override suspend fun searchUsers(searchTerm: String, limit: Long): Result = - withContext(dispatchers.io) { - runCatching { - client.searchUsers(searchTerm, limit.toULong()).let(UserSearchResultMapper::map) - } - } - private fun File.deleteSessionDirectory(userID: String): Boolean { // Rust sanitises the user ID replacing invalid characters with an _ val sanitisedUserID = userID.replace(":", "_") diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index 9d3ce40da1..bdf76c8e05 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -59,6 +59,7 @@ class FakeMatrixClient( private val getRoomResults = mutableMapOf() private val searchUserResults = mutableMapOf>() private val getProfileResults = mutableMapOf>() + private var uploadMediaResult: Result = Result.success(AN_AVATAR_URL) override fun getRoom(roomId: RoomId): MatrixRoom? { return getRoomResults[roomId] @@ -91,6 +92,10 @@ class FakeMatrixClient( return getProfileResults[userId] ?: Result.failure(IllegalStateException("No profile found for $userId")) } + override suspend fun searchUsers(searchTerm: String, limit: Long): Result { + return searchUserResults[searchTerm] ?: Result.failure(IllegalStateException("No response defined for $searchTerm")) + } + override fun startSync() = Unit override fun stopSync() = Unit @@ -122,6 +127,10 @@ class FakeMatrixClient( return Result.success(ByteArray(0)) } + override suspend fun uploadMedia(mimeType: String, data: ByteArray): Result { + return uploadMediaResult + } + override fun sessionVerificationService(): SessionVerificationService = sessionVerificationService override fun pushersService(): PushersService = pushersService @@ -134,10 +143,6 @@ class FakeMatrixClient( return RoomMembershipObserver() } - override suspend fun searchUsers(searchTerm: String, limit: Long): Result { - return searchUserResults[searchTerm] ?: Result.failure(IllegalStateException("No response defined for $searchTerm")) - } - // Mocks fun givenLogoutError(failure: Throwable?) { @@ -179,4 +184,8 @@ class FakeMatrixClient( fun givenGetProfileResult(userId: UserId, result: Result) { getProfileResults[userId] = result } + + fun givenUploadMediaResult(result: Result) { + uploadMediaResult = result + } } diff --git a/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerProvider.kt b/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerProvider.kt index 9b0b248aa5..9becdc8aee 100644 --- a/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerProvider.kt +++ b/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerProvider.kt @@ -27,6 +27,11 @@ interface PickerProvider { onResult: (uri: Uri?, mimeType: String?) -> Unit ): PickerLauncher + @Composable + fun registerGalleryImagePicker( + onResult: (Uri?) -> Unit + ): PickerLauncher + @Composable fun registerFilePicker( mimeType: String, @@ -38,5 +43,4 @@ interface PickerProvider { @Composable fun registerCameraVideoPicker(onResult: (Uri?) -> Unit): PickerLauncher - } diff --git a/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerType.kt b/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerType.kt index ad89175ddf..7c86009d34 100644 --- a/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerType.kt +++ b/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerType.kt @@ -26,6 +26,13 @@ sealed interface PickerType { fun getContract(): ActivityResultContract fun getDefaultRequest(): Input + object Image : PickerType { + override fun getContract() = ActivityResultContracts.PickVisualMedia() + override fun getDefaultRequest(): PickVisualMediaRequest { + return PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + } + } + object ImageAndVideo : PickerType { override fun getContract() = ActivityResultContracts.PickVisualMedia() override fun getDefaultRequest(): PickVisualMediaRequest { diff --git a/libraries/mediapickers/impl/src/main/kotlin/io/element/android/libraries/mediapickers/impl/PickerProviderImpl.kt b/libraries/mediapickers/impl/src/main/kotlin/io/element/android/libraries/mediapickers/impl/PickerProviderImpl.kt index b7e9bad420..201fd9b069 100644 --- a/libraries/mediapickers/impl/src/main/kotlin/io/element/android/libraries/mediapickers/impl/PickerProviderImpl.kt +++ b/libraries/mediapickers/impl/src/main/kotlin/io/element/android/libraries/mediapickers/impl/PickerProviderImpl.kt @@ -59,6 +59,20 @@ class PickerProviderImpl constructor(private val isInTest: Boolean) : PickerProv } } + /** + * Remembers and returns a [PickerLauncher] for a gallery picture. + * [onResult] will be called with either the selected file's [Uri] or `null` if nothing was selected. + */ + @Composable + override fun registerGalleryImagePicker(onResult: (Uri?) -> Unit): PickerLauncher { + // Tests and UI preview can't handle Contexts, so we might as well disable the whole picker + return if (LocalInspectionMode.current || isInTest) { + NoOpPickerLauncher { onResult(null) } + } else { + rememberPickerLauncher(type = PickerType.Image) { uri -> onResult(uri) } + } + } + /** * Remembers and returns a [PickerLauncher] for a gallery item, either a picture or a video. * [onResult] will be called with either the selected file's [Uri] or `null` if nothing was selected. diff --git a/libraries/mediapickers/test/src/main/kotlin/io/element/android/libraries/mediapickers/test/FakePickerProvider.kt b/libraries/mediapickers/test/src/main/kotlin/io/element/android/libraries/mediapickers/test/FakePickerProvider.kt index 2a32387db4..e243a22138 100644 --- a/libraries/mediapickers/test/src/main/kotlin/io/element/android/libraries/mediapickers/test/FakePickerProvider.kt +++ b/libraries/mediapickers/test/src/main/kotlin/io/element/android/libraries/mediapickers/test/FakePickerProvider.kt @@ -33,6 +33,11 @@ class FakePickerProvider : PickerProvider { return NoOpPickerLauncher { onResult(result, mimeType) } } + @Composable + override fun registerGalleryImagePicker(onResult: (uri: Uri?) -> Unit): PickerLauncher { + return NoOpPickerLauncher { onResult(result) } + } + @Composable override fun registerFilePicker(mimeType: String, onResult: (Uri?) -> Unit): PickerLauncher { return NoOpPickerLauncher { onResult(result) } diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.configureroom.avatar_null_DefaultGroup_SheetContentDarkPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.configureroom.avatar_null_DefaultGroup_SheetContentDarkPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..1a8de3bbb4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.configureroom.avatar_null_DefaultGroup_SheetContentDarkPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c824fcf4167c6315d0d6568a4d300375a7bf6d4818e3d0dae9ed055f3269313d +size 13072 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.configureroom.avatar_null_DefaultGroup_SheetContentLightPreview_0_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.configureroom.avatar_null_DefaultGroup_SheetContentLightPreview_0_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..809b8135eb --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.createroom.impl.configureroom.avatar_null_DefaultGroup_SheetContentLightPreview_0_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8bad887952347b21606a783a19390fe73e5d6deb19390e2d413304398cff810c +size 13872