Skip to main content

Android - Notification Module Interface

The NotificationModule interface is the modern way for a module to participate in push notifications: FCM data messages, notification taps, channel creation, and push enrollment events. It replaces the older PushReceiverModule — which is deprecated as of MSDK Android 26.7.0 but still dispatched during the deprecation window — with a result-typed dispatch model and optional owner-scoped delivery.

For URI-based deep links (ACTION_VIEW intents with a Uri), see DeepLinkModule instead. Push notifications and deep links are routed separately.

Available since

NotificationModule, NotificationReceiveResult, NotificationPayloadKeys, and NotificationChannelIds were added in MSDK Android 26.7.0 as part of the sdk_interfaces 0.4.0 bundle.

DevApp Setup

If your module needs push notification support, add the standard push dependencies to your DevApp build.gradle:

implementation "com.q2.push:push:$q2Version"
implementation "com.app.q2.modules.push.q2_push_service:q2_push_service:$q2Version"

Q2PushService and PushMessagingService route inbound FCM messages through the Q2RoutingService façade, which then dispatches to every registered NotificationModule (and, during the deprecation window, PushReceiverModule). No additional wiring is needed in the DevApp for the routing itself.

Implementation

Create a class and inherit NotificationModule. Every method has a default implementation — override only what your module needs.

Minimal — let the routing service post the notification

class MyNotifModule : NotificationModule {

override val notificationOwners = listOf("my_partner_id")

override fun onNotificationReceived(
context: Context,
remoteMessage: RemoteMessage
): NotificationReceiveResult {
// Owner-scoped: this only fires for payloads addressed to "my_partner_id".
return NotificationReceiveResult.DisplayDefault(channelId = MY_CHANNEL_ID)
}

override fun onNotificationTapped(intent: Intent): Boolean {
// Read FCM `data` keys forwarded as extras — q2_module_id is already here.
val target = intent.getStringExtra("targetScreen") ?: return false
navigateTo(target)
return true
}

override fun onCreateNotificationChannel(
context: Context,
notificationManager: NotificationManager
) {
notificationManager.createNotificationChannel(
NotificationChannel(MY_CHANNEL_ID, "My Alerts", NotificationManager.IMPORTANCE_DEFAULT)
)
}

private companion object { const val MY_CHANNEL_ID = "com.example.partner.alerts" }
}

Building your own notification

Return Consumed after you notify() yourself. The routing service does nothing further.

override fun onNotificationReceived(
context: Context,
remoteMessage: RemoteMessage
): NotificationReceiveResult {
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val notification = NotificationCompat.Builder(context, MY_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_logo)
.setContentTitle(remoteMessage.data["title"])
.setContentText(remoteMessage.data["body"])
.setContentIntent(myPendingIntent(context, remoteMessage))
.setAutoCancel(true)
.build()
nm.notify(remoteMessage.messageId.hashCode(), notification)
return NotificationReceiveResult.Consumed
}

Silent background work — no UI

override fun onNotificationReceived(
context: Context,
remoteMessage: RemoteMessage
): NotificationReceiveResult {
if (remoteMessage.data["type"] != "sync") return NotificationReceiveResult.NotHandled
WorkManager.getInstance(context).enqueue(OneTimeWorkRequestBuilder<SyncWorker>().build())
return NotificationReceiveResult.Consumed
}

Consumed with no notify() is an intentional silent handling. Routing stops for this payload.

Interface Reference

interface NotificationModule : SDKModule {

/**
* Identifiers this module claims for owner-scoped dispatch. A payload whose
* NotificationPayloadKeys.MODULE_ID matches any string here is delivered
* exclusively to this module — both on receipt and on tap.
*
* Default: empty (module participates in unscoped iteration only).
*/
val notificationOwners: List<String> get() = emptyList()

@WorkerThread
fun onNotificationReceived(
context: Context,
remoteMessage: RemoteMessage
): NotificationReceiveResult = NotificationReceiveResult.NotHandled

@MainThread
fun onNotificationTapped(intent: Intent): Boolean = false

@WorkerThread
fun onPushTokenUpdated(token: String) {}

@MainThread
fun onCreateNotificationChannel(
context: Context,
notificationManager: NotificationManager
) {}

@MainThread
fun onPushServiceEnabled() {}

@MainThread
fun onPushServiceDisabled() {}
}

