⎇ a4998fe44b630e7fbb929875d300c82de41ca981
launcher/src/main/java/com/rizoma/orbita/ui/OrbitaViewModel.kt
Historia
crudo
Órbita: caché en disco de la cosecha de apps
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}