Browse Source

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

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

* Split `mediapicker` and `mediaupload` modules.
feature/jme/open-room-member-details-when-clicking-on-user-data
Jorge Martin Espinosa 1 year ago committed by GitHub
parent
commit
5eaa40a14b
  1. 2
      .idea/kotlinc.xml
  2. 18
      app/src/main/AndroidManifest.xml
  3. 1
      changelog.d/396.feature
  4. 6
      features/messages/impl/build.gradle.kts
  5. 44
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenter.kt
  6. 6
      features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt
  7. 187
      features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt
  8. 2
      gradle/libs.versions.toml
  9. 1
      libraries/androidutils/build.gradle.kts
  10. 74
      libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/bitmap/Bitmap.kt
  11. 8
      libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt
  12. 24
      libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/media/MediaMetaDataRetriever.kt
  13. 4
      libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt
  14. 6
      libraries/mediapickers/api/build.gradle.kts
  15. 2
      libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerLauncher.kt
  16. 42
      libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerProvider.kt
  17. 2
      libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerType.kt
  18. 1
      libraries/mediapickers/api/src/test/kotlin/io/element/android/libraries/mediapickers/PickerTypeTests.kt
  19. 31
      libraries/mediapickers/impl/build.gradle.kts
  20. 46
      libraries/mediapickers/impl/src/main/kotlin/io/element/android/libraries/mediapickers/impl/PickerProviderImpl.kt
  21. 31
      libraries/mediapickers/test/build.gradle.kts
  22. 58
      libraries/mediapickers/test/src/main/kotlin/io/element/android/libraries/mediapickers/test/FakePickerProvider.kt
  23. 42
      libraries/mediaupload/api/build.gradle.kts
  24. 32
      libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaPreProcessor.kt
  25. 24
      libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaType.kt
  26. 36
      libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt
  27. 49
      libraries/mediaupload/impl/build.gradle.kts
  28. 117
      libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/ImageCompressor.kt
  29. 263
      libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/MediaPreProcessorImpl.kt
  30. 71
      libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/VideoCompressor.kt
  31. 27
      libraries/mediaupload/test/build.gradle.kts
  32. 44
      libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt
  33. 3
      plugins/src/main/kotlin/extension/DependencyHandleScope.kt

2
.idea/kotlinc.xml

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.8.20" />
<option name="version" value="1.8.21" />
</component>
</project>

18
app/src/main/AndroidManifest.xml

@ -29,15 +29,6 @@ @@ -29,15 +29,6 @@
android:theme="@style/Theme.ElementX"
tools:targetApi="33">
<provider
android:authorities="${applicationId}.fileprovider"
android:name="androidx.core.content.FileProvider"
android:grantUriPermissions="true"
android:exported="false">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_providers" />
</provider>
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
@ -66,6 +57,15 @@ @@ -66,6 +57,15 @@
android:exported="false"
tools:node="remove" />
<provider
android:authorities="${applicationId}.fileprovider"
android:name="androidx.core.content.FileProvider"
android:grantUriPermissions="true"
android:exported="false">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_providers" />
</provider>
</application>
</manifest>

1
changelog.d/396.feature

@ -0,0 +1 @@ @@ -0,0 +1 @@
Add media pre-processing before uploading it.

6
features/messages/impl/build.gradle.kts

