Aller au contenu
Tous les articles

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.

MFKAPPS 5 min de lecture

Si un rappel vous oblige à ouvrir l’application pour le fermer, vous avez ajouté une étape que personne n’a demandée. Tout l’intérêt d’une notification est d’être l’interface elle-même — l’utilisateur y réagit et passe à autre chose. OldSchool, un rappel de médicaments, en dépend entièrement : les boutons Pris, Reporter et Sauter sur l’écran de verrouillage doivent fonctionner instantanément, mettre à jour la bonne donnée, et ne jamais lancer une Activity que l’utilisateur n’a pas demandée. Voici comment c’est construit, et les deux ou trois endroits où Android complique inutilement les choses.

Le problème du « il suffit d’ouvrir l’application »

La version naïve d’une notification de rappel est un simple appui qui ouvre l’application sur un écran où l’on appuie sur un bouton. Cela fait deux actions supplémentaires pour quelque chose qui devrait n’en être qu’une. Cela signifie aussi démarrer une Activity à froid à chaque fois — sur un téléphone milieu de gamme avec une dizaine d’autres applications en mémoire, c’est un délai visible entre « j’ai vu le rappel » et « c’est marqué comme fait ».

La solution est NotificationCompat.Action : des boutons attachés directement à la notification qui se déclenchent sans jamais mettre une Activity au premier plan.

val takenIntent = Intent(context, DoseActionReceiver::class.java).apply {
    action = ACTION_MARK_TAKEN
    putExtra(EXTRA_DOSE_ID, doseId)
}
val takenPendingIntent = PendingIntent.getBroadcast(
    context,
    doseId.hashCode(),
    takenIntent,
    PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)

NotificationCompat.Builder(context, CHANNEL_ID)
    .setContentTitle("Metformin — 500mg")
    .addAction(R.drawable.ic_check, "Taken", takenPendingIntent)
    .addAction(R.drawable.ic_snooze, "Snooze", snoozePendingIntent)
    .addAction(R.drawable.ic_skip, "Skip", skipPendingIntent)
    .build()

Trois boutons, trois PendingIntent, chacun pointant vers un BroadcastReceiver plutôt qu’une Activity. Cette distinction est toute l’astuce.

Pourquoi un receiver, pas une Activity

Il est tentant de faire transiter l’action par une Activity — vous en avez déjà une, elle parle déjà à votre base de données. Ne le faites pas. Depuis Android 12, lancer une Activity directement depuis une action de notification (plutôt que depuis la cible tactile principale de la notification) est restreint en tant que « trampoline d’activité », et même là où cela fonctionne encore, cela signifie un flash d’interface indésirable, une entrée dans le sélecteur de tâches, et une réponse plus lente. Un BroadcastReceiver ne fait rien de tout cela — il s’exécute, fait son travail, et rien de visible ne se produit à part la mise à jour de la notification.

FLAG_IMMUTABLE compte aussi : depuis Android 12, les PendingIntent mutables sont bloqués par défaut pour tout ce qui n’exige pas explicitement FLAG_MUTABLE (comme un RemoteInput). Un simple bouton d’action n’a pas besoin de mutabilité — marquez-le immuable et passez à la suite.

Faire du vrai travail dans un receiver : goAsync()

Le problème avec BroadcastReceiver.onReceive() est qu’il est censé retourner presque instantanément — quelques millisecondes, pas le temps que prend une écriture Room en arrière-plan. Si vous retournez avant que votre écriture ne se termine, le système peut tuer le processus en pleine écriture sur certaines surcouches constructeur.

goAsync() achète ce temps supplémentaire correctement :

class DoseActionReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        val doseId = intent.getLongExtra(EXTRA_DOSE_ID, -1)
        val pendingResult = goAsync()

        CoroutineScope(Dispatchers.IO).launch {
            try {
                doseDao.markTaken(doseId, timestamp = System.currentTimeMillis())
                NotificationManagerCompat.from(context).cancel(doseId.toInt())
            } finally {
                pendingResult.finish()
            }
        }
    }
}

goAsync() vous donne environ 10 secondes de durée de vie prolongée — suffisant pour une écriture Room, pas assez pour justifier un service au premier plan. Appelez toujours pendingResult.finish() dans un bloc finally ; l’oublier sur un chemin d’exception laisse le système détenir un wakelock qu’il ne peut pas libérer, et finit par signaler votre receiver comme se comportant mal.

Garder la notification et la base de données honnêtes

Marquer une dose comme « prise » n’est pas seulement une ligne de base de données — la notification elle-même doit disparaître ou changer, sinon l’utilisateur y touchera à nouveau et se demandera pourquoi rien ne se passe. Appeler NotificationManagerCompat.cancel() après l’écriture est le minimum ; pour Reporter, je replanifie via le même chemin AlarmManager/WorkManager que le rappel original a utilisé (voir l’article sur WorkManager et les alarmes exactes pour la moitié planification de tout ça) plutôt que d’inventer un second pipeline de notification. Un rappel, un seul chemin de code, qu’il se soit déclenché la première fois ou la troisième.

L’autre erreur facile est de mettre à jour un état en mémoire que rien n’observe. Si la vue du jour est alimentée par un Flow provenant de Room, l’écriture du receiver suffit — l’interface se recompose la prochaine fois qu’elle est visible, sans aucun code de synchronisation explicite. Sinon, vous obtiendrez des données obsolètes dès que l’utilisateur reviendra dans l’application, et vous finirez par déboguer une condition de course qui n’est en réalité qu’un observateur manquant.

Ce que cela vous apporte

La différence mesurable n’est pas spectaculaire sur le papier — un écran de moins, un appui de moins — mais elle change ce que ressent un rappel. Une notification qui se résout d’elle-même en moins d’une seconde, là où elle est apparue, se lit comme respectueuse des deux secondes que vous lui avez accordées. Une notification qui exige un lancement à froid de l’application se lit comme de la friction, et la friction est ce qui transforme un rappel en quelque chose qu’on balaie par habitude plutôt qu’en quelque chose qu’on utilise.

Si vous construisez un type de notification « agissez maintenant » — un rappel, une relance de tâche, une confirmation de livraison — le principe tient quel que soit le domaine : faites transiter les actions par un BroadcastReceiver, étendez votre fenêtre avec goAsync(), écrivez via votre couche de données existante, et laissez une interface réactive capter le changement au lieu de le pousser manuellement. C’est un peu de plomberie pour une fonctionnalité que les utilisateurs ne remarquent que lorsqu’elle manque.