🎯 Learning Path:

  1. Complete REL-ID Complete Activation & Login Flow Codelab
  2. You are here → Push Notification Integration

Welcome to the REL-ID Push Notification Integration codelab! This tutorial enhances your existing REL-ID application with secure push notification capabilities using REL-ID SDK's getDeviceToken callback.

What You'll Build

In this codelab, you'll enhance your existing REL-ID application with:

What You'll Learn

By completing this codelab, you'll master:

  1. REL-ID Push Architecture: Understanding two-channel security model
  2. getDeviceToken Callback: Complete implementation of REL-ID device token callback
  3. Device Token Management: Implementing token retrieval, caching, and refresh cycles
  4. Service Architecture: Building scalable push notification services with singleton patterns
  5. REL-ID SDK Integration: Best practices for integrating with existing REL-ID service layer
  6. Production Deployment: Security best practices, error handling, and monitoring strategies

Prerequisites

Before starting this codelab, ensure you have:

Get the Code from GitHub

The code to get started can be found in a GitHub repository.

You can clone the repository using the following command:

git clone https://github.com/uniken-public/codelab-android.git

Navigate to the relid-push-notification-token folder in the repository you cloned earlier

Why REL-ID Push Notifications?

REL-ID push notifications provide a secure, two-channel architecture that goes beyond standard push messaging with secure wake-up signals, MITM-proof channels, transaction approvals, and device-bound credentials.

REL-ID Flow: FCM Wake-up → App Launch → Secure REL-ID Channel → Encrypted Data Retrieval → User Action

Codelab Architecture Overview

This codelab implements two core components:

  1. PushNotificationService: Singleton service managing FCM tokens and REL-ID registration via callback
  2. MyFirebaseMessagingService: System service handling token refresh events

Before implementing push notifications, let's understand how REL-ID's secure notification system works.

REL-ID Two-Channel Security Model

REL-ID uses a sophisticated two-channel approach for maximum security:

📱 REL-ID Server → FCM (Wake-up Signal) → Mobile App → REL-ID Secure Channel → Encrypted Data

Channel 1: FCM Wake-Up Signal

Channel 2: REL-ID Secure Channel

Device Token Registration Flow

Here's how the getDeviceToken() callback enables secure REL-ID communications:

// Device Registration Flow
FCM Token Generation → Cache Token → SDK calls getDeviceToken() →
Return Cached Token → REL-ID Backend Registration → Secure Channel Establishment

Step

Description

Security Benefit

1. FCM Token

Generate Firebase Cloud Messaging token

Device uniqueness

2. Cache Token

Store token in PushNotificationService

Immediate callback response

3. SDK Callback

SDK invokes getDeviceToken() during initialization

Token retrieval

4. REL-ID Registration

SDK registers device with REL-ID backend

Device-server binding

5. Secure Channel

Establish encrypted communication channel

MITM protection

6. Transaction Support

Enable approve/reject actions with MFA

Multi-factor security

REL-ID Push Notification Use Cases

Once integrated, your app can handle these secure notification types:

Firebase Role: Provides the platform infrastructure (FCM token generation and message delivery)

REL-ID Role: Provides the secure communication and transaction approval capabilities

Now let's implement the core push notification service that handles FCM token management and REL-ID SDK integration via the getDeviceToken() callback.

Create PushNotificationService

Create the singleton service that manages FCM token generation and caching:

// app/src/main/java/com/yourpackage/uniken/services/PushNotificationService.kt

package com.relidcodelab.uniken.services

import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.core.content.ContextCompat
import com.google.firebase.messaging.FirebaseMessaging
import kotlinx.coroutines.tasks.await

/**
 * PushNotificationService - FCM Token Manager for REL-ID SDK
 *
 * Handles Firebase Cloud Messaging (FCM) token generation and caching.
 * The cached token is returned to SDK via getDeviceToken() callback.
 *
 * Flow:
 * 1. App startup → initialize() generates FCM token
 * 2. Token is cached in memory
 * 3. SDK calls getDeviceToken() callback → returns cached token
 *
 * Features:
 * - FCM token retrieval and caching
 * - Android 13+ POST_NOTIFICATIONS permission handling
 * - Automatic token refresh handling
 * - Thread-safe token storage
 *
 * Usage:
 * ```kotlin
 * // Before SDK initialization
 * PushNotificationService.initialize(context)
 *
 * // In RDNACallbackManager.getDeviceToken()
 * return PushNotificationService.getCachedToken()
 * ```
 */
