🎯 Learning Path:

  1. Complete REL-ID Complete Activation & Login Flow Codelab
  2. Complete REL-ID Additional Device Activation Flow With Notifications Codelab
  3. You are here β†’ Step-Up Authentication for Notification Actions

Welcome to the REL-ID Step-Up Authentication with Notifications codelab! This tutorial builds upon your existing MFA implementation to add secure re-authentication for sensitive notification actions using REL-ID SDK's step-up authentication capabilities.

What You'll Build

In this codelab, you'll enhance your existing notification application with:

What You'll Learn

By completing this codelab, you'll master:

  1. Step-Up Authentication Concept: Understanding when and why re-authentication is required for notification actions
  2. Authentication Method Selection: How SDK determines password vs LDA based on user's login method
  3. ViewModel-Level Event Handlers: Implementing event collection pattern for challengeMode 3
  4. StepUpPasswordDialog Component: Building modal password dialog with attempts counter using Jetpack Compose
  5. LDA Fallback Handling: Managing biometric cancellation with automatic password fallback
  6. Keyboard Optimization: Implementing proper dialog sizing to prevent keyboard from hiding buttons
  7. Error State Management: Auto-clearing password fields on authentication failure
  8. Critical Status Code Handling: Displaying alerts before SDK triggers logout for status codes 110 and 153
  9. Error Code Management: Managing LDA cancellation (error code 131) with retry

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-team/relid-codelab-android.git

Navigate to the relid-step-up-auth-notification folder in the repository you cloned earlier

Codelab Architecture Overview

This codelab extends your notification application with three core step-up authentication components:

  1. StepUpPasswordDialog: Modal password dialog with attempts counter, error display, and keyboard management
  2. ViewModel-Level Event Handler: getPassword event collection for challengeMode 3 in GetNotificationsViewModel
  3. Enhanced Error Handling: onUpdateNotification event handler for critical errors (110, 153, 131)

Before implementing step-up authentication, let's understand the key SDK events and APIs that power the notification action re-authentication workflow.

What is Step-Up Authentication?

Step-up authentication is a security mechanism that requires users to re-authenticate when performing sensitive operations, even if they're already logged in. For notification actions, this adds an extra layer of security.

User Logged In β†’ Acts on Notification β†’ updateNotification() API β†’
SDK Checks if Action Requires Auth β†’ Step-Up Authentication Required β†’
Password or LDA Verification β†’ onUpdateNotification Event β†’ Success/Failure

Step-Up Authentication Event Flow

The step-up authentication process follows this event-driven pattern:

User Taps Notification Action β†’ updateNotification(uuid, action) API Called β†’
SDK Determines Auth Method (Based on Login Method + Enrolled Credentials) β†’

IF Password Required:
  SDK Triggers getPassword Event (challengeMode=3) β†’
  StepUpPasswordDialog Displays β†’ User Enters Password β†’
  setPassword(password, 3) API β†’ onUpdateNotification Event

IF LDA Required:
  SDK Prompts Biometric Internally β†’ User Authenticates β†’
  onUpdateNotification Event (No getPassword event)

IF LDA Cancelled AND Password Enrolled:
  SDK Directly Triggers getPassword Event (challengeMode=3) β†’ No Error, Seamless Fallback β†’
  StepUpPasswordDialog Displays β†’ User Enters Password β†’
  setPassword(password, 3) API β†’ onUpdateNotification Event

IF LDA Cancelled AND Password NOT Enrolled:
  onUpdateNotification Event with error code 131 β†’
  Show Alert "Authentication Cancelled" β†’ User Can Retry LDA

Challenge Mode 3 - RDNA_OP_AUTHORIZE_NOTIFICATION

Challenge Mode 3 is specifically for notification action authorization:

Challenge Mode

Purpose

User Action Required

Screen

Trigger

challengeMode = 0

Verify existing password

Enter password to login

VerifyPasswordScreen

User login attempt

challengeMode = 1

Set new password

Create password during activation

SetPasswordScreen

First-time activation

challengeMode = 2

Update password (user-initiated)

Provide current + new password

UpdatePasswordScreen

User taps "Update Password"

challengeMode = 3

Authorize notification action

Re-enter password for verification

StepUpPasswordDialog (Modal)

updateNotification() requires auth

challengeMode = 4

Update expired password

Provide current + new password

UpdateExpiryPasswordScreen

Server detects expired password

Authentication Method Selection Logic

Important: The SDK automatically determines which authentication method to use based on:

  1. How the user logged in (Password or LDA)
  2. What authentication methods are enrolled for the app

Login Method

Enrolled Methods

Step-Up Authentication Method

SDK Behavior

Password

Password only

Password

SDK triggers getPassword with challengeMode 3

LDA

LDA only

LDA

SDK prompts biometric internally, no getPassword event

Password

Both Password & LDA

Password

SDK triggers getPassword with challengeMode 3

LDA

Both Password & LDA

LDA (with Password fallback)

SDK attempts LDA first. If user cancels, SDK directly triggers getPassword (no error)

Core Step-Up Authentication Events

The REL-ID SDK triggers these main events during step-up authentication:

Event Type

Description

User Action Required

getPassword (challengeMode=3)

Password required for notification action authorization

User re-enters password for verification

onUpdateNotification

Notification action result (success/failure/auth errors)

System handles response and displays result

Error Codes and Status Handling

Step-up authentication can fail with these critical errors:

Error/Status Code

Type

Meaning

SDK Behavior

Action Required

statusCode = 100

Status

Success - action completed

Continue normal flow

Display success message

statusCode = 110

Status

Password expired during action

SDK triggers logout

Show alert BEFORE logout

statusCode = 153

Status

Attempts exhausted

SDK triggers logout

Show alert BEFORE logout

error code = 131

Error

LDA cancelled and Password NOT enrolled

No fallback available

Show alert, allow retry

UpdateNotification API Pattern

Add these Kotlin definitions to understand the updateNotification API structure:

// app/src/main/java/*/uniken/services/RDNAService.kt (notification APIs)

/**
 * Updates notification with user's action selection
 * @param notificationUUID The unique identifier of the notification
 * @param action The action selected by the user
 * @returns RDNAError that indicates success or failure
 *
 * Note: If action requires authentication, SDK will trigger:
 * - getPassword event with challengeMode 3 (if password required)
 * - Biometric prompt internally (if LDA required)
 */
fun updateNotification(
    notificationId: String,
    response: String
): RDNA.RDNAError {
    return rdna.updateNotification(notificationId, response)
}

Let's create the modal password dialog component that will be displayed when step-up authentication is required.

Understanding the Dialog Requirements

The StepUpPasswordDialog needs to:

Create the StepUpPasswordDialog Component

Create a new file for the password dialog modal:

// app/src/main/java/*/uniken/components/modals/StepUpPasswordDialog.kt

package com.relidcodelab.uniken.components.modals

