gioser

Órbita: caché en disco de la cosecha de apps

El arranque se iba en rasterizar ciento y pico iconos (abrir el APK de cada
app e inflar su drawable adaptativo), en un solo hilo y sin pintar nada hasta
el final. Como al launcher lo matan a menudo, ese trabajo se repetía entero
cada vez —de ahí la sensación de que "se libera de la memoria"—.

Ahora la cosecha queda en cacheDir: un índice de texto con los metadatos y un
WebP por icono, sellados con idioma+densidad. Al arrancar se pinta lo guardado
al instante y luego se corrige encima: solo se vuelve a rasterizar el paquete
cuyo lastUpdateTime cambió, en cuatro hilos y emitiendo por lotes de doce.
Las fechas de todos los paquetes se piden en una sola llamada en vez de una
por paquete, y las ráfagas del receptor de paquetes se amortiguan 400 ms.

b7b92b8360b37bcb7fa6c023ff5d614b5e7635ff · por Sergio · (hace 1 mes) · padre 0d52d93ce · ver el árbol en este commit

Delta semántico

Ninguna definición cambió: el cambio es de texto, documentación o datos.

3 archivos · +247 −25

diff --git a/launcher/src/main/java/com/rizoma/orbita/data/AppCache.kt b/launcher/src/main/java/com/rizoma/orbita/data/AppCache.kt
new file mode 100644
index 0000000..9bfefe4
--- /dev/null
+++ b/launcher/src/main/java/com/rizoma/orbita/data/AppCache.kt
@@ -0,0 +1,130 @@
+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<CachedNode> {
+        val lines = runCatching { index.readLines() }.getOrNull() ?: return emptyList()
+        if (lines.firstOrNull() != stamp) {
+            clear()
+            return emptyList()
+        }
+        val out = ArrayList<CachedNode>(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<CachedNode>) {
+        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
+    }
+}
diff --git a/launcher/src/main/java/com/rizoma/orbita/data/AppRepository.kt b/launcher/src/main/java/com/rizoma/orbita/data/AppRepository.kt
index cc55e29..c4d3546 100644
--- a/launcher/src/main/java/com/rizoma/orbita/data/AppRepository.kt
+++ b/launcher/src/main/java/com/rizoma/orbita/data/AppRepository.kt
@@ -2,45 +2,120 @@ package com.rizoma.orbita.data
 
 import android.content.Context
 import android.content.Intent
