Канал задаёт категорию, к которой на Android 8+ обязано относиться каждое уведомление; уведомление в несозданный канал молча отбрасывается, а важность, звук и вибрация канала фиксируются при создании, потому что дальше ими управляет пользователь.
Создавать до показа: в Application.onCreate. createNotificationChannel идемпотентен, вызывать его при каждом запуске бесплатно. Отсутствующий канал ничего не бросает, в logcat только No Channel found for pkg=....
Что принадлежит пользователю: важность, звук, вибрация, бейдж, видимость на экране блокировки и полное отключение, отдельно для каждого канала. В этом и смысл: пользователь глушит «Акции» и оставляет «Статус доставки».
Что можно менять: название, описание и группа обновляются повторным вызовом; повышение важности и смена звука или вибрации игнорируются. Чтобы реально поменять поведение, заведите новый id (delivery_v2) и удалите старый; повторное создание удалённого id возвращает старые настройки пользователя.
AndroidMiddle
Уведомления и push
NotificationCompat, каналы, POST_NOTIFICATIONS, FCM, data- и notification-сообщения, deep links
Закрыто вопросов
0 / 19
- 0Повторить
Вопросы
Id канала и маленькая иконка составляют жёсткий минимум; заголовок, текст и content intent на тап делают уведомление полезным, а показывает его notify(id, notification).
**setSmallIcon**: единственное поле, без которого всё падает (IllegalArgumentException в notify()). Нужен белый вектор на прозрачном фоне, 24dp: система берёт только альфа-канал, поэтому цветная иконка лаунчера превращается в белый квадрат.
**setContentIntent: без него тап ничего не делает.
notify(id, notification)**: id задаёт идентичность уведомления. На Android 13+ нужен POST_NOTIFICATIONS.
BigTextStyle, BigPictureStyle и InboxStyle меняют только развёрнутый вид, а свёрнутая строка по-прежнему берётся из setContentTitle и setContentText; MessagingStyle и CallStyle заменяют ещё и свёрнутый вид.
**BigTextStyle: длинный абзац. Дёшево; добавляйте всегда, когда текст может не влезть в одну строку.
BigPictureStyle: одна картинка. Декодируйте уменьшенный bitmap не на главном потоке: всё уведомление уходит в систему через Binder.
InboxStyle: несколько коротких строк, типично для сводки группы.
MessagingStyle**: чат, у каждого сообщения свой Person; обязателен для раздела «Разговоры». Свёрнутая строка показывает последнее сообщение и игнорирует заголовок и текст контента.
**CallStyle**: входящие и текущие звонки со стандартными кнопками «ответить» и «отклонить», собирается из Person.
Прогресс обычно задаётся через setProgress(max, current, indeterminate), а не стилем; в Android 16 появился платформенный Notification.ProgressStyle для пошаговых сценариев вроде поездки или доставки.
Снова вызвать notify() с тем же id (и тегом), чтобы обновить его на месте, и cancel(id) или cancelAll(), чтобы убрать; id задаёт идентичность уведомления, поэтому он должен быть стабильным для каждой показываемой сущности.
Тихое обновление: добавьте setOnlyAlertOnce(true), иначе каждое обновление снова звучит и вибрирует. С тегом notify(tag, id, n) и cancel(tag, id) работают с парой как с идентичностью.
Автоудаление: setAutoCancel(true) убирает по тапу, а setTimeoutAfter(ms) убирает по истечении времени, что подходит для одноразового кода или предложения поездки.
Ongoing: setOngoing(true) больше не гарантирует, что строка останется: с Android 14 пользователь может смахнуть большинство ongoing-уведомлений (кроме CallStyle и медиа). Через setDeleteIntent можно узнать о смахивании.
С Android 13 для показа уведомлений нужно runtime-разрешение, которое на новых установках выключено, поэтому его объявляют в манифесте, запрашивают в контексте через Activity Result API и дают контенту другое место, если пользователь отказал.
У кого оно есть: приложения, установленные и имевшие право на уведомления до обновления ОС, получают его автоматически; новые установки начинают с отказа. Поэтому пропадают только новые пользователи.
Важен targetSdk: при target 33+ ничего не спрашивается, пока вы не спросите. При target 32 и ниже система сама покажет диалог при создании первого канала, обычно на холодном первом запуске.
Спрашивайте в контексте: когда пользователь включает уведомления о цене, а не на сплэше.
Два отказа: диалог больше не появляется, запрос разрешения сразу возвращает false; остаётся только Settings.ACTION_APP_NOTIFICATION_SETTINGS.
Что продолжает работать: data-сообщения FCM по-прежнему приходят в onMessageReceived, отбрасывается только видимый показ. Foreground-сервис продолжает работать, просто его уведомления нет в шторке.
Перед работой, единственным результатом которой будет уведомление, проверяйте areNotificationsEnabled() и держите запасной вариант: инбокс в приложении или бейдж.
На Android 8+ только важность канала; setPriority читается лишь ниже API 26, а приоритет FCM определяет, как быстро сообщение пройдёт через Doze, а не как оно будет звучать.
Уровни важности: IMPORTANCE_HIGH (heads-up и звук), DEFAULT (только звук), LOW (тихая строка в шторке), MIN (свёрнутая строка без иконки в статус-баре), NONE (блокировка).
Ей владеет пользователь: прежде чем отлаживать билдер, прочитайте в рантайме getNotificationChannelCompat(id)?.importance; пониженный пользователем канал объясняет большинство жалоб на «тихие» уведомления.
**FCM high**: будит устройство в Doze и доставляет сразу. Высокоприоритетный пуш в канал LOW придёт мгновенно и беззвучно.
Не беспокоить: фильтрует по setCategory и по каналам, которым пользователь разрешил обходить режим, поэтому честно ставьте CATEGORY_MESSAGE, CATEGORY_CALL или CATEGORY_ALARM.
Условия heads-up: нужен включённый экран, а частые повторные оповещения от одного приложения система может приглушать.
Это notification trampoline: у приложений с target API 31+ receiver или сервис, запущенные тапом по уведомлению, не могут стартовать Activity, поэтому запуск молча отбрасывается; направляйте content intent прямо на Activity.
Что видно: никакого краша, только Indirect notification activity start (trampoline) from <pkg> blocked в logcat. QA сообщает об этом как «уведомления ничего не делают».
Почему запретили: промежуточный шаг холодно стартует процесс и выполняет ваш код до того, как сменится хоть один пиксель, и ровно в тот момент, когда пользователь острее всего чувствует задержку.
Исправление: PendingIntent.getActivity с маршрутом в data Uri интента. Логируйте открытие в onCreate и onNewIntent этой Activity, когда экран уже открывается.
Receiver по-прежнему уместен для действий без UI: отметить прочитанным, отложить, setDeleteIntent.
Если что-то нужно сделать до показа экрана, запустите Activity и пусть она поставит эту работу в очередь.
Все чаты получают один и тот же PendingIntent: его идентичность определяют request code плюс компонент, action и data интента, но не extras, поэтому FLAG_UPDATE_CURRENT перезаписывает chat_id и любое уведомление открывает последний чат.
Почему: getActivity сначала ищет существующую запись с тем же request code и совпадением по Intent.filterEquals (action, data, type, identifier, компонент, категории). Extras не учитываются, поэтому второй вызов возвращает тот же самый PendingIntent (первый); с FLAG_UPDATE_CURRENT его extras заменяются, без него остаются extras первого чата и каждый тап открывает первый чат.
Исправление: request code, уникальный для чата (chat.id, то же значение, что и id уведомления), и тогда у каждого разговора своя запись. Подойдёт и setData(Uri) на каждый чат, например app://chat/42, и заодно это готовый deep link.
**FLAG_IMMUTABLE ни при чём**: он запрещает отправителю дописывать интент; приложение-создатель всё равно может заменить extras через FLAG_UPDATE_CURRENT.
Как поймать: инструментальный тест, который показывает уведомления для двух чатов и проверяет, что их contentIntent не равны (PendingIntent.equals возвращает true для одной и той же записи); adb shell dumpsys activity intents выводит живые записи.
Дайте всем уведомлениям одинаковый ключ setGroup, покажите одну дополнительную сводку с setGroupSummary(true) и поставьте всем setGroupAlertBehavior(GROUP_ALERT_SUMMARY), чтобы оповещала только сводка.
Сводка: показывается под своим id; это та свёрнутая строка, которую пользователь реально читает, обычно InboxStyle или счётчик.
Поведение оповещений: по умолчанию GROUP_ALERT_ALL оповещает за каждое дочернее уведомление, отсюда ваши десять вибраций. GROUP_ALERT_CHILDREN даёт зеркальный вариант.
Автогруппировка только внешняя: Android 7+ сам объединяет четыре и более уведомлений одного приложения даже без сводки, но к этому моменту каждое дочернее уже оповестило.
Осиротевшая сводка: если дочерние уведомления смахиваются по одному, отменяйте сводку, когда ушло последнее дочернее.
Лимит: у приложения может быть около 50 активных уведомлений; сверх этого новые показы отбрасываются.
Если пользователь не может действовать с каждым элементом отдельно, покажите одну строку: «Синхронизировано 12 файлов».
Каждый notify() в звучащем канале оповещает заново, поэтому телефон каждый раз проигрывает звук на очередном чанке; система ограничивает обновления существующего уведомления примерно пятью в секунду на приложение и молча отбрасывает остальные, включая финальное обновление «Готово», потому что setProgress(0, 0, false) от лимита не освобождено.
Где смотреть: в logcat видно Package enqueue rate is ... Shedding .... От лимита освобождено только обновление с завершённым прогрессом (max != 0 и progress == max).
Что видит пользователь: последний чанк отправляет setProgress(100, 100, false), оно проходит, и полоса заполнена; отброшено было то обновление, которое несло «Готово».
Убрать повторные звуки: setOnlyAlertOnce(true), а прогресс вынести в канал важности LOW: это статус, а не новость.
Троттлинг: обновляйте только при смене целого процента и не чаще примерно раза в секунду.
Надёжный финал: отмените уведомление с прогрессом и покажите результат под новым id (новый показ не считается обновлением) либо задержите финальное обновление. Обновление 100/100 от лимита освобождено, но лишь заполняет полосу, текст оно не заменяет.
Долгая загрузка должна жить в WorkManager с setForeground, чтобы пережить уход пользователя из приложения.
PendingIntent неизменяемый, поэтому система не может записать в него введённый текст; а receiver не перепоказывает уведомление, хотя только это завершает состояние «отправка» у поля ответа.
**FLAG_MUTABLE**: обязателен для действия с RemoteInput, потому что система дописывает данные в интент. Все остальные PendingIntent держите неизменяемыми.
Какой чат? В интенте нет id разговора, и receiver не знает, куда отправлять текст. Положите его в extras и используйте как request code.
Перепоказ под тем же id: добавьте исходящее сообщение в MessagingStyle (или используйте setRemoteInputHistory) с setOnlyAlertOnce(true); это убирает спиннер.
Отправка вне receiver: у onReceive около десяти секунд, поэтому сетевой вызов уходит в WorkManager; при ошибке обновите уведомление, чтобы пользователь узнал.
Receiver здесь правильный выбор: он не открывает UI, значит это не trampoline.
setSemanticAction(SEMANTIC_ACTION_REPLY) и setAllowGeneratedReplies(true) включают умные ответы, Wear OS и Android Auto.
MessagingStyle с Person у каждого сообщения плюс setShortcutId, указывающий на долгоживущий динамический ярлык этого чата; без ярлыка уведомление молча остаётся в обычном разделе.
Опубликуйте долгоживущий ярлык на каждый разговор: ShortcutInfoCompat с setLongLived(true) и Person, через ShortcutManagerCompat.pushDynamicShortcut.
Соберите уведомление с MessagingStyle(me) и addMessage(text, time, sender), чтобы система нарисовала аватары и вёрстку чата.
Поставьте setShortcutId с тем же id и setCategory(CATEGORY_MESSAGE).
Что это даёт: раздел «Разговоры» вверху шторки, приоритетные разговоры, которые пробивают «Не беспокоить», настройки на уровне разговора и bubbles через setBubbleMetadata.
Один id на чат: добавляйте новые сообщения в стиль и перепоказывайте, а не создавайте строку на каждое сообщение.
Сообщение с блоком notification рисует сам FCM SDK, пока приложение в фоне, и onMessageReceived пропускается; data-only сообщение всегда приходит в onMessageReceived, а уведомление вы собираете сами.
Только notification: на переднем плане: вызывается onMessageReceived, ничего не рисуется. В фоне: рисует система, ваш код не выполняется.
Только data: onMessageReceived вызывается в обоих состояниях. Единственный вариант, позволяющий дедуплицировать, расшифровать, локализовать, маршрутизировать и логировать.
Оба блока: в фоне система рисует часть notification, а ключи data приходят в extras launcher Activity, открытой тапом. На переднем плане: один RemoteMessage с обеими частями.
Что ломается: бейдж, аналитика или отметка «доставлено» внутри onMessageReceived молча не выполняются у тех, кто не открывает приложение.
Канал для системного показа: берётся из android.notification.channel_id, иначе из default_notification_channel_id в манифесте, иначе из резервного канала Miscellaneous, который создаёт SDK.
Бюджет времени: считайте, что у onMessageReceived несколько секунд (в примере Firebase их 10); долгую работу отдавайте WorkManager. Принудительно остановленное приложение не получает ничего.
Блок notification годится для маркетинговых рассылок; всё, от чего зависит продукт, отправляйте data-only.
Токен обозначает одну установку приложения, а не пользователя и не устройство, и может смениться в любой момент, поэтому клиент перезаливает его, как только он отличается от последнего отправленного, а сервер удаляет токены, о которых FCM сообщает UNREGISTERED.
Когда меняется: новая установка, очистка данных, восстановление на новое устройство, deleteToken() и ротация самим FCM.
**onNewToken недостаточно**: он может прийти до логина и теряется, если токен сменился, пока процесс был мёртв. Сверяйте FirebaseMessaging.getInstance().token ещё и при каждом запуске.
Выход из аккаунта: попросите сервер удалить запись и вызовите deleteToken(). Иначе следующий, кто войдёт на этом телефоне, получит пуши предыдущего пользователя, а это инцидент приватности.
Хранение на сервере: токен, id пользователя, платформа, версия приложения, updated_at. У одного пользователя несколько токенов, а на одном устройстве со временем бывает несколько аккаунтов.
Ошибки отправки: при UNREGISTERED удалить запись; при UNAVAILABLE и INTERNAL повторить с backoff.
Устаревание: по умолчанию FCM считает токен устаревшим, если экземпляр приложения не выходил на связь месяц (порог можно выбрать самому), а на Android токен, неактивный 270 дней, истекает; отправка на него завершается ошибкой.
Без Google Play services токена нет вовсе: задача token завершается ошибкой.
Вы управляете видимостью уведомления (VISIBILITY_PRIVATE с урезанной setPublicVersion или VISIBILITY_SECRET), а показывает ли экран блокировки содержимое вообще, решает пользователь, а на управляемом устройстве и администратор.
Три значения: PUBLIC показывает всё; PRIVATE показывает вашу публичную версию или общую строку «Содержимое скрыто», если её нет; SECRET не показывает на экране блокировки ничего.
Когда действует: только при защищённой блокировке (PIN, графический ключ, пароль). lockscreenVisibility канала задаётся при создании, позже его может поменять пользователь; побеждает более строгое из двух.
Не про heads-up: на разблокированном телефоне уведомление показывается целиком, и это нормально, но добавьте setTimeoutAfter, чтобы оно истекало вместе с кодом.
Страховка платформы: с Android 15 система скрывает распознанные одноразовые коды от notification listener без RECEIVE_SENSITIVE_NOTIFICATIONS. Это эвристика, так что свою видимость ставьте всё равно.
Администратор: KEYGUARD_DISABLE_UNREDACTED_NOTIFICATIONS принудительно скрывает содержимое, а приостановленный рабочий профиль прячет свои уведомления полностью.
Делайте публичную версию настоящим уведомлением с тем же действием по тапу, а не заглушкой.
Full-screen intent просит систему запустить Activity поверх экрана блокировки для срочных событий вроде звонков и будильников; с Android 14 это разрешение по умолчанию дают только звонилкам и будильникам, а без него уведомление молча становится обычным heads-up.
Никогда не гарантия: Activity запускается, только если устройство заблокировано или экран выключен; на разблокированном телефоне пользователь видит heads-up и остаётся там, где был.
**Нужен канал IMPORTANCE_HIGH: на канале ниже он не срабатывает, есть разрешение или нет.
Android 14+**: проверяйте NotificationManager.canUseFullScreenIntent() и, если false, ведите пользователя в Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT. Google Play выдаёт его при установке только приложениям, заявившим звонки или будильник как основную функцию, и проверяет эту декларацию.
Приложение для звонков сегодня: CallStyle.forIncomingCall с CATEGORY_CALL, foreground-сервис типа phoneCall и желательно self-managed ConnectionService (или Jetpack core-telecom), чтобы аудио и конфликты с сотовыми звонками разруливала система.
Проектируйте heads-up как основной путь, а запуск Activity поверх экрана блокировки как улучшение, которого может не быть.
Ничего не должно вибрировать: сообщение попадает в репозиторий, открытый экран обновляется из него, а единственный notification presenter, который знает, что сейчас на экране, решает ничего не показывать.
Путь сообщения
onMessageReceived записывает сообщение через репозиторий и не делает ничего, связанного с UI. Эта часть выполняется в любом состоянии приложения.
Открытый экран собирает тот же Flow и показывает новое сообщение по мере прихода.
Presenter задаёт два вопроса: приложение на переднем плане (ProcessLifecycleOwner) и открыт ли именно этот разговор (это небольшой холдер уровня приложения, который экран заполняет в resume и очищает в pause).
Разговор открыт: не показывать и отметить прочитанным. Приложение открыто, но на другом экране: баннер внутри приложения. Фон: обычное уведомление.
Ловушки
Это работает только с data-only сообщениями: уведомление, которое система рисует за свёрнутое приложение, подавить нельзя.
Открытие разговора должно отменять его уведомление, а сервер должен слать data-only пуш «прочитано», чтобы другие ваши устройства тоже убрали своё.
Один стабильный id уведомления на разговор и setOnlyAlertOnce(true), чтобы пятое сообщение обновляло строку, а не вибрировало в пятый раз.
Не выясняйте текущую Activity из сервиса. Один холдер, который пишет один lifecycle-наблюдатель, читается в одном месте.
Системное уведомление нужно, чтобы достучаться до пользователя, который не смотрит; если он смотрит, уведомлением служит сам UI.
collapse_key позволяет новому сообщению заменить недоставленное, ttl ограничивает срок хранения в FCM, а приоритет решает, будить ли устройство в Doze сейчас или ждать окна обслуживания; если ошибётесь, сообщения чата пропадут, а пуш «водитель приехал» опоздает.
Схлопывание и очередь
collapse_key означает «замени любое недоставленное сообщение с этим ключом на этом устройстве». Подходит для пинга состояния вроде счётчика бейджа или курсора синхронизации и не подходит для того, что пользователь должен прочитать: выживет только последнее.
FCM хранит не больше четырёх разных collapse key на устройство одновременно.
Несхлопываемые сообщения копятся в очереди примерно до 100 на устройство; дальше FCM сбрасывает очередь и вызывает onDeletedMessages(), а правильная реакция на него только одна: полная синхронизация.
Приоритет и срок жизни
high будит устройство в Doze и ненадолго даёт процессу доступ к сети; normal ждёт окна обслуживания, а для приложения в ограниченной standby-группе это могут быть часы.
Высокий приоритет надо заслужить: FCM может понизить приложение, чьи high-сообщения не приводят к видимому уведомлению. Сравнивайте priority с originalPriority на устройстве.
ttl по умолчанию и максимум составляют четыре недели. ttl 0 означает «доставить сейчас или выбросить», это верно для звонка или одноразового кода.
По типам сообщений
Водитель приехал: высокий приоритет, ttl в минутах, без collapse key.
Сообщение чата: высокий приоритет, не схлопывается.
Обновление бейджа: обычный приоритет, схлопывается, длинный ttl.
Разбейте путь на отрезки, которые можно посчитать (принято FCM, доставлено на устройство, получено процессом, показано в шторке), потому что «уведомления не приходят» объединяет несколько не связанных поломок, и только одна из них на совести OEM.
Инструментируйте каждый отрезок
Сервер: логируйте id сообщения, который возвращает каждая отправка, и ошибку по каждому токену для неудачных.
Доставка: данные о доставке в Firebase и их экспорт в BigQuery показывают по каждому сообщению, доставил ли его FCM, отбросил из-за неактивного устройства или слишком большого числа ожидающих сообщений, или задержал из-за standby-группы приложения.
Получение: в onMessageReceived логируйте id сообщения, задержку относительно sentTime и priority против originalPriority, чтобы увидеть понижение приоритета со стороны FCM.
Показ: прямо перед notify() логируйте areNotificationsEnabled() и важность канала. Пользователь, выключивший канал, с точки зрения сервера выглядит точно как потерянный пуш, и это объясняет многие такие тикеты.
Воспроизведите и разделите
adb shell dumpsys notification --noredact показывает показанные записи и состояние каналов.
adb shell am set-standby-bucket <pkg> restricted плюс adb shell dumpsys deviceidle force-idle, а затем отправка с обычным приоритетом воспроизводят задержку.
Отправляйте ActivityManager.isBackgroundRestricted и PowerManager.isIgnoringBatteryOptimizations() вместе с событием получения: агрессивный менеджер батареи OEM проявится как отдельная когорта.
Принудительно остановленное приложение не получает ничего, пока пользователь снова его не откроет. Это поведение платформы, а не причуда OEM.
Только после этого действуйте: попросите затронутую когорту исключить приложение из оптимизации батареи или поднимите приоритет для сообщений, которые этого заслуживают.