Skip to content

Commit

Permalink
Prevent sub-sampling of AVIF images
Browse files Browse the repository at this point in the history
Addresses #118
  • Loading branch information
saket committed Jan 27, 2025
1 parent 8375cb5 commit 9d2e2b2
Show file tree
Hide file tree
Showing 9 changed files with 119 additions and 7 deletions.
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,22 @@ class CoilImageSourceTest {
}
}

@Test fun avif_images_should_not_be_sub_sampled() = runTest {
serverRule.server.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
check(request.path!!.endsWith(".avif"))
return assetAsResponse("full_image.avif")
}
}

resolve {
serverRule.server.url("full_image.avif")
}.test {
skipItems(1) // Default item.
assertThat(awaitItem().delegate!!).isNotInstanceOf(ZoomableImageSource.SubSamplingDelegate::class.java)
}
}

// Regression test for https://github.com/saket/telephoto/issues/37.
@Test fun reload_image_if_its_evicted_from_the_disk_cache_but_is_still_present_in_the_memory_cache() = runTest {
val memoryCache = context.imageLoader.memoryCache!!
Expand Down
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,22 @@ class Coil3ImageSourceTest {
}
}

@Test fun avif_images_should_not_be_sub_sampled() = runTest {
serverRule.server.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
check(request.path!!.endsWith(".avif"))
return assetAsResponse("full_image.avif")
}
}

resolve {
serverRule.server.url("full_image.avif").toString()
}.test {
skipItems(1) // Default item.
assertThat(awaitItem().delegate!!).isNotInstanceOf(ZoomableImageSource.SubSamplingDelegate::class.java)
}
}

// Regression test for https://github.com/saket/telephoto/issues/37.
@Test fun reload_image_if_its_evicted_from_the_disk_cache_but_is_still_present_in_the_memory_cache() = runTest {
val memoryCache = context.imageLoader.memoryCache!!
Expand Down
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,22 @@ class GlideImageSourceTest {
}
}

@Test fun avif_images_should_not_be_sub_sampled() = runTest {
serverRule.server.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
check(request.path!!.endsWith(".avif"))
return assetAsResponse("full_image.avif")
}
}

resolve(
model = serverRule.server.url("full_image.avif").toString()
).test {
skipItems(1) // Default item.
assertThat(awaitItem().delegate!!).isNotInstanceOf(SubSamplingDelegate::class.java)
}
}

