Support for AVIF images

This commit is contained in:
Koitharu
2024-10-22 13:13:31 +03:00
parent 5bccc595a8
commit c15a0ece3e
10 changed files with 268 additions and 84 deletions

View File

@@ -16,8 +16,8 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdk = 21 minSdk = 21
targetSdk = 35 targetSdk = 35
versionCode = 681 versionCode = 682
versionName = '7.7-a2' versionName = '7.7-a3'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner' testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp { ksp {
@@ -92,7 +92,7 @@ dependencies {
implementation 'androidx.appcompat:appcompat:1.7.0' implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'androidx.core:core-ktx:1.13.1' implementation 'androidx.core:core-ktx:1.13.1'
implementation 'androidx.activity:activity-ktx:1.9.2' implementation 'androidx.activity:activity-ktx:1.9.3'
implementation 'androidx.fragment:fragment-ktx:1.8.4' implementation 'androidx.fragment:fragment-ktx:1.8.4'
implementation 'androidx.transition:transition-ktx:1.5.1' implementation 'androidx.transition:transition-ktx:1.5.1'
implementation 'androidx.collection:collection-ktx:1.4.4' implementation 'androidx.collection:collection-ktx:1.4.4'
@@ -136,7 +136,8 @@ dependencies {
implementation 'io.coil-kt:coil-base:2.7.0' implementation 'io.coil-kt:coil-base:2.7.0'
implementation 'io.coil-kt:coil-svg:2.7.0' implementation 'io.coil-kt:coil-svg:2.7.0'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:ac7360c5e3' implementation 'org.aomedia.avif.android:avif:1.1.1.14d8e3c4'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:d1d10a6975'
implementation 'com.github.solkin:disk-lru-cache:1.4' implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'io.noties.markwon:core:4.6.2' implementation 'io.noties.markwon:core:4.6.2'

View File

@@ -27,6 +27,8 @@ import okhttp3.OkHttpClient
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.image.AvifImageDecoder
import org.koitharu.kotatsu.core.image.RegionBitmapDecoder
import org.koitharu.kotatsu.core.network.MangaHttpClient import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
import org.koitharu.kotatsu.core.os.AppShortcutManager import org.koitharu.kotatsu.core.os.AppShortcutManager
@@ -119,6 +121,8 @@ interface AppModule {
ComponentRegistry.Builder() ComponentRegistry.Builder()
.add(SvgDecoder.Factory()) .add(SvgDecoder.Factory())
.add(CbzFetcher.Factory()) .add(CbzFetcher.Factory())
.add(AvifImageDecoder.Factory())
.add(RegionBitmapDecoder.Factory())
.add(FaviconFetcher.Factory(context, okHttpClientLazy, mangaRepositoryFactory)) .add(FaviconFetcher.Factory(context, okHttpClientLazy, mangaRepositoryFactory))
.add(MangaPageKeyer()) .add(MangaPageKeyer())
.add(pageFetcherFactory) .add(pageFetcherFactory)

View File

@@ -0,0 +1,67 @@
package org.koitharu.kotatsu.core.image
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.core.graphics.drawable.toDrawable
import coil.ImageLoader
import coil.decode.DecodeResult
import coil.decode.Decoder
import coil.decode.ImageSource
import coil.fetch.SourceResult
import coil.request.Options
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
import kotlinx.coroutines.sync.Semaphore
import org.aomedia.avif.android.AvifDecoder
import org.aomedia.avif.android.AvifDecoder.Info
import org.koitharu.kotatsu.core.util.ext.toByteBuffer
class AvifImageDecoder(source: ImageSource, options: Options, parallelismLock: Semaphore) :
BaseCoilDecoder(source, options, parallelismLock) {
override fun BitmapFactory.Options.decode(): DecodeResult {
val bytes = source.source().use {
it.inputStream().toByteBuffer()
}
val info = Info()
if (!AvifDecoder.getInfo(bytes, bytes.remaining(), info)) {
throw ImageDecodeException(
null,
"avif",
"Requested to decode byte buffer which cannot be handled by AvifDecoder",
)
}
val config = if (info.depth == 8 || info.alphaPresent) Bitmap.Config.ARGB_8888 else Bitmap.Config.RGB_565
val bitmap = Bitmap.createBitmap(info.width, info.height, config)
if (!AvifDecoder.decode(bytes, bytes.remaining(), bitmap)) {
bitmap.recycle()
throw ImageDecodeException(null, "avif")
}
return DecodeResult(
drawable = bitmap.toDrawable(options.context.resources),
isSampled = false,
)
}
class Factory : Decoder.Factory {
private val parallelismLock = Semaphore(DEFAULT_PARALLELISM)
override fun create(
result: SourceResult,
options: Options,
imageLoader: ImageLoader
): Decoder? = if (isApplicable(result)) {
AvifImageDecoder(result.source, options, parallelismLock)
} else {
null
}
override fun equals(other: Any?) = other is Factory
override fun hashCode() = javaClass.hashCode()
private fun isApplicable(result: SourceResult): Boolean {
return result.mimeType == "image/avif"
}
}
}

View File

@@ -0,0 +1,50 @@
package org.koitharu.kotatsu.core.image
import android.graphics.BitmapFactory
import coil.decode.DecodeResult
import coil.decode.Decoder
import coil.decode.ImageSource
import coil.request.Options
import coil.size.Dimension
import coil.size.Scale
import coil.size.Size
import coil.size.isOriginal
import coil.size.pxOrElse
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import org.jetbrains.annotations.Blocking
abstract class BaseCoilDecoder(
protected val source: ImageSource,
protected val options: Options,
private val parallelismLock: Semaphore,
) : Decoder {
final override suspend fun decode(): DecodeResult = parallelismLock.withPermit {
runInterruptible { BitmapFactory.Options().decode() }
}
@Blocking
protected abstract fun BitmapFactory.Options.decode(): DecodeResult
protected companion object {
const val DEFAULT_PARALLELISM = 4
inline fun Size.widthPx(scale: Scale, original: () -> Int): Int {
return if (isOriginal) original() else width.toPx(scale)
}
inline fun Size.heightPx(scale: Scale, original: () -> Int): Int {
return if (isOriginal) original() else height.toPx(scale)
}
fun Dimension.toPx(scale: Scale) = pxOrElse {
when (scale) {
Scale.FILL -> Int.MIN_VALUE
Scale.FIT -> Int.MAX_VALUE
}
}
}
}

View File

@@ -0,0 +1,77 @@
package org.koitharu.kotatsu.core.image
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.ImageDecoder
import android.os.Build
import android.webkit.MimeTypeMap
import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import org.aomedia.avif.android.AvifDecoder
import org.aomedia.avif.android.AvifDecoder.Info
import org.jetbrains.annotations.Blocking
import org.koitharu.kotatsu.core.util.ext.toByteBuffer
import java.io.File
import java.io.InputStream
import java.nio.ByteBuffer
import java.nio.file.Files
object BitmapDecoderCompat {
private const val FORMAT_AVIF = "avif"
@Blocking
fun decode(file: File): Bitmap = when (val format = getMimeType(file)?.subtype) {
FORMAT_AVIF -> file.inputStream().use { decodeAvif(it.toByteBuffer()) }
else -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
ImageDecoder.decodeBitmap(ImageDecoder.createSource(file))
} else {
checkBitmapNotNull(BitmapFactory.decodeFile(file.absolutePath), format)
}
}
@Blocking
fun decode(stream: InputStream, type: MediaType?): Bitmap {
val format = type?.subtype
if (format == FORMAT_AVIF) {
return decodeAvif(stream.toByteBuffer())
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
return checkBitmapNotNull(BitmapFactory.decodeStream(stream), format)
}
val byteBuffer = stream.toByteBuffer()
return if (AvifDecoder.isAvifImage(byteBuffer)) {
decodeAvif(byteBuffer)
} else {
ImageDecoder.decodeBitmap(ImageDecoder.createSource(byteBuffer))
}
}
private fun getMimeType(file: File): MediaType? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Files.probeContentType(file.toPath())?.toMediaTypeOrNull()
} else {
MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.extension)?.toMediaTypeOrNull()
}
private fun checkBitmapNotNull(bitmap: Bitmap?, format: String?): Bitmap =
bitmap ?: throw ImageDecodeException(null, format)
private fun decodeAvif(bytes: ByteBuffer): Bitmap {
val info = Info()
if (!AvifDecoder.getInfo(bytes, bytes.remaining(), info)) {
throw ImageDecodeException(
null,
FORMAT_AVIF,
"Requested to decode byte buffer which cannot be handled by AvifDecoder",
)
}
val config = if (info.depth == 8 || info.alphaPresent) Bitmap.Config.ARGB_8888 else Bitmap.Config.RGB_565
val bitmap = Bitmap.createBitmap(info.width, info.height, config)
if (!AvifDecoder.decode(bytes, bytes.remaining(), bitmap)) {
bitmap.recycle()
throw ImageDecodeException(null, FORMAT_AVIF)
}
return bitmap
}
}

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.core.ui.image package org.koitharu.kotatsu.core.image
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
@@ -13,27 +13,14 @@ import coil.decode.Decoder
import coil.decode.ImageSource import coil.decode.ImageSource
import coil.fetch.SourceResult import coil.fetch.SourceResult
import coil.request.Options import coil.request.Options
import coil.size.Dimension
import coil.size.Scale
import coil.size.Size
import coil.size.isOriginal
import coil.size.pxOrElse
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlin.math.roundToInt import kotlin.math.roundToInt
class RegionBitmapDecoder( class RegionBitmapDecoder(
private val source: ImageSource, source: ImageSource, options: Options, parallelismLock: Semaphore
private val options: Options, ) : BaseCoilDecoder(source, options, parallelismLock) {
private val parallelismLock: Semaphore,
) : Decoder {
override suspend fun decode() = parallelismLock.withPermit { override fun BitmapFactory.Options.decode(): DecodeResult {
runInterruptible { BitmapFactory.Options().decode() }
}
private fun BitmapFactory.Options.decode(): DecodeResult {
val regionDecoder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val regionDecoder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
BitmapRegionDecoder.newInstance(source.source().inputStream()) BitmapRegionDecoder.newInstance(source.source().inputStream())
} else { } else {
@@ -55,29 +42,6 @@ class RegionBitmapDecoder(
} }
} }
private fun BitmapFactory.Options.configureConfig() {
var config = options.config
inMutable = false
if (Build.VERSION.SDK_INT >= 26 && options.colorSpace != null) {
inPreferredColorSpace = options.colorSpace
}
inPremultiplied = options.premultipliedAlpha
// Decode the image as RGB_565 as an optimization if allowed.
if (options.allowRgb565 && config == Bitmap.Config.ARGB_8888 && outMimeType == "image/jpeg") {
config = Bitmap.Config.RGB_565
}
// High color depth images must be decoded as either RGBA_F16 or HARDWARE.
if (Build.VERSION.SDK_INT >= 26 && outConfig == Bitmap.Config.RGBA_F16 && config != Bitmap.Config.HARDWARE) {
config = Bitmap.Config.RGBA_F16
}
inPreferredConfig = config
}
/** Compute and set the scaling properties for [BitmapFactory.Options]. */ /** Compute and set the scaling properties for [BitmapFactory.Options]. */
private fun BitmapFactory.Options.configureScale(srcWidth: Int, srcHeight: Int): Rect { private fun BitmapFactory.Options.configureScale(srcWidth: Int, srcHeight: Int): Rect {
val dstWidth = options.size.widthPx(options.scale) { srcWidth } val dstWidth = options.size.widthPx(options.scale) { srcWidth }
@@ -142,18 +106,41 @@ class RegionBitmapDecoder(
return rect return rect
} }
class Factory( private fun BitmapFactory.Options.configureConfig() {
maxParallelism: Int = DEFAULT_MAX_PARALLELISM, var config = options.config
) : Decoder.Factory {
@Suppress("NEWER_VERSION_IN_SINCE_KOTLIN") inMutable = false
@SinceKotlin("999.9") // Only public in Java.
constructor() : this()
private val parallelismLock = Semaphore(maxParallelism) if (Build.VERSION.SDK_INT >= 26 && options.colorSpace != null) {
inPreferredColorSpace = options.colorSpace
}
inPremultiplied = options.premultipliedAlpha
override fun create(result: SourceResult, options: Options, imageLoader: ImageLoader): Decoder { // Decode the image as RGB_565 as an optimization if allowed.
return RegionBitmapDecoder(result.source, options, parallelismLock) if (options.allowRgb565 && config == Bitmap.Config.ARGB_8888 && outMimeType == "image/jpeg") {
config = Bitmap.Config.RGB_565
}
// High color depth images must be decoded as either RGBA_F16 or HARDWARE.
if (Build.VERSION.SDK_INT >= 26 && outConfig == Bitmap.Config.RGBA_F16 && config != Bitmap.Config.HARDWARE) {
config = Bitmap.Config.RGBA_F16
}
inPreferredConfig = config
}
class Factory : Decoder.Factory {
private val parallelismLock = Semaphore(DEFAULT_PARALLELISM)
override fun create(
result: SourceResult,
options: Options,
imageLoader: ImageLoader
): Decoder? = if (options.parameters.value<Boolean>(PARAM_REGION) == true) {
RegionBitmapDecoder(result.source, options, parallelismLock)
} else {
null
} }
override fun equals(other: Any?) = other is Factory override fun equals(other: Any?) = other is Factory
@@ -164,22 +151,7 @@ class RegionBitmapDecoder(
companion object { companion object {
const val PARAM_SCROLL = "scroll" const val PARAM_SCROLL = "scroll"
const val PARAM_REGION = "region"
const val SCROLL_UNDEFINED = -1 const val SCROLL_UNDEFINED = -1
private const val DEFAULT_MAX_PARALLELISM = 4
private inline fun Size.widthPx(scale: Scale, original: () -> Int): Int {
return if (isOriginal) original() else width.toPx(scale)
}
private inline fun Size.heightPx(scale: Scale, original: () -> Int): Int {
return if (isOriginal) original() else height.toPx(scale)
}
private fun Dimension.toPx(scale: Scale) = pxOrElse {
when (scale) {
Scale.FILL -> Int.MIN_VALUE
Scale.FIT -> Int.MAX_VALUE
}
}
} }
} }

View File

@@ -14,8 +14,8 @@ import coil.request.SuccessResult
import coil.util.CoilUtils import coil.util.CoilUtils
import com.google.android.material.progressindicator.BaseProgressIndicator import com.google.android.material.progressindicator.BaseProgressIndicator
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.image.RegionBitmapDecoder
import org.koitharu.kotatsu.core.ui.image.AnimatedPlaceholderDrawable import org.koitharu.kotatsu.core.ui.image.AnimatedPlaceholderDrawable
import org.koitharu.kotatsu.core.ui.image.RegionBitmapDecoder
import org.koitharu.kotatsu.core.util.progress.ImageRequestIndicatorListener import org.koitharu.kotatsu.core.util.progress.ImageRequestIndicatorListener
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
@@ -63,7 +63,7 @@ fun ImageRequest.Builder.indicator(indicators: List<BaseProgressIndicator<*>>):
fun ImageRequest.Builder.decodeRegion( fun ImageRequest.Builder.decodeRegion(
scroll: Int = RegionBitmapDecoder.SCROLL_UNDEFINED, scroll: Int = RegionBitmapDecoder.SCROLL_UNDEFINED,
): ImageRequest.Builder = decoderFactory(RegionBitmapDecoder.Factory()) ): ImageRequest.Builder = setParameter(RegionBitmapDecoder.PARAM_REGION, true)
.setParameter(RegionBitmapDecoder.PARAM_SCROLL, scroll) .setParameter(RegionBitmapDecoder.PARAM_SCROLL, scroll)
@Suppress("SpellCheckingInspection") @Suppress("SpellCheckingInspection")

View File

@@ -7,9 +7,12 @@ import android.os.Build
import android.os.Environment import android.os.Environment
import android.os.storage.StorageManager import android.os.storage.StorageManager
import android.provider.OpenableColumns import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import androidx.core.database.getStringOrNull import androidx.core.database.getStringOrNull
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import org.jetbrains.annotations.Blocking import org.jetbrains.annotations.Blocking
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.fs.FileSequence import org.koitharu.kotatsu.core.fs.FileSequence
@@ -38,6 +41,12 @@ fun ZipFile.readText(entry: ZipEntry) = getInputStream(entry).use { output ->
output.bufferedReader().use(BufferedReader::readText) output.bufferedReader().use(BufferedReader::readText)
} }
val ZipEntry.mimeType: MediaType?
get() {
val ext = name.substringAfterLast('.')
return MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext)?.toMediaTypeOrNull()
}
fun File.getStorageName(context: Context): String = runCatching { fun File.getStorageName(context: Context): String = runCatching {
val manager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager val manager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {

View File

@@ -10,6 +10,9 @@ import okio.BufferedSink
import okio.Source import okio.Source
import org.koitharu.kotatsu.core.util.CancellableSource import org.koitharu.kotatsu.core.util.CancellableSource
import org.koitharu.kotatsu.core.util.progress.ProgressResponseBody import org.koitharu.kotatsu.core.util.progress.ProgressResponseBody
import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.nio.ByteBuffer
fun ResponseBody.withProgress(progressState: MutableStateFlow<Float>): ResponseBody { fun ResponseBody.withProgress(progressState: MutableStateFlow<Float>): ResponseBody {
return ProgressResponseBody(this, progressState) return ProgressResponseBody(this, progressState)
@@ -23,3 +26,10 @@ suspend fun Source.cancellable(): Source {
suspend fun BufferedSink.writeAllCancellable(source: Source) = withContext(Dispatchers.IO) { suspend fun BufferedSink.writeAllCancellable(source: Source) = withContext(Dispatchers.IO) {
writeAll(source.cancellable()) writeAll(source.cancellable())
} }
fun InputStream.toByteBuffer(): ByteBuffer {
val outStream = ByteArrayOutputStream(available())
copyTo(outStream)
val bytes = outStream.toByteArray()
return ByteBuffer.allocateDirect(bytes.size).put(bytes).position(0) as ByteBuffer
}

View File

@@ -1,12 +1,10 @@
package org.koitharu.kotatsu.reader.domain package org.koitharu.kotatsu.reader.domain
import android.content.ContentResolver.MimeTypeInfo
import android.content.Context import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.ImageDecoder
import android.graphics.Rect import android.graphics.Rect
import android.net.Uri import android.net.Uri
import android.os.Build import android.webkit.MimeTypeMap
import androidx.annotation.AnyThread import androidx.annotation.AnyThread
import androidx.collection.LongSparseArray import androidx.collection.LongSparseArray
import androidx.collection.set import androidx.collection.set
@@ -61,6 +59,8 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mimeType import org.koitharu.kotatsu.parsers.util.mimeType
import org.koitharu.kotatsu.parsers.util.requireBody import org.koitharu.kotatsu.parsers.util.requireBody
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.core.image.BitmapDecoderCompat
import org.koitharu.kotatsu.core.util.ext.mimeType
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import java.util.LinkedList import java.util.LinkedList
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
@@ -144,8 +144,8 @@ class PageLoader @Inject constructor(
ZipFile(uri.schemeSpecificPart).use { zip -> ZipFile(uri.schemeSpecificPart).use { zip ->
val entry = zip.getEntry(uri.fragment) val entry = zip.getEntry(uri.fragment)
context.ensureRamAtLeast(entry.size * 2) context.ensureRamAtLeast(entry.size * 2)
zip.getInputStream(zip.getEntry(uri.fragment)).use { zip.getInputStream(entry).use {
checkBitmapNotNull(BitmapFactory.decodeStream(it)) BitmapDecoderCompat.decode(it, entry.mimeType)
} }
} }
} }
@@ -154,11 +154,7 @@ class PageLoader @Inject constructor(
val file = uri.toFile() val file = uri.toFile()
runInterruptible(Dispatchers.IO) { runInterruptible(Dispatchers.IO) {
context.ensureRamAtLeast(file.length() * 2) context.ensureRamAtLeast(file.length() * 2)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { BitmapDecoderCompat.decode(file)
ImageDecoder.decodeBitmap(ImageDecoder.createSource(file))
} else {
checkBitmapNotNull(BitmapFactory.decodeFile(file.absolutePath))
}
}.use { image -> }.use { image ->
image.compressToPNG(file) image.compressToPNG(file)
} }
@@ -253,8 +249,6 @@ class PageLoader @Inject constructor(
return context.ramAvailable <= FileSize.MEGABYTES.convert(PREFETCH_MIN_RAM_MB, FileSize.BYTES) return context.ramAvailable <= FileSize.MEGABYTES.convert(PREFETCH_MIN_RAM_MB, FileSize.BYTES)
} }
private fun checkBitmapNotNull(bitmap: Bitmap?): Bitmap = checkNotNull(bitmap) { "Cannot decode bitmap" }
private fun Deferred<Uri>.isValid(): Boolean { private fun Deferred<Uri>.isValid(): Boolean {
return getCompletionResultOrNull()?.map { uri -> return getCompletionResultOrNull()?.map { uri ->
uri.exists() && uri.isTargetNotEmpty() uri.exists() && uri.isTargetNotEmpty()