Saltar al contenido
Todas las entradas

Construyendo Mintly: mantener un temporizador de enfoque preciso cuando Android quiere matar el proceso

Un temporizador Pomodoro en ejecución tiene un problema de fiabilidad más difícil que un recordatorio puntual. Así es como Mintly sobrevive al modo Doze, a la muerte del proceso y al desfase con la pantalla apagada, con un servicio en primer plano y una hora de fin en tiempo real.

MFKAPPS 5 min de lectura

Un recordatorio puntual solo tiene que dispararse una vez, más o menos a la hora correcta. Un temporizador de enfoque es un problema más difícil: tiene que mantenerse visible y continuamente correcto durante los 25 minutos completos, incluso con la pantalla apagada, incluso si Android decide matar el proceso a mitad de la sesión y reiniciarlo. Si esto sale mal, el fallo no es una notificación perdida — es un temporizador que muestra “quedan 12:41” durante un minuto entero, o que se reinicia a 25:00 después de echar un vistazo al teléfono.

Me topé con ambos casos construyendo Mintly. Esta es la arquitectura que lo resolvió: un servicio en primer plano, una hora de fin en tiempo real en lugar de un contador decreciente, y una notificación que dibuja su propia cuenta atrás en vez de confiar en que el servicio la redibuje.

El error que me enseñó la lección

Mi primera versión era la obvia: un bucle parecido a un CoroutineWorker que marcaba cada segundo, decrementaba un campo remainingSeconds y publicaba una notificación actualizada en cada marca. Funcionaba perfecto en el emulador y se rompió en un teléfono real en un día. Dos modos de fallo:

  • Marcas agrupadas. El modo Doze agrupa los despertares. Un bucle de “una marca por segundo” no llega a ejecutarse realmente cada segundo una vez apagada la pantalla — el sistema lo retrasa y luego entrega varias marcas de golpe. La cuenta atrás visible tartamudea o salta.
  • Muerte silenciosa del proceso. Si el sistema necesitaba memoria y mataba el proceso de la app, el campo remainingSeconds en memoria se iba con él. Al reiniciar no había nada de qué reanudar, así que el temporizador desaparecía o volvía a arrancar con su duración original — desde el punto de vista del usuario, cinco minutos de enfoque simplemente desaparecían.

La solución no era “marcar de forma más fiable”. Era dejar de tratar el tiempo restante como un estado que necesita sobrevivir y empezar a tratarlo como algo que siempre se puede recalcular.

Guardar una hora de fin, no una cuenta atrás

El cambio central: persistir un único sessionEndAtMillis (tiempo real, basado en System.currentTimeMillis()) en el momento en que empieza una sesión, y derivar todo lo demás bajo demanda.

data class FocusSession(
    val id: Long,
    val ritual: RitualType,
    val startedAtMillis: Long,
    val endAtMillis: Long,
    val state: SessionState, // RUNNING, PAUSED, COMPLETED
)

fun FocusSession.remainingMillis(nowMillis: Long = System.currentTimeMillis()): Long =
    (endAtMillis - nowMillis).coerceAtLeast(0L)

Este único cambio hace que todo el sistema se autorrepare. No importa cuántas marcas se perdieran, ni si el proceso fue matado y reiniciado entre medias — remainingMillis() es correcta en el instante en que se llama, calculada a partir de dos marcas de tiempo en lugar de acumulada desde un bucle. La sesión se persiste en Room en el momento en que arranca, así que un proceso nuevo puede recargarla y saber de inmediato exactamente en qué punto está.

Un servicio en primer plano que casi nunca marca

El trabajo del servicio se reduce a: mantener el proceso vivo el tiempo suficiente para notar la finalización, y mantener la notificación razonablemente actualizada. No es dueño de la fuente de verdad.

class FocusTimerService : Service() {
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        val session = repository.activeSessionOrNull() ?: run { stopSelf(); return START_NOT_STICKY }

        startForeground(NOTIFICATION_ID, buildChronometerNotification(session))

        scope.launch {
            while (isActive) {
                val remaining = session.remainingMillis()
                if (remaining <= 0L) {
                    repository.markCompleted(session.id)
                    postCompletionNotification(session)
                    stopSelf()
                    break
                }
                delay(15_000) // refresh cadence, not the source of truth
            }
        }
        return START_REDELIVER_INTENT
    }
}

Dos detalles hacen la mayor parte del trabajo:

  • setUsesChronometer(true) en la notificación, inicializado con chronometerCountDown = true y una base calculada a partir de endAtMillis. El propio sistema de notificaciones de Android redibuja entonces la cuenta atrás cada segundo usando su propio reloj — no mi bucle. El número visible nunca tartamudea, aunque mi servicio solo se refresque cada 15 segundos.
  • START_REDELIVER_INTENT, no START_STICKY. Si el sistema mata el servicio, se reinicia con el intent original, y onStartCommand vuelve a leer activeSessionOrNull() desde Room en lugar de confiar en nada que se mantuviera en memoria. Un servicio matado y reiniciado produce una notificación idéntica a la de uno que nunca murió.

La red de seguridad de la alarma exacta

Un servicio en primer plano tiene muchas menos probabilidades de ser matado que uno en segundo plano, pero “muchas menos probabilidades” no es una garantía — un gestor de batería agresivo de un fabricante todavía puede tumbarlo. Por eso la finalización tiene una segunda vía que no depende en absoluto de que el servicio sobreviva: una alarma exacta, programada para endAtMillis en el mismo momento en que arranca la sesión, con el mismo patrón setExactAndAllowWhileIdle que he descrito para los recordatorios de Hydrame. Si el servicio está vivo cuando salta la alarma, es una comprobación redundante inofensiva. Si el servicio fue matado, es la alarma la que realmente publica “Sesión completada” a tiempo. Cinturón y tirantes, pero los tirantes solo cuestan una llamada a AlarmManager.

Lo primero que comprueba la app al reanudarse

Cuando la app vuelve a primer plano — desde un arranque nuevo, desde la notificación o desde el selector de multitarea — lo primero que hace la pantalla de enfoque es leer activeSessionOrNull() y llamar a remainingMillis() antes de dibujar nada. No hay un indicador de “¿estaba corriendo el temporizador antes de que me fuera?” que pueda quedar obsoleto. O bien existe una sesión activa en Room con tiempo restante, y la interfaz muestra exactamente eso, o no existe, y muestra el selector de ritual. El temporizador no puede contradecirse a sí mismo porque solo hay un lugar autorizado a calcular la respuesta.

La lección general

El patrón se generaliza más allá de los temporizadores de enfoque: cada vez que sientas la tentación de mantener una cuenta atrás en vivo como estado mutable en memoria, sustitúyela por una marca de tiempo de fin fija y una función pura de “ahora”. Las marcas pasan de ser la fuente de verdad a ser solo una cadencia de refresco, lo que significa que las marcas perdidas, los procesos matados y el agrupamiento del modo Doze dejan de ser errores de corrección — son solo momentos en los que nadie hizo la pregunta, y la respuesta sigue siendo correcta la próxima vez que alguien la haga.