Irbisa · Шпаргалка

Уведомления и push

NotificationCompat, каналы, POST_NOTIFICATIONS, FCM, data- и notification-сообщения, deep links

Middle-разработчик · 19 вопросов

  1. 01

    Что такое notification channel и почему повышение его важности в коде позже ничего не меняет?

    Канал задаёт категорию, к которой на Android 8+ обязано относиться каждое уведомление; уведомление в несозданный канал молча отбрасывается, а важность, звук и вибрация канала фиксируются при создании, потому что дальше ими управляет пользователь.

    class App : Application() {  override fun onCreate() {    super.onCreate()    // Idempotent: safe on every launch, must run before the first notify()    NotificationManagerCompat.from(this).createNotificationChannel(      NotificationChannelCompat.Builder("delivery_v2", NotificationManagerCompat.IMPORTANCE_HIGH)…
  2. 02

    Что минимально нужно NotificationCompat.Builder, чтобы показать рабочее уведомление?

    Id канала и маленькая иконка составляют жёсткий минимум; заголовок, текст и content intent на тап делают уведомление полезным, а показывает его notify(id, notification).

    val tap = PendingIntent.getActivity(  ctx, 0,  Intent(Intent.ACTION_VIEW, "myapp://orders/42".toUri(), ctx, MainActivity::class.java),  PendingIntent.FLAG_IMMUTABLE)…
  3. 03

    Какие стили уведомлений есть в NotificationCompat и какую часть уведомления меняет стиль?

    BigTextStyle, BigPictureStyle и InboxStyle меняют только развёрнутый вид, а свёрнутая строка по-прежнему берётся из setContentTitle и setContentText; MessagingStyle и CallStyle заменяют ещё и свёрнутый вид.

    val release = NotificationCompat.Builder(ctx, "news")  .setSmallIcon(R.drawable.ic_stat_news)  .setContentTitle("Release 4.2 is out")  .setContentText("Offline sync, faster search, 40 fixes")              // collapsed row  .setStyle(NotificationCompat.BigTextStyle().bigText(fullChangelog))  // expanded only  .build()…
  4. 04

    Как обновить или убрать уже показанное уведомление?

    Снова вызвать notify() с тем же id (и тегом), чтобы обновить его на месте, и cancel(id) или cancelAll(), чтобы убрать; id задаёт идентичность уведомления, поэтому он должен быть стабильным для каждой показываемой сущности.

    val nm = NotificationManagerCompat.from(ctx)val base = NotificationCompat.Builder(ctx, "orders")  .setSmallIcon(R.drawable.ic_stat_order)  .setContentTitle("Order #42") // Same id: updates the row in place, quietly…
  5. 05

    Что изменилось с POST_NOTIFICATIONS в Android 13 и что делать, если пользователь отказал?

    С Android 13 для показа уведомлений нужно runtime-разрешение, которое на новых установках выключено, поэтому его объявляют в манифесте, запрашивают в контексте через Activity Result API и дают контенту другое место, если пользователь отказал.

    // AndroidManifest.xml: <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> @Composablefun PriceAlertButton(onEnabled: () -> Unit) {  val ctx = LocalContext.current  val launcher = rememberLauncherForActivityResult(…
  6. 06

    Что определяет громкость уведомления: важность канала, setPriority или приоритет сообщения FCM?

    На Android 8+ только важность канала; setPriority читается лишь ниже API 26, а приоритет FCM определяет, как быстро сообщение пройдёт через Doze, а не как оно будет звучать.

  7. 07

    Content intent уведомления указывает на этот receiver. На Android 12+ экран так и не открывается. Почему и что делать вместо этого?

    // setContentIntent(PendingIntent.getBroadcast(ctx, 0, openIntent, FLAG_IMMUTABLE))class OpenReceiver : BroadcastReceiver() {  override fun onReceive(ctx: Context, intent: Intent) {    val route = intent.getStringExtra("route")    analytics.log("notification_open", route)    ctx.startActivity(      Intent(ctx, MainActivity::class.java)        .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)        .putExtra("route", route)    )  }}

    Это notification trampoline: у приложений с target API 31+ receiver или сервис, запущенные тапом по уведомлению, не могут стартовать Activity, поэтому запуск молча отбрасывается; направляйте content intent прямо на Activity.

  8. 08

    Найдите баг: в шторке два уведомления из разных чатов, а тап по любому из них открывает чат с последним сообщением.

    fun showChatNotification(ctx: Context, chat: Chat, text: String) {  val open = Intent(ctx, MainActivity::class.java)    .putExtra("chat_id", chat.id)  val pi = PendingIntent.getActivity(    ctx, 0, open,    PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT  )  val notification = NotificationCompat.Builder(ctx, CHANNEL_CHATS)    .setSmallIcon(R.drawable.ic_stat_chat)    .setContentTitle(chat.title)    .setContentText(text)    .setContentIntent(pi)    .setAutoCancel(true)    .build()  NotificationManagerCompat.from(ctx).notify(chat.id, notification)}

    Все чаты получают один и тот же PendingIntent: его идентичность определяют request code плюс компонент, action и data интента, но не extras, поэтому FLAG_UPDATE_CURRENT перезаписывает chat_id и любое уведомление открывает последний чат.

  9. 09

    Как сгруппировать уведомления под сводкой, чтобы десять результатов давали один звук, а не десять?

    Дайте всем уведомлениям одинаковый ключ setGroup, покажите одну дополнительную сводку с setGroupSummary(true) и поставьте всем setGroupAlertBehavior(GROUP_ALERT_SUMMARY), чтобы оповещала только сводка.

    private const val GROUP_SYNC = "sync_results"private const val SUMMARY_ID = 0 fun postSyncResults(ctx: Context, files: List<SyncedFile>) {  val nm = NotificationManagerCompat.from(ctx)  files.forEach { file ->…
  10. 10

    Эта загрузка обновляет уведомление с прогрессом на каждом чанке. Что услышит пользователь и почему полоса остаётся полной, а текст «Готово» так и не появляется?

    suspend fun download(ctx: Context, file: RemoteFile) {  val nm = NotificationManagerCompat.from(ctx)  val builder = NotificationCompat.Builder(ctx, "downloads")  // IMPORTANCE_DEFAULT    .setSmallIcon(R.drawable.ic_stat_download)    .setContentTitle(file.name)   file.chunks().collectIndexed { i, chunk ->    sink.write(chunk)    builder.setProgress(100, (i + 1) * 100 / file.chunkCount, false)    nm.notify(DOWNLOAD_ID, builder.build())  }  builder.setContentText("Done").setProgress(0, 0, false)  nm.notify(DOWNLOAD_ID, builder.build())}

    Каждый notify() в звучащем канале оповещает заново, поэтому телефон каждый раз проигрывает звук на очередном чанке; система ограничивает обновления существующего уведомления примерно пятью в секунду на приложение и молча отбрасывает остальные, включая финальное обновление «Готово», потому что setProgress(0, 0, false) от лимита не освобождено.

  11. 11

    В этом direct reply getResultsFromIntent возвращает null, а после исправления поле ответа бесконечно крутится. Что не так?

    val replyPi = PendingIntent.getBroadcast(  ctx, 0, Intent(ctx, ReplyReceiver::class.java), PendingIntent.FLAG_IMMUTABLE)val action = NotificationCompat.Action.Builder(R.drawable.ic_send, "Reply", replyPi)  .addRemoteInput(RemoteInput.Builder(KEY_REPLY).setLabel("Message").build())  .build() class ReplyReceiver : BroadcastReceiver() {  override fun onReceive(ctx: Context, intent: Intent) {    val text = RemoteInput.getResultsFromIntent(intent)      ?.getCharSequence(KEY_REPLY) ?: return    SendReplyWorker.enqueue(ctx, text.toString())  }}

    PendingIntent неизменяемый, поэтому система не может записать в него введённый текст; а receiver не перепоказывает уведомление, хотя только это завершает состояние «отправка» у поля ответа.

  12. 12

    Что нужно уведомлению чата, чтобы попасть в раздел «Разговоры» и поддерживать bubbles?

    MessagingStyle с Person у каждого сообщения плюс setShortcutId, указывающий на долгоживущий динамический ярлык этого чата; без ярлыка уведомление молча остаётся в обычном разделе.

    val sender = Person.Builder().setName("Dana").setKey("user:88").build()val shortcutId = "chat:88" // 1. Long-lived shortcut: without it there is no Conversations sectionShortcutManagerCompat.pushDynamicShortcut(  ctx,…
  13. 13

    Чем notification-сообщения FCM отличаются от data-сообщений и когда на самом деле вызывается onMessageReceived?

    Сообщение с блоком notification рисует сам FCM SDK, пока приложение в фоне, и onMessageReceived пропускается; data-only сообщение всегда приходит в onMessageReceived, а уведомление вы собираете сами.

    class AppMessagingService : FirebaseMessagingService() {  // Data-only: runs in every app state. With a "notification" block:  // runs only while the app is in the foreground.  override fun onMessageReceived(message: RemoteMessage) {    val convId = message.data["conversation_id"] ?: return    repository.insert(message.data)…
  14. 14

    Как управлять жизненным циклом регистрационных токенов FCM на клиенте и на сервере?

    Токен обозначает одну установку приложения, а не пользователя и не устройство, и может смениться в любой момент, поэтому клиент перезаливает его, как только он отличается от последнего отправленного, а сервер удаляет токены, о которых FCM сообщает UNREGISTERED.

    class AppMessagingService : FirebaseMessagingService() {  // Install, data clear, restore, rotation; often before anyone signed in  override fun onNewToken(token: String) {    TokenStore.savePending(this, token)    TokenUploadWorker.enqueue(this)   // retryable: the network may be down  }…
  15. 15

    Как убрать одноразовый код с экрана блокировки и что вместо вас решают пользователь и администратор?

    Вы управляете видимостью уведомления (VISIBILITY_PRIVATE с урезанной setPublicVersion или VISIBILITY_SECRET), а показывает ли экран блокировки содержимое вообще, решает пользователь, а на управляемом устройстве и администратор.

    fun otpNotification(ctx: Context, code: String, tap: PendingIntent): Notification {  val redacted = NotificationCompat.Builder(ctx, "security")    .setSmallIcon(R.drawable.ic_lock)    .setContentTitle("New sign-in code")   // says what it is, never the code    .setContentIntent(tap)                 // same tap target    .build()…
  16. 16

    Что такое full-screen intent и что изменилось с USE_FULL_SCREEN_INTENT в Android 14?

    Full-screen intent просит систему запустить Activity поверх экрана блокировки для срочных событий вроде звонков и будильников; с Android 14 это разрешение по умолчанию дают только звонилкам и будильникам, а без него уведомление молча становится обычным heads-up.

    fun ensureFullScreenIntent(activity: Activity) {  if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return  val nm = activity.getSystemService(NotificationManager::class.java)  if (nm.canUseFullScreenIntent()) return  activity.startActivity(    Intent(Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT, "package:${activity.packageName}".toUri())…
  17. 17

    Пуш из чата приходит, пока пользователь читает именно этот разговор. Что должно произойти и где в архитектуре это решается?

    Ничего не должно вибрировать: сообщение попадает в репозиторий, открытый экран обновляется из него, а единственный notification presenter, который знает, что сейчас на экране, решает ничего не показывать.

  18. 18

    Как collapse_key, ttl и приоритет сообщения в FCM влияют на доставку и как выбирать их для разных типов сообщений?

    collapse_key позволяет новому сообщению заменить недоставленное, ttl ограничивает срок хранения в FCM, а приоритет решает, будить ли устройство в Doze сейчас или ждать окна обслуживания; если ошибётесь, сообщения чата пропадут, а пуш «водитель приехал» опоздает.

    // Server, Firebase Admin SDK: one shape per message typefun driverArrived(token: String, rideId: String): Message = Message.builder()  .setToken(token)  .putData("ride_id", rideId)  .setAndroidConfig(AndroidConfig.builder()    .setPriority(AndroidConfig.Priority.HIGH)    // wake it out of Doze…
  19. 19

    Пользователи некоторых OEM-телефонов говорят, что уведомления не приходят, а у вас не воспроизводится. Как найти, где они теряются?

    Разбейте путь на отрезки, которые можно посчитать (принято FCM, доставлено на устройство, получено процессом, показано в шторке), потому что «уведомления не приходят» объединяет несколько не связанных поломок, и только одна из них на совести OEM.