Capacitor local notifications not working
My Capacitor game's reminders never showed on Android 14+. An exact-alarm default, a toggle the OS stopped answering, my own quiet hours, a missing channel.
Contents
ASMR Logo Craft Block has lives. Lose one and it comes back after thirty minutes, and when they are all back the game sends a local notification: "Back to full lives!". I lost a life, closed the game for forty minutes, and nothing came.
Working through it turned up four separate problems, and the last thing between me and a notification was a rule I had written myself. If your Capacitor local notifications are not working on Android 14 or later, the first section below is the one most likely to be yours. The rest are what kept the reminder from arriving after that one was fixed.
The game is React and Vite inside Capacitor, using @capacitor/local-notifications 8.3.1.
Device testing was on two emulators: a Pixel 9 Pro image with the Play Store build installed,
and an Android 16 (API 36) Google APIs image that I can root.
Exact alarms on Android 14
Since Android 12, an app needs special access to set an exact alarm. Android's documentation says that from Android 14, newly installed apps targeting Android 13 or higher no longer get that access by default. On my Android 16 emulator, after a fresh install, the app had nothing recorded for it:
$ adb shell cmd appops get com.example.game SCHEDULE_EXACT_ALARM
No operations.
Default mode: default
The local notifications plugin asks for an exact alarm unless you tell it not to. In 8.3.x the
option is isExactNotification, and it defaults to true. I read the plugin's Android source
to see what schedule() does when the access is missing: it opens the system's "Alarms &
reminders" screen with startActivityForResult and waits for the player to come back from it.
Only then does it schedule anything.
From inside the game that looked like nothing at all. I called schedule() directly in a
release build through the WebView inspector, and the call had still not resolved after six
minutes. The foreground activity was com.android.settings/.spa.SpaActivity the whole time.
It also did not let go. Pressing Back on that screen landed me on the same screen again, more than once. The game sets a reminder whenever it goes to the background, and opening a settings screen sends it to the background, so my reading is a loop: each reminder opens the screen, and the screen triggers the next reminder. I did not trace it call by call, but it is what the code says will happen. I got out by force-stopping the app.
On the Play build emulator exact alarms happened to be allowed (SCHEDULE_EXACT_ALARM: allow),
so this path never ran there, and a test on that emulator alone would have passed. What stopped
the reminder in my forty-minute test on that install I could not reconstruct afterwards: its
reminders had been cancelled and never set again, and nothing on the device said why.
A "lives full" reminder does not need to land on the second, so the fix is to stop asking for an exact alarm. Simplified, this is what the schedule call looks like now:
await LocalNotifications.schedule({
notifications: [{
id: 1003,
title: 'Back to full lives! ❤️',
body: 'That tricky level is waiting for you.',
channelId: 'game_reminders',
schedule: { at, allowWhileIdle: true },
isExactNotification: false, // never open "Alarms & reminders"
}],
});
isExactNotification arrived in 8.3.0, so I raised the minimum version of the plugin in our
shared notifications package to match.
allowWhileIdle lets the alarm fire while the phone is in Doze, which the plugin's docs say is
limited to once per nine minutes per app. There is a unit test that checks both options are
sent, so nobody quietly turns them back off.
The toggle that could not be switched back on
Before any of that, I could not even turn notifications on. The settings screen had a notifications toggle. I switched it off, and from then on it snapped back to off on every tap.
The toggle showed the setting the game had saved, never the phone's permission. Switching it
on called requestPermissions(), and once the system has a final no, that call returns
denied without showing anything. The toggle saved "off" again, and the game had no way out.
How many refusals count as final differs. iOS shows its prompt once; after one "Don't Allow" only the Settings app can change it. Android, since version 11, shows its dialog again after the first refusal and stops after the second. I reproduced the Android case on the emulator. With notifications turned off in system settings, one tap brought up "Allow ASMR Logo Craft Block to send you notifications?". I tapped "Don't allow", and the permission flags became:
android.permission.POST_NOTIFICATIONS: granted=false,
flags=[ USER_SET|USER_FIXED|USER_SENSITIVE_WHEN_GRANTED|USER_SENSITIVE_WHEN_DENIED]
USER_FIXED is the state where the dialog is never shown again. Every later request answered
denied straight away.
The fix has three parts:
- The toggle shows on only when the player chose it and the system allows it. The state
comes from
checkPermissions(), which readsprompt,grantedordeniedwithout prompting. - When the state is
denied, a tap opens the phone's notification settings for the game. Capacitor has no call for that, so it took a few lines of native code: on Android an intent toandroid.settings.APP_NOTIFICATION_SETTINGSwith the app's package. - The toggle reads the permission again whenever the game comes back to the foreground, so allowing notifications in system settings turns it on by itself.
On Android I walked through it on the emulator: dialog, refuse, the next tap opens the game's notification settings, allow there, go back, and the toggle is on. On iOS I only got part of the way. On a fresh simulator install the call opened the main Settings page, because an app that has never asked for notifications has no page there yet. I have not confirmed that it lands on the game's own page after a real "Don't Allow".
The toggle then still did nothing in the Play build. checkPermissions() resolved with
undefined in a release build and worked in my debug test build. That was R8, a separate
problem with its own fix, and once it was fixed the toggle worked in the store build too.
A life lost before notifications were on
Reading the reminder code while chasing the exact alarm turned up a second gap.
The "lives full" reminder was set at the moment a life was lost. If notifications were off at that moment, nothing was set, and turning them on later did not go back and set it. A player who says no to notifications, then runs out of lives and changes their mind, got nothing.
Now switching notifications on, by the toggle, by the in-game prompt or by allowing them in system settings, sets every reminder that should already be waiting: "lives full" if lives are missing, "reward ready" if today's gift was claimed. The test for that case failed on the old code, and the game's 9,496 tests pass with the change.
The last one was my own quiet hours
The next store release had both fixes. I tested it, waited, and saw no notification.
This time the reminders were there. dumpsys alarm on the Play build emulator showed two
pending alarms for the game:
tag=*walarm*:com.example.game/com.capacitorjs.plugins.localnotifications.TimedNotificationPublisher
policyWhenElapsed: requester=+8h28m11s157ms ...
tag=*walarm*:com.example.game/com.capacitorjs.plugins.localnotifications.TimedNotificationPublisher
policyWhenElapsed: requester=+9h28m11s157ms ...
Counted from the moment I ran it, those land at 08:00 and 09:00. The walarm tag is
a wakeup alarm, so they would fire with the phone asleep. During an earlier bug hunt the game
had picked up a rule that holds reminders between 22:00 and 08:00, so nobody's phone buzzes
at night about a puzzle game. I tested after 22:00, so the lives-full reminder was waiting for
08:00 and the gift reminder for 09:00.
So that last test was not a bug. The only way to know that was to move the clock.
Moving the emulator clock forward
Waiting for real time does not work for this. A reminder that is due in thirty minutes, inside quiet hours, on an emulator that may be under load, gives you an empty notification shade and no idea why. I wanted to see one arrive with the game fully closed, so I took control of the clock on the rootable emulator:
adb root
adb shell settings put global auto_time 0
adb shell date 100914002026.00 # MMDDhhmmYYYY.ss: 14:00, outside quiet hours
Then, on a fresh install of a release build:
- Claimed the daily gift, switched notifications on in settings, tapped Allow.
- Lost a life. My first two attempts did not cost one: quitting a level from its settings keeps the board for later, and a life is only lost when a level runs out of moves and you give up. So I played level 1 until it ran out, then tapped Home. Lives went from 5 to 4 and the reminder was set for 14:34.
- Pressed Home, killed the process with
adb shell am kill com.example.game, and moved the clock past the reminder:
adb shell date 100914352026.30
About thirty seconds later the game had a notification posted:
NotificationRecord(... pkg=com.example.game ... id=1003 ... importance=5 ...
Notification(channel=game_reminders ... flags=ONLY_ALERT_ONCE|AUTO_CANCEL ...))
and the shade showed "Back to full lives! ❤️ That tricky level is waiting for you." with the game's icon. Afterwards I put the clock back:
adb shell settings put global auto_time 1
adb unroot
Two things made the run possible. The emulator has to allow adb root; mine was a Google APIs
image, a userdebug build. And test ads had to be out of the way: on an earlier attempt two of
them covered the screen, and then the WebView's renderer died with the machine under heavy
load. For the clock run I switched ads off in the save before starting. I did not look further
into the renderer crash.
A channel that only existed if the dialog said yes
The same notifications package runs in Picture Word Connect, and a parallel bug hunt on that game found one more way for reminders to vanish, this time without any error at all.
On Android 8 and later, every notification belongs to a channel. Our package created the reminder channel in one place: when the permission dialog came back granted. The toggle fix above made a second route official, allowing notifications in system settings, and that route never shows the dialog. So a player could end up with the permission granted, reminders scheduled, and no channel.
Capacitor does not cover for that. In 8.3.1 the Android side passes your channelId straight
to the notification builder, without checking the channel exists and without falling back to
its default channel. Android's documentation says a notification posted to a channel that
does not exist is not shown. I read both; I did not watch it happen on a device.
The fix is to create the channel before the first reminder of every launch, whatever route granted the permission. Creating a channel that already exists with the same settings does nothing, so it is safe to repeat. A simplified version:
let channelReady = null;
async function scheduleReminder(reminder) {
channelReady ??= LocalNotifications.createChannel(CHANNEL)
.catch(() => { channelReady = null; }); // try again next time
await channelReady;
return LocalNotifications.schedule({ notifications: [reminder] });
}
This one is verified by a unit test only. Against the old code it failed with
expected "createChannel" to be called 1 times, but got 0 times, and it passes now. That proves
the channel is now created on the settings route. It does not prove that a player ever lost a
reminder this way, and I have not reproduced the missing notification on a device.
What I check now
When a Capacitor local notification does not show on Android, in this order:
adb shell dumpsys alarm | grep <your package>. No alarm means it was never scheduled; an alarm with a largerequester=offset means it is waiting, maybe for a rule of your own.adb shell cmd appops get <your package> SCHEDULE_EXACT_ALARM, and whether the foreground activity is the "Alarms & reminders" screen. If you do not need the exact minute, passisExactNotification: false.- The permission's flags in
dumpsys package.USER_FIXEDmeans your request will never show a dialog again, and the only way forward is the system settings page. - Whether the channel exists before the first
schedule(), on every route that can grant the permission. - Whether anything is set for events that happened while notifications were off.
Then prove it with the clock moved forward and the app killed, on a release build. Twice in this story a fix passed its tests and the next real install still showed nothing. The run with the clock moved forward was the first time I saw the notification arrive.