diff --git a/app/src/main/java/org/oxycblt/auxio/image/stack/CoverRetriever.kt b/app/src/main/java/org/oxycblt/auxio/image/stack/CoverRetriever.kt index 86d7560ad..85741d73c 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/stack/CoverRetriever.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/stack/CoverRetriever.kt @@ -33,9 +33,5 @@ class CoverRetrieverImpl constructor(private val coverCache: CoverCache, private val coverExtractor: CoverExtractor) : CoverRetriever { override suspend fun retrieve(cover: Cover.Single) = - coverCache.read(cover) - ?: coverExtractor.extract(cover)?.also { - coverCache.write(cover, it) - it.reset() - } + coverCache.read(cover) ?: coverExtractor.extract(cover)?.let { coverCache.write(cover, it) } } diff --git a/app/src/main/java/org/oxycblt/auxio/image/stack/cache/AppFiles.kt b/app/src/main/java/org/oxycblt/auxio/image/stack/cache/AppFiles.kt index 3182557fe..92853ff7b 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/stack/cache/AppFiles.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/stack/cache/AppFiles.kt @@ -34,6 +34,8 @@ interface AppFiles { suspend fun read(file: String): InputStream? suspend fun write(file: String, inputStream: InputStream): Boolean + + suspend fun exists(file: String): Boolean } class AppFilesImpl @Inject constructor(@ApplicationContext private val context: Context) : @@ -77,4 +79,7 @@ class AppFilesImpl @Inject constructor(@ApplicationContext private val context: } } } + + override suspend fun exists(file: String): Boolean = + withContext(Dispatchers.IO) { File(context.filesDir, file).exists() } } diff --git a/app/src/main/java/org/oxycblt/auxio/image/stack/cache/CoverCache.kt b/app/src/main/java/org/oxycblt/auxio/image/stack/cache/CoverCache.kt index 45868a109..88bd4e008 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/stack/cache/CoverCache.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/stack/cache/CoverCache.kt @@ -18,6 +18,11 @@ package org.oxycblt.auxio.image.stack.cache +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.os.Build +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream import java.io.InputStream import javax.inject.Inject import kotlin.math.min @@ -28,7 +33,7 @@ import org.oxycblt.auxio.image.Cover interface CoverCache { suspend fun read(cover: Cover.Single): InputStream? - suspend fun write(cover: Cover.Single, inputStream: InputStream): Boolean + suspend fun write(cover: Cover.Single, inputStream: InputStream): InputStream? } class CoverCacheImpl @@ -43,32 +48,83 @@ constructor(private val storedCoversDao: StoredCoversDao, private val appFiles: return appFiles.read(fileName(perceptualHash)) } - override suspend fun write(cover: Cover.Single, inputStream: InputStream): Boolean = - withContext(Dispatchers.IO) { - val available = inputStream.available() - val skip = min(available / 2L, available - COVER_KEY_SAMPLE.toLong()) - inputStream.skip(skip) - val bytes = ByteArray(COVER_KEY_SAMPLE) - inputStream.read(bytes) - inputStream.reset() - - @OptIn(ExperimentalStdlibApi::class) val perceptualHash = bytes.toHexString() - val writeSuccess = appFiles.write(fileName(perceptualHash), inputStream) - - if (writeSuccess) { - storedCoversDao.setCoverFile( - StoredCover( - uid = cover.uid, - lastModified = cover.lastModified, - perceptualHash = perceptualHash)) + override suspend fun write(cover: Cover.Single, inputStream: InputStream): InputStream? { + val id = + withContext(Dispatchers.IO) { + val available = inputStream.available() + val skip = min(available / 2L, available - COVER_KEY_SAMPLE.toLong()) + inputStream.skip(skip) + val bytes = ByteArray(COVER_KEY_SAMPLE) + inputStream.read(bytes) + inputStream.reset() + @OptIn(ExperimentalStdlibApi::class) bytes.toHexString() + } + val file = fileName(id) + if (!appFiles.exists(file)) { + val transcoded = transcodeImage(inputStream, FORMAT_WEBP) + val writeSuccess = appFiles.write(fileName(id), transcoded) + if (!writeSuccess) { + return null } - - writeSuccess } - private fun fileName(perceptualHash: String) = "cover_$perceptualHash" + storedCoversDao.setCoverFile( + StoredCover(uid = cover.uid, lastModified = cover.lastModified, perceptualHash = id)) + + return appFiles.read(file) + } + + private fun fileName(id: String) = "cover_$id" + + private fun transcodeImage( + inputStream: InputStream, + targetFormat: Bitmap.CompressFormat + ): InputStream { + val options = + BitmapFactory.Options().apply { + inJustDecodeBounds = true + BitmapFactory.decodeStream(inputStream, null, this) + } + + options.inSampleSize = calculateInSampleSize(options, 750, 750) + inputStream.reset() + + val bitmap = + BitmapFactory.decodeStream( + inputStream, null, options.apply { inJustDecodeBounds = false }) + + return ByteArrayOutputStream().use { outputStream -> + bitmap?.compress(targetFormat, 80, outputStream) + ByteArrayInputStream(outputStream.toByteArray()) + } + } + + private fun calculateInSampleSize( + options: BitmapFactory.Options, + reqWidth: Int, + reqHeight: Int + ): Int { + var inSampleSize = 1 + val (height, width) = options.outHeight to options.outWidth + + if (height > reqHeight || width > reqWidth) { + val halfHeight = height / 2 + val halfWidth = width / 2 + while ((halfHeight / inSampleSize) >= reqHeight && + (halfWidth / inSampleSize) >= reqWidth) { + inSampleSize *= 2 + } + } + return inSampleSize + } private companion object { const val COVER_KEY_SAMPLE = 32 + val FORMAT_WEBP = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Bitmap.CompressFormat.WEBP_LOSSY + } else { + Bitmap.CompressFormat.WEBP + } } }