Я делал планер дня под Android. Его главная фишка простая: занятие начинается — телефон напоминает. Утром — будильник. Казалось бы, AlarmManager существует с первой версии Android, что тут сложного?

На деле оказалось, что «напомнить в 07:00» и «напомнить примерно около 07:00, если телефон не против» — это два разных API. Ниже — всё, на что я наступил, с кодом и цифрами с реальных телефонов (Realme GT Neo 2 на ColorOS и OnePlus 15R на Android 16).

1. Почему уведомления опаздывают

Причин три, и все они работают одновременно. Первая — Doze: когда экран выключен и телефон лежит неподвижно, система копит будильники приложений и выполняет их пачкой в «окнах обслуживания», причём окна сначала частые, а потом всё реже. Вторая — App Standby Buckets: чем реже человек открывает приложение, тем ниже его «корзина» и тем реже системе разрешено его будить. Третья — оболочки производителей: поверх Android у Realme, Xiaomi, Huawei и других есть свои «оптимизаторы батареи».

Замеры это подтвердили. setExactAndAllowWhileIdle в принудительном глубоком Doze на Realme пришёл на 134 секунды позже. На ColorOS напоминания любых сторонних приложений — не только моего, но и TickTick, и WhatsApp — получали окно до часа, даже когда приложению в настройках разрешена «работа без ограничений»; это политика оболочки, и обойти её из приложения я не нашёл как. Ровно в минуту на обоих телефонах звонил только setAlarmClock.

2. Три уровня точности

AlarmManager am = (AlarmManager) ctx.getSystemService(Context.ALARM_SERVICE);

// 1) «Когда-нибудь около» — система вправе сдвинуть и сгруппировать
am.set(AlarmManager.RTC_WAKEUP, at, pi);

// 2) Точно, в том числе в Doze, но с ограничением частоты
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, pi);

// 3) Будильник: Doze его не откладывает
am.setAlarmClock(new AlarmManager.AlarmClockInfo(at, showPi), pi);

За каждый уровень приходится платить. setExactAndAllowWhileIdle в Doze срабатывает не чаще, чем примерно раз в несколько минут на приложение (точные цифры зависят от версии Android), но для «напомнить в начале занятия» этого хватает: занятия не идут каждую минуту.

setAlarmClock честнее всех: система выходит из Doze заранее, чтобы будильник прозвенел вовремя. Зато в строке состояния появляется значок будильника, а на экране блокировки — время следующего. Поэтому в планере это отдельный режим: «уведомление» или «настоящий будильник» выбирает человек, а не я за него.

Итоговое правило у меня такое:

Что

Чем ставим

Подъём, «не пропусти»

setAlarmClock

Напоминание о начале занятия или дела

setExactAndAllowWhileIdle

Обновление виджета

setExact без пробуждения (RTC, не RTC_WAKEUP)

3. Разрешения: Android 12, 13, 14

Android 12+ — точные будильники. Нужен SCHEDULE_EXACT_ALARM (человек может его отозвать) или USE_EXACT_ALARM (его не отзывают, но Google Play разрешает его только приложениям, где будильник или календарь — основная функция). Проверять обязательно, иначе setExact… бросит SecurityException:

static boolean canExact(Context ctx) {
    AlarmManager am = (AlarmManager) ctx.getSystemService(Context.ALARM_SERVICE);
    if (Build.VERSION.SDK_INT >= 31) return am.canScheduleExactAlarms();
    return true;
}

if (canExact(ctx)) am.setAlarmClock(new AlarmManager.AlarmClockInfo(at, show), fire);
else am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, fire);   // хуже, но не падаем

Android 13+ — POST_NOTIFICATIONS. Я сначала спрашивал разрешение при первом запуске — когда человек ещё не понимает, зачем оно. Теперь вопрос появляется карточкой в момент, когда человек сам включает напоминания и понимает, о чём речь.

Android 14+ — USE_FULL_SCREEN_INTENT. Без него будильник при заблокированном экране не разворачивается на весь экран, а приходит обычным уведомлением. Это разрешение тоже стали выдавать не всем, поэтому проверяем и при необходимости ведём человека в настройки:

NotificationManager nm = ctx.getSystemService(NotificationManager.class);
if (Build.VERSION.SDK_INT >= 34 && !nm.canUseFullScreenIntent()) {
    Intent i = new Intent(Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT,
            Uri.parse("package:" + ctx.getPackageName()));
    activity.startActivity(i);
}

4. Звонок — не в уведомлении, а в сервисе

Первая версия играла звук самим уведомлением: канал с высоким приоритетом, мелодия будильника. Работало, пока тестировщик не написал: «Нажал „Готово", а телефон продолжает вибрировать». Звук и вибрация уведомления живут своей жизнью, и надёжно погасить их из кода нельзя.

Решение — короткоживущий foreground service, который сам играет звук и вибрирует, а уведомление только показывает кнопки:

// AlarmService: звук через поток будильника — его громкость отдельная, и он звучит в режиме «Не беспокоить»,
// если человек разрешил будильники
MediaPlayer mp = new MediaPlayer();
mp.setAudioAttributes(new AudioAttributes.Builder()
        .setUsage(AudioAttributes.USAGE_ALARM)
        .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
        .build());
mp.setDataSource(this, alarmUri);
mp.setLooping(true);
mp.prepare();
mp.start();

// вибрация с той же «ролью», иначе в беззвучном режиме её срежут
AudioAttributes aa = new AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_ALARM).build();
vibrator.vibrate(VibrationEffect.createWaveform(PATTERN, 0), aa);

// не даём процессору уснуть, но и не звоним вечно
wake.acquire(MAX_RING_MS + 5000);           // MAX_RING_MS = 10 минут
handler.postDelayed(this::stopRinging, MAX_RING_MS);

Сервис стартует с типом FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK (Android 10+), в манифесте — FOREGROUND_SERVICE и FOREGROUND_SERVICE_MEDIA_PLAYBACK. Кнопки «Выключить» и «Отложить» в уведомлении и на полноэкранном окне вызывают stopRinging() в том же процессе — и звук с вибрацией гаснут сразу.

Уведомление при этом:

new NotificationCompat.Builder(this, CH)
        .setCategory(NotificationCompat.CATEGORY_ALARM)
        .setPriority(NotificationCompat.PRIORITY_MAX)
        .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
        .setFullScreenIntent(fullPi, true)   // окно будильника поверх экрана блокировки
        .setOngoing(true)
        .addAction(0, "Выключить", dismissPi)
        .addAction(0, "Отложить 10 мин", snoozePi)
        .build();

Грабля, на которую ушёл вечер: раз звук играет сервис, хочется сделать уведомление «тихим» через setSilent(true). Не делайте так: тихое уведомление не запускает full-screen intent, и окно будильника просто не появляется. Тишину лучше задать каналом без звука.

5. Перезагрузка и обновление приложения

Все будильники AlarmManager пропадают при перезагрузке. Это известно, но есть нюансы:

<receiver android:name=".BootReceiver" android:exported="true">
    <intent-filter>
        <action android:name="android.intent.action.BOOT_COMPLETED" />
        <action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
    </intent-filter>
</receiver>

Сам список будильников я храню в SharedPreferences: приёмник берёт его оттуда и ставит всё заново, не дожидаясь, пока человек откроет приложение. MY_PACKAGE_REPLACED нужен потому, что обновление приложения тоже снимает его будильники: без этого человек обновился вечером — и утром будильник молчит.

У этого есть обратная сторона: обновление может «воскресить» то, что новая версия уже не хочет ставить. У меня старые версии сами включали будильник подъёма, новая перестала, но после обновления приёмник восстановил старый список раньше, чем человек открыл приложение, и будильник прозвенел. Пришлось один раз чистить сохранённый список прямо в приёмнике.

И ещё одна тонкость: BOOT_COMPLETED приходит только после первой разблокировки после перезагрузки. Если телефон перезагрузился ночью сам (обновление системы), утренний будильник не прозвенит, пока его не разблокируют. Лечится через LOCKED_BOOT_COMPLETED и хранение списка в device-protected storage — так у меня теперь и сделано.

6. Часовые пояса

Если хранить будильник как «07:00 по местному времени», а ставить в AlarmManager абсолютное время в миллисекундах, после перелёта всё уедет на разницу поясов. Я пересчитываю и переставляю напоминания один раз при смене пояса: человек, прилетевший из Москвы в Бангкок, хочет вставать в 07:00 по-бангкокски, а не в 11:00.

7. Как это проверять

Ждать сутки, пока телефон уснёт сам, не нужно:

# какие будильники стоят у приложения
adb shell dumpsys alarm | grep -A3 com.example.app

# загнать телефон в глубокий Doze прямо сейчас
adb shell dumpsys battery unplug
adb shell dumpsys deviceidle force-idle

# вернуть как было
adb shell dumpsys deviceidle unforce
adb shell dumpsys battery reset

Из практики добавлю пару советов. Сначала убедитесь через dumpsys alarm, что тестовый будильник действительно поставлен, и только потом включайте force-idle. Если приложение переставляет напоминания при каждом открытии (у меня так), оно сотрёт ваши тестовые записи, поэтому тестовое напоминание лучше добавлять «по-честному» — как обычное занятие в плане — и потом удалять. И проверяйте на телефонах с разными оболочками: чистый Android и ColorOS ведут себя очень по-разному.

Итог

Если свести всё вместе, то, что должно прозвенеть «ровно», ставьте через setAlarmClock, а остальное — через setExactAndAllowWhileIdle. Проверяйте canScheduleExactAlarms() и имейте запасной путь без исключения. POST_NOTIFICATIONS спрашивайте в момент, когда человек понимает зачем, а на Android 14+ проверяйте ещё и canUseFullScreenIntent().

Звук и вибрацию будильника играйте в foreground service с USAGE_ALARM, а не в уведомлении, и не используйте setSilent() на уведомлении с full-screen intent. Восстанавливайте будильники на BOOT_COMPLETED и MY_PACKAGE_REPLACED и подумайте о LOCKED_BOOT_COMPLETED; при смене часового пояса их тоже нужно переставлять. А принудительный Doze через adb экономит дни тестов.

Буду рад, если в комментариях расскажете, как с этим справляются Xiaomi и Samsung — у меня под рукой были только Realme и OnePlus.

Всё описанное работает в моём планере дня «Ритм» (Android, бесплатный).