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.
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
remainingSecondsen 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 avecchronometerCountDown = trueet une base calculée à partir deendAtMillis. 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, pasSTART_STICKY. Si le système tue le service, il redémarre avec l’intent d’origine, etonStartCommandrelitactiveSessionOrNull()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.
// À lire aussi
D’autres notes du journal
Construire Subly : les calculs de calendrier derrière les dates de renouvellement d'abonnement
Prédire la prochaine date de prélèvement d'un abonnement semble trivial jusqu'à ce qu'on tombe sur la facturation en fin de mois, les années bissextiles et les conversions d'essai. Voici comment Subly y arrive, sur l'appareil.
Bien faire les actions de notification Android : marquer une dose comme prise sans ouvrir l'application
Un guide pratique des boutons d'action de notification Android — PendingIntent, la restriction du trampoline d'activité, goAsync() et la mise à jour sûre de l'état Room.
Des rappels Android fiables en 2026 : WorkManager, alarmes exactes et les nouvelles règles de batterie
Comment publier en 2026 des rappels Android qui se déclenchent vraiment — WorkManager vs AlarmManager, SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS et les bizarreries des constructeurs qui piègent encore.