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 directory — app/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:
setSmallIconis 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_IMMUTABLEis required on Android 12+ (API 31). Omitting it crashes withIllegalArgumentException: 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.
createNotificationChannelis 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.
onNewTokenfires 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/WorkManagerscheduled 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
- In the Firebase console → Cloud Messaging, compose a test notification.
- Target a single device by pasting its FCM token (log it from
onNewTokenduring development). - Test four states: foreground (your
onMessageReceivedruns), 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.jsonatapp/google-services.json; package name matchesapplicationId. - Firebase BoM used for dependency versions;
firebase-messagingincluded. -
FirebaseMessagingServicedeclared in the manifest with theMESSAGING_EVENTfilter. - Notification channels created before posting; channel importance mapped to message urgency (HIGH for dose alerts, lower for informational).
-
setSmallIconset with a proper notification icon;FLAG_IMMUTABLEon thePendingIntent. -
onNewTokenforwards 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_NOTIFICATIONSrequested 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 onBOOT_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.