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 new file mode 100644 index 000000000..6e2a34309 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/image/stack/cache/AppFiles.kt @@ -0,0 +1,42 @@ +package org.oxycblt.auxio.image.stack.cache + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.IOException +import java.io.InputStream +import javax.inject.Inject + +interface AppFiles { + suspend fun read(file: String): InputStream? + suspend fun write(file: String, inputStream: InputStream): Boolean +} + +class AppFilesImpl @Inject constructor( + @ApplicationContext private val context: Context +) : AppFiles { + override suspend fun read(file: String): InputStream? = + withContext(context = Dispatchers.IO) { + try { + context.openFileInput(file) + } catch (e: IOException) { + null + } + } + + override suspend fun write(file: String, inputStream: InputStream) = + withContext(context = Dispatchers.IO) { + try { + context.openFileOutput(file, Context.MODE_PRIVATE).use { fileOutputStream -> + inputStream.copyTo(fileOutputStream) + } + true + } catch (e: IOException) { + false + } finally { + inputStream.close() + } + } + +} \ No newline at end of file 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 new file mode 100644 index 000000000..27dcb610d --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/image/stack/cache/CacheModule.kt @@ -0,0 +1,38 @@ +package org.oxycblt.auxio.image.stack.cache + +import android.content.Context +import androidx.media3.datasource.cache.Cache +import androidx.room.Room +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import org.oxycblt.auxio.music.stack.explore.cache.TagCache +import org.oxycblt.auxio.music.stack.explore.cache.TagCacheImpl +import org.oxycblt.auxio.music.stack.explore.cache.TagDatabase +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +interface StackModule { + @Binds fun appFiles(impl: AppFilesImpl): AppFiles + + @Binds fun cache(impl: CoverCacheImpl): Cache + + @Binds fun perceptualHash(perceptualHash: PerceptualHashImpl): PerceptualHash + + @Binds fun coverCache(cache: CoverCacheImpl): CoverCache +} + +@Module +@InstallIn(SingletonComponent::class) +class StoredCoversDatabaseModule { + @Singleton + @Provides + fun database(@ApplicationContext context: Context) = + Room.databaseBuilder(context.applicationContext, StoredCoversDatabase::class.java, "stored_covers.db") + .fallbackToDestructiveMigration() + .build() +} 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 new file mode 100644 index 000000000..1e211f022 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/image/stack/cache/CoverCache.kt @@ -0,0 +1,68 @@ +package org.oxycblt.auxio.image.stack.cache + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.os.Build +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.oxycblt.auxio.music.Song +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.InputStream +import javax.inject.Inject + +interface CoverCache { + suspend fun read(song: Song): InputStream? + suspend fun write(song: Song, inputStream: InputStream): Boolean +} + + +class CoverCacheImpl @Inject constructor( + private val storedCoversDao: StoredCoversDao, + private val appFiles: AppFiles, + private val perceptualHash: PerceptualHash +) : CoverCache { + + override suspend fun read(song: Song): InputStream? { + val perceptualHash = storedCoversDao.getCoverFile(song.uid, song.lastModified) + ?: return null + + return appFiles.read(fileName(perceptualHash)) + } + + override suspend fun write(song: Song, inputStream: InputStream): Boolean = withContext(Dispatchers.IO) { + val bitmap = BitmapFactory.decodeStream(inputStream) + val perceptualHash = perceptualHash.hash(bitmap) + + // 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) + + if (writeSuccess) { + storedCoversDao.setCoverFile( + StoredCover( + uid = song.uid, + lastModified = song.lastModified, + perceptualHash = perceptualHash + ) + ) + } + + writeSuccess + } + + private fun fileName(perceptualHash: String) = "cover_$perceptualHash.png" + + private companion object { + @Suppress("DEPRECATION") + val COVER_CACHE_FORMAT = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Bitmap.CompressFormat.WEBP_LOSSY + } else { + Bitmap.CompressFormat.WEBP + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/oxycblt/auxio/image/stack/cache/PerceptualHash.kt b/app/src/main/java/org/oxycblt/auxio/image/stack/cache/PerceptualHash.kt new file mode 100644 index 000000000..21a21f4f8 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/image/stack/cache/PerceptualHash.kt @@ -0,0 +1,49 @@ +package org.oxycblt.auxio.image.stack.cache + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.ColorMatrix +import android.graphics.ColorMatrixColorFilter +import android.graphics.Paint +import java.math.BigInteger + +interface PerceptualHash { + fun hash(bitmap: Bitmap): String +} + +class PerceptualHashImpl : PerceptualHash { + override fun hash(bitmap: Bitmap): String { + val hashSize = 16 + // Step 1: Resize the bitmap to a fixed size + val resizedBitmap = Bitmap.createScaledBitmap(bitmap, hashSize + 1, hashSize, true) + + // Step 2: Convert the bitmap to grayscale + val grayBitmap = + Bitmap.createBitmap(resizedBitmap.width, resizedBitmap.height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(grayBitmap) + val paint = Paint() + val colorMatrix = ColorMatrix() + colorMatrix.setSaturation(0f) + val filter = ColorMatrixColorFilter(colorMatrix) + paint.colorFilter = filter + canvas.drawBitmap(resizedBitmap, 0f, 0f, paint) + + // Step 3: Compute the difference between adjacent pixels + var hash = BigInteger.valueOf(0) + val one = BigInteger.valueOf(1) + for (y in 0 until hashSize) { + for (x in 0 until hashSize) { + val pixel1 = grayBitmap.getPixel(x, y) + val pixel2 = grayBitmap.getPixel(x + 1, y) + val diff = Color.red(pixel1) - Color.red(pixel2) + if (diff > 0) { + hash = hash.or(one.shl(y * hashSize + x)) + } + } + } + + return hash.toString(16) + } + +} \ No newline at end of file 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 new file mode 100644 index 000000000..29e88f143 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/image/stack/cache/StoredCoversDatabase.kt @@ -0,0 +1,35 @@ +package org.oxycblt.auxio.image.stack.cache + +import androidx.room.Dao +import androidx.room.Database +import androidx.room.Entity +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.PrimaryKey +import androidx.room.Query +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import org.oxycblt.auxio.music.Music + +@Database(entities = [StoredCover::class], version = 50, exportSchema = false) +abstract class StoredCoversDatabase : RoomDatabase() { + abstract fun storedCoversDao(): StoredCoversDao +} + +@Dao +interface StoredCoversDao { + @Query("SELECT perceptualHash FROM StoredCover WHERE uid = :uid AND lastModified = :lastModified") + fun getCoverFile(uid: Music.UID, lastModified: Long): String? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun setCoverFile(storedCover: StoredCover) +} + +@Entity +@TypeConverters(Music.UID.TypeConverters::class) +data class StoredCover( + @PrimaryKey + val uid: Music.UID, + val lastModified: Long, + val perceptualHash: String +) \ No newline at end of file