Ó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.
Semantic delta
No definition changed: this change is text, documentation or data.
3 files · +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
}