diff --git a/.gitattributes b/.gitattributes index 2062142284..d0cf036126 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ **/snapshots/**/*.png filter=lfs diff=lfs merge=lfs -text **/docs/images-lfs/*.png filter=lfs diff=lfs merge=lfs -text +libraries/mediaupload/impl/src/test/assets/* filter=lfs diff=lfs merge=lfs -text diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt index 983d016854..28d403c9a6 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt @@ -33,6 +33,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import timber.log.Timber import kotlin.coroutines.coroutineContext class AttachmentsPreviewPresenter @AssistedInject constructor( @@ -114,6 +115,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor( sendActionState.value = SendActionState.Done }, onFailure = { error -> + Timber.e(error, "Failed to send attachment") if (error is CancellationException) { throw error } else { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index 04ca34ac08..7a5c06721c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -72,6 +72,7 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.merge import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject import kotlin.coroutines.coroutineContext import kotlin.time.Duration.Companion.seconds @@ -432,6 +433,7 @@ class MessageComposerPresenter @Inject constructor( attachmentState.value = AttachmentsState.None } .onFailure { cause -> + Timber.e(cause, "Failed to send attachment") attachmentState.value = AttachmentsState.None if (cause is CancellationException) { throw cause diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt index 8ec2d2b7b8..5bf2310048 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt @@ -580,7 +580,7 @@ class RustMatrixRoom( onNewSyncedEvent = { _syncUpdateFlow.value = systemClock.epochMillis() } ) - private suspend fun sendAttachment(files: List, handle: () -> SendAttachmentJoinHandle): Result { + private fun sendAttachment(files: List, handle: () -> SendAttachmentJoinHandle): Result { return runCatching { MediaUploadHandlerImpl(files, handle()) } diff --git a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaPreProcessor.kt b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaPreProcessor.kt index 31c6a813ff..9a48b50aca 100644 --- a/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaPreProcessor.kt +++ b/libraries/mediaupload/api/src/main/kotlin/io/element/android/libraries/mediaupload/api/MediaPreProcessor.kt @@ -31,6 +31,6 @@ interface MediaPreProcessor { compressIfPossible: Boolean ): Result - data class Failure(override val cause: Throwable?) : RuntimeException(cause) + data class Failure(override val cause: Throwable?) : Exception(cause) } diff --git a/libraries/mediaupload/impl/build.gradle.kts b/libraries/mediaupload/impl/build.gradle.kts index a23ab14b74..c65b6b01a6 100644 --- a/libraries/mediaupload/impl/build.gradle.kts +++ b/libraries/mediaupload/impl/build.gradle.kts @@ -27,6 +27,12 @@ android { generateDaggerFactories.set(true) } + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } + dependencies { implementation(projects.anvilannotations) anvil(projects.anvilcodegen) @@ -37,6 +43,7 @@ android { implementation(projects.libraries.core) implementation(projects.libraries.di) implementation(projects.libraries.matrix.api) + implementation(projects.services.toolbox.api) implementation(libs.inject) implementation(libs.androidx.exifinterface) implementation(libs.coroutines.core) @@ -44,7 +51,10 @@ android { implementation(libs.vanniktech.blurhash) testImplementation(libs.test.junit) + testImplementation(libs.test.robolectric) testImplementation(libs.coroutines.test) testImplementation(libs.test.truth) + testImplementation(projects.tests.testutils) + testImplementation(projects.services.toolbox.test) } } diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/ImageCompressor.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/ImageCompressor.kt index d936b3a5bf..9f93818576 100644 --- a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/ImageCompressor.kt +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/ImageCompressor.kt @@ -24,8 +24,8 @@ 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.core.coroutine.CoroutineDispatchers import io.element.android.libraries.di.ApplicationContext -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File import java.io.InputStream @@ -33,8 +33,8 @@ import javax.inject.Inject class ImageCompressor @Inject constructor( @ApplicationContext private val context: Context, + private val dispatchers: CoroutineDispatchers, ) { - /** * 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], [orientation] and [desiredQuality]. @@ -46,7 +46,7 @@ class ImageCompressor @Inject constructor( format: Bitmap.CompressFormat = Bitmap.CompressFormat.JPEG, orientation: Int = ExifInterface.ORIENTATION_UNDEFINED, desiredQuality: Int = 80, - ): Result = withContext(Dispatchers.IO) { + ): Result = withContext(dispatchers.io) { runCatching { val compressedBitmap = compressToBitmap(inputStreamProvider, resizeMode, orientation).getOrThrow() // Encode bitmap to the destination temporary file diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/ThumbnailFactory.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/ThumbnailFactory.kt index 2cee2566d1..40a75b6428 100644 --- a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/ThumbnailFactory.kt +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/ThumbnailFactory.kt @@ -32,6 +32,7 @@ import io.element.android.libraries.androidutils.media.runAndRelease import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.matrix.api.media.ThumbnailInfo +import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider import kotlinx.coroutines.suspendCancellableCoroutine import java.io.File import javax.inject.Inject @@ -56,13 +57,14 @@ private const val VIDEO_THUMB_FRAME = 0L class ThumbnailFactory @Inject constructor( @ApplicationContext private val context: Context, + private val sdkIntProvider: BuildVersionSdkIntProvider ) { @SuppressLint("NewApi") suspend fun createImageThumbnail(file: File): ThumbnailResult { return createThumbnail { cancellationSignal -> // This API works correctly with GIF - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + if (sdkIntProvider.isAtLeast(Build.VERSION_CODES.Q)) { ThumbnailUtils.createImageThumbnail( file, Size(THUMB_MAX_WIDTH, THUMB_MAX_HEIGHT), diff --git a/libraries/mediaupload/impl/src/test/assets/animated_gif.gif b/libraries/mediaupload/impl/src/test/assets/animated_gif.gif new file mode 100644 index 0000000000..8fd2ce1550 --- /dev/null +++ b/libraries/mediaupload/impl/src/test/assets/animated_gif.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6831610b21668c49e31732f9005177e959277233d3cab758910e061294f91d79 +size 687979 diff --git a/libraries/mediaupload/impl/src/test/assets/image.png b/libraries/mediaupload/impl/src/test/assets/image.png new file mode 100644 index 0000000000..b0946f7ba8 --- /dev/null +++ b/libraries/mediaupload/impl/src/test/assets/image.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a980f7b74cb9edc323919db8652798da4b3dcf865fc7b6a1eb1110096b7bfb4f +size 1856786 diff --git a/libraries/mediaupload/impl/src/test/assets/sample3s.mp3 b/libraries/mediaupload/impl/src/test/assets/sample3s.mp3 new file mode 100644 index 0000000000..cb4db9c9d8 --- /dev/null +++ b/libraries/mediaupload/impl/src/test/assets/sample3s.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0244590f2b4bcb62352b574e78bea940e8d89cfa69823b5208ef4c43e0abcb44 +size 52079 diff --git a/libraries/mediaupload/impl/src/test/assets/text.txt b/libraries/mediaupload/impl/src/test/assets/text.txt new file mode 100644 index 0000000000..d45ec4338b --- /dev/null +++ b/libraries/mediaupload/impl/src/test/assets/text.txt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0ba904eae8773b70c75333db4de2f3ac45a8ad4ddba1b242f0b3cfc199391dd8 +size 13 diff --git a/libraries/mediaupload/impl/src/test/assets/video.mp4 b/libraries/mediaupload/impl/src/test/assets/video.mp4 new file mode 100644 index 0000000000..4d57318b1d --- /dev/null +++ b/libraries/mediaupload/impl/src/test/assets/video.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fb58436524db95bd0c10b2c3023c2eb7b87404a2eab8987939f051647eb859d3 +size 1673712 diff --git a/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/AndroidMediaPreProcessorTest.kt b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/AndroidMediaPreProcessorTest.kt new file mode 100644 index 0000000000..3d745b3ec3 --- /dev/null +++ b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/AndroidMediaPreProcessorTest.kt @@ -0,0 +1,347 @@ +/* + * 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.os.Build +import androidx.core.net.toUri +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.core.mimetype.MimeTypes +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.MediaUploadInfo +import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Ignore +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File +import java.io.FileNotFoundException +import java.io.IOException +import kotlin.time.Duration + +@RunWith(RobolectricTestRunner::class) +class AndroidMediaPreProcessorTest { + @Test + fun `test processing image`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val sut = createAndroidMediaPreProcessor(context) + val file = getFileFromAssets(context, "image.png") + val result = sut.process( + uri = file.toUri(), + mimeType = MimeTypes.Png, + deleteOriginal = false, + compressIfPossible = true, + ) + // This is failing for now + val error = result.exceptionOrNull() + assertThat(error).isInstanceOf(MediaPreProcessor.Failure::class.java) + assertThat(error?.cause).isInstanceOf(NullPointerException::class.java) + /* + val data = result.getOrThrow() + assertThat(data.file.path).endsWith("image.png") + val info = data as MediaUploadInfo.Image + assertThat(info.thumbnailFile).isNull() // TODO Check this + assertThat(info.imageInfo).isEqualTo( + ImageInfo( + height = 1_178, + width = 1_818, + mimetype = MimeTypes.Png, + size = 114_867, + thumbnailInfo = null, + thumbnailSource = null, + blurhash = null, + ) + ) + assertThat(file.exists()).isTrue() + */ + } + + @Test + fun `test processing image api Q`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val sut = createAndroidMediaPreProcessor(context, sdkIntVersion = Build.VERSION_CODES.Q) + val file = getFileFromAssets(context, "image.png") + val result = sut.process( + uri = file.toUri(), + mimeType = MimeTypes.Png, + deleteOriginal = false, + compressIfPossible = true, + ) + // This is not working for now + val error = result.exceptionOrNull() + assertThat(error).isInstanceOf(MediaPreProcessor.Failure::class.java) + assertThat(error?.cause).isInstanceOf(NoSuchMethodError::class.java) + /* + val data = result.getOrThrow() + assertThat(data.file.path).endsWith("image.png") + val info = data as MediaUploadInfo.Image + assertThat(info.thumbnailFile).isNull() // TODO Check this + assertThat(info.imageInfo).isEqualTo( + ImageInfo( + height = 1_178, + width = 1_818, + mimetype = MimeTypes.Png, + size = 114_867, + thumbnailInfo = null, + thumbnailSource = null, + blurhash = null, + ) + ) + assertThat(file.exists()).isTrue() + */ + } + + @Test + fun `test processing image no compression`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val sut = createAndroidMediaPreProcessor(context) + val file = getFileFromAssets(context, "image.png") + val result = sut.process( + uri = file.toUri(), + mimeType = MimeTypes.Png, + deleteOriginal = false, + compressIfPossible = false, + ).getOrThrow() + assertThat(result.file.path).endsWith("image.png") + val info = result as MediaUploadInfo.Image + assertThat(info.thumbnailFile).isNotNull() + assertThat(info.imageInfo).isEqualTo( + ImageInfo( + height = 1_178, + width = 1_818, + mimetype = MimeTypes.Png, + size = 1_856_786, + thumbnailInfo = ThumbnailInfo(height = 25, width = 25, mimetype = MimeTypes.Jpeg, size = 643), + thumbnailSource = null, + blurhash = "K00000fQfQfQfQfQfQfQfQ", + ) + ) + assertThat(file.exists()).isTrue() + } + + @Test + fun `test processing image and delete`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val sut = createAndroidMediaPreProcessor(context) + val file = getFileFromAssets(context, "image.png") + val result = sut.process( + uri = file.toUri(), + mimeType = MimeTypes.Png, + deleteOriginal = true, + compressIfPossible = false, + ).getOrThrow() + assertThat(result.file.path).endsWith("image.png") + val info = result as MediaUploadInfo.Image + assertThat(info.thumbnailFile).isNotNull() + assertThat(info.imageInfo).isEqualTo( + ImageInfo( + height = 1_178, + width = 1_818, + mimetype = MimeTypes.Png, + size = 1_856_786, + thumbnailInfo = ThumbnailInfo(height = 25, width = 25, mimetype = MimeTypes.Jpeg, size = 643), + thumbnailSource = null, + blurhash = "K00000fQfQfQfQfQfQfQfQ", + ) + ) + // Does not work + // assertThat(file.exists()).isFalse() + } + + @Test + fun `test processing gif`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val sut = createAndroidMediaPreProcessor(context) + val file = getFileFromAssets(context, "animated_gif.gif") + val result = sut.process( + uri = file.toUri(), + mimeType = MimeTypes.Gif, + deleteOriginal = false, + compressIfPossible = true, + ).getOrThrow() + assertThat(result.file.path).endsWith("animated_gif.gif") + val info = result as MediaUploadInfo.Image + assertThat(info.thumbnailFile).isNotNull() + assertThat(info.imageInfo).isEqualTo( + ImageInfo( + height = 600, + width = 800, + mimetype = MimeTypes.Gif, + size = 687_979, + thumbnailInfo = ThumbnailInfo(height = 50, width = 50, mimetype = MimeTypes.Jpeg, size = 691), + thumbnailSource = null, + blurhash = "K00000fQfQfQfQfQfQfQfQ", + ) + ) + assertThat(file.exists()).isTrue() + } + + @Test + fun `test processing file`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val sut = createAndroidMediaPreProcessor(context) + val file = getFileFromAssets(context, "text.txt") + val result = sut.process( + uri = file.toUri(), + mimeType = MimeTypes.PlainText, + deleteOriginal = false, + compressIfPossible = true, + ).getOrThrow() + assertThat(result.file.path).endsWith("text.txt") + val info = result as MediaUploadInfo.AnyFile + assertThat(info.fileInfo).isEqualTo( + FileInfo( + mimetype = MimeTypes.PlainText, + size = 13, + thumbnailInfo = null, + thumbnailSource = null, + ) + ) + assertThat(file.exists()).isTrue() + } + + @Ignore("Compressing video is not working with Robolectric") + @Test + fun `test processing video`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val sut = createAndroidMediaPreProcessor(context) + val file = getFileFromAssets(context, "video.mp4") + val result = sut.process( + uri = file.toUri(), + mimeType = MimeTypes.Mp4, + deleteOriginal = false, + compressIfPossible = true, + ).getOrThrow() + assertThat(result.file.path).endsWith("video.mp4") + val info = result as MediaUploadInfo.Video + assertThat(info.thumbnailFile).isNotNull() + assertThat(info.videoInfo).isEqualTo( + VideoInfo( + duration = Duration.ZERO, // Not available with Robolectric? + height = 1_178, + width = 1_818, + mimetype = MimeTypes.Mp4, + size = 114_867, + thumbnailInfo = null, + thumbnailSource = null, + blurhash = null, + ) + ) + assertThat(file.exists()).isTrue() + } + + @Test + fun `test processing video no compression`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val sut = createAndroidMediaPreProcessor(context) + val file = getFileFromAssets(context, "video.mp4") + val result = sut.process( + uri = file.toUri(), + mimeType = MimeTypes.Mp4, + deleteOriginal = false, + compressIfPossible = false, + ).getOrThrow() + assertThat(result.file.path).endsWith("video.mp4") + val info = result as MediaUploadInfo.Video + assertThat(info.thumbnailFile).isNotNull() + assertThat(info.videoInfo).isEqualTo( + VideoInfo( + duration = Duration.ZERO, // Not available with Robolectric? + height = 0, // Not available with Robolectric? + width = 0, // Not available with Robolectric? + mimetype = MimeTypes.Mp4, + size = 1_673_712, + thumbnailInfo = ThumbnailInfo(height = null, width = null, mimetype = MimeTypes.Jpeg, size = 0), // Not available with Robolectric? + thumbnailSource = null, + blurhash = null, + ) + ) + assertThat(file.exists()).isTrue() + } + + @Test + fun `test processing audio`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val sut = createAndroidMediaPreProcessor(context) + val file = getFileFromAssets(context, "sample3s.mp3") + val result = sut.process( + uri = file.toUri(), + mimeType = MimeTypes.Mp3, + deleteOriginal = false, + compressIfPossible = true, + ).getOrThrow() + assertThat(result.file.path).endsWith("sample3s.mp3") + val info = result as MediaUploadInfo.Audio + assertThat(info.audioInfo).isEqualTo( + AudioInfo( + duration = Duration.ZERO, // Not available with Robolectric? + size = 52_079, + mimetype = MimeTypes.Mp3, + ) + ) + assertThat(file.exists()).isTrue() + } + + @Test + fun `test file which does not exist`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val sut = createAndroidMediaPreProcessor(context) + val file = File(context.cacheDir, "not found.txt") + val result = sut.process( + uri = file.toUri(), + mimeType = MimeTypes.PlainText, + deleteOriginal = false, + compressIfPossible = true, + ) + assertThat(result.isFailure).isTrue() + val failure = result.exceptionOrNull() + assertThat(failure).isInstanceOf(MediaPreProcessor.Failure::class.java) + assertThat(failure?.cause).isInstanceOf(FileNotFoundException::class.java) + } + + private fun TestScope.createAndroidMediaPreProcessor( + context: Context, + sdkIntVersion: Int = Build.VERSION_CODES.P + ) = AndroidMediaPreProcessor( + context = context, + thumbnailFactory = ThumbnailFactory(context, FakeBuildVersionSdkIntProvider(sdkIntVersion)), + imageCompressor = ImageCompressor(context, testCoroutineDispatchers()), + videoCompressor = VideoCompressor(context), + coroutineDispatchers = testCoroutineDispatchers(), + ) + + @Throws(IOException::class) + private fun getFileFromAssets(context: Context, fileName: String): File = File(context.cacheDir, fileName) + .also { + if (!it.exists()) { + it.outputStream().use { cache -> + context.assets.open(fileName).use { inputStream -> + inputStream.copyTo(cache) + } + } + } + } +}