gioser

sergio/humanoid

HTML 25Markdown 1TOML 10 Definiciones

⎇ a4998fe44b630e7fbb929875d300c82de41ca981 launcher/src/main/java/com/rizoma/orbita/data/AppCache.kt 130 líneas · 4.8 KB Historia crudo

Órbita: caché en disco de la cosecha de apps b7b92b836 · Sergio ·

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}