From a031511879f0e55468058d80a86890ec5f527649 Mon Sep 17 00:00:00 2001 From: Lukas Pieper <30661176+lukaspieper@users.noreply.github.com> Date: Tue, 25 Jul 2023 20:22:04 +0200 Subject: [PATCH] [coil-video] Support MediaDataSource implementations (#1795) * Implement issue coil#1790 * Address PR feedback * Update public API file * Set Timeout.NONE and remove ExperimentalCoilApi --- coil-video/api/coil-video.api | 16 ++++ .../java/coil/FileMediaDataSource.kt | 38 +++++++++ .../decode/MediaDataSourceOkioSourceTest.kt | 36 +++++++++ .../java/coil/decode/VideoFrameDecoderTest.kt | 27 +++++++ .../coil/fetch/MediaDataSourceFetcherTest.kt | 45 +++++++++++ .../java/coil/decode/VideoFrameDecoder.kt | 6 ++ .../java/coil/fetch/MediaDataSourceFetcher.kt | 78 +++++++++++++++++++ 7 files changed, 246 insertions(+) create mode 100644 coil-video/src/androidTest/java/coil/FileMediaDataSource.kt create mode 100644 coil-video/src/androidTest/java/coil/decode/MediaDataSourceOkioSourceTest.kt create mode 100644 coil-video/src/androidTest/java/coil/fetch/MediaDataSourceFetcherTest.kt create mode 100644 coil-video/src/main/java/coil/fetch/MediaDataSourceFetcher.kt diff --git a/coil-video/api/coil-video.api b/coil-video/api/coil-video.api index 895a5a6270..7ca8656523 100644 --- a/coil-video/api/coil-video.api +++ b/coil-video/api/coil-video.api @@ -17,6 +17,22 @@ public final class coil/decode/VideoFrameDecoder$Factory : coil/decode/Decoder$F public fun hashCode ()I } +public final class coil/fetch/MediaDataSourceFetcher : coil/fetch/Fetcher { + public fun (Landroid/media/MediaDataSource;Lcoil/request/Options;)V + public fun fetch (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class coil/fetch/MediaDataSourceFetcher$Factory : coil/fetch/Fetcher$Factory { + public fun ()V + public fun create (Landroid/media/MediaDataSource;Lcoil/request/Options;Lcoil/ImageLoader;)Lcoil/fetch/Fetcher; + public synthetic fun create (Ljava/lang/Object;Lcoil/request/Options;Lcoil/ImageLoader;)Lcoil/fetch/Fetcher; +} + +public final class coil/fetch/MediaDataSourceFetcher$MediaSourceMetadata : coil/decode/ImageSource$Metadata { + public fun (Landroid/media/MediaDataSource;)V + public final fun getMediaDataSource ()Landroid/media/MediaDataSource; +} + public final class coil/request/Videos { public static final fun videoFrameMicros (Lcoil/request/ImageRequest$Builder;J)Lcoil/request/ImageRequest$Builder; public static final fun videoFrameMicros (Lcoil/request/Parameters;)Ljava/lang/Long; diff --git a/coil-video/src/androidTest/java/coil/FileMediaDataSource.kt b/coil-video/src/androidTest/java/coil/FileMediaDataSource.kt new file mode 100644 index 0000000000..1c901ecd45 --- /dev/null +++ b/coil-video/src/androidTest/java/coil/FileMediaDataSource.kt @@ -0,0 +1,38 @@ +package coil + +import android.media.MediaDataSource +import android.os.Build +import androidx.annotation.RequiresApi +import java.io.File +import java.io.RandomAccessFile + +@RequiresApi(Build.VERSION_CODES.M) +class FileMediaDataSource(private val file: File) : MediaDataSource() { + + private var randomAccessFile: RandomAccessFile? = null + + override fun readAt(position: Long, buffer: ByteArray?, offset: Int, size: Int): Int { + synchronized(file) { + if (randomAccessFile == null) { + randomAccessFile = RandomAccessFile(file, "r") + } + + if (position >= getSize()) { + // indicates EOF + return -1 + } + + val sizeToRead = minOf(size, (getSize() - position).toInt()) + randomAccessFile!!.seek(position) + return randomAccessFile!!.read(buffer, offset, sizeToRead) + } + } + + override fun getSize(): Long { + return file.length() + } + + override fun close() { + randomAccessFile?.close() + } +} diff --git a/coil-video/src/androidTest/java/coil/decode/MediaDataSourceOkioSourceTest.kt b/coil-video/src/androidTest/java/coil/decode/MediaDataSourceOkioSourceTest.kt new file mode 100644 index 0000000000..be9a3d8ee1 --- /dev/null +++ b/coil-video/src/androidTest/java/coil/decode/MediaDataSourceOkioSourceTest.kt @@ -0,0 +1,36 @@ +package coil.decode + +import android.content.Context +import android.os.Build.VERSION.SDK_INT +import androidx.test.core.app.ApplicationProvider +import coil.FileMediaDataSource +import coil.fetch.MediaDataSourceFetcher +import coil.util.assumeTrue +import coil.util.copyAssetToFile +import kotlinx.coroutines.test.runTest +import okio.buffer +import org.junit.Assert.assertArrayEquals +import org.junit.Before +import org.junit.Test + +class MediaDataSourceOkioSourceTest { + + private lateinit var context: Context + + @Before + fun before() { + context = ApplicationProvider.getApplicationContext() + } + + @Test + fun mediaDataSourceOkioSource() = runTest { + assumeTrue(SDK_INT >= 23) + val file = context.copyAssetToFile("video_frame_1.jpg") + + val expected = file.readBytes() + val source = MediaDataSourceFetcher.MediaDataSourceOkioSource(FileMediaDataSource(file)) + val actual = source.buffer().readByteArray() + + assertArrayEquals(expected, actual) + } +} diff --git a/coil-video/src/androidTest/java/coil/decode/VideoFrameDecoderTest.kt b/coil-video/src/androidTest/java/coil/decode/VideoFrameDecoderTest.kt index 388a01c28a..819144fc89 100644 --- a/coil-video/src/androidTest/java/coil/decode/VideoFrameDecoderTest.kt +++ b/coil-video/src/androidTest/java/coil/decode/VideoFrameDecoderTest.kt @@ -4,13 +4,16 @@ import android.content.Context import android.graphics.drawable.BitmapDrawable import android.os.Build.VERSION.SDK_INT import androidx.test.core.app.ApplicationProvider +import coil.FileMediaDataSource import coil.decode.VideoFrameDecoder.Companion.VIDEO_FRAME_MICROS_KEY import coil.decode.VideoFrameDecoder.Companion.VIDEO_FRAME_PERCENT_KEY +import coil.fetch.MediaDataSourceFetcher import coil.request.Options import coil.request.Parameters import coil.size.Size import coil.util.assertIsSimilarTo import coil.util.assumeTrue +import coil.util.copyAssetToFile import coil.util.decodeBitmapAsset import kotlin.test.assertFalse import kotlin.test.assertNotNull @@ -148,4 +151,28 @@ class VideoFrameDecoderTest { val expected = context.decodeBitmapAsset("video_frame_1.jpg") actual.assertIsSimilarTo(expected) } + + @Test + fun mediaDataSource() = runTest(timeout = 1.minutes) { + // MediaMetadataRetriever does not work on the emulator pre-API 23. + assumeTrue(SDK_INT >= 23) + val file = context.copyAssetToFile("video.mp4") + + val dataSource = FileMediaDataSource(file) + val result = VideoFrameDecoder( + source = ImageSource( + source = MediaDataSourceFetcher.MediaDataSourceOkioSource(dataSource).buffer(), + context = context, + metadata = MediaDataSourceFetcher.MediaSourceMetadata(dataSource), + ), + options = Options(context), + ).decode() + + val actual = (result.drawable as? BitmapDrawable)?.bitmap + assertNotNull(actual) + assertFalse(result.isSampled) + + val expected = context.decodeBitmapAsset("video_frame_1.jpg") + actual.assertIsSimilarTo(expected) + } } diff --git a/coil-video/src/androidTest/java/coil/fetch/MediaDataSourceFetcherTest.kt b/coil-video/src/androidTest/java/coil/fetch/MediaDataSourceFetcherTest.kt new file mode 100644 index 0000000000..9b6b017291 --- /dev/null +++ b/coil-video/src/androidTest/java/coil/fetch/MediaDataSourceFetcherTest.kt @@ -0,0 +1,45 @@ +package coil.fetch + +import android.content.Context +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import coil.FileMediaDataSource +import coil.ImageLoader +import coil.request.Options +import coil.util.assumeTrue +import coil.util.copyAssetToFile +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +class MediaDataSourceFetcherTest { + + private lateinit var context: Context + private lateinit var fetcherFactory: MediaDataSourceFetcher.Factory + + @Before + fun before() { + context = ApplicationProvider.getApplicationContext() + fetcherFactory = MediaDataSourceFetcher.Factory() + } + + @Test + fun basic() = runTest { + assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) + val file = context.copyAssetToFile("video.mp4") + + val dataSource = FileMediaDataSource(file) + val fetcher = assertIs( + fetcherFactory.create(dataSource, Options(context), ImageLoader(context)), + ) + + val result = fetcher.fetch() + + assertTrue(result is SourceResult) + assertEquals(null, result.mimeType) + assertIs(result.source.metadata) + } +} diff --git a/coil-video/src/main/java/coil/decode/VideoFrameDecoder.kt b/coil-video/src/main/java/coil/decode/VideoFrameDecoder.kt index 5885eb8896..20f4bb8002 100644 --- a/coil-video/src/main/java/coil/decode/VideoFrameDecoder.kt +++ b/coil-video/src/main/java/coil/decode/VideoFrameDecoder.kt @@ -13,6 +13,7 @@ import androidx.core.graphics.applyCanvas import androidx.core.graphics.createBitmap import androidx.core.graphics.drawable.toDrawable import coil.ImageLoader +import coil.fetch.MediaDataSourceFetcher.MediaSourceMetadata import coil.fetch.SourceResult import coil.request.Options import coil.request.videoFrameMicros @@ -182,6 +183,11 @@ class VideoFrameDecoder( } private fun MediaMetadataRetriever.setDataSource(source: ImageSource) { + if (SDK_INT >= 23 && source.metadata is MediaSourceMetadata) { + setDataSource((source.metadata as MediaSourceMetadata).mediaDataSource) + return + } + when (val metadata = source.metadata) { is AssetMetadata -> { options.context.assets.openFd(metadata.filePath).use { diff --git a/coil-video/src/main/java/coil/fetch/MediaDataSourceFetcher.kt b/coil-video/src/main/java/coil/fetch/MediaDataSourceFetcher.kt new file mode 100644 index 0000000000..edc2c50175 --- /dev/null +++ b/coil-video/src/main/java/coil/fetch/MediaDataSourceFetcher.kt @@ -0,0 +1,78 @@ +package coil.fetch + +import android.media.MediaDataSource +import android.os.Build +import androidx.annotation.RequiresApi +import coil.ImageLoader +import coil.decode.DataSource +import coil.decode.ImageSource +import coil.request.Options +import okio.Buffer +import okio.Source +import okio.Timeout +import okio.buffer + +@RequiresApi(Build.VERSION_CODES.M) +class MediaDataSourceFetcher( + private val data: MediaDataSource, + private val options: Options, +) : Fetcher { + + override suspend fun fetch(): FetchResult { + val imageSource = ImageSource( + source = MediaDataSourceOkioSource(data).buffer(), + context = options.context, + metadata = MediaSourceMetadata(data), + ) + + return SourceResult( + source = imageSource, + mimeType = null, + dataSource = DataSource.DISK + ) + } + + class Factory : Fetcher.Factory { + + override fun create( + data: MediaDataSource, + options: Options, + imageLoader: ImageLoader, + ): Fetcher { + return MediaDataSourceFetcher(data, options) + } + } + + internal class MediaDataSourceOkioSource(private val mediaDataSource: MediaDataSource) : Source { + + private var size = mediaDataSource.size + private var position: Long = 0L + + override fun read(sink: Buffer, byteCount: Long): Long { + if (position >= size) { + // indicates EOF + return -1 + } + + val sizeToRead = minOf(byteCount, size - position) + val byteArray = ByteArray(sizeToRead.toInt()) + val readBytes = mediaDataSource.readAt(position, byteArray, 0, byteArray.size) + + position += readBytes + sink.write(byteArray, 0, readBytes) + + return readBytes.toLong() + } + + override fun timeout(): Timeout { + return Timeout.NONE + } + + override fun close() { + mediaDataSource.close() + } + } + + @RequiresApi(Build.VERSION_CODES.M) + class MediaSourceMetadata(val mediaDataSource: MediaDataSource) : ImageSource.Metadata() +}