The reports had a shape to them. "It used to wake me up." "Worked fine last month." "I didn't change anything." And in almost every one, somewhere in the details, a device that had recently taken a major Android upgrade.
Nothing had changed in NotiAlarm. That was the point. The users' phones had moved to Android 14, and one of the mechanisms the app depends on stopped working in a way that produced no error at all.
The permission that stops being granted
An alarm that takes over a locked screen is built on a full-screen intent: a notification that, instead of appearing as a banner, launches an activity when the device is locked or the display is off.
Until Android 14, USE_FULL_SCREEN_INTENT was a normal permission. You declared it in the manifest and it was granted at install time, no dialog, no user decision.
Android 14 restricted it. The permission is now reserved by default for apps whose core function is calling or alarms, and for everything else it is not granted. The relevant part for us is what happens when it is missing: nothing throws. notify() succeeds. The system quietly downgrades the full-screen intent to a heads-up notification.
So on a locked phone at 3 a.m., an alarm that used to fill the screen and play alarm-stream audio became a banner behind the lock screen. Technically delivered. Practically invisible.
NotificationManager.canUseFullScreenIntent() reports the current state, and ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT opens the settings screen where a user can grant it. Neither of those helps if the app never asks the question — and ours did not, because when the code was written the answer was always yes.
We were checking the wrong thing
What made this worse is that NotiAlarm already had a health check. It checked notification access, which is the permission needed to *see* notifications. That check was green the whole time. The listener was bound, rules were matching, and the app's own diagnostics said everything was fine.
We had instrumented the input side of the pipeline and left the output side untested. A rule matched, an alarm was raised, and we recorded a success — because from the app's perspective it was one. The failure was two layers down, in the system's decision about how to present what we posted.
That is the actual lesson from this incident, more than any specific API. If your app's job is to deliver something, checking that you sent it is not a check.
What the diagnostics look like now
NotiAlarm now models the whole delivery path as a chain and reports each link separately.
Notification access. Is the listener component enabled and currently bound? We distinguish these, because a listener can be enabled in settings and not bound after a process kill, and that distinction is the difference between "grant a permission" and "toggle it off and on."
Listener liveness. How long since we last received any notification of any kind? A device that has delivered nothing at all for an unusually long stretch is almost certainly a dead binding rather than a quiet phone. When we see it, we call requestRebind() and, if that does not restore traffic, we say so plainly.
Full-screen intent capability. canUseFullScreenIntent(), checked on launch and whenever a rule is created or edited. If it is false, NotiAlarm will not describe a rule as an alarm. It says the rule will produce a notification, and offers the settings link.
Do Not Disturb policy. We read the current interruption filter and evaluate whether an alarm-usage sound would pass it. If your DND configuration suppresses alarms and you are setting up a night-time rule, that is worth knowing before the night rather than after.
Battery exemption. Whether the app is exempt from Doze-level restrictions, and separately, whether we can detect the manufacturer-specific policies that go beyond the standard ones.
Each of these renders as its own line with its own fix. The previous version had one status indicator, which meant a green light was almost meaningless and a red light did not tell you where to look.
The watchdog, and its honest limits
Alongside the diagnostics we added a watchdog for the listener. It notices when the notification stream goes quiet for longer than the device's own history suggests it should, attempts a rebind, and escalates to telling the user if that does not work.
It is worth being clear about what this cannot do. Android permits a listener process to be killed, and it is supposed to rebind. Several manufacturer builds interfere with that, through battery policies with names like app sleeping, autostart management, or protected apps. There is no API that reports "your listener was killed and this device will not rebind it." There is no callback. From inside the process, a listener that will never receive another notification is indistinguishable from a phone nobody is using.
So the watchdog is inference, not detection. It can be wrong in both directions — a genuinely quiet phone can look broken, and a partial failure can look fine. We tuned it to be conservative about crying wolf, which means it is slower than we would like to notice a real problem.
The only reliable fix on affected devices is a manufacturer settings screen exempting NotiAlarm from battery management. We cannot open those screens programmatically on most builds, and their locations move between OS versions. We maintain the paths we know about, per manufacturer, in the app. It is an unsatisfying answer and it is the true one.
Battery guidance, and what we will not do
There is a standard Android intent, ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, that prompts the user to exempt an app from Doze. Google Play restricts which apps may use it, and the restriction is reasonable: most apps that want it do not need it.
NotiAlarm explains the setting and links to the general battery optimisation screen rather than prompting for a blanket exemption. Partly for policy reasons, mostly because a battery exemption is a real cost to the user's device and we would rather they grant it deliberately, having read why, than tap through a dialog.
The guidance is also device-specific rather than generic, because generic guidance is useless here. "Disable battery optimisation" means something different on a Samsung device than on a Xiaomi one, and the sequence of taps is genuinely different.
Smaller changes in the same release
The alarm screen itself got work while we were in there. The pause action moved into a deliberate bottom-sheet flow rather than a button that was easy to hit while reaching for the phone in the dark — dismissing an alarm should take a moment of intent. The floating alarm launcher and its icon treatment were clarified so that what appears over another app is recognisably NotiAlarm and not an unexplained overlay.
These are small, and they matter for the same reason the diagnostics do: an alarm that fires at a bad moment is already an interruption, and the interface should make the three possible responses obvious rather than making you read.
What we would do differently
We should have had a synthetic end-to-end check from the beginning: on a schedule, construct the exact notification an alarm rule would produce, ask the system what it would do with it, and record the answer. Not fire it — just evaluate the path. That check would have caught the Android 14 change on the first device that upgraded, instead of after a month of reports.
We should also have been reading platform behaviour-change notes as a release blocker rather than as background reading. The full-screen intent restriction was documented well before Android 14 shipped. It was not a surprise to anyone who looked; it was a surprise to us because we did not.