object PushNotificationService {

    private const val TAG = "PushNotificationService"
    private var isInitialized = false

    /**
     * Cached FCM token - accessed by getDeviceToken() callback
     * Thread-safe using @Volatile
     */
    @Volatile
    private var cachedFcmToken: String = ""

    /**
     * Get cached FCM token for SDK callback
     *
     * Called by RDNACallbackManager.getDeviceToken() when SDK requests the token.
     *
     * @return Cached FCM token string, or empty string if not available
     */
    fun getCachedToken(): String {
        Log.d(TAG, "getCachedToken() called, returning: ${if (cachedFcmToken.isNotEmpty()) "token (${cachedFcmToken.length} chars)" else "empty"}")
        return cachedFcmToken
    }

    /**
     * Initialize FCM and cache token for SDK
     *
     * MUST be called BEFORE SDK initialization so token is available
     * when SDK invokes getDeviceToken() callback.
     *
     * Handles permission checks for Android 13+ (POST_NOTIFICATIONS).
     *
     * @param context Application context
     */
    suspend fun initialize(context: Context) {
        if (isInitialized) {
            Log.d(TAG, "Already initialized")
            return
        }

        Log.d(TAG, "Starting FCM initialization for Android ${Build.VERSION.SDK_INT}")

        try {
            // Check notification permission (Android 13+)
            val hasPermission = checkNotificationPermission(context)
            if (!hasPermission) {
                Log.w(TAG, "POST_NOTIFICATIONS permission not granted")
                Log.w(TAG, "Note: Permission should be requested in UI (Activity)")
                // Continue anyway - token can still be generated
            }

            // Get and cache FCM token
            generateAndCacheToken()

            // Setup token refresh listener
            setupTokenRefreshListener()

            isInitialized = true
            Log.d(TAG, "Initialization complete - token cached and ready for SDK callback")
        } catch (error: Exception) {
            Log.e(TAG, "Initialization failed", error)
            throw error
        }
    }

    /**
     * Check notification permission (Android 13+)
     *
     * For Android 13 (API 33) and above, POST_NOTIFICATIONS permission is required.
     * For older versions, notifications are enabled by default.
     *
     * @param context Application context
     * @return true if permission granted or not required, false otherwise
     */
    private fun checkNotificationPermission(context: Context): Boolean {
        Log.d(TAG, "Platform Version: ${Build.VERSION.SDK_INT}")

        // Android 13+ (API 33+) requires POST_NOTIFICATIONS permission
        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            val granted = ContextCompat.checkSelfPermission(
                context,
                Manifest.permission.POST_NOTIFICATIONS
            ) == PackageManager.PERMISSION_GRANTED

            Log.d(TAG, "POST_NOTIFICATIONS permission: ${if (granted) "granted" else "denied"}")
            granted
        } else {
            // Older Android versions don't require runtime permission
            Log.d(TAG, "Android < 13, POST_NOTIFICATIONS not required")
            true
        }
    }

    /**
     * Generate FCM token and cache it for SDK callback
     *
     * The cached token will be returned by getDeviceToken() callback
     * when the SDK requests it during initialization.
     */
    private suspend fun generateAndCacheToken() {
        try {
            Log.d(TAG, "Generating FCM token for Android")

            // Get FCM token using coroutine-friendly await()
            val token = FirebaseMessaging.getInstance().token.await()

            if (token.isNullOrEmpty()) {
                Log.w(TAG, "No FCM token received for Android")
                cachedFcmToken = ""
                return
            }

            // Cache token for getDeviceToken() callback
            cachedFcmToken = token

            Log.d(TAG, "FCM token generated and cached, length: ${token.length}")
            Log.d(TAG, "FCM TOKEN: $token")
            Log.d(TAG, "Token ready for SDK getDeviceToken() callback")
        } catch (error: Exception) {
            Log.e(TAG, "Token generation failed", error)
            cachedFcmToken = ""
            throw error
        }
    }

    /**
     * Set up automatic token refresh listener
     *
     * Handles token refresh and updates the cache.
     * The SDK can retrieve the updated token on next getDeviceToken() call.
     */
    private fun setupTokenRefreshListener() {
        Log.d(TAG, "Setting up token refresh listener for Android")

        // Note: Token refresh is handled by MyFirebaseMessagingService
        // which extends FirebaseMessagingService and overrides onNewToken()
        // That service should call updateCachedToken() when token refreshes
        Log.d(TAG, "Token refresh will be handled by MyFirebaseMessagingService")
    }

    /**
     * Update cached token (called by FirebaseMessagingService on refresh)
     *
     * This should be called from MyFirebaseMessagingService.onNewToken()
     * to update the cached token when Firebase refreshes it.
     *
     * @param newToken Refreshed FCM token
     */
    fun updateCachedToken(newToken: String) {
        Log.d(TAG, "Updating cached token, length: ${newToken.length}")
        cachedFcmToken = newToken
        Log.d(TAG, "REFRESHED FCM TOKEN: $newToken")
        Log.d(TAG, "Updated token will be available for next SDK callback")
    }

    /**
     * Get current FCM token directly from Firebase (for debugging)
     *
     * This bypasses the cache and queries Firebase directly.
     *
     * @return Current FCM token or null if not available
     */
    suspend fun getCurrentTokenFromFirebase(): String? {
        return try {
            FirebaseMessaging.getInstance().token.await()
        } catch (error: Exception) {
            Log.e(TAG, "Failed to get current token from Firebase", error)
            null
        }
    }

    /**
     * Cleanup (reset initialization state and cached token)
     */
    fun cleanup() {
        Log.d(TAG, "Cleanup")
        isInitialized = false
        cachedFcmToken = ""
    }
}

Service Architecture Benefits

This implementation follows enterprise-grade patterns:

Pattern

Benefit

Implementation

Singleton

Single point of control

object declaration

Caching

Immediate callback response

@Volatile cached token

Coroutines

Non-blocking async operations

suspend fun with .await()

State Management

Prevents double initialization

isInitialized flag

Permission Handling

Android 13+ compatibility

Version checks with Build.VERSION

Create a Firebase Messaging Service to handle token refresh events automatically.

Create MyFirebaseMessagingService

This service extends FirebaseMessagingService to handle token refresh:

// app/src/main/java/com/yourpackage/uniken/services/MyFirebaseMessagingService.kt

package com.relidcodelab.uniken.services

import android.util.Log
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage

/**
 * MyFirebaseMessagingService - Handles FCM token refresh and push messages
 *
 * Extends FirebaseMessagingService to handle:
 * - Token refresh events (onNewToken)
 * - Incoming push notifications (onMessageReceived)
 *
 * This service is automatically invoked by Firebase when:
 * 1. App is first installed and token is generated
 * 2. Token is refreshed by Firebase
 * 3. Push notifications are received
 *
 * Registration:
 * Must be declared in AndroidManifest.xml with intent-filter for firebase messaging
 */
class MyFirebaseMessagingService : FirebaseMessagingService() {

    companion object {
        private const val TAG = "MyFCMService"
    }

    /**
     * Called when FCM token is refreshed
     *
     * This is called by Firebase when:
     * - App is first installed
     * - Token is refreshed
     * - App is reinstalled
     * - App data is cleared
     *
     * The new token must be sent to the REL-ID SDK by updating the cache.
     *
     * @param token The new FCM token
     */
    override fun onNewToken(token: String) {
        super.onNewToken(token)

        Log.d(TAG, "FCM token refreshed")
        Log.d(TAG, "New token length: ${token.length}")
        Log.d(TAG, "NEW FCM TOKEN: $token")

        // Update cached token for getDeviceToken() callback
        PushNotificationService.updateCachedToken(token)

        Log.d(TAG, "Token cache updated - will be available for next SDK callback")
    }

