music: make caching thread safe

This commit is contained in:
Alexander Capehart 2024-11-29 09:50:09 -07:00
parent c3f9f0d80e
commit b832ac8639
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
3 changed files with 53 additions and 38 deletions

View file

@ -20,10 +20,14 @@ package org.oxycblt.auxio.image.stack.cache
import android.content.Context import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
import javax.inject.Inject import javax.inject.Inject
import kotlin.concurrent.withLock
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
interface AppFiles { interface AppFiles {
@ -34,8 +38,15 @@ interface AppFiles {
class AppFilesImpl @Inject constructor(@ApplicationContext private val context: Context) : class AppFilesImpl @Inject constructor(@ApplicationContext private val context: Context) :
AppFiles { AppFiles {
private val fileMutexes = mutableMapOf<String, Mutex>()
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? = override suspend fun read(file: String): InputStream? =
withContext(context = Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
context.openFileInput(file) context.openFileInput(file)
} catch (e: IOException) { } catch (e: IOException) {
@ -43,17 +54,27 @@ class AppFilesImpl @Inject constructor(@ApplicationContext private val context:
} }
} }
override suspend fun write(file: String, inputStream: InputStream) = override suspend fun write(file: String, inputStream: InputStream): Boolean =
withContext(context = Dispatchers.IO) { withContext(Dispatchers.IO) {
try { val fileMutex = getMutexForFile(file)
context.openFileOutput(file, Context.MODE_PRIVATE).use { fileOutputStream ->
inputStream.copyTo(fileOutputStream) 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()
} }
} }
} }

View file

@ -31,7 +31,7 @@ import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
interface StackModule { interface StackModule {
@Binds fun appFiles(impl: AppFilesImpl): AppFiles @Singleton @Binds fun appFiles(impl: AppFilesImpl): AppFiles
@Binds fun perceptualHash(perceptualHash: PerceptualHashImpl): PerceptualHash @Binds fun perceptualHash(perceptualHash: PerceptualHashImpl): PerceptualHash

View file

@ -19,56 +19,49 @@
package org.oxycblt.auxio.image.stack.cache package org.oxycblt.auxio.image.stack.cache
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Build import android.os.Build
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStream import java.io.InputStream
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.min
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.image.extractor.Cover
interface CoverCache { 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 class CoverCacheImpl
@Inject @Inject
constructor( constructor(private val storedCoversDao: StoredCoversDao, private val appFiles: AppFiles) :
private val storedCoversDao: StoredCoversDao, CoverCache {
private val appFiles: AppFiles,
private val perceptualHash: PerceptualHash
) : CoverCache {
override suspend fun read(song: Song): InputStream? { override suspend fun read(cover: Cover.Single): InputStream? {
val perceptualHash = val perceptualHash =
storedCoversDao.getCoverFile(song.uid, song.lastModified) ?: return null storedCoversDao.getCoverFile(cover.uid, cover.lastModified) ?: return null
return appFiles.read(fileName(perceptualHash)) 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) { withContext(Dispatchers.IO) {
val bitmap = BitmapFactory.decodeStream(inputStream) val available = inputStream.available()
val perceptualHash = perceptualHash.hash(bitmap) 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 @OptIn(ExperimentalStdlibApi::class) val perceptualHash = bytes.toHexString()
val compressedStream = val writeSuccess = appFiles.write(fileName(perceptualHash), inputStream)
ByteArrayOutputStream().use { outputStream ->
bitmap.compress(COVER_CACHE_FORMAT, 80, outputStream)
ByteArrayInputStream(outputStream.toByteArray())
}
val writeSuccess = appFiles.write(fileName(perceptualHash), compressedStream)
if (writeSuccess) { if (writeSuccess) {
storedCoversDao.setCoverFile( storedCoversDao.setCoverFile(
StoredCover( StoredCover(
uid = song.uid, uid = cover.uid,
lastModified = song.lastModified, lastModified = cover.lastModified,
perceptualHash = perceptualHash)) perceptualHash = perceptualHash))
} }
@ -78,6 +71,7 @@ constructor(
private fun fileName(perceptualHash: String) = "cover_$perceptualHash.png" private fun fileName(perceptualHash: String) = "cover_$perceptualHash.png"
private companion object { private companion object {
const val COVER_KEY_SAMPLE = 32
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
val COVER_CACHE_FORMAT = val COVER_CACHE_FORMAT =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {