Aller au contenu
Tous les articles

Construire Mintly : garder un minuteur de focus précis quand Android veut tuer le processus

Un minuteur Pomodoro en cours d'exécution pose un problème de fiabilité plus difficile qu'un simple rappel ponctuel. Voici comment Mintly survit au mode Doze, à la mort du processus et à la dérive écran éteint grâce à un service au premier plan et une heure de fin en temps réel.

MFKAPPS 5 min de lecture

Un rappel ponctuel doit simplement se déclencher une fois, à peu près au bon moment. Un minuteur de focus est un problème plus difficile : il doit rester visiblement, continuellement correct pendant les 25 minutes complètes, même écran éteint, même si Android décide de tuer le processus en cours de session et de le redémarrer. Si vous ratez ça, le mode d’échec n’est pas une notification manquée — c’est un minuteur qui affiche « 12:41 restantes » pendant une minute entière, ou qui revient à 25:00 après un simple coup d’œil sur votre téléphone.

J’ai connu les deux en construisant Mintly. Voici l’architecture qui a corrigé ça : un service au premier plan, une heure de fin en temps réel plutôt qu’un compteur décroissant, et une notification qui dessine son propre compte à rebours au lieu de faire confiance au service pour le redessiner.

Le bug qui m’a donné la leçon

Ma première version était l’évidente : une boucle proche d’un CoroutineWorker qui tickait chaque seconde, décrémentait un champ remainingSeconds, et publiait une notification mise à jour à chaque tick. Ça marchait parfaitement dans l’émulateur et s’est effondré sur un vrai téléphone en une journée. Deux modes d’échec :

  • Ticks fusionnés. Le mode Doze regroupe les réveils. Une boucle « un tick par seconde » ne s’exécute pas réellement chaque seconde une fois l’écran éteint — le système la diffère, puis livre plusieurs ticks d’un coup. Le compte à rebours visible bégaie ou saute.
  • Mort silencieuse du processus. Si le système avait besoin de mémoire et tuait le processus de l’app, le champ remainingSeconds en mémoire partait avec lui. Au redémarrage, il n’y avait rien pour reprendre, donc le minuteur disparaissait ou redémarrait à sa durée d’origine — du point de vue de l’utilisateur, cinq minutes de focus disparaissaient tout simplement.

La correction n’était pas « ticker plus fiablement ». C’était d’arrêter de traiter le temps restant comme un état qui doit survivre, et de commencer à le traiter comme quelque chose qu’on peut toujours recalculer.

Stocker une heure de fin, pas un compte à rebours

Le changement central : persister une unique sessionEndAtMillis (temps réel, basée sur System.currentTimeMillis()) au moment où une session démarre, et dériver tout le reste à la demande.

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)

Ce seul changement rend tout le système auto-réparateur. Peu importe le nombre de ticks perdus, ou si le processus a été tué et redémarré entre-temps — remainingMillis() est correcte à l’instant où elle est appelée, calculée à partir de deux horodatages plutôt qu’accumulée depuis une boucle. La session est persistée dans Room dès son démarrage, donc un processus tout neuf peut la recharger et savoir immédiatement exactement où elle en est.

Un service au premier plan qui ne tique presque jamais

Le rôle du service se réduit à : garder le processus en vie assez longtemps pour remarquer la fin, et garder la notification raisonnablement à jour. Il ne détient pas la source de vérité.

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
    }
}

Deux détails font l’essentiel du travail :

  • setUsesChronometer(true) sur la notification, initialisée avec chronometerCountDown = true et une base calculée à partir de endAtMillis. Le système de notification propre à Android redessine alors le compte à rebours chaque seconde en utilisant sa propre horloge — pas ma boucle. Le nombre affiché ne bégaie jamais, même si mon service ne se rafraîchit que toutes les 15 secondes.
  • START_REDELIVER_INTENT, pas START_STICKY. Si le système tue le service, il redémarre avec l’intent d’origine, et onStartCommand relit activeSessionOrNull() depuis Room plutôt que de faire confiance à quoi que ce soit conservé en mémoire. Un service tué puis redémarré produit une notification identique à celle d’un service qui n’est jamais mort.

Le filet de sécurité de l’alarme exacte

Un service au premier plan a beaucoup moins de risques d’être tué qu’un service en arrière-plan, mais « beaucoup moins de risques » n’est pas une garantie — un gestionnaire de batterie agressif d’un constructeur peut quand même le faire tomber. La fin de session a donc un second chemin qui ne dépend pas du tout de la survie du service : une alarme exacte, planifiée pour endAtMillis dès le démarrage de la session, avec le même modèle setExactAndAllowWhileIdle que j’ai décrit pour les rappels de Hydrame. Si le service est vivant quand l’alarme se déclenche, c’est une vérification redondante inoffensive. Si le service a été tué, c’est l’alarme qui publie réellement « Session terminée » à l’heure. Ceinture et bretelles, mais les bretelles ne coûtent qu’un appel à AlarmManager.

Ce que la reprise de l’app vérifie en premier

Quand l’app revient au premier plan — depuis un lancement neuf, depuis la notification, ou depuis le sélecteur multitâche — la première chose que fait l’écran de focus est de lire activeSessionOrNull() et d’appeler remainingMillis() avant de dessiner quoi que ce soit. Il n’y a pas de drapeau « le minuteur tournait-il avant que je parte ? » qui puisse devenir périmé. Soit il existe une session active dans Room avec du temps restant, et l’interface montre exactement ça, soit il n’y en a pas, et elle montre le sélecteur de rituel. Le minuteur ne peut pas se contredire lui-même parce qu’il n’y a qu’un seul endroit autorisé à calculer la réponse.

La leçon générale

Le schéma se généralise au-delà des minuteurs de focus : chaque fois que vous êtes tenté de garder un compte à rebours en direct comme un état mutable en mémoire, remplacez-le par un horodatage de fin fixe et une fonction pure de « maintenant ». Les ticks deviennent une cadence de rafraîchissement plutôt qu’une source de vérité, ce qui signifie que les ticks perdus, les processus tués et le regroupement du mode Doze cessent d’être des bugs de correction — ce ne sont plus que des moments où personne n’a posé la question, et la réponse reste correcte la prochaine fois que quelqu’un la pose.