TL;DR
-
AlarmManager.setExactAndAllowWhileIdle()+PendingIntent.getBroadcast()という「教科書通り」の組み合わせは、Android 14 以降(特に Samsung One UI)でアプリのプロセスがキャッシュ/凍結されていると アラームは正しい時刻に発火するのにBroadcastReceiver#onReceive()が一度も呼ばれない ことがある。 -
dumpsys alarm/ logcat では AlarmManagerService 側は「配信済み」に見えるため、原因の切り分けに時間がかかる。 - 回避策は、アラームの
PendingIntentをBroadcastReceiver経由にせず、PendingIntent.getForegroundService()で Foreground Service に直接向けること。setExactAndAllowWhileIdleはこのケースを一時的な while-idle allowlist 付きの特権 FGS 起動として扱うため、ブロードキャストの配信保証に頼らずに済む。 - あわせて、Doze 対策(
SCHEDULE_EXACT_ALARMのフォールバック)、WakeLock、再起動後の再武装、失敗時のサーキットブレーカーまで含めた実装を紹介する。
筆者は Android 単体で動く自律 AI エージェントアプリ Shelly(Expo/React Native + Kotlin ネイティブモジュール)を開発しており、「@agent 毎朝8時にニュースをまとめて通知して」のような自然文登録から、画面オフのままスケジュール実行するまでの一連の実装で、この問題に実機検証で行き当たった。
前提: AlarmManager と Doze モード
Android 6.0 (Doze) 以降、アプリは端末がアイドル状態になると通常のアラームやネットワークアクセスが制限される。正確な時刻に、Doze 中でも確実に起こしたい場合は次の API を使う。
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
triggerAtMillis,
pendingIntent
)
Android 12 (API 31) 以降はさらに SCHEDULE_EXACT_ALARM という特別な権限が必要で、ユーザーがいつでも取り消せる。取れていない場合は setAndAllowWhileIdle(概ねの時刻での起床)にフォールバックするのが定石だ。
private fun setExactWhileIdle(am: AlarmManager, triggerAtMs: Long, pi: PendingIntent) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
(Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms())
) {
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMs, pi)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMs, pi)
} else {
am.set(AlarmManager.RTC_WAKEUP, triggerAtMs, pi)
}
} catch (e: SecurityException) {
// 権限が取り消された場合のフォールバック
Log.w(TAG, "Exact alarm denied; falling back to inexact alarm", e)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMs, pi)
} else {
am.set(AlarmManager.RTC_WAKEUP, triggerAtMs, pi)
}
}
}
ここまでは多くの解説記事に書いてある内容で、実際ここまでは問題なく動く。
最初の実装: BroadcastReceiver 経由
一般的な実装パターンとして、まず PendingIntent.getBroadcast() で BroadcastReceiver を起こし、その中から Context.startForegroundService() を呼んで実処理を Foreground Service に委譲する、という構成にした。
AlarmManager --(exact & allow-while-idle)--> BroadcastReceiver.onReceive()
--> startForegroundService(...)
マニフェスト上は BroadcastReceiver を明示的に起こす分、構成としては素直で、多くの記事・サンプルコードもこの形を紹介している。
起きた問題
ところが実機(Android 14 / Samsung One UI、API 36)で、画面オフ・アプリがバックグラウンドでしばらく放置された状態から検証すると、登録した時刻ちょうどに何も起きないという現象が再現した。
dumpsys alarm で見る限り、AlarmManagerService はアラームを認識しており、指定時刻に「発火」の記録も残る。しかし:
-
BroadcastReceiver#onReceive()の先頭に置いたLog.iが一行も出力されない - Foreground Service が起動した形跡がない
- 何のエラーも例外も出ない(サイレント)
同じ端末で、UI からボタンをタップして手動実行するパスは問題なく動く。つまり「エージェント自体は正しく動く、アラームの配信だけが失敗している」ことが切り分けられた。
原因は、AlarmManager 自体は指定時刻にきちんと発火させているものの、そのブロードキャストをキャッシュ/凍結されたプロセスへ配信することまでは保証されないことだった(Android 14 以降、特に Samsung One UI のプロセス管理が絡むと顕著)。BroadcastReceiver を経由する設計は、「アラームが発火する」ことと「そのプロセスでコードが実際に実行される」ことの間に、暗黙の配信ステップを一段挟んでしまっている。
解決策: PendingIntent を Foreground Service に直接向ける
BroadcastReceiver を経由させず、アラームの PendingIntent を最初から 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())
}
}
AlarmManager --(exact & allow-while-idle)--> TerminalSessionService.onStartCommand()
(Foreground Service 起動そのものがアラーム配信)
setExactAndAllowWhileIdle でスケジュールされたアラームが PendingIntent.getForegroundService() を対象にしている場合、その起動は一時的な while-idle allowlist 付きの特権 FGS 起動として扱われる。ブロードキャストという別レイヤーの配信ステップを挟まないため、「アラームは発火したのに何も起きない」という状態そのものが構造的に発生しなくなる。
この変更だけで、同一端末・同一シナリオでの再現が解消した。
周辺の実装: Doze 対策以外に必要だったもの
スケジュール実行を実用に耐えるものにするには、上記の他にいくつかの要素が必要だった。
WakeLock
Foreground Service の onStartCommand に処理が来た時点では画面はまだオフのままなので、実処理中に CPU がスリープしないよう明示的に PARTIAL_WAKE_LOCK を取得する。
private fun acquireAgentWakeLock(agentId: String): PowerManager.WakeLock? {
return try {
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "shelly:agent:$agentId").also {
it.setReferenceCounted(false)
it.acquire(35 * 60 * 1000L) // 35分の上限を明示
}
} catch (e: Exception) {
Log.e(TAG, "Failed to acquire agent WakeLock for $agentId", e)
null
}
}
タイムアウトを明示的に指定しているのは、万一 release() を呼び忘れる実装バグが将来入っても、端末のバッテリーを無限に消費し続ける事故にはしないため。
再起動後の再武装
AlarmManager にセットしたアラームは、端末の再起動で消える。これはドキュメントにも明記されている既知の仕様だが、見落とすとサイレントに「再起動後スケジュールが動かなくなる」バグになる。
BOOT_COMPLETED を受け取る BroadcastReceiver で、永続化しておいたスケジュール一覧を読み直して再度アラームを立て直す。ここで一つ注意点がある。BOOT_COMPLETED の時点では React Native 側はまだ起動していないため、JS 側のストアではなくネイティブの SharedPreferences から読み直す必要がある。
// AlarmManager alarms armed via setExactAndAllowWhileIdle are CLEARED by the OS on
// reboot, so scheduled agents silently stop firing after a restart.
// BOOT_COMPLETED fires before RN is initialized — read from native SharedPreferences,
// not the JS agent store.
マニフェストの <receiver> に android:permission="android.permission.RECEIVE_BOOT_COMPLETED" を付けたくなるが、これは付けてはいけない。そのパーミッションを保持している必要があるのは送信元(system_server)側であり、要求すると配信自体が壊れる。これは実際に別アプリの実装で dumpsys を使って確認した。
失敗時のサーキットブレーカー
無人実行が失敗し続けるエージェントが、無限に端末を起こし続けるのは避けたい。実行結果を記録し、3 回連続で失敗したエージェントは自動的に無効化し、ライブのアラームと再起動用の永続化データの両方をキャンセルする。
PendingIntent の request code をスケジュール実行と手動実行で分ける
PendingIntent の同一性は Intent の extras を見ない。手動実行用の PendingIntent とスケジュール実行用の PendingIntent で同じ request code を使い回すと、FLAG_UPDATE_CURRENT によって片方の extras(interval や cron 文字列)がもう片方に上書きされ、次回の再武装が壊れる。この2つは明確に別の request code を持たせる必要がある。
まとめ
| 課題 | 対策 |
|---|---|
| Doze 中でも正確な時刻に起こしたい |
setExactAndAllowWhileIdle + SCHEDULE_EXACT_ALARM の権限チェックとフォールバック |
| BroadcastReceiver がキャッシュ/凍結プロセスに配信されないことがある(Android 14+ / One UI で確認) |
PendingIntent を getForegroundService() で Foreground Service に直接向ける |
| 画面オフのまま処理を続けたい |
PARTIAL_WAKE_LOCK(タイムアウト必須) |
| 再起動でアラームが消える |
BOOT_COMPLETED でネイティブ側の永続化データから再武装(JS ストアには頼らない) |
| 失敗し続けるジョブが端末を起こし続ける | 連続失敗数によるサーキットブレーカーで自動無効化 |
| 手動実行とスケジュール実行の PendingIntent が競合する | request code を明確に分離 |
実装全体は GitHub で公開している(RYOITABASHI/Shelly、GPLv3)。該当コードは AgentAlarmScheduler.kt / TerminalSessionService.kt / BootCompletedReceiver.kt にある。