NotificationReceiveResult

sealed class NotificationReceiveResult {
object Consumed : NotificationReceiveResult()
data class DisplayDefault(val channelId: String? = null) : NotificationReceiveResult()
object NotHandled : NotificationReceiveResult()
}
Return valueMeaning
ConsumedModule fully handled it. Stop routing. Side effects allowed.
DisplayDefault(channelId)Module owns the payload; routing service posts a default notification on its behalf. Uses the platform default channel if channelId is null or unknown. Tap extras carry every FCM data key.
NotHandledNot mine — try the next module. Must be side-effect free.

Dispatch Model

FCM payloads and tap intents are dispatched by Core's notification routing service. Two-stage routing:

  1. Owner-scoped (exclusive). If the FCM data block contains NotificationPayloadKeys.MODULE_ID and its value matches a string in some module's notificationOwners, only that module is consulted. A declined addressed payload falls back to the routing service's default display handler — never to other partner modules.
  2. Unscoped iteration. Otherwise every registered NotificationModule is offered the payload in registration order. First non-NotHandled result wins.
  3. Default handler. If nobody claims the payload, the routing service posts a default notification using the FCM notification block's title/body.

Tap dispatch follows the same two stages against Intent extras.

onNotificationReceived is not always called

FCM payload shapeApp stateonNotificationReceived called?
data-onlyany✅ yes
notification + dataforegrounded✅ yes
notification + databackgrounded / killed❌ no — the system auto-displays; data keys land on the launching intent → onNotificationTapped fires when tapped

For owner-scoped tap routing to work in the backgrounded case, the backend must include q2_module_id in the data block.

Threading

MethodThread
onNotificationReceived, onPushTokenUpdatedbackground (FCM)
onNotificationTapped, onCreateNotificationChannel, onPushServiceEnabled/Disabledmain

Do not launch activities or touch UI from onNotificationReceived. Use a PendingIntent on a posted notification, or hand off to WorkManager / coroutines.

Backend Payload — Owner-Scoped

{
"data": {
"q2_module_id": "my_partner_id",
"title": "Payment received",
"body": "$50 from Alice",
"targetScreen": "payments/123"
}
}

Always reference NotificationPayloadKeys.MODULE_ID in code — never hardcode "q2_module_id".

Shared Constants

sdk_interfaces ships two constants objects that partner modules should reference rather than duplicating string literals:

object NotificationPayloadKeys {
/** Identifies which module owns this payload. Matches NotificationModule.notificationOwners. */
const val MODULE_ID = "q2_module_id"
}

object NotificationChannelIds {
/** Channel for security-related alerts (login attempts, account lock, etc.). */
const val SECURITY_ALERTS = "com.q2.sdk_interfaces.security_alerts"
}

NotificationChannelIds.SECURITY_ALERTS replaces the nested PushReceiverModule.NotificationChannelId.SECURITY_ALERTS constant, which is deprecated with a @ReplaceWith hint pointing at the new object.

Registration

NotificationModule implementations are registered through the standard SDK module registration in settings.json (sdk_modules list). The routing service pulls them via SdkModuleStore.getNotificationModuleList() — no extra wiring needed.

Learn more in Configuring settings.json.

Migrating from PushReceiverModule

PushReceiverModule is @Deprecated in 26.7.0 but continues to be dispatched. SdkModuleStore logs a warning if a module implements both NotificationModule and PushReceiverModule — remove PushReceiverModule once you've migrated.

PushReceiverModule (deprecated)NotificationModule (26.7.0+)
willConsumeNotification(context, remoteMessage): BooleanonNotificationReceived(context, remoteMessage): NotificationReceiveResult
(none — implicit Boolean claim)notificationOwners: List<String> for owner-scoped exclusive dispatch
onNewToken(token)onPushTokenUpdated(token)
createNotificationChannel(context, notificationManager)onCreateNotificationChannel(context, notificationManager)
onPushEnabled() / onPushDisabled()onPushServiceEnabled() / onPushServiceDisabled()
(implicit — tap handled via subclassing FirebaseMessagingService)onNotificationTapped(intent): Boolean
PushReceiverModule.NotificationChannelId.SECURITY_ALERTSNotificationChannelIds.SECURITY_ALERTS