[Media upload] Media pre-processing (#403)
* Create `mediaupload` module for media pre-processing. * Split `mediapicker` and `mediaupload` modules.
This commit is contained in:
parent
7c02e7ad4b
commit
5eaa40a14b
33 changed files with 1148 additions and 156 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue