Push Module

May 5, 2026 · View on GitHub

Ping Identity

Push Module

The Push module facilitates multi-factor authentication using push notifications, supporting both PingAM and Ping AIC formats. This module is responsible for securely storing push credentials, allowing applications to register devices for push notifications, and processing incoming notifications.

Getting Started

Prerequisites

  • Ping Advanced Identity Cloud / PingAM Supported Versions
  • Android API level 29 or higher
  • Firebase Cloud Messaging (FCM) configured for your application
  • Push Notification Service from Ping Identity (PingAM or Ping AIC)

Installation

To integrate this module into your Android project, include the following dependency in your build.gradle.kts (or build.gradle) file:

dependencies {
    implementation("com.pingidentity.sdks:push:<version>")

    // Firebase dependencies for push notifications
    implementation("com.google.firebase:firebase-messaging:<version>")
}

Replace <version> with the latest available version of the SDK from the Maven repository. Ensure your project's repositories block includes Maven Central or the Ping Identity Maven repository.

Usage

Initialize the Push Client

Before using the Push MFA functionality, you need to initialize the PushClient. There are several ways to create and initialize a PushClient:

Basic Initialization

// Create a client with default configuration (not initialized)
val pushClient = PushClient()

// Initialize the client
pushClient.initialize()

Initialize with Custom Configuration

// Create with custom configuration using DSL-style builder
val pushClient = PushClient {
    enableCredentialCache = true
    timeoutMs = 30000
    // Any other configuration options
}

The DSL-style initialization automatically calls initialize() for you.

Register a Device

You can register a device for push notifications in several ways:

Using a QR Code URI (for PingAM)

// Parse a push QR code URI (supported by PingAM)
val uri = "pushauth://push/issuer:user@example.com?key=ABCDEFGHIJK&c=https://example.com/push"

// Using onSuccess/onFailure callbacks
pushClient.addCredentialFromUri(uri).onSuccess { credential ->
    // Handle the successfully created credential
    println("Created credential: ${credential.issuer}")
}.onFailure { exception ->
    // Handle error
    println("Failed to add credential: ${exception.message}")
}

// Alternative: Using getOrThrow()
try {
    val credential = pushClient.addCredentialFromUri(uri).getOrThrow()
    // Use credential
} catch (e: Exception) {
    // Handle exception
}

Process a Push Notification

When you receive a push notification from Firebase Cloud Messaging, you need to process it using the Push SDK:

// When a push notification is received (e.g., from Firebase)
val messageData = remoteMessage.data
val notification = pushClient.processNotification(messageData)

// Show notification to user based on type
if (notification != null) {
    when (notification.pushType) {
        PushType.DEFAULT -> showDefaultNotification(notification)
        PushType.CHALLENGE -> showChallengeNotification(notification)
        PushType.BIOMETRIC -> showBiometricPrompt(notification)
    }
}

Handling Different Types of Push Notifications

The Push module supports several types of authentication flows:

Default Push Notifications

Simple approve/deny without additional verification:

// To approve
pushClient.approveNotification(notificationId)
    .onSuccess { success ->
        if (success) {
            // Notify user of successful authentication
        } else {
            // Handle failure
        }
    }
    .onFailure { error ->
        // Handle error
    }

// To deny
pushClient.denyNotification(notificationId)
    .onSuccess { success ->
        if (success) {
            // Notify user of successful denial
        } else {
            // Handle failure
        }
    }
    .onFailure { error ->
        // Handle error
    }

Challenge-based Push Notifications

These require the user to verify a challenge (usually numbers):

// The user sees matching numbers on both login screen and mobile device
// They enter or select the challenge response
val challengeResponse = userSelectedResponse // e.g., "80"

pushClient.approveChallengeNotification(notificationId, challengeResponse)
    .onSuccess { success ->
        if (success) {
            // Notify user of successful authentication
        } else {
            // Handle failure (possibly wrong challenge response)
        }
    }
    .onFailure { error ->
        // Handle error
    }

Biometric Push Notifications

These require biometric authentication:

// After successful biometric authentication
val authMethod = "fingerprint" // or "face", "iris", etc.

pushClient.approveBiometricNotification(notificationId, authMethod)
    .onSuccess { success ->
        if (success) {
            // Notify user of successful authentication
        } else {
            // Handle failure
        }
    }
    .onFailure { error ->
        // Handle error
    }

Managing Push Notifications

Getting Pending Notifications

You can retrieve pending notifications that have not been approved or denied:

// Get all pending notifications
pushClient.getPendingNotifications().onSuccess { notifications ->
    if (notifications.isNotEmpty()) {
        // Display pending notifications to the user
        displayPendingNotifications(notifications)
    } else {
        // No pending notifications
        showEmptyState()
    }
}.onFailure { error ->
    // Handle error
    showError("Failed to retrieve notifications: ${error.message}")
}

// Get a specific notification by ID
pushClient.getNotification(notificationId).onSuccess { notification ->
    if (notification != null) {
        // Display the notification details
        showNotificationDetails(notification)
    } else {
        // Notification not found
        showNotFoundMessage()
    }
}.onFailure { error ->
    // Handle error
    showError("Failed to retrieve notification: ${error.message}")
}

Device Token Management

// Update the device token when it changes
val deviceToken = firebaseMessaging.getToken().await()
pushClient.setDeviceToken(deviceToken)

// Update the device token for a specific credential
pushClient.setDeviceToken(deviceToken, credentialId)

Managing Notification Cleanup

The Push module provides automatic cleanup functionality for push notifications through the NotificationCleanupConfig class. This helps prevent your app from accumulating too many push notification records, which can improve performance and reduce storage usage.

Configuring Notification Cleanup

You can configure how notifications are cleaned up when initializing the Push client:

// Create a client with custom notification cleanup configuration
val pushClient = PushClient {
    // Configure notification cleanup
    notificationCleanupConfig = NotificationCleanupConfig {
        // Choose a cleanup mode: NONE, COUNT_BASED, AGE_BASED, or HYBRID
        cleanupMode = NotificationCleanupConfig.CleanupMode.HYBRID
        
        // Maximum notifications to keep when using COUNT_BASED or HYBRID mode
        maxStoredNotifications = 50
        
        // Maximum age in days for notifications when using AGE_BASED or HYBRID mode
        maxNotificationAgeDays = 14
    }
}

Cleanup Modes

The SDK supports different cleanup strategies:

  • CleanupMode.NONE - No automatic cleanup is performed
  • CleanupMode.COUNT_BASED - Keeps a maximum number of notifications (deleting oldest first)
  • CleanupMode.AGE_BASED - Deletes notifications older than a certain age
  • CleanupMode.HYBRID - Applies both count and age limits

Default Configuration

If not specified, the default configuration uses:

  • Keeps a maximum number of notifications (CleanupMode.COUNT_BASED)
  • Maximum of 100 stored notifications (if using COUNT_BASED)
  • Maximum notification age of 30 days (if using AGE_BASED)

Manual Cleanup

You can also trigger notification cleanup manually:

// Clean up notifications for all credentials
pushClient.cleanupNotifications()
    .onSuccess { count ->
        println("Removed $count old notifications")
    }

// Clean up notifications for a specific credential
pushClient.cleanupNotifications(credentialId)
    .onSuccess { count ->
        println("Removed $count old notifications for credential $credentialId")
    }

Clean Up

If you need to release resources used by the PushClient, you can call the close() method:

// Clean up resources when no longer needed
pushClient.close()

Error Handling

The Push module uses Kotlin's Result API for error handling, providing a more functional approach:

// Using onSuccess/onFailure
pushClient.addCredentialFromUri(uri)
    .onSuccess { credential ->
        // Success path
    }
    .onFailure { exception ->
        when (exception) {
            is IllegalArgumentException -> showError() // Handle invalid URI format
            is MfaException -> showError() // Handle general MFA errors
            is NetworkException -> showError() // Handle network connectivity issues
            else -> showError() // Handle other exceptions
        }
    }

// Using fold for combined handling
pushClient.addCredentialFromUri(uri).fold(
    onSuccess = { credential -> 
        // Handle success
    },
    onFailure = { exception ->
        // Handle failure
    }
)

// Using runCatching for additional operations
runCatching { 
    pushClient.addCredentialFromUri(uri).getOrThrow()
}.onSuccess { credential ->
    // Do something with credential
}.onFailure { exception ->
    // Handle error
}

Internal Storage

The Push module uses the MFA common storage infrastructure to securely store credentials and notifications. By default:

  • Credentials, notifications, and device token are encrypted using the Android KeyStore system
  • Data is persisted in a SQLite database
  • Sensitive data is never stored in plain text

Customizing Storage with SQLPushStorage

You can customize the storage behavior by creating a custom instance of SQLPushStorage and passing it to the PushClient:

// Create a custom storage instance with specific parameters
val customStorage = SQLPushStorage {
    context = applicationContext
    databaseName = "my_custom_push_db.db"
    passphraseProvider = NonePassphraseProvider()
}

// Create the client with the custom storage
val pushClient = PushClient {
    storage = customStorage
    enableCredentialCache = true
}

The custom storage options include:

  • context: Android application context (required)
  • databaseName: Custom database name for the SQLite database (optional)
  • databaseVersion: Custom database version (default: 1)
  • passphraseProvider: Custom passphrase provider for database encryption (default: KeyStorePassphraseProvider for encrypted storage, use NonePassphraseProvider() for unencrypted storage)

Advanced Usage

Custom Push Handler Implementation

If you need to support a custom push notification format, you can implement the PushHandler interface:

class CustomPushHandler : PushHandler {
    override fun canHandle(messageData: Map<String, String>): Boolean {
        // Check if this handler can process the given payload
        return payload.containsKey("custom_push_key")
    }
    
    override fun parseMessage(messageData: Map<String, Any>): Map<String, Any> {
        // Process the push payload and create a PushNotification
        val notificationId = payload["message_id"] ?: return null
        val issuer = payload["issuer"] ?: "Unknown"
        val message = payload["message"] ?: "Authentication request"
        
        // Populate and return a map associated with PushNotification fields
        return mapOf(
            "messageId" to notificationId,
            "message" to message,
            "issuer" to issuer,
            "pushType" to PushType.DEFAULT // or CHALLENGE, BIOMETRIC based on payload
        )
    }
    
    override fun sendApproval(
        credential: PushCredential,
        notification: PushNotification,
        params: Map<String, Any>
    ): Boolean {
        // Implement the approval logic
        return runCatching {
            // Make network request to approve the authentication
            // ...
            true
        }
    }
    
    override suspend fun sendDenial(
        credential: PushCredential,
        notification: PushNotification,
        params: Map<String, Any>
    ): Boolean {
        // Implement the denial logic
        return runCatching {
            // Make network request to deny the authentication
            // ...
            true
        }
    }

    override suspend fun setDeviceToken(
        credential: PushCredential,
        deviceToken: String,
        params: Map<String, Any>
    ): Boolean {
        // Implement the logic to set the device token for the credential
        return runCatching {
            // Make network request to register the device token
            // ...
            true
        }
    }
    
    override suspend fun register(
        credential: PushCredential,
        params: Map<String, Any>,
    ): Boolean {
        // Implement the registration logic
        return runCatching {
            // Make network request to register the credential
            // ...
            true
        }
    }
}

// Register your custom handler when initializing the PushClient
val pushClient = PushClient {
    customHandlers = listOf(CustomPushHandler())
}

Custom Storage Implementation

You can implement a custom storage solution as an alternative to the default SQLPushStorage by implementing the PushStorage interface:

class MyCustomStorage : PushStorage {
    override suspend fun storePushCredential(credential: PushCredential) {
        // Implement storing the credential
    }
    
    override suspend fun retrievePushCredential(credentialId: String): PushCredential? {
        // Implement retrieving a credential by ID
        return null
    }
    
    override suspend fun removePushCredential(credentialId: String): Boolean {
        // Implement deleting a credential by ID
        return true
    }
    
    override suspend fun getAllPushCredentials(): List<PushCredential> {
        // Implement listing all stored credentials
        return emptyList()
    }
    
    override suspend fun storePushNotification(notification: PushNotification) {
        // Implement storing a notification
    }
    
    override suspend fun retrievePushNotification(notificationId: String): PushNotification? {
        // Implement retrieving a notification by ID
        return null
    }
    
    override suspend fun removePushNotification(notificationId: String): Boolean {
        // Implement deleting a notification by ID
        return true
    }
    
    // Implement other required methods...
}

Extended Implementation Example

Following there are some comprehensive examples of integrating Push MFA into your application.

Please note that these examples are simplified and may require additional error handling, UI updates, and other considerations based on your specific application requirements. For a more complete implementation, refer to the sample applications provided.

Initializing the Push Client and Handling Push Notifications

class PushMainActivity : AppCompatActivity() {
    private lateinit var pushClient: PushClient
    private lateinit var credentialAdapter: CredentialAdapter
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_push_mfa)
        
        // Initialize Push client
        lifecycleScope.launch {
            try {
                pushClient = PushClient {
                    enableCredentialCache = true
                }
                
                // Load all credentials
                loadCredentials()
            } catch (e: Exception) {
                showError("Failed to initialize: ${e.message}")
            }
        }

        // Register Firebase token
        firebaseMessaging.getToken().addOnSuccessListener { token ->
            lifecycleScope.launch {
                pushClient.setDeviceToken(token, credential.id)
                    .onSuccess {
                        loadCredentials() // Refresh list
                    }
            }
        }
        
        // Set up QR code scanner button
        binding.btnScanQr.setOnClickListener {
            startQrCodeScan()
        }
        
        // Set up recycler view for credentials
        credentialAdapter = CredentialAdapter { credential ->
            // Show credential details
            showCredentialDetails(credential)
        }
        binding.recyclerViewCredentials.adapter = credentialAdapter
        
        // Set up pending notifications button
        binding.btnPendingNotifications.setOnClickListener {
            showPendingNotifications()
        }
    }
    
    private fun loadCredentials() {
        lifecycleScope.launch {
            pushClient.getCredentials().onSuccess { credentials ->
                if (credentials.isEmpty()) {
                    binding.emptyStateView.isVisible = true
                } else {
                    binding.emptyStateView.isVisible = false
                    credentialAdapter.submitList(credentials)
                }
            }.onFailure { error ->
                showError("Failed to load credentials: ${error.message}")
            }
        }
    }
    
    private fun startQrCodeScan() {
        // Launch QR code scanner (implementation depends on your QR code scanner library)
        qrCodeScanner.startScan { result ->
            if (result != null) {
                processQrCode(result)
            }
        }
    }
    
    private fun processQrCode(qrCode: String) {
        lifecycleScope.launch {
            pushClient.addCredentialFromUri(qrCode).onSuccess { credential ->
                showMessage("Credential added: ${credential.issuer}")
            }.onFailure { error ->
                showError("Failed to add credential: ${error.message}")
            }
        }
    }
    
    private fun showPendingNotifications() {
        lifecycleScope.launch {
            pushClient.getPendingNotifications().onSuccess { notifications ->
                if (notifications.isNotEmpty()) {
                    // Start activity to show pending notifications
                    val intent = Intent(this@PushMfaActivity, NotificationListActivity::class.java)
                    startActivity(intent)
                } else {
                    showMessage("No pending notifications")
                }
            }.onFailure { error ->
                showError("Failed to get notifications: ${error.message}")
            }
        }
    }
    
    override fun onDestroy() {
        super.onDestroy()
        lifecycleScope.launch {
            pushClient.close()
        }
    }
    
    // Helper methods
    private fun showMessage(message: String) {
        Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
    }
    
    private fun showError(error: String) {
        Toast.makeText(this, error, Toast.LENGTH_LONG).show()
    }
}

Implement a FirebaseMessagingService to receive push notifications

class MyFirebaseMessagingService : FirebaseMessagingService() {
    private val pushClient by lazy { PushClient.create() }
    
    override fun onMessageReceived(remoteMessage: RemoteMessage) {
        super.onMessageReceived(remoteMessage)
        
        // Check if this is a Push MFA notification
        val messageData = remoteMessage.data
        pushClient.processNotification(messageData).onSuccess { notification ->
            if (notification != null) {
                // This is a Push MFA notification that the SDK can handle
                when (notification.pushType) {
                    PushType.DEFAULT -> showDefaultNotification(notification)
                    PushType.CHALLENGE -> showChallengeNotification(notification)
                    PushType.BIOMETRIC -> showBiometricPrompt(notification)
                }
            } else {
                // Not a Push MFA notification, handle it as a regular notification
                handleRegularNotification(remoteMessage)
            }
        }.onFailure { error ->
            Log.e("PushService", "Failed to process notification", error)
            // Handle the error or process as a regular notification
            handleRegularNotification(remoteMessage)
        }
    }
    
    override fun onNewToken(token: String) {
        super.onNewToken(token)
        
        // Update all registered Push MFA credentials with the new token
        pushClient.setDeviceToken(token)
    }
    
    // Helper methods to display notifications to the user
    private fun showDefaultNotification(notification: PushNotification) {
        // Create and display a notification to the user
        val notificationBuilder = NotificationCompat.Builder(this, CHANNEL_ID)
            .setSmallIcon(R.drawable.ic_notification)
            .setContentTitle("Authentication Request")
            .setContentText("Tap to approve or deny this login attempt")
            .setPriority(NotificationCompat.PRIORITY_HIGH)
            .setAutoCancel(true)
            
        // Create an intent to open your authentication activity
        val intent = Intent(this, AuthenticationActivity::class.java).apply {
            putExtra("notificationId", notification.id)
        }
        
        val pendingIntent = PendingIntent.getActivity(
            this, 0, intent, PendingIntent.FLAG_IMMUTABLE
        )
        notificationBuilder.setContentIntent(pendingIntent)
        
        // Show the notification
        with(NotificationManagerCompat.from(this)) {
            notify(notification.id.hashCode(), notificationBuilder.build())
        }
    }
    
    // Similar methods for challenge and biometric notifications
    // ...
}

Biometric Authentication Activity

class BiometricAuthActivity : AppCompatActivity() {
    private lateinit var pushClient: PushClient
    private lateinit var notificationId: String
    private lateinit var biometricPrompt: BiometricPrompt
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_biometric_auth)
        
        pushClient = PushClient.create()
        notificationId = intent.getStringExtra("notificationId") ?: return
        
        setupBiometricAuthentication()
        
        // Get notification details and prompt for biometric auth
        lifecycleScope.launch {
            pushClient.getNotification(notificationId).onSuccess { notification ->
                if (notification != null && notification.pushType == PushType.BIOMETRIC) {
                    // Display notification details
                    binding.tvIssuer.text = notification.issuer
                    binding.tvMessage.text = notification.message
                    
                    // Show biometric prompt
                    showBiometricPrompt()
                }
            }
        }
    }
    
    private fun setupBiometricAuthentication() {
        val executor = ContextCompat.getMainExecutor(this)
        val callback = object : BiometricPrompt.AuthenticationCallback() {
            override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
                super.onAuthenticationSucceeded(result)
                
                // Determine authentication method from result
                val authMethod = when (result.authenticationType) {
                    BiometricPrompt.AUTHENTICATION_RESULT_TYPE_FINGERPRINT -> "fingerprint"
                    BiometricPrompt.AUTHENTICATION_RESULT_TYPE_FACE -> "face"
                    else -> "biometric"
                }
                
                // Approve the notification with the authentication method
                lifecycleScope.launch {
                    pushClient.approveBiometricNotification(notificationId, authMethod)
                        .onSuccess { success -> 
                            if (success) {
                                showMessage("Biometric authentication successful")
                                finish()
                            } else {
                                showError("Failed to authenticate")
                            }
                        }
                        .onFailure { error ->
                            showError("Error: ${error.message}")
                        }
                }
            }
            
            override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
                super.onAuthenticationError(errorCode, errString)
                showError("Authentication error: $errString")
                
                // Deny the notification or allow retry
                if (errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON) {
                    lifecycleScope.launch {
                        pushClient.denyNotification(notificationId)
                    }
                    finish()
                }
            }
            
            override fun onAuthenticationFailed() {
                super.onAuthenticationFailed()
                showMessage("Authentication failed, please try again")
            }
        }
        
        biometricPrompt = BiometricPrompt(this, executor, callback)
    }
    
    private fun showBiometricPrompt() {
        val promptInfo = BiometricPrompt.PromptInfo.Builder()
            .setTitle("Authentication Required")
            .setSubtitle("Verify your identity to approve this login")
            .setNegativeButtonText("Cancel")
            .setAllowedAuthenticators(BIOMETRIC_STRONG)
            .build()
            
        biometricPrompt.authenticate(promptInfo)
    }
}

Challenge Authentication Activity

class ChallengeAuthActivity : AppCompatActivity() {
    private lateinit var pushClient: PushClient
    private lateinit var notificationId: String
    private var challengeOptions: List<String>? = null
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_challenge_auth)
        
        pushClient = PushClient.create()
        notificationId = intent.getStringExtra("notificationId") ?: return
        
        // Get notification details
        lifecycleScope.launch {
            pushClient.getNotification(notificationId).onSuccess { notification ->
                if (notification != null && notification.pushType == PushType.CHALLENGE) {
                    // Display notification details
                    binding.tvChallenge.text = notification.challenge
                    
                    // Setup challenge options if available
                    challengeOptions = notification.challengeOptions
                    if (!challengeOptions.isNullOrEmpty()) {
                        setupChallengeOptions(challengeOptions!!)
                    }
                }
            }
        }
    }
    
    private fun setupChallengeOptions(options: List<String>) {
        val adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, options)
        binding.challengeList.adapter = adapter
        binding.challengeList.setOnItemClickListener { _, _, position, _ ->
            val selectedChallenge = options[position]
            submitChallengeResponse(selectedChallenge)
        }
    }
    
    private fun submitChallengeResponse(response: String) {
        lifecycleScope.launch {
            pushClient.approveChallengeNotification(notificationId, response)
                .onSuccess { success -> 
                    if (success) {
                        showMessage("Challenge verified successfully")
                        finish()
                    } else {
                        showError("Incorrect challenge response")
                    }
                }
                .onFailure { error ->
                    showError("Error: ${error.message}")
                }
        }
    }
}

Default Authentication Activity

class AuthenticationActivity : AppCompatActivity() {
    private lateinit var pushClient: PushClient
    private lateinit var notificationId: String
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_authentication)
        
        pushClient = PushClient.create()
        notificationId = intent.getStringExtra("notificationId") ?: return
        
        // Get notification details
        lifecycleScope.launch {
            pushClient.getNotification(notificationId).onSuccess { notification ->
                if (notification != null) {
                    // Display notification details
                    binding.tvIssuer.text = notification.issuer
                    binding.tvMessage.text = notification.message
                    binding.tvTimestamp.text = DateFormat.getDateTimeInstance().format(notification.timestamp)
                }
            }
        }
        
        // Set up approve button
        binding.btnApprove.setOnClickListener {
            lifecycleScope.launch {
                pushClient.approveNotification(notificationId)
                    .onSuccess { success -> 
                        if (success) {
                            showMessage("Authentication successful")
                            finish()
                        } else {
                            showError("Failed to authenticate")
                        }
                    }
                    .onFailure { error ->
                        showError("Error: ${error.message}")
                    }
            }
        }
        
        // Set up deny button
        binding.btnDeny.setOnClickListener {
            lifecycleScope.launch {
                pushClient.denyNotification(notificationId)
                    .onSuccess { success -> 
                        if (success) {
                            showMessage("Authentication denied")
                            finish()
                        } else {
                            showError("Failed to deny authentication")
                        }
                    }
                    .onFailure { error ->
                        showError("Error: ${error.message}")
                    }
            }
        }
    }
}

License

This software may be modified and distributed under the terms of the MIT license. See the LICENSE file for details.

© Copyright 2025-2026 Ping Identity Corporation. All rights reserved.