package com.rizoma.orbita.data import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory import android.os.Build import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.graphics.asImageBitmap import com.rizoma.orbita.model.AppNode import java.io.File import java.security.MessageDigest /** Un nodo tal como se guardó, junto a la versión del paquete que lo produjo. */ internal class CachedNode(val node: AppNode, val lastUpdateTime: Long) /** * La cosecha de apps, guardada en disco. * * Rasterizar los iconos es lo caro del arranque: cada uno abre el APK de su app, * monta un AssetManager e infla un drawable adaptativo. Al launcher lo matan a * menudo —es el proceso que se va al fondo cada vez que abres algo—, así que sin * esto ese trabajo se repite entero en cada arranque. * * Aquí queda un índice de texto con los metadatos y un WebP por icono, listos para * pintarse sin volver a preguntarle al sistema. La caché se sella con la * configuración (idioma + densidad): si cambia, los iconos y los nombres ya no * valen y se tira entera. */ internal class AppCache(context: Context) { private val dir = File(context.cacheDir, "apps") private val index = File(dir, "index") private val stamp: String = context.resources.configuration.let { c -> "$VERSION|${c.locales[0]}|${c.densityDpi}" } /** La última cosecha, o nada si no hay, no sirve o está rota. */ fun read(): List { val lines = runCatching { index.readLines() }.getOrNull() ?: return emptyList() if (lines.firstOrNull() != stamp) { clear() return emptyList() } val out = ArrayList(lines.size - 1) for (line in lines.drop(1)) { val f = line.split('\t') if (f.size < 5) continue val icon = readIcon(f[0] + "/" + f[1]) ?: continue // sin icono, que se rehaga out.add( CachedNode( AppNode( packageName = f[0], activityName = f[1], label = f[4], icon = icon, firstInstallTime = f[2].toLongOrNull() ?: 0L, ), lastUpdateTime = f[3].toLongOrNull() ?: 0L, ) ) } return out } /** Guarda el icono recién rasterizado de un nodo. */ fun writeIcon(key: String, icon: ImageBitmap) { runCatching { dir.mkdirs() val tmp = File(dir, "${name(key)}.tmp") tmp.outputStream().use { icon.asAndroidBitmap().compress(FORMAT, 100, it) } tmp.renameTo(iconFile(key)) } } /** Sella la cosecha entera y barre los iconos que ya no orbitan a nadie. */ fun writeIndex(nodes: List) { runCatching { dir.mkdirs() val texto = buildString { append(stamp).append('\n') for (c in nodes) { val n = c.node append(n.packageName).append('\t') .append(n.activityName).append('\t') .append(n.firstInstallTime).append('\t') .append(c.lastUpdateTime).append('\t') .append(n.label.replace('\t', ' ').replace('\n', ' ')).append('\n') } } val tmp = File(dir, "index.tmp") tmp.writeText(texto) tmp.renameTo(index) val vivos = nodes.mapTo(HashSet()) { iconFile(it.node.key).name } dir.listFiles()?.forEach { f -> if (f.name != index.name && f.name !in vivos) f.delete() } } } private fun clear() { runCatching { dir.listFiles()?.forEach { it.delete() } } } private fun readIcon(key: String): ImageBitmap? = runCatching { BitmapFactory.decodeFile(iconFile(key).path)?.asImageBitmap() }.getOrNull() private fun iconFile(key: String) = File(dir, "${name(key)}.webp") /** Nombre de archivo estable y corto para una clave `paquete/actividad`. */ private fun name(key: String): String { val hash = MessageDigest.getInstance("SHA-1").digest(key.toByteArray()) val sb = StringBuilder(hash.size * 2) for (b in hash) { sb.append(HEX[(b.toInt() shr 4) and 0xf]).append(HEX[b.toInt() and 0xf]) } return sb.toString() } private companion object { const val VERSION = "orbita-1" val HEX = "0123456789abcdef".toCharArray() @Suppress("DEPRECATION") val FORMAT: Bitmap.CompressFormat = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) Bitmap.CompressFormat.WEBP_LOSSLESS else Bitmap.CompressFormat.WEBP } }