@ -41,8 +41,9 @@ dependencies { @@ -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 { @@ -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)

44
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenter.kt

@ -16,6 +16,7 @@ @@ -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 @@ -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( @@ -46,27 +52,38 @@ class MessageComposerPresenter @Inject constructor(
private val room: MatrixRoom,
private val mediaPickerProvider: PickerProvider,
private val featureFlagService: FeatureFlagService,
private val mediaPreProcessor: MediaPreProcessor,
) : Presenter<MessageComposerState> {
@Composable
override fun present(): MessageComposerState {
val localCoroutineScope = rememberCoroutineScope()
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker(onResult = { uri ->
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker(onResult = { uri, mimeType ->
Timber.d("Media picked from $uri")
// We don't know which type of media was retrieved, so we need this check
val mediaType = when {
mimeType.isMimeTypeImage() -> MediaType.Image
mimeType.isMimeTypeVideo() -> MediaType.Video
else -> error("MimeType must be either image/* or video/*")
}
localCoroutineScope.handleMediaPreProcessing(uri, mediaType)
})
val filesPicker = mediaPickerProvider.registerFilePicker(onResult = { uri ->
val filesPicker = mediaPickerProvider.registerFilePicker(mimeType = MimeTypes.Any) { uri ->
Timber.d("File picked from $uri")
})
localCoroutineScope.handleMediaPreProcessing(uri, MediaType.File)
}
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(onResult = { uri ->
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri ->
Timber.d("Photo saved at $uri")
})
localCoroutineScope.handleMediaPreProcessing(uri, MediaType.Image, deleteOriginal = true)
}
val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker(onResult = { uri ->
val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker { uri ->
Timber.d("Video saved at $uri")
})
localCoroutineScope.handleMediaPreProcessing(uri, MediaType.Video, deleteOriginal = true)
}
val isFullScreen = rememberSaveable {
mutableStateOf(false)
@ -163,4 +180,15 @@ class MessageComposerPresenter @Inject constructor( @@ -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")
}
}

6
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 @@ -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 { @@ -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(),

187
features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt

@ -22,23 +22,30 @@ import app.cash.molecule.RecompositionClock @@ -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 @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -139,23 +129,9 @@ class MessageComposerPresenterTest {
}
}
private suspend fun ReceiveTurbine<MessageComposerState>.backToNormalMode(state: MessageComposerState, skipCount: Int = 0) {
state.eventSink.invoke(MessageComposerEvents.CloseSpecialMode)
skipItems(skipCount)
val normalState = awaitItem()
assertThat(normalState.mode).isEqualTo(MessageComposerMode.Normal(""))
assertThat(normalState.text).isEqualTo(StableCharSequence(""))
assertThat(normalState.isSendButtonVisible).isFalse()
}
@Test
fun `present - change mode to reply`() = runTest {
val presenter = MessageComposerPresenter(
this,
FakeMatrixRoom(),
pickerProvider,
featureFlagService,
)
val presenter = createPresenter(this)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
@ -172,12 +148,7 @@ class MessageComposerPresenterTest { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -415,6 +365,25 @@ class MessageComposerPresenterTest {
// TODO verify some post processing of the captured video is done
}
}
private suspend fun ReceiveTurbine<MessageComposerState>.backToNormalMode(state: MessageComposerState, skipCount: Int = 0) {
state.eventSink.invoke(MessageComposerEvents.CloseSpecialMode)
skipItems(skipCount)
val normalState = awaitItem()
assertThat(normalState.mode).isEqualTo(MessageComposerMode.Normal(""))
assertThat(normalState.text).isEqualTo(StableCharSequence(""))
assertThat(normalState.isSendButtonVisible).isFalse()
}
private fun createPresenter(
coroutineScope: CoroutineScope,
room: MatrixRoom = FakeMatrixRoom(),
pickerProvider: PickerProvider = this.pickerProvider,
featureFlagService: FeatureFlagService = this.featureFlagService,
mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor,
) = MessageComposerPresenter(
coroutineScope, room, pickerProvider, featureFlagService, mediaPreProcessor
)
}
fun anEditMode() = MessageComposerMode.Edit(AN_EVENT_ID, A_MESSAGE)

2
gradle/libs.versions.toml

@ -63,6 +63,7 @@ androidx_core = { module = "androidx.core:core", version.ref = "core" } @@ -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" @@ -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"

1
libraries/androidutils/build.gradle.kts

@ -26,6 +26,7 @@ dependencies { @@ -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)
}

74
libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/bitmap/Bitmap.kt

@ -17,7 +17,13 @@ @@ -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 @@ -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<Bitmap> =
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)
}

8
libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt

@ -16,9 +16,13 @@ @@ -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() { @@ -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() }
}

24
libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/media/MediaMetaDataRetriever.kt

@ -0,0 +1,24 @@ @@ -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 <T> MediaMetadataRetriever.runAndRelease(block: MediaMetadataRetriever.() -> T): T {
return block().also { release() }
}

4
libraries/core/src/main/kotlin/io/element/android/libraries/core/mimetype/MimeTypes.kt

@ -31,6 +31,10 @@ object MimeTypes { @@ -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"

6
libraries/mediapickers/build.gradle.kts → libraries/mediapickers/api/build.gradle.kts

@ -1,5 +1,5 @@ @@ -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 @@ @@ -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)

2
libraries/mediapickers/src/main/kotlin/io/element/android/libraries/mediapickers/PickerLauncher.kt → libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerLauncher.kt

@ -14,7 +14,7 @@ @@ -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

42
libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerProvider.kt

@ -0,0 +1,42 @@ @@ -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<PickVisualMediaRequest, Uri?>
@Composable
fun registerFilePicker(
mimeType: String,
onResult: (Uri?) -> Unit
): PickerLauncher<String, Uri?>
@Composable
fun registerCameraPhotoPicker(onResult: (Uri?) -> Unit): PickerLauncher<Uri, Boolean>
@Composable
fun registerCameraVideoPicker(onResult: (Uri?) -> Unit): PickerLauncher<Uri, Boolean>
}