/**
 * Step-Up Password Dialog Component
 *
 * Modal dialog for step-up authentication during notification actions.
 * Handles challengeMode = 3 (RDNA_OP_AUTHORIZE_NOTIFICATION) when the SDK
 * requires password verification before allowing a notification action.
 *
 * Features:
 * - Password input with visibility toggle
 * - Attempts left counter with color-coding
 * - Error message display
 * - Loading state during authentication
 * - Notification context display (title)
 * - Auto-focus on password field
 * - Auto-clear password on error
 * - Keyboard management with proper sizing
 *
 * Usage:
 * ```kotlin
 * StepUpPasswordDialog(
 *   notificationTitle = "Payment Approval",
 *   attemptsLeft = 3,
 *   errorMessage = "Incorrect password",
 *   isSubmitting = false,
 *   onSubmitPassword = { password -> viewModel.submitPassword(password) },
 *   onCancel = { viewModel.cancelStepUpAuth() }
 * )
 * ```
 */

import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import kotlinx.coroutines.delay

@Composable
fun StepUpPasswordDialog(
    notificationTitle: String,
    attemptsLeft: Int,
    errorMessage: String? = null,
    isSubmitting: Boolean,
    onSubmitPassword: (String) -> Unit,
    onCancel: () -> Unit
) {
    var password by remember { mutableStateOf("") }
    var showPassword by remember { mutableStateOf(false) }
    val focusRequester = remember { FocusRequester() }
    val focusManager = LocalFocusManager.current

    // Auto-focus password field after a short delay
    LaunchedEffect(Unit) {
        delay(300)
        focusRequester.requestFocus()
    }

    // Clear password field when error message changes (wrong password)
    // This ensures the field is cleared when SDK triggers getPassword again after failure
    LaunchedEffect(errorMessage) {
        if (errorMessage != null && errorMessage.isNotEmpty()) {
            password = ""
        }
    }

    // Disable hardware back button during submission
    BackHandler(enabled = !isSubmitting) {
        onCancel()
    }

    Dialog(
        onDismissRequest = { if (!isSubmitting) onCancel() },
        properties = DialogProperties(
            dismissOnBackPress = !isSubmitting,
            dismissOnClickOutside = false
        )
    ) {
        Card(
            modifier = Modifier
                .fillMaxWidth()
                .wrapContentHeight(),
            shape = RoundedCornerShape(16.dp),
            elevation = CardDefaults.cardElevation(defaultElevation = 10.dp)
        ) {
            Column(
                modifier = Modifier.fillMaxWidth()
            ) {
                // Header
                Box(
                    modifier = Modifier
                        .fillMaxWidth()
                        .background(Color(0xFF3B82F6))
                        .padding(20.dp),
                    contentAlignment = Alignment.Center
                ) {
                    Column(horizontalAlignment = Alignment.CenterHorizontally) {
                        Text(
                            text = "πŸ” Authentication Required",
                            fontSize = 20.sp,
                            fontWeight = FontWeight.Bold,
                            color = Color.White,
                            textAlign = TextAlign.Center
                        )
                        Spacer(modifier = Modifier.height(8.dp))
                        Text(
                            text = "Please verify your password to authorize this action",
                            fontSize = 14.sp,
                            color = Color(0xFFDBEAFE),
                            textAlign = TextAlign.Center
                        )
                    }
                }

                // Scrollable Content
                Column(
                    modifier = Modifier
                        .fillMaxWidth()
                        .heightIn(max = 400.dp)
                        .verticalScroll(rememberScrollState())
                        .padding(20.dp)
                ) {
                    // Notification Title
                    Card(
                        modifier = Modifier.fillMaxWidth(),
                        colors = CardDefaults.cardColors(
                            containerColor = Color(0xFFF0F9FF)
                        ),
                        shape = RoundedCornerShape(8.dp)
                    ) {
                        Text(
                            text = notificationTitle,
                            modifier = Modifier.padding(12.dp),
                            fontSize = 15.sp,
                            fontWeight = FontWeight.SemiBold,
                            color = Color(0xFF1E40AF),
                            textAlign = TextAlign.Center
                        )
                    }

                    Spacer(modifier = Modifier.height(16.dp))

                    // Attempts Left Counter
                    if (attemptsLeft <= 3) {
                        val attemptsColor = when {
                            attemptsLeft == 1 -> Color(0xFFDC2626) // Red
                            attemptsLeft == 2 -> Color(0xFFF59E0B) // Orange
                            else -> Color(0xFF10B981) // Green
                        }

                        Card(
                            modifier = Modifier.fillMaxWidth(),
                            colors = CardDefaults.cardColors(
                                containerColor = attemptsColor.copy(alpha = 0.12f)
                            ),
                            shape = RoundedCornerShape(8.dp)
                        ) {
                            Text(
                                text = "$attemptsLeft attempt${if (attemptsLeft != 1) "s" else ""} remaining",
                                modifier = Modifier.padding(12.dp),
                                fontSize = 14.sp,
                                fontWeight = FontWeight.SemiBold,
                                color = attemptsColor,
                                textAlign = TextAlign.Center
                            )
                        }

                        Spacer(modifier = Modifier.height(16.dp))
                    }

                    // Error Display
                    if (!errorMessage.isNullOrEmpty()) {
                        Card(
                            modifier = Modifier.fillMaxWidth(),
                            colors = CardDefaults.cardColors(
                                containerColor = Color(0xFFFEF2F2)
                            ),
                            shape = RoundedCornerShape(8.dp)
                        ) {
                            Text(
                                text = errorMessage,
                                modifier = Modifier.padding(12.dp),
                                fontSize = 14.sp,
                                color = Color(0xFF7F1D1D),
                                textAlign = TextAlign.Center
                            )
                        }

                        Spacer(modifier = Modifier.height(16.dp))
                    }

                    // Password Input
                    Text(
                        text = "Password",
                        fontSize = 14.sp,
                        fontWeight = FontWeight.SemiBold,
                        color = Color(0xFF374151)
                    )

                    Spacer(modifier = Modifier.height(8.dp))

                    OutlinedTextField(
                        value = password,
                        onValueChange = { password = it },
                        modifier = Modifier
                            .fillMaxWidth()
                            .focusRequester(focusRequester),
                        placeholder = { Text("Enter your password") },
                        visualTransformation = if (showPassword)
                            VisualTransformation.None
                        else
                            PasswordVisualTransformation(),
                        trailingIcon = {
                            TextButton(
                                onClick = { showPassword = !showPassword },
                                enabled = !isSubmitting
                            ) {
                                Text(if (showPassword) "Hide" else "Show")
                            }
                        },
                        keyboardOptions = KeyboardOptions(
                            keyboardType = KeyboardType.Password,
                            imeAction = ImeAction.Done
                        ),
                        keyboardActions = KeyboardActions(
                            onDone = {
                                focusManager.clearFocus()
                                if (password.trim().isNotEmpty() && !isSubmitting) {
                                    onSubmitPassword(password.trim())
                                }
                            }
                        ),
                        enabled = !isSubmitting,
                        singleLine = true
                    )
                }

                // Action Buttons
                Column(
                    modifier = Modifier
                        .fillMaxWidth()
                        .background(Color(0xFFF3F4F6))
                        .padding(20.dp)
                ) {
                    // Submit Button
                    Button(
                        onClick = {
                            focusManager.clearFocus()
                            onSubmitPassword(password.trim())
                        },
                        modifier = Modifier.fillMaxWidth(),
                        enabled = password.trim().isNotEmpty() && !isSubmitting,
                        colors = ButtonDefaults.buttonColors(
                            containerColor = Color(0xFF3B82F6)
                        )
                    ) {
                        if (isSubmitting) {
                            Row(
                                horizontalArrangement = Arrangement.spacedBy(8.dp),
                                verticalAlignment = Alignment.CenterVertically
                            ) {
                                CircularProgressIndicator(
                                    modifier = Modifier.size(20.dp),
                                    color = Color.White,
                                    strokeWidth = 2.dp
                                )
                                Text("Verifying...")
                            }
                        } else {
                            Text("Verify & Continue", fontSize = 16.sp)
                        }
                    }

                    Spacer(modifier = Modifier.height(12.dp))

                    // Cancel Button
                    OutlinedButton(
                        onClick = {
                            focusManager.clearFocus()
                            onCancel()
                        },
                        modifier = Modifier.fillMaxWidth(),
                        enabled = !isSubmitting
                    ) {
                        Text("Cancel", fontSize = 16.sp, color = Color(0xFF6B7280))
                    }
                }
            }
        }
    }
}

The following image showcase screen from the sample application:

Step-Up Authentication Screen

Now let's implement the ViewModel-level event handler that will collect getPassword events with challengeMode = 3 and update UI state to display our step-up password dialog.

Understanding Event Collection Pattern

The event collection pattern in ViewModels uses Kotlin coroutines to reactively respond to SDK events:

// Collect getPassword events in ViewModel
viewModelScope.launch {
    callbackManager.getPasswordEvent.collect { eventData ->
        if (eventData.mode == RDNA.RDNAChallengeOpMode.RDNA_OP_AUTHORIZE_NOTIFICATION) {
            // Handle challengeMode 3 in ViewModel
            handleStepUpAuth(eventData)
        }
        // Other modes handled by SDKEventProvider
    }
}

Enhance GetNotificationsViewModel with Step-Up Auth State

Add step-up authentication state management to your GetNotificationsViewModel:

// app/src/main/java/*/tutorial/viewmodels/GetNotificationsViewModel.kt (additions)

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import com.uniken.rdna.RDNA
import com.relidcodelab.uniken.services.RDNAService
import com.relidcodelab.uniken.services.RDNACallbackManager

data class GetNotificationsUiState(
    val isLoading: Boolean = true,
    val notifications: List<NotificationItem> = emptyList(),
    val selectedNotification: NotificationItem? = null,
    val showActionModal: Boolean = false,
    val actionLoading: Boolean = false,
    val error: String = "",

    // Step-up authentication state
    val showStepUpAuth: Boolean = false,
    val stepUpNotificationUUID: String? = null,
    val stepUpNotificationTitle: String = "",
    val stepUpNotificationMessage: String = "",
    val stepUpAction: String? = null,
    val stepUpAttemptsLeft: Int = 3,
    val stepUpErrorMessage: String = "",
    val stepUpSubmitting: Boolean = false,
    val stepUpChallengeMode: Int = 3,

    // Alert dialog state
    val alertDialog: AlertDialogState? = null
)

data class AlertDialogState(
    val title: String,
    val message: String,
    val onDismiss: () -> Unit = {}
)

class GetNotificationsViewModel(
    private val rdnaService: RDNAService,
    private val callbackManager: RDNACallbackManager
) : ViewModel() {

    private val _uiState = MutableStateFlow(GetNotificationsUiState())
    val uiState: StateFlow<GetNotificationsUiState> = _uiState.asStateFlow()

    init {
        setupEventHandlers()
        loadNotifications()
    }

    // ... existing code ...
}

Implement getPassword Event Collection for ChallengeMode 3

Add the event collector that handles getPassword events with challengeMode = 3:

// app/src/main/java/*/tutorial/viewmodels/GetNotificationsViewModel.kt (addition)

/**
 * Set up event handlers for SDK callbacks
 * Collects getPassword, getNotifications, and updateNotification events
 */
private fun setupEventHandlers() {
    // Collect getPassword events for step-up authentication (challengeMode = 3)
    viewModelScope.launch {
        callbackManager.getPasswordEvent.collect { eventData ->
            // Only handle challengeMode 3 (RDNA_OP_AUTHORIZE_NOTIFICATION)
            if (eventData.mode == RDNA.RDNAChallengeOpMode.RDNA_OP_AUTHORIZE_NOTIFICATION) {
                handleGetPasswordStepUp(eventData)
            }
            // Other challenge modes (0, 1, 2, 4) handled by SDKEventProvider
        }
    }

    // Collect getNotifications response events
    viewModelScope.launch {
        callbackManager.getNotificationsEvent.collect { status ->
            handleGetNotificationsResponse(status)
        }
    }

    // Collect updateNotification response events
    viewModelScope.launch {
        callbackManager.updateNotificationEvent.collect { status ->
            handleUpdateNotificationResponse(status)
        }
    }
}

/**
 * Handle getPassword event for step-up authentication (challengeMode = 3)
 * Displays the step-up password dialog with notification context
 */
private fun handleGetPasswordStepUp(eventData: GetPasswordEventData) {
    Log.d(TAG, "GetPassword event received for step-up auth - " +
        "challengeMode: ${eventData.mode}, attemptsLeft: ${eventData.attemptsLeft}")

    val notification = _uiState.value.selectedNotification
    val challengeModeValue = when (eventData.mode) {
        RDNA.RDNAChallengeOpMode.RDNA_OP_AUTHORIZE_NOTIFICATION -> 3
        else -> 3
    }

    // Update UI state to show step-up dialog
    _uiState.update { state ->
        state.copy(
            showActionModal = false, // Hide action modal
            showStepUpAuth = true,
            stepUpAttemptsLeft = eventData.attemptsLeft,
            stepUpNotificationUUID = notification?.notification_uuid,
            stepUpNotificationTitle = notification?.body?.firstOrNull()?.subject ?: "Notification Action",
            stepUpNotificationMessage = notification?.body?.firstOrNull()?.message ?: "",
            stepUpChallengeMode = challengeModeValue,
            stepUpSubmitting = false
        )
    }

    // Check for error status codes
    val statusCode = eventData.response?.status?.statusCode?.intValue ?: 100
    val statusMessage = eventData.response?.status?.statusMessage ?: ""

    if (statusCode != 100) {
        // Failed authentication - show error
        _uiState.update { state ->
            state.copy(
                stepUpErrorMessage = statusMessage.ifEmpty {
                    "Authentication failed. Please try again."
                }
            )
        }
    } else {
        // Clear any previous errors
        _uiState.update { state ->
            state.copy(stepUpErrorMessage = "")
        }
    }

    Log.d(TAG, "Step-up dialog displayed - attempts left: ${eventData.attemptsLeft}")
}

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

Load Notifications on Initialization

Implement the notification loading logic:

// app/src/main/java/*/tutorial/viewmodels/GetNotificationsViewModel.kt (addition)

/**
 * Load notifications from the server
 * Calls getNotifications API which triggers onGetNotifications event
 */
fun loadNotifications() {
    _uiState.update { it.copy(isLoading = true, error = "") }

    Log.d(TAG, "Calling getNotifications API")
    val error = rdnaService.getNotifications(
        recordCount = 0,
        enterpriseID = "",
        startIndex = 1,
        startDate = "",
        endDate = ""
    )

    if (error.longErrorCode != 0) {
        Log.e(TAG, "getNotifications API error: ${error.errorString}")
        _uiState.update { state ->
            state.copy(
                isLoading = false,
                error = error.errorString ?: "Failed to load notifications"
            )
        }
    } else {
        Log.d(TAG, "getNotifications API call successful - waiting for response event")
        // Response will be handled by handleGetNotificationsResponse via event
    }
}

/**
 * Handle getNotifications response event
 * Parses notification data and updates UI state
 */
private fun handleGetNotificationsResponse(status: RDNA.RDNAStatusGetNotifications) {
    Log.d(TAG, "onGetNotifications event received")

    _uiState.update { it.copy(isLoading = false) }

    if (status.error != null && status.error.longErrorCode != 0) {
        Log.e(TAG, "Get notifications failed: ${status.error.errorString}")
        _uiState.update { state ->
            state.copy(error = status.error.errorString ?: "Failed to load notifications")
        }
    } else {
        val notifications = parseNotifications(status)
        Log.d(TAG, "Loaded ${notifications.size} notifications")
        _uiState.update { state ->
            state.copy(notifications = notifications)
        }
    }
}

/**
 * Parse notification data from SDK response
 */
private fun parseNotifications(status: RDNA.RDNAStatusGetNotifications): List<NotificationItem> {
    // Implementation to parse notifications from status
    // This would match your existing notification parsing logic
    return emptyList() // Placeholder
}

Now let's implement the password submission handler that will be called when the user submits their password from the StepUpPasswordDialog.

Handle Password Submission

Add the handler that submits the password to the SDK:

// app/src/main/java/*/tutorial/viewmodels/GetNotificationsViewModel.kt (addition)

/**
 * Handle password submission from StepUpPasswordDialog
 * Calls setPassword API with challengeMode 3
 */
fun submitStepUpPassword(password: String) {
    Log.d(TAG, "Submitting step-up password")

    _uiState.update { it.copy(
        stepUpSubmitting = true,
        stepUpErrorMessage = ""
    )}

    val error = rdnaService.setPassword(
        password.trim(),
        challengeMode = _uiState.value.stepUpChallengeMode
    )

    if (error.longErrorCode != 0) {
        Log.e(TAG, "setPassword API error: ${error.errorString}")
        _uiState.update { state ->
            state.copy(
                stepUpSubmitting = false,
                stepUpErrorMessage = error.errorString ?: "Password submission failed"
            )
        }
    } else {
        Log.d(TAG, "setPassword API call successful - waiting for onUpdateNotification event")
        // If successful, SDK will trigger onUpdateNotification event
        // Keep modal open until we receive the response
    }
}

/**
 * Handle step-up authentication cancellation
 * Closes the dialog and resets state
 */
fun cancelStepUpAuth() {
    Log.d(TAG, "Step-up authentication cancelled by user")

    _uiState.update { state ->
        state.copy(
            showStepUpAuth = false,
            stepUpNotificationUUID = null,
            stepUpAction = null,
            stepUpErrorMessage = "",
            stepUpSubmitting = false
        )
    }
}

Store Notification Context on Action Selection

When user selects an action, store the notification context for the step-up dialog:

// app/src/main/java/*/tutorial/viewmodels/GetNotificationsViewModel.kt (addition)

/**
 * Handle notification action selection
 * Calls updateNotification API, which may trigger step-up auth
 */
fun selectNotificationAction(action: String) {
    val notification = _uiState.value.selectedNotification ?: return

    if (_uiState.value.actionLoading) return

    Log.d(TAG, "Action selected: $action for notification ${notification.notification_uuid}")

    // Store notification context for potential step-up auth
    _uiState.update { state ->
        state.copy(
            actionLoading = true,
            stepUpNotificationUUID = notification.notification_uuid,
            stepUpNotificationTitle = notification.body.firstOrNull()?.subject ?: "Notification Action",
            stepUpNotificationMessage = notification.body.firstOrNull()?.message ?: "",
            stepUpAction = action,
            stepUpAttemptsLeft = 3, // Reset attempts
            stepUpErrorMessage = "" // Clear errors
        )
    }

    Log.d(TAG, "Calling updateNotification API")
    val error = rdnaService.updateNotification(notification.notification_uuid, action)

    if (error.longErrorCode != 0) {
        Log.e(TAG, "updateNotification API error: ${error.errorString}")
        _uiState.update { state ->
            state.copy(
                actionLoading = false,
                stepUpNotificationUUID = null,
                stepUpAction = null,
                alertDialog = AlertDialogState(
                    title = "Update Failed",
                    message = error.errorString ?: "Failed to update notification"
                )
            )
        }
    } else {
        Log.d(TAG, "updateNotification API call successful - waiting for response")
        // Response will be handled by handleUpdateNotificationResponse via event
        // If step-up auth is required, SDK will trigger getPassword with challengeMode 3
    }
}

/**
 * Show notification action modal
 */
fun showNotificationActions(notification: NotificationItem) {
    _uiState.update { state ->
        state.copy(
            selectedNotification = notification,
            showActionModal = true
        )
    }
}

/**
 * Hide notification action modal
 */
fun hideActionModal() {
    if (_uiState.value.actionLoading) return

    _uiState.update { state ->
        state.copy(
            selectedNotification = null,
            showActionModal = false
        )
    }
}

Now let's implement comprehensive error handling for the onUpdateNotification event, including critical errors that require alerts before logout.

Implement Enhanced UpdateNotification Handler

Add the handler that processes onUpdateNotification events with proper error handling:

// app/src/main/java/*/tutorial/viewmodels/GetNotificationsViewModel.kt (addition)

/**
 * Handle onUpdateNotification event
 * Processes success, critical errors, and LDA cancellation
 */
private fun handleUpdateNotificationResponse(status: RDNA.RDNAStatusUpdateNotification) {
    Log.d(TAG, "onUpdateNotification event received - " +
        "errorCode: ${status.error?.longErrorCode}, " +
        "statusCode: ${status.status?.statusCode?.intValue}")

    _uiState.update { state ->
        state.copy(
            actionLoading = false,
            stepUpSubmitting = false
        )
    }

    // Check for LDA cancelled (error code 131)
    // This only occurs when LDA is cancelled AND Password is NOT enrolled
    // If Password IS enrolled, SDK directly triggers getPassword (no error)
    if (status.error != null && status.error.longErrorCode == 131) {
        Log.d(TAG, "LDA cancelled - Password not enrolled")
        _uiState.update { state ->
            state.copy(
                showStepUpAuth = false,
                alertDialog = AlertDialogState(
                    title = "Authentication Cancelled",
                    message = "Local device authentication was cancelled. Please try again.",
                    onDismiss = {
                        _uiState.update { it.copy(alertDialog = null) }
                        // Keep action modal open to allow user to retry LDA
                    }
                )
            )
        }
        return
    }

    // Extract response status
    val statusCode = status.status?.statusCode?.intValue
    val statusMessage = status.status?.statusMessage ?: "Action completed successfully"

    Log.d(TAG, "Response status - code: $statusCode, message: $statusMessage")

    when (statusCode) {
        100, 0 -> {
            // Success - action completed
            Log.d(TAG, "Notification action successful")
            _uiState.update { state ->
                state.copy(
                    showStepUpAuth = false,
                    showActionModal = false,
                    selectedNotification = null,
                    alertDialog = AlertDialogState(
                        title = "Success",
                        message = statusMessage,
                        onDismiss = {
                            _uiState.update { it.copy(alertDialog = null) }
                        }
                    )
                )
            }

            // Reload notifications to reflect the change
            loadNotifications()
        }

        110, 153 -> {
            // Critical errors - show alert BEFORE SDK logout
            // statusCode 110 = Password expired during action
            // statusCode 153 = Attempts exhausted
            Log.w(TAG, "Critical error - SDK will trigger logout - statusCode: $statusCode")

            _uiState.update { state ->
                state.copy(
                    showStepUpAuth = false,
                    showActionModal = false,
                    selectedNotification = null,
                    alertDialog = AlertDialogState(
                        title = "Authentication Failed",
                        message = statusMessage,
                        onDismiss = {
                            _uiState.update { it.copy(alertDialog = null) }
                            Log.d(TAG, "Waiting for SDK to trigger logout flow")
                            // SDK will automatically trigger onUserLoggedOff event
                            // SDKEventProvider will handle navigation to login
                        }
                    )
                )
            }
        }

        else -> {
            // Other errors
            Log.e(TAG, "Update notification failed with status: $statusCode")
            _uiState.update { state ->
                state.copy(
                    showStepUpAuth = false,
                    showActionModal = false,
                    selectedNotification = null,
                    alertDialog = AlertDialogState(
                        title = "Update Failed",
                        message = statusMessage,
                        onDismiss = {
                            _uiState.update { it.copy(alertDialog = null) }
                        }
                    )
                )
            }
        }
    }
}

/**
 * Dismiss alert dialog
 */
fun dismissAlert() {
    _uiState.update { state ->
        state.copy(alertDialog = null)
    }

    // Execute onDismiss callback if present
    _uiState.value.alertDialog?.onDismiss?.invoke()
}

Understanding Error Code Flow

The error handling flow for different scenarios:

LDA Cancelled (Password IS enrolled):
  User cancels biometric β†’ SDK directly triggers getPassword (challengeMode 3) β†’
  No error, seamless fallback β†’ StepUpPasswordDialog shows

LDA Cancelled (Password NOT enrolled):
  User cancels biometric β†’ onUpdateNotification (error code 131) β†’
  Show alert "Authentication Cancelled" β†’ User can retry LDA

Password Expired (statusCode 110):
  Password authentication fails β†’ onUpdateNotification (statusCode 110) β†’
  Show alert "Authentication Failed - Password Expired" β†’
  SDK triggers onUserLoggedOff β†’ SDKEventProvider navigates to login

Attempts Exhausted (statusCode 153):
  Too many failed attempts β†’ onUpdateNotification (statusCode 153) β†’
  Show alert "Authentication Failed - Attempts Exhausted" β†’
  SDK triggers onUserLoggedOff β†’ SDKEventProvider navigates to login

Success (statusCode 100):
  Authentication successful β†’ onUpdateNotification (statusCode 100) β†’
  Show alert "Success" β†’ Reload notifications

Now let's add the dialog to the GetNotificationsScreen composable so it displays when step-up authentication is required.

Add Dialog to GetNotificationsScreen Composable

Update your GetNotificationsScreen to include the StepUpPasswordDialog:

// app/src/main/java/*/tutorial/screens/notification/GetNotificationsScreen.kt

import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.relidcodelab.uniken.components.modals.StepUpPasswordDialog

@Composable
fun GetNotificationsScreen(
    viewModel: GetNotificationsViewModel,
    onNavigateToDashboard: () -> Unit
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    // Handle navigation on success
    LaunchedEffect(uiState.alertDialog) {
        if (uiState.alertDialog?.title == "Success") {
            // Navigate back to dashboard after success
            kotlinx.coroutines.delay(2000)
            onNavigateToDashboard()
        }
    }

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("Notifications") }
            )
        }
    ) { padding ->
        Box(
            modifier = Modifier
                .fillMaxSize()
                .padding(padding)
        ) {
            // Notification List
            if (uiState.isLoading) {
                CircularProgressIndicator(
                    modifier = Modifier.align(Alignment.Center)
                )
            } else if (uiState.error.isNotEmpty()) {
                Text(
                    text = uiState.error,
                    modifier = Modifier
                        .align(Alignment.Center)
                        .padding(20.dp)
                )
            } else if (uiState.notifications.isEmpty()) {
                Text(
                    text = "No notifications available",
                    modifier = Modifier.align(Alignment.Center)
                )
            } else {
                LazyColumn(
                    modifier = Modifier.fillMaxSize(),
                    contentPadding = PaddingValues(16.dp),
                    verticalArrangement = Arrangement.spacedBy(12.dp)
                ) {
                    items(uiState.notifications) { notification ->
                        NotificationCard(
                            notification = notification,
                            onClick = { viewModel.showNotificationActions(notification) }
                        )
                    }
                }
            }

            // Action Modal (existing)
            if (uiState.showActionModal) {
                NotificationActionModal(
                    notification = uiState.selectedNotification,
                    isLoading = uiState.actionLoading,
                    onActionSelected = viewModel::selectNotificationAction,
                    onDismiss = viewModel::hideActionModal
                )
            }

            // Step-Up Password Dialog
            if (uiState.showStepUpAuth) {
                StepUpPasswordDialog(
                    notificationTitle = uiState.stepUpNotificationTitle,
                    attemptsLeft = uiState.stepUpAttemptsLeft,
                    errorMessage = uiState.stepUpErrorMessage.takeIf { it.isNotEmpty() },
                    isSubmitting = uiState.stepUpSubmitting,
                    onSubmitPassword = viewModel::submitStepUpPassword,
                    onCancel = viewModel::cancelStepUpAuth
                )
            }

            // Alert Dialog
            uiState.alertDialog?.let { alertState ->
                AlertDialog(
                    onDismissRequest = viewModel::dismissAlert,
                    title = { Text(alertState.title) },
                    text = { Text(alertState.message) },
                    confirmButton = {
                        TextButton(onClick = viewModel::dismissAlert) {
                            Text("OK")
                        }
                    }
                )
            }
        }
    }
}

Verify StateFlow Collection

Ensure UI state is collected using lifecycle-aware collection:

// Collect UI state safely with lifecycle awareness
val uiState by viewModel.uiState.collectAsStateWithLifecycle()

Now let's test the complete step-up authentication implementation with various scenarios.

Server Configuration

Before testing, ensure your REL-ID server is configured for step-up authentication:

Test Scenario 1: Password Step-Up (User Logged in with Password)

