diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index 69e86158ba..217e5c51fb 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -1,6 +1,6 @@ - \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4a81d52d46..22a63b60c9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -29,15 +29,6 @@ android:theme="@style/Theme.ElementX" tools:targetApi="33"> - - - - + + + + diff --git a/changelog.d/396.feature b/changelog.d/396.feature new file mode 100644 index 0000000000..17e5a2da77 --- /dev/null +++ b/changelog.d/396.feature @@ -0,0 +1 @@ +Add media pre-processing before uploading it. diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index f53f130bf7..d50c02bc82 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -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) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenter.kt index 8d495aa269..8193c0c0b2 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenter.kt @@ -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 { @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") + } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt index aa916f1e79..d2ba50ad5a 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt @@ -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(), diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt index e550371abd..dc1efd7d88 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt @@ -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.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.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) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 551aa7d39f..e5495eaf3c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -63,6 +63,7 @@ androidx_core = { module = "androidx.core:core", version.ref = "core" } androidx_corektx = { module = "androidx.core:core-ktx", version.ref = "core" } androidx_datastore_preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } androidx_datastore_datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } +androidx_exifinterface = "androidx.exifinterface:exifinterface:1.3.6" androidx_constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" } androidx_recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" } androidx_lifecycle_runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } @@ -136,6 +137,7 @@ sqlcipher = "net.zetetic:android-database-sqlcipher:4.5.4" sqlite = "androidx.sqlite:sqlite:2.3.1" unifiedpush = "com.github.UnifiedPush:android-connector:2.1.1" gujun_span = "me.gujun.android:span:1.7" +otaliastudios_transcoder = "com.otaliastudios:transcoder:0.10.5" # Di inject = "javax.inject:javax.inject:1" diff --git a/libraries/androidutils/build.gradle.kts b/libraries/androidutils/build.gradle.kts index 971cc9ef40..e9a8feaa05 100644 --- a/libraries/androidutils/build.gradle.kts +++ b/libraries/androidutils/build.gradle.kts @@ -26,6 +26,7 @@ dependencies { implementation(libs.timber) implementation(libs.androidx.corektx) implementation(libs.androidx.activity.activity) + implementation(libs.androidx.exifinterface) implementation(libs.androidx.security.crypto) implementation(projects.libraries.core) } diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/bitmap/Bitmap.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/bitmap/Bitmap.kt index f71fb1535e..6f8aa76d03 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/bitmap/Bitmap.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/bitmap/Bitmap.kt @@ -17,7 +17,13 @@ package io.element.android.libraries.androidutils.bitmap import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix +import androidx.core.graphics.scale +import androidx.exifinterface.media.ExifInterface import java.io.File +import java.io.InputStream +import kotlin.math.min fun File.writeBitmap(bitmap: Bitmap, format: Bitmap.CompressFormat, quality: Int) { outputStream().use { out -> @@ -25,3 +31,71 @@ fun File.writeBitmap(bitmap: Bitmap, format: Bitmap.CompressFormat, quality: Int out.flush() } } + +/** + * Reads the EXIF metadata from the [inputStream] and rotates the current [Bitmap] to match it. + * @return The resulting [Bitmap] or `null` if no metadata was found. + */ +fun Bitmap.rotateToMetadataOrientation(inputStream: InputStream): Result = + runCatching { rotateToMetadataOrientation(this, ExifInterface(inputStream)) } + +/** + * Scales the current [Bitmap] to fit the ([maxWidth], [maxHeight]) bounds while keeping aspect ratio. + * @throws IllegalStateException if [maxWidth] or [maxHeight] <= 0. + */ +fun Bitmap.resizeToMax(maxWidth: Int, maxHeight: Int): Bitmap { + // No need to resize + if (this.width == maxWidth && this.height == maxHeight) return this + + val aspectRatio = this.width.toFloat() / this.height.toFloat() + val useWidth = aspectRatio >= 1 + val calculatedMaxWidth = min(this.width, maxWidth) + val calculatedMinHeight = min(this.height, maxHeight) + val width = if (useWidth) calculatedMaxWidth else (calculatedMinHeight * aspectRatio).toInt() + val height = if (useWidth) (calculatedMaxWidth / aspectRatio).toInt() else calculatedMinHeight + return scale(width, height) +} + +/** + * Calculates and returns [BitmapFactory.Options.inSampleSize] given a pair of [desiredWidth] & [desiredHeight] + * and the previously read [BitmapFactory.Options.outWidth] & [BitmapFactory.Options.outHeight]. + */ +fun BitmapFactory.Options.calculateInSampleSize(desiredWidth: Int, desiredHeight: Int): Int { + var inSampleSize = 1 + + if (outWidth > desiredWidth || outHeight > desiredHeight) { + val halfHeight: Int = outHeight / 2 + val halfWidth: Int = outWidth / 2 + + // Calculate the largest inSampleSize value that is a power of 2 and keeps both + // height and width larger than the requested height and width. + while (halfHeight / inSampleSize >= desiredHeight && halfWidth / inSampleSize >= desiredWidth) { + inSampleSize *= 2 + } + } + + return inSampleSize +} + +private fun rotateToMetadataOrientation(bitmap: Bitmap, exifInterface: ExifInterface): Bitmap { + val orientation = exifInterface.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) + val matrix = Matrix() + when (orientation) { + ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f) + ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f) + ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f) + ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.preScale(-1f, 1f) + ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.preScale(1f, -1f) + ExifInterface.ORIENTATION_TRANSPOSE -> { + matrix.preRotate(-90f) + matrix.preScale(-1f, 1f) + } + ExifInterface.ORIENTATION_TRANSVERSE -> { + matrix.preRotate(90f) + matrix.preScale(-1f, 1f) + } + else -> return bitmap + } + + return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt index b12e4d9986..137b15a893 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt @@ -16,9 +16,13 @@ package io.element.android.libraries.androidutils.file +import android.content.Context import io.element.android.libraries.core.data.tryOrNull +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import timber.log.Timber import java.io.File +import java.util.UUID fun File.safeDelete() { tryOrNull( @@ -32,3 +36,7 @@ fun File.safeDelete() { } ) } + +suspend fun Context.createTmpFile(baseDir: File = cacheDir): File = withContext(Dispatchers.IO) { + File.createTempFile(UUID.randomUUID().toString(), null, baseDir).apply { mkdirs() } +} diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/media/MediaMetaDataRetriever.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/media/MediaMetaDataRetriever.kt new file mode 100644 index 0000000000..3b29787285 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/media/MediaMetaDataRetriever.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.androidutils.media + +import android.media.MediaMetadataRetriever + +/** [MediaMetadataRetriever] only implements `AutoClosable` since API 29, so we need to execute this to have the same in older APIs. */ +inline fun MediaMetadataRetriever.runAndRelease(block: MediaMetadataRetriever.() -> T): T { + return block().also { release() } +} diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt index 082623b4de..3e3afbfe0e 100644 --- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt @@ -31,6 +31,10 @@ object MimeTypes { const val Jpeg = "image/jpeg" const val Gif = "image/gif" + const val Videos = "video/*" + + const val Audio = "audio/*" + const val Ogg = "audio/ogg" const val PlainText = "text/plain" diff --git a/libraries/mediapickers/build.gradle.kts b/libraries/mediapickers/api/build.gradle.kts similarity index 84% rename from libraries/mediapickers/build.gradle.kts rename to libraries/mediapickers/api/build.gradle.kts index 444244d2f0..75afa417f4 100644 --- a/libraries/mediapickers/build.gradle.kts +++ b/libraries/mediapickers/api/build.gradle.kts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * Copyright (c) 2023 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,14 +16,16 @@ plugins { id("io.element.android-compose-library") + alias(libs.plugins.anvil) } android { - namespace = "io.element.android.libraries.mediapickers" + namespace = "io.element.android.libraries.mediapickers.api" dependencies { implementation(projects.libraries.uiStrings) implementation(projects.libraries.core) + implementation(projects.libraries.di) implementation(libs.inject) testImplementation(libs.test.junit) diff --git a/libraries/mediapickers/src/main/kotlin/io/element/android/libraries/mediapickers/PickerLauncher.kt b/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerLauncher.kt similarity index 96% rename from libraries/mediapickers/src/main/kotlin/io/element/android/libraries/mediapickers/PickerLauncher.kt rename to libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerLauncher.kt index 16f21a9683..9eca6b373a 100644 --- a/libraries/mediapickers/src/main/kotlin/io/element/android/libraries/mediapickers/PickerLauncher.kt +++ b/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerLauncher.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.libraries.mediapickers +package io.element.android.libraries.mediapickers.api import androidx.activity.compose.ManagedActivityResultLauncher diff --git a/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerProvider.kt b/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerProvider.kt new file mode 100644 index 0000000000..9b0b248aa5 --- /dev/null +++ b/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerProvider.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.mediapickers.api + +import android.net.Uri +import androidx.activity.result.PickVisualMediaRequest +import androidx.compose.runtime.Composable + +interface PickerProvider { + + @Composable + fun registerGalleryPicker( + onResult: (uri: Uri?, mimeType: String?) -> Unit + ): PickerLauncher + + @Composable + fun registerFilePicker( + mimeType: String, + onResult: (Uri?) -> Unit + ): PickerLauncher + + @Composable + fun registerCameraPhotoPicker(onResult: (Uri?) -> Unit): PickerLauncher + + @Composable + fun registerCameraVideoPicker(onResult: (Uri?) -> Unit): PickerLauncher + +} diff --git a/libraries/mediapickers/src/main/kotlin/io/element/android/libraries/mediapickers/PickerType.kt b/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerType.kt similarity index 97% rename from libraries/mediapickers/src/main/kotlin/io/element/android/libraries/mediapickers/PickerType.kt rename to libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerType.kt index 354d9a4918..ad89175ddf 100644 --- a/libraries/mediapickers/src/main/kotlin/io/element/android/libraries/mediapickers/PickerType.kt +++ b/libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerType.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.libraries.mediapickers +package io.element.android.libraries.mediapickers.api import android.net.Uri import androidx.activity.result.PickVisualMediaRequest diff --git a/libraries/mediapickers/src/test/kotlin/io/element/android/libraries/mediapickers/PickerTypeTests.kt b/libraries/mediapickers/api/src/test/kotlin/io/element/android/libraries/mediapickers/PickerTypeTests.kt similarity index 97% rename from libraries/mediapickers/src/test/kotlin/io/element/android/libraries/mediapickers/PickerTypeTests.kt rename to libraries/mediapickers/api/src/test/kotlin/io/element/android/libraries/mediapickers/PickerTypeTests.kt index 693ef40cfe..4f17081f8b 100644 --- a/libraries/mediapickers/src/test/kotlin/io/element/android/libraries/mediapickers/PickerTypeTests.kt +++ b/libraries/mediapickers/api/src/test/kotlin/io/element/android/libraries/mediapickers/PickerTypeTests.kt @@ -20,6 +20,7 @@ import android.net.Uri import androidx.activity.result.contract.ActivityResultContracts import com.google.common.truth.Truth.assertThat import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.mediapickers.api.PickerType import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner diff --git a/libraries/mediapickers/impl/build.gradle.kts b/libraries/mediapickers/impl/build.gradle.kts new file mode 100644 index 0000000000..856e7765b0 --- /dev/null +++ b/libraries/mediapickers/impl/build.gradle.kts @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.anvil) +} + +android { + namespace = "io.element.android.libraries.mediapickers.impl" + + dependencies { + implementation(projects.libraries.core) + implementation(projects.libraries.di) + implementation(libs.inject) + api(projects.libraries.mediapickers.api) + } +} diff --git a/libraries/mediapickers/src/main/kotlin/io/element/android/libraries/mediapickers/PickerProvider.kt b/libraries/mediapickers/impl/src/main/kotlin/io/element/android/libraries/mediapickers/impl/PickerProviderImpl.kt similarity index 78% rename from libraries/mediapickers/src/main/kotlin/io/element/android/libraries/mediapickers/PickerProvider.kt rename to libraries/mediapickers/impl/src/main/kotlin/io/element/android/libraries/mediapickers/impl/PickerProviderImpl.kt index 720378aaca..b7e9bad420 100644 --- a/libraries/mediapickers/src/main/kotlin/io/element/android/libraries/mediapickers/PickerProvider.kt +++ b/libraries/mediapickers/impl/src/main/kotlin/io/element/android/libraries/mediapickers/impl/PickerProviderImpl.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.libraries.mediapickers +package io.element.android.libraries.mediapickers.impl import android.content.Context import android.net.Uri @@ -25,12 +25,19 @@ import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode import androidx.core.content.FileProvider -import io.element.android.libraries.core.mimetype.MimeTypes +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.mediapickers.api.ComposePickerLauncher +import io.element.android.libraries.mediapickers.api.NoOpPickerLauncher +import io.element.android.libraries.mediapickers.api.PickerLauncher +import io.element.android.libraries.mediapickers.api.PickerProvider +import io.element.android.libraries.mediapickers.api.PickerType import java.io.File import java.util.UUID import javax.inject.Inject -class PickerProvider constructor(private val isInTest: Boolean) { +@ContributesBinding(AppScope::class) +class PickerProviderImpl constructor(private val isInTest: Boolean) : PickerProvider { @Inject constructor(): this(false) @@ -57,12 +64,18 @@ class PickerProvider constructor(private val isInTest: Boolean) { * [onResult] will be called with either the selected file's [Uri] or `null` if nothing was selected. */ @Composable - fun registerGalleryPicker(onResult: (Uri?) -> Unit): PickerLauncher { + override fun registerGalleryPicker( + onResult: (uri: Uri?, mimeType: String?) -> Unit + ): PickerLauncher { // Tests and UI preview can't handle Contexts, so we might as well disable the whole picker return if (LocalInspectionMode.current || isInTest) { - NoOpPickerLauncher { onResult(null) } + NoOpPickerLauncher { onResult(null, null) } } else { - rememberPickerLauncher(type = PickerType.ImageAndVideo) { uri -> onResult(uri) } + val context = LocalContext.current + rememberPickerLauncher(type = PickerType.ImageAndVideo) { uri -> + val mimeType = uri?.let { context.contentResolver.getType(it) } + onResult(uri, mimeType) + } } } @@ -71,7 +84,10 @@ class PickerProvider constructor(private val isInTest: Boolean) { * [onResult] will be called with either the selected file's [Uri] or `null` if nothing was selected. */ @Composable - fun registerFilePicker(mimeType: String = MimeTypes.Any, onResult: (Uri?) -> Unit): PickerLauncher { + override fun registerFilePicker( + mimeType: String, + onResult: (Uri?) -> Unit, + ): PickerLauncher { // Tests and UI preview can't handle Context or FileProviders, so we might as well disable the whole picker return if (LocalInspectionMode.current || isInTest) { NoOpPickerLauncher { onResult(null) } @@ -83,11 +99,9 @@ class PickerProvider constructor(private val isInTest: Boolean) { /** * Remembers and returns a [PickerLauncher] for taking a photo with a camera app. * @param [onResult] will be called with either the photo's [Uri] or `null` if nothing was selected. - * @param [deleteAfter] When it's `true`, the taken photo will be automatically removed after calling [onResult]. - * It's `true` by default. */ @Composable - fun registerCameraPhotoPicker(onResult: (Uri?) -> Unit, deleteAfter: Boolean = true): PickerLauncher { + override fun registerCameraPhotoPicker(onResult: (Uri?) -> Unit): PickerLauncher { // Tests and UI preview can't handle Context or FileProviders, so we might as well disable the whole picker return if (LocalInspectionMode.current || isInTest) { NoOpPickerLauncher { onResult(null) } @@ -98,10 +112,6 @@ class PickerProvider constructor(private val isInTest: Boolean) { rememberPickerLauncher(type = PickerType.Camera.Photo(tmpFileUri)) { success -> // Execute callback onResult(if (success) tmpFileUri else null) - // Then remove the file and clear the picker - if (deleteAfter) { - tmpFile.delete() - } } } } @@ -109,11 +119,9 @@ class PickerProvider constructor(private val isInTest: Boolean) { /** * Remembers and returns a [PickerLauncher] for recording a video with a camera app. * @param [onResult] will be called with either the video's [Uri] or `null` if nothing was selected. - * @param [deleteAfter] When it's `true`, the recorded video will be automatically removed after calling [onResult]. - * It's `true` by default. */ @Composable - fun registerCameraVideoPicker(onResult: (Uri?) -> Unit, deleteAfter: Boolean = true): PickerLauncher { + override fun registerCameraVideoPicker(onResult: (Uri?) -> Unit): PickerLauncher { // Tests and UI preview can't handle Context or FileProviders, so we might as well disable the whole picker return if (LocalInspectionMode.current || isInTest) { NoOpPickerLauncher { onResult(null) } @@ -124,10 +132,6 @@ class PickerProvider constructor(private val isInTest: Boolean) { rememberPickerLauncher(type = PickerType.Camera.Video(tmpFileUri)) { success -> // Execute callback onResult(if (success) tmpFileUri else null) - // Then remove the file and clear the picker - if (deleteAfter) { - tmpFile.delete() - } } } } diff --git a/libraries/mediapickers/test/build.gradle.kts b/libraries/mediapickers/test/build.gradle.kts new file mode 100644 index 0000000000..87bde9b6e7 --- /dev/null +++ b/libraries/mediapickers/test/build.gradle.kts @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.anvil) +} + +android { + namespace = "io.element.android.libraries.mediapickers.test" + + dependencies { + implementation(projects.libraries.core) + implementation(projects.libraries.di) + implementation(libs.inject) + api(projects.libraries.mediapickers.api) + } +} diff --git a/libraries/mediapickers/test/src/main/kotlin/io/element/android/libraries/mediapickers/test/FakePickerProvider.kt b/libraries/mediapickers/test/src/main/kotlin/io/element/android/libraries/mediapickers/test/FakePickerProvider.kt new file mode 100644 index 0000000000..2a32387db4 --- /dev/null +++ b/libraries/mediapickers/test/src/main/kotlin/io/element/android/libraries/mediapickers/test/FakePickerProvider.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.mediapickers.test + +import android.net.Uri +import androidx.activity.result.PickVisualMediaRequest +import androidx.compose.runtime.Composable +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.mediapickers.api.NoOpPickerLauncher +import io.element.android.libraries.mediapickers.api.PickerLauncher +import io.element.android.libraries.mediapickers.api.PickerProvider + +class FakePickerProvider : PickerProvider { + private var mimeType = MimeTypes.Any + private var result: Uri? = null + + @Composable + override fun registerGalleryPicker(onResult: (uri: Uri?, mimeType: String?) -> Unit): PickerLauncher { + return NoOpPickerLauncher { onResult(result, mimeType) } + } + + @Composable + override fun registerFilePicker(mimeType: String, onResult: (Uri?) -> Unit): PickerLauncher { + return NoOpPickerLauncher { onResult(result) } + } + + @Composable + override fun registerCameraPhotoPicker(onResult: (Uri?) -> Unit): PickerLauncher { + return NoOpPickerLauncher { onResult(result) } + } + + @Composable + override fun registerCameraVideoPicker(onResult: (Uri?) -> Unit): PickerLauncher { + return NoOpPickerLauncher { onResult(result) } + } + + fun givenResult(value: Uri?) { + this.result = value + } + + fun givenMimeType(mimeType: String) { + this.mimeType = mimeType + } +} diff --git a/libraries/mediaupload/api/build.gradle.kts b/libraries/mediaupload/api/build.gradle.kts new file mode 100644 index 0000000000..111abc2bcc --- /dev/null +++ b/libraries/mediaupload/api/build.gradle.kts @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("io.element.android-library") + alias(libs.plugins.anvil) + alias(libs.plugins.ksp) +} + +android { + namespace = "io.element.android.libraries.mediaupload.api" + + anvil { + generateDaggerFactories.set(true) + } + + dependencies { + implementation(projects.anvilannotations) + anvil(projects.anvilcodegen) + + implementation(projects.libraries.architecture) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.core) + implementation(projects.libraries.di) + api(projects.libraries.matrix.api) + implementation(libs.inject) + implementation(libs.coroutines.core) + } +} diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaPreProcessor.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaPreProcessor.kt new file mode 100644 index 0000000000..6e2168ca4b --- /dev/null +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaPreProcessor.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.mediaupload.api + +import android.net.Uri + +interface MediaPreProcessor { + /** + * Given a [uri] and [mediaType], pre-processes the media before it's uploaded, resizing, transcoding, and removing sensitive info from its metadata. + * If [deleteOriginal] is `true`, the file reference by the [uri] will be automatically deleted too when this process finishes. + * @return a [Result] with the [MediaUploadInfo] containing all the info needed to begin the upload. + */ + suspend fun process( + uri: Uri, + mediaType: MediaType, + deleteOriginal: Boolean = false + ): Result +} diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaType.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaType.kt new file mode 100644 index 0000000000..e16ca43699 --- /dev/null +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaType.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.mediaupload.api + +sealed interface MediaType { + object Image : MediaType + object Video : MediaType + object Audio : MediaType + object File : MediaType +} diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt new file mode 100644 index 0000000000..36eb8f5102 --- /dev/null +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.mediaupload.api + +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.ImageInfo +import io.element.android.libraries.matrix.api.media.ThumbnailInfo +import io.element.android.libraries.matrix.api.media.VideoInfo +import java.io.File + +sealed interface MediaUploadInfo { + data class Image(val file: File, val info: ImageInfo, val thumbnailInfo: ThumbnailProcessingInfo?) : MediaUploadInfo + data class Video(val file: File, val info: VideoInfo, val thumbnailInfo: ThumbnailProcessingInfo?) : MediaUploadInfo + data class Audio(val file: File, val info: AudioInfo) : MediaUploadInfo + data class AnyFile(val file: File, val info: FileInfo) : MediaUploadInfo +} + +data class ThumbnailProcessingInfo( + val file: File, + val info: ThumbnailInfo, +) diff --git a/libraries/mediaupload/impl/build.gradle.kts b/libraries/mediaupload/impl/build.gradle.kts new file mode 100644 index 0000000000..c451d81c7e --- /dev/null +++ b/libraries/mediaupload/impl/build.gradle.kts @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("io.element.android-library") + alias(libs.plugins.anvil) + alias(libs.plugins.ksp) +} + +android { + namespace = "io.element.android.libraries.mediaupload.impl" + + anvil { + generateDaggerFactories.set(true) + } + + dependencies { + implementation(projects.anvilannotations) + anvil(projects.anvilcodegen) + + api(projects.libraries.mediaupload.api) + implementation(projects.libraries.architecture) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.core) + implementation(projects.libraries.di) + implementation(projects.libraries.matrix.api) + implementation(libs.inject) + implementation(libs.androidx.exifinterface) + implementation(libs.coroutines.core) + implementation(libs.otaliastudios.transcoder) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.test.truth) + } +} diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/ImageCompressor.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/ImageCompressor.kt new file mode 100644 index 0000000000..45c09a2a47 --- /dev/null +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/ImageCompressor.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.mediaupload + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import io.element.android.libraries.androidutils.bitmap.calculateInSampleSize +import io.element.android.libraries.androidutils.bitmap.resizeToMax +import io.element.android.libraries.androidutils.bitmap.rotateToMetadataOrientation +import io.element.android.libraries.androidutils.file.createTmpFile +import io.element.android.libraries.di.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.BufferedInputStream +import java.io.File +import java.io.InputStream +import javax.inject.Inject + +class ImageCompressor @Inject constructor( + @ApplicationContext private val context: Context, +) { + + /** + * Decodes the [inputStream] into a [Bitmap] and applies the needed transformations (rotation, scale) based on [resizeMode], then writes it into a + * temporary file using the passed [format] and [desiredQuality]. + * @return a [Result] containing the resulting [ImageCompressionResult] with the temporary [File] and some metadata. + */ + suspend fun compressToTmpFile( + inputStream: InputStream, + resizeMode: ResizeMode, + format: Bitmap.CompressFormat = Bitmap.CompressFormat.JPEG, + desiredQuality: Int = 80, + ): Result = withContext(Dispatchers.IO) { + runCatching { + val compressedBitmap = compressToBitmap(inputStream, resizeMode).getOrThrow() + + // Encode bitmap to the destination temporary file + val tmpFile = context.createTmpFile() + tmpFile.outputStream().use { + compressedBitmap.compress(format, desiredQuality, it) + } + + ImageCompressionResult(tmpFile, compressedBitmap.width, compressedBitmap.height, tmpFile.length()) + } + } + + /** + * Decodes the [inputStream] into a [Bitmap] and applies the needed transformations (rotation, scale) based on [resizeMode]. + * @return a [Result] containing the resulting [Bitmap]. + */ + fun compressToBitmap( + inputStream: InputStream, + resizeMode: ResizeMode, + ): Result = runCatching { + BufferedInputStream(inputStream).use { input -> + val options = BitmapFactory.Options() + calculateDecodingScale(input, resizeMode, options) + val decodedBitmap = BitmapFactory.decodeStream(input, null, options) + ?: error("Decoding Bitmap from InputStream failed") + val rotatedBitmap = decodedBitmap.rotateToMetadataOrientation(input).getOrThrow() + if (resizeMode is ResizeMode.Strict) { + rotatedBitmap.resizeToMax(resizeMode.maxWidth, resizeMode.maxHeight) + } else { + rotatedBitmap + } + } + } + + private fun calculateDecodingScale( + inputStream: BufferedInputStream, + resizeMode: ResizeMode, + options: BitmapFactory.Options + ) { + val (width, height) = when (resizeMode) { + is ResizeMode.Approximate -> resizeMode.desiredWidth to resizeMode.desiredHeight + is ResizeMode.Strict -> (resizeMode.maxWidth / 2) to (resizeMode.maxHeight / 2) + is ResizeMode.None -> return + } + // Read bounds only + inputStream.mark(inputStream.available()) + options.inJustDecodeBounds = true + BitmapFactory.decodeStream(inputStream, null, options) + // Set sample size based on the outWidth and outHeight + options.inSampleSize = options.calculateInSampleSize(width, height) + // Now read the actual image and rotate it to match its metadata + inputStream.reset() + options.inJustDecodeBounds = false + } +} + +data class ImageCompressionResult( + val file: File, + val width: Int, + val height: Int, + val size: Long, +) + +sealed interface ResizeMode { + object None : ResizeMode + data class Approximate(val desiredWidth: Int, val desiredHeight: Int) : ResizeMode + data class Strict(val maxWidth: Int, val maxHeight: Int) : ResizeMode +} diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/MediaPreProcessorImpl.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/MediaPreProcessorImpl.kt new file mode 100644 index 0000000000..3d51cb24f1 --- /dev/null +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/MediaPreProcessorImpl.kt @@ -0,0 +1,263 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.mediaupload + +import android.content.Context +import android.graphics.Bitmap +import android.media.MediaMetadataRetriever +import android.net.Uri +import androidx.exifinterface.media.ExifInterface +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.androidutils.file.createTmpFile +import io.element.android.libraries.androidutils.media.runAndRelease +import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +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.ImageInfo +import io.element.android.libraries.matrix.api.media.ThumbnailInfo +import io.element.android.libraries.matrix.api.media.VideoInfo +import io.element.android.libraries.mediaupload.api.MediaPreProcessor +import io.element.android.libraries.mediaupload.api.MediaType +import io.element.android.libraries.mediaupload.api.MediaUploadInfo +import io.element.android.libraries.mediaupload.api.ThumbnailProcessingInfo +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.withContext +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.InputStream +import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds + +@ContributesBinding(AppScope::class) +class MediaPreProcessorImpl @Inject constructor( + @ApplicationContext private val context: Context, + private val imageCompressor: ImageCompressor, + private val videoCompressor: VideoCompressor, +) : MediaPreProcessor { + companion object { + /** + * Used for calculating `inSampleSize` for bitmaps. + * + * *Note*: Ideally, this should result in images of up to (but not included) 1280x1280 being sent. However, images with very different width and height + * values may surpass this limit. (i.e.: an image of `480x3000px` would have `inSampleSize=1` and be sent as is). + */ + private const val IMAGE_SCALE_REF_SIZE = 640 + + /** + * Max width of thumbnail images. + * See [the Matrix spec](https://spec.matrix.org/latest/client-server-api/?ref=blog.gitter.im#thumbnails). + */ + private const val THUMB_MAX_WIDTH = 800 + /** + * Max height of thumbnail images. + * See [the Matrix spec](https://spec.matrix.org/latest/client-server-api/?ref=blog.gitter.im#thumbnails). + */ + private const val THUMB_MAX_HEIGHT = 600 + + /** + * Frame of the video to be used for generating a thumbnail. + */ + private val VIDEO_THUMB_FRAME = 5.seconds.inWholeMicroseconds + } + + private val contentResolver = context.contentResolver + + override suspend fun process( + uri: Uri, + mediaType: MediaType, + deleteOriginal: Boolean, + ): Result = runCatching { + // Camera returns an 'octet-stream' mimetype, so it needs to be overridden + val originalMimeType = contentResolver.getType(uri) + val mimeType = when (mediaType) { + MediaType.Image -> MimeTypes.Images + MediaType.Video -> MimeTypes.Videos + MediaType.Audio -> MimeTypes.Audio + else -> originalMimeType + } + val compressBeforeSending = mediaType in sequenceOf(MediaType.Image, MediaType.Video) + val result = if (compressBeforeSending && mimeType != MimeTypes.Gif) { + when { + mimeType.isMimeTypeImage() -> processImage(uri) + mimeType.isMimeTypeVideo() -> processVideo(uri, mimeType) + mimeType.isMimeTypeAudio() -> processAudio(uri) + else -> error("Cannot compress file of type: $mimeType") + } + } else { + val file = copyToTmpFile(uri) + // Remove image metadata here too + if (mimeType.isMimeTypeImage() && mimeType != MimeTypes.Gif) { + removeSensitiveImageMetadata(file) + } + val info = FileInfo( + mimetype = originalMimeType, + size = file.length(), + thumbnailInfo = null, + thumbnailUrl = null, + ) + MediaUploadInfo.AnyFile(file, info) + } + + if (deleteOriginal) { + contentResolver.delete(uri, null, null) + } + + result + } + + private suspend fun processImage(uri: Uri): MediaUploadInfo { + val compressedFileResult = contentResolver.openInputStream(uri).use { input -> + imageCompressor.compressToTmpFile( + inputStream = requireNotNull(input), + resizeMode = ResizeMode.Approximate(IMAGE_SCALE_REF_SIZE, IMAGE_SCALE_REF_SIZE), + ).getOrThrow() + } + + removeSensitiveImageMetadata(compressedFileResult.file) + + val thumbnailResult = compressedFileResult.file.inputStream().use { generateImageThumbnail(it) } + val processingResult = compressedFileResult.toImageInfo(MimeTypes.Jpeg, thumbnailResult?.file?.path, thumbnailResult?.info) + return MediaUploadInfo.Image(compressedFileResult.file, processingResult, thumbnailResult) + } + + private suspend fun processVideo(uri: Uri, mimeType: String?): MediaUploadInfo { + val thumbnailInfo = extractVideoThumbnail(uri) + val resultFile = videoCompressor.compress(uri) + .onEach { + // TODO handle progress + } + .filterIsInstance() + .first() + .file + + val videoProcessingInfo = extractVideoMetadata(resultFile, mimeType, thumbnailInfo?.file?.path, thumbnailInfo?.info) + return MediaUploadInfo.Video(resultFile, videoProcessingInfo, thumbnailInfo) + } + + private suspend fun processAudio(uri: Uri): MediaUploadInfo { + val file = copyToTmpFile(uri) + return MediaMetadataRetriever().runAndRelease { + setDataSource(context, Uri.fromFile(file)) + + val info = AudioInfo( + duration = extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0L, + size = file.length() + ) + + MediaUploadInfo.Audio(file, info) + } + } + + private suspend fun generateImageThumbnail(inputStream: InputStream): ThumbnailProcessingInfo? { + val thumbnailResult = imageCompressor + .compressToTmpFile( + inputStream = inputStream, + resizeMode = ResizeMode.Strict(THUMB_MAX_WIDTH, THUMB_MAX_HEIGHT), + ).getOrNull() + + return thumbnailResult?.toThumbnailProcessingInfo(MimeTypes.Jpeg) + } + + private fun removeSensitiveImageMetadata(file: File) { + // Remove GPS info, user comments and subject location tags + val exifInterface = ExifInterface(file) + // See ExifInterface.TAG_GPS_INFO_IFD_POINTER + exifInterface.setAttribute("GPSInfoIFDPointer", null) + exifInterface.setAttribute(ExifInterface.TAG_USER_COMMENT, null) + exifInterface.setAttribute(ExifInterface.TAG_SUBJECT_LOCATION, null) + tryOrNull { exifInterface.saveAttributes() } + } + + private suspend fun createTmpFileWithInput(inputStream: InputStream): File? { + return withContext(Dispatchers.IO) { + tryOrNull { + val tmpFile = context.createTmpFile() + tmpFile.outputStream().use { inputStream.copyTo(it) } + tmpFile + } + } + } + + private fun extractVideoMetadata(file: File, mimeType: String?, thumbnailUrl: String?, thumbnailInfo: ThumbnailInfo?): VideoInfo = + MediaMetadataRetriever().runAndRelease { + setDataSource(context, Uri.fromFile(file)) + + VideoInfo( + duration = extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0L, + width = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toLong() ?: 0L, + height = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toLong() ?: 0L, + mimetype = mimeType, + size = file.length(), + thumbnailInfo = thumbnailInfo, + thumbnailUrl = thumbnailUrl, + blurhash = null, + ) + } + + private suspend fun extractVideoThumbnail(uri: Uri): ThumbnailProcessingInfo? = + MediaMetadataRetriever().runAndRelease { + setDataSource(context, uri) + val bitmap = getFrameAtTime(VIDEO_THUMB_FRAME) ?: return@runAndRelease null + val inputStream = ByteArrayOutputStream().use { + bitmap.compress(Bitmap.CompressFormat.JPEG, 80, it) + ByteArrayInputStream(it.toByteArray()) + } + + val result = imageCompressor.compressToTmpFile( + inputStream = inputStream, + resizeMode = ResizeMode.Strict(THUMB_MAX_WIDTH, THUMB_MAX_HEIGHT), + ) + + result.getOrThrow().toThumbnailProcessingInfo(MimeTypes.Jpeg) + } + + private suspend fun copyToTmpFile(uri: Uri): File { + return contentResolver.openInputStream(uri)?.use { createTmpFileWithInput(it) } + ?: error("Could not copy the contents of $uri to a temporary file") + } +} + +fun ImageCompressionResult.toImageInfo(mimeType: String, thumbnailUrl: String?, thumbnailInfo: ThumbnailInfo?) = ImageInfo( + width = width.toLong(), + height = height.toLong(), + mimetype = mimeType, + size = size, + thumbnailInfo = thumbnailInfo, + thumbnailUrl = thumbnailUrl, + blurhash = null, +) + +fun ImageCompressionResult.toThumbnailProcessingInfo(mimeType: String) = ThumbnailProcessingInfo( + file = file, + info = ThumbnailInfo( + width = width.toLong(), + height = height.toLong(), + mimetype = mimeType, + size = size, + ), +) diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/VideoCompressor.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/VideoCompressor.kt new file mode 100644 index 0000000000..ea0dbf28fd --- /dev/null +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/VideoCompressor.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.mediaupload + +import android.content.Context +import android.net.Uri +import com.otaliastudios.transcoder.Transcoder +import com.otaliastudios.transcoder.TranscoderListener +import io.element.android.libraries.androidutils.file.createTmpFile +import io.element.android.libraries.di.ApplicationContext +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.callbackFlow +import java.io.File +import javax.inject.Inject + +class VideoCompressor @Inject constructor( + @ApplicationContext private val context: Context, +) { + + fun compress(uri: Uri) = callbackFlow { + val tmpFile = context.createTmpFile() + val future = Transcoder.into(tmpFile.path) + .addDataSource(context, uri) + .setListener(object : TranscoderListener { + override fun onTranscodeProgress(progress: Double) { + trySend(VideoTranscodingEvent.Progress(progress.toFloat())) + } + + override fun onTranscodeCompleted(successCode: Int) { + trySend(VideoTranscodingEvent.Completed(tmpFile)) + close() + } + + override fun onTranscodeCanceled() { + tmpFile.delete() + close() + } + + override fun onTranscodeFailed(exception: Throwable) { + tmpFile.delete() + close(exception) + } + }) + .transcode() + + awaitClose { + if (!future.isDone) { + future.cancel(true) + } + } + } +} + +sealed interface VideoTranscodingEvent { + data class Progress(val value: Float) : VideoTranscodingEvent + data class Completed(val file: File) : VideoTranscodingEvent +} diff --git a/libraries/mediaupload/test/build.gradle.kts b/libraries/mediaupload/test/build.gradle.kts new file mode 100644 index 0000000000..6daa89b152 --- /dev/null +++ b/libraries/mediaupload/test/build.gradle.kts @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.libraries.matrix.test" +} + +dependencies { + api(projects.libraries.mediaupload.api) +} diff --git a/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt b/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt new file mode 100644 index 0000000000..08a284af6c --- /dev/null +++ b/libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.mediaupload.test + +import android.net.Uri +import io.element.android.libraries.matrix.api.media.FileInfo +import io.element.android.libraries.mediaupload.api.MediaPreProcessor +import io.element.android.libraries.mediaupload.api.MediaType +import io.element.android.libraries.mediaupload.api.MediaUploadInfo +import java.io.File + +class FakeMediaPreProcessor : MediaPreProcessor { + + private var result: Result = Result.success( + MediaUploadInfo.AnyFile( + File("test"), + FileInfo( + mimetype = "*/*", + size = 999L, + thumbnailInfo = null, + thumbnailUrl = null, + ) + ) + ) + override suspend fun process(uri: Uri, mediaType: MediaType, deleteOriginal: Boolean): Result = result + + fun givenResult(value: Result) { + this.result = value + } +} diff --git a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt index 18ce6e55f8..885a830b96 100644 --- a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt +++ b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt @@ -90,7 +90,8 @@ fun DependencyHandlerScope.allLibrariesImpl() { implementation(project(":libraries:di")) implementation(project(":libraries:session-storage:impl")) implementation(project(":libraries:statemachine")) - implementation(project(":libraries:mediapickers")) + implementation(project(":libraries:mediapickers:impl")) + implementation(project(":libraries:mediaupload:impl")) } fun DependencyHandlerScope.allServicesImpl() {