2
libraries/mediapickers/src/main/kotlin/io/element/android/libraries/mediapickers/PickerType.kt → libraries/mediapickers/api/src/main/kotlin/io/element/android/libraries/mediapickers/api/PickerType.kt

@ -14,7 +14,7 @@ @@ -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

1
libraries/mediapickers/src/test/kotlin/io/element/android/libraries/mediapickers/PickerTypeTests.kt → libraries/mediapickers/api/src/test/kotlin/io/element/android/libraries/mediapickers/PickerTypeTests.kt

@ -20,6 +20,7 @@ import android.net.Uri @@ -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

31
libraries/mediapickers/impl/build.gradle.kts

@ -0,0 +1,31 @@ @@ -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)
}
}

46
libraries/mediapickers/src/main/kotlin/io/element/android/libraries/mediapickers/PickerProvider.kt → libraries/mediapickers/impl/src/main/kotlin/io/element/android/libraries/mediapickers/impl/PickerProviderImpl.kt

@ -14,7 +14,7 @@ @@ -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 @@ -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) { @@ -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<PickVisualMediaRequest, Uri?> {
override fun registerGalleryPicker(
onResult: (uri: Uri?, mimeType: String?) -> Unit
): PickerLauncher<PickVisualMediaRequest, Uri?> {
// 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) { @@ -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<String, Uri?> {
override fun registerFilePicker(
mimeType: String,
onResult: (Uri?) -> Unit,
): PickerLauncher<String, Uri?> {
// 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) { @@ -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<Uri, Boolean> {
override fun registerCameraPhotoPicker(onResult: (Uri?) -> Unit): PickerLauncher<Uri, Boolean> {
// 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) { @@ -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) { @@ -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<Uri, Boolean> {
override fun registerCameraVideoPicker(onResult: (Uri?) -> Unit): PickerLauncher<Uri, Boolean> {
// 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) { @@ -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()
}
}
}
}

31
libraries/mediapickers/test/build.gradle.kts

@ -0,0 +1,31 @@ @@ -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)
}
}

58
libraries/mediapickers/test/src/main/kotlin/io/element/android/libraries/mediapickers/test/FakePickerProvider.kt

@ -0,0 +1,58 @@ @@ -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<PickVisualMediaRequest, Uri?> {
return NoOpPickerLauncher { onResult(result, mimeType) }
}
@Composable
override fun registerFilePicker(mimeType: String, onResult: (Uri?) -> Unit): PickerLauncher<String, Uri?> {
return NoOpPickerLauncher { onResult(result) }
}
@Composable
override fun registerCameraPhotoPicker(onResult: (Uri?) -> Unit): PickerLauncher<Uri, Boolean> {
return NoOpPickerLauncher { onResult(result) }
}
@Composable
override fun registerCameraVideoPicker(onResult: (Uri?) -> Unit): PickerLauncher<Uri, Boolean> {
return NoOpPickerLauncher { onResult(result) }
}
fun givenResult(value: Uri?) {
this.result = value
}
fun givenMimeType(mimeType: String) {
this.mimeType = mimeType
}
}

42
libraries/mediaupload/api/build.gradle.kts

@ -0,0 +1,42 @@ @@ -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)
}
}

32
libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaPreProcessor.kt

@ -0,0 +1,32 @@ @@ -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<MediaUploadInfo>
}

24
libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaType.kt

@ -0,0 +1,24 @@ @@ -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
}

36
libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaUploadInfo.kt

@ -0,0 +1,36 @@ @@ -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,
)

49
libraries/mediaupload/impl/build.gradle.kts

@ -0,0 +1,49 @@ @@ -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)
}
}

117
libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/ImageCompressor.kt

@ -0,0 +1,117 @@ @@ -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<ImageCompressionResult> = 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<Bitmap> = 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
}

263
libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/MediaPreProcessorImpl.kt

@ -0,0 +1,263 @@ @@ -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<MediaUploadInfo> = 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<VideoTranscodingEvent.Completed>()
.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,
),
)

71
libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/VideoCompressor.kt

@ -0,0 +1,71 @@ @@ -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
}

27
libraries/mediaupload/test/build.gradle.kts

@ -0,0 +1,27 @@ @@ -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)
}

44
libraries/mediaupload/test/src/main/kotlin/io/element/android/libraries/mediaupload/test/FakeMediaPreProcessor.kt

@ -0,0 +1,44 @@ @@ -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<MediaUploadInfo> = 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<MediaUploadInfo> = result
fun givenResult(value: Result<MediaUploadInfo>) {
this.result = value
}
}

3
plugins/src/main/kotlin/extension/DependencyHandleScope.kt

@ -90,7 +90,8 @@ fun DependencyHandlerScope.allLibrariesImpl() { @@ -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() {

Loading…
Cancel
Save