    /**
     * Called when a push notification message is received
     *
     * This is called when the app is in the foreground and a notification arrives.
     * When the app is in the background, the system handles the notification automatically.
     *
     * @param remoteMessage The message received from Firebase
     */
    override fun onMessageReceived(remoteMessage: RemoteMessage) {
        super.onMessageReceived(remoteMessage)

        Log.d(TAG, "Push notification received from: ${remoteMessage.from}")

        // Handle notification data payload
        if (remoteMessage.data.isNotEmpty()) {
            Log.d(TAG, "Message data payload: ${remoteMessage.data}")
            // TODO: Handle notification data if needed
        }

        // Handle notification message
        remoteMessage.notification?.let {
            Log.d(TAG, "Message Notification Title: ${it.title}")
            Log.d(TAG, "Message Notification Body: ${it.body}")
            // TODO: Display notification or handle as needed
        }
    }
}

Token Refresh Flow

Here's how automatic token refresh works:

┌─────────────────────────────────────────────────────────┐
│ 1. Firebase detects token refresh needed                │
│    - Token expiry, reinstall, clear data, etc.         │
└─────────────────────┬───────────────────────────────────┘
                      ↓
┌─────────────────────────────────────────────────────────┐
│ 2. MyFirebaseMessagingService.onNewToken(token)        │
│    - Called by Firebase system automatically            │
└─────────────────────┬───────────────────────────────────┘
                      ↓
┌─────────────────────────────────────────────────────────┐
│ 3. PushNotificationService.updateCachedToken(token)    │
│    - Update @Volatile cachedFcmToken variable          │
└─────────────────────┬───────────────────────────────────┘
                      ↓
┌─────────────────────────────────────────────────────────┐
│ 4. Next SDK callback retrieves refreshed token         │
│    - RDNACallbackManager.getDeviceToken() returns new  │
└─────────────────────────────────────────────────────────┘

Now integrate the push notification service with your REL-ID SDK callback manager.

Update RDNACallbackManager

Add the getDeviceToken() callback implementation to your existing RDNACallbackManager:

// app/src/main/java/com/yourpackage/uniken/services/RDNACallbackManager.kt

class RDNACallbackManager(
    private val context: Context,
    private val currentActivity: Activity?
) : RDNA.RDNACallbacks {

    companion object {
        private const val TAG = "RDNACallbackManager"
    }

    // ... other callback implementations ...

    /**
     * Get device FCM token for push notifications
     *
     * This callback is invoked by the SDK to retrieve the device's FCM push notification token.
     * The token should be generated and cached by PushNotificationService before SDK initialization.
     *
     * @return FCM device token string, or empty string if not available
     */
    override fun getDeviceToken(): String {
        val token = PushNotificationService.getCachedToken()
        Log.d(TAG, "SDK requested device token, returning: ${if (token.isNotEmpty()) "token (${token.length} chars)" else "empty"}")
        return token
    }
}

Update MainActivity

Initialize FCM BEFORE SDK initialization in your MainActivity:

// app/src/main/java/com/yourpackage/MainActivity.kt

package com.relidcodelab

import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.lifecycle.lifecycleScope
import com.relidcodelab.uniken.services.PushNotificationService
import com.relidcodelab.uniken.services.RDNACallbackManager
import com.relidcodelab.uniken.services.RDNAService
import kotlinx.coroutines.launch

class MainActivity : ComponentActivity() {

    companion object {
        private const val TAG = "MainActivity"
    }

    private lateinit var callbackManager: RDNACallbackManager
    private val rdnaService = RDNAService

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        enableEdgeToEdge()

        Log.d(TAG, "onCreate - Initializing MainActivity")

        // CRITICAL: Initialize FCM BEFORE SDK initialization
        // This ensures token is cached and ready for getDeviceToken() callback
        lifecycleScope.launch {
            try {
                PushNotificationService.initialize(applicationContext)
                Log.d(TAG, "FCM initialized - token cached and ready for SDK")
            } catch (error: Exception) {
                Log.e(TAG, "Failed to initialize FCM", error)
                // Don't crash app - token will be empty but app can still function
            }
        }

