Twitter-scale notifications must be delivered reliably across different device states (foreground, background, killed), battery optimization regimes, and network conditions. Getting this right requires understanding FCM's delivery modes and Android's doze/standby constraints.
FCM Delivery Modes
// HIGH priority (bypasses Doze; battery impact)
// Use for: DMs, @mentions, follows — time-critical alerts
// NORMAL priority (queued during Doze; ~15-minute delay)
// Use for: likes, retweets, weekly digests — non-urgent
// Data message vs notification message:
// Notification message: FCM shows it automatically (OS handles display)
// Data message: your code handles display (full control, required for custom behavior)
Data Message Handler
class TwitterFirebaseMessagingService : FirebaseMessagingService() {
override fun onMessageReceived(message: RemoteMessage) {
val type = message.data["notification_type"] ?: return
when (NotificationType.fromString(type)) {
NotificationType.DIRECT_MESSAGE -> handleDm(message.data)
NotificationType.MENTION -> handleMention(message.data)
NotificationType.LIKE -> handleLike(message.data)
NotificationType.FOLLOW -> handleFollow(message.data)
NotificationType.RETWEET -> handleRetweet(message.data)
}
}
private fun handleDm(data: Map<String, String>) {
val conversationId = data["conversation_id"] ?: return
val senderName = data["sender_name"] ?: return
val preview = data["preview"] ?: return
// Update local Room database with new message info
WorkManager.getInstance(this).enqueue(
OneTimeWorkRequestBuilder<FetchDmWorker>()
.setInputData(workDataOf("conversation_id" to conversationId))
.setConstraints(Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build())
.build()
)
// Show notification immediately with preview (without waiting for full sync)
showDmNotification(conversationId, senderName, preview)
}
private fun showDmNotification(conversationId: String, senderName: String, preview: String) {
val intent = Intent(this, MainActivity::class.java).apply {
putExtra("destination", "conversation")
putExtra("conversation_id", conversationId)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val notification = NotificationCompat.Builder(this, "direct_messages")
.setSmallIcon(R.drawable.ic_dm)
.setContentTitle(senderName)
.setContentText(preview)
.setContentIntent(PendingIntent.getActivity(
this, conversationId.hashCode(), intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
))
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.build()
NotificationManagerCompat.from(this).notify(conversationId.hashCode(), notification)
}
}
Handling Device Token Refresh
class TwitterMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
// Must register new token with your server
WorkManager.getInstance(this).enqueue(
OneTimeWorkRequestBuilder<RegisterTokenWorker>()
.setInputData(workDataOf("token" to token))
.setConstraints(Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
.build()
)
}
}
Notification Grouping
// Group multiple notifications from the same category
fun showLikeNotification(likeCount: Int, latestLiker: String, tweetId: String) {
val GROUP_KEY = "twitter_likes"
if (likeCount == 1) {
// Single notification
NotificationManagerCompat.from(context).notify(tweetId.hashCode(),
NotificationCompat.Builder(context, "likes")
.setContentTitle("$latestLiker liked your tweet")
.setSmallIcon(R.drawable.ic_like)
.setGroup(GROUP_KEY)
.build()
)
} else {
// Summary notification (groups the individual ones)
NotificationManagerCompat.from(context).notify(GROUP_KEY.hashCode(),
NotificationCompat.Builder(context, "likes")
.setContentTitle("$likeCount people liked your tweets")
.setSmallIcon(R.drawable.ic_like)
.setGroup(GROUP_KEY)
.setGroupSummary(true)
.setStyle(NotificationCompat.InboxStyle()
.addLine("$latestLiker and ${likeCount - 1} others liked your tweet")
.setSummaryText("$likeCount likes")
)
.build()
)
}
}
Delivery Guarantee Patterns
At-Least-Once with Deduplication
FCM can deliver a message multiple times (e.g., during network instability). Your handler must be idempotent:
class NotificationDeduplicator(private val prefs: DataStore<Preferences>) {
private val processedKey = stringSetPreferencesKey("processed_notification_ids")
suspend fun processOnce(messageId: String, action: suspend () -> Unit) {
val processed = prefs.data.first()[processedKey] ?: emptySet()
if (messageId in processed) return // already processed
action()
prefs.edit {
val current = it[processedKey] ?: emptySet()
it[processedKey] = (current + messageId).takeLast(1000).toSet()
}
}
}
Key Takeaways
| Pattern | Rule |
|---|---|
| HIGH vs NORMAL priority | DM/mention → HIGH (bypass Doze); likes → NORMAL (can wait) |
| Data messages | Use for custom display logic; notification messages for simple alerts |
| Token refresh via WorkManager | Retry on network failure; losing the token = lost notifications |
| Notification grouping | setGroup + summary notification for multiple alerts of same type |
| Idempotent handler | FCM may deliver twice; deduplicate by message ID |
| Permission check | POST_NOTIFICATIONS required on API 33+; request at first-login |