Будильник, который точно звонит: что я узнал о точных напоминаниях на Android, пока делал планер
Я делал планер дня под 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 заранее, чтобы будильник прозвенел вовремя. Зато в строке состояния появляется значок будильника, а на экране блокировки — время следующего. Поэтому в планере это отдельный режим: «уведомление» или «настоящий будильник» выбирает человек, а не я за него.
Итоговое правило у меня такое:
Что | Чем ставим |
|---|---|
Подъём, «не пропусти» |
|
Напоминание о начале занятия или дела |
|
Обновление виджета |
|
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, бесплатный).
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.