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.
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 value | Meaning |
|---|---|
Consumed | Module 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. |
NotHandled | Not 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:
- Owner-scoped (exclusive). If the FCM
datablock containsNotificationPayloadKeys.MODULE_IDand its value matches a string in some module'snotificationOwners, only that module is consulted. A declined addressed payload falls back to the routing service's default display handler — never to other partner modules. - Unscoped iteration. Otherwise every registered
NotificationModuleis offered the payload in registration order. First non-NotHandledresult wins. - Default handler. If nobody claims the payload, the routing service posts a
default notification using the FCM
notificationblock's title/body.
Tap dispatch follows the same two stages against Intent extras.
onNotificationReceived is not always called
| FCM payload shape | App state | onNotificationReceived called? |
|---|---|---|
data-only | any | ✅ yes |
notification + data | foregrounded | ✅ yes |
notification + data | backgrounded / 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
| Method | Thread |
|---|---|
onNotificationReceived, onPushTokenUpdated | background (FCM) |
onNotificationTapped, onCreateNotificationChannel, onPushServiceEnabled/Disabled | main |
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): Boolean | onNotificationReceived(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_ALERTS | NotificationChannelIds.SECURITY_ALERTS |