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..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 @@ -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,43 @@ 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}", + ) + remoteMessage.data[NOTIFICATION_ID]?.toIntOrNull() + ?.let { notificationManager.cancel(it) } + } - // 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..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 @@ -41,6 +41,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 +56,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 +202,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 +276,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) {