Merge pull request #3819 from element-hq/feature/bma/fixDeleteOriginalFile
Do not delete the original file if it's not a temporary file when sending it to a room.
This commit is contained in:
commit
e726024f95
18 changed files with 424 additions and 118 deletions
|
|
@ -175,7 +175,12 @@ class ConfigureRoomPresenter @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun uploadAvatar(avatarUri: Uri): String {
|
private suspend fun uploadAvatar(avatarUri: Uri): String {
|
||||||
val preprocessed = mediaPreProcessor.process(avatarUri, MimeTypes.Jpeg, compressIfPossible = false).getOrThrow()
|
val preprocessed = mediaPreProcessor.process(
|
||||||
|
uri = avatarUri,
|
||||||
|
mimeType = MimeTypes.Jpeg,
|
||||||
|
deleteOriginal = false,
|
||||||
|
compressIfPossible = false,
|
||||||
|
).getOrThrow()
|
||||||
val byteArray = preprocessed.file.readBytes()
|
val byteArray = preprocessed.file.readBytes()
|
||||||
return matrixClient.uploadMedia(MimeTypes.Jpeg, byteArray, null).getOrThrow()
|
return matrixClient.uploadMedia(MimeTypes.Jpeg, byteArray, null).getOrThrow()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,5 +12,6 @@ import androidx.compose.runtime.Immutable
|
||||||
@Immutable
|
@Immutable
|
||||||
sealed interface AttachmentsPreviewEvents {
|
sealed interface AttachmentsPreviewEvents {
|
||||||
data object SendAttachment : AttachmentsPreviewEvents
|
data object SendAttachment : AttachmentsPreviewEvents
|
||||||
|
data object Cancel : AttachmentsPreviewEvents
|
||||||
data object ClearSendState : AttachmentsPreviewEvents
|
data object ClearSendState : AttachmentsPreviewEvents
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,14 @@ class AttachmentsPreviewNode @AssistedInject constructor(
|
||||||
|
|
||||||
private val inputs: Inputs = inputs()
|
private val inputs: Inputs = inputs()
|
||||||
|
|
||||||
private val presenter = presenterFactory.create(inputs.attachment)
|
private val onDoneListener = OnDoneListener {
|
||||||
|
navigateUp()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val presenter = presenterFactory.create(
|
||||||
|
attachment = inputs.attachment,
|
||||||
|
onDoneListener = onDoneListener,
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
|
|
@ -39,7 +46,6 @@ class AttachmentsPreviewNode @AssistedInject constructor(
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
AttachmentsPreviewView(
|
AttachmentsPreviewView(
|
||||||
state = state,
|
state = state,
|
||||||
onDismiss = this::navigateUp,
|
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import io.element.android.features.messages.impl.attachments.Attachment
|
import io.element.android.features.messages.impl.attachments.Attachment
|
||||||
|
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.matrix.api.core.ProgressCallback
|
import io.element.android.libraries.matrix.api.core.ProgressCallback
|
||||||
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
|
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
|
||||||
|
|
@ -34,12 +35,17 @@ import kotlin.coroutines.coroutineContext
|
||||||
|
|
||||||
class AttachmentsPreviewPresenter @AssistedInject constructor(
|
class AttachmentsPreviewPresenter @AssistedInject constructor(
|
||||||
@Assisted private val attachment: Attachment,
|
@Assisted private val attachment: Attachment,
|
||||||
|
@Assisted private val onDoneListener: OnDoneListener,
|
||||||
private val mediaSender: MediaSender,
|
private val mediaSender: MediaSender,
|
||||||
private val permalinkBuilder: PermalinkBuilder,
|
private val permalinkBuilder: PermalinkBuilder,
|
||||||
|
private val temporaryUriDeleter: TemporaryUriDeleter,
|
||||||
) : Presenter<AttachmentsPreviewState> {
|
) : Presenter<AttachmentsPreviewState> {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
interface Factory {
|
interface Factory {
|
||||||
fun create(attachment: Attachment): AttachmentsPreviewPresenter
|
fun create(
|
||||||
|
attachment: Attachment,
|
||||||
|
onDoneListener: OnDoneListener,
|
||||||
|
): AttachmentsPreviewPresenter
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -68,6 +74,9 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
|
||||||
sendActionState = sendActionState,
|
sendActionState = sendActionState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
AttachmentsPreviewEvents.Cancel -> {
|
||||||
|
coroutineScope.cancel(attachment)
|
||||||
|
}
|
||||||
AttachmentsPreviewEvents.ClearSendState -> {
|
AttachmentsPreviewEvents.ClearSendState -> {
|
||||||
ongoingSendAttachmentJob.value?.let {
|
ongoingSendAttachmentJob.value?.let {
|
||||||
it.cancel()
|
it.cancel()
|
||||||
|
|
@ -102,6 +111,18 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun CoroutineScope.cancel(
|
||||||
|
attachment: Attachment,
|
||||||
|
) = launch {
|
||||||
|
// Delete the temporary file
|
||||||
|
when (attachment) {
|
||||||
|
is Attachment.Media -> {
|
||||||
|
temporaryUriDeleter.delete(attachment.localMedia.uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onDoneListener()
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun sendMedia(
|
private suspend fun sendMedia(
|
||||||
mediaAttachment: Attachment.Media,
|
mediaAttachment: Attachment.Media,
|
||||||
caption: String?,
|
caption: String?,
|
||||||
|
|
@ -124,7 +145,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
|
||||||
).getOrThrow()
|
).getOrThrow()
|
||||||
}.fold(
|
}.fold(
|
||||||
onSuccess = {
|
onSuccess = {
|
||||||
sendActionState.value = SendActionState.Done
|
onDoneListener()
|
||||||
},
|
},
|
||||||
onFailure = { error ->
|
onFailure = { error ->
|
||||||
Timber.e(error, "Failed to send attachment")
|
Timber.e(error, "Failed to send attachment")
|
||||||
|
|
|
||||||
|
|
@ -36,5 +36,4 @@ sealed interface SendActionState {
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Failure(val error: Throwable) : SendActionState
|
data class Failure(val error: Throwable) : SendActionState
|
||||||
data object Done : SendActionState
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.attachments.preview
|
package io.element.android.features.messages.impl.attachments.preview
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.IntrinsicSize
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
|
|
@ -17,9 +18,6 @@ import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
|
@ -50,22 +48,22 @@ import me.saket.telephoto.zoomable.rememberZoomableState
|
||||||
@Composable
|
@Composable
|
||||||
fun AttachmentsPreviewView(
|
fun AttachmentsPreviewView(
|
||||||
state: AttachmentsPreviewState,
|
state: AttachmentsPreviewState,
|
||||||
onDismiss: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
fun postSendAttachment() {
|
fun postSendAttachment() {
|
||||||
state.eventSink(AttachmentsPreviewEvents.SendAttachment)
|
state.eventSink(AttachmentsPreviewEvents.SendAttachment)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun postCancel() {
|
||||||
|
state.eventSink(AttachmentsPreviewEvents.Cancel)
|
||||||
|
}
|
||||||
|
|
||||||
fun postClearSendState() {
|
fun postClearSendState() {
|
||||||
state.eventSink(AttachmentsPreviewEvents.ClearSendState)
|
state.eventSink(AttachmentsPreviewEvents.ClearSendState)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.sendActionState is SendActionState.Done) {
|
BackHandler(enabled = state.sendActionState !is SendActionState.Sending) {
|
||||||
val latestOnDismiss by rememberUpdatedState(onDismiss)
|
postCancel()
|
||||||
LaunchedEffect(state.sendActionState) {
|
|
||||||
latestOnDismiss()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
|
@ -75,7 +73,7 @@ fun AttachmentsPreviewView(
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
BackButton(
|
BackButton(
|
||||||
imageVector = CompoundIcons.Close(),
|
imageVector = CompoundIcons.Close(),
|
||||||
onClick = onDismiss,
|
onClick = ::postCancel,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
title = {},
|
title = {},
|
||||||
|
|
@ -202,6 +200,5 @@ private fun AttachmentsPreviewBottomActions(
|
||||||
internal fun AttachmentsPreviewViewPreview(@PreviewParameter(AttachmentsPreviewStateProvider::class) state: AttachmentsPreviewState) = ElementPreviewDark {
|
internal fun AttachmentsPreviewViewPreview(@PreviewParameter(AttachmentsPreviewStateProvider::class) state: AttachmentsPreviewState) = ElementPreviewDark {
|
||||||
AttachmentsPreviewView(
|
AttachmentsPreviewView(
|
||||||
state = state,
|
state = state,
|
||||||
onDismiss = {},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.messages.impl.attachments.preview
|
||||||
|
|
||||||
|
fun interface OnDoneListener {
|
||||||
|
operator fun invoke()
|
||||||
|
}
|
||||||
|
|
@ -16,8 +16,10 @@ import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewEvents
|
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewEvents
|
||||||
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewPresenter
|
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewPresenter
|
||||||
|
import io.element.android.features.messages.impl.attachments.preview.OnDoneListener
|
||||||
import io.element.android.features.messages.impl.attachments.preview.SendActionState
|
import io.element.android.features.messages.impl.attachments.preview.SendActionState
|
||||||
import io.element.android.features.messages.impl.fixtures.aMediaAttachment
|
import io.element.android.features.messages.impl.fixtures.aMediaAttachment
|
||||||
|
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
|
||||||
import io.element.android.libraries.matrix.api.core.ProgressCallback
|
import io.element.android.libraries.matrix.api.core.ProgressCallback
|
||||||
import io.element.android.libraries.matrix.api.media.FileInfo
|
import io.element.android.libraries.matrix.api.media.FileInfo
|
||||||
import io.element.android.libraries.matrix.api.media.ImageInfo
|
import io.element.android.libraries.matrix.api.media.ImageInfo
|
||||||
|
|
@ -35,11 +37,13 @@ import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||||
import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia
|
import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia
|
||||||
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
|
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
|
import io.element.android.tests.testutils.fake.FakeTemporaryUriDeleter
|
||||||
import io.element.android.tests.testutils.lambda.any
|
import io.element.android.tests.testutils.lambda.any
|
||||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
import io.element.android.tests.testutils.lambda.value
|
import io.element.android.tests.testutils.lambda.value
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
@ -67,7 +71,11 @@ class AttachmentsPreviewPresenterTest {
|
||||||
),
|
),
|
||||||
sendFileResult = sendFileResult,
|
sendFileResult = sendFileResult,
|
||||||
)
|
)
|
||||||
val presenter = createAttachmentsPreviewPresenter(room = room)
|
val onDoneListener = lambdaRecorder<Unit> { }
|
||||||
|
val presenter = createAttachmentsPreviewPresenter(
|
||||||
|
room = room,
|
||||||
|
onDoneListener = { onDoneListener() },
|
||||||
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -78,9 +86,28 @@ class AttachmentsPreviewPresenterTest {
|
||||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(0f))
|
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(0f))
|
||||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(0.5f))
|
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(0.5f))
|
||||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(1f))
|
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(1f))
|
||||||
val successState = awaitItem()
|
advanceUntilIdle()
|
||||||
assertThat(successState.sendActionState).isEqualTo(SendActionState.Done)
|
|
||||||
sendFileResult.assertions().isCalledOnce()
|
sendFileResult.assertions().isCalledOnce()
|
||||||
|
onDoneListener.assertions().isCalledOnce()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - cancel scenario`() = runTest {
|
||||||
|
val onDoneListener = lambdaRecorder<Unit> { }
|
||||||
|
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||||
|
val presenter = createAttachmentsPreviewPresenter(
|
||||||
|
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||||
|
onDoneListener = { onDoneListener() },
|
||||||
|
)
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
|
||||||
|
initialState.eventSink(AttachmentsPreviewEvents.Cancel)
|
||||||
|
deleteCallback.assertions().isCalledOnce()
|
||||||
|
onDoneListener.assertions().isCalledOnce()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,9 +123,11 @@ class AttachmentsPreviewPresenterTest {
|
||||||
val room = FakeMatrixRoom(
|
val room = FakeMatrixRoom(
|
||||||
sendImageResult = sendImageResult,
|
sendImageResult = sendImageResult,
|
||||||
)
|
)
|
||||||
|
val onDoneListener = lambdaRecorder<Unit> { }
|
||||||
val presenter = createAttachmentsPreviewPresenter(
|
val presenter = createAttachmentsPreviewPresenter(
|
||||||
room = room,
|
room = room,
|
||||||
mediaPreProcessor = mediaPreProcessor,
|
mediaPreProcessor = mediaPreProcessor,
|
||||||
|
onDoneListener = { onDoneListener() },
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -108,8 +137,7 @@ class AttachmentsPreviewPresenterTest {
|
||||||
initialState.textEditorState.setMarkdown(A_CAPTION)
|
initialState.textEditorState.setMarkdown(A_CAPTION)
|
||||||
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
|
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
|
||||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
|
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
|
||||||
val successState = awaitItem()
|
advanceUntilIdle()
|
||||||
assertThat(successState.sendActionState).isEqualTo(SendActionState.Done)
|
|
||||||
sendImageResult.assertions().isCalledOnce().with(
|
sendImageResult.assertions().isCalledOnce().with(
|
||||||
any(),
|
any(),
|
||||||
any(),
|
any(),
|
||||||
|
|
@ -118,6 +146,7 @@ class AttachmentsPreviewPresenterTest {
|
||||||
any(),
|
any(),
|
||||||
any(),
|
any(),
|
||||||
)
|
)
|
||||||
|
onDoneListener.assertions().isCalledOnce()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -133,9 +162,11 @@ class AttachmentsPreviewPresenterTest {
|
||||||
val room = FakeMatrixRoom(
|
val room = FakeMatrixRoom(
|
||||||
sendVideoResult = sendVideoResult,
|
sendVideoResult = sendVideoResult,
|
||||||
)
|
)
|
||||||
|
val onDoneListener = lambdaRecorder<Unit> { }
|
||||||
val presenter = createAttachmentsPreviewPresenter(
|
val presenter = createAttachmentsPreviewPresenter(
|
||||||
room = room,
|
room = room,
|
||||||
mediaPreProcessor = mediaPreProcessor,
|
mediaPreProcessor = mediaPreProcessor,
|
||||||
|
onDoneListener = { onDoneListener() },
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -145,8 +176,7 @@ class AttachmentsPreviewPresenterTest {
|
||||||
initialState.textEditorState.setMarkdown(A_CAPTION)
|
initialState.textEditorState.setMarkdown(A_CAPTION)
|
||||||
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
|
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
|
||||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
|
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
|
||||||
val successState = awaitItem()
|
advanceUntilIdle()
|
||||||
assertThat(successState.sendActionState).isEqualTo(SendActionState.Done)
|
|
||||||
sendVideoResult.assertions().isCalledOnce().with(
|
sendVideoResult.assertions().isCalledOnce().with(
|
||||||
any(),
|
any(),
|
||||||
any(),
|
any(),
|
||||||
|
|
@ -155,6 +185,7 @@ class AttachmentsPreviewPresenterTest {
|
||||||
any(),
|
any(),
|
||||||
any(),
|
any(),
|
||||||
)
|
)
|
||||||
|
onDoneListener.assertions().isCalledOnce()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -207,11 +238,15 @@ class AttachmentsPreviewPresenterTest {
|
||||||
room: MatrixRoom = FakeMatrixRoom(),
|
room: MatrixRoom = FakeMatrixRoom(),
|
||||||
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(),
|
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(),
|
||||||
mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor(),
|
mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor(),
|
||||||
|
temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(),
|
||||||
|
onDoneListener: OnDoneListener = OnDoneListener {},
|
||||||
): AttachmentsPreviewPresenter {
|
): AttachmentsPreviewPresenter {
|
||||||
return AttachmentsPreviewPresenter(
|
return AttachmentsPreviewPresenter(
|
||||||
attachment = aMediaAttachment(localMedia),
|
attachment = aMediaAttachment(localMedia),
|
||||||
|
onDoneListener = onDoneListener,
|
||||||
mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()),
|
mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()),
|
||||||
permalinkBuilder = permalinkBuilder,
|
permalinkBuilder = permalinkBuilder,
|
||||||
|
temporaryUriDeleter = temporaryUriDeleter,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import androidx.core.net.toUri
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
|
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
|
||||||
import io.element.android.libraries.architecture.AsyncAction
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
||||||
|
|
@ -43,6 +44,7 @@ class EditUserProfilePresenter @AssistedInject constructor(
|
||||||
private val matrixClient: MatrixClient,
|
private val matrixClient: MatrixClient,
|
||||||
private val mediaPickerProvider: PickerProvider,
|
private val mediaPickerProvider: PickerProvider,
|
||||||
private val mediaPreProcessor: MediaPreProcessor,
|
private val mediaPreProcessor: MediaPreProcessor,
|
||||||
|
private val temporaryUriDeleter: TemporaryUriDeleter,
|
||||||
permissionsPresenterFactory: PermissionsPresenter.Factory,
|
permissionsPresenterFactory: PermissionsPresenter.Factory,
|
||||||
) : Presenter<EditUserProfileState> {
|
) : Presenter<EditUserProfileState> {
|
||||||
private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
|
private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
|
||||||
|
|
@ -59,10 +61,20 @@ class EditUserProfilePresenter @AssistedInject constructor(
|
||||||
var userAvatarUri by rememberSaveable { mutableStateOf(matrixUser.avatarUrl?.let { Uri.parse(it) }) }
|
var userAvatarUri by rememberSaveable { mutableStateOf(matrixUser.avatarUrl?.let { Uri.parse(it) }) }
|
||||||
var userDisplayName by rememberSaveable { mutableStateOf(matrixUser.displayName) }
|
var userDisplayName by rememberSaveable { mutableStateOf(matrixUser.displayName) }
|
||||||
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(
|
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(
|
||||||
onResult = { uri -> if (uri != null) userAvatarUri = uri }
|
onResult = { uri ->
|
||||||
|
if (uri != null) {
|
||||||
|
temporaryUriDeleter.delete(userAvatarUri)
|
||||||
|
userAvatarUri = uri
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
val galleryImagePicker = mediaPickerProvider.registerGalleryImagePicker(
|
val galleryImagePicker = mediaPickerProvider.registerGalleryImagePicker(
|
||||||
onResult = { uri -> if (uri != null) userAvatarUri = uri }
|
onResult = { uri ->
|
||||||
|
if (uri != null) {
|
||||||
|
temporaryUriDeleter.delete(userAvatarUri)
|
||||||
|
userAvatarUri = uri
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
val avatarActions by remember(userAvatarUri) {
|
val avatarActions by remember(userAvatarUri) {
|
||||||
|
|
@ -96,7 +108,10 @@ class EditUserProfilePresenter @AssistedInject constructor(
|
||||||
pendingPermissionRequest = true
|
pendingPermissionRequest = true
|
||||||
cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions)
|
cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions)
|
||||||
}
|
}
|
||||||
AvatarAction.Remove -> userAvatarUri = null
|
AvatarAction.Remove -> {
|
||||||
|
temporaryUriDeleter.delete(userAvatarUri)
|
||||||
|
userAvatarUri = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -155,7 +170,12 @@ class EditUserProfilePresenter @AssistedInject constructor(
|
||||||
private suspend fun updateAvatar(avatarUri: Uri?): Result<Unit> {
|
private suspend fun updateAvatar(avatarUri: Uri?): Result<Unit> {
|
||||||
return runCatching {
|
return runCatching {
|
||||||
if (avatarUri != null) {
|
if (avatarUri != null) {
|
||||||
val preprocessed = mediaPreProcessor.process(avatarUri, MimeTypes.Jpeg, compressIfPossible = false).getOrThrow()
|
val preprocessed = mediaPreProcessor.process(
|
||||||
|
uri = avatarUri,
|
||||||
|
mimeType = MimeTypes.Jpeg,
|
||||||
|
deleteOriginal = false,
|
||||||
|
compressIfPossible = false,
|
||||||
|
).getOrThrow()
|
||||||
matrixClient.uploadAvatar(MimeTypes.Jpeg, preprocessed.file.readBytes()).getOrThrow()
|
matrixClient.uploadAvatar(MimeTypes.Jpeg, preprocessed.file.readBytes()).getOrThrow()
|
||||||
} else {
|
} else {
|
||||||
matrixClient.removeAvatar().getOrThrow()
|
matrixClient.removeAvatar().getOrThrow()
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import app.cash.molecule.RecompositionMode
|
||||||
import app.cash.molecule.moleculeFlow
|
import app.cash.molecule.moleculeFlow
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
|
||||||
import io.element.android.libraries.architecture.AsyncAction
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
|
@ -29,6 +30,9 @@ import io.element.android.libraries.permissions.test.FakePermissionsPresenterFac
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
||||||
import io.element.android.tests.testutils.consumeItemsUntilTimeout
|
import io.element.android.tests.testutils.consumeItemsUntilTimeout
|
||||||
|
import io.element.android.tests.testutils.fake.FakeTemporaryUriDeleter
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
|
import io.element.android.tests.testutils.lambda.value
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import io.mockk.mockkStatic
|
import io.mockk.mockkStatic
|
||||||
|
|
@ -73,12 +77,14 @@ class EditUserProfilePresenterTest {
|
||||||
matrixClient: MatrixClient = FakeMatrixClient(),
|
matrixClient: MatrixClient = FakeMatrixClient(),
|
||||||
matrixUser: MatrixUser = aMatrixUser(),
|
matrixUser: MatrixUser = aMatrixUser(),
|
||||||
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
|
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
|
||||||
|
temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(),
|
||||||
): EditUserProfilePresenter {
|
): EditUserProfilePresenter {
|
||||||
return EditUserProfilePresenter(
|
return EditUserProfilePresenter(
|
||||||
matrixClient = matrixClient,
|
matrixClient = matrixClient,
|
||||||
matrixUser = matrixUser,
|
matrixUser = matrixUser,
|
||||||
mediaPickerProvider = fakePickerProvider,
|
mediaPickerProvider = fakePickerProvider,
|
||||||
mediaPreProcessor = fakeMediaPreProcessor,
|
mediaPreProcessor = fakeMediaPreProcessor,
|
||||||
|
temporaryUriDeleter = temporaryUriDeleter,
|
||||||
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),
|
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -107,7 +113,12 @@ class EditUserProfilePresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `present - updates state in response to changes`() = runTest {
|
fun `present - updates state in response to changes`() = runTest {
|
||||||
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
|
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
|
||||||
val presenter = createEditUserProfilePresenter(matrixUser = user)
|
val presenter = createEditUserProfilePresenter(
|
||||||
|
matrixUser = user,
|
||||||
|
temporaryUriDeleter = FakeTemporaryUriDeleter(
|
||||||
|
deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) }
|
||||||
|
),
|
||||||
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -136,7 +147,12 @@ class EditUserProfilePresenterTest {
|
||||||
fun `present - obtains avatar uris from gallery`() = runTest {
|
fun `present - obtains avatar uris from gallery`() = runTest {
|
||||||
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
|
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
|
||||||
fakePickerProvider.givenResult(anotherAvatarUri)
|
fakePickerProvider.givenResult(anotherAvatarUri)
|
||||||
val presenter = createEditUserProfilePresenter(matrixUser = user)
|
val presenter = createEditUserProfilePresenter(
|
||||||
|
matrixUser = user,
|
||||||
|
temporaryUriDeleter = FakeTemporaryUriDeleter(
|
||||||
|
deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) }
|
||||||
|
),
|
||||||
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -154,9 +170,13 @@ class EditUserProfilePresenterTest {
|
||||||
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
|
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
|
||||||
fakePickerProvider.givenResult(anotherAvatarUri)
|
fakePickerProvider.givenResult(anotherAvatarUri)
|
||||||
val fakePermissionsPresenter = FakePermissionsPresenter()
|
val fakePermissionsPresenter = FakePermissionsPresenter()
|
||||||
|
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||||
val presenter = createEditUserProfilePresenter(
|
val presenter = createEditUserProfilePresenter(
|
||||||
matrixUser = user,
|
matrixUser = user,
|
||||||
permissionsPresenter = fakePermissionsPresenter,
|
permissionsPresenter = fakePermissionsPresenter,
|
||||||
|
temporaryUriDeleter = FakeTemporaryUriDeleter(
|
||||||
|
deleteLambda = deleteCallback,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -177,6 +197,10 @@ class EditUserProfilePresenterTest {
|
||||||
stateWithNewAvatar.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.TakePhoto))
|
stateWithNewAvatar.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.TakePhoto))
|
||||||
val stateWithNewAvatar2 = awaitItem()
|
val stateWithNewAvatar2 = awaitItem()
|
||||||
assertThat(stateWithNewAvatar2.userAvatarUrl).isEqualTo(userAvatarUri)
|
assertThat(stateWithNewAvatar2.userAvatarUrl).isEqualTo(userAvatarUri)
|
||||||
|
deleteCallback.assertions().isCalledExactly(2).withSequence(
|
||||||
|
listOf(value(userAvatarUri)),
|
||||||
|
listOf(value(anotherAvatarUri)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -184,7 +208,13 @@ class EditUserProfilePresenterTest {
|
||||||
fun `present - updates save button state`() = runTest {
|
fun `present - updates save button state`() = runTest {
|
||||||
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
|
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
|
||||||
fakePickerProvider.givenResult(userAvatarUri)
|
fakePickerProvider.givenResult(userAvatarUri)
|
||||||
val presenter = createEditUserProfilePresenter(matrixUser = user)
|
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||||
|
val presenter = createEditUserProfilePresenter(
|
||||||
|
matrixUser = user,
|
||||||
|
temporaryUriDeleter = FakeTemporaryUriDeleter(
|
||||||
|
deleteLambda = deleteCallback
|
||||||
|
),
|
||||||
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -210,6 +240,10 @@ class EditUserProfilePresenterTest {
|
||||||
awaitItem().apply {
|
awaitItem().apply {
|
||||||
assertThat(saveButtonEnabled).isFalse()
|
assertThat(saveButtonEnabled).isFalse()
|
||||||
}
|
}
|
||||||
|
deleteCallback.assertions().isCalledExactly(2).withSequence(
|
||||||
|
listOf(value(userAvatarUri)),
|
||||||
|
listOf(value(null)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -217,7 +251,13 @@ class EditUserProfilePresenterTest {
|
||||||
fun `present - updates save button state when initial values are null`() = runTest {
|
fun `present - updates save button state when initial values are null`() = runTest {
|
||||||
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = null)
|
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = null)
|
||||||
fakePickerProvider.givenResult(userAvatarUri)
|
fakePickerProvider.givenResult(userAvatarUri)
|
||||||
val presenter = createEditUserProfilePresenter(matrixUser = user)
|
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||||
|
val presenter = createEditUserProfilePresenter(
|
||||||
|
matrixUser = user,
|
||||||
|
temporaryUriDeleter = FakeTemporaryUriDeleter(
|
||||||
|
deleteLambda = deleteCallback
|
||||||
|
),
|
||||||
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -243,6 +283,10 @@ class EditUserProfilePresenterTest {
|
||||||
awaitItem().apply {
|
awaitItem().apply {
|
||||||
assertThat(saveButtonEnabled).isFalse()
|
assertThat(saveButtonEnabled).isFalse()
|
||||||
}
|
}
|
||||||
|
deleteCallback.assertions().isCalledExactly(2).withSequence(
|
||||||
|
listOf(value(null)),
|
||||||
|
listOf(value(userAvatarUri)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -252,7 +296,10 @@ class EditUserProfilePresenterTest {
|
||||||
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
|
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
|
||||||
val presenter = createEditUserProfilePresenter(
|
val presenter = createEditUserProfilePresenter(
|
||||||
matrixClient = matrixClient,
|
matrixClient = matrixClient,
|
||||||
matrixUser = user
|
matrixUser = user,
|
||||||
|
temporaryUriDeleter = FakeTemporaryUriDeleter(
|
||||||
|
deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) }
|
||||||
|
),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -318,7 +365,10 @@ class EditUserProfilePresenterTest {
|
||||||
givenPickerReturnsFile()
|
givenPickerReturnsFile()
|
||||||
val presenter = createEditUserProfilePresenter(
|
val presenter = createEditUserProfilePresenter(
|
||||||
matrixClient = matrixClient,
|
matrixClient = matrixClient,
|
||||||
matrixUser = user
|
matrixUser = user,
|
||||||
|
temporaryUriDeleter = FakeTemporaryUriDeleter(
|
||||||
|
deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) }
|
||||||
|
),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -337,7 +387,10 @@ class EditUserProfilePresenterTest {
|
||||||
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
|
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
|
||||||
val presenter = createEditUserProfilePresenter(
|
val presenter = createEditUserProfilePresenter(
|
||||||
matrixClient = matrixClient,
|
matrixClient = matrixClient,
|
||||||
matrixUser = user
|
matrixUser = user,
|
||||||
|
temporaryUriDeleter = FakeTemporaryUriDeleter(
|
||||||
|
deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) }
|
||||||
|
),
|
||||||
)
|
)
|
||||||
fakePickerProvider.givenResult(anotherAvatarUri)
|
fakePickerProvider.givenResult(anotherAvatarUri)
|
||||||
fakeMediaPreProcessor.givenResult(Result.failure(Throwable("Oh no")))
|
fakeMediaPreProcessor.givenResult(Result.failure(Throwable("Oh no")))
|
||||||
|
|
@ -403,7 +456,13 @@ class EditUserProfilePresenterTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun saveAndAssertFailure(matrixUser: MatrixUser, matrixClient: MatrixClient, event: EditUserProfileEvents) {
|
private suspend fun saveAndAssertFailure(matrixUser: MatrixUser, matrixClient: MatrixClient, event: EditUserProfileEvents) {
|
||||||
val presenter = createEditUserProfilePresenter(matrixUser = matrixUser, matrixClient = matrixClient)
|
val presenter = createEditUserProfilePresenter(
|
||||||
|
matrixUser = matrixUser,
|
||||||
|
matrixClient = matrixClient,
|
||||||
|
temporaryUriDeleter = FakeTemporaryUriDeleter(
|
||||||
|
deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) }
|
||||||
|
),
|
||||||
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
|
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
|
||||||
import io.element.android.libraries.architecture.AsyncAction
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
||||||
|
|
@ -45,6 +46,7 @@ class RoomDetailsEditPresenter @Inject constructor(
|
||||||
private val room: MatrixRoom,
|
private val room: MatrixRoom,
|
||||||
private val mediaPickerProvider: PickerProvider,
|
private val mediaPickerProvider: PickerProvider,
|
||||||
private val mediaPreProcessor: MediaPreProcessor,
|
private val mediaPreProcessor: MediaPreProcessor,
|
||||||
|
private val temporaryUriDeleter: TemporaryUriDeleter,
|
||||||
permissionsPresenterFactory: PermissionsPresenter.Factory,
|
permissionsPresenterFactory: PermissionsPresenter.Factory,
|
||||||
) : Presenter<RoomDetailsEditState> {
|
) : Presenter<RoomDetailsEditState> {
|
||||||
private val cameraPermissionPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
|
private val cameraPermissionPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA)
|
||||||
|
|
@ -59,6 +61,7 @@ class RoomDetailsEditPresenter @Inject constructor(
|
||||||
var roomAvatarUriEdited by rememberSaveable { mutableStateOf<Uri?>(null) }
|
var roomAvatarUriEdited by rememberSaveable { mutableStateOf<Uri?>(null) }
|
||||||
LaunchedEffect(roomAvatarUri) {
|
LaunchedEffect(roomAvatarUri) {
|
||||||
// Every time the roomAvatar change (from sync), we can set the new avatar.
|
// Every time the roomAvatar change (from sync), we can set the new avatar.
|
||||||
|
temporaryUriDeleter.delete(roomAvatarUriEdited)
|
||||||
roomAvatarUriEdited = roomAvatarUri
|
roomAvatarUriEdited = roomAvatarUri
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,10 +101,20 @@ class RoomDetailsEditPresenter @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(
|
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(
|
||||||
onResult = { uri -> if (uri != null) roomAvatarUriEdited = uri }
|
onResult = { uri ->
|
||||||
|
if (uri != null) {
|
||||||
|
temporaryUriDeleter.delete(roomAvatarUriEdited)
|
||||||
|
roomAvatarUriEdited = uri
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
val galleryImagePicker = mediaPickerProvider.registerGalleryImagePicker(
|
val galleryImagePicker = mediaPickerProvider.registerGalleryImagePicker(
|
||||||
onResult = { uri -> if (uri != null) roomAvatarUriEdited = uri }
|
onResult = { uri ->
|
||||||
|
if (uri != null) {
|
||||||
|
temporaryUriDeleter.delete(roomAvatarUriEdited)
|
||||||
|
roomAvatarUriEdited = uri
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
LaunchedEffect(cameraPermissionState.permissionGranted) {
|
LaunchedEffect(cameraPermissionState.permissionGranted) {
|
||||||
|
|
@ -143,7 +156,10 @@ class RoomDetailsEditPresenter @Inject constructor(
|
||||||
pendingPermissionRequest = true
|
pendingPermissionRequest = true
|
||||||
cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions)
|
cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions)
|
||||||
}
|
}
|
||||||
AvatarAction.Remove -> roomAvatarUriEdited = null
|
AvatarAction.Remove -> {
|
||||||
|
temporaryUriDeleter.delete(roomAvatarUriEdited)
|
||||||
|
roomAvatarUriEdited = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -202,7 +218,12 @@ class RoomDetailsEditPresenter @Inject constructor(
|
||||||
private suspend fun updateAvatar(avatarUri: Uri?): Result<Unit> {
|
private suspend fun updateAvatar(avatarUri: Uri?): Result<Unit> {
|
||||||
return runCatching {
|
return runCatching {
|
||||||
if (avatarUri != null) {
|
if (avatarUri != null) {
|
||||||
val preprocessed = mediaPreProcessor.process(avatarUri, MimeTypes.Jpeg, compressIfPossible = false).getOrThrow()
|
val preprocessed = mediaPreProcessor.process(
|
||||||
|
uri = avatarUri,
|
||||||
|
mimeType = MimeTypes.Jpeg,
|
||||||
|
deleteOriginal = false,
|
||||||
|
compressIfPossible = false,
|
||||||
|
).getOrThrow()
|
||||||
room.updateAvatar(MimeTypes.Jpeg, preprocessed.file.readBytes()).getOrThrow()
|
room.updateAvatar(MimeTypes.Jpeg, preprocessed.file.readBytes()).getOrThrow()
|
||||||
} else {
|
} else {
|
||||||
room.removeAvatar().getOrThrow()
|
room.removeAvatar().getOrThrow()
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,12 @@
|
||||||
package io.element.android.features.roomdetails.edit
|
package io.element.android.features.roomdetails.edit
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import app.cash.molecule.RecompositionMode
|
|
||||||
import app.cash.molecule.moleculeFlow
|
|
||||||
import app.cash.turbine.ReceiveTurbine
|
import app.cash.turbine.ReceiveTurbine
|
||||||
import app.cash.turbine.test
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.roomdetails.aMatrixRoom
|
import io.element.android.features.roomdetails.aMatrixRoom
|
||||||
import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditEvents
|
import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditEvents
|
||||||
import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditPresenter
|
import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditPresenter
|
||||||
|
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
|
||||||
import io.element.android.libraries.architecture.AsyncAction
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||||
|
|
@ -31,9 +29,11 @@ import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
||||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
|
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
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.lambdaError
|
||||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
import io.element.android.tests.testutils.lambda.value
|
import io.element.android.tests.testutils.lambda.value
|
||||||
|
import io.element.android.tests.testutils.test
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import io.mockk.mockkStatic
|
import io.mockk.mockkStatic
|
||||||
|
|
@ -46,6 +46,7 @@ import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
|
@Suppress("LargeClass")
|
||||||
@ExperimentalCoroutinesApi
|
@ExperimentalCoroutinesApi
|
||||||
class RoomDetailsEditPresenterTest {
|
class RoomDetailsEditPresenterTest {
|
||||||
@get:Rule
|
@get:Rule
|
||||||
|
|
@ -77,12 +78,14 @@ class RoomDetailsEditPresenterTest {
|
||||||
private fun createRoomDetailsEditPresenter(
|
private fun createRoomDetailsEditPresenter(
|
||||||
room: MatrixRoom,
|
room: MatrixRoom,
|
||||||
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
|
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
|
||||||
|
temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(),
|
||||||
): RoomDetailsEditPresenter {
|
): RoomDetailsEditPresenter {
|
||||||
return RoomDetailsEditPresenter(
|
return RoomDetailsEditPresenter(
|
||||||
room = room,
|
room = room,
|
||||||
mediaPickerProvider = fakePickerProvider,
|
mediaPickerProvider = fakePickerProvider,
|
||||||
mediaPreProcessor = fakeMediaPreProcessor,
|
mediaPreProcessor = fakeMediaPreProcessor,
|
||||||
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),
|
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),
|
||||||
|
temporaryUriDeleter = temporaryUriDeleter,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -95,10 +98,12 @@ class RoomDetailsEditPresenterTest {
|
||||||
emitRoomInfo = true,
|
emitRoomInfo = true,
|
||||||
canSendStateResult = { _, _ -> Result.success(true) }
|
canSendStateResult = { _, _ -> Result.success(true) }
|
||||||
)
|
)
|
||||||
val presenter = createRoomDetailsEditPresenter(room)
|
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
val presenter = createRoomDetailsEditPresenter(
|
||||||
presenter.present()
|
room = room,
|
||||||
}.test {
|
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
val initialState = awaitFirstItem()
|
val initialState = awaitFirstItem()
|
||||||
assertThat(initialState.roomId).isEqualTo(room.roomId)
|
assertThat(initialState.roomId).isEqualTo(room.roomId)
|
||||||
assertThat(initialState.roomRawName).isEqualTo(A_ROOM_RAW_NAME)
|
assertThat(initialState.roomRawName).isEqualTo(A_ROOM_RAW_NAME)
|
||||||
|
|
@ -127,10 +132,12 @@ class RoomDetailsEditPresenterTest {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
val presenter = createRoomDetailsEditPresenter(room)
|
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
val presenter = createRoomDetailsEditPresenter(
|
||||||
presenter.present()
|
room = room,
|
||||||
}.test {
|
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
// Initially false
|
// Initially false
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.canChangeName).isFalse()
|
assertThat(initialState.canChangeName).isFalse()
|
||||||
|
|
@ -141,6 +148,7 @@ class RoomDetailsEditPresenterTest {
|
||||||
assertThat(settledState.canChangeName).isTrue()
|
assertThat(settledState.canChangeName).isTrue()
|
||||||
assertThat(settledState.canChangeAvatar).isFalse()
|
assertThat(settledState.canChangeAvatar).isFalse()
|
||||||
assertThat(settledState.canChangeTopic).isFalse()
|
assertThat(settledState.canChangeTopic).isFalse()
|
||||||
|
deleteCallback.assertions().isCalledOnce().with(value(null))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -157,10 +165,12 @@ class RoomDetailsEditPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
val presenter = createRoomDetailsEditPresenter(room)
|
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
val presenter = createRoomDetailsEditPresenter(
|
||||||
presenter.present()
|
room = room,
|
||||||
}.test {
|
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
// Initially false
|
// Initially false
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.canChangeName).isFalse()
|
assertThat(initialState.canChangeName).isFalse()
|
||||||
|
|
@ -187,10 +197,12 @@ class RoomDetailsEditPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
val presenter = createRoomDetailsEditPresenter(room)
|
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
val presenter = createRoomDetailsEditPresenter(
|
||||||
presenter.present()
|
room = room,
|
||||||
}.test {
|
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
// Initially false
|
// Initially false
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.canChangeName).isFalse()
|
assertThat(initialState.canChangeName).isFalse()
|
||||||
|
|
@ -213,10 +225,12 @@ class RoomDetailsEditPresenterTest {
|
||||||
emitRoomInfo = true,
|
emitRoomInfo = true,
|
||||||
canSendStateResult = { _, _ -> Result.success(true) }
|
canSendStateResult = { _, _ -> Result.success(true) }
|
||||||
)
|
)
|
||||||
val presenter = createRoomDetailsEditPresenter(room)
|
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
val presenter = createRoomDetailsEditPresenter(
|
||||||
presenter.present()
|
room = room,
|
||||||
}.test {
|
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
val initialState = awaitFirstItem()
|
val initialState = awaitFirstItem()
|
||||||
assertThat(initialState.roomTopic).isEqualTo("My topic")
|
assertThat(initialState.roomTopic).isEqualTo("My topic")
|
||||||
assertThat(initialState.roomRawName).isEqualTo("Name")
|
assertThat(initialState.roomRawName).isEqualTo("Name")
|
||||||
|
|
@ -258,10 +272,12 @@ class RoomDetailsEditPresenterTest {
|
||||||
canSendStateResult = { _, _ -> Result.success(true) }
|
canSendStateResult = { _, _ -> Result.success(true) }
|
||||||
)
|
)
|
||||||
fakePickerProvider.givenResult(anotherAvatarUri)
|
fakePickerProvider.givenResult(anotherAvatarUri)
|
||||||
val presenter = createRoomDetailsEditPresenter(room)
|
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
val presenter = createRoomDetailsEditPresenter(
|
||||||
presenter.present()
|
room = room,
|
||||||
}.test {
|
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
val initialState = awaitFirstItem()
|
val initialState = awaitFirstItem()
|
||||||
assertThat(initialState.roomAvatarUrl).isEqualTo(roomAvatarUri)
|
assertThat(initialState.roomAvatarUrl).isEqualTo(roomAvatarUri)
|
||||||
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
|
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
|
||||||
|
|
@ -282,13 +298,13 @@ class RoomDetailsEditPresenterTest {
|
||||||
)
|
)
|
||||||
fakePickerProvider.givenResult(anotherAvatarUri)
|
fakePickerProvider.givenResult(anotherAvatarUri)
|
||||||
val fakePermissionsPresenter = FakePermissionsPresenter()
|
val fakePermissionsPresenter = FakePermissionsPresenter()
|
||||||
|
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||||
val presenter = createRoomDetailsEditPresenter(
|
val presenter = createRoomDetailsEditPresenter(
|
||||||
room = room,
|
room = room,
|
||||||
permissionsPresenter = fakePermissionsPresenter,
|
permissionsPresenter = fakePermissionsPresenter,
|
||||||
|
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
presenter.test {
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
val initialState = awaitFirstItem()
|
val initialState = awaitFirstItem()
|
||||||
assertThat(initialState.roomAvatarUrl).isEqualTo(roomAvatarUri)
|
assertThat(initialState.roomAvatarUrl).isEqualTo(roomAvatarUri)
|
||||||
assertThat(initialState.cameraPermissionState.permissionGranted).isFalse()
|
assertThat(initialState.cameraPermissionState.permissionGranted).isFalse()
|
||||||
|
|
@ -305,6 +321,12 @@ class RoomDetailsEditPresenterTest {
|
||||||
stateWithNewAvatar.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.TakePhoto))
|
stateWithNewAvatar.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.TakePhoto))
|
||||||
val stateWithNewAvatar2 = awaitItem()
|
val stateWithNewAvatar2 = awaitItem()
|
||||||
assertThat(stateWithNewAvatar2.roomAvatarUrl).isEqualTo(roomAvatarUri)
|
assertThat(stateWithNewAvatar2.roomAvatarUrl).isEqualTo(roomAvatarUri)
|
||||||
|
deleteCallback.assertions().isCalledExactly(4).withSequence(
|
||||||
|
listOf(value(null)),
|
||||||
|
listOf(value(null)),
|
||||||
|
listOf(value(roomAvatarUri)),
|
||||||
|
listOf(value(anotherAvatarUri)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -318,10 +340,12 @@ class RoomDetailsEditPresenterTest {
|
||||||
canSendStateResult = { _, _ -> Result.success(true) }
|
canSendStateResult = { _, _ -> Result.success(true) }
|
||||||
)
|
)
|
||||||
fakePickerProvider.givenResult(roomAvatarUri)
|
fakePickerProvider.givenResult(roomAvatarUri)
|
||||||
val presenter = createRoomDetailsEditPresenter(room)
|
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
val presenter = createRoomDetailsEditPresenter(
|
||||||
presenter.present()
|
room = room,
|
||||||
}.test {
|
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
val initialState = awaitFirstItem()
|
val initialState = awaitFirstItem()
|
||||||
assertThat(initialState.saveButtonEnabled).isFalse()
|
assertThat(initialState.saveButtonEnabled).isFalse()
|
||||||
// Once a change is made, the save button is enabled
|
// Once a change is made, the save button is enabled
|
||||||
|
|
@ -367,10 +391,12 @@ class RoomDetailsEditPresenterTest {
|
||||||
canSendStateResult = { _, _ -> Result.success(true) }
|
canSendStateResult = { _, _ -> Result.success(true) }
|
||||||
)
|
)
|
||||||
fakePickerProvider.givenResult(roomAvatarUri)
|
fakePickerProvider.givenResult(roomAvatarUri)
|
||||||
val presenter = createRoomDetailsEditPresenter(room)
|
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
val presenter = createRoomDetailsEditPresenter(
|
||||||
presenter.present()
|
room = room,
|
||||||
}.test {
|
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
val initialState = awaitFirstItem()
|
val initialState = awaitFirstItem()
|
||||||
assertThat(initialState.saveButtonEnabled).isFalse()
|
assertThat(initialState.saveButtonEnabled).isFalse()
|
||||||
// Once a change is made, the save button is enabled
|
// Once a change is made, the save button is enabled
|
||||||
|
|
@ -421,10 +447,12 @@ class RoomDetailsEditPresenterTest {
|
||||||
removeAvatarResult = removeAvatarResult,
|
removeAvatarResult = removeAvatarResult,
|
||||||
canSendStateResult = { _, _ -> Result.success(true) }
|
canSendStateResult = { _, _ -> Result.success(true) }
|
||||||
)
|
)
|
||||||
val presenter = createRoomDetailsEditPresenter(room)
|
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
val presenter = createRoomDetailsEditPresenter(
|
||||||
presenter.present()
|
room = room,
|
||||||
}.test {
|
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
val initialState = awaitFirstItem()
|
val initialState = awaitFirstItem()
|
||||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("New name"))
|
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("New name"))
|
||||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("New topic"))
|
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("New topic"))
|
||||||
|
|
@ -445,10 +473,12 @@ class RoomDetailsEditPresenterTest {
|
||||||
avatarUrl = AN_AVATAR_URL,
|
avatarUrl = AN_AVATAR_URL,
|
||||||
canSendStateResult = { _, _ -> Result.success(true) }
|
canSendStateResult = { _, _ -> Result.success(true) }
|
||||||
)
|
)
|
||||||
val presenter = createRoomDetailsEditPresenter(room)
|
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
val presenter = createRoomDetailsEditPresenter(
|
||||||
presenter.present()
|
room = room,
|
||||||
}.test {
|
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName(" Name "))
|
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName(" Name "))
|
||||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic(" My topic "))
|
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic(" My topic "))
|
||||||
|
|
@ -465,14 +495,17 @@ class RoomDetailsEditPresenterTest {
|
||||||
avatarUrl = AN_AVATAR_URL,
|
avatarUrl = AN_AVATAR_URL,
|
||||||
canSendStateResult = { _, _ -> Result.success(true) }
|
canSendStateResult = { _, _ -> Result.success(true) }
|
||||||
)
|
)
|
||||||
val presenter = createRoomDetailsEditPresenter(room)
|
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
val presenter = createRoomDetailsEditPresenter(
|
||||||
presenter.present()
|
room = room,
|
||||||
}.test {
|
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic(""))
|
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic(""))
|
||||||
initialState.eventSink(RoomDetailsEditEvents.Save)
|
initialState.eventSink(RoomDetailsEditEvents.Save)
|
||||||
cancelAndIgnoreRemainingEvents()
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
deleteCallback.assertions().isCalledOnce().with(value(null))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -484,14 +517,17 @@ class RoomDetailsEditPresenterTest {
|
||||||
avatarUrl = AN_AVATAR_URL,
|
avatarUrl = AN_AVATAR_URL,
|
||||||
canSendStateResult = { _, _ -> Result.success(true) }
|
canSendStateResult = { _, _ -> Result.success(true) }
|
||||||
)
|
)
|
||||||
val presenter = createRoomDetailsEditPresenter(room)
|
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
val presenter = createRoomDetailsEditPresenter(
|
||||||
presenter.present()
|
room = room,
|
||||||
}.test {
|
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName(""))
|
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName(""))
|
||||||
initialState.eventSink(RoomDetailsEditEvents.Save)
|
initialState.eventSink(RoomDetailsEditEvents.Save)
|
||||||
cancelAndIgnoreRemainingEvents()
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
deleteCallback.assertions().isCalledOnce().with(value(null))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -506,15 +542,21 @@ class RoomDetailsEditPresenterTest {
|
||||||
canSendStateResult = { _, _ -> Result.success(true) }
|
canSendStateResult = { _, _ -> Result.success(true) }
|
||||||
)
|
)
|
||||||
givenPickerReturnsFile()
|
givenPickerReturnsFile()
|
||||||
val presenter = createRoomDetailsEditPresenter(room)
|
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
val presenter = createRoomDetailsEditPresenter(
|
||||||
presenter.present()
|
room = room,
|
||||||
}.test {
|
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
|
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
|
||||||
initialState.eventSink(RoomDetailsEditEvents.Save)
|
initialState.eventSink(RoomDetailsEditEvents.Save)
|
||||||
skipItems(4)
|
skipItems(4)
|
||||||
updateAvatarResult.assertions().isCalledOnce().with(value(MimeTypes.Jpeg), value(fakeFileContents))
|
updateAvatarResult.assertions().isCalledOnce().with(value(MimeTypes.Jpeg), value(fakeFileContents))
|
||||||
|
deleteCallback.assertions().isCalledExactly(2).withSequence(
|
||||||
|
listOf(value(null)),
|
||||||
|
listOf(value(null)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -528,10 +570,12 @@ class RoomDetailsEditPresenterTest {
|
||||||
)
|
)
|
||||||
fakePickerProvider.givenResult(anotherAvatarUri)
|
fakePickerProvider.givenResult(anotherAvatarUri)
|
||||||
fakeMediaPreProcessor.givenResult(Result.failure(Throwable("Oh no")))
|
fakeMediaPreProcessor.givenResult(Result.failure(Throwable("Oh no")))
|
||||||
val presenter = createRoomDetailsEditPresenter(room)
|
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
val presenter = createRoomDetailsEditPresenter(
|
||||||
presenter.present()
|
room = room,
|
||||||
}.test {
|
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
|
initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
|
||||||
initialState.eventSink(RoomDetailsEditEvents.Save)
|
initialState.eventSink(RoomDetailsEditEvents.Save)
|
||||||
|
|
@ -576,7 +620,7 @@ class RoomDetailsEditPresenterTest {
|
||||||
removeAvatarResult = { Result.failure(Throwable("!")) },
|
removeAvatarResult = { Result.failure(Throwable("!")) },
|
||||||
canSendStateResult = { _, _ -> Result.success(true) }
|
canSendStateResult = { _, _ -> Result.success(true) }
|
||||||
)
|
)
|
||||||
saveAndAssertFailure(room, RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove))
|
saveAndAssertFailure(room, RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove), deleteCallbackNumberOfInvocation = 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -590,7 +634,7 @@ class RoomDetailsEditPresenterTest {
|
||||||
updateAvatarResult = { _, _ -> Result.failure(Throwable("!")) },
|
updateAvatarResult = { _, _ -> Result.failure(Throwable("!")) },
|
||||||
canSendStateResult = { _, _ -> Result.success(true) }
|
canSendStateResult = { _, _ -> Result.success(true) }
|
||||||
)
|
)
|
||||||
saveAndAssertFailure(room, RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
|
saveAndAssertFailure(room, RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto), deleteCallbackNumberOfInvocation = 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -603,10 +647,12 @@ class RoomDetailsEditPresenterTest {
|
||||||
setTopicResult = { Result.failure(Throwable("!")) },
|
setTopicResult = { Result.failure(Throwable("!")) },
|
||||||
canSendStateResult = { _, _ -> Result.success(true) }
|
canSendStateResult = { _, _ -> Result.success(true) }
|
||||||
)
|
)
|
||||||
val presenter = createRoomDetailsEditPresenter(room)
|
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
val presenter = createRoomDetailsEditPresenter(
|
||||||
presenter.present()
|
room = room,
|
||||||
}.test {
|
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("foo"))
|
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("foo"))
|
||||||
initialState.eventSink(RoomDetailsEditEvents.Save)
|
initialState.eventSink(RoomDetailsEditEvents.Save)
|
||||||
|
|
@ -617,17 +663,24 @@ class RoomDetailsEditPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun saveAndAssertFailure(room: MatrixRoom, event: RoomDetailsEditEvents) {
|
private suspend fun saveAndAssertFailure(
|
||||||
val presenter = createRoomDetailsEditPresenter(room)
|
room: MatrixRoom,
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
event: RoomDetailsEditEvents,
|
||||||
presenter.present()
|
deleteCallbackNumberOfInvocation: Int = 2,
|
||||||
}.test {
|
) {
|
||||||
|
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||||
|
val presenter = createRoomDetailsEditPresenter(
|
||||||
|
room = room,
|
||||||
|
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
val initialState = awaitFirstItem()
|
val initialState = awaitFirstItem()
|
||||||
initialState.eventSink(event)
|
initialState.eventSink(event)
|
||||||
initialState.eventSink(RoomDetailsEditEvents.Save)
|
initialState.eventSink(RoomDetailsEditEvents.Save)
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Loading::class.java)
|
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Loading::class.java)
|
||||||
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java)
|
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java)
|
||||||
|
deleteCallback.assertions().isCalledExactly(deleteCallbackNumberOfInvocation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.androidutils.file
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
interface TemporaryUriDeleter {
|
||||||
|
/**
|
||||||
|
* Delete the Uri only if it is a temporary one.
|
||||||
|
*/
|
||||||
|
fun delete(uri: Uri?)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
class DefaultTemporaryUriDeleter @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) : TemporaryUriDeleter {
|
||||||
|
private val baseCacheUri = "content://${context.packageName}.fileprovider/cache"
|
||||||
|
|
||||||
|
override fun delete(uri: Uri?) {
|
||||||
|
uri ?: return
|
||||||
|
if (uri.toString().startsWith(baseCacheUri)) {
|
||||||
|
context.contentResolver.delete(uri, null, null)
|
||||||
|
} else {
|
||||||
|
Timber.d("Do not delete the uri")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,8 +18,8 @@ interface MediaPreProcessor {
|
||||||
suspend fun process(
|
suspend fun process(
|
||||||
uri: Uri,
|
uri: Uri,
|
||||||
mimeType: String,
|
mimeType: String,
|
||||||
deleteOriginal: Boolean = false,
|
deleteOriginal: Boolean,
|
||||||
compressIfPossible: Boolean
|
compressIfPossible: Boolean,
|
||||||
): Result<MediaUploadInfo>
|
): Result<MediaUploadInfo>
|
||||||
|
|
||||||
data class Failure(override val cause: Throwable?) : Exception(cause)
|
data class Failure(override val cause: Throwable?) : Exception(cause)
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ class MediaSender @Inject constructor(
|
||||||
.process(
|
.process(
|
||||||
uri = uri,
|
uri = uri,
|
||||||
mimeType = mimeType,
|
mimeType = mimeType,
|
||||||
deleteOriginal = true,
|
deleteOriginal = false,
|
||||||
compressIfPossible = compressIfPossible,
|
compressIfPossible = compressIfPossible,
|
||||||
)
|
)
|
||||||
.flatMapCatching { info ->
|
.flatMapCatching { info ->
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import android.media.MediaMetadataRetriever
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.exifinterface.media.ExifInterface
|
import androidx.exifinterface.media.ExifInterface
|
||||||
import com.squareup.anvil.annotations.ContributesBinding
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
|
||||||
import io.element.android.libraries.androidutils.file.createTmpFile
|
import io.element.android.libraries.androidutils.file.createTmpFile
|
||||||
import io.element.android.libraries.androidutils.file.getFileName
|
import io.element.android.libraries.androidutils.file.getFileName
|
||||||
import io.element.android.libraries.androidutils.file.safeRenameTo
|
import io.element.android.libraries.androidutils.file.safeRenameTo
|
||||||
|
|
@ -36,6 +37,7 @@ import kotlinx.coroutines.flow.filterIsInstance
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -49,6 +51,7 @@ class AndroidMediaPreProcessor @Inject constructor(
|
||||||
private val imageCompressor: ImageCompressor,
|
private val imageCompressor: ImageCompressor,
|
||||||
private val videoCompressor: VideoCompressor,
|
private val videoCompressor: VideoCompressor,
|
||||||
private val coroutineDispatchers: CoroutineDispatchers,
|
private val coroutineDispatchers: CoroutineDispatchers,
|
||||||
|
private val temporaryUriDeleter: TemporaryUriDeleter,
|
||||||
) : MediaPreProcessor {
|
) : MediaPreProcessor {
|
||||||
companion object {
|
companion object {
|
||||||
/**
|
/**
|
||||||
|
|
@ -82,8 +85,11 @@ class AndroidMediaPreProcessor @Inject constructor(
|
||||||
}
|
}
|
||||||
if (deleteOriginal) {
|
if (deleteOriginal) {
|
||||||
tryOrNull {
|
tryOrNull {
|
||||||
|
Timber.w("Deleting original uri $uri")
|
||||||
contentResolver.delete(uri, null, null)
|
contentResolver.delete(uri, null, null)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
temporaryUriDeleter.delete(uri)
|
||||||
}
|
}
|
||||||
result.postProcess(uri)
|
result.postProcess(uri)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,12 @@
|
||||||
package io.element.android.libraries.mediaupload.impl
|
package io.element.android.libraries.mediaupload.impl
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
import io.element.android.libraries.matrix.api.media.AudioInfo
|
import io.element.android.libraries.matrix.api.media.AudioInfo
|
||||||
import io.element.android.libraries.matrix.api.media.FileInfo
|
import io.element.android.libraries.matrix.api.media.FileInfo
|
||||||
|
|
@ -21,6 +23,8 @@ import io.element.android.libraries.matrix.api.media.VideoInfo
|
||||||
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
||||||
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
|
||||||
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
|
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
|
||||||
|
import io.element.android.tests.testutils.fake.FakeTemporaryUriDeleter
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
|
|
@ -42,7 +46,12 @@ class AndroidMediaPreProcessorTest {
|
||||||
deleteOriginal: Boolean = false,
|
deleteOriginal: Boolean = false,
|
||||||
): MediaUploadInfo {
|
): MediaUploadInfo {
|
||||||
val context = InstrumentationRegistry.getInstrumentation().context
|
val context = InstrumentationRegistry.getInstrumentation().context
|
||||||
val sut = createAndroidMediaPreProcessor(context, sdkIntVersion)
|
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
|
||||||
|
val sut = createAndroidMediaPreProcessor(
|
||||||
|
context = context,
|
||||||
|
sdkIntVersion = sdkIntVersion,
|
||||||
|
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
|
||||||
|
)
|
||||||
val file = getFileFromAssets(context, asset.filename)
|
val file = getFileFromAssets(context, asset.filename)
|
||||||
val result = sut.process(
|
val result = sut.process(
|
||||||
uri = file.toUri(),
|
uri = file.toUri(),
|
||||||
|
|
@ -52,6 +61,7 @@ class AndroidMediaPreProcessorTest {
|
||||||
)
|
)
|
||||||
val data = result.getOrThrow()
|
val data = result.getOrThrow()
|
||||||
assertThat(data.file.path).endsWith(asset.filename)
|
assertThat(data.file.path).endsWith(asset.filename)
|
||||||
|
deleteCallback.assertions().isCalledExactly(if (deleteOriginal) 0 else 1)
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -356,13 +366,15 @@ class AndroidMediaPreProcessorTest {
|
||||||
|
|
||||||
private fun TestScope.createAndroidMediaPreProcessor(
|
private fun TestScope.createAndroidMediaPreProcessor(
|
||||||
context: Context,
|
context: Context,
|
||||||
sdkIntVersion: Int = Build.VERSION_CODES.P
|
sdkIntVersion: Int = Build.VERSION_CODES.P,
|
||||||
|
temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(),
|
||||||
) = AndroidMediaPreProcessor(
|
) = AndroidMediaPreProcessor(
|
||||||
context = context,
|
context = context,
|
||||||
thumbnailFactory = ThumbnailFactory(context, FakeBuildVersionSdkIntProvider(sdkIntVersion)),
|
thumbnailFactory = ThumbnailFactory(context, FakeBuildVersionSdkIntProvider(sdkIntVersion)),
|
||||||
imageCompressor = ImageCompressor(context, testCoroutineDispatchers()),
|
imageCompressor = ImageCompressor(context, testCoroutineDispatchers()),
|
||||||
videoCompressor = VideoCompressor(context),
|
videoCompressor = VideoCompressor(context),
|
||||||
coroutineDispatchers = testCoroutineDispatchers(),
|
coroutineDispatchers = testCoroutineDispatchers(),
|
||||||
|
temporaryUriDeleter = temporaryUriDeleter,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.tests.testutils.fake
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaError
|
||||||
|
|
||||||
|
class FakeTemporaryUriDeleter(
|
||||||
|
val deleteLambda: (uri: Uri?) -> Unit = { lambdaError() }
|
||||||
|
) : TemporaryUriDeleter {
|
||||||
|
override fun delete(uri: Uri?) {
|
||||||
|
deleteLambda(uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue