Design a Push Notification System

1. Understanding the Problem

Design the Android client side of a push notification system β€” think the layer that handles FCM token registration, receives messages from Firebase Cloud Messaging, routes them to the correct notification channel, builds rich notifications, and deep-links users into the right screen on tap. The backend sends the push; your job is everything that happens on device.

πŸ“Œ Pattern: Token β†’ Receive β†’ Route β†’ Deep Link

The end-to-end flow on Android has four phases: (1) Register β€” obtain an FCM token and sync it to your backend. (2) Receive β€” FirebaseMessagingService.onMessageReceived() is your entry point for every push. (3) Build β€” construct a NotificationCompat with the right channel, priority, and actions. (4) Route β€” a tap fires a PendingIntent that deep-links into the correct screen via Navigation Component or explicit Intent.

Learn This Pattern β†’

βœ… Functional Requirements

  • Register device FCM token with backend on first launch
  • Re-register when token rotates (security refresh)
  • Receive push when app is in foreground, background, killed
  • Show rich notifications: title, body, image, action buttons
  • Group related notifications (e.g., 5 unread chat messages)
  • Tapping a notification deep-links to the correct screen
  • Notification preferences: user can disable per category

β›” Non-Functional Requirements

  • Android 13+ POST_NOTIFICATIONS permission at runtime
  • Notification channels for each category (Android 8+)
  • Data messages must process even when app is killed
  • No duplicate notifications for retried server sends
  • Notification tap must work from killed state (cold start)
  • Token sync must be idempotent β€” safe to call multiple times
  • Respect Do Not Disturb / battery saver constraints

2. The Set Up

Notification message vs Data message β€” the most important FCM distinction

Notification Message
System-displayed

FCM displays the notification automatically using the notification payload. When the app is in the background or killed, Android shows it without calling your code.

  • βœ… Zero code to show notification
  • ❌ No custom logic before display
  • ❌ No image loading, no channel control
  • ❌ Always uses default channel
Data Message β€” Chosen βœ“
App-displayed

FCM always calls onMessageReceived() β€” even in background/killed state (via a high-priority wake). Your code has full control over display, channel, image loading, and local data sync.

  • βœ… Full notification customisation
  • βœ… Correct channel routing
  • βœ… Image download with Coil/Glide
  • βœ… Local data sync before showing

Notification channels β€” Android 8+ mandatory

πŸ’¬ Chat Messages
HIGH importance
Heads-up notification with sound. User can silence per-conversation.
πŸ“’ Promotional
DEFAULT importance
Appears in shade only β€” no sound, no heads-up pop. Easy to disable without losing other channels.
🚨 Breaking News
MAX importance
Heads-up with sound + vibration. Reserved for critical time-sensitive content.
πŸ”• Silent Updates
LOW importance
Data sync triggers with no visual or sound β€” app updates local DB silently.

3. High-Level Design

Push Notification System β€” Architecture
Your Backend Stores FCM token Sends push via FCM API per user/segment Firebase Cloud Messaging (FCM) Routes message to device data msg push ANDROID DEVICE FirebaseMessagingService onMessageReceived() / onNewToken() NotificationRouter maps type β†’ channel + builder NotifBuilder BigPicture / Inbox TokenManager sync token to server DeepLinkHandler PendingIntent β†’ nav Room / DataStore prefs + token cache Android NotificationManager system tray β€’ heads-up β€’ lock screen token registration (HTTPS)
πŸ“¨
FirebaseMessagingService
The FCM entry point. onMessageReceived() fires for foreground + background + killed (data messages). onNewToken() fires when token rotates.
πŸ—ΊοΈ
NotificationRouter
Reads the type field from the data payload and dispatches to the correct channel and builder strategy. Decoupled from FCM β€” testable in isolation.
πŸ”‘
TokenManager
Retrieves the FCM token, caches it in DataStore, compares with last-synced value, and calls the backend registration API only when changed.
πŸ””
NotificationBuilder
Builds NotificationCompat with the right style (BigText, BigPicture, InboxStyle). Loads images synchronously via Coil for background builds.
πŸ”—
DeepLinkHandler
Creates the tap PendingIntent. For Navigation Component apps: wraps a NavDeepLinkBuilder. For cold-start: sets Intent extras for the launcher Activity.
πŸ“Ί
ChannelRegistry
Creates all NotificationChannels on app launch. Idempotent β€” calling createNotificationChannel() with same ID is a no-op on Android 8+.

4. Low-Level Design

FirebaseMessagingService β€” the FCM entry point

class AppMessagingService : FirebaseMessagingService() {

    @Inject lateinit var router: NotificationRouter
    @Inject lateinit var tokenManager: TokenManager

    /**
     * Called for data messages regardless of app state (foreground / background / killed).
     * For notification+data combos in background, system shows the notification payload
     * automatically β€” onMessageReceived is NOT called. Use pure data messages.
     */
    override fun onMessageReceived(message: RemoteMessage) {
        val type = message.data["type"] ?: return
        router.route(type, message.data)
    }

    /**
     * Called when FCM rotates the token (security refresh, app reinstall, etc.)
     * Must sync new token to backend β€” old token becomes invalid.
     */
    override fun onNewToken(token: String) {
        tokenManager.onTokenRefreshed(token)
    }
}

TokenManager β€” idempotent registration

class TokenManager @Inject constructor(
    private val dataStore: DataStore<Preferences>,
    private val api: NotificationApi,
    private val scope: CoroutineScope
) {
    private val KEY_TOKEN = stringPreferencesKey("fcm_token")

    /** Called on first launch and whenever token changes */
    fun initToken() {
        scope.launch {
            FirebaseMessaging.getInstance().token.await().let { newToken ->
                syncIfChanged(newToken)
            }
        }
    }

    /** Called by FirebaseMessagingService.onNewToken() */
    fun onTokenRefreshed(token: String) {
        scope.launch { syncIfChanged(token) }
    }

    private suspend fun syncIfChanged(newToken: String) {
        val cached = dataStore.data.first()[KEY_TOKEN]
        if (cached == newToken) return   // idempotent β€” don't spam backend

        try {
            api.registerToken(TokenRequest(token = newToken, platform = "android"))
            dataStore.edit { it[KEY_TOKEN] = newToken }  // only cache after server ACK
        } catch (e: Exception) {
            // Retry on next launch β€” don't cache so next call tries again
        }
    }

    /** Called on logout β€” unregister token so server stops sending */
    suspend fun clearToken() {
        FirebaseMessaging.getInstance().deleteToken().await()
        dataStore.edit { it.remove(KEY_TOKEN) }
        api.unregisterToken()
    }
}

NotificationRouter + Builder strategy

class NotificationRouter @Inject constructor(
    private val context: Context,
    private val deepLinkHandler: DeepLinkHandler
) {
    fun route(type: String, data: Map<String, String>) {
        val notification = when (type) {
            "chat_message"    -> buildChatNotification(data)
            "promo"           -> buildPromoNotification(data)
            "breaking_news"   -> buildBreakingNewsNotification(data)
            "silent_sync"     -> { triggerSilentSync(data); return }
            else              -> return   // unknown type β€” ignore safely
        }
        val notifId = data["notif_id"]?.hashCode() ?: System.currentTimeMillis().toInt()
        NotificationManagerCompat.from(context).notify(notifId, notification)
    }

    private fun buildChatNotification(data: Map<String, String>): Notification {
        val tapIntent = deepLinkHandler.chatScreen(data["conversation_id"]!!)
        val person = Person.Builder().setName(data["sender_name"]!!).build()

        return NotificationCompat.Builder(context, CH_CHAT)
            .setSmallIcon(R.drawable.ic_notification)
            .setContentTitle(data["sender_name"])
            .setContentText(data["body"])
            .setStyle(
                NotificationCompat.MessagingStyle(person)
                    .addMessage(data["body"]!!, System.currentTimeMillis(), person)
            )
            .setContentIntent(tapIntent)
            .setAutoCancel(true)
            .setGroup("chat_${data["conversation_id"]}")   // bundle by conversation
            .build()
    }

    private fun buildBreakingNewsNotification(data: Map<String, String>): Notification {
        // Load image synchronously β€” we're already on a background thread
        val bitmap = data["image_url"]?.let {
            Coil.imageLoader(context)
                .executeBlocking(ImageRequest.Builder(context).data(it).build())
                .drawable?.toBitmap()
        }
        val style = if (bitmap != null)
            NotificationCompat.BigPictureStyle().bigPicture(bitmap)
        else
            NotificationCompat.BigTextStyle().bigText(data["body"])

        return NotificationCompat.Builder(context, CH_BREAKING)
            .setSmallIcon(R.drawable.ic_news)
            .setContentTitle(data["title"])
            .setContentText(data["body"])
            .setStyle(style)
            .setPriority(NotificationCompat.PRIORITY_MAX)
            .setContentIntent(deepLinkHandler.articleScreen(data["article_id"]!!))
            .setAutoCancel(true)
            .build()
    }

    private fun triggerSilentSync(data: Map<String, String>) {
        // Launch an expedited WorkManager job β€” no notification shown
        val work = OneTimeWorkRequestBuilder<SyncWorker>()
            .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
            .setInputData(workDataOf("entity" to data["entity"]))
            .build()
        WorkManager.getInstance(context).enqueue(work)
    }
}

DeepLinkHandler β€” tap-to-screen routing

class DeepLinkHandler @Inject constructor(private val context: Context) {

    /**
     * NavDeepLinkBuilder correctly handles:
     * - Foreground: navigates within running nav graph
     * - Background/killed: builds a synthetic back stack so Back works correctly
     */
    fun chatScreen(conversationId: String): PendingIntent =
        NavDeepLinkBuilder(context)
            .setGraph(R.navigation.nav_graph)
            .setDestination(R.id.chatFragment)
            .setArguments(bundleOf("conversationId" to conversationId))
            .createPendingIntent()

    fun articleScreen(articleId: String): PendingIntent {
        val intent = Intent(Intent.ACTION_VIEW, "app://news/article/$articleId".toUri())
            .setPackage(context.packageName)
        return PendingIntent.getActivity(
            context, articleId.hashCode(), intent,
            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
        )
    }
}

LLD Whiteboard β€” notification lifecycle flows

Component Interaction β€” Message Received to Notification Shown
FCM data message push MessagingService onMessageReceived() NotifRouter type switch NotifBuilder MessagingStyle etc. DeepLinkHandler NavDeepLinkBuilder System Tray SyncWorker expedited WorkManager silent_sync
Flow 1 β€” First Launch Token Registration
App / Activity TokenManager Firebase SDK Your Backend DataStore
1.App launches. Application.onCreate() calls tokenManager.initToken() Calls FirebaseMessaging.getInstance().token.await() β€” β€” β€”
β€” β€” 2.Returns current token (or generates new one on first run) β€” β€”
β€” 3.Reads cached token from DataStore β€” null on first launch β€” β€” read KEY_TOKEN β†’ null
β€” 4.Token changed β†’ calls api.registerToken(token, "android") β€” Stores token mapped to userId in DB β€”
β€” 5.Server returns 200 OK β†’ writes token to DataStore β€” β€” write KEY_TOKEN = newToken
On subsequent launches: DataStore token == FCM token β†’ no API call β€” β€” β€” β€”
Flow 2 β€” Data Push Received (App in Background)
FCM / OS MessagingService NotifRouter NotifBuilder System Tray
1.Backend sends FCM data message with priority: "high" β€” β€” β€” β€”
FCM wakes device (high-priority wakelock), starts MessagingService process 2.onMessageReceived() called β€” ~10 s deadline to complete β€” β€” β€”
β€” 3.Reads data["type"] = "breaking_news", passes to router.route() Matches "breaking_news" branch β€” β€”
β€” β€” 4.Calls buildBreakingNewsNotification(data) Fetches image via Coil.executeBlocking(imageUrl). Builds BigPictureStyle. β€”
β€” β€” 5.Calls NotificationManagerCompat.notify(id, notification) β€” Heads-up notification appears. User sees image + title.
β€” β€” β€” β€” 6.User taps β†’ PendingIntent fires β†’ NavDeepLink opens ArticleFragment
Flow 3 β€” Notification Tap Deep Link from Killed State
System Tray MainActivity (cold start) NavController NavDeepLinkBuilder Fragment
1.User taps the notification while app is killed β€” β€” β€” β€”
OS fires the PendingIntent built by NavDeepLinkBuilder 2.MainActivity starts cold. onCreate() inflates nav graph. β€” β€” β€”
β€” 3.NavController.handleDeepLink(intent) is called automatically Reads the deep link URI from intent extras β€” β€”
β€” β€” 4.NavDeepLinkBuilder pre-built a synthetic back stack: Home β†’ Article Back stack: [HomeFragment, ArticleFragment] β€”
β€” β€” β€” β€” 5.ArticleFragment is on screen. articleId available via Safe Args. Back press β†’ HomeFragment.

5. Deep Dives

Notification channels and permission (Android 13+)

object ChannelRegistry {
    const val CH_CHAT     = "channel_chat"
    const val CH_PROMO    = "channel_promo"
    const val CH_BREAKING = "channel_breaking"
    const val CH_SILENT   = "channel_silent"

    // Call once from Application.onCreate() β€” idempotent on 8+
    fun createAll(context: Context) {
        val mgr = context.getSystemService(NotificationManager::class.java)
        listOf(
            NotificationChannel(CH_CHAT, "Chat Messages", IMPORTANCE_HIGH),
            NotificationChannel(CH_PROMO, "Promotions", IMPORTANCE_DEFAULT),
            NotificationChannel(CH_BREAKING, "Breaking News", IMPORTANCE_MAX),
            NotificationChannel(CH_SILENT, "Background Updates", IMPORTANCE_MIN)
        ).forEach { mgr.createNotificationChannel(it) }
    }
}

// Android 13+ runtime permission β€” request from Activity/Fragment
val launcher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
    if (!granted) {
        // Show rationale: "Enable notifications to get breaking news alerts"
    }
}
if (Build.VERSION.SDK_INT >= 33 &&
    ContextCompat.checkSelfPermission(this, POST_NOTIFICATIONS) != PERMISSION_GRANTED) {
    launcher.launch(POST_NOTIFICATIONS)
}

Grouped notifications (InboxStyle + summary)

private fun buildGroupedChatNotif(messages: List<ChatMessage>): List<Notification> {
    val groupKey = "com.app.CHAT_GROUP"

    // Individual notification per message
    val children = messages.map { msg ->
        NotificationCompat.Builder(context, CH_CHAT)
            .setSmallIcon(R.drawable.ic_notification)
            .setContentTitle(msg.senderName)
            .setContentText(msg.body)
            .setGroup(groupKey)
            .build()
    }

    // Summary notification β€” required for grouping to work
    val summary = NotificationCompat.Builder(context, CH_CHAT)
        .setSmallIcon(R.drawable.ic_notification)
        .setContentTitle("${messages.size} new messages")
        .setStyle(
            NotificationCompat.InboxStyle()
                .setSummaryText("${messages.size} new messages")
                .also { s -> messages.take(5).forEach { s.addLine(it.body) } }
        )
        .setGroup(groupKey)
        .setGroupSummary(true)
        .build()

    return children + summary
}

6. Expected at Each Level

πŸ”΅ Mid-Level
  • Knows FCM token must be registered with backend
  • Understands onMessageReceived() + onNewToken() lifecycle
  • Creates notification channels for Android 8+
  • Handles foreground vs background behaviour difference
  • Builds basic NotificationCompat with tap PendingIntent
  • Requests POST_NOTIFICATIONS on Android 13+
🟒 Senior
  • Data message vs notification message β€” knows when to use each
  • Idempotent token sync via DataStore cached comparison
  • NavDeepLinkBuilder for correct back stack on cold start
  • MessagingStyle / BigPictureStyle / InboxStyle β€” right style per type
  • Grouped notifications with mandatory summary
  • Silent sync via expedited WorkManager from onMessageReceived
  • Token cleanup on logout with deleteToken()
🟣 Staff+
  • Server-side fanout architecture β€” topic vs individual token sends
  • Dedup: notif_id field, same ID = replace existing notification
  • Notification analytics: tap rate, dismiss rate per channel
  • Batch notification collapse β€” "5 messages" summary strategy
  • A/B testing push copy via remote config feature flags
  • Fallback: if FCM unavailable (China), detect and route to alternative
  • Multi-device token management (tablet + phone same user)

7. Interview Q&A (20 Questions)

Q1. What is the difference between a notification message and a data message in FCM?
Easyβ–Ύ

A notification message contains a notification JSON payload. When the app is in the background or killed, FCM displays it automatically using Android's system tray β€” onMessageReceived() is NOT called. You lose control over channel, image, and custom logic. A data message contains only a data payload. FCM always calls onMessageReceived(), even in background/killed state (using a high-priority wakelock). You have full control to build the notification with the correct channel, load images, or trigger a silent data sync without any visible notification. For production apps: always use pure data messages with a type field to dispatch logic in your service.

Q2. When does onMessageReceived() NOT get called?
Mediumβ–Ύ

Two cases. (1) When the message has a notification payload and the app is in background or killed β€” FCM intercepts and displays it automatically without calling your code. (2) When the device has data saver enabled and the message is not high-priority β€” FCM may throttle delivery. To guarantee onMessageReceived() is always called: (a) use pure data messages only, (b) set priority: "high" on the FCM send request. For breaking-news style content, high priority is essential. Normal priority messages can be batched and delayed by the OS.

Q3. Why do you need notification channels and how do you create them?
Easyβ–Ύ

Since Android 8.0 (API 26), every notification must be assigned to a NotificationChannel. Channels let users control each notification category independently β€” they can mute Promotions without affecting Chat. You create channels via NotificationManager.createNotificationChannel(channel). Crucially, this is idempotent β€” calling it with the same channel ID multiple times is a no-op, so it's safe to call from Application.onCreate() on every launch. Once created, the user controls importance β€” your app cannot downgrade a channel the user has elevated, and vice versa. Create all channels on first launch, before posting any notification.

Q4. How do you handle POST_NOTIFICATIONS permission on Android 13+?
Easyβ–Ύ

Android 13 introduced android.permission.POST_NOTIFICATIONS as a runtime permission. Without it, notifications are silently blocked. Request it using ActivityResultContracts.RequestPermission(). Best practice: don't request immediately on launch β€” show the permission rationale in context first (e.g., "Enable notifications to receive chat messages"). If the user denies: gracefully degrade without crashing, offer to enable it later via Settings. Check Build.VERSION.SDK_INT >= 33 before requesting β€” on older versions it's granted automatically. Target 33+ in your manifest to trigger the runtime request.

Q5. Why must token sync be idempotent and how do you implement that?
Mediumβ–Ύ

Token sync is called on every app launch and every onNewToken(). Without idempotency, you'd hit the backend API on every cold start β€” wasteful and potentially rate-limited. The fix: cache the last successfully synced token in DataStore. On each launch, compare the FCM-provided token against the cached value. Only call the backend if they differ. Crucially: only write the new token to DataStore after the server returns 200 OK β€” if you cache first and the network call fails, the token appears synced but isn't, and future launches won't retry.

Q6. How does NavDeepLinkBuilder create the correct back stack on cold start?
Mediumβ–Ύ

When a user taps a notification from the killed state, the app cold-starts directly into the destination screen. Without careful setup, pressing Back would exit the app instead of going to Home β€” a broken UX. NavDeepLinkBuilder from Navigation Component solves this by constructing a synthetic back stack matching the nav graph hierarchy. You call .setGraph(R.navigation.nav_graph).setDestination(R.id.chatFragment).setArguments(bundle). The PendingIntent it creates inflates the full back stack, so Back from ChatFragment correctly goes to HomeFragment. This is the recommended approach over manual Intent construction.

Q7. What happens if you show a notification without creating its channel first?
Easyβ–Ύ

On Android 8+, the notification is silently dropped β€” it doesn't appear anywhere, no error is thrown. This is a common bug where notifications work on Android 7 but silently fail on 8+. The fix: always call ChannelRegistry.createAll() in Application.onCreate() before any notification is posted. Since channel creation is idempotent, there's no cost to calling it on every launch. The notification must also reference the exact channel ID string β€” a typo means the notification is posted to a non-existent channel and dropped.

Q8. How do you implement notification grouping for multiple chat messages?
Mediumβ–Ύ

Use Android's notification grouping API. (1) Assign each notification a setGroup(groupKey) β€” same key groups them together. (2) Post a mandatory summary notification with setGroupSummary(true) and the same group key β€” without this, Android won't visually collapse the group. Use InboxStyle on the summary to show a preview of all messages. The summary appears on Android 7+ where multiple notifications collapse into one expandable bundle. On Android <7, individual notifications appear separately. Always post the summary last, after all child notifications.

Q9. How do you load an image into a notification?
Mediumβ–Ύ

onMessageReceived() runs on a background thread, so synchronous image loading is acceptable. Use Coil's imageLoader.executeBlocking() or Glide's Glide.with(context).asBitmap().load(url).submit().get() β€” both block until the image is downloaded. Convert the result to a Bitmap and pass to NotificationCompat.BigPictureStyle().bigPicture(bitmap). Cap total time: onMessageReceived() has about 10 seconds to complete before FCM kills it. Use a short timeout on the image request (2–3 s) and fall back to BigTextStyle if the download times out. Never load images on the main thread.

Q10. What's the 10-second rule for onMessageReceived()?
Mediumβ–Ύ

FCM gives onMessageReceived() approximately 10 seconds to complete all work. If it exceeds this, the service is killed and the message may be lost or shown incompletely. For work that exceeds 10 seconds (e.g., heavy sync, large image download): use onMessageReceived() only to enqueue an expedited OneTimeWorkRequest β€” .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST). The worker runs with expedited priority (similar to foreground service) and has no time limit. The notification can either be shown inside the worker or pre-shown in onMessageReceived() with a "Loading…" placeholder.

Q11. How do you prevent duplicate notifications for retried server sends?
Mediumβ–Ύ

The server includes a stable notif_id in the data payload (e.g., "notif_id": "msg_12345"). On the client: use notif_id.hashCode() as the Android notification ID passed to NotificationManagerCompat.notify(id, notification). Calling notify() with an already-active ID replaces the existing notification silently β€” the user sees at most one notification per logical event. This handles the case where FCM delivers the same message twice (rare but possible). If the server retries with a different ID, you'll get a duplicate β€” so the ID must be stable and message-specific, not time-based.

Q12. What is a silent push notification and when do you use it?
Easyβ–Ύ

A silent push is a data message where onMessageReceived() triggers a data sync without showing any notification to the user. Use cases: refreshing a feed in the background when new content is published, syncing chat message list before the user opens the app, invalidating a cached screen. Implementation: inside onMessageReceived(), check type == "silent_sync" and enqueue an expedited WorkManager job. The job syncs Room from the API. When the user opens the app, fresh data is ready. No NotificationManagerCompat.notify() call is made. Works even when the app is killed (high-priority data message).

Q13. How do you handle token refresh (onNewToken)?
Easyβ–Ύ

FCM rotates tokens for security (when the app is reinstalled, the user clears app data, or Google Play Services decides to refresh). onNewToken(token) is called with the new token. You must sync it to your backend immediately β€” the old token is now invalid and server pushes will fail silently. Call tokenManager.onTokenRefreshed(token) which launches a coroutine to call api.registerToken(newToken) and updates DataStore. Also sync on every launch via FirebaseMessaging.getInstance().token.await() as a belt-and-suspenders check β€” onNewToken delivery is not guaranteed if the device was offline when the rotation happened.

Q14. How do you unregister from notifications on user logout?
Mediumβ–Ύ

Two steps on logout. (1) Client: call FirebaseMessaging.getInstance().deleteToken().await() β€” this invalidates the current token so FCM can no longer deliver to this device. Clear the cached token from DataStore. (2) Server: call your backend api.unregisterToken() so it removes the token from the user's device list. If you skip the server step, the backend will try to push to a deleted token, FCM will return a 404/InvalidRegistration error, and the backend should handle that by removing the stale token. If you skip the client step, a new token is generated on next launch β€” but until then, someone else logging in could receive the previous user's notifications.

Q15. What is MessagingStyle and when should you use it over BigTextStyle?
Mediumβ–Ύ

MessagingStyle is purpose-built for person-to-person messaging. It displays a conversation thread with sender names, avatars via Person.Builder(), and supports multiple messages in one notification. It also integrates with smart reply suggestions on Android 10+ and the Bubbles API. BigTextStyle is for long text content from a single source β€” news articles, emails. Use MessagingStyle whenever you're showing chat messages. It gets preferential treatment by the OS (Conversation Space on Android 11+), appears higher in the notification shade, and supports direct reply without opening the app. Do NOT use it for non-conversation content.

Q16. How do you add a Reply action to a chat notification?
Hardβ–Ύ

Use RemoteInput to capture typed text inline. Build it with RemoteInput.Builder("reply_key").setLabel("Reply…").build(). Create a PendingIntent pointing to a BroadcastReceiver or Service that handles the reply. Attach both to a NotificationCompat.Action with setAllowGeneratedReplies(true). In your receiver/service: call RemoteInput.getResultsFromIntent(intent) to extract the typed text. Send the reply to your API, then update the notification's MessagingStyle to add the sent message β€” otherwise the notification spinner keeps spinning. Cancel the old notification and re-post with the sent message included so the user sees their reply.

Q17. How do notification importance levels map to user experience?
Easyβ–Ύ

IMPORTANCE_MAX: full-screen intent (for incoming calls) or heads-up with sound + vibration. IMPORTANCE_HIGH: heads-up notification + sound β€” pops up over the current screen. IMPORTANCE_DEFAULT: sound, shows in shade but no heads-up pop. IMPORTANCE_LOW: silent, in shade only, no badge. IMPORTANCE_MIN: silent, collapsed at bottom of shade, no badge. The user can adjust this in System Settings per channel β€” your code cannot override user changes to importance after channel creation. This is by design: users have full control. Choose the right importance at channel creation β€” you cannot change it later in code.

Q18. How would you implement topic-based push (e.g., sports scores)?
Mediumβ–Ύ

FCM supports topic subscriptions. Call FirebaseMessaging.getInstance().subscribeToTopic("sports_cricket") β€” FCM registers the device for that topic on its servers. Your backend can then send one message addressed to /topics/sports_cricket and FCM fans it out to all subscribed devices. The client doesn't need to manage individual tokens for broadcast scenarios. Unsubscribe with unsubscribeFromTopic(). Limitations: topic messages are always low-priority by default; you can't target a specific segment of topic subscribers with personalized data; and FCM throttles bulk topic sends. For user-specific data (e.g., "your order shipped"), use individual token addressing.

Q19. What's the difference between FLAG_IMMUTABLE and FLAG_MUTABLE in PendingIntent?
Mediumβ–Ύ

FLAG_IMMUTABLE: once created, the PendingIntent cannot have its extras modified by the receiving component. Required for notification actions and deep links from Android 12+ (mandatory; your app will crash without it on API 31+). FLAG_MUTABLE: the receiving component can modify the Intent extras β€” required for specific cases like AlarmManager exact alarms and media session callbacks that need to inject data. For notification PendingIntents, always use FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT. The FLAG_UPDATE_CURRENT ensures that if a PendingIntent with the same request code already exists, its extras are updated β€” important when the same screen can be opened with different data.

Q20. How do you handle push notifications in a multi-process app?
Hardβ–Ύ

FirebaseMessagingService runs in the main app process by default. If your app uses multiple processes (e.g., a separate :push process), ensure the service and its Hilt/Dagger injections are initialised correctly in that process β€” Application.onCreate() is called once per process, so multi-process apps need to guard initialisation with ProcessPhoenix or explicit process name checks. For Room: multi-process Room requires WAL mode and careful connection pooling. Simpler alternative: always run the MessagingService in the main process (the default) by not specifying android:process in the manifest. Dispatch heavy work to WorkManager, which handles cross-process coordination safely.