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 fb5caf54c..3182557fe 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 @@ -20,10 +20,14 @@ package org.oxycblt.auxio.image.stack.cache import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext +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 { @@ -34,8 +38,15 @@ interface AppFiles { class AppFilesImpl @Inject constructor(@ApplicationContext private val context: Context) : AppFiles { + private val fileMutexes = mutableMapOf() + private val mapMutex = Mutex() + + private suspend fun getMutexForFile(file: String): Mutex { + return mapMutex.withLock { fileMutexes.getOrPut(file) { Mutex() } } + } + override suspend fun read(file: String): InputStream? = - withContext(context = Dispatchers.IO) { + withContext(Dispatchers.IO) { try { context.openFileInput(file) } catch (e: IOException) { @@ -43,17 +54,27 @@ class AppFilesImpl @Inject constructor(@ApplicationContext private val context: } } - override suspend fun write(file: String, inputStream: InputStream) = - withContext(context = Dispatchers.IO) { - try { - context.openFileOutput(file, Context.MODE_PRIVATE).use { fileOutputStream -> - inputStream.copyTo(fileOutputStream) + override suspend fun write(file: String, inputStream: InputStream): Boolean = + withContext(Dispatchers.IO) { + val fileMutex = getMutexForFile(file) + + fileMutex.withLock { + val tempFile = File(context.filesDir, "$file.tmp") + val targetFile = File(context.filesDir, file) + + try { + tempFile.outputStream().use { fileOutputStream -> + inputStream.copyTo(fileOutputStream) + } + + tempFile.renameTo(targetFile) + true + } catch (e: IOException) { + tempFile.delete() + false + } finally { + inputStream.close() } - true - } catch (e: IOException) { - false - } finally { - inputStream.close() } } } 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 e813395e1..ae6f55f79 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,7 +31,7 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) interface StackModule { - @Binds fun appFiles(impl: AppFilesImpl): AppFiles + @Singleton @Binds fun appFiles(impl: AppFilesImpl): AppFiles @Binds fun perceptualHash(perceptualHash: PerceptualHashImpl): PerceptualHash 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 ae6ea3101..b84145973 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 @@ -19,56 +19,49 @@ 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 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.image.extractor.Cover interface CoverCache { - suspend fun read(song: Song): InputStream? + suspend fun read(cover: Cover.Single): InputStream? - suspend fun write(song: Song, inputStream: InputStream): Boolean + suspend fun write(cover: Cover.Single, inputStream: InputStream): Boolean } class CoverCacheImpl @Inject -constructor( - private val storedCoversDao: StoredCoversDao, - private val appFiles: AppFiles, - private val perceptualHash: PerceptualHash -) : CoverCache { +constructor(private val storedCoversDao: StoredCoversDao, private val appFiles: AppFiles) : + CoverCache { - override suspend fun read(song: Song): InputStream? { + override suspend fun read(cover: Cover.Single): InputStream? { val perceptualHash = - storedCoversDao.getCoverFile(song.uid, song.lastModified) ?: return null + storedCoversDao.getCoverFile(cover.uid, cover.lastModified) ?: return null return appFiles.read(fileName(perceptualHash)) } - override suspend fun write(song: Song, inputStream: InputStream): Boolean = + override suspend fun write(cover: Cover.Single, inputStream: InputStream): Boolean = withContext(Dispatchers.IO) { - val bitmap = BitmapFactory.decodeStream(inputStream) - val perceptualHash = perceptualHash.hash(bitmap) + 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() - // Compress bitmap down to webp into another inputstream - val compressedStream = - ByteArrayOutputStream().use { outputStream -> - bitmap.compress(COVER_CACHE_FORMAT, 80, outputStream) - ByteArrayInputStream(outputStream.toByteArray()) - } - - val writeSuccess = appFiles.write(fileName(perceptualHash), compressedStream) + @OptIn(ExperimentalStdlibApi::class) val perceptualHash = bytes.toHexString() + val writeSuccess = appFiles.write(fileName(perceptualHash), inputStream) if (writeSuccess) { storedCoversDao.setCoverFile( StoredCover( - uid = song.uid, - lastModified = song.lastModified, + uid = cover.uid, + lastModified = cover.lastModified, perceptualHash = perceptualHash)) } @@ -78,6 +71,7 @@ constructor( private fun fileName(perceptualHash: String) = "cover_$perceptualHash.png" private companion object { + const val COVER_KEY_SAMPLE = 32 @Suppress("DEPRECATION") val COVER_CACHE_FORMAT = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {