[Media upload] Media pre-processing (#403)
* Create `mediaupload` module for media pre-processing. * Split `mediapicker` and `mediaupload` modules.
This commit is contained in:
parent
1b39a782c5
commit
cd298b9359
33 changed files with 1148 additions and 156 deletions
2
.idea/kotlinc.xml
generated
2
.idea/kotlinc.xml
generated
|
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="KotlinJpsPluginSettings">
|
<component name="KotlinJpsPluginSettings">
|
||||||
<option name="version" value="1.8.20" />
|
<option name="version" value="1.8.21" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
|
|
@ -29,15 +29,6 @@
|
||||||
android:theme="@style/Theme.ElementX"
|
android:theme="@style/Theme.ElementX"
|
||||||
tools:targetApi="33">
|
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
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
|
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
|
||||||
|
|
@ -66,6 +57,15 @@
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
tools:node="remove" />
|
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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</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.textcomposer)
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
implementation(projects.libraries.dateformatter.api)
|
implementation(projects.libraries.dateformatter.api)
|
||||||
implementation(projects.libraries.mediapickers)
|
implementation(projects.libraries.mediapickers.api)
|
||||||
implementation(projects.libraries.featureflag.api)
|
implementation(projects.libraries.featureflag.api)
|
||||||
|
implementation(projects.libraries.mediaupload.api)
|
||||||
implementation(projects.features.networkmonitor.api)
|
implementation(projects.features.networkmonitor.api)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
implementation(libs.datetime)
|
implementation(libs.datetime)
|
||||||
|
|
@ -60,6 +61,9 @@ dependencies {
|
||||||
testImplementation(projects.features.networkmonitor.test)
|
testImplementation(projects.features.networkmonitor.test)
|
||||||
testImplementation(projects.tests.testutils)
|
testImplementation(projects.tests.testutils)
|
||||||
testImplementation(projects.libraries.featureflag.test)
|
testImplementation(projects.libraries.featureflag.test)
|
||||||
|
testImplementation(projects.libraries.mediaupload.test)
|
||||||
|
testImplementation(projects.libraries.mediapickers.test)
|
||||||
|
testImplementation(libs.test.mockk)
|
||||||
|
|
||||||
androidTestImplementation(libs.test.junitext)
|
androidTestImplementation(libs.test.junitext)
|
||||||
ksp(libs.showkase.processor)
|
ksp(libs.showkase.processor)
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.textcomposer
|
package io.element.android.features.messages.impl.textcomposer
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.MutableState
|
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.architecture.Presenter
|
||||||
import io.element.android.libraries.core.data.StableCharSequence
|
import io.element.android.libraries.core.data.StableCharSequence
|
||||||
import io.element.android.libraries.core.data.toStableCharSequence
|
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.RoomScope
|
||||||
import io.element.android.libraries.di.SingleIn
|
import io.element.android.libraries.di.SingleIn
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
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 io.element.android.libraries.textcomposer.MessageComposerMode
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
@ -46,27 +52,38 @@ class MessageComposerPresenter @Inject constructor(
|
||||||
private val room: MatrixRoom,
|
private val room: MatrixRoom,
|
||||||
private val mediaPickerProvider: PickerProvider,
|
private val mediaPickerProvider: PickerProvider,
|
||||||
private val featureFlagService: FeatureFlagService,
|
private val featureFlagService: FeatureFlagService,
|
||||||
|
private val mediaPreProcessor: MediaPreProcessor,
|
||||||
) : Presenter<MessageComposerState> {
|
) : Presenter<MessageComposerState> {
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): MessageComposerState {
|
override fun present(): MessageComposerState {
|
||||||
val localCoroutineScope = rememberCoroutineScope()
|
val localCoroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker(onResult = { uri ->
|
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker(onResult = { uri, mimeType ->
|
||||||
Timber.d("Media picked from $uri")
|
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")
|
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")
|
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")
|
Timber.d("Video saved at $uri")
|
||||||
})
|
localCoroutineScope.handleMediaPreProcessing(uri, MediaType.Video, deleteOriginal = true)
|
||||||
|
}
|
||||||
|
|
||||||
val isFullScreen = rememberSaveable {
|
val isFullScreen = rememberSaveable {
|
||||||
mutableStateOf(false)
|
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.AN_EVENT_ID
|
||||||
import io.element.android.libraries.matrix.test.A_ROOM_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.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 io.element.android.libraries.textcomposer.MessageComposerMode
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
|
@ -132,8 +133,9 @@ class MessagesPresenterTest {
|
||||||
val messageComposerPresenter = MessageComposerPresenter(
|
val messageComposerPresenter = MessageComposerPresenter(
|
||||||
appCoroutineScope = this,
|
appCoroutineScope = this,
|
||||||
room = matrixRoom,
|
room = matrixRoom,
|
||||||
mediaPickerProvider = PickerProvider(isInTest = true),
|
mediaPickerProvider = FakePickerProvider(),
|
||||||
featureFlagService = FakeFeatureFlagService(),
|
featureFlagService = FakeFeatureFlagService(),
|
||||||
|
mediaPreProcessor = FakeMediaPreProcessor(),
|
||||||
)
|
)
|
||||||
val timelinePresenter = TimelinePresenter(
|
val timelinePresenter = TimelinePresenter(
|
||||||
timelineItemsFactory = aTimelineItemsFactory(),
|
timelineItemsFactory = aTimelineItemsFactory(),
|
||||||
|
|
|
||||||
|
|
@ -22,23 +22,30 @@ import app.cash.molecule.RecompositionClock
|
||||||
import app.cash.molecule.moleculeFlow
|
import app.cash.molecule.moleculeFlow
|
||||||
import app.cash.turbine.ReceiveTurbine
|
import app.cash.turbine.ReceiveTurbine
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.messages.impl.textcomposer.AttachmentSourcePicker
|
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.MessageComposerEvents
|
||||||
import io.element.android.features.messages.impl.textcomposer.MessageComposerPresenter
|
import io.element.android.features.messages.impl.textcomposer.MessageComposerPresenter
|
||||||
import io.element.android.features.messages.impl.textcomposer.MessageComposerState
|
import io.element.android.features.messages.impl.textcomposer.MessageComposerState
|
||||||
import io.element.android.libraries.core.data.StableCharSequence
|
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.api.FeatureFlags
|
||||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
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.ANOTHER_MESSAGE
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
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_MESSAGE
|
||||||
import io.element.android.libraries.matrix.test.A_REPLY
|
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.A_USER_NAME
|
||||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
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.element.android.libraries.textcomposer.MessageComposerMode
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
|
|
@ -46,21 +53,19 @@ import org.junit.Test
|
||||||
|
|
||||||
class MessageComposerPresenterTest {
|
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 {
|
private val featureFlagService = FakeFeatureFlagService().apply {
|
||||||
runBlocking {
|
runBlocking {
|
||||||
setFeatureEnabled(FeatureFlags.ShowMediaUploadingFlow, true)
|
setFeatureEnabled(FeatureFlags.ShowMediaUploadingFlow, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
private val mediaPreProcessor = FakeMediaPreProcessor()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - initial state`() = runTest {
|
fun `present - initial state`() = runTest {
|
||||||
val presenter = MessageComposerPresenter(
|
val presenter = createPresenter(this)
|
||||||
this,
|
|
||||||
FakeMatrixRoom(),
|
|
||||||
pickerProvider,
|
|
||||||
featureFlagService,
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -74,12 +79,7 @@ class MessageComposerPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - toggle fullscreen`() = runTest {
|
fun `present - toggle fullscreen`() = runTest {
|
||||||
val presenter = MessageComposerPresenter(
|
val presenter = createPresenter(this)
|
||||||
this,
|
|
||||||
FakeMatrixRoom(),
|
|
||||||
pickerProvider,
|
|
||||||
featureFlagService,
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -95,12 +95,7 @@ class MessageComposerPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - change message`() = runTest {
|
fun `present - change message`() = runTest {
|
||||||
val presenter = MessageComposerPresenter(
|
val presenter = createPresenter(this)
|
||||||
this,
|
|
||||||
FakeMatrixRoom(),
|
|
||||||
pickerProvider,
|
|
||||||
featureFlagService,
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -118,12 +113,7 @@ class MessageComposerPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - change mode to edit`() = runTest {
|
fun `present - change mode to edit`() = runTest {
|
||||||
val presenter = MessageComposerPresenter(
|
val presenter = createPresenter(this)
|
||||||
this,
|
|
||||||
FakeMatrixRoom(),
|
|
||||||
pickerProvider,
|
|
||||||
featureFlagService,
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.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
|
@Test
|
||||||
fun `present - change mode to reply`() = runTest {
|
fun `present - change mode to reply`() = runTest {
|
||||||
val presenter = MessageComposerPresenter(
|
val presenter = createPresenter(this)
|
||||||
this,
|
|
||||||
FakeMatrixRoom(),
|
|
||||||
pickerProvider,
|
|
||||||
featureFlagService,
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -172,12 +148,7 @@ class MessageComposerPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - change mode to quote`() = runTest {
|
fun `present - change mode to quote`() = runTest {
|
||||||
val presenter = MessageComposerPresenter(
|
val presenter = createPresenter(this)
|
||||||
this,
|
|
||||||
FakeMatrixRoom(),
|
|
||||||
pickerProvider,
|
|
||||||
featureFlagService,
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -194,12 +165,7 @@ class MessageComposerPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - send message`() = runTest {
|
fun `present - send message`() = runTest {
|
||||||
val presenter = MessageComposerPresenter(
|
val presenter = createPresenter(this)
|
||||||
this,
|
|
||||||
FakeMatrixRoom(),
|
|
||||||
pickerProvider,
|
|
||||||
featureFlagService,
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -218,11 +184,9 @@ class MessageComposerPresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `present - edit message`() = runTest {
|
fun `present - edit message`() = runTest {
|
||||||
val fakeMatrixRoom = FakeMatrixRoom()
|
val fakeMatrixRoom = FakeMatrixRoom()
|
||||||
val presenter = MessageComposerPresenter(
|
val presenter = createPresenter(
|
||||||
this,
|
this,
|
||||||
fakeMatrixRoom,
|
fakeMatrixRoom,
|
||||||
pickerProvider,
|
|
||||||
featureFlagService,
|
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -251,11 +215,9 @@ class MessageComposerPresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `present - reply message`() = runTest {
|
fun `present - reply message`() = runTest {
|
||||||
val fakeMatrixRoom = FakeMatrixRoom()
|
val fakeMatrixRoom = FakeMatrixRoom()
|
||||||
val presenter = MessageComposerPresenter(
|
val presenter = createPresenter(
|
||||||
this,
|
this,
|
||||||
fakeMatrixRoom,
|
fakeMatrixRoom,
|
||||||
pickerProvider,
|
|
||||||
featureFlagService,
|
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -283,13 +245,7 @@ class MessageComposerPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - Open attachments menu`() = runTest {
|
fun `present - Open attachments menu`() = runTest {
|
||||||
val fakeMatrixRoom = FakeMatrixRoom()
|
val presenter = createPresenter(this)
|
||||||
val presenter = MessageComposerPresenter(
|
|
||||||
this,
|
|
||||||
fakeMatrixRoom,
|
|
||||||
pickerProvider,
|
|
||||||
featureFlagService,
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -302,13 +258,7 @@ class MessageComposerPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - Open camera attachments menu`() = runTest {
|
fun `present - Open camera attachments menu`() = runTest {
|
||||||
val fakeMatrixRoom = FakeMatrixRoom()
|
val presenter = createPresenter(this)
|
||||||
val presenter = MessageComposerPresenter(
|
|
||||||
this,
|
|
||||||
fakeMatrixRoom,
|
|
||||||
pickerProvider,
|
|
||||||
featureFlagService,
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -321,13 +271,7 @@ class MessageComposerPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - Dismiss attachments menu`() = runTest {
|
fun `present - Dismiss attachments menu`() = runTest {
|
||||||
val fakeMatrixRoom = FakeMatrixRoom()
|
val presenter = createPresenter(this)
|
||||||
val presenter = MessageComposerPresenter(
|
|
||||||
this,
|
|
||||||
fakeMatrixRoom,
|
|
||||||
pickerProvider,
|
|
||||||
featureFlagService,
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -342,13 +286,8 @@ class MessageComposerPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - Pick media from gallery`() = runTest {
|
fun `present - Pick media from gallery`() = runTest {
|
||||||
val fakeMatrixRoom = FakeMatrixRoom()
|
val presenter = createPresenter(this)
|
||||||
val presenter = MessageComposerPresenter(
|
pickerProvider.givenMimeType(MimeTypes.Images)
|
||||||
this,
|
|
||||||
fakeMatrixRoom,
|
|
||||||
pickerProvider,
|
|
||||||
featureFlagService,
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.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
|
@Test
|
||||||
fun `present - Pick file from storage`() = runTest {
|
fun `present - Pick file from storage`() = runTest {
|
||||||
val fakeMatrixRoom = FakeMatrixRoom()
|
val presenter = createPresenter(this)
|
||||||
val presenter = MessageComposerPresenter(
|
|
||||||
this,
|
|
||||||
fakeMatrixRoom,
|
|
||||||
pickerProvider,
|
|
||||||
featureFlagService,
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -380,13 +342,7 @@ class MessageComposerPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - Take photo`() = runTest {
|
fun `present - Take photo`() = runTest {
|
||||||
val fakeMatrixRoom = FakeMatrixRoom()
|
val presenter = createPresenter(this)
|
||||||
val presenter = MessageComposerPresenter(
|
|
||||||
this,
|
|
||||||
fakeMatrixRoom,
|
|
||||||
pickerProvider,
|
|
||||||
featureFlagService,
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -399,13 +355,7 @@ class MessageComposerPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - Record video`() = runTest {
|
fun `present - Record video`() = runTest {
|
||||||
val fakeMatrixRoom = FakeMatrixRoom()
|
val presenter = createPresenter(this)
|
||||||
val presenter = MessageComposerPresenter(
|
|
||||||
this,
|
|
||||||
fakeMatrixRoom,
|
|
||||||
pickerProvider,
|
|
||||||
featureFlagService,
|
|
||||||
)
|
|
||||||
moleculeFlow(RecompositionClock.Immediate) {
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -415,6 +365,25 @@ class MessageComposerPresenterTest {
|
||||||
// TODO verify some post processing of the captured video is done
|
// 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)
|
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_corektx = { module = "androidx.core:core-ktx", version.ref = "core" }
|
||||||
androidx_datastore_preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
|
androidx_datastore_preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
|
||||||
androidx_datastore_datastore = { module = "androidx.datastore:datastore", 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_constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" }
|
||||||
androidx_recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" }
|
androidx_recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" }
|
||||||
androidx_lifecycle_runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
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"
|
sqlite = "androidx.sqlite:sqlite:2.3.1"
|
||||||
unifiedpush = "com.github.UnifiedPush:android-connector:2.1.1"
|
unifiedpush = "com.github.UnifiedPush:android-connector:2.1.1"
|
||||||
gujun_span = "me.gujun.android:span:1.7"
|
gujun_span = "me.gujun.android:span:1.7"
|
||||||
|
otaliastudios_transcoder = "com.otaliastudios:transcoder:0.10.5"
|
||||||
|
|
||||||
# Di
|
# Di
|
||||||
inject = "javax.inject:javax.inject:1"
|
inject = "javax.inject:javax.inject:1"
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ dependencies {
|
||||||
implementation(libs.timber)
|
implementation(libs.timber)
|
||||||
implementation(libs.androidx.corektx)
|
implementation(libs.androidx.corektx)
|
||||||
implementation(libs.androidx.activity.activity)
|
implementation(libs.androidx.activity.activity)
|
||||||
|
implementation(libs.androidx.exifinterface)
|
||||||
implementation(libs.androidx.security.crypto)
|
implementation(libs.androidx.security.crypto)
|
||||||
implementation(projects.libraries.core)
|
implementation(projects.libraries.core)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,13 @@
|
||||||
package io.element.android.libraries.androidutils.bitmap
|
package io.element.android.libraries.androidutils.bitmap
|
||||||
|
|
||||||
import android.graphics.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.File
|
||||||
|
import java.io.InputStream
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
fun File.writeBitmap(bitmap: Bitmap, format: Bitmap.CompressFormat, quality: Int) {
|
fun File.writeBitmap(bitmap: Bitmap, format: Bitmap.CompressFormat, quality: Int) {
|
||||||
outputStream().use { out ->
|
outputStream().use { out ->
|
||||||
|
|
@ -25,3 +31,71 @@ fun File.writeBitmap(bitmap: Bitmap, format: Bitmap.CompressFormat, quality: Int
|
||||||
out.flush()
|
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
|
package io.element.android.libraries.androidutils.file
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import io.element.android.libraries.core.data.tryOrNull
|
import io.element.android.libraries.core.data.tryOrNull
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
fun File.safeDelete() {
|
fun File.safeDelete() {
|
||||||
tryOrNull(
|
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 Jpeg = "image/jpeg"
|
||||||
const val Gif = "image/gif"
|
const val Gif = "image/gif"
|
||||||
|
|
||||||
|
const val Videos = "video/*"
|
||||||
|
|
||||||
|
const val Audio = "audio/*"
|
||||||
|
|
||||||
const val Ogg = "audio/ogg"
|
const val Ogg = "audio/ogg"
|
||||||
|
|
||||||
const val PlainText = "text/plain"
|
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");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
|
@ -16,14 +16,16 @@
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("io.element.android-compose-library")
|
id("io.element.android-compose-library")
|
||||||
|
alias(libs.plugins.anvil)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "io.element.android.libraries.mediapickers"
|
namespace = "io.element.android.libraries.mediapickers.api"
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
implementation(projects.libraries.core)
|
implementation(projects.libraries.core)
|
||||||
|
implementation(projects.libraries.di)
|
||||||
implementation(libs.inject)
|
implementation(libs.inject)
|
||||||
|
|
||||||
testImplementation(libs.test.junit)
|
testImplementation(libs.test.junit)
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.libraries.mediapickers
|
package io.element.android.libraries.mediapickers.api
|
||||||
|
|
||||||
import androidx.activity.compose.ManagedActivityResultLauncher
|
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.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.libraries.mediapickers
|
package io.element.android.libraries.mediapickers.api
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.activity.result.PickVisualMediaRequest
|
import androidx.activity.result.PickVisualMediaRequest
|
||||||
|
|
@ -20,6 +20,7 @@ import android.net.Uri
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
|
import io.element.android.libraries.mediapickers.api.PickerType
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.robolectric.RobolectricTestRunner
|
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.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.libraries.mediapickers
|
package io.element.android.libraries.mediapickers.impl
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
|
@ -25,12 +25,19 @@ import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalInspectionMode
|
import androidx.compose.ui.platform.LocalInspectionMode
|
||||||
import androidx.core.content.FileProvider
|
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.io.File
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class PickerProvider constructor(private val isInTest: Boolean) {
|
@ContributesBinding(AppScope::class)
|
||||||
|
class PickerProviderImpl constructor(private val isInTest: Boolean) : PickerProvider {
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
constructor(): this(false)
|
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.
|
* [onResult] will be called with either the selected file's [Uri] or `null` if nothing was selected.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@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
|
// Tests and UI preview can't handle Contexts, so we might as well disable the whole picker
|
||||||
return if (LocalInspectionMode.current || isInTest) {
|
return if (LocalInspectionMode.current || isInTest) {
|
||||||
NoOpPickerLauncher { onResult(null) }
|
NoOpPickerLauncher { onResult(null, null) }
|
||||||
} else {
|
} 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.
|
* [onResult] will be called with either the selected file's [Uri] or `null` if nothing was selected.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@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
|
// Tests and UI preview can't handle Context or FileProviders, so we might as well disable the whole picker
|
||||||
return if (LocalInspectionMode.current || isInTest) {
|
return if (LocalInspectionMode.current || isInTest) {
|
||||||
NoOpPickerLauncher { onResult(null) }
|
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.
|
* 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 [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
|
@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
|
// Tests and UI preview can't handle Context or FileProviders, so we might as well disable the whole picker
|
||||||
return if (LocalInspectionMode.current || isInTest) {
|
return if (LocalInspectionMode.current || isInTest) {
|
||||||
NoOpPickerLauncher { onResult(null) }
|
NoOpPickerLauncher { onResult(null) }
|
||||||
|
|
@ -98,10 +112,6 @@ class PickerProvider constructor(private val isInTest: Boolean) {
|
||||||
rememberPickerLauncher(type = PickerType.Camera.Photo(tmpFileUri)) { success ->
|
rememberPickerLauncher(type = PickerType.Camera.Photo(tmpFileUri)) { success ->
|
||||||
// Execute callback
|
// Execute callback
|
||||||
onResult(if (success) tmpFileUri else null)
|
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.
|
* 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 [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
|
@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
|
// Tests and UI preview can't handle Context or FileProviders, so we might as well disable the whole picker
|
||||||
return if (LocalInspectionMode.current || isInTest) {
|
return if (LocalInspectionMode.current || isInTest) {
|
||||||
NoOpPickerLauncher { onResult(null) }
|
NoOpPickerLauncher { onResult(null) }
|
||||||
|
|
@ -124,10 +132,6 @@ class PickerProvider constructor(private val isInTest: Boolean) {
|
||||||
rememberPickerLauncher(type = PickerType.Camera.Video(tmpFileUri)) { success ->
|
rememberPickerLauncher(type = PickerType.Camera.Video(tmpFileUri)) { success ->
|
||||||
// Execute callback
|
// Execute callback
|
||||||
onResult(if (success) tmpFileUri else null)
|
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:di"))
|
||||||
implementation(project(":libraries:session-storage:impl"))
|
implementation(project(":libraries:session-storage:impl"))
|
||||||
implementation(project(":libraries:statemachine"))
|
implementation(project(":libraries:statemachine"))
|
||||||
implementation(project(":libraries:mediapickers"))
|
implementation(project(":libraries:mediapickers:impl"))
|
||||||
|
implementation(project(":libraries:mediaupload:impl"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun DependencyHandlerScope.allServicesImpl() {
|
fun DependencyHandlerScope.allServicesImpl() {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue