FCM Push Notifications: The Complete Android Implementation Guide

How do you add push notifications to an Android app? With Firebase Cloud Messaging: register the app in the Firebase console, place google-services.json correctly, extend FirebaseMessagingService, create notification channels, and handle device tokens and topics. This guide covers data vs notification payloads, Doze mode, OEM battery savers, the Android 13 permission, and local-scheduling fallback.

Written by Projectech16 min readPublished
For B.E./B.Tech Computer Science and IT final-year students adding push notifications to Android apps Topics: Kotlin, Firebase Cloud Messaging, Android SDK, AlarmManager, WorkManager
Illustration of stacked notification cards on a smartphone with a Firebase-style beacon radiating signal waves.
Illustration generated for this guide.
In this guide

A medicine reminder that cannot notify is just a list. A bus tracker that cannot ping "your bus is 5 minutes away" is just a map. Push notifications are how an app reaches the user when it is not open — and on Android, Firebase Cloud Messaging (FCM) is the standard way to do it.

This guide walks through the complete FCM integration: Firebase console setup, the google-services.json placement that trips up every first-timer, FirebaseMessagingService, notification channels, device tokens, topics, data payloads, and background handling — including the real-world cases (Doze mode, aggressive OEM battery savers) where notifications silently stop arriving.

The FCM mental model: your server tells Firebase "send this to these devices"; Firebase holds a persistent connection to every device and delivers it. Your app never polls.

Setup, step by step

1. Create the Firebase project. Go to the Firebase console, add a project, and register your Android app with its exact package name (e.g. com.example.medreminder). The package name must match your app module's applicationId character for character — a mismatch is the most common setup failure.

2. Download google-services.json and place it in the app module directoryapp/google-services.json, next to app/build.gradle. Not in src/, not in the project root. The Google Services Gradle plugin looks in exactly one place.

3. Add the plugin and dependency:

// project-level build.gradle
plugins {
    id("com.google.gms.google-services") version "4.4.2" apply false
}

// app-level build.gradle
plugins {
    id("com.android.application")
    id("com.google.gms.google-services")
}

dependencies {
    implementation(platform("com.google.firebase:firebase-bom:<version>"))
    implementation("com.google.firebase:firebase-messaging")
}

The Firebase BoM (Bill of Materials) keeps all Firebase libraries on compatible versions with one version number — use it rather than versioning each Firebase artifact separately.

4. Sync and run. If the build fails with File google-services.json is missing, the file is in the wrong directory. If it fails with a package-name complaint, the console registration does not match applicationId.

Receiving messages: FirebaseMessagingService

Create a service that extends FirebaseMessagingService and declare it in the manifest:

class AppMessagingService : FirebaseMessagingService() {

    override fun onMessageReceived(message: RemoteMessage) {
        // Data payload — always delivered here, foreground or background
        val title = message.data["title"] ?: "Reminder"
        val body = message.data["body"] ?: ""

        // Notification payload — delivered here only in foreground;
        // in background the system tray shows it automatically
        message.notification?.let {
            showNotification(it.title ?: title, it.body ?: body)
        } ?: showNotification(title, body)
    }

    override fun onNewToken(token: String) {
        // Token rotated — send it to your server immediately
        sendTokenToServer(token)
    }

    private fun showNotification(title: String, body: String) {
        val channelId = "reminders"
        val manager = getSystemService(NotificationManager::class.java)

        val channel = NotificationChannel(
            channelId,
            "Reminders",
            NotificationManager.IMPORTANCE_HIGH
        ).apply {
            description = "Time-sensitive reminders"
            enableVibration(true)
        }
        manager.createNotificationChannel(channel)

        val intent = Intent(this, MainActivity::class.java).apply {
            flags = Intent.FLAG_ACTIVITY_CLEAR_TOP
        }
        val pendingIntent = PendingIntent.getActivity(
            this, 0, intent,
            PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
        )

        val notification = NotificationCompat.Builder(this, channelId)
            .setSmallIcon(R.drawable.ic_notification)
            .setContentTitle(title)
            .setContentText(body)
            .setPriority(NotificationCompat.PRIORITY_HIGH)
            .setContentIntent(pendingIntent)
            .setAutoCancel(true)
            .build()

        manager.notify(System.currentTimeMillis().toInt(), notification)
    }
}
<!-- AndroidManifest.xml -->
<service
    android:name=".AppMessagingService"
    android:exported="false">
    <intent-filter>
        <action android:name="com.google.firebase.MESSAGING_EVENT" />
    </intent-filter>
</service>

