Firebase Cloud Messaging в Android: базовая настройка push-уведомлений в Kotlin

На чтение
11 мин
Дата обновления
05.11.2026

Уведомление может успешно появиться и всё равно привести пользователя не туда. Для минимального Android-сценария нужно проверить две вещи отдельно: кто показывает карточку и как приложение обрабатывает данные при нажатии. Разберём авторское сообщение «Доступен урок» с переходом на учебный экран.

Код ниже относится к совместимому legacy AndroidConfig и Kotlin-проекту. Текущая справка Firebase уже помечает доступ к registration token deprecated и предлагает FID; для AndroidConfigV2 предусмотрены специальные callbacks и автоматический показ уведомлений. Различайте назначение идентификатора регистрации и формат сообщения; при постепенной миграции документация предусматривает совместимость, но callbacks нужно анализировать по фактическому пути доставки. Пример legacy нужен для разбора существующей схемы и Console-теста из руководства, а для новой интеграции следует отдельно оценить переход на актуальные FID/V2.

AndroidConfigV2 в руководстве Firebase помечен preview/Pre-GA, а не стабильным общедоступным релизом. Условия и поддержка такого режима отличаются; упоминание V2 здесь не является рекомендацией без проверки переносить на него production-приложение. Статус FID-регистрации и устаревание token access оцениваются отдельно от статуса V2.

Подготовить Firebase и границы примера

Создайте или выберите Firebase-проект, зарегистрируйте фактический applicationId Android-приложения и поместите соответствующий google-services.json в модуль app. Подключите Google services plugin и Messaging. В руководстве setup показаны версии plugin 4.5.0 и BoM 35.1.0; для выбранного проекта проверьте совместимость toolchain.

// Добавление в существующий корневой plugins-блок
id("com.google.gms.google-services") version "4.5.0" apply false
// Добавление в plugins-блок app
id("com.google.gms.google-services")
// Добавление в dependencies app
implementation(platform("com.google.firebase:firebase-bom:35.1.0"))
implementation("com.google.firebase:firebase-messaging")

Не заменяйте этими строками весь Gradle-файл. Ниже также используются AndroidX Core и ComponentActivity из зависимостей приложения. Создайте small icon ic_stat_lesson и учебную LessonActivity. Имена package, ресурсов и классов согласуйте в своём проекте.

В Manifest необходимы POST_NOTIFICATIONS, service Firebase и объявление нового экрана. В существующей MainActivity для нашего опыта задайте launchMode="singleTop", сохранив её launcher-настройки:

<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<!-- Внутри application: согласуйте с существующей MainActivity, не дублируйте её -->
<activity android:name=".MainActivity" android:exported="true"
    android:launchMode="singleTop">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
</activity>
<service android:name=".LessonMessagingService" android:exported="false">
    <intent-filter>
        <action android:name="com.google.firebase.MESSAGING_EVENT" />
    </intent-filter>
</service>
<activity android:name=".LessonActivity" android:exported="false"
    android:parentActivityName=".MainActivity" />

Не перепутать регистрацию и разрешение показа

Для совместимого Console-теста получите текущий registration token в Activity через FirebaseMessaging.getInstance().token.addOnCompleteListener. Сначала проверьте task.isSuccessful, затем используйте task.result. Не копируйте в отправитель значение от другого варианта applicationId.

Изменение токена приходит в onNewToken. Для приложения с backend это место должно инициировать вашу синхронизацию актуального адреса устройства; получение при старте и обработку обновления нужно согласовать с серверным хранением. Не публикуйте идентификатор и не оставляйте его в общедоступном журнале. Сам протокол backend в этой статье не реализуется.

На Android 13+ объявите и запросите runtime-разрешение POST_NOTIFICATIONS. Используйте зарегистрированный Activity Result launcher с RequestPermission, вызывайте его после понятного пользователю действия, при необходимости объяснения учитывайте shouldShowRequestPermissionRationale. На отказ не отвечайте бесконечным повторным запросом; для теста проверьте настройки приложения. Наличие токена не подтверждает разрешение показывать карточки.

Data описывает маршрут, но не даёт права на данные

В нашем вымышленном сообщении используются строковые поля:

{
  "screen": "lesson",
  "lesson_id": "42"
}

Это пример объекта data, не полный запрос отправки FCM. Значение 42 условное. Получатель проверяет разрешённое имя экрана и корректный положительный идентификатор; произвольный адрес или название класса из сообщения не исполняется. В реальном приложении доступ к уроку дополнительно проверяет его обычная логика авторизации. Уведомление не является основанием показать закрытое содержимое.

Сервис для legacy foreground и data

Следующий класс использует явный маршрут через Intent, канал и TaskStackBuilder. Для ясности учебный сервис отображает только последнее уведомление с ID 1001.

import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.TaskStackBuilder
import androidx.core.content.ContextCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage

class LessonMessagingService : FirebaseMessagingService() {
    override fun onNewToken(token: String) {
        if (BuildConfig.DEBUG) Log.d("FCM_LOCAL_TEST", token)
    }

    override fun onMessageReceived(message: RemoteMessage) {
        if (Build.VERSION.SDK_INT >= 33 &&
            ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
                != PackageManager.PERMISSION_GRANTED) return

        val channelId = "lesson_updates"
        if (Build.VERSION.SDK_INT >= 26) {
            getSystemService(NotificationManager::class.java).createNotificationChannel(
                NotificationChannel(channelId, "Новые уроки", NotificationManager.IMPORTANCE_DEFAULT)
            )
        }
        val lessonId = message.data["lesson_id"]?.toLongOrNull()?.takeIf { it > 0 }
        val target = if (message.data["screen"] == "lesson" && lessonId != null) {
            Intent(this, LessonActivity::class.java).putExtra("lesson_id", lessonId)
        } else {
            Intent(this, MainActivity::class.java)
        }
        val pending = TaskStackBuilder.create(this)
            .addNextIntentWithParentStack(target)
            .getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
        val notification = NotificationCompat.Builder(this, channelId)
            .setSmallIcon(R.drawable.ic_stat_lesson)
            .setContentTitle(message.notification?.title ?: "Доступен урок")
            .setContentText(message.notification?.body ?: message.data["text"].orEmpty())
            .setContentIntent(pending)
            .setAutoCancel(true)
            .build()
        NotificationManagerCompat.from(this).notify(1001, notification)
    }
}

Печать onNewToken здесь предназначена только для локального debug-теста; в продукте её заменяют вашей синхронизацией. Канал имеет постоянный ID lesson_updates. Он относится к карточкам, создаваемым нашим Builder. Автоматический показ FCM в фоне выбирает канал по своим параметрам и fallback-настройкам; этот фрагмент сам по себе не привязывает Console-уведомление к lesson_updates. Пользовательские настройки канала влияют на показ; пересоздание канала не является способом отменить его выбор.

TaskStackBuilder использует parentActivityName, чтобы у обычного экрана была родительская история переходов. FLAG_IMMUTABLE и FLAG_UPDATE_CURRENT относятся к нашему PendingIntent. Один request code и один notification ID выбраны согласованно для замены последней карточки. Для нескольких независимых уведомлений потребуется отдельная политика идентичности, а не слепое копирование этих нулей и 1001.

Почему background notification + data идёт другим путём

У legacy notification в фоне показ берёт на себя FCM; у сочетания notification+data полезные поля приходят в extras launcher Activity при нажатии. Наш ручной onMessageReceived в этом случае не является точкой отображения. Если payload содержит только data, маршрут callback иной; это не тот же тест, что обычная notification campaign.

Поэтому в MainActivity обработайте extras как при создании, так и при получении нового Intent. Ниже полный учебный вариант MainActivity: кнопка разрешения, получение текущего test token и маршрутизация. Для существующего приложения перенесите логику в его UI, не удаляя свою реализацию экрана:

import android.Manifest
import android.app.AlertDialog
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.widget.Button
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import com.google.firebase.messaging.FirebaseMessaging

