[Media upload] Media pre-processing (#403)

* Create `mediaupload` module for media pre-processing.

* Split `mediapicker` and `mediaupload` modules.
This commit is contained in:
Jorge Martin Espinosa 2023-05-10 10:06:56 +02:00 committed by GitHub
parent 7c02e7ad4b
commit 5eaa40a14b
33 changed files with 1148 additions and 156 deletions

View file

@ -41,8 +41,9 @@ dependencies {
implementation(projects.libraries.textcomposer)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.dateformatter.api)
implementation(projects.libraries.mediapickers)
implementation(projects.libraries.mediapickers.api)
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.mediaupload.api)
implementation(projects.features.networkmonitor.api)
implementation(libs.coil.compose)
implementation(libs.datetime)
@ -60,6 +61,9 @@ dependencies {
testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.tests.testutils)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.mediaupload.test)
testImplementation(projects.libraries.mediapickers.test)
testImplementation(libs.test.mockk)
androidTestImplementation(libs.test.junitext)
ksp(libs.showkase.processor)

View file

@ -16,6 +16,7 @@
package io.element.android.features.messages.impl.textcomposer
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
@ -28,12 +29,17 @@ import androidx.compose.runtime.setValue
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.data.StableCharSequence
import io.element.android.libraries.core.data.toStableCharSequence
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.mediapickers.PickerProvider
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaType
import io.element.android.libraries.textcomposer.MessageComposerMode
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@ -46,27 +52,38 @@ class MessageComposerPresenter @Inject constructor(
private val room: MatrixRoom,
private val mediaPickerProvider: PickerProvider,
private val featureFlagService: FeatureFlagService,
private val mediaPreProcessor: MediaPreProcessor,
) : Presenter<MessageComposerState> {
@Composable
override fun present(): MessageComposerState {
val localCoroutineScope = rememberCoroutineScope()
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker(onResult = { uri ->
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker(onResult = { uri, mimeType ->
Timber.d("Media picked from $uri")
// We don't know which type of media was retrieved, so we need this check
val mediaType = when {
mimeType.isMimeTypeImage() -> MediaType.Image
mimeType.isMimeTypeVideo() -> MediaType.Video
else -> error("MimeType must be either image/* or video/*")
}
localCoroutineScope.handleMediaPreProcessing(uri, mediaType)
})
val filesPicker = mediaPickerProvider.registerFilePicker(onResult = { uri ->
val filesPicker = mediaPickerProvider.registerFilePicker(mimeType = MimeTypes.Any) { uri ->
Timber.d("File picked from $uri")
})
localCoroutineScope.handleMediaPreProcessing(uri, MediaType.File)
}
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(onResult = { uri ->
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri ->
Timber.d("Photo saved at $uri")
})
localCoroutineScope.handleMediaPreProcessing(uri, MediaType.Image, deleteOriginal = true)
}
val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker(onResult = { uri ->
val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker { uri ->
Timber.d("Video saved at $uri")
})
localCoroutineScope.handleMediaPreProcessing(uri, MediaType.Video, deleteOriginal = true)
}
val isFullScreen = rememberSaveable {
mutableStateOf(false)
@ -163,4 +180,15 @@ class MessageComposerPresenter @Inject constructor(
)
}
}
private fun CoroutineScope.handleMediaPreProcessing(
uri: Uri?,
mediaType: MediaType,
deleteOriginal: Boolean = false
) = launch {
if (uri == null) return@launch
val result = mediaPreProcessor.process(uri, mediaType, deleteOriginal = deleteOriginal)
Timber.d("Pre-processed media result: $result")
}
}

View file

@ -36,7 +36,8 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.mediapickers.PickerProvider
import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.textcomposer.MessageComposerMode
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
@ -132,8 +133,9 @@ class MessagesPresenterTest {
val messageComposerPresenter = MessageComposerPresenter(
appCoroutineScope = this,
room = matrixRoom,
mediaPickerProvider = PickerProvider(isInTest = true),
mediaPickerProvider = FakePickerProvider(),
featureFlagService = FakeFeatureFlagService(),
mediaPreProcessor = FakeMediaPreProcessor(),
)
val timelinePresenter = TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(),

View file

@ -22,23 +22,30 @@ import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.test
import com.google.common.truth.Truth
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.textcomposer.AttachmentSourcePicker
import io.element.android.features.messages.impl.textcomposer.MessageComposerEvents
import io.element.android.features.messages.impl.textcomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.textcomposer.MessageComposerState
import io.element.android.libraries.core.data.StableCharSequence
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.test.ANOTHER_MESSAGE
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_REPLY
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.mediapickers.PickerProvider
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.textcomposer.MessageComposerMode
import io.mockk.mockk
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
@ -46,21 +53,19 @@ import org.junit.Test
class MessageComposerPresenterTest {
private val pickerProvider = PickerProvider(isInTest = true)
private val pickerProvider = FakePickerProvider().apply {
givenResult(mockk()) // Uri is not available in JVM, so the only way to have a non-null Uri is using Mockk
}
private val featureFlagService = FakeFeatureFlagService().apply {
runBlocking {
setFeatureEnabled(FeatureFlags.ShowMediaUploadingFlow, true)
}
}
private val mediaPreProcessor = FakeMediaPreProcessor()
@Test
fun `present - initial state`() = runTest {
val presenter = MessageComposerPresenter(
this,
FakeMatrixRoom(),
pickerProvider,
featureFlagService,
)
val presenter = createPresenter(this)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -74,12 +79,7 @@ class MessageComposerPresenterTest {
@Test
fun `present - toggle fullscreen`() = runTest {
val presenter = MessageComposerPresenter(
this,
FakeMatrixRoom(),
pickerProvider,
featureFlagService,
)
val presenter = createPresenter(this)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -95,12 +95,7 @@ class MessageComposerPresenterTest {
@Test
fun `present - change message`() = runTest {
val presenter = MessageComposerPresenter(
this,
FakeMatrixRoom(),
pickerProvider,
featureFlagService,
)
val presenter = createPresenter(this)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -118,12 +113,7 @@ class MessageComposerPresenterTest {
@Test
fun `present - change mode to edit`() = runTest {
val presenter = MessageComposerPresenter(
this,
FakeMatrixRoom(),
pickerProvider,
featureFlagService,
)
val presenter = createPresenter(this)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -139,23 +129,9 @@ class MessageComposerPresenterTest {
}
}
private suspend fun ReceiveTurbine<MessageComposerState>.backToNormalMode(state: MessageComposerState, skipCount: Int = 0) {
state.eventSink.invoke(MessageComposerEvents.CloseSpecialMode)
skipItems(skipCount)
val normalState = awaitItem()
assertThat(normalState.mode).isEqualTo(MessageComposerMode.Normal(""))
assertThat(normalState.text).isEqualTo(StableCharSequence(""))
assertThat(normalState.isSendButtonVisible).isFalse()
}
@Test
fun `present - change mode to reply`() = runTest {
val presenter = MessageComposerPresenter(
this,
FakeMatrixRoom(),
pickerProvider,
featureFlagService,
)
val presenter = createPresenter(this)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -172,12 +148,7 @@ class MessageComposerPresenterTest {
@Test
fun `present - change mode to quote`() = runTest {
val presenter = MessageComposerPresenter(
this,
FakeMatrixRoom(),
pickerProvider,
featureFlagService,
)
val presenter = createPresenter(this)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -194,12 +165,7 @@ class MessageComposerPresenterTest {
@Test
fun `present - send message`() = runTest {
val presenter = MessageComposerPresenter(
this,
FakeMatrixRoom(),
pickerProvider,
featureFlagService,
)
val presenter = createPresenter(this)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -218,11 +184,9 @@ class MessageComposerPresenterTest {
@Test
fun `present - edit message`() = runTest {
val fakeMatrixRoom = FakeMatrixRoom()
val presenter = MessageComposerPresenter(
val presenter = createPresenter(
this,
fakeMatrixRoom,
pickerProvider,
featureFlagService,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -251,11 +215,9 @@ class MessageComposerPresenterTest {
@Test
fun `present - reply message`() = runTest {
val fakeMatrixRoom = FakeMatrixRoom()
val presenter = MessageComposerPresenter(
val presenter = createPresenter(
this,
fakeMatrixRoom,
pickerProvider,
featureFlagService,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -283,13 +245,7 @@ class MessageComposerPresenterTest {
@Test
fun `present - Open attachments menu`() = runTest {
val fakeMatrixRoom = FakeMatrixRoom()
val presenter = MessageComposerPresenter(
this,
fakeMatrixRoom,
pickerProvider,
featureFlagService,
)
val presenter = createPresenter(this)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -302,13 +258,7 @@ class MessageComposerPresenterTest {
@Test
fun `present - Open camera attachments menu`() = runTest {
val fakeMatrixRoom = FakeMatrixRoom()
val presenter = MessageComposerPresenter(
this,
fakeMatrixRoom,
pickerProvider,
featureFlagService,
)
val presenter = createPresenter(this)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -321,13 +271,7 @@ class MessageComposerPresenterTest {
@Test
fun `present - Dismiss attachments menu`() = runTest {
val fakeMatrixRoom = FakeMatrixRoom()
val presenter = MessageComposerPresenter(
this,
fakeMatrixRoom,
pickerProvider,
featureFlagService,
)
val presenter = createPresenter(this)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -342,13 +286,8 @@ class MessageComposerPresenterTest {
@Test
fun `present - Pick media from gallery`() = runTest {
val fakeMatrixRoom = FakeMatrixRoom()
val presenter = MessageComposerPresenter(
this,
fakeMatrixRoom,
pickerProvider,
featureFlagService,
)
val presenter = createPresenter(this)
pickerProvider.givenMimeType(MimeTypes.Images)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -359,15 +298,38 @@ class MessageComposerPresenterTest {
}
}
@Test
fun `present - Pick media from gallery fails if returned mimetype is not video or image`() = runTest {
val presenter = createPresenter(this)
pickerProvider.givenMimeType(MimeTypes.Audio)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery)
assertThat(awaitError()).isInstanceOf(IllegalStateException::class.java)
}
}
@Test
fun `present - Pick media from gallery & cancel does nothing`() = runTest {
val presenter = createPresenter(this)
with(pickerProvider){
givenResult(null) // Simulate a user canceling the flow
givenMimeType(MimeTypes.Images)
}
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery)
// No crashes here, otherwise it fails
}
}
@Test
fun `present - Pick file from storage`() = runTest {
val fakeMatrixRoom = FakeMatrixRoom()
val presenter = MessageComposerPresenter(
this,
fakeMatrixRoom,
pickerProvider,
featureFlagService,
)
val presenter = createPresenter(this)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -380,13 +342,7 @@ class MessageComposerPresenterTest {
@Test
fun `present - Take photo`() = runTest {
val fakeMatrixRoom = FakeMatrixRoom()
val presenter = MessageComposerPresenter(
this,
fakeMatrixRoom,
pickerProvider,
featureFlagService,
)
val presenter = createPresenter(this)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -399,13 +355,7 @@ class MessageComposerPresenterTest {
@Test
fun `present - Record video`() = runTest {
val fakeMatrixRoom = FakeMatrixRoom()
val presenter = MessageComposerPresenter(
this,
fakeMatrixRoom,
pickerProvider,
featureFlagService,
)
val presenter = createPresenter(this)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -415,6 +365,25 @@ class MessageComposerPresenterTest {
// TODO verify some post processing of the captured video is done
}
}
private suspend fun ReceiveTurbine<MessageComposerState>.backToNormalMode(state: MessageComposerState, skipCount: Int = 0) {
state.eventSink.invoke(MessageComposerEvents.CloseSpecialMode)
skipItems(skipCount)
val normalState = awaitItem()
assertThat(normalState.mode).isEqualTo(MessageComposerMode.Normal(""))
assertThat(normalState.text).isEqualTo(StableCharSequence(""))
assertThat(normalState.isSendButtonVisible).isFalse()
}
private fun createPresenter(
coroutineScope: CoroutineScope,
room: MatrixRoom = FakeMatrixRoom(),
pickerProvider: PickerProvider = this.pickerProvider,
featureFlagService: FeatureFlagService = this.featureFlagService,
mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor,
) = MessageComposerPresenter(
coroutineScope, room, pickerProvider, featureFlagService, mediaPreProcessor
)
}
fun anEditMode() = MessageComposerMode.Edit(AN_EVENT_ID, A_MESSAGE)