Delete temporary created files.

This commit is contained in:
Benoit Marty 2024-11-06 14:30:42 +01:00
parent be42821352
commit 22bb8796ef
10 changed files with 328 additions and 89 deletions

View file

@ -8,6 +8,7 @@
package io.element.android.features.messages.impl.attachments.preview package io.element.android.features.messages.impl.attachments.preview
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -18,6 +19,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
@ -36,6 +38,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
@Assisted private val attachment: Attachment, @Assisted private val attachment: Attachment,
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 {
@ -57,6 +60,20 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) } val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) }
DisposableEffect(Unit) {
onDispose {
// Delete the temporary file when the composable is disposed, in case it was not sent
if (sendActionState.value == SendActionState.Idle) {
// Attachment has not been sent, maybe delete it
when (attachment) {
is Attachment.Media -> {
temporaryUriDeleter.delete(attachment.localMedia.uri)
}
}
}
}
}
fun handleEvents(attachmentsPreviewEvents: AttachmentsPreviewEvents) { fun handleEvents(attachmentsPreviewEvents: AttachmentsPreviewEvents) {
when (attachmentsPreviewEvents) { when (attachmentsPreviewEvents) {
is AttachmentsPreviewEvents.SendAttachment -> { is AttachmentsPreviewEvents.SendAttachment -> {

View file

@ -18,6 +18,7 @@ import io.element.android.features.messages.impl.attachments.preview.Attachments
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.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,6 +36,7 @@ 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
@ -207,11 +209,13 @@ class AttachmentsPreviewPresenterTest {
room: MatrixRoom = FakeMatrixRoom(), room: MatrixRoom = FakeMatrixRoom(),
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(), permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(),
mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor(), mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor(),
temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(),
): AttachmentsPreviewPresenter { ): AttachmentsPreviewPresenter {
return AttachmentsPreviewPresenter( return AttachmentsPreviewPresenter(
attachment = aMediaAttachment(localMedia), attachment = aMediaAttachment(localMedia),
mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()), mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()),
permalinkBuilder = permalinkBuilder, permalinkBuilder = permalinkBuilder,
temporaryUriDeleter = temporaryUriDeleter,
) )
} }
} }

View file

@ -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
}
} }
} }

View file

@ -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(
deleteCallback = { 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(
deleteCallback = { 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(
deleteCallback = 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(
deleteCallback = 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(
deleteCallback = 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(
deleteCallback = { 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(
deleteCallback = { 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(
deleteCallback = { 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(
deleteCallback = { assertThat(it).isEqualTo(userAvatarUri) }
),
)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {

View file

@ -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
}
} }
} }

View file

@ -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)
} }
} }

View file

@ -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")
}
}
}

View file

@ -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
@ -50,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 {
/** /**
@ -86,6 +88,8 @@ class AndroidMediaPreProcessor @Inject constructor(
Timber.w("Deleting original uri $uri") 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)
} }

View file

@ -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)

View file

@ -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 deleteCallback: (uri: Uri?) -> Unit = { lambdaError() }
) : TemporaryUriDeleter {
override fun delete(uri: Uri?) {
deleteCallback(uri)
}
}