⎇ b7b92b8360b37bcb7fa6c023ff5d614b5e7635ff
launcher/src/main/java/com/rizoma/orbita/data/AppCache.kt
Historia
crudo
Órbita: caché en disco de la cosecha de apps
1package com.rizoma.orbita.data
2
3import android.content.Context
4import android.graphics.Bitmap
5import android.graphics.BitmapFactory
6import android.os.Build
7import androidx.compose.ui.graphics.ImageBitmap
8import androidx.compose.ui.graphics.asAndroidBitmap
9import androidx.compose.ui.graphics.asImageBitmap
10import com.rizoma.orbita.model.AppNode
11import java.io.File
12import java.security.MessageDigest
13
14/** Un nodo tal como se guardó, junto a la versión del paquete que lo produjo. */
15internal class CachedNode(val node: AppNode, val lastUpdateTime: Long)
16
17/**
18 * La cosecha de apps, guardada en disco.
19 *
20 * Rasterizar los iconos es lo caro del arranque: cada uno abre el APK de su app,
21 * monta un AssetManager e infla un drawable adaptativo. Al launcher lo matan a
22 * menudo —es el proceso que se va al fondo cada vez que abres algo—, así que sin
23 * esto ese trabajo se repite entero en cada arranque.
24 *
25 * Aquí queda un índice de texto con los metadatos y un WebP por icono, listos para
26 * pintarse sin volver a preguntarle al sistema. La caché se sella con la
27 * configuración (idioma + densidad): si cambia, los iconos y los nombres ya no
28 * valen y se tira entera.
29 */
30internal class AppCache(context: Context) {
31
32 private val dir = File(context.cacheDir, "apps")
33 private val index = File(dir, "index")
34 private val stamp: String = context.resources.configuration.let { c ->
35 "$VERSION|${c.locales[0]}|${c.densityDpi}"
36 }
37
38 /** La última cosecha, o nada si no hay, no sirve o está rota. */
39 fun read(): List<CachedNode> {
40 val lines = runCatching { index.readLines() }.getOrNull() ?: return emptyList()
41 if (lines.firstOrNull() != stamp) {
42 clear()
43 return emptyList()
44 }
45 val out = ArrayList<CachedNode>(lines.size - 1)
46 for (line in lines.drop(1)) {
47 val f = line.split('\t')
48 if (f.size < 5) continue
49 val icon = readIcon(f[0] + "/" + f[1]) ?: continue // sin icono, que se rehaga
50 out.add(
51 CachedNode(
52 AppNode(
53 packageName = f[0],
54 activityName = f[1],
55 label = f[4],
56 icon = icon,
57 firstInstallTime = f[2].toLongOrNull() ?: 0L,
58 ),
59 lastUpdateTime = f[3].toLongOrNull() ?: 0L,
60 )
61 )
62 }
63 return out
64 }
65
66 /** Guarda el icono recién rasterizado de un nodo. */
67 fun writeIcon(key: String, icon: ImageBitmap) {
68 runCatching {
69 dir.mkdirs()
70 val tmp = File(dir, "${name(key)}.tmp")
71 tmp.outputStream().use { icon.asAndroidBitmap().compress(FORMAT, 100, it) }
72 tmp.renameTo(iconFile(key))
73 }
74 }
75
76 /** Sella la cosecha entera y barre los iconos que ya no orbitan a nadie. */
77 fun writeIndex(nodes: List<CachedNode>) {
78 runCatching {
79 dir.mkdirs()
80 val texto = buildString {
81 append(stamp).append('\n')
82 for (c in nodes) {
83 val n = c.node
84 append(n.packageName).append('\t')
85 .append(n.activityName).append('\t')
86 .append(n.firstInstallTime).append('\t')
87 .append(c.lastUpdateTime).append('\t')
88 .append(n.label.replace('\t', ' ').replace('\n', ' ')).append('\n')
89 }
90 }
91 val tmp = File(dir, "index.tmp")
92 tmp.writeText(texto)
93 tmp.renameTo(index)
94
95 val vivos = nodes.mapTo(HashSet()) { iconFile(it.node.key).name }
96 dir.listFiles()?.forEach { f ->
97 if (f.name != index.name && f.name !in vivos) f.delete()
98 }
99 }
100 }
101
102 private fun clear() {
103 runCatching { dir.listFiles()?.forEach { it.delete() } }
104 }
105
106 private fun readIcon(key: String): ImageBitmap? =
107 runCatching { BitmapFactory.decodeFile(iconFile(key).path)?.asImageBitmap() }.getOrNull()
108
109 private fun iconFile(key: String) = File(dir, "${name(key)}.webp")
110
111 /** Nombre de archivo estable y corto para una clave `paquete/actividad`. */
112 private fun name(key: String): String {
113 val hash = MessageDigest.getInstance("SHA-1").digest(key.toByteArray())
114 val sb = StringBuilder(hash.size * 2)
115 for (b in hash) {
116 sb.append(HEX[(b.toInt() shr 4) and 0xf]).append(HEX[b.toInt() and 0xf])
117 }
118 return sb.toString()
119 }
120
121 private companion object {
122 const val VERSION = "orbita-1"
123 val HEX = "0123456789abcdef".toCharArray()
124
125 @Suppress("DEPRECATION")
126 val FORMAT: Bitmap.CompressFormat =
127 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) Bitmap.CompressFormat.WEBP_LOSSLESS
128 else Bitmap.CompressFormat.WEBP
129 }
130}