Points that matter:

  • setSmallIcon is mandatory. A notification without a small icon does not render on many devices — it silently fails or shows a blank. Use a simple white-on-transparent silhouette; full-colour launcher icons render as a white square on some OEM skins.
  • PendingIntent.FLAG_IMMUTABLE is required on Android 12+ (API 31). Omitting it crashes with IllegalArgumentException: Targeting S+ requires FLAG_IMMUTABLE or FLAG_MUTABLE.
  • Create the channel before posting. Posting to a non-existent channel on Android 8.0+ (API 26) drops the notification silently. createNotificationChannel is idempotent — calling it every time is safe.
  • Notification ID controls grouping: same ID replaces the previous notification, unique IDs stack them. A medicine reminder wants unique IDs per dose; a "bus arriving" alert wants one replacing ID.

Notification channels: the Android 8.0 contract

Since API 26, every notification belongs to a channel, and the user controls each channel's importance, sound, and vibration from system settings — your app cannot override the user's choice after the channel is created. Design channels around user intent:

Channel Importance Example
reminders HIGH — sound + heads-up "Time to take your 8 PM tablet"
general DEFAULT — sound, no heads-up "Mess menu for tomorrow is updated"
promos LOW — silent "New feature: dark mode"

A medicine reminder app needs HIGH for dose alerts (missing one has consequences) and DEFAULT for refill nudges. A water intake reminder typically uses DEFAULT — hydration nudges should not buzz like an alarm. A hostel mess menu app can live on DEFAULT or LOW for menu updates. Getting this mapping right is a genuine product decision evaluators notice.

Channel settings are sticky. Once created, changing importance in code does nothing — the user's system setting wins. During development, uninstall/reinstall the app (or delete the channel) to test channel changes.

Device tokens: addressing one phone

Each app installation gets a unique FCM registration token. Your server stores it and targets it for personal messages ("your appointment is confirmed"):

FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
    if (task.isSuccessful) {
        val token = task.result
        sendTokenToServer(token)
    }
}

Token facts that prevent bugs:

  • Tokens rotate. onNewToken fires when it happens — always forward the new token to your server, or personal notifications stop reaching that device with no error on your side.
  • Tokens are per app installation, not per user. On logout, delete the server-side token mapping (and consider FirebaseMessaging.getInstance().deleteToken()), or the next user of the phone receives the previous user's notifications.
  • One user, many devices means one user maps to many tokens. Store the mapping as user → list of tokens.

Topics: addressing a crowd

Topics are Firebase-managed mailing lists. Devices subscribe; your server publishes once to the topic:

// All users of hostel block B get mess updates
FirebaseMessaging.getInstance().subscribeToTopic("mess-block-b")

// Stop when the user changes preference
FirebaseMessaging.getInstance().unsubscribeFromTopic("mess-block-b")

Topics fit broadcast content: mess menus per hostel block, bus route alerts per route, exam notices per department. They do not fit personal content — anyone can subscribe to any topic name, so never send personal data through topics. Rate limits apply to topic fan-out; for very large audiences the Firebase console documents the current quotas — check them before promising "all 10,000 students" in your report.

Data vs notification payloads: the table that decides your architecture

Notification payload Data payload
Handled by System tray automatically when app is in background/killed Always your onMessageReceived, in every app state
Custom handling in background No — your code does not run Yes — you decide what to show and what to do
Use for Simple alerts the user just reads Anything requiring logic: silent sync triggers, conditional display, updating Room before notifying
Risk None — always displayed If your handler crashes or is slow, the message is lost

The practical rule: send data payloads and build the notification yourself whenever the message drives app behaviour. A medicine reminder that should also mark the dose "due" in the local Room database needs a data payload — a notification payload in background never touches your code, so the database stays stale. Notification payloads are fine for pure announcements ("menu updated").

Background delivery: Doze mode and the OEM problem

Two forces work against background delivery, and your testing must account for both:

Doze mode (Android 6.0+) defers background work when the device is idle. FCM high-priority messages wake the device — that is their designed purpose — but normal-priority messages may wait for the next maintenance window. Time-sensitive alerts (medicine doses, bus arrivals) should be sent high-priority from the server; routine updates stay normal-priority.

OEM battery savers are the harder problem. Several manufacturers (notably Xiaomi, Oppo, Vivo, Huawei) kill background processes aggressively, and FCM delivery to a force-stopped or battery-restricted app can be delayed or dropped entirely. Mitigations:

  • Guide the user to exempt your app: Settings → Battery → app → "No restrictions" (wording varies by OEM). A first-run dialog explaining this is standard practice in reminder apps.
  • Do not rely on FCM alone for critical alerts — pair it with AlarmManager/WorkManager scheduled locally as a fallback. The local scheduler fires even if FCM is throttled; FCM handles the server-driven cases.
  • Test on a real device from one of these OEMs, not just the emulator. The emulator's well-behaved background handling will lie to you.

