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

Уведомление может успешно появиться и всё равно привести пользователя не туда. Для минимального 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 и настроек телефона.