Test the basic password step-up flow:

  1. Complete MFA flow and log in to dashboard using password
  2. Navigate to Notifications screen from drawer menu
  3. Verify notifications loaded - Check that getNotifications() succeeded
  4. Tap notification action button (e.g., "Approve", "Reject")
  5. Verify updateNotification API called - Check Logcat logs
  6. Verify step-up dialog appears:
    • Dialog should display with notification title
    • "Authentication Required" header visible
    • Password input field should be focused
    • Attempts counter shows "3 attempts remaining" in green
    • Action modal should be closed
  7. Enter incorrect password and submit
  8. Verify error handling:
    • getPassword event triggered again with error
    • Error message displayed in red box
    • Password field automatically cleared
    • Attempts counter decremented to "2 attempts remaining" (orange)
  9. Enter correct password and submit
  10. Verify success:
    • onUpdateNotification event with statusCode 100
    • Success alert displayed
    • Dialog closes
    • Notifications list reloads

Test Scenario 2: LDA Step-Up (User Logged in with LDA)

Test biometric authentication step-up:

  1. Complete MFA flow and log in to dashboard using LDA (biometric)
  2. Navigate to Notifications screen
  3. Tap notification action button
  4. Verify LDA prompt appears:
    • System biometric prompt (Fingerprint, Face Unlock)
    • No getPassword event triggered
    • No password dialog displayed
  5. Complete biometric authentication
  6. Verify success:
    • onUpdateNotification event with statusCode 100
    • Success alert displayed
    • Notifications list reloads

Test Scenario 3: LDA Cancellation with Password Fallback

Test automatic fallback when user cancels biometric (requires both Password & LDA enrolled):

  1. Enroll both Password and LDA during activation
  2. Log in using LDA (biometric)
  3. Navigate to Notifications screen
  4. Tap notification action button
  5. LDA prompt appears - System biometric prompt
  6. Cancel the biometric prompt (tap "Cancel" or use back button)
  7. Verify fallback behavior:
    • SDK automatically triggers getPassword with challengeMode 3
    • StepUpPasswordDialog appears as fallback
    • No error alert displayed (seamless fallback)
  8. Enter password and submit
  9. Verify success:
    • onUpdateNotification event with statusCode 100
    • Success alert displayed
    • Notifications list reloads

Test Scenario 4: Critical Error - Password Expired (statusCode 110)

Test error handling when password expires during action:

  1. Log in with password that will expire during the action
  2. Navigate to Notifications screen
  3. Tap notification action button
  4. Enter password in step-up dialog
  5. Verify critical error handling:
    • onUpdateNotification receives statusCode 110
    • Alert displays BEFORE logout: "Authentication Failed - Password Expired"
    • Step-up dialog closes
    • Action modal closes
  6. Tap "OK" on alert
  7. Verify logout flow:
    • SDK triggers onUserLoggedOff event
    • SDKEventProvider handles logout
    • User navigated to login screen

Test Scenario 5: Critical Error - Attempts Exhausted (statusCode 153)

Test error handling when authentication attempts are exhausted:

  1. Log in with password
  2. Navigate to Notifications screen
  3. Tap notification action button
  4. Enter wrong password 3 times:
    • First attempt: "3 attempts remaining" (green)
    • Second attempt: "2 attempts remaining" (orange)
    • Third attempt: "1 attempt remaining" (red)
  5. Verify attempts exhausted:
    • onUpdateNotification receives statusCode 153
    • Alert displays BEFORE logout: "Authentication Failed - Attempts Exhausted"
    • Step-up dialog closes
    • Action modal closes
  6. Tap "OK" on alert
  7. Verify logout flow:
    • SDK triggers onUserLoggedOff event
    • SDKEventProvider handles logout
    • User navigated to login screen

Test Scenario 6: Keyboard Management

Test that keyboard doesn't hide action buttons:

  1. Log in and navigate to Notifications screen
  2. Tap notification action button
  3. Step-up dialog appears
  4. Tap password input field
  5. Verify keyboard behavior:
    • Keyboard appears
    • Dialog buttons ("Verify & Continue", "Cancel") remain visible
    • Dialog content scrolls if needed
    • Buttons are not hidden behind keyboard
  6. Test on multiple devices with different screen sizes

Verification Checklist

Use this checklist to verify your implementation:

Let's understand why we chose ViewModel-level handling for challengeMode = 3 instead of global handling.

Design Decision Rationale

The implementation handles getPassword with challengeMode = 3 at the ViewModel level (GetNotificationsViewModel) rather than globally. This is a deliberate architectural choice with significant benefits.

ViewModel-Level Handler Approach (Current Implementation)

Advantages:

  1. Context Access: Direct access to notification data (title, message, action) already loaded in ViewModel
  2. State Management: All step-up auth state lives in ViewModel StateFlow, no complex state passing
  3. UI Integration: Easy integration with Composable UI through state collection
  4. Modal Management: Simple state-based rendering of dialog
  5. Lifecycle Management: Event collection active only while ViewModel is in scope
  6. Event Isolation: Only handles challengeMode 3, other modes handled by SDKEventProvider
// GetNotificationsViewModel - ViewModel-level approach
class GetNotificationsViewModel(...) : ViewModel() {
    private fun setupEventHandlers() {
        viewModelScope.launch {
            callbackManager.getPasswordEvent.collect { eventData ->
                // Only handle challengeMode 3
                if (eventData.mode == RDNA.RDNAChallengeOpMode.RDNA_OP_AUTHORIZE_NOTIFICATION) {
                    // ViewModel has direct access to notification context
                    handleStepUpAuth(eventData)
                }
                // Other modes handled by SDKEventProvider
            }
        }
    }
}

Global Handler Approach (Alternative - Not Used)

Disadvantages if we used global approach:

  1. No Context Access: Notification data not available in global provider
  2. Complex State Management: Need to pass notification data through navigation or shared state
  3. Navigation Overhead: Navigate to new screen instead of modal overlay
  4. Poor UX: User loses context of which notification they're acting on
  5. Tight Coupling: Hard to reuse pattern for other step-up auth scenarios
  6. Maintenance Burden: Flow scattered across multiple files
// SDKEventProvider - Global approach (NOT USED)
object SDKEventProvider {
    fun initialize(...) {
        lifecycleOwner.lifecycleScope.launch {
            callbackManager.getPasswordEvent.collect { eventData ->
                if (eventData.mode == RDNA.RDNAChallengeOpMode.RDNA_OP_AUTHORIZE_NOTIFICATION) {
                    // Problems:
                    // - Notification context not available here
                    // - Need complex state management to pass data
                    // - Navigation to new screen breaks UX
                    navController.navigate(Routes.STEP_UP_AUTH)
                }
            }
        }
    }
}

Architecture Comparison

Aspect

ViewModel-Level Handler (βœ… Current)

Global Handler (❌ Alternative)

Context Access

Direct access to notification data

Need state management layer

UI Pattern

Modal dialog on same screen

Navigate to new screen

State Management

StateFlow in ViewModel

Need global state or navigation args

Code Locality

All related code in ViewModel & Screen

Scattered across multiple files

Maintenance