        // Initialize RDNA SDK instance
        rdnaService.getInstance(applicationContext)

        // Create callback manager (includes getDeviceToken implementation)
        callbackManager = RDNACallbackManager(
            context = applicationContext,
            currentActivity = this
        )

        setContent {
            // Your Compose UI here
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        Log.d(TAG, "onDestroy - Cleaning up")
        PushNotificationService.cleanup()
    }
}

Complete Token Registration Flow

Here's the complete flow from app startup to token registration:

┌─────────────────────────────────────────────────────────┐
│ 1. App Startup (MainActivity.onCreate)                  │
│    - enableEdgeToEdge()                                 │
│    - lifecycleScope.launch { ... }                      │
└─────────────────────┬───────────────────────────────────┘
                      ↓
┌─────────────────────────────────────────────────────────┐
│ 2. PushNotificationService.initialize(context)          │
│    - Check POST_NOTIFICATIONS permission               │
│    - FirebaseMessaging.getInstance().token.await()     │
│    - Cache token in @Volatile cachedFcmToken           │
└─────────────────────┬───────────────────────────────────┘
                      ↓
┌─────────────────────────────────────────────────────────┐
│ 3. RDNAService.getInstance(context)                     │
│    - Initialize RDNA instance (singleton)               │
└─────────────────────┬───────────────────────────────────┘
                      ↓
┌─────────────────────────────────────────────────────────┐
│ 4. RDNACallbackManager(context, activity)              │
│    - Implements RDNA.RDNACallbacks interface           │
│    - Includes getDeviceToken() implementation          │
└─────────────────────┬───────────────────────────────────┘
                      ↓
┌─────────────────────────────────────────────────────────┐
│ 5. RDNAService.initialize(context, callbackManager)    │
│    - Call rdna.Initialize(..., callbacks, ...)         │
│    - SDK invokes: callbacks.getDeviceToken()           │
└─────────────────────┬───────────────────────────────────┘
                      ↓
┌─────────────────────────────────────────────────────────┐
│ 6. RDNACallbackManager.getDeviceToken()                │
│    - Get from PushNotificationService.getCachedToken() │
│    - Return cached FCM token to SDK                    │
└─────────────────────┬───────────────────────────────────┘
                      ↓
┌─────────────────────────────────────────────────────────┐
│ 7. SDK registers token with backend                     │
│    - Token stored for push notification delivery       │
│    - SDK continues initialization (async events)        │
└─────────────────────────────────────────────────────────┘

Let's thoroughly test your push notification implementation with comprehensive scenarios to ensure production readiness.

Test Scenario 1: Complete Token Registration Flow

Prerequisites:

Test Steps:

  1. Build and Install Application
    ./gradlew installDebug
    
  2. Monitor Logcat Look for this successful initialization sequence:
    adb logcat | grep -E "(PushNotificationService|MyFCMService|RDNACallbackManager)"
    
    Expected log sequence:
    ✅ MainActivity: onCreate - Initializing MainActivity
    ✅ PushNotificationService: Starting FCM initialization for Android 33
    ✅ PushNotificationService: POST_NOTIFICATIONS permission: granted
    ✅ PushNotificationService: FCM token generated and cached, length: 163
    ✅ PushNotificationService: FCM TOKEN: [token string]
    ✅ MainActivity: FCM initialized - token cached and ready for SDK
    ✅ RDNACallbackManager: SDK requested device token, returning: token (163 chars)
    
  3. Verify Token Generation Confirm you see a valid FCM token logged (typically 150-170 characters)
  4. Test Token Refresh Clear app data and restart to trigger token refresh:
    adb shell pm clear com.relidcodelab
    ./gradlew installDebug
    
    Look for refresh logs:
    ✅ MyFCMService: FCM token refreshed
    ✅ MyFCMService: NEW FCM TOKEN: [token string]
    ✅ PushNotificationService: Token cache updated
    

Expected Results:

Test Scenario 2: Permission Handling

Android 13+ Permission Test:

  1. Install on Android 13+ device/emulator
  2. First launch - verify permission request appears
  3. Grant permission - verify FCM initialization continues
  4. Deny permission test:
    adb shell pm revoke com.relidcodelab android.permission.POST_NOTIFICATIONS
    
    Then restart app and verify graceful handling

