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 85741d73c..102378e3d 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 @@ -23,6 +23,7 @@ import javax.inject.Inject import org.oxycblt.auxio.image.Cover import org.oxycblt.auxio.image.stack.cache.CoverCache import org.oxycblt.auxio.image.stack.extractor.CoverExtractor +import timber.log.Timber interface CoverRetriever { suspend fun retrieve(cover: Cover.Single): InputStream? @@ -33,5 +34,12 @@ 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)?.let { coverCache.write(cover, it) } + try { + coverCache.read(cover) + ?: coverExtractor.extract(cover)?.let { coverCache.write(cover, it) } + } catch (e: Exception) { + Timber.e("Image extraction failed!") + Timber.e(e.stackTraceToString()) + throw e + } } diff --git a/app/src/main/java/org/oxycblt/auxio/image/stack/cache/CacheModule.kt b/app/src/main/java/org/oxycblt/auxio/image/stack/cache/CacheModule.kt index eddd90b9d..ef7ce7c98 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/stack/cache/CacheModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/stack/cache/CacheModule.kt @@ -31,11 +31,13 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) interface StackModule { - @Singleton @Binds fun appFiles(impl: AppFilesImpl): AppFiles + @Singleton @Binds fun appFiles(impl: CoverFilesImpl): CoverFiles @Binds fun coverCache(cache: CoverCacheImpl): CoverCache @Binds fun coverIdentifier(identifierImpl: CoverIdentifierImpl): CoverIdentifier + + @Binds fun coverFormat(coverFormatImpl: CoverFormatImpl): CoverFormat } @Module 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 3199b132e..9c7dfb993 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,11 +18,6 @@ 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 org.oxycblt.auxio.image.Cover @@ -38,79 +33,19 @@ class CoverCacheImpl constructor( private val coverIdentifier: CoverIdentifier, private val storedCoversDao: StoredCoversDao, - private val appFiles: AppFiles + private val coverFiles: CoverFiles ) : CoverCache { override suspend fun read(cover: Cover.Single): InputStream? { - val perceptualHash = - storedCoversDao.getStoredCover(cover.uid, cover.lastModified) ?: return null - - return appFiles.read(fileName(perceptualHash)) + val id = storedCoversDao.getStoredCoverId(cover.uid, cover.lastModified) ?: return null + return coverFiles.read(id) } override suspend fun write(cover: Cover.Single, data: ByteArray): InputStream? { val id = coverIdentifier.identify(data) - val file = fileName(id) - if (!appFiles.exists(file)) { - val transcoded = transcodeImage(data, FORMAT_WEBP) - val writeSuccess = appFiles.write(fileName(id), transcoded) - if (!writeSuccess) { - return null - } - } - + coverFiles.write(id, data) storedCoversDao.setStoredCover( StoredCover(uid = cover.uid, lastModified = cover.lastModified, coverId = id)) - - return appFiles.read(file) - } - - private fun fileName(id: String) = "cover_$id" - - private fun transcodeImage(data: ByteArray, targetFormat: Bitmap.CompressFormat): InputStream { - val options = - BitmapFactory.Options().apply { - inJustDecodeBounds = true - BitmapFactory.decodeByteArray(data, 0, data.size, this) - } - - options.inSampleSize = calculateInSampleSize(options, 750, 750) - - val bitmap = - BitmapFactory.decodeByteArray( - data, 0, data.size, 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 { - val FORMAT_WEBP = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - Bitmap.CompressFormat.WEBP_LOSSY - } else { - Bitmap.CompressFormat.WEBP - } + return coverFiles.read(id) } } 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/CoverFiles.kt similarity index 58% rename from app/src/main/java/org/oxycblt/auxio/image/stack/cache/AppFiles.kt rename to app/src/main/java/org/oxycblt/auxio/image/stack/cache/CoverFiles.kt index 92853ff7b..9bb7beeae 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/CoverFiles.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2024 Auxio Project - * AppFiles.kt is part of Auxio. + * CoverFiles.kt is part of Auxio. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -24,22 +24,23 @@ import java.io.File import java.io.IOException import java.io.InputStream import javax.inject.Inject -import kotlin.concurrent.withLock import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -interface AppFiles { - suspend fun read(file: String): InputStream? +interface CoverFiles { + suspend fun read(id: String): InputStream? - suspend fun write(file: String, inputStream: InputStream): Boolean - - suspend fun exists(file: String): Boolean + suspend fun write(id: String, data: ByteArray) } -class AppFilesImpl @Inject constructor(@ApplicationContext private val context: Context) : - AppFiles { +class CoverFilesImpl +@Inject +constructor( + @ApplicationContext private val context: Context, + private val coverFormat: CoverFormat +) : CoverFiles { private val fileMutexes = mutableMapOf() private val mapMutex = Mutex() @@ -47,39 +48,38 @@ class AppFilesImpl @Inject constructor(@ApplicationContext private val context: return mapMutex.withLock { fileMutexes.getOrPut(file) { Mutex() } } } - override suspend fun read(file: String): InputStream? = + override suspend fun read(id: String): InputStream? = withContext(Dispatchers.IO) { try { - context.openFileInput(file) + context.openFileInput(getTargetFilePath(id)) } catch (e: IOException) { null } } - override suspend fun write(file: String, inputStream: InputStream): Boolean = - withContext(Dispatchers.IO) { - val fileMutex = getMutexForFile(file) + override suspend fun write(id: String, data: ByteArray) { + val fileMutex = getMutexForFile(id) - fileMutex.withLock { - val tempFile = File(context.filesDir, "$file.tmp") - val targetFile = File(context.filesDir, file) + fileMutex.withLock { + val targetFile = File(context.filesDir, getTargetFilePath(id)) + if (targetFile.exists()) { + return + } + withContext(Dispatchers.IO) { + val tempFile = File(context.filesDir, getTempFilePath(id)) try { - tempFile.outputStream().use { fileOutputStream -> - inputStream.copyTo(fileOutputStream) - } + tempFile.outputStream().use { coverFormat.transcodeInto(data, it) } tempFile.renameTo(targetFile) - true } catch (e: IOException) { tempFile.delete() - false - } finally { - inputStream.close() } } } + } - override suspend fun exists(file: String): Boolean = - withContext(Dispatchers.IO) { File(context.filesDir, file).exists() } + private fun getTargetFilePath(name: String) = "cover_${name}.${coverFormat.extension}" + + private fun getTempFilePath(name: String) = "${getTargetFilePath(name)}.tmp" } diff --git a/app/src/main/java/org/oxycblt/auxio/image/stack/cache/CoverFormat.kt b/app/src/main/java/org/oxycblt/auxio/image/stack/cache/CoverFormat.kt new file mode 100644 index 000000000..06bfab973 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/image/stack/cache/CoverFormat.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2024 Auxio Project + * CoverFormat.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.image.stack.cache + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.os.Build +import java.io.OutputStream +import javax.inject.Inject + +interface CoverFormat { + val extension: String + + fun transcodeInto(data: ByteArray, output: OutputStream): Boolean +} + +class CoverFormatImpl @Inject constructor() : CoverFormat { + override val extension = EXTENSION + + override fun transcodeInto(data: ByteArray, output: OutputStream) = + BitmapFactory.Options().run { + inJustDecodeBounds = true + BitmapFactory.decodeByteArray(data, 0, data.size, this) + inSampleSize = calculateInSampleSize(SIZE) + inJustDecodeBounds = false + val bitmap = BitmapFactory.decodeByteArray(data, 0, data.size, this) ?: return@run false + bitmap.compress(FORMAT, QUALITY, output) + true + } + + private fun BitmapFactory.Options.calculateInSampleSize(size: Int): Int { + var inSampleSize = 1 + val (height, width) = outHeight to outWidth + + if (height > size || width > size) { + val halfHeight = height / 2 + val halfWidth = width / 2 + while ((halfHeight / inSampleSize) >= size && (halfWidth / inSampleSize) >= size) { + inSampleSize *= 2 + } + } + return inSampleSize + } + + private companion object { + const val SIZE = 750 + val FORMAT = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Bitmap.CompressFormat.WEBP_LOSSY + } else { + Bitmap.CompressFormat.WEBP + } + const val QUALITY = 80 + const val EXTENSION = "webp" + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/image/stack/cache/StoredCoversDatabase.kt b/app/src/main/java/org/oxycblt/auxio/image/stack/cache/StoredCoversDatabase.kt index b9fbae075..41651feab 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/stack/cache/StoredCoversDatabase.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/stack/cache/StoredCoversDatabase.kt @@ -38,7 +38,7 @@ abstract class StoredCoversDatabase : RoomDatabase() { interface StoredCoversDao { @Query("SELECT coverId FROM StoredCover WHERE uid = :uid AND lastModified = :lastModified") @TypeConverters(Music.UID.TypeConverters::class) - suspend fun getStoredCover(uid: Music.UID, lastModified: Long): String? + suspend fun getStoredCoverId(uid: Music.UID, lastModified: Long): String? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun setStoredCover(storedCover: StoredCover)