Easy to understand and modify

Hard to trace flow

Lifecycle

Automatic cleanup with ViewModel

Manual cleanup needed

Reusability

Pattern reusable for other features

Tightly coupled to specific flow

Composable Integration

Simple state collection

Complex navigation + state

When to Use Each Pattern

ViewModel-level handlers are recommended when:

Global handlers are appropriate when:

Let's address common issues you might encounter when implementing step-up authentication.

Issue 1: Step-Up Dialog Not Appearing

Symptoms:

Possible Causes & Solutions:

  1. Event not collected before API call: Ensure event collection happens in init block
// ❌ Wrong - Events collected after API call
class GetNotificationsViewModel(...) {
    init {
        loadNotifications() // Called first
        setupEventHandlers() // Too late!
    }
}

// βœ… Correct - Events collected first
class GetNotificationsViewModel(...) {
    init {
        setupEventHandlers() // Set up listeners first
        loadNotifications() // Then make API calls
    }
}
  1. Wrong challenge mode check: Verify enum comparison
// ❌ Wrong - Comparing ordinal values
if (eventData.mode == 3) { ... }

// βœ… Correct - Comparing enum
if (eventData.mode == RDNA.RDNAChallengeOpMode.RDNA_OP_AUTHORIZE_NOTIFICATION) { ... }
  1. State not collected in Composable: Use lifecycle-aware collection
// ❌ Wrong - No lifecycle awareness
val uiState = viewModel.uiState.collectAsState()

// βœ… Correct - Lifecycle-aware collection
val uiState by viewModel.uiState.collectAsStateWithLifecycle()

Issue 2: Password Field Not Clearing on Retry

Symptoms:

Solution: Add LaunchedEffect to clear password when error changes

// βœ… Correct - Auto-clear password on error
@Composable
fun StepUpPasswordDialog(...) {
    var password by remember { mutableStateOf("") }

    LaunchedEffect(errorMessage) {
        if (errorMessage != null && errorMessage.isNotEmpty()) {
            password = ""
        }
    }
}

Issue 3: Keyboard Hiding Action Buttons

Symptoms:

Solution: Use proper dialog sizing and scrollable content

// βœ… Correct - Proper sizing with scrollable content
Column {
    // Header (fixed)
    Box(modifier = Modifier.fillMaxWidth().background(...)) { ... }

    // Scrollable Content (max height)
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .heightIn(max = 400.dp)
            .verticalScroll(rememberScrollState())
    ) {
        // Form content
    }

    // Buttons (fixed, always visible)
    Column(modifier = Modifier.fillMaxWidth().background(...)) { ... }
}

Issue 4: Global Handler Broken After ViewModel Disposed

Symptoms:

Solution: Ensure ViewModel only handles challengeMode 3, others pass through

// βœ… Correct - Let other modes pass to global handler
viewModelScope.launch {
    callbackManager.getPasswordEvent.collect { eventData ->
        if (eventData.mode == RDNA.RDNAChallengeOpMode.RDNA_OP_AUTHORIZE_NOTIFICATION) {
            handleStepUpAuth(eventData)
        }
        // Other modes automatically handled by SDKEventProvider
    }
}

Issue 5: Alert Not Showing Before Logout

Symptoms:

Solution: Ensure alert is shown in onUpdateNotification handler

// βœ… Correct - Show alert BEFORE SDK logout
when (statusCode) {
    110, 153 -> {
        _uiState.update { state ->
            state.copy(
                showStepUpAuth = false,
                alertDialog = AlertDialogState(
                    title = "Authentication Failed",
                    message = statusMessage
                )
            )
        }
        // SDK will trigger logout after this
    }
}

Issue 6: LDA Fallback Not Working

Symptoms:

Solution: Verify both Password and LDA are enrolled

// This fallback only works when BOTH Password and LDA are enrolled
// If only LDA is enrolled, cancellation results in error code 131

// In handleUpdateNotificationResponse:
if (status.error?.longErrorCode == 131) {
    // LDA cancelled, no password enrolled
    _uiState.update { state ->
        state.copy(
            alertDialog = AlertDialogState(
                title = "Authentication Cancelled",
                message = "Local device authentication was cancelled. Please try again."
            )
        )
    }
}

Issue 7: Dialog Not Dismissible on Android

Symptoms:

Solution: Implement BackHandler in StepUpPasswordDialog

// βœ… Already implemented in StepUpPasswordDialog
@Composable
fun StepUpPasswordDialog(...) {
    // Disable back button during submission
    BackHandler(enabled = !isSubmitting) {
        onCancel()
    }
}

Debugging Tips

Enable detailed logging to troubleshoot issues:

// Add detailed logs at each step
private fun handleGetPasswordStepUp(eventData: GetPasswordEventData) {
    Log.d(TAG, "getPassword event - " +
        "mode: ${eventData.mode}, " +
        "attemptsLeft: ${eventData.attemptsLeft}, " +
        "statusCode: ${eventData.response?.status?.statusCode}"
    )

    Log.d(TAG, "State before showing dialog - " +
        "showStepUpAuth: ${_uiState.value.showStepUpAuth}, " +
        "attemptsLeft: ${_uiState.value.stepUpAttemptsLeft}"
    )
}

private fun handleUpdateNotificationResponse(status: RDNA.RDNAStatusUpdateNotification) {
    Log.d(TAG, "onUpdateNotification - " +
        "errorCode: ${status.error?.longErrorCode}, " +
        "statusCode: ${status.status?.statusCode?.intValue}, " +
        "message: ${status.status?.statusMessage}"
    )
}

Let's review important security considerations for step-up authentication implementation.

Password Handling

Never log or expose passwords:

// ❌ Wrong - Logging password
Log.d(TAG, "Password submitted: $password")

// βœ… Correct - Only log that password was submitted
Log.d(TAG, "Password submitted for step-up auth")

Clear sensitive data on disposal:

// βœ… Correct - Clear password on dialog dismiss
@Composable
fun StepUpPasswordDialog(...) {
    var password by remember { mutableStateOf("") }

    DisposableEffect(Unit) {
        onDispose {
            password = "" // Clear from memory
        }
    }
}

Authentication Method Respect

Never bypass step-up authentication:

// ❌ Wrong - Trying to bypass auth check
if (requiresAuth) {
    // Don't try to call action API directly
}

// βœ… Correct - Always respect SDK's auth requirement
fun selectNotificationAction(action: String) {
    val error = rdnaService.updateNotification(uuid, action)
    // Let SDK handle auth requirement via events
}

Error Message Sanitization

Don't expose sensitive information in error messages:

// ❌ Wrong - Exposing system details
AlertDialog(
    title = { Text("Error") },
    text = { Text("Database error: ${sqlException.stackTrace}") }
)

// βœ… Correct - User-friendly generic message
AlertDialog(
    title = { Text("Error") },
    text = { Text("Unable to process action. Please try again.") }
)

Attempt Limiting

Respect server-configured attempt limits:

// βœ… Correct - Use SDK-provided attempts
_uiState.update { it.copy(stepUpAttemptsLeft = eventData.attemptsLeft) }

