From 99610fe9e30b9a086248f5d89d4f61f1f3416c89 Mon Sep 17 00:00:00 2001 From: Aaron Labiaga Date: Wed, 4 Mar 2026 14:20:03 -0700 Subject: [PATCH 1/2] Add boilerplate code option for handling cross device dismissal Change-Id: Ib64421fe875781c518cccaf5866b019b00371be5 --- app/src/main/AndroidManifest.xml | 3 +- .../android/samples/socialite/MainActivity.kt | 6 ++- .../fcm/MessagingBroadcastReceiver.kt | 51 +++++++++++++++++++ .../samples/socialite/fcm/MessagingService.kt | 47 ++++++++++++++--- .../repository/NotificationHelper.kt | 26 +++++++++- 5 files changed, 122 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/com/google/android/samples/socialite/fcm/MessagingBroadcastReceiver.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f813a705..09d4f764 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -182,7 +182,8 @@ - + + diff --git a/app/src/main/java/com/google/android/samples/socialite/MainActivity.kt b/app/src/main/java/com/google/android/samples/socialite/MainActivity.kt index 5a661ec7..1e5a449c 100644 --- a/app/src/main/java/com/google/android/samples/socialite/MainActivity.kt +++ b/app/src/main/java/com/google/android/samples/socialite/MainActivity.kt @@ -70,10 +70,12 @@ class MainActivity : ComponentActivity() { if (!task.isSuccessful) { Log.w("FCM", "Fetching FCM registration token failed", task.exception) return@OnCompleteListener - } // Get new FCM registration token + } // Get new FCM registration token. + // A unique key for this specific app instance and used for sending messages to + // this specific app instance. val token = task.result // Log token, for testing purposes only. - // Log.d("FCM", "FCM message token $token") + // Log.d(MainActivity::class::simpleName.toString(), "FCM message token $token") }, ) } diff --git a/app/src/main/java/com/google/android/samples/socialite/fcm/MessagingBroadcastReceiver.kt b/app/src/main/java/com/google/android/samples/socialite/fcm/MessagingBroadcastReceiver.kt new file mode 100644 index 00000000..34a4811d --- /dev/null +++ b/app/src/main/java/com/google/android/samples/socialite/fcm/MessagingBroadcastReceiver.kt @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.samples.socialite.fcm + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import com.google.android.samples.socialite.repository.NOTIFICATION_DISMISSED +import com.google.android.samples.socialite.repository.NOTIFICATION_ID + +/** + * Receiver for handling notification dismissals set via + * Notification$Builder#setDeleteIntent(Intent). + * + * In a multi-device world (mobile, large screen), notification + * dismissal on one client should propagate to all other clients + * to dismiss the notification. + */ +class MessagingBroadcastReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == NOTIFICATION_DISMISSED) { + intent.extras?.getInt(NOTIFICATION_ID)?.let { + Log.d( + MessagingBroadcastReceiver::class::simpleName.toString(), + "Dismissing notification w/ ID $it", + ) + // Here is where your app server is notified of a notification dismissal. + // The app server can then talk to FCM backend to route a message to + // all clients to dismiss/cancel the notification. + // See sending messages to devices in + // https://firebase.google.com/docs/cloud-messaging/server-environment + } + } + } +} diff --git a/app/src/main/java/com/google/android/samples/socialite/fcm/MessagingService.kt b/app/src/main/java/com/google/android/samples/socialite/fcm/MessagingService.kt index 9937c2a3..9454fac7 100644 --- a/app/src/main/java/com/google/android/samples/socialite/fcm/MessagingService.kt +++ b/app/src/main/java/com/google/android/samples/socialite/fcm/MessagingService.kt @@ -16,9 +16,17 @@ package com.google.android.samples.socialite.fcm +import android.app.NotificationManager +import android.util.Log +import androidx.core.content.getSystemService +import com.google.android.samples.socialite.repository.NOTIFICATION_ACTION +import com.google.android.samples.socialite.repository.NOTIFICATION_ID import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage +/** + * Service for handling Firebase Cloud Messaging. + */ class MessagingService : FirebaseMessagingService() { override fun onNewToken(token: String) { @@ -30,15 +38,42 @@ class MessagingService : FirebaseMessagingService() { override fun onMessageReceived(remoteMessage: RemoteMessage) { super.onMessageReceived(remoteMessage) + val notificationManager: NotificationManager = + this.getSystemService() ?: throw IllegalStateException() + // Handle data payload if (remoteMessage.data.isNotEmpty()) { - // Log.d("FCM", "Message data payload: ${remoteMessage.data}") - } + // If payload is relevant to dismissal, cancel the notification. + // For the device that triggered this dismissal, it is noop but + // for other devices that belongs to the user, the notification will be + // removed. + // Note that the below is just an example of a payload that can be + // relevant to dismissal. + if (remoteMessage.data[NOTIFICATION_ACTION] == "DISMISSAL" && + remoteMessage.data[NOTIFICATION_ID] != null + ) { + Log.d( + MessagingService::class::simpleName.toString(), + "Message data payload: ${remoteMessage.data}", + ) + notificationManager.cancel(remoteMessage.data[NOTIFICATION_ID]!!.toInt()) + } - // Handle notification payload - remoteMessage.notification?.let { - // Log.d("FCM", "Message Notification Body: ${it.body}") - // Trigger local notification here + // Handle notification payload + remoteMessage.notification?.let { + // Log.d("FCM", "Message Notification Body: ${it.body}") + // Trigger local notification here + // This notif should have unique ID as well. + } } } + + /** + * It is good practice to handle deleted messages in FCM + * See https://firebase.google.com/docs/cloud-messaging/android/receive-messages#override-on-deleted-messages + */ + override fun onDeletedMessages() { + super.onDeletedMessages() + // Up to the developer on how to handle this and surface this behavior to the end user. + } } diff --git a/app/src/main/java/com/google/android/samples/socialite/repository/NotificationHelper.kt b/app/src/main/java/com/google/android/samples/socialite/repository/NotificationHelper.kt index ed8ff31f..bf1553d3 100644 --- a/app/src/main/java/com/google/android/samples/socialite/repository/NotificationHelper.kt +++ b/app/src/main/java/com/google/android/samples/socialite/repository/NotificationHelper.kt @@ -27,6 +27,7 @@ import android.graphics.BitmapFactory import android.os.Build import androidx.annotation.RequiresApi import androidx.annotation.WorkerThread +import androidx.compose.runtime.snapshots.toInt import androidx.core.app.NotificationCompat import androidx.core.app.Person import androidx.core.app.RemoteInput @@ -41,6 +42,7 @@ import com.google.android.samples.socialite.BubbleActivity import com.google.android.samples.socialite.MainActivity import com.google.android.samples.socialite.R import com.google.android.samples.socialite.ReplyReceiver +import com.google.android.samples.socialite.fcm.MessagingBroadcastReceiver import com.google.android.samples.socialite.model.Contact import com.google.android.samples.socialite.model.Message import dagger.hilt.android.qualifiers.ApplicationContext @@ -55,6 +57,10 @@ enum class PushReason { OutgoingMessage, } +const val NOTIFICATION_ACTION = "NOTIFICATION_ACTION" +const val NOTIFICATION_DISMISSED = "NOTIFICATION_DISMISSED" +const val NOTIFICATION_ID = "NOTIFICATION_ID" + /** * Handles all operations related to [Notification]. */ @@ -197,6 +203,9 @@ class NotificationHelper @Inject constructor(@ApplicationContext context: Contex } } + val lastMessage = messages.last() + val notificationId = contact.id.toInt() + lastMessage.id.toInt() + val builder = NotificationCompat.Builder(appContext, CHANNEL_NEW_MESSAGES) // A notification can be shown as a bubble by calling setBubbleMetadata() .setBubbleMetadata( @@ -268,12 +277,25 @@ class NotificationHelper @Inject constructor(@ApplicationContext context: Contex // Let's add some more content to the notification in case it falls back to a normal // notification. .setStyle(messagingStyle) - .setWhen(messages.last().timestamp) + .setWhen(lastMessage.timestamp) + .setDeleteIntent(createNotificationDismissIntent(notificationId)) // Don't sound/vibrate if an update to an existing notification. if (update) { builder.setOnlyAlertOnce(true) } - notificationManager.notify(contact.id.toInt(), builder.build()) + notificationManager.notify(notificationId, builder.build()) + } + + private fun createNotificationDismissIntent(notificationId: Int): PendingIntent { + val deleteIntent = Intent(appContext, MessagingBroadcastReceiver::class.java) + deleteIntent.action = NOTIFICATION_DISMISSED + deleteIntent.putExtra(NOTIFICATION_ID, notificationId) + return PendingIntent.getBroadcast( + appContext, + notificationId, + deleteIntent, + flagUpdateCurrent(mutable = false), + ) } fun dismissNotification(chatId: Long) { From 1b691b562293d90d1c7f1b10e9f194fe1c233919 Mon Sep 17 00:00:00 2001 From: Aaron Labiaga Date: Wed, 4 Mar 2026 14:43:00 -0700 Subject: [PATCH 2/2] clean up Change-Id: I1fbf315aa91de2430e92cc95e938b9bf74da08cf --- .../google/android/samples/socialite/fcm/MessagingService.kt | 3 ++- .../android/samples/socialite/repository/NotificationHelper.kt | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/google/android/samples/socialite/fcm/MessagingService.kt b/app/src/main/java/com/google/android/samples/socialite/fcm/MessagingService.kt index 9454fac7..7834b788 100644 --- a/app/src/main/java/com/google/android/samples/socialite/fcm/MessagingService.kt +++ b/app/src/main/java/com/google/android/samples/socialite/fcm/MessagingService.kt @@ -56,7 +56,8 @@ class MessagingService : FirebaseMessagingService() { MessagingService::class::simpleName.toString(), "Message data payload: ${remoteMessage.data}", ) - notificationManager.cancel(remoteMessage.data[NOTIFICATION_ID]!!.toInt()) + remoteMessage.data[NOTIFICATION_ID]?.toIntOrNull() + ?.let { notificationManager.cancel(it) } } // Handle notification payload diff --git a/app/src/main/java/com/google/android/samples/socialite/repository/NotificationHelper.kt b/app/src/main/java/com/google/android/samples/socialite/repository/NotificationHelper.kt index bf1553d3..161d7380 100644 --- a/app/src/main/java/com/google/android/samples/socialite/repository/NotificationHelper.kt +++ b/app/src/main/java/com/google/android/samples/socialite/repository/NotificationHelper.kt @@ -27,7 +27,6 @@ import android.graphics.BitmapFactory import android.os.Build import androidx.annotation.RequiresApi import androidx.annotation.WorkerThread -import androidx.compose.runtime.snapshots.toInt import androidx.core.app.NotificationCompat import androidx.core.app.Person import androidx.core.app.RemoteInput