Expected Permission Flow:

📱 POST_NOTIFICATIONS permission request → User grants → FCM initialization
📱 POST_NOTIFICATIONS permission request → User denies → Graceful fallback

Test Scenario 3: Firebase Configuration Validation

Test Missing google-services.json:

  1. Temporarily rename google-services.json to google-services.json.backup
  2. Build project: ./gradlew clean build
  3. Verify error handling - should see Google Services related build errors
  4. Restore file and rebuild successfully

Test Google Services Plugin:

# Verify Google Services plugin processing
cd android && ./gradlew app:dependencies | grep google-services

Production Readiness Checklist

Before deploying to production, verify:

Here are solutions to common issues you might encounter.

Android-Specific Issues

"No Firebase app found"

"FirebaseMessaging: Failed to get token"

"POST_NOTIFICATIONS permission denied"

"SDK getDeviceToken callback returns empty string"

"Token not cached when SDK calls callback"

"Build fails with Google Services plugin error"

"ClassNotFoundException: FirebaseMessaging"

Gradle sync fails

Follow these best practices for production-ready push notification implementation.

Security Best Practices

Token Security:

Permission Handling:

Code Obfuscation:

// Example ProGuard rules for Firebase
-keepattributes Signature
-keepattributes *Annotation*
-keep class com.google.firebase.** { *; }

Memory Management

Singleton Pattern:

Coroutine Scope:

Token Caching:

Threading

Coroutines for Async Operations:

lifecycleScope.launch {
    // Runs on Main dispatcher by default
    PushNotificationService.initialize(context)
}

Performance

Initialization Optimization:

Logging Strategy:

App Lifecycle

Handle Configuration Changes:

Background Token Refresh:

App Restart Scenarios:

Production Deployment

Environment Configuration:

Monitoring:

Testing:

Congratulations! You've successfully implemented secure push notification integration with the REL-ID SDK. Here's your complete implementation overview.

🚀 What You've Built

Secure Device Registration - FCM tokens registered with REL-ID backend for two-channel security ✅ Firebase Integration - Complete FCM setup with Google Services plugin and token management ✅ Production-Ready Service - Singleton architecture with error handling and token refresh ✅ Android Configuration - Google Services plugin, permissions, and notification handling ✅ SDK Callback Integration - getDeviceToken() callback implementation with cached tokens

Key Files Created/Modified

app/src/main/java/com/yourpackage/
├── uniken/services/
│   ├── PushNotificationService.kt     ✅ FCM token manager singleton
│   ├── MyFirebaseMessagingService.kt  ✅ Token refresh handler
│   ├── RDNACallbackManager.kt         ✅ Enhanced with getDeviceToken()
│   └── RDNAService.kt                 ✅ SDK wrapper (existing)
│
├── MainActivity.kt                    ✅ FCM initialization

app/
├── build.gradle.kts                   ✅ Firebase dependencies & Google Services plugin
├── google-services.json               ✅ Firebase configuration
└── src/main/
    └── AndroidManifest.xml            ✅ FCM service & POST_NOTIFICATIONS permission

build.gradle.kts                       ✅ Project-level Google Services plugin

Architecture Achievement

Your implementation demonstrates enterprise-grade patterns:

Component

Pattern

Benefit

PushNotificationService

Singleton Object

Centralized token management

MyFirebaseMessagingService

Service Extension

Automatic token refresh

RDNACallbackManager

Callback Implementation

Clean SDK integration

Firebase Configuration

Google Services Plugin

Auto-configuration

Error Handling

Graceful Degradation

Production reliability

Security Benefits Unlocked

Your REL-ID push notification integration now enables:

Key Android Patterns Learned

Additional Resources

🎉 Congratulations!

You've successfully implemented secure push notification capabilities that integrate seamlessly with the REL-ID security ecosystem. Your app can now participate in secure, two-channel communications for transaction approvals, authentication challenges, and security notifications.

Your users now have a more secure, responsive authentication experience with the power of REL-ID's push notification infrastructure!