class MainActivity : ComponentActivity() {
    private val notificationPermission = registerForActivityResult(
        ActivityResultContracts.RequestPermission()
    ) { granted ->
        Log.d("FCM_LOCAL_TEST", "Permission granted: $granted")
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(Button(this).apply {
            text = "Включить уведомления об уроках"
            setOnClickListener { requestNotifications() }
        })
        FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
            if (task.isSuccessful) {
                if (BuildConfig.DEBUG) Log.d("FCM_LOCAL_TEST", task.result)
            } else {
                Log.w("FCM_LOCAL_TEST", "Registration failed", task.exception)
            }
        }
        routePush(intent)
    }

    private fun requestNotifications() {
        if (Build.VERSION.SDK_INT < 33) return
        if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
            == PackageManager.PERMISSION_GRANTED) return
        if (shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS)) {
            AlertDialog.Builder(this)
                .setMessage("Разрешение нужно для уведомлений о новых уроках.")
                .setPositiveButton("Разрешить") { _, _ ->
                    notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
                }
                .setNegativeButton("Не сейчас", null)
                .show()
        } else {
            notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
        }
    }

    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        setIntent(intent)
        routePush(intent)
    }

    private fun routePush(source: Intent) {
        if (source.getStringExtra("screen") != "lesson") return
        val id = source.getStringExtra("lesson_id")?.toLongOrNull() ?: return
        if (id <= 0) return
        source.removeExtra("screen")
        source.removeExtra("lesson_id")
        startActivity(Intent(this, LessonActivity::class.java).putExtra("lesson_id", id))
    }
}

В учебной MainActivity targetSdk предполагается 33 или выше, чтобы приложение управляло моментом runtime-запроса. На более ранних targetSdk поведение запроса отдельно проверяют по справке Android. В singleTop новый запуск существующей верхней Activity может прийти через onNewIntent; getIntent без setIntent продолжает возвращать исходный Intent. Поэтому обработка только старого поля intent способна потерять новый маршрут.

В LessonActivity учебный экран может читать уже проверенный Long так:

val id = intent.getLongExtra("lesson_id", -1L)
if (id <= 0L) {
    finish()
    return
}
setContentView(TextView(this).apply {
    text = "Учебный экран урока $id"
})

Это содержимое onCreate после super.onCreate, с импортом TextView. Так мы не выдаём показ текста за загрузку реального урока. Для восстановления экрана и полноценной навигации используйте принятый в своём приложении подход, не смешивая несогласованные типы extras.

Что меняется в FID и AndroidConfigV2

В актуальном пути регистрации через FID справка описывает manifest-флаг firebase_messaging_installation_id_enabled=true и onRegistered(installationId). Это отдельный контракт обновления регистрации. Доступ к registration token помечен как устаревающий, поэтому перенос существующей системы нужно планировать, а не просто переименовать переменную.

В preview V2 предусмотрены RemoteNotification и новые callbacks onNotificationReceived/onBackgroundSyncReceived; для EAP документация требует отдельный SDK из локального Maven-архива, а не только стандартную зависимость из BoM. При этом есть совместимость: если V2-callbacks ещё не реализованы, GMS Core может передать сообщение через legacy onMessageReceived. Поэтому нельзя утверждать, что наш сервис никогда не получит сообщение от V2-отправителя. Код статьи проектировался для legacy-сценария; перед постепенной миграцией проверьте, какие callbacks реально реализованы, используется ли fallback и кто показывает уведомление. Не переносите ожидания одного пути на другой без такого разбора.

Проверка без серверной рассылки

Для базового теста выполните описанную Firebase Console процедуру: установите и запустите приложение, получите registration token, разрешите уведомления и переведите приложение в фон. В Messaging создайте notification campaign, выберите Send test message и целевое устройство по token. Этот тест проверяет notification-доставку, а не автоматически все варианты data.

Для отдельного разрешённого теста notification+data отправитель должен сформировать показанные поля. В foreground проверьте ручную карточку и переход через PendingIntent; в background — extras launcher Activity. Затем проверьте неверный lesson_id и неизвестный screen: учебный обработчик не должен открывать произвольный экран по таким данным.

Если карточка видна, но открывается главная страница, проверьте наличие data, их тип и место чтения. Если callback не вызван в фоне, сначала сравните тип сообщения с legacy-правилами. Если не видно самой карточки, проверьте разрешение и канал. Разделение этих этапов полезнее, чем одновременная замена Firebase-проекта, Intent и настроек телефона.