Skip to content

Commit

Permalink
[coil-video] Support MediaDataSource implementations (#1795)
Browse files Browse the repository at this point in the history
* Implement issue coil#1790

* Address PR feedback

* Update public API file

* Set Timeout.NONE and remove ExperimentalCoilApi
  • Loading branch information
lukaspieper authored Jul 25, 2023
1 parent b904ac9 commit a031511
Show file tree
Hide file tree
Showing 7 changed files with 246 additions and 0 deletions.
16 changes: 16 additions & 0 deletions coil-video/api/coil-video.api
Original file line number Diff line number Diff line change
Expand Up @@ -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 <init> (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 <init> ()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 <init> (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;
Expand Down
38 changes: 38 additions & 0 deletions coil-video/src/androidTest/java/coil/FileMediaDataSource.kt
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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<MediaDataSourceFetcher>(
fetcherFactory.create(dataSource, Options(context), ImageLoader(context)),
)

val result = fetcher.fetch()

assertTrue(result is SourceResult)
assertEquals(null, result.mimeType)
assertIs<MediaDataSourceFetcher.MediaSourceMetadata>(result.source.metadata)
}
}
6 changes: 6 additions & 0 deletions coil-video/src/main/java/coil/decode/VideoFrameDecoder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
78 changes: 78 additions & 0 deletions coil-video/src/main/java/coil/fetch/MediaDataSourceFetcher.kt
Original file line number Diff line number Diff line change
@@ -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<MediaDataSource> {

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()
}

0 comments on commit a031511

Please sign in to comment.