Android notification actions done right: marking a dose taken without opening the app
A practical guide to Android notification action buttons — PendingIntents, the trampoline restriction, goAsync(), and updating Room state safely.
If a reminder makes you open the app to dismiss it, you’ve added a step nobody asked for. The whole point of a notification is that it’s the interface — the user reacts to it and moves on. OldSchool, a medication reminder, lives or dies by this: the lock-screen buttons for Taken, Snooze, and Skip have to work instantly, update the right data, and never launch an Activity the user didn’t ask to see. Here’s how that’s built, and the two or three places Android makes it harder than it should be.
The problem with “just open the app”
The naive version of a reminder notification is a single tap that opens the app to a screen where you press a button. That’s two extra actions for something that should be one. It also means cold-starting an Activity every time — on a mid-range phone with a dozen other apps in memory, that’s a visible delay between “I saw the reminder” and “it’s marked done.”
The fix is NotificationCompat.Action: buttons attached directly to the notification that fire without ever bringing an Activity to the foreground.
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()
Three buttons, three PendingIntents, each pointing at a BroadcastReceiver rather than an Activity. That distinction is the whole trick.
Why a receiver, not an Activity
It’s tempting to route the action through an Activity — you already have one, it already talks to your database. Don’t. Since Android 12, launching an Activity directly from a notification action (rather than from the notification’s main tap target) is restricted as an “activity trampoline,” and even where it still works it means an unwanted flash of UI, a task-switcher entry, and a slower response. A BroadcastReceiver does none of that — it runs, does its work, and nothing visible happens except the notification updating.
FLAG_IMMUTABLE matters too: as of Android 12, mutable PendingIntents are blocked by default for anything not explicitly requiring FLAG_MUTABLE (like a RemoteInput). A plain action button doesn’t need mutability — mark it immutable and move on.
Doing real work in a receiver: goAsync()
The catch with BroadcastReceiver.onReceive() is that it’s expected to return almost instantly — a few milliseconds, not the time it takes to write to a Room database on a background thread. Return before your write finishes and the system can kill the process mid-write on some OEM skins.
goAsync() buys the extra time correctly:
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() gives you roughly 10 seconds of extended lifetime — enough for a Room write, not enough to justify a foreground service. Always call pendingResult.finish() in a finally block; forgetting it under an exception path leaves the system holding a wakelock it can’t release, and eventually it’ll flag your receiver as misbehaving.
Keeping the notification and the database honest
Marking a dose “taken” isn’t just a database row — the notification itself has to disappear or change, or the user will tap it again and wonder why nothing happens. NotificationManagerCompat.cancel() after the write is the minimum; for Snooze, I reschedule via the same AlarmManager/WorkManager path the original reminder used (see the WorkManager and exact alarms post for the scheduling half of this) rather than inventing a second notification pipeline. One reminder, one code path, regardless of whether it fired the first time or the third.
The other easy mistake is updating in-memory state that nothing observes. If the day view is backed by a Flow from Room, the receiver’s write is enough — the UI recomposes next time it’s visible, with zero explicit sync code. If it isn’t reactive, you’ll get stale data the moment the user returns to the app, and you’ll end up debugging a race condition that’s really just a missing observer.
What this buys you
The measurable difference isn’t dramatic on paper — one less screen, one less tap — but it changes what a reminder feels like. A notification that resolves itself in under a second, right where it appeared, reads as respectful of the two seconds you gave it. One that demands a cold app launch reads as friction, and friction is what turns a reminder into something you swipe away out of habit instead of using.
If you’re building any kind of “act on this now” notification — a reminder, a to-do nudge, a delivery confirmation — the pattern holds regardless of the domain: route actions through a BroadcastReceiver, extend your window with goAsync(), write through your existing data layer, and let a reactive UI pick up the change instead of manually pushing it. It’s a small amount of plumbing for a feature users only notice when it’s missing.
// Related reading
More from the journal
Reliable Android reminders in 2026: WorkManager, exact alarms, and the new battery rules
How to ship reminders on Android in 2026 that actually fire — WorkManager vs AlarmManager, SCHEDULE_EXACT_ALARM, POST_NOTIFICATIONS, and the OEM quirks that still bite.
Building a medication routine you'll actually stick to
A practical guide to medication adherence — why people miss doses, how reminder timing actually works, and how to build a routine that survives a bad week.
A hydration routine that doesn't rely on willpower
Most hydration advice boils down to 'drink more water' and hopes willpower fills the gap. Here's a routine built around cues and gentle nudges instead.