@Test fun correctly_resolve_vector_drawables() {
screenshotValidator.tolerancePercentOnCi = 0.06f

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import android.content.Context
import android.util.TypedValue
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import me.saket.telephoto.subsamplingimage.ResourceImageSource
import me.saket.telephoto.subsamplingimage.BufferedSubSamplingImageSource
import me.saket.telephoto.subsamplingimage.ResourceImageSource
import me.saket.telephoto.subsamplingimage.SubSamplingImageSource
import me.saket.telephoto.subsamplingimage.internal.AndroidImageRegionDecoder
import me.saket.telephoto.subsamplingimage.internal.isAvif
import me.saket.telephoto.subsamplingimage.internal.isGif
import me.saket.telephoto.subsamplingimage.internal.isSvg
import okio.Buffer
Expand All @@ -27,7 +28,7 @@ suspend fun SubSamplingImageSource.canBeSubSampled(context: Context): Boolean {
peek(context).use {
// Check for GIFs as well because Android's ImageDecoder
// can return a Bitmap for single-frame GIFs.
!isSvg(it) && !isGif(it)
!isSvg(it) && !isGif(it) && !isAvif(it)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,16 @@ private val LEFT_ANGLE_BRACKET: ByteString = "<".encodeUtf8()
private val GIF_HEADER_87A = "GIF87a".encodeUtf8()
private val GIF_HEADER_89A = "GIF89a".encodeUtf8()

private val FTYP_MAJOR_BRAND_AVIF = "avif".encodeUtf8()
private val FTYP_MAJOR_BRAND_AVIS = "avis".encodeUtf8()

/**
* Copied from coil-svg.
*
* Return 'true' if the [source] contains an SVG image. The [source] is not consumed.
* Checks if a [source] possibly contains an SVG image.
*
* NOTE: There's no guaranteed method to determine if a byte stream is an SVG without attempting
* to decode it. This method uses heuristics.
* NOTE: There's no guaranteed method to determine if a byte stream is
* an SVG without attempting to decode it. This method uses heuristics.
*/
internal fun isSvg(source: BufferedSource): Boolean {
return source.rangeEquals(0, LEFT_ANGLE_BRACKET) &&
Expand All @@ -27,16 +30,52 @@ internal fun isSvg(source: BufferedSource): Boolean {
/**
* Copied from coil-gif.
*
* Return 'true' if the [source] contains a GIF image. The [source] is not consumed.
* Checks if a [source] possibly contains a GIF image.
*/
internal fun isGif(source: BufferedSource): Boolean {
return source.rangeEquals(0, GIF_HEADER_89A) ||
source.rangeEquals(0, GIF_HEADER_87A)
}

/**
* Checks if a [source] possibly contains an AVIF image, which
* [can't be sub-sampled yet](https://issuetracker.google.com/u/0/issues/392661391).
*
* AVIF is an [ISO-BMFF-based format](https://aomediacodec.github.io/av1-avif/#avif-required-boxes):
* the first box is 'ftyp', and the major brand often 'avif' or 'avis' indicates it’s an AVIF.
*/
internal fun isAvif(source: BufferedSource): Boolean {
// At least 8 bytes are needed. 4 for reading the box size + 4 for the box type.
if (!source.request(8)) {
return false
}

@Suppress("NAME_SHADOWING")
val source = source.peek()

// Read the box size (bytes 0..3). For a valid 'ftyp'
// box, at least 16 bytes are expected:
// - 8 bytes for size + type
// - another 8+ for major brand, minor version, etc.
val boxSize = source.readInt()
if (boxSize < 16) return false

// The box type (bytes 4..7) must be "ftyp".
val boxType = source.readUtf8(byteCount = 4)
if (boxType != "ftyp") return false

// Make sure that the source has enough bytes for
// the entire remainder can be read.
val toRead = boxSize - 8
if (!source.request(toRead.toLong())) return false

val boxData = source.readByteString(toRead.toLong())
return (boxData.indexOf(FTYP_MAJOR_BRAND_AVIF) != -1 || boxData.indexOf(FTYP_MAJOR_BRAND_AVIS) != -1)
}

/** Copied from coil-svg. */
internal fun BufferedSource.indexOf(bytes: ByteString, fromIndex: Long, toIndex: Long): Long {
require(bytes.size > 0) { "bytes is empty" }
require(bytes.size > 0) { "bytes are empty" }

val firstByte = bytes[0]
val lastIndex = toIndex - bytes.size
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package me.saket.telephoto.subsamplingimage.internal

import assertk.assertThat
import assertk.assertions.isTrue
import okio.Buffer
import okio.ByteString.Companion.encodeUtf8
import kotlin.test.Test

class ImageFormatsTest {
@Test fun `read avif header`() {
val pseudoAvifString = (
"\u0000\u0000\u0000\u0024" + // boxSize = 36, as bytes [0..3]
"ftyp" + // bytes [4..7]
"mif1" + // bytes [8..11] (major brand)
"\u0000\u0000\u0000\u0000" + // bytes [12..15] (minor version)
"miaf" + // bytes [16..19] (compatible brand #1)
"heic" + // bytes [20..23] (compatible brand #2)
"junk" + // bytes [24..27] (compatible brand #3)
"junk" + // bytes [28..31] (compatible brand #4)
"avif" // bytes [32..35] (compatible brand #5)
).encodeUtf8()
assertThat(isAvif(Buffer().write(pseudoAvifString))).isTrue()
}
}

0 comments on commit 9d2e2b2

Please sign in to comment.