tawasuyu

sergio/humanoid

HTML 38Markdown 1TOML 10 Definiciones

⎇ a4998fe44b630e7fbb929875d300c82de41ca981 launcher/src/main/java/com/rizoma/orbita/ui/OrbitaViewModel.kt 230 líneas · 9.0 KB Historia crudo

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

1package com.rizoma.orbita.ui
2
3import android.app.Application
4import android.content.BroadcastReceiver
5import android.content.ComponentName
6import android.content.Context
7import android.content.Intent
8import android.content.IntentFilter
9import android.net.Uri
10import android.provider.Settings
11import androidx.lifecycle.AndroidViewModel
12import androidx.lifecycle.viewModelScope
13import com.rizoma.orbita.data.AppRepository
14import com.rizoma.orbita.data.SkyRepository
15import com.rizoma.orbita.data.UsageStore
16import com.rizoma.orbita.data.Weather
17import com.rizoma.orbita.model.AppNode
18import com.rizoma.orbita.model.Buckets
19import com.rizoma.orbita.model.ExpelledNode
20import com.rizoma.orbita.model.SearchSort
21import kotlinx.coroutines.Dispatchers
22import kotlinx.coroutines.Job
23import kotlinx.coroutines.delay
24import kotlinx.coroutines.flow.MutableStateFlow
25import kotlinx.coroutines.flow.SharingStarted
26import kotlinx.coroutines.flow.StateFlow
27import kotlinx.coroutines.flow.asStateFlow
28import kotlinx.coroutines.flow.combine
29import kotlinx.coroutines.flow.flow
30import kotlinx.coroutines.flow.map
31import kotlinx.coroutines.flow.stateIn
32import kotlinx.coroutines.launch
33import java.util.Calendar
34
35class OrbitaViewModel(app: Application) : AndroidViewModel(app) {
36
37    private val repo = AppRepository(app)
38    private val usage = UsageStore(app)
39
40    private val rawApps = MutableStateFlow<List<AppNode>>(emptyList())
41
42    /** Todas las apps ya ponderadas y ordenadas (fijados, calor, alfabético). Para la búsqueda. */
43    val allNodes: StateFlow<List<AppNode>> = combine(rawApps, usage.snapshot()) { apps, u ->
44        apps.map { node ->
45            node.copy(
46                score = (u.counts[node.key] ?: 0).toFloat(),
47                pinned = node.key in u.pinned,
48                penalty = u.penalties[node.key] ?: 0,
49                hidden = node.key in u.hidden,
50                hiddenAt = u.hiddenAt[node.key] ?: 0L,
51            )
52        }.sortedWith(
53            compareByDescending<AppNode> { it.pinned }
54                .thenByDescending { it.score - (u.penalties[it.key] ?: 0).toFloat() }
55                .thenBy { it.label.lowercase() }
56        )
57    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
58
59    /** Las apps repartidas en las cuatro zonas (átomo, nuevas, expulsadas, desterradas). */
60    val buckets: StateFlow<Buckets> = allNodes
61        .map { classify(it) }
62        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), Buckets(emptyList(), emptyList(), emptyList(), emptyList()))
63
64    private fun classify(nodes: List<AppNode>): Buckets {
65        val now = System.currentTimeMillis()
66        val nuevas = nodes.filter {
67            !it.hidden && it.score == 0f &&
68                it.firstInstallTime > 0L && now - it.firstInstallTime < NEW_WINDOW
69        }.sortedByDescending { it.firstInstallTime }
70        val nuevasKeys = nuevas.mapTo(HashSet()) { it.key }
71
72        val atom = nodes.filter { !it.hidden && it.key !in nuevasKeys }
73
74        val expulsadas = ArrayList<ExpelledNode>()
75        val desterradas = ArrayList<AppNode>()
76        for (n in nodes.filter { it.hidden }) {
77            val age = now - n.hiddenAt
78            if (n.hiddenAt > 0L && age < CALM_WINDOW) {
79                expulsadas.add(ExpelledNode(n, (1f - age.toFloat() / CALM_WINDOW).coerceIn(0f, 1f)))
80            } else {
81                desterradas.add(n)
82            }
83        }
84        return Buckets(atom, nuevas, expulsadas, desterradas.sortedBy { it.label.lowercase() })
85    }
86
87    // --- Búsqueda: criterio de orden (se recuerda entre sesiones). ---
88
89    /** Cómo se ordena la lista de la búsqueda. Por defecto, por frecuencia de uso. */
90    val searchSort: StateFlow<SearchSort> = usage.sortMode()
91        .map { SearchSort.from(it) }
92        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SearchSort.FRECUENCIA)
93
94    fun setSearchSort(sort: SearchSort) {
95        viewModelScope.launch { usage.setSortMode(sort.name) }
96    }
97
98    // --- Cielo del fondo: hora del día + clima local (perezoso). ---
99
100    private val _weather = MutableStateFlow<Weather?>(null)
101    /** Clima actual (null hasta que se visita la página de clima). */
102    val weather: StateFlow<Weather?> = _weather.asStateFlow()
103
104    /** Marca la hora actual (con fracción) y se reemite cada minuto. */
105    private val clock = flow {
106        while (true) {
107            emit(currentHourFraction())
108            delay(60_000L)
109        }
110    }
111
112    val sky: StateFlow<SkyPalette> = combine(clock, _weather) { hour, w ->
113        skyPaletteFor(hour, w)
114    }.stateIn(
115        viewModelScope,
116        SharingStarted.WhileSubscribed(5_000),
117        skyPaletteFor(currentHourFraction(), null),
118    )
119
120    /**
121     * Refresca el clima local (best-effort, una sola vez salvo que se fuerce).
122     * Se llama al acercarse a la página de clima: mientras no se abra, no se carga.
123     */
124    fun ensureWeather() {
125        if (_weather.value != null) return
126        refreshWeather()
127    }
128
129    fun refreshWeather() {
130        viewModelScope.launch(Dispatchers.IO) {
131            SkyRepository(getApplication()).fetch()?.let { _weather.value = it }
132        }
133    }
134
135    private fun currentHourFraction(): Float {
136        val c = Calendar.getInstance()
137        return c.get(Calendar.HOUR_OF_DAY) + c.get(Calendar.MINUTE) / 60f
138    }
139
140    private var refreshJob: Job? = null
141
142    private val pkgReceiver = object : BroadcastReceiver() {
143        override fun onReceive(context: Context?, intent: Intent?) = refresh(PKG_DEBOUNCE)
144    }
145
146    init {
147        refresh()
148        val filter = IntentFilter().apply {
149            addAction(Intent.ACTION_PACKAGE_ADDED)
150            addAction(Intent.ACTION_PACKAGE_REMOVED)
151            addAction(Intent.ACTION_PACKAGE_CHANGED)
152            addDataScheme("package")
153        }
154        getApplication<Application>().registerReceiver(pkgReceiver, filter)
155    }
156
157    /**
158     * Rehace la cosecha. Primero pinta lo que quedó en la caché —el átomo aparece
159     * ya formado en vez de en blanco mientras se rasterizan ciento y pico iconos—
160     * y luego deja que el repositorio lo vaya corrigiendo encima por lotes.
161     *
162     * [esperaMs] amortigua las ráfagas del receptor de paquetes: instalar una app
163     * dispara varios avisos seguidos y no hace falta recorrer el sistema por cada uno.
164     */
165    fun refresh(esperaMs: Long = 0L) {
166        refreshJob?.cancel()
167        refreshJob = viewModelScope.launch(Dispatchers.IO) {
168            if (esperaMs > 0L) delay(esperaMs)
169            if (rawApps.value.isEmpty()) {
170                repo.cached().takeIf { it.isNotEmpty() }?.let { rawApps.value = it }
171            }
172            repo.load { rawApps.value = it }
173        }
174    }
175
176    fun launch(node: AppNode) {
177        val intent = Intent(Intent.ACTION_MAIN)
178            .addCategory(Intent.CATEGORY_LAUNCHER)
179            .setComponent(ComponentName(node.packageName, node.activityName))
180            .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
181        runCatching { getApplication<Application>().startActivity(intent) }
182        viewModelScope.launch { usage.record(node.key) }
183    }
184
185    /** Abre una app por nombre de paquete (p.ej. Nimbo desde la página de clima). */
186    fun launchPackage(pkg: String): Boolean {
187        val intent = getApplication<Application>().packageManager.getLaunchIntentForPackage(pkg)
188            ?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) ?: return false
189        return runCatching { getApplication<Application>().startActivity(intent); true }.getOrDefault(false)
190    }
191
192    fun togglePin(node: AppNode) {
193        viewModelScope.launch { usage.setPinned(node.key, !node.pinned) }
194    }
195
196    /** Expulsa el nodo del átomo (o lo devuelve). No desinstala: la app sigue ahí. */
197    fun toggleHidden(node: AppNode) {
198        viewModelScope.launch { usage.setHidden(node.key, !node.hidden, System.currentTimeMillis()) }
199    }
200
201    /** Aleja el nodo a órbitas externas (usa su calor actual como referencia). */
202    fun pushAway(node: AppNode) {
203        viewModelScope.launch { usage.pushAway(node.key, node.score.toInt()) }
204    }
205
206    fun openInfo(node: AppNode) {
207        val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
208            .setData(Uri.fromParts("package", node.packageName, null))
209            .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
210        runCatching { getApplication<Application>().startActivity(intent) }
211    }
212
213    fun uninstall(node: AppNode) {
214        @Suppress("DEPRECATION")
215        val intent = Intent(Intent.ACTION_DELETE)
216            .setData(Uri.fromParts("package", node.packageName, null))
217            .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
218        runCatching { getApplication<Application>().startActivity(intent) }
219    }
220
221    override fun onCleared() {
222        runCatching { getApplication<Application>().unregisterReceiver(pkgReceiver) }
223    }
224
225    private companion object {
226        const val PKG_DEBOUNCE = 400L                  // ráfaga de avisos de paquete
227        const val NEW_WINDOW = 3L * 24 * 3600 * 1000   // "nueva" durante 3 días
228        const val CALM_WINDOW = 7L * 24 * 3600 * 1000  // se calma/destierra en 7 días
229    }
230}