← Back to list

Push Notifications in Android — The Complete A→Z Guide: Firebase Cloud Messaging (FCM)…

Your banking app sends a push notification: “Transfer of $500 to Ahmed completed.” The user taps it, the app opens directly to the…

Ramadan Sayed · 2026-04-23 17:49 · 3 claps · 13.2 min read paywalled
#push-notification #firebasecloudmessaging #fcm #fcm-push-notification
Open on Medium ↗
Wiki topics: ECO · Economy · General

Push Notifications in Android — The Complete A→Z Guide: Firebase Cloud Messaging (FCM), Notification Channels, Runtime Permission (Android 13+), FirebaseMessagingService, Data vs Notification Messages, Topics, Token Management, Rich Notifications with Images and Actions, Deep Link Handling, Foreground Notifications in Compose, Local Notifications, Scheduled Notifications, and Every Production Pattern for 2026

Your banking app sends a push notification: “Transfer of $500 to Ahmed completed.” The user taps it, the app opens directly to the transaction detail screen. The notification had the bank logo, a summary, and two action buttons: “View Receipt” and “Share.”

This is what users expect from every app in 2026. But building it involves a surprising number of moving parts: FCM registration, token management (tokens expire and rotate), notification channels (required since Android 8), runtime permission (required since Android 13), handling messages in foreground vs background vs killed states, deep links, custom layouts, and the differences between notification messages and data messages.

This article covers everything from zero — FCM setup, token management, message handling in all app states, notification channels, Android 13+ permission flow in Compose, rich notifications, deep linking, local notifications, scheduled notifications, topics, and the complete production patterns for a banking app.

Part 1: Understanding Push Notifications on Android

How FCM Works

YOUR BACKEND SERVER
      │
      │ POST https://fcm.googleapis.com/v1/projects/{id}/messages:send
      │ { "message": { "token": "device_fcm_token", "notification": {...} } }
      │
      ▼
FIREBASE CLOUD MESSAGING (Google's servers)
      │
      │ Routes message to the correct device
      │ Handles offline queuing, batching, throttling
      │
      ▼
DEVICE (Google Play Services)
      │
      │ Wakes your app (or starts it if killed)
      │ Calls FirebaseMessagingService.onMessageReceived()
      │
      ▼
YOUR APP
      │
      ├── Foreground: You build & show notification manually
      ├── Background: System auto-shows notification from payload
      └── Killed:     System auto-shows notification from payload

Notification vs Data Messages

TYPE 1: NOTIFICATION MESSAGE
  Server sends: { "notification": { "title": "...", "body": "..." } }

  App in FOREGROUND:  onMessageReceived() called — YOU handle it
  App in BACKGROUND:  System auto-shows notification (you DON'T handle it)
  App KILLED:         System auto-shows notification (you DON'T handle it)

  Use for: Simple alerts where you trust FCM's default display

TYPE 2: DATA MESSAGE
  Server sends: { "data": { "type": "transfer", "id": "123", ... } }

  App in FOREGROUND:  onMessageReceived() called - YOU handle it
  App in BACKGROUND:  onMessageReceived() called - YOU handle it ✅
  App KILLED:         onMessageReceived() called - YOU handle it ✅

  Use for: Full control over notification display in ALL states

  ⭐ RECOMMENDED for production apps - always use DATA messages
TYPE 3: BOTH (notification + data)
  Server sends: { "notification": {...}, "data": {...} }

  App in FOREGROUND:  onMessageReceived() - both available
  App in BACKGROUND:  System shows notification, data in intent extras
  App KILLED:         System shows notification, data in intent extras

  Use for: Simple display + click handling with extras

Dependencies

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

// build.gradle.kts (app)
plugins {
    id("com.google.gms.google-services")
}
dependencies {
    // Firebase BoM - manages all Firebase versions
    implementation(platform("com.google.firebase:firebase-bom:33.7.0"))

    // Cloud Messaging
    implementation("com.google.firebase:firebase-messaging-ktx")

    // Analytics (required for FCM delivery reports)
    implementation("com.google.firebase:firebase-analytics-ktx")
}
<!-- AndroidManifest.xml -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

Part 2: Setting Up Notification Channels (Required Android 8+)

Every notification must belong to a channel. Users can independently control each channel in system Settings.

// data/notification/NotificationChannelManager.kt

object NotificationChannelManager {
    fun createAllChannels(context: Context) {
        val manager = context.getSystemService(NotificationManager::class.java)
        val channels = listOf(
            // HIGH importance - heads-up notification, sound + vibration
            NotificationChannel(
                "transactions",
                "Transactions",
                NotificationManager.IMPORTANCE_HIGH
            ).apply {
                description = "Transfer completions, incoming payments, failed transactions"
                enableLights(true)
                lightColor = Color.GREEN
                enableVibration(true)
                vibrationPattern = longArrayOf(0, 250, 100, 250)
                setShowBadge(true)
            },
            // HIGH - security alerts always break through
            NotificationChannel(
                "security",
                "Security Alerts",
                NotificationManager.IMPORTANCE_HIGH
            ).apply {
                description = "Login attempts, suspicious activity, password changes"
                enableLights(true)
                lightColor = Color.RED
                enableVibration(true)
                setBypassDnd(true) // Break through Do Not Disturb
            },
            // DEFAULT - visible in shade, sound
            NotificationChannel(
                "promotions",
                "Offers & Promotions",
                NotificationManager.IMPORTANCE_DEFAULT
            ).apply {
                description = "Special offers, rewards, and cashback"
                setShowBadge(true)
            },
            // LOW - no sound, appears in shade
            NotificationChannel(
                "general",
                "General",
                NotificationManager.IMPORTANCE_LOW
            ).apply {
                description = "App updates, tips, and general information"
            },
            // MIN - silent, only in pull-down shade
            NotificationChannel(
                "sync",
                "Background Sync",
                NotificationManager.IMPORTANCE_MIN
            ).apply {
                description = "Data synchronization progress"
                setShowBadge(false)
            }
        )
        manager.createNotificationChannels(channels)
    }
    // Channel groups (optional - organize channels in Settings)
    fun createChannelGroups(context: Context) {
        val manager = context.getSystemService(NotificationManager::class.java)
        manager.createNotificationChannelGroups(listOf(
            NotificationChannelGroup("banking", "Banking"),
            NotificationChannelGroup("marketing", "Marketing")
        ))
    }
}
// Call in Application.onCreate():
class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()
        NotificationChannelManager.createAllChannels(this)
    }
}

Part 3: Android 13+ Notification Permission in Compose

Starting with Android 13 (API 33), apps must request POST_NOTIFICATIONS permission at runtime:

@Composable
fun NotificationPermissionHandler(
    onPermissionResult: (Boolean) -> Unit = {}
) {
    val context = LocalContext.current

// Check if we need to ask (Android 13+ only)
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
        // Pre-Android 13 - permission auto-granted
        return
    }
    var showRationale by remember { mutableStateOf(false) }
    val permissionLauncher = rememberLauncherForActivityResult(
        ActivityResultContracts.RequestPermission()
    ) { isGranted ->
        onPermissionResult(isGranted)
        if (!isGranted) {
            showRationale = true
        }
    }
    // Check current permission state
    val hasPermission = remember {
        ContextCompat.checkSelfPermission(
            context,
            Manifest.permission.POST_NOTIFICATIONS
        ) == PackageManager.PERMISSION_GRANTED
    }
    // Request permission on first launch
    LaunchedEffect(Unit) {
        if (!hasPermission) {
            permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
        }
    }
    // Rationale dialog - explain WHY notifications matter
    if (showRationale) {
        AlertDialog(
            onDismissRequest = { showRationale = false },
            icon = { Icon(Icons.Default.Notifications, null) },
            title = { Text("Enable Notifications") },
            text = {
                Text(
                    "Get instant alerts for transfers, payments, and security events. " +
                    "You can customize which notifications you receive in Settings."
                )
            },
            confirmButton = {
                Button(onClick = {
                    showRationale = false
                    // Open app notification settings
                    val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
                        putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
                    }
                    context.startActivity(intent)
                }) {
                    Text("Open Settings")
                }
            },
            dismissButton = {
                TextButton(onClick = { showRationale = false }) {
                    Text("Not now")
                }
            }
        )
    }
}
// Use in your main screen:
@Composable
fun MainScreen() {
    NotificationPermissionHandler(
        onPermissionResult = { granted ->
            if (granted) {
                // Permission granted - FCM will deliver notifications
            }
        }
    )
    // ... rest of your UI
}

Part 4: FirebaseMessagingService (Handling Messages)

// data/notification/MyFirebaseMessagingService.kt

class MyFirebaseMessagingService : FirebaseMessagingService() {
    // ═══════════ TOKEN REFRESH ═══════════
    override fun onNewToken(token: String) {
        super.onNewToken(token)
        // Token changed - send to your backend
        // This happens on: first app install, app data cleared, app restored on new device
        Log.d("FCM", "New token: $token")
        CoroutineScope(Dispatchers.IO).launch {
            try {
                // Send token to your backend
                val api = RetrofitClient.create(AuthApi::class.java)
                api.updateFcmToken(UpdateTokenRequest(token))
            } catch (e: Exception) {
                Log.e("FCM", "Failed to update token", e)
                // Store locally and retry later
                TokenStorage(applicationContext).savePendingToken(token)
            }
        }
    }
    // ═══════════ MESSAGE RECEIVED ═══════════
    override fun onMessageReceived(message: RemoteMessage) {
        super.onMessageReceived(message)
        Log.d("FCM", "From: ${message.from}")
        Log.d("FCM", "Data: ${message.data}")
        // DATA MESSAGE (recommended - you control everything)
        if (message.data.isNotEmpty()) {
            handleDataMessage(message.data)
            return
        }
        // NOTIFICATION MESSAGE (system handles in background)
        message.notification?.let { notification ->
            showSimpleNotification(
                title = notification.title ?: "MyBank",
                body = notification.body ?: "",
                channelId = notification.channelId ?: "general",
                imageUrl = notification.imageUrl?.toString()
            )
        }
    }
    private fun handleDataMessage(data: Map<String, String>) {
        val type = data["type"] ?: return
        when (type) {
            "transfer_completed" -> {
                val transferId = data["transfer_id"] ?: return
                val amount = data["amount"] ?: ""
                val recipientName = data["recipient_name"] ?: ""
                val currency = data["currency"] ?: "USD"
                showTransferNotification(
                    transferId = transferId,
                    title = "Transfer Completed",
                    body = "$currency $amount sent to $recipientName",
                    amount = amount,
                    recipientName = recipientName
                )
            }
            "incoming_payment" -> {
                val amount = data["amount"] ?: ""
                val senderName = data["sender_name"] ?: ""
                showSimpleNotification(
                    title = "Money Received!",
                    body = "$$amount from $senderName",
                    channelId = "transactions"
                )
            }
            "security_alert" -> {
                val alertMessage = data["message"] ?: "Security alert"
                val alertType = data["alert_type"] ?: "unknown"
                showSecurityNotification(alertMessage, alertType)
            }
            "promotion" -> {
                val title = data["title"] ?: "Special Offer"
                val body = data["body"] ?: ""
                val imageUrl = data["image_url"]
                showPromotionNotification(title, body, imageUrl)
            }
            "silent_sync" -> {
                // No notification - just trigger a background sync
                CoroutineScope(Dispatchers.IO).launch {
                    SyncManager(applicationContext).triggerImmediateSync()
                }
            }
        }
    }
    // ═══════════ NOTIFICATION BUILDERS ═══════════
    private fun showSimpleNotification(
        title: String,
        body: String,
        channelId: String = "general",
        imageUrl: String? = null
    ) {
        val intent = Intent(this, MainActivity::class.java).apply {
            flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
        }
        val pendingIntent = PendingIntent.getActivity(
            this, 0, intent,
            PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
        )
        val builder = NotificationCompat.Builder(this, channelId)
            .setSmallIcon(R.drawable.ic_notification)
            .setContentTitle(title)
            .setContentText(body)
            .setAutoCancel(true)
            .setContentIntent(pendingIntent)
            .setPriority(NotificationCompat.PRIORITY_HIGH)
            .setDefaults(NotificationCompat.DEFAULT_ALL)
        // Load image if provided
        imageUrl?.let { url ->
            try {
                val bitmap = Glide.with(this)
                    .asBitmap()
                    .load(url)
                    .submit()
                    .get(10, TimeUnit.SECONDS)
                builder.setLargeIcon(bitmap)
                builder.setStyle(
                    NotificationCompat.BigPictureStyle()
                        .bigPicture(bitmap)
                        .bigLargeIcon(null as Bitmap?)
                )
            } catch (e: Exception) {
                Log.e("FCM", "Failed to load notification image", e)
            }
        }
        val manager = getSystemService(NotificationManager::class.java)
        manager.notify(System.currentTimeMillis().toInt(), builder.build())
    }
    private fun showTransferNotification(
        transferId: String,
        title: String,
        body: String,
        amount: String,
        recipientName: String
    ) {
        // Deep link to transaction detail
        val deepLinkIntent = Intent(this, MainActivity::class.java).apply {
            flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
            putExtra("deep_link", "mybank://transfers/$transferId")
            data = Uri.parse("mybank://transfers/$transferId")
        }
        val pendingIntent = PendingIntent.getActivity(
            this,
            transferId.hashCode(),
            deepLinkIntent,
            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
        )
        // Action: View Receipt
        val receiptIntent = Intent(this, MainActivity::class.java).apply {
            putExtra("deep_link", "mybank://transfers/$transferId/receipt")
            data = Uri.parse("mybank://transfers/$transferId/receipt")
        }
        val receiptPendingIntent = PendingIntent.getActivity(
            this, (transferId + "_receipt").hashCode(),
            receiptIntent,
            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
        )
        val notification = NotificationCompat.Builder(this, "transactions")
            .setSmallIcon(R.drawable.ic_transfer)
            .setContentTitle(title)
            .setContentText(body)
            .setStyle(NotificationCompat.BigTextStyle().bigText(
                "$body\n\nTap to view transaction details."
            ))
            .setAutoCancel(true)
            .setContentIntent(pendingIntent)
            .setPriority(NotificationCompat.PRIORITY_HIGH)
            .setCategory(NotificationCompat.CATEGORY_MESSAGE)
            .setColor(ContextCompat.getColor(this, R.color.primary))
            .addAction(
                R.drawable.ic_receipt, "View Receipt", receiptPendingIntent
            )
            .setGroup("transfers")
            .build()
        val manager = getSystemService(NotificationManager::class.java)
        manager.notify(transferId.hashCode(), notification)
    }
    private fun showSecurityNotification(message: String, alertType: String) {
        val builder = NotificationCompat.Builder(this, "security")
            .setSmallIcon(R.drawable.ic_security)
            .setContentTitle("Security Alert")
            .setContentText(message)
            .setStyle(NotificationCompat.BigTextStyle().bigText(message))
            .setPriority(NotificationCompat.PRIORITY_MAX)
            .setCategory(NotificationCompat.CATEGORY_ALARM)
            .setColor(android.graphics.Color.RED)
            .setAutoCancel(true)
            .setDefaults(NotificationCompat.DEFAULT_ALL)
        val manager = getSystemService(NotificationManager::class.java)
        manager.notify("security_$alertType".hashCode(), builder.build())
    }
    private fun showPromotionNotification(title: String, body: String, imageUrl: String?) {
        val builder = NotificationCompat.Builder(this, "promotions")
            .setSmallIcon(R.drawable.ic_offer)
            .setContentTitle(title)
            .setContentText(body)
            .setPriority(NotificationCompat.PRIORITY_DEFAULT)
            .setAutoCancel(true)
        imageUrl?.let { url ->
            try {
                val bitmap = Glide.with(this).asBitmap().load(url).submit().get(10, TimeUnit.SECONDS)
                builder.setStyle(NotificationCompat.BigPictureStyle().bigPicture(bitmap))
            } catch (_: Exception) {}
        }
        val manager = getSystemService(NotificationManager::class.java)
        manager.notify(title.hashCode(), builder.build())
    }
}
<!-- AndroidManifest.xml -->
<service
    android:name=".data.notification.MyFirebaseMessagingService"
    android:exported="false">
    <intent-filter>
        <action android:name="com.google.firebase.MESSAGING_EVENT" />
    </intent-filter>
</service>

<!-- Default notification channel (for background notification messages) -->
<meta-data
    android:name="com.google.firebase.messaging.default_notification_channel_id"
    android:value="general" />
<!-- Default notification icon -->
<meta-data
    android:name="com.google.firebase.messaging.default_notification_icon"
    android:resource="@drawable/ic_notification" />
<!-- Default notification color -->
<meta-data
    android:name="com.google.firebase.messaging.default_notification_color"
    android:resource="@color/primary" />

Part 5: Token Management

FCM tokens change — on app reinstall, data clear, new device, or periodically for privacy. Your backend needs the latest token.

// data/notification/TokenManager.kt

class FcmTokenManager(
    private val context: Context,
    private val api: AuthApi,
    private val preferences: SharedPreferences
) {
    companion object {
        private const val KEY_FCM_TOKEN = "fcm_token"
        private const val KEY_TOKEN_SENT = "fcm_token_sent_to_server"
        private const val KEY_TOKEN_TIMESTAMP = "fcm_token_timestamp"
    }
    /**
     * Get current FCM token - call on app startup and after login
     */
    suspend fun ensureTokenRegistered() {
        try {
            val token = Firebase.messaging.token.await()
            val savedToken = preferences.getString(KEY_FCM_TOKEN, null)
            val isSent = preferences.getBoolean(KEY_TOKEN_SENT, false)
            if (token != savedToken || !isSent) {
                // Token changed or wasn't sent to server
                sendTokenToServer(token)
            }
        } catch (e: Exception) {
            Log.e("FCM", "Failed to get FCM token", e)
        }
    }
    /**
     * Send token to backend
     */
    suspend fun sendTokenToServer(token: String) {
        try {
            api.updateFcmToken(UpdateTokenRequest(
                token = token,
                platform = "android",
                appVersion = BuildConfig.VERSION_NAME,
                deviceModel = Build.MODEL,
                osVersion = Build.VERSION.SDK_INT.toString()
            ))
            preferences.edit()
                .putString(KEY_FCM_TOKEN, token)
                .putBoolean(KEY_TOKEN_SENT, true)
                .putLong(KEY_TOKEN_TIMESTAMP, System.currentTimeMillis())
                .apply()
            Log.d("FCM", "Token registered with server")
        } catch (e: Exception) {
            Log.e("FCM", "Failed to send token to server", e)
            // Save for retry
            preferences.edit()
                .putString(KEY_FCM_TOKEN, token)
                .putBoolean(KEY_TOKEN_SENT, false)
                .apply()
        }
    }
    /**
     * Remove token on logout - stops notifications for this user
     */
    suspend fun unregisterToken() {
        try {
            val token = preferences.getString(KEY_FCM_TOKEN, null)
            if (token != null) {
                api.removeFcmToken(token)
            }
            Firebase.messaging.deleteToken().await()
            preferences.edit()
                .remove(KEY_FCM_TOKEN)
                .remove(KEY_TOKEN_SENT)
                .apply()
        } catch (e: Exception) {
            Log.e("FCM", "Failed to unregister token", e)
        }
    }
    /**
     * Retry sending pending token (call on app startup)
     */
    suspend fun retryPendingToken() {
        val token = preferences.getString(KEY_FCM_TOKEN, null) ?: return
        val isSent = preferences.getBoolean(KEY_TOKEN_SENT, false)
        if (!isSent) {
            sendTokenToServer(token)
        }
    }
}

Part 6: Topics (Subscribe User Groups)

// Subscribe to topic-based notifications
object TopicManager {

fun subscribeToUserTopics(userId: String) {
        // All users get general notifications
        Firebase.messaging.subscribeToTopic("all_users")
        // User-specific topic (alternative to token-based targeting)
        Firebase.messaging.subscribeToTopic("user_$userId")
        // Feature-based subscriptions
        Firebase.messaging.subscribeToTopic("promotions")
        Firebase.messaging.subscribeToTopic("market_alerts")
    }
    fun unsubscribeFromPromotions() {
        Firebase.messaging.unsubscribeFromTopic("promotions")
    }
    fun unsubscribeFromAll() {
        Firebase.messaging.unsubscribeFromTopic("all_users")
        Firebase.messaging.unsubscribeFromTopic("promotions")
        Firebase.messaging.unsubscribeFromTopic("market_alerts")
    }
}
// Notification preferences screen
@Composable
fun NotificationPreferencesScreen() {
    var transactionsEnabled by remember { mutableStateOf(true) }
    var promotionsEnabled by remember { mutableStateOf(true) }
    var securityEnabled by remember { mutableStateOf(true) }
    var marketAlertsEnabled by remember { mutableStateOf(false) }
    Column(modifier = Modifier.padding(16.dp)) {
        Text("Notification Preferences", style = MaterialTheme.typography.headlineSmall)
        Spacer(Modifier.height(16.dp))
        SwitchPreference(
            title = "Transactions",
            subtitle = "Transfers, payments, and receipts",
            checked = transactionsEnabled,
            onCheckedChange = { transactionsEnabled = it },
            icon = Icons.Default.SwapHoriz
        )
        SwitchPreference(
            title = "Security Alerts",
            subtitle = "Login attempts and suspicious activity",
            checked = securityEnabled,
            onCheckedChange = { securityEnabled = it },
            enabled = false, // Can't disable security alerts
            icon = Icons.Default.Security
        )
        SwitchPreference(
            title = "Promotions",
            subtitle = "Special offers and cashback",
            checked = promotionsEnabled,
            onCheckedChange = {
                promotionsEnabled = it
                if (it) Firebase.messaging.subscribeToTopic("promotions")
                else Firebase.messaging.unsubscribeFromTopic("promotions")
            },
            icon = Icons.Default.LocalOffer
        )
        SwitchPreference(
            title = "Market Alerts",
            subtitle = "Currency rates and market updates",
            checked = marketAlertsEnabled,
            onCheckedChange = {
                marketAlertsEnabled = it
                if (it) Firebase.messaging.subscribeToTopic("market_alerts")
                else Firebase.messaging.unsubscribeFromTopic("market_alerts")
            },
            icon = Icons.Default.TrendingUp
        )
    }
}
@Composable
fun SwitchPreference(
    title: String,
    subtitle: String,
    checked: Boolean,
    onCheckedChange: (Boolean) -> Unit,
    icon: ImageVector,
    enabled: Boolean = true
) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(vertical = 12.dp),
        verticalAlignment = Alignment.CenterVertically
    ) {
        Icon(icon, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(28.dp))
        Spacer(Modifier.width(16.dp))
        Column(modifier = Modifier.weight(1f)) {
            Text(title, style = MaterialTheme.typography.bodyLarge)
            Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
        }
        Switch(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled)
    }
}

Part 7: Foreground Notification Handling in Compose

When the app is in the foreground, you might want to show an in-app banner instead of a system notification:

// presentation/notification/InAppNotificationHandler.kt

@Composable
fun InAppNotificationBanner(
    modifier: Modifier = Modifier
) {
    var notification by remember { mutableStateOf<InAppNotification?>(null) }
    val scope = rememberCoroutineScope()
    // Listen for foreground messages
    DisposableEffect(Unit) {
        val listener = object : ForegroundMessageListener {
            override fun onMessage(title: String, body: String, data: Map<String, String>) {
                notification = InAppNotification(title, body, data)
                scope.launch {
                    delay(5000) // Auto-dismiss after 5 seconds
                    notification = null
                }
            }
        }
        ForegroundNotificationManager.addListener(listener)
        onDispose { ForegroundNotificationManager.removeListener(listener) }
    }
    AnimatedVisibility(
        visible = notification != null,
        enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(),
        exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(),
        modifier = modifier
    ) {
        notification?.let { notif ->
            Card(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(horizontal = 16.dp, vertical = 8.dp)
                    .clickable {
                        // Handle tap - navigate to deep link
                        val deepLink = notif.data["deep_link"]
                        deepLink?.let { /* navigate */ }
                        notification = null
                    },
                colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
                elevation = CardDefaults.cardElevation(8.dp)
            ) {
                Row(
                    modifier = Modifier.padding(16.dp),
                    verticalAlignment = Alignment.CenterVertically
                ) {
                    Icon(
                        Icons.Default.Notifications,
                        null,
                        tint = MaterialTheme.colorScheme.primary
                    )
                    Spacer(Modifier.width(12.dp))
                    Column(modifier = Modifier.weight(1f)) {
                        Text(
                            notif.title,
                            style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold)
                        )
                        Text(
                            notif.body,
                            style = MaterialTheme.typography.bodySmall,
                            maxLines = 2,
                            overflow = TextOverflow.Ellipsis
                        )
                    }
                    IconButton(onClick = { notification = null }) {
                        Icon(Icons.Default.Close, "Dismiss", modifier = Modifier.size(18.dp))
                    }
                }
            }
        }
    }
}
data class InAppNotification(val title: String, val body: String, val data: Map<String, String>)
// Singleton to bridge Service → Compose
object ForegroundNotificationManager {
    private val listeners = mutableListOf<ForegroundMessageListener>()
    fun addListener(listener: ForegroundMessageListener) { listeners.add(listener) }
    fun removeListener(listener: ForegroundMessageListener) { listeners.remove(listener) }
    fun notifyForeground(title: String, body: String, data: Map<String, String>) {
        listeners.forEach { it.onMessage(title, body, data) }
    }
}
interface ForegroundMessageListener {
    fun onMessage(title: String, body: String, data: Map<String, String>)
}

Usage in the service:

// In FirebaseMessagingService.onMessageReceived:
if (isAppInForeground()) {
    // Show in-app banner instead of system notification
    ForegroundNotificationManager.notifyForeground(title, body, data)
} else {
    // Show system notification
    showSystemNotification(title, body, data)
}

private fun isAppInForeground(): Boolean {
    val appProcess = (getSystemService(ACTIVITY_SERVICE) as ActivityManager)
        .runningAppProcesses?.find { it.pid == android.os.Process.myPid() }
    return appProcess?.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND
}

Part 8: Deep Link Handling from Notifications

// In MainActivity — handle notification deep links
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

setContent {
            val navController = rememberNavController()
            // Handle deep link from notification tap
            LaunchedEffect(Unit) {
                handleNotificationDeepLink(intent, navController)
            }
            MyApp(navController = navController)
        }
    }
    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        // Handle when app is already open and notification is tapped
        setIntent(intent)
    }
    private fun handleNotificationDeepLink(intent: Intent?, navController: NavController) {
        val deepLink = intent?.getStringExtra("deep_link")
            ?: intent?.data?.toString()
            ?: return
        val uri = Uri.parse(deepLink)
        when {
            deepLink.contains("transfers/") -> {
                val transferId = uri.lastPathSegment ?: return
                navController.navigate("transfer_detail/$transferId")
            }
            deepLink.contains("security") -> {
                navController.navigate("security_settings")
            }
            deepLink.contains("promotions") -> {
                val promoId = uri.getQueryParameter("id") ?: return
                navController.navigate("promotion_detail/$promoId")
            }
        }
    }
}

Part 9: Local Notifications (No Server Needed)

For notifications triggered locally — reminders, timers, download completion:

// data/notification/LocalNotificationHelper.kt

object LocalNotificationHelper {
    fun showLocalNotification(
        context: Context,
        title: String,
        body: String,
        channelId: String = "general",
        notificationId: Int = System.currentTimeMillis().toInt(),
        deepLink: String? = null
    ) {
        val intent = Intent(context, MainActivity::class.java).apply {
            flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
            deepLink?.let { putExtra("deep_link", it) }
        }
        val pendingIntent = PendingIntent.getActivity(
            context, notificationId, intent,
            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
        )
        val notification = NotificationCompat.Builder(context, channelId)
            .setSmallIcon(R.drawable.ic_notification)
            .setContentTitle(title)
            .setContentText(body)
            .setAutoCancel(true)
            .setContentIntent(pendingIntent)
            .setPriority(NotificationCompat.PRIORITY_DEFAULT)
            .build()
        val manager = context.getSystemService(NotificationManager::class.java)
        manager.notify(notificationId, notification)
    }
    /**
     * Schedule a notification for the future using AlarmManager
     */
    fun scheduleNotification(
        context: Context,
        title: String,
        body: String,
        triggerAtMillis: Long,
        notificationId: Int,
        channelId: String = "general"
    ) {
        val intent = Intent(context, ScheduledNotificationReceiver::class.java).apply {
            putExtra("title", title)
            putExtra("body", body)
            putExtra("notification_id", notificationId)
            putExtra("channel_id", channelId)
        }
        val pendingIntent = PendingIntent.getBroadcast(
            context, notificationId, intent,
            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
        )
        val alarmManager = context.getSystemService(AlarmManager::class.java)
        alarmManager.setExactAndAllowWhileIdle(
            AlarmManager.RTC_WAKEUP,
            triggerAtMillis,
            pendingIntent
        )
    }
}
class ScheduledNotificationReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        val title = intent.getStringExtra("title") ?: return
        val body = intent.getStringExtra("body") ?: return
        val notificationId = intent.getIntExtra("notification_id", 0)
        val channelId = intent.getStringExtra("channel_id") ?: "general"
        LocalNotificationHelper.showLocalNotification(
            context, title, body, channelId, notificationId
        )
    }
}

Part 10: Notification Grouping and Summary

When multiple notifications arrive, group them to avoid flooding the shade:

