Back in July I wrote about the moment my phone started waking itself up at 8am to run an agent I'd registered from a home screen widget. I was proud of that post. AlarmManager, setExactAndAllowWhileIdle, fires through Doze, screen off, phone face down on the nightstand — I said all of that with a straight face like I'd earned the right to.
Then, weeks later, on the same phone, at the registered time: nothing. No notification. No log line. The agent just didn't run.
The alarm that fires into nothing
Here's the thing about "I still can't write code" — I also can't read a stack trace and know I'm looking at the right one. What I can do is ask for dumpsys alarm, read what comes back, and notice when it doesn't match what actually happened.
dumpsys alarm said the alarm fired. Right time, right entry, no complaints. But the receiver that was supposed to wake up and run the agent never printed its first log line. Not late. Not with an error. It just never ran, as if the alarm had gone off inside an empty room.
Tapping the same agent's "run now" button from the app worked instantly. So the agent itself wasn't broken. Something between "the OS decided to fire the alarm" and "code in my app actually executes" was failing, silently, only when the phone had been left alone for a while.
I had it walk me through what was actually happening under the hood, because "trust me, it's fixed" isn't something I'm willing to write in a post title.
The setup that looks correct and isn't
The alarm was wired the way basically every tutorial tells you to wire it: AlarmManager fires a PendingIntent that targets a BroadcastReceiver, and that receiver's job is just to call startForegroundService() and hand off to where the real work happens.
AlarmManager --(exact & allow-while-idle)--> BroadcastReceiver.onReceive()
--> startForegroundService(...)
Reasonable. Conventional. And, on Android 14 with Samsung's One UI on top, apparently optional. If the app's process had been sitting frozen in the background for a while, the system fired the alarm and then just... didn't guarantee the broadcast would reach a receiver in a process that isn't currently alive enough to care. AlarmManager kept its promise. The broadcast didn't keep its promise. Nobody threw an exception about it, because from the OS's point of view, nothing went wrong.
The fix, in the version I can actually explain
The fix is to stop routing through the receiver at all. Point the alarm's PendingIntent straight at the Foreground Service:
private fun runServicePendingIntent(
context: Context,
agentId: String,
intervalMs: Long,
cron: String?
): PendingIntent {
val intent = Intent(context, TerminalSessionService::class.java).apply {
action = TerminalSessionService.ACTION_RUN_AGENT
putExtra(TerminalSessionService.EXTRA_AGENT_ID, agentId)
putExtra(TerminalSessionService.EXTRA_INTERVAL_MS, intervalMs)
if (!cron.isNullOrBlank()) putExtra(TerminalSessionService.EXTRA_CRON, cron)
}
val rc = getAgentRequestCode(context, agentId)
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
PendingIntent.getForegroundService(context, rc, intent, piFlags())
} else {
PendingIntent.getService(context, rc, intent, piFlags())
}
}
The way this was explained to me: when an exact-while-idle alarm targets a foreground service directly, the OS treats starting that service as the privileged, wake-the-device action itself. There's no second hop where a broadcast has to successfully find its way to a receiver in a process the system doesn't feel like fully waking up. The delivery step that was quietly failing simply isn't there to fail anymore.
Same phone, same scenario that used to go silent. It doesn't anymore.
What else it took to actually trust this
A fix that only works in the one scenario I happened to catch isn't a fix, it's a patch on a symptom. A few more things had to be true before I'd trust a schedule with something I actually care about:
-
The screen stays off, the work still has to finish. A
PARTIAL_WAKE_LOCKkeeps the CPU up while the agent runs — with an explicit timeout, so a future bug in releasing it costs 35 minutes of battery, not the rest of the day. - Rebooting the phone clears every alarm. That's documented Android behavior, and it's exactly the kind of thing that turns into a quiet, hard-to-notice regression. A boot receiver reloads whatever was scheduled and re-arms it — from native storage, specifically, because the JS side of the app isn't even running yet at that point in boot.
- A broken agent shouldn't get to wake the phone forever. Three failures in a row and it disables itself, alarm and all, instead of dutifully waking the device every morning to fail the same way.
- Manual "run now" and the scheduled alarm can't share an identity, or triggering one silently corrupts the other's saved schedule. Small detail, the kind that's invisible until it isn't.
None of that was obvious to me going in. All of it came out of the same instinct that started this whole post: don't take "it worked once" as the finish line.
What I still can't do
Read Kotlin fluently enough to have spotted this myself. Tell you, from first principles, which Android OEM will be the next one to quietly break a contract the OS itself defined. Promise you this is the last silent failure mode in here — I'd be lying, and the whole point of this post is that I stopped believing "it worked once" a while ago.
What I can do is notice when a thing that's supposed to happen at 8am doesn't, ask why in plain language until I get an answer I actually understand, and refuse to write a triumphant blog post about a schedule I haven't watched fail and come back from.
GitHub: github.com/RYOITABASHI/Shelly — GPLv3. If your alarm doesn't fire either, I'd genuinely like to hear about it.


Top comments (0)