FCM Push Notification & Local Notification

Burak
ITNEXT
Published in
9 min readJan 18, 2023

--

In this article, we’ll implement Firebase Cloud Messaging (FCM) for push notification and local notification.

Table of Contents

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 use System.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 to DashboardFragment.
  • DATA_EXTRA is any other data that we can pass for DashboardFragment. In our case, it’s string. We’ll pass it while navigating with bundleOf. To retrieve the DATA_EXTRA in DashboardFragment,
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 created PendingIntent should be mutable.
  • FLAG_UPDATE_CURRENT, flag indicating that if the described PendingIntent 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 the firstActionExtra data, overwrite current notification and show new notification with firstActionExtra.
  • TEXT_ACTION is for handling reply function. getMessageText will retrieve the sent message, overwrite current notification and show new notification with getMessageText.
Actions
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. A RemoteInput object specifies input to be collected from a user to be passed along with an intent inside a PendingIntent that is sent.

That’s it! I hope it was useful. 👋👋

Full Code

MrNtlu/Notification-Guide: FCM & Local Notification (github.com)

Sources:

You can contact me on,

--

--