Local scheduling vs FCM: which triggers the reminder?

A reminder app has two ways to fire an alert: FCM from the server, or a locally scheduled alarm. They are complements, not competitors:

FCM push Local (AlarmManager / WorkManager)
Triggered by Server events: "your slot was confirmed", "menu updated" Time the app itself knows: "8 PM dose", "drink water hourly"
Needs network Yes, at send time No — fires from the device clock
Needs server Yes No
Survives app kill Yes (service restarts via Play Services) AlarmManager.setExactAndAllowWhileIdle survives; WorkManager may defer under Doze
Fails when OEM battery saver kills delivery; token stale User force-stops the app; device reboot (must re-register on BOOT_COMPLETED)

The robust pattern for a medicine reminder: schedule every dose locally with AlarmManager (it is the source of truth for "8 PM"), and use FCM for server-side events (a caregiver added a new prescription → push "new schedule available" → app re-syncs and re-schedules locally). Either channel alone has a failure mode; together they cover each other's gaps. Document this dual design in your report — it is exactly the kind of engineering judgement evaluators reward.

On reboot, all alarms are lost — register a BroadcastReceiver for BOOT_COMPLETED that reads the schedule from Room and re-registers the alarms. Without this, every reminder app silently stops working after a restart, and nobody notices until a dose is missed.

Sending from your own backend: the FCM HTTP v1 API

The console is fine for testing, but your server sends notifications programmatically. The current API is FCM HTTP v1 (the legacy https://fcm.googleapis.com/fcm/send endpoint with a server key still works but is deprecated — build on v1 for anything new):

curl -X POST https://fcm.googleapis.com/v1/projects/YOUR_PROJECT_ID/messages:send \
  -H "Authorization: Bearer YOUR_OAUTH2_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "message": {
      "token": "DEVICE_FCM_TOKEN",
      "data": {
        "title": "Dose due",
        "body": "Take your 8 PM tablet",
        "doseId": "42"
      },
      "android": {
        "priority": "high"
      }
    }
  }'

Two things to note: v1 uses a short-lived OAuth2 access token (from a service-account key) instead of a static server key — more secure, slightly more setup on the backend. And "android": {"priority": "high"} is how the server requests high-priority delivery through Doze mode; the app-side channel importance then decides how intrusive the notification actually is. Priority and channel importance are independent knobs — a high-priority message to a LOW channel still arrives promptly but silently.

Your backend also needs the token registry: an endpoint where the app POSTs its token on onNewToken and on login, keyed by user. Without it, personal notifications have nowhere to address.

Rich notifications: styles and grouping

Plain title-and-text covers most student needs, but two patterns elevate a reminder app:

BigTextStyle for long content — a full prescription instruction or a multi-line mess menu:

val style = NotificationCompat.BigTextStyle()
    .bigText("Take 1 tablet of Metformin 500mg after dinner. Do not skip.")
    .setBigContentTitle("8 PM dose — details")

NotificationCompat.Builder(this, channelId)
    .setSmallIcon(R.drawable.ic_notification)
    .setContentTitle("Dose due")
    .setContentText("Take your 8 PM tablet — expand for details")
    .setStyle(style)
    // ... rest as before

Grouping for bursts — five bus-arrival alerts should collapse, not stack:

// each alert uses the same group key...
.setGroup("bus-alerts")
// ...plus one summary notification with:
.setGroup("bus-alerts")
.setGroupSummary(true)

Without grouping, a busy route spams the shade and the user disables your channel — the notification equivalent of an unsubscribe.

Deep linking: opening the right screen

Tapping "Dose due" should open the dose detail, not the app's home screen. Build the PendingIntent with the destination encoded:

val deepLink = Uri.parse("myapp://dose/" + doseId)
val intent = Intent(Intent.ACTION_VIEW, deepLink).apply {
    flags = Intent.FLAG_ACTIVITY_CLEAR_TOP
}
// PendingIntent as before, with FLAG_IMMUTABLE

Handle the deep link in the target Activity (or a navigation graph with deep-link support) by reading the doseId and loading that record. Test the tap path as carefully as the delivery path — a notification that opens the wrong screen teaches users to ignore the next one. A doctor appointment booking app uses this for "your appointment is confirmed" → opens the booking detail directly.

Notification permission on Android 13+

Since API 33, posting notifications requires the runtime POST_NOTIFICATIONS permission — it is not granted by default:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
    when {
        checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) ==
            PackageManager.PERMISSION_GRANTED -> { /* proceed */ }
        shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) -> {
            // explain why, then ask
            showRationaleDialog {
                requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
            }
        }
        else -> {
            requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
        }
    }
}

Ask at the moment the user enables reminders — not on first launch. A permission prompt with no context gets denied; the same prompt after tapping "enable dose reminders" gets accepted. If denied, deep-link the user to the app's system settings page rather than re-prompting — repeated prompts train users to tap "don't ask again." Also handle the permanently-denied path gracefully: the app should still function — reminders visible in-app, badges on the launcher icon where supported — just without heads-up alerts. A reminder app that refuses to work without notification permission has confused a feature with a requirement.

FCM, data usage, and battery: the honest numbers

Students sometimes worry that push infrastructure drains the battery. The reality is reassuring: FCM maintains a single shared connection per device, multiplexed across all apps — your app adds no persistent socket of its own. A handful of notifications a day costs negligible battery and a few kilobytes. What does cost battery is what your handler does on receipt: a data message that triggers a full database re-sync and image downloads on every ping will show up in battery stats. Keep handlers lean — update the one record the message concerns, defer heavy work to a constrained WorkManager job, and the push layer stays effectively free.

Testing: the Firebase console is enough to start

  1. In the Firebase console → Cloud Messaging, compose a test notification.
  2. Target a single device by pasting its FCM token (log it from onNewToken during development).
  3. Test four states: foreground (your onMessageReceived runs), background (tray shows it), killed (swiped away — still arrives via the service), and airplane-mode-then-reconnect (Firebase queues while offline, delivers on reconnect).

The error-message field guide

Error / symptom Cause Fix
Default FirebaseApp is not initialized google-services plugin missing, or google-services.json in the wrong directory Verify plugin applied in app module and the JSON sits at app/google-services.json
onMessageReceived never fires in background Sent a notification payload and expected code to run Notification payloads bypass your code in background — use a data payload
Notifications arrive but make no sound / no heads-up Channel importance too low, or user changed it in settings Use HIGH for time-sensitive channels; remember settings are sticky after first creation
Token is null / empty Called before Firebase initialised, or Play Services missing Wait for the async token task; check Play Services on the device
MismatchedSenderId (server side) Server key belongs to a different Firebase project than the app's google-services.json Regenerate alignment: one Firebase project, matching JSON and server key
Notifications stop after a few hours on Xiaomi/Oppo/Vivo OEM battery saver killing the app User must set battery optimisation to "No restrictions" for your app; add a local scheduler fallback
IllegalArgumentException: Targeting S+ requires FLAG_IMMUTABLE... PendingIntent without mutability flag on Android 12+ Add PendingIntent.FLAG_IMMUTABLE

Security and privacy notes

  • The server key is a secret. It lives on your server, never in the app. Anyone with the server key can send notifications to all your users.
  • Do not put sensitive data in notification payloads. Ticker text can appear on lock screens. Send IDs in the payload and let the app fetch details over your authenticated API.
  • Topics are public by design. Treat topic names as visible and never route personal information through them.
  • Log tokens only in debug builds. A device token printed to Logcat in a release build is a personal identifier you cannot revoke cheaply — gate every token log behind BuildConfig.DEBUG.

Checklist

  • google-services.json at app/google-services.json; package name matches applicationId.
  • Firebase BoM used for dependency versions; firebase-messaging included.
  • FirebaseMessagingService declared in the manifest with the MESSAGING_EVENT filter.
  • Notification channels created before posting; channel importance mapped to message urgency (HIGH for dose alerts, lower for informational).
  • setSmallIcon set with a proper notification icon; FLAG_IMMUTABLE on the PendingIntent.
  • onNewToken forwards rotated tokens to the server; logout clears the token mapping.
  • Data payloads used wherever the app must run logic; notification payloads only for pure announcements.
  • POST_NOTIFICATIONS requested at the right moment on API 33+; rationale shown before the system prompt.
  • Critical reminders have a local AlarmManager/WorkManager fallback, not FCM alone; alarms re-registered on BOOT_COMPLETED.
  • Tested in foreground, background, killed, and offline-then-reconnect states — on a real device, including an aggressive-OEM device if the audience uses one.
  • Backend token registry stores one token per installation, keyed by user, and clears the mapping on logout so the next user of the device never receives the previous user's notifications.

Where this fits your project

FCM is the server-to-device half of a connected app: Retrofit handles device-to-server calls, FCM handles the reverse direction. A medicine reminder pairs FCM dose alerts with local scheduling fallback; a water intake reminder uses recurring nudges; a hostel mess menu app broadcasts menu updates per hostel block via topics. Store the notification-driven state changes in a local Room database so the app stays consistent offline, and structure it all in an MVVM layout with the repository owning data access. More Android builds live in the Android branch hub.

More project guides

More in Android