// ❌ Wrong - Ignoring SDK attempts and implementing custom limit
const val MAX_ATTEMPTS = 5 // Don't do this

Session Security

Handle critical errors properly:

// βœ… Correct - Show alert BEFORE logout
when (statusCode) {
    110, 153 -> {
        _uiState.update { state ->
            state.copy(
                alertDialog = AlertDialogState(
                    title = "Authentication Failed",
                    message = statusMessage,
                    onDismiss = {
                        // SDK will trigger logout automatically
                    }
                )
            )
        }
    }
}

Biometric Fallback Security

Implement proper LDA cancellation handling:

// βœ… Correct - Allow retry or fallback based on enrollment
if (status.error?.longErrorCode == 131) {
    // If both Password & LDA enrolled: SDK falls back to password
    // If only LDA enrolled: Allow user to retry LDA
    _uiState.update { state ->
        state.copy(
            alertDialog = AlertDialogState(
                title = "Authentication Cancelled",
                message = "Local device authentication was cancelled. Please try again."
            )
        )
    }
}

Dialog Security

Prevent dismissal during sensitive operations:

// βœ… Correct - Disable dismissal during submission
Dialog(
    onDismissRequest = { if (!isSubmitting) onCancel() },
    properties = DialogProperties(
        dismissOnBackPress = !isSubmitting,
        dismissOnClickOutside = false
    )
) {
    // Dialog content
}

// Disable cancel button during submission
OutlinedButton(
    onClick = onCancel,
    enabled = !isSubmitting
) {
    Text("Cancel")
}

Audit and Monitoring

Log security-relevant events:

// βœ… Correct - Log auth attempts and results
Log.d(TAG, "Step-up authentication initiated for notification: $notificationUUID")
Log.d(TAG, "Step-up authentication result - " +
    "success: ${statusCode == 100}, " +
    "attemptsRemaining: $attemptsLeft, " +
    "authMethod: ${if (challengeMode == 3) "Password" else "LDA"}"
)

Testing Security Scenarios

Always test these security scenarios:

  1. Attempt exhaustion: Verify logout after max attempts
  2. Password expiry: Verify proper error handling for expired passwords
  3. Concurrent sessions: Test behavior with multiple devices
  4. Network failures: Ensure graceful handling of connection issues
  5. Biometric security: Verify SDK handles biometric security
  6. Replay attacks: SDK prevents replay of authentication tokens

Let's optimize the step-up authentication implementation for better performance.

StateFlow Optimization

Use StateFlow efficiently with minimal updates:

// βœ… Correct - Batched state update
_uiState.update { state ->
    state.copy(
        showActionModal = false,
        showStepUpAuth = true,
        stepUpErrorMessage = "",
        stepUpAttemptsLeft = 3
    )
}

// ❌ Wrong - Multiple separate updates
_uiState.update { it.copy(showActionModal = false) }
_uiState.update { it.copy(showStepUpAuth = true) }
_uiState.update { it.copy(stepUpErrorMessage = "") }
_uiState.update { it.copy(stepUpAttemptsLeft = 3) }

Avoid Unnecessary Recompositions

Use derivedStateOf for computed values:

// βœ… Correct - Memoize color calculation
@Composable
fun StepUpPasswordDialog(attemptsLeft: Int, ...) {
    val attemptsColor by remember(attemptsLeft) {
        derivedStateOf {
            when {
                attemptsLeft == 1 -> Color(0xFFDC2626)
                attemptsLeft == 2 -> Color(0xFFF59E0B)
                else -> Color(0xFF10B981)
            }
        }
    }
}

Optimize Modal Rendering

Only render dialog when needed:

// βœ… Correct - Conditional rendering
@Composable
fun GetNotificationsScreen(viewModel: GetNotificationsViewModel) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    Scaffold { padding ->
        // Main content

        // Only render dialog when visible
        if (uiState.showStepUpAuth) {
            StepUpPasswordDialog(
                notificationTitle = uiState.stepUpNotificationTitle,
                // ... other props
            )
        }
    }
}

Debounce Password Validation

Optional: Debounce complex password validation:

// Optional optimization for complex validation
@Composable
fun StepUpPasswordDialog(...) {
    var password by remember { mutableStateOf("") }

    // Debounce validation if you have complex password rules
    LaunchedEffect(password) {
        if (password.isNotEmpty()) {
            delay(300) // Wait 300ms after user stops typing
            // Perform complex validation
        }
    }
}

Memory Management

Clean up coroutines and listeners:

// βœ… ViewModel handles cleanup automatically
class GetNotificationsViewModel(...) : ViewModel() {
    private fun setupEventHandlers() {
        // viewModelScope automatically cancels when ViewModel is cleared
        viewModelScope.launch {
            callbackManager.getPasswordEvent.collect { ... }
        }
    }

    override fun onCleared() {
        super.onCleared()
        // Any manual cleanup if needed
    }
}

Performance Monitoring

Monitor step-up auth performance:

// Optional: Add performance monitoring
fun submitStepUpPassword(password: String) {
    val startTime = System.currentTimeMillis()

    val error = rdnaService.setPassword(password.trim(), challengeMode = 3)

    val duration = System.currentTimeMillis() - startTime
    Log.d(TAG, "setPassword completed in ${duration}ms - " +
        "success: ${error.longErrorCode == 0}"
    )
}

Optimize Notification Parsing

Cache parsed data to avoid re-parsing:

// βœ… Correct - Parse once and cache in state
private fun handleGetNotificationsResponse(status: RDNA.RDNAStatusGetNotifications) {
    val notifications = parseNotifications(status) // Parse once
    _uiState.update { state ->
        state.copy(notifications = notifications) // Cache in state
    }
}

// ❌ Wrong - Re-parsing on every access
val notifications = _uiState.value.notifications.map { parseNotification(it) }

Congratulations! You've successfully implemented step-up authentication for notification actions with REL-ID SDK on Android.

What You've Accomplished

In this codelab, you've learned how to:

βœ… Understand Step-Up Authentication: Learned when and why re-authentication is required for sensitive operations

βœ… Create StepUpPasswordDialog: Built a modal password dialog with attempts counter, error handling, and keyboard management using Jetpack Compose

βœ… Implement ViewModel-Level Event Handler: Used coroutine Flow collection to handle challengeMode = 3 at ViewModel level

βœ… Handle LDA and Password Flows: Supported both biometric authentication and password-based step-up with automatic fallback

βœ… Manage Critical Errors: Properly handled status codes 110, 153 with alerts before logout and error code 131 with alert

βœ… Optimize Keyboard Behavior: Implemented proper dialog sizing and scrolling to prevent buttons from being hidden

βœ… Auto-Clear Password Fields: Automatically cleared password when authentication failed and SDK triggered retry

βœ… Understand Architecture Decisions: Learned why ViewModel-level handlers are better than global handlers for step-up auth

Key Takeaways

Authentication Method Selection:

Error Handling:

Architecture Pattern:

Security Best Practices:

Additional Resources

Thank you for completing this codelab! You now have the knowledge to implement secure, production-ready step-up authentication for notification actions in your Android applications using Kotlin and Jetpack Compose.

Happy Coding! πŸš€