[Media upload] Media pre-processing (#403)
* Create `mediaupload` module for media pre-processing. * Split `mediapicker` and `mediaupload` modules.
This commit is contained in:
committed by
GitHub
parent
1b39a782c5
commit
cd298b9359
2
.idea/kotlinc.xml
generated
2
.idea/kotlinc.xml
generated
@@ -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>
|
||||
@@ -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 @@
|
||||
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
Normal file
1
changelog.d/396.feature
Normal file
@@ -0,0 +1 @@
|
||||
Add media pre-processing before uploading it.
|
||||
@@ -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)
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
package io.element.android.features.messages.impl.textcomposer
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
@@ -28,12 +29,17 @@ import androidx.compose.runtime.setValue
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.data.StableCharSequence
|
||||
import io.element.android.libraries.core.data.toStableCharSequence
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.di.SingleIn
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.mediapickers.PickerProvider
|
||||
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
||||
import io.element.android.libraries.mediaupload.api.MediaType
|
||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -46,27 +52,38 @@ class MessageComposerPresenter @Inject constructor(
|
||||
private val room: MatrixRoom,
|
||||
private val mediaPickerProvider: PickerProvider,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val mediaPreProcessor: MediaPreProcessor,
|
||||
) : Presenter<MessageComposerState> {
|
||||
|
||||
@Composable
|
||||
override fun present(): MessageComposerState {
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
|
||||
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker(onResult = { uri ->
|
||||
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker(onResult = { uri, mimeType ->
|
||||
Timber.d("Media picked from $uri")
|
||||
// We don't know which type of media was retrieved, so we need this check
|
||||
val mediaType = when {
|
||||
mimeType.isMimeTypeImage() -> MediaType.Image
|
||||
mimeType.isMimeTypeVideo() -> MediaType.Video
|
||||
else -> error("MimeType must be either image/* or video/*")
|
||||
}
|
||||
localCoroutineScope.handleMediaPreProcessing(uri, mediaType)
|
||||
})
|
||||
|
||||
val filesPicker = mediaPickerProvider.registerFilePicker(onResult = { uri ->
|
||||
val filesPicker = mediaPickerProvider.registerFilePicker(mimeType = MimeTypes.Any) { uri ->
|
||||
Timber.d("File picked from $uri")
|
||||
})
|
||||
localCoroutineScope.handleMediaPreProcessing(uri, MediaType.File)
|
||||
}
|
||||
|
||||
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(onResult = { uri ->
|
||||
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri ->
|
||||
Timber.d("Photo saved at $uri")
|
||||
})
|
||||
localCoroutineScope.handleMediaPreProcessing(uri, MediaType.Image, deleteOriginal = true)
|
||||
}
|
||||
|
||||
val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker(onResult = { uri ->
|
||||
val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker { uri ->
|
||||
Timber.d("Video saved at $uri")
|
||||
})
|
||||
localCoroutineScope.handleMediaPreProcessing(uri, MediaType.Video, deleteOriginal = true)
|
||||
}
|
||||
|
||||
val isFullScreen = rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
@@ -163,4 +180,15 @@ class MessageComposerPresenter @Inject constructor(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.handleMediaPreProcessing(
|
||||
uri: Uri?,
|
||||
mediaType: MediaType,
|
||||
deleteOriginal: Boolean = false
|
||||
) = launch {
|
||||
if (uri == null) return@launch
|
||||
|
||||
val result = mediaPreProcessor.process(uri, mediaType, deleteOriginal = deleteOriginal)
|
||||
Timber.d("Pre-processed media result: $result")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -22,23 +22,30 @@ import app.cash.molecule.RecompositionClock
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.ReceiveTurbine
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.messages.impl.textcomposer.AttachmentSourcePicker
|
||||
import io.element.android.features.messages.impl.textcomposer.MessageComposerEvents
|
||||
import io.element.android.features.messages.impl.textcomposer.MessageComposerPresenter
|
||||
import io.element.android.features.messages.impl.textcomposer.MessageComposerState
|
||||
import io.element.android.libraries.core.data.StableCharSequence
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.test.ANOTHER_MESSAGE
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.A_MESSAGE
|
||||
import io.element.android.libraries.matrix.test.A_REPLY
|
||||
import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.mediapickers.PickerProvider
|
||||
import io.element.android.libraries.mediapickers.api.PickerProvider
|
||||
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
||||
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
|
||||
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
||||
import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.runTest
|
||||
@@ -46,21 +53,19 @@ import org.junit.Test
|
||||
|
||||
class MessageComposerPresenterTest {
|
||||
|
||||
private val pickerProvider = PickerProvider(isInTest = true)
|
||||
private val pickerProvider = FakePickerProvider().apply {
|
||||
givenResult(mockk()) // Uri is not available in JVM, so the only way to have a non-null Uri is using Mockk
|
||||
}
|
||||
private val featureFlagService = FakeFeatureFlagService().apply {
|
||||
runBlocking {
|
||||
setFeatureEnabled(FeatureFlags.ShowMediaUploadingFlow, true)
|
||||
}
|
||||
}
|
||||
private val mediaPreProcessor = FakeMediaPreProcessor()
|
||||
|
||||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
val presenter = MessageComposerPresenter(
|
||||
this,
|
||||
FakeMatrixRoom(),
|
||||
pickerProvider,
|
||||
featureFlagService,
|
||||
)
|
||||
val presenter = createPresenter(this)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
@@ -74,12 +79,7 @@ class MessageComposerPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - toggle fullscreen`() = runTest {
|
||||
val presenter = MessageComposerPresenter(
|
||||
this,
|
||||
FakeMatrixRoom(),
|
||||
pickerProvider,
|
||||
featureFlagService,
|
||||
)
|
||||
val presenter = createPresenter(this)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
@@ -95,12 +95,7 @@ class MessageComposerPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - change message`() = runTest {
|
||||
val presenter = MessageComposerPresenter(
|
||||
this,
|
||||
FakeMatrixRoom(),
|
||||
pickerProvider,
|
||||
featureFlagService,
|
||||
)
|
||||
val presenter = createPresenter(this)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
@@ -118,12 +113,7 @@ class MessageComposerPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - change mode to edit`() = runTest {
|
||||
val presenter = MessageComposerPresenter(
|
||||
this,
|
||||
FakeMatrixRoom(),
|
||||
pickerProvider,
|
||||
featureFlagService,
|
||||
)
|
||||
val presenter = createPresenter(this)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
@@ -139,23 +129,9 @@ class MessageComposerPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ReceiveTurbine<MessageComposerState>.backToNormalMode(state: MessageComposerState, skipCount: Int = 0) {
|
||||
state.eventSink.invoke(MessageComposerEvents.CloseSpecialMode)
|
||||
skipItems(skipCount)
|
||||
val normalState = awaitItem()
|
||||
assertThat(normalState.mode).isEqualTo(MessageComposerMode.Normal(""))
|
||||
assertThat(normalState.text).isEqualTo(StableCharSequence(""))
|
||||
assertThat(normalState.isSendButtonVisible).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - change mode to reply`() = runTest {
|
||||
val presenter = MessageComposerPresenter(
|
||||
this,
|
||||
FakeMatrixRoom(),
|
||||
pickerProvider,
|
||||
featureFlagService,
|
||||
)
|
||||
val presenter = createPresenter(this)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
@@ -172,12 +148,7 @@ class MessageComposerPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - change mode to quote`() = runTest {
|
||||
val presenter = MessageComposerPresenter(
|
||||
this,
|
||||
FakeMatrixRoom(),
|
||||
pickerProvider,
|
||||
featureFlagService,
|
||||
)
|
||||
val presenter = createPresenter(this)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
@@ -194,12 +165,7 @@ class MessageComposerPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - send message`() = runTest {
|
||||
val presenter = MessageComposerPresenter(
|
||||
this,
|
||||
FakeMatrixRoom(),
|
||||
pickerProvider,
|
||||
featureFlagService,
|
||||
)
|
||||
val presenter = createPresenter(this)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
@@ -218,11 +184,9 @@ class MessageComposerPresenterTest {
|
||||
@Test
|
||||
fun `present - edit message`() = runTest {
|
||||
val fakeMatrixRoom = FakeMatrixRoom()
|
||||
val presenter = MessageComposerPresenter(
|
||||
val presenter = createPresenter(
|
||||
this,
|
||||
fakeMatrixRoom,
|
||||
pickerProvider,
|
||||
featureFlagService,
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
@@ -251,11 +215,9 @@ class MessageComposerPresenterTest {
|
||||
@Test
|
||||
fun `present - reply message`() = runTest {
|
||||
val fakeMatrixRoom = FakeMatrixRoom()
|
||||
val presenter = MessageComposerPresenter(
|
||||
val presenter = createPresenter(
|
||||
this,
|
||||
fakeMatrixRoom,
|
||||
pickerProvider,
|
||||
featureFlagService,
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
@@ -283,13 +245,7 @@ class MessageComposerPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - Open attachments menu`() = runTest {
|
||||
val fakeMatrixRoom = FakeMatrixRoom()
|
||||
val presenter = MessageComposerPresenter(
|
||||
this,
|
||||
fakeMatrixRoom,
|
||||
pickerProvider,
|
||||
featureFlagService,
|
||||
)
|
||||
val presenter = createPresenter(this)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
@@ -302,13 +258,7 @@ class MessageComposerPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - Open camera attachments menu`() = runTest {
|
||||
val fakeMatrixRoom = FakeMatrixRoom()
|
||||
val presenter = MessageComposerPresenter(
|
||||
this,
|
||||
fakeMatrixRoom,
|
||||
pickerProvider,
|
||||
featureFlagService,
|
||||
)
|
||||
val presenter = createPresenter(this)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
@@ -321,13 +271,7 @@ class MessageComposerPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - Dismiss attachments menu`() = runTest {
|
||||
val fakeMatrixRoom = FakeMatrixRoom()
|
||||
val presenter = MessageComposerPresenter(
|
||||
this,
|
||||
fakeMatrixRoom,
|
||||
pickerProvider,
|
||||
featureFlagService,
|
||||
)
|
||||
val presenter = createPresenter(this)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
@@ -342,13 +286,8 @@ class MessageComposerPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - Pick media from gallery`() = runTest {
|
||||
val fakeMatrixRoom = FakeMatrixRoom()
|
||||
val presenter = MessageComposerPresenter(
|
||||
this,
|
||||
fakeMatrixRoom,
|
||||
pickerProvider,
|
||||
featureFlagService,
|
||||
)
|
||||
val presenter = createPresenter(this)
|
||||
pickerProvider.givenMimeType(MimeTypes.Images)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
@@ -359,15 +298,38 @@ class MessageComposerPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Pick media from gallery fails if returned mimetype is not video or image`() = runTest {
|
||||
val presenter = createPresenter(this)
|
||||
pickerProvider.givenMimeType(MimeTypes.Audio)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery)
|
||||
assertThat(awaitError()).isInstanceOf(IllegalStateException::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Pick media from gallery & cancel does nothing`() = runTest {
|
||||
val presenter = createPresenter(this)
|
||||
with(pickerProvider){
|
||||
givenResult(null) // Simulate a user canceling the flow
|
||||
givenMimeType(MimeTypes.Images)
|
||||
}
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery)
|
||||
// No crashes here, otherwise it fails
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Pick file from storage`() = runTest {
|
||||
val fakeMatrixRoom = FakeMatrixRoom()
|
||||
val presenter = MessageComposerPresenter(
|
||||
this,
|
||||
fakeMatrixRoom,
|
||||
pickerProvider,
|
||||
featureFlagService,
|
||||
)
|
||||
val presenter = createPresenter(this)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
@@ -380,13 +342,7 @@ class MessageComposerPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - Take photo`() = runTest {
|
||||
val fakeMatrixRoom = FakeMatrixRoom()
|
||||
val presenter = MessageComposerPresenter(
|
||||
this,
|
||||
fakeMatrixRoom,
|
||||
pickerProvider,
|
||||
featureFlagService,
|
||||
)
|
||||
val presenter = createPresenter(this)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
@@ -399,13 +355,7 @@ class MessageComposerPresenterTest {
|
||||
|
||||
@Test
|
||||
fun `present - Record video`() = runTest {
|
||||
val fakeMatrixRoom = FakeMatrixRoom()
|
||||
val presenter = MessageComposerPresenter(
|
||||
this,
|
||||
fakeMatrixRoom,
|
||||
pickerProvider,
|
||||
featureFlagService,
|
||||
)
|
||||
val presenter = createPresenter(this)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
@@ -415,6 +365,25 @@ class MessageComposerPresenterTest {
|
||||
// TODO verify some post processing of the captured video is done
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ReceiveTurbine<MessageComposerState>.backToNormalMode(state: MessageComposerState, skipCount: Int = 0) {
|
||||
state.eventSink.invoke(MessageComposerEvents.CloseSpecialMode)
|
||||
skipItems(skipCount)
|
||||
val normalState = awaitItem()
|
||||
assertThat(normalState.mode).isEqualTo(MessageComposerMode.Normal(""))
|
||||
assertThat(normalState.text).isEqualTo(StableCharSequence(""))
|
||||
assertThat(normalState.isSendButtonVisible).isFalse()
|
||||
}
|
||||
|
||||
private fun createPresenter(
|
||||
coroutineScope: CoroutineScope,
|
||||
room: MatrixRoom = FakeMatrixRoom(),
|
||||
pickerProvider: PickerProvider = this.pickerProvider,
|
||||
featureFlagService: FeatureFlagService = this.featureFlagService,
|
||||
mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor,
|
||||
) = MessageComposerPresenter(
|
||||
coroutineScope, room, pickerProvider, featureFlagService, mediaPreProcessor
|
||||
)
|
||||
}
|
||||
|
||||
fun anEditMode() = MessageComposerMode.Edit(AN_EVENT_ID, A_MESSAGE)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<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)
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
Normal file
31
libraries/mediapickers/impl/build.gradle.kts
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<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) {
|
||||
* [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) {
|
||||
/**
|
||||
* 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) {
|
||||
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<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) {
|
||||
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
Normal file
31
libraries/mediapickers/test/build.gradle.kts
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
Normal file
42
libraries/mediaupload/api/build.gradle.kts
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
Normal file
49
libraries/mediaupload/impl/build.gradle.kts
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
@@ -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
Normal file
27
libraries/mediaupload/test/build.gradle.kts
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user