In this article, we’ll implement Firebase Cloud Messaging (FCM) for push notification and local notification.
Table of Contents
- Getting Started
- Firebase Cloud Messaging (FCM) Setup
- Permissions and FCM Token - FCM Notification
- Local Notification
- Local Notification w/Big Text
- Local Notification w/Action & Broadcast Receiver
Getting Started
Top level build.gradle
file,
buildscript {
dependencies {
classpath 'com.google.gms:google-services:4.3.14'
}
}
plugins {
id 'com.android.application' version '7.3.1' apply false
id 'com.android.library' version '7.3.1' apply false
id 'org.jetbrains.kotlin.android' version '1.7.20' apply false
}
App level build.gradle
file,
implementation platform('com.google.firebase:firebase-bom:31.1.1')
implementation 'com.google.firebase:firebase-analytics-ktx'
implementation 'com.google.firebase:firebase-messaging-ktx'
Finally, we’ll create extension functions that we’ll use later.
fun Context.setNotification(
channelId: String,
title: String?,
body: String?,
soundUri: Uri?,
groupId: String?,
pendingIntent: PendingIntent,
): NotificationCompat.Builder {
val notification = NotificationCompat.Builder(this, channelId)
.setSmallIcon(R.drawable.ic_stat_test)
.setLargeIcon(BitmapFactory.decodeResource(resources, R.drawable.ic_stat_test))
.setColor(ContextCompat.getColor(applicationContext, R.color.notification))
.setContentTitle(title)
.setContentText(body)
.setAutoCancel(true)
.setSound(soundUri)
.setGroupSummary(false)
if (groupId != null)
notification.setGroup(groupId)
notification.setContentIntent(pendingIntent)
return notification
}
fun Context.setGroupNotification(
channelId: String,
groupId: String,
groupSummary: Boolean,
lineText: String,
bigContentTitle: String,
summaryText: String,
): Notification = NotificationCompat.Builder(this, channelId)
.setSmallIcon(R.drawable.ic_stat_test)
.setLargeIcon(BitmapFactory.decodeResource(resources, R.drawable.ic_stat_test))
.setColor(ContextCompat.getColor(applicationContext, R.color.notification))
.setStyle(
NotificationCompat.InboxStyle()
.addLine(lineText)
.setBigContentTitle(bigContentTitle)
.setSummaryText(summaryText)
)
.setGroup(groupId)
.setGroupSummary(groupSummary)
.setAutoCancel(true)
.build()
setNotification
,
setSmallIcon()
this is the only user-visible content that's required.setAutoCancel()
should notification automatically be canceled when user clicks.setGroup()
sets this notification to be part of a group of notifications sharing the same key.setGroupSummary()
whether this notification should be a group summary. You can read more about it.
setGroupNotification
is to help notification grouping. In case of multiple related notifications being stacked, we’ll group them with this function.
Firebase Cloud Messaging (FCM) Setup
Before we start, create Firebase project for your app and add the google-services.json
file if you haven’t already. Firebase setup link
AndroidManifest.xml
,
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application
//... >
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<meta-data
android:name="android.app.lib_name"
android:value="" />
</activity>
<!-- [START fcm_default_icon] -->
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/ic_stat_test" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_color"
android:resource="@color/notification" />
<!-- [END fcm_default_icon] -->
<!-- [START fcm_default_channel] -->
<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="@string/notification_channel_id" />
<!-- [END fcm_default_channel] -->
<!-- [START firebase_service] -->
<service
android:name=".service.FirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<!-- [END firebase_service] -->
<!-- [START broadcast_receiver] -->
<receiver android:name=".service.NotificationActionBroadcastReceiver"/>
</application>
fcm_default_icon
, you can set the notification icon and icon background color in here. You can use this link to generate notification icon.
Starting in Android 8.0 (API level 26), all notifications must be assigned to a channel or it will not appear. Reference
fcm_default_channel
, we set the notification channel id.
We’ll implement and explain firebase_service
and broadcast_receiver
later.
Permissions and FCM Token
FCM Token is necessary if you want to target single device.
Now, we need to ask for notification permission from the user and retrieve the FCM token. We’ll ask permission as soon as user launches the application but it’s not the recommended way.
Recommended Implementation: Generally, you should display a UI explaining to the user the features that will be enabled if they grant permissions for the app to post notifications. This UI should provide the user options to agree or deny, such as OK and No thanks buttons. If the user selects OK, directly request the permission. If the user selects No thanks, allow the user to continue without notifications.
Now we can start the implementation. We’ll ask for permission and retrieve token in MainActivity.kt
,
private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) {
// FCM SDK (and your app) can post notifications.
Log.d(TAG, "Granted")
} else {
// TODO: Inform user that that your app will not show notifications.
Log.d(TAG, "Failed")
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//...
getFCMToken()
askNotificationPermission()
}
private fun getFCMToken() {
FirebaseMessaging.getInstance().token.addOnCompleteListener(OnCompleteListener { task ->
if (!task.isSuccessful) {
Log.d(TAG, "Fetching FCM registration token failed", task.exception)
return@OnCompleteListener
}
// Get new FCM registration token
val token = task.result
Log.d(TAG, "Token is $token")
})
}
private fun askNotificationPermission() {
// This is only necessary for API level >= 33 (TIRAMISU)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
) {
// FCM SDK (and your app) can post notifications.
} else if (shouldShowRequestPermissionRationale(POST_NOTIFICATIONS)) {
// TODO: Display UI Here
} else {
// Directly ask for the permission
requestPermissionLauncher.launch(POST_NOTIFICATIONS)
}
}
}
FCM Notification
Firebase notifications behave differently depending on the foreground/background state of the receiving app. If you want foregrounded apps to receive notification messages or data messages, you’ll need to use a service that extends FirebaseMessagingService.
Now, we’ll create FirebaseMessagingService
,
class FirebaseMessagingService: FirebaseMessagingService() {
companion object {
const val CHANNEL_NAME = "Test Notification"
const val GROUP_NAME = "Test Group Notification"
const val GROUP_ID = "test.notification"
const val PATH_EXTRA = "path"
const val DATA_EXTRA = "data"
}
override fun onNewToken(token: String) {
super.onNewToken(token)
handleNewToken(token)
}
private fun handleNewToken(token: String) {
Log.d(TAG, "handleNewToken: $token")
}
override fun onMessageReceived(remoteMessage: RemoteMessage) {
// Check if message contains a data payload.
if (remoteMessage.data.isNotEmpty()) {
Log.d(TAG, "Message data payload: ${remoteMessage.data}")
}
// Check if message contains a notification payload.
remoteMessage.notification?.let {
Log.d(TAG, "Message Notification Body: ${it.body}")
sendNotification(it.title, it.body, remoteMessage.data)
}
}
private fun sendNotification(
title: String?,
messageBody: String?,
data: Map<String, String>
) {
val intent = Intent(this, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP
for (i in 0 until data.size) {
val key = data.keys.toList()[i]
val value = data.values.toList()[i]
intent.putExtra(key, value)
}
val pendingIntent = PendingIntent.getActivity(
this, 0, intent,
PendingIntent.FLAG_IMMUTABLE
)
val defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
val channelId = getString(R.string.notification_channel_id)
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val notification = setNotification(
channelId,
title,
messageBody,
defaultSoundUri,
GROUP_ID,
pendingIntent
)
val groupNotification = setGroupNotification(
channelId,
GROUP_ID,
true,
"$title $messageBody",
"New Notifications",
"Notifications Grouped"
)
//ID of notification
notificationManager.notify(System.currentTimeMillis().toInt(), notification.build())
notificationManager.notify(0, groupNotification)
}
}
onNewToken
is called if the FCM token is updated. Also note that this is called when the FCM registration token is initially generated so this is where you would retrieve the token.
handleNewToken
, after obtaining the token, in an ideal scenario we can send FCM Token to backend and store it.
onMessageReceived
is called when a notification message is received while the app is in the foreground.
When your app is in the background, notification messages are displayed in the system tray, and
onMessageReceived
is not called. For notification messages with a data payload, the notification message is displayed in the system tray, and the data that was included with the notification message can be retrieved from the intent launched when the user taps on the notification.
sendNotification
, creates and shows a simple notification containing the received FCM message.
pendingIntent
is for tap action. Every notification should respond to a tap, usually to open an activity in your app that corresponds to the notification.- With the help of extension functions that we’ve created earlier, we set notification and notify the
notificationManager
. notificationManager.notify(ID, notification)
, ID is very important. If notification ids are the same, it overwrites the previous notification. Since we don’t have any unique id for notification or we don’t pass id with notification data, we’ll useSystem.currentTimeMillis
.- Finally, we put incoming data to
intent
via.putExtra(key, value)
.
Let’s retrieve the data in MainActivity
,
class MainActivity : AppCompatActivity() {
//...
override fun onCreate(savedInstanceState: Bundle?) {
//...
handleIntentData(intent?.extras)
getFCMToken()
askNotificationPermission()
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
handleIntentData(intent?.extras)
}
private fun handleIntentData(extras: Bundle?) {
if (extras != null) {
val data = extras.getString(DATA_EXTRA)
when(extras.getString(PATH_EXTRA)) {
"dashboard" -> {
navController.navigate(R.id.action_global_navigation_dashboard, bundleOf(
DATA_EXTRA to data
))
}
}
}
}
//...
}
onNewIntent
, when the activity is re-launched while at the top of the activity stack instead of a new instance of the activity being started, onNewIntent() will be called on the existing instance with the Intent that was used to re-launch it.
handleIntentData
will retrieve the data that notification has. In our case, we are expecting two data, DATA_EXTRA
and PATH_EXTRA
.
PATH_EXTRA
will be used for navigation. If data is set to"dashboard"
we’ll navigate toDashboardFragment
.DATA_EXTRA
is any other data that we can pass forDashboardFragment
. In our case, it’s string. We’ll pass it while navigating withbundleOf
. To retrieve theDATA_EXTRA
inDashboardFragment
,
class DashboardFragment : Fragment() {
//...
private var data: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
arguments?.let {
data = it.getString(DATA_EXTRA)
}
}
//...
}
It’s that simple.
Local Notification
Local Notification w/Big Text
class NotificationsFragment : Fragment() {
//...
private val notificationManager by lazy { context?.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager }
private val redirectChannelId = 1
private val actionChannelID = 2
private fun sendLocalTestNotificationWithRedirect() {
val intent = Intent(context, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
intent.putExtra(PATH_EXTRA, "dashboard")
val pendingIntent = PendingIntent.getActivity(
context, 0, intent,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else PendingIntent.FLAG_UPDATE_CURRENT
)
val defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
val notification = context?.setNotification(
getString(R.string.notification_channel_id),
"Local Notification Ch 1",
getString(R.string.short_text),
defaultSoundUri,
null,
pendingIntent
)
notification?.apply {
setStyle(
NotificationCompat.BigTextStyle()
.bigText(getString(R.string.long_text))
)
}
notificationManager.notify(redirectChannelId, notification?.build())
}
}
This notification is very simple, it’s similar to what we’ve already done.
FLAG_ACTIVITY_NEW_TASK
, this activity will become the start of a new task on this history stack.FLAG_ACTIVITY_SINGLE_TOP
, the activity will not be launched if it is already running at the top of the history stack.- We’ll use
putExtra
to add tap action. FLAG_MUTABLE
, flag indicating that the createdPendingIntent
should be mutable.FLAG_UPDATE_CURRENT
, flag indicating that if the describedPendingIntent
already exists, then keep it but replace its extra data with what is in this new Intent..bigText
will add expandable big text like the image below.
Local Notification w/Action & Broadcast Receiver
Before we set notification, let’s implement Broadcast Receiver,
Android apps can send or receive broadcast messages from the Android system and other Android apps, similar to the publish-subscribe design pattern. These broadcasts are sent when an event of interest occurs.
class NotificationActionBroadcastReceiver: BroadcastReceiver() {
companion object {
const val CHANNEL_ID_EXTRA = "channel_id"
const val FIRST_ACTION = "first"
const val FIRST_ACTION_EXTRA = "first_extra"
const val TEXT_ACTION = "text"
}
override fun onReceive(context: Context?, intent: Intent?) {
val channelId = intent?.extras?.getInt(CHANNEL_ID_EXTRA) ?: 0
val firstActionExtra = intent?.extras?.getString(FIRST_ACTION_EXTRA)
when(intent?.action) {
FIRST_ACTION -> {
if (firstActionExtra != null) {
context?.let {
val notificationManager = it.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val pendingIntent = PendingIntent.getActivity(
it, 0, intent,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else PendingIntent.FLAG_UPDATE_CURRENT
)
val newNotification = it.setNotification(
it.getString(R.string.notification_channel_id),
"Action",
"Data is, $firstActionExtra",
null, null, pendingIntent
)
notificationManager.notify(channelId, newNotification.build())
}
}
}
TEXT_ACTION -> {
if (getMessageText(intent) != null) {
context?.let {
val notificationManager = it.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val pendingIntent = PendingIntent.getActivity(
it, 0, intent,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else PendingIntent.FLAG_UPDATE_CURRENT
)
val newNotification = it.setNotification(
it.getString(R.string.notification_channel_id),
"Success, message sent!",
getMessageText(intent).toString(),
null, null, pendingIntent
)
notificationManager.notify(channelId, newNotification.build())
}
}
}
}
}
private fun getMessageText(intent: Intent): CharSequence? {
return RemoteInput.getResultsFromIntent(intent)?.getCharSequence(TEXT_ACTION)
}
}
onReceive
, we’ll receive channelId
and firstActionExtra
in Broadcast Receiver. Finally, we’ll check action type with intent?.action
,
FIRST_ACTION
is to handle simple action press on notification. In our example, we’ll retrieve thefirstActionExtra
data, overwrite current notification and show new notification withfirstActionExtra
.TEXT_ACTION
is for handling reply function.getMessageText
will retrieve the sent message, overwrite current notification and show new notification withgetMessageText
.
class NotificationsFragment : Fragment() {
//...
private val notificationManager by lazy { context?.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager }
private val redirectChannelId = 1
private val actionChannelID = 2
private fun sendLocalTestNotificationWithRedirect() {
//...
}
private fun sendLocalTestNotificationWithAction() {
val intent = Intent(context, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
intent.putExtra(PATH_EXTRA, "dashboard")
val pendingIntent = PendingIntent.getActivity(
context, 0, intent,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else PendingIntent.FLAG_UPDATE_CURRENT
)
//Redirect Action
val actionIntent = Intent(context, NotificationActionBroadcastReceiver::class.java).apply {
action = FIRST_ACTION
putExtra(FIRST_ACTION_EXTRA, "Action Extra")
putExtra(CHANNEL_ID_EXTRA, actionChannelID)
}
val actionPendingIntent = PendingIntent.getBroadcast(
context, 0, actionIntent, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else PendingIntent.FLAG_UPDATE_CURRENT
)
//Reply Action
val remoteInput = RemoteInput.Builder(TEXT_ACTION).run {
setLabel("Text Input")
build()
}
val replyIntent = Intent(context, NotificationActionBroadcastReceiver::class.java).apply {
action = TEXT_ACTION
putExtra(CHANNEL_ID_EXTRA, actionChannelID)
}
val replyPendingIntent = PendingIntent.getBroadcast(
context,
10, //message id
replyIntent,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else PendingIntent.FLAG_UPDATE_CURRENT
)
val defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
val notification = context?.setNotification(
getString(R.string.notification_channel_id),
"Local Notification Ch 2",
"Notification with action",
defaultSoundUri,
null,
pendingIntent
)
notification?.apply {
addAction(R.drawable.ic_dashboard_black_24dp, "Action", actionPendingIntent)
addAction(
NotificationCompat.Action.Builder(
R.drawable.ic_stat_test, "Reply", replyPendingIntent
).addRemoteInput(remoteInput).build()
)
}
notificationManager.notify(actionChannelID, notification?.build())
}
}
In Redirect Action
, we set action
as FIRST_ACTION
and send extra variables.
In Reply Action
, again we set action
and send extras. We use getBroadcast
to retrieve a PendingIntent that will perform a broadcast.
Finally, we set the actions with addAction
.
- For reply action, we pass
remoteInput
that we’ve created. ARemoteInput
object specifies input to be collected from a user to be passed along with an intent inside aPendingIntent
that is sent.
That’s it! I hope it was useful. 👋👋
Full Code
MrNtlu/Notification-Guide: FCM & Local Notification (github.com)
Sources:
- Notification runtime permission | Android Developers
- Notifications Overview | Android Developers
- Set up a Firebase Cloud Messaging client app on Android (google.com)
- Broadcasts overview | Android Developers
- PendingIntent | Android Developers
- https://youtu.be/LP623htmWcI
- Android: remove notification from notification bar — Stack Overflow
- android — How to open particular fragment on the click of the push notification message? — Stack Overflow
- Start an Activity from a Notification | Android Developers
- Create a Notification | Android Developers