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() {