+import android.content.pm.ResolveInfo
 import androidx.compose.ui.graphics.asImageBitmap
 import androidx.core.graphics.drawable.toBitmap
 import com.rizoma.orbita.model.AppNode
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import java.util.concurrent.ConcurrentHashMap
 
 /**
  * Cosecha del sistema: todas las actividades lanzables se vuelven nodos del rizoma.
- * El icono se rasteriza una sola vez aquí (operación cara → fuera del hilo principal).
+ *
+ * Rasterizar un icono es caro y son ciento y pico, así que:
+ * lo de la última vez se pinta de la caché sin preguntar a nadie ([cached]), y la
+ * cosecha de verdad ([load]) solo vuelve a rasterizar los paquetes que cambiaron,
+ * en paralelo y emitiendo por lotes en vez de aparecer de golpe al final.
  */
 class AppRepository(context: Context) {
 
     private val appContext = context.applicationContext
     private val pm = appContext.packageManager
+    private val cache = AppCache(appContext)
 
-    fun loadApps(): List<AppNode> {
-        val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
+    /** La última cosecha conocida, por clave de nodo. */
+    private var known: Map<String, CachedNode> = emptyMap()
+    private val enCurso = Mutex()
+
+    @OptIn(ExperimentalCoroutinesApi::class)
+    private val rasterizador = Dispatchers.IO.limitedParallelism(PARALELO)
+
+    /** Lo que quedó de la última vez: se pinta al instante, sin tocar el PackageManager. */
+    fun cached(): List<AppNode> {
+        if (known.isEmpty()) known = cache.read().associateBy { it.node.key }
+        return known.values.map { it.node }
+    }
+
+    /**
+     * Recorre el sistema y va emitiendo la lista según se completa: primero, de un
+     * tirón, todo lo que la caché ya tenía al día; luego cada lote de iconos nuevos.
+     */
+    suspend fun load(emit: (List<AppNode>) -> Unit) = enCurso.withLock {
         val self = appContext.packageName
-        // Fecha de instalación por paquete (cara de consultar): se cachea, ya que
-        // un mismo paquete puede aportar varias actividades lanzables.
-        val installed = HashMap<String, Long>()
-        return pm.queryIntentActivities(intent, 0)
-            .asSequence()
+        val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
+        val resueltas = pm.queryIntentActivities(intent, 0)
             .filter { it.activityInfo.packageName != self }
-            .map { ri ->
-                val pkg = ri.activityInfo.packageName
-                val icon = ri.loadIcon(pm).toBitmap(ICON_PX, ICON_PX).asImageBitmap()
-                AppNode(
-                    packageName = pkg,
-                    activityName = ri.activityInfo.name,
-                    label = ri.loadLabel(pm).toString(),
-                    icon = icon,
-                    firstInstallTime = installed.getOrPut(pkg) {
-                        runCatching { pm.getPackageInfo(pkg, 0).firstInstallTime }.getOrDefault(0L)
-                    },
-                )
+
+        // Las fechas de todos los paquetes en una sola llamada al sistema, en vez de
+        // una por paquete. firstInstallTime nunca cambia; lastUpdateTime dice si el
+        // icono y el nombre que guardamos siguen valiendo.
+        val fechas = ConcurrentHashMap<String, Pair<Long, Long>>()
+        @Suppress("DEPRECATION")
+        runCatching { pm.getInstalledPackages(0) }.getOrNull()?.forEach {
+            fechas[it.packageName] = it.firstInstallTime to it.lastUpdateTime
+        }
+        fun fechaDe(pkg: String): Pair<Long, Long> = fechas.getOrPut(pkg) {
+            runCatching { pm.getPackageInfo(pkg, 0) }
+                .map { it.firstInstallTime to it.lastUpdateTime }
+                .getOrDefault(0L to 0L)
+        }
+
+        val cosecha = LinkedHashMap<String, CachedNode>(resueltas.size)
+        val pendientes = ArrayList<ResolveInfo>()
+        for (ri in resueltas) {
+            val key = "${ri.activityInfo.packageName}/${ri.activityInfo.name}"
+            val (_, actualizado) = fechaDe(ri.activityInfo.packageName)
+            val previo = known[key]
+            // Sin fecha fiable (0) no nos arriesgamos: se vuelve a rasterizar.
+            if (previo != null && actualizado != 0L && previo.lastUpdateTime == actualizado) {
+                cosecha[key] = previo
+            } else {
+                pendientes.add(ri)
+            }
+        }
+
+        // Lo reutilizable, ya: también así desaparecen las que se hayan desinstalado.
+        // (Si no hay nada que reutilizar pero sí que rasterizar, callamos: mejor
+        // dejar el átomo como está que vaciarlo un instante.)
+        if (cosecha.isNotEmpty() || pendientes.isEmpty()) emit(cosecha.values.map { it.node })
+
+        for (lote in pendientes.chunked(LOTE)) {
+            val nuevos = coroutineScope {
+                lote.map { ri -> async(rasterizador) { rasterizar(ri, ::fechaDe) } }.awaitAll()
             }
-            .toList()
+            nuevos.forEach { cosecha[it.node.key] = it }
+            emit(cosecha.values.map { it.node })
+        }
+
+        known = cosecha
+        cache.writeIndex(cosecha.values.toList())
+    }
+
+    /** El trabajo caro: abrir el APK ajeno, inflar su icono y dejarlo ya en la caché. */
+    private fun rasterizar(ri: ResolveInfo, fechas: (String) -> Pair<Long, Long>): CachedNode {
+        val pkg = ri.activityInfo.packageName
+        val (instalado, actualizado) = fechas(pkg)
+        val icon = ri.loadIcon(pm).toBitmap(ICON_PX, ICON_PX).asImageBitmap()
+        val node = AppNode(
+            packageName = pkg,
+            activityName = ri.activityInfo.name,
+            label = ri.loadLabel(pm).toString(),
+            icon = icon,
+            firstInstallTime = instalado,
+        )
+        cache.writeIcon(node.key, icon)
+        return CachedNode(node, actualizado)
     }
 
     private companion object {
         const val ICON_PX = 144
+        const val PARALELO = 4   // hilos rasterizando a la vez
+        const val LOTE = 12      // cada cuántos iconos se repinta el átomo
     }
 }
diff --git a/launcher/src/main/java/com/rizoma/orbita/ui/OrbitaViewModel.kt b/launcher/src/main/java/com/rizoma/orbita/ui/OrbitaViewModel.kt
index 8aa1549..beefe01 100644
--- a/launcher/src/main/java/com/rizoma/orbita/ui/OrbitaViewModel.kt
+++ b/launcher/src/main/java/com/rizoma/orbita/ui/OrbitaViewModel.kt
@@ -19,6 +19,7 @@ import com.rizoma.orbita.model.Buckets
 import com.rizoma.orbita.model.ExpelledNode
 import com.rizoma.orbita.model.SearchSort
 import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.SharingStarted
@@ -136,8 +137,10 @@ class OrbitaViewModel(app: Application) : AndroidViewModel(app) {
         return c.get(Calendar.HOUR_OF_DAY) + c.get(Calendar.MINUTE) / 60f
     }
 
+    private var refreshJob: Job? = null
+
     private val pkgReceiver = object : BroadcastReceiver() {
-        override fun onReceive(context: Context?, intent: Intent?) = refresh()
+        override fun onReceive(context: Context?, intent: Intent?) = refresh(PKG_DEBOUNCE)
     }
 
     init {
@@ -151,9 +154,22 @@ class OrbitaViewModel(app: Application) : AndroidViewModel(app) {
         getApplication<Application>().registerReceiver(pkgReceiver, filter)
     }
 
-    fun refresh() {
-        viewModelScope.launch(Dispatchers.IO) {
-            rawApps.value = repo.loadApps()
+    /**
+     * Rehace la cosecha. Primero pinta lo que quedó en la caché —el átomo aparece
+     * ya formado en vez de en blanco mientras se rasterizan ciento y pico iconos—
+     * y luego deja que el repositorio lo vaya corrigiendo encima por lotes.
+     *
+     * [esperaMs] amortigua las ráfagas del receptor de paquetes: instalar una app
+     * dispara varios avisos seguidos y no hace falta recorrer el sistema por cada uno.
+     */
+    fun refresh(esperaMs: Long = 0L) {
+        refreshJob?.cancel()
+        refreshJob = viewModelScope.launch(Dispatchers.IO) {
+            if (esperaMs > 0L) delay(esperaMs)
+            if (rawApps.value.isEmpty()) {
+                repo.cached().takeIf { it.isNotEmpty() }?.let { rawApps.value = it }
+            }
+            repo.load { rawApps.value = it }
         }
     }
 
@@ -207,6 +223,7 @@ class OrbitaViewModel(app: Application) : AndroidViewModel(app) {
     }
 
     private companion object {
+        const val PKG_DEBOUNCE = 400L                  // ráfaga de avisos de paquete
         const val NEW_WINDOW = 3L * 24 * 3600 * 1000   // "nueva" durante 3 días
         const val CALM_WINDOW = 7L * 24 * 3600 * 1000  // se calma/destierra en 7 días
     }