Firebase Cloud Messaging (FCM) is the standard push notification service for Android. Getting notifications right means handling all delivery scenarios: app in foreground, background, and terminated state.
FCM Message Types
| Type | Delivered | Wakes app | onMessageReceived called |
|---|---|---|---|
| Notification message | By system if app backgrounded | No | Only if app is foreground |
| Data message | Always to onMessageReceived | Yes (high-priority) | Yes |
| Notification + Data | System handles notification; data in getExtras() | No | Foreground only |
For apps that need full control, use data messages only and build your own notification.
Setup
// build.gradle.kts
implementation("com.google.firebase:firebase-messaging-ktx:23.4.1")
class AppMessagingService : FirebaseMessagingService() {
// Called when a new FCM token is generated (first run or token refresh)
override fun onNewToken(token: String) {
// Send this token to your server so it can send push notifications
CoroutineScope(Dispatchers.IO + SupervisorJob()).launch {
serverApi.updateDeviceToken(token)
}
}
// Called for ALL data messages; called for notification messages only when app is foreground
override fun onMessageReceived(message: RemoteMessage) {
val notificationType = message.data["type"] ?: return
when (notificationType) {
"new_message" -> handleNewMessage(message)
"like" -> handleLikeNotification(message)
"comment" -> handleCommentNotification(message)
}
}
}
Notification Channels (Required for API 26+)
fun createNotificationChannels(context: Context) {
val notificationManager = context.getSystemService(NotificationManager::class.java)
val channels = listOf(
NotificationChannel(
"messages",
"Messages",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "New message notifications"
enableVibration(true)
setShowBadge(true)
},
NotificationChannel(
"social",
"Likes & Comments",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "Likes, comments, and follows"
},
NotificationChannel(
"system",
"System",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Account and security notifications"
}
)
channels.forEach { notificationManager.createNotificationChannel(it) }
}
Building and Showing Notifications
private fun handleNewMessage(message: RemoteMessage) {
val senderId = message.data["sender_id"] ?: return
val senderName = message.data["sender_name"] ?: "Someone"
val body = message.data["body"] ?: ""
val conversationId = message.data["conversation_id"] ?: return
// Deep link intent — opens the conversation when tapped
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra("destination", "conversation")
putExtra("conversation_id", conversationId)
}
val pendingIntent = PendingIntent.getActivity(
this, conversationId.hashCode(), intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(this, "messages")
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(senderName)
.setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setContentIntent(pendingIntent)
.setAutoCancel(true) // dismiss on tap
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setShortcutId(senderId) // link to conversation shortcut for People API
.build()
NotificationManagerCompat.from(this)
.notify(conversationId.hashCode(), notification)
}
Notification Permission (API 33+)
class MainActivity : AppCompatActivity() {
private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
if (!isGranted) showRationale()
}
fun requestNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
when {
ContextCompat.checkSelfPermission(
this, Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED -> { /* already granted */ }
shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) ->
showRationale()
else ->
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
}
Handling Deep Link from Notification
// In MainActivity.onCreate and onNewIntent:
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleNotificationDeepLink(intent)
}
private fun handleNotificationDeepLink(intent: Intent) {
when (intent.getStringExtra("destination")) {
"conversation" -> {
val conversationId = intent.getStringExtra("conversation_id") ?: return
navController.navigate("conversation/$conversationId")
}
"article" -> {
val articleId = intent.getStringExtra("article_id") ?: return
navController.navigate("article/$articleId")
}
}
}
Key Takeaways
| Concept | Rule |
|---|---|
| Data messages | Use for push; always triggers onMessageReceived regardless of app state |
| Notification channels | Create all channels in Application.onCreate; never change channel ID after release |
FLAG_IMMUTABLE | Required for PendingIntent on API 31+ |
| POST_NOTIFICATIONS | Request at runtime for API 33+; explain why before prompting |
| FCM token refresh | Always listen for onNewToken — tokens rotate; sync to server |
| Notification ID | Use a meaningful ID (e.g., conversationId.hashCode) so updates replace, not stack |