fun showGroupedTransferNotification(
    context: Context,
    transferId: String,
    title: String,
    body: String
) {
    val manager = context.getSystemService(NotificationManager::class.java)
    val groupKey = "com.mybank.TRANSFERS"

// Individual notification
    val notification = NotificationCompat.Builder(context, "transactions")
        .setSmallIcon(R.drawable.ic_transfer)
        .setContentTitle(title)
        .setContentText(body)
        .setAutoCancel(true)
        .setGroup(groupKey)
        .build()
    manager.notify(transferId.hashCode(), notification)
    // Summary notification (shows when 2+ notifications in group)
    val summary = NotificationCompat.Builder(context, "transactions")
        .setSmallIcon(R.drawable.ic_transfer)
        .setContentTitle("MyBank Transfers")
        .setContentText("You have new transfer updates")
        .setStyle(NotificationCompat.InboxStyle()
            .setSummaryText("Transfer updates"))
        .setGroup(groupKey)
        .setGroupSummary(true)
        .setAutoCancel(true)
        .build()
    manager.notify(0, summary) // ID 0 for summary
}

Part 11: Testing FCM

// Get the token for testing
class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()
        NotificationChannelManager.createAllChannels(this)

// Log token for testing
        CoroutineScope(Dispatchers.IO).launch {
            val token = Firebase.messaging.token.await()
            Log.d("FCM_TOKEN", token)
        }
    }
}
// Test using Firebase Console:
// 1. Go to Firebase Console → Cloud Messaging → Send your first message
// 2. Enter title/body
// 3. Click "Send test message"
// 4. Paste the FCM token from Logcat
// 5. Click "Test"
// Test using cURL:
// curl -X POST https://fcm.googleapis.com/v1/projects/YOUR_PROJECT/messages:send \
//   -H "Authorization: Bearer $(gcloud auth print-access-token)" \
//   -H "Content-Type: application/json" \
//   -d '{
//     "message": {
//       "token": "DEVICE_TOKEN",
//       "data": {
//         "type": "transfer_completed",
//         "transfer_id": "test_123",
//         "amount": "500.00",
//         "recipient_name": "Ahmed"
//       }
//     }
//   }'

Part 12: Common Pitfalls

Pitfall 1: Using Notification Messages Instead of Data Messages

❌ NOTIFICATION MESSAGE: System handles background display
   You LOSE control over how it looks in background/killed state

✅ DATA MESSAGE: You handle ALL states consistently
   Same notification UI in foreground, background, and killed

Pitfall 2: Not Creating Channels Before FCM Arrives

// ❌ Channel created lazily — first notification arrives before channel exists
// → Notification is LOST on Android 13+ (no permission prompt triggered)

// ✅ Create ALL channels in Application.onCreate()
// BEFORE any FCM message can arrive
class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()
        NotificationChannelManager.createAllChannels(this) // ← FIRST thing
    }
}

Pitfall 3: Not Refreshing Stale Tokens

// ❌ Register token once and forget
// → Token rotates → backend sends to old token → delivery fails silently

// ✅ Re-register on every app launch + login
// AND handle onNewToken() in FirebaseMessagingService

Pitfall 4: Not Handling Notification Permission Denial

// ❌ Request once, never ask again
// → User denied → notifications silently fail forever

// ✅ Show rationale dialog, guide to Settings
// Check permission state on each app launch

Conclusion

Push notifications on Android in 2026 require careful handling of three layers: the transport (FCM tokens, data vs notification messages, topics), the display (notification channels, rich layouts, grouping, deep links), and the permission (Android 13+ runtime permission, rationale dialog, Settings redirect).

For production apps, always use data-only messages — they give you complete control over notification appearance and behavior in all app states. Create notification channels in Application.onCreate() before any FCM message can arrive. Manage tokens proactively — register on every launch, handle onNewToken(), and delete on logout. Use in-app banners for foreground notifications and system notifications for background delivery.

Connect with Me on LinkedIn

Follow me on LinkedIn

Tags: #FCM #PushNotifications #Firebase #Android #JetpackCompose #Kotlin #NotificationChannels #DeepLinks #CloudMessaging #MobileDevelopment


메타데이터
post_id
fa728572cd8a
slug
push-notifications-in-android-the-complete-a-z-guide-firebase-cloud-messaging-fcm-fa728572cd8a
url
https://medium.com/@ramadan123sayed/push-notifications-in-android-the-complete-a-z-guide-firebase-cloud-messaging-fcm-fa728572cd8a
canonical_url
https://medium.com/@ramadan123sayed/push-notifications-in-android-the-complete-a-z-guide-firebase-cloud-messaging-fcm-fa728572cd8a
author_url
https://medium.com/@ramadan123sayed
status
ok
fetched_at
2026-06-09 15:37:30