π― Learning Path:
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.
In this codelab, you'll enhance your existing notification application with:
challengeMode = 3 (RDNA_OP_AUTHORIZE_NOTIFICATION)getPassword callback handling for challengeMode 3By completing this codelab, you'll master:
Before starting this codelab, ensure you have:
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
This codelab extends your notification application with three core step-up authentication components:
getPassword event collection for challengeMode 3 in GetNotificationsViewModelonUpdateNotification 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.
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
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 is specifically for notification action authorization:
Challenge Mode | Purpose | User Action Required | Screen | Trigger |
| Verify existing password | Enter password to login | VerifyPasswordScreen | User login attempt |
| Set new password | Create password during activation | SetPasswordScreen | First-time activation |
| Update password (user-initiated) | Provide current + new password | UpdatePasswordScreen | User taps "Update Password" |
| Authorize notification action | Re-enter password for verification | StepUpPasswordDialog (Modal) | updateNotification() requires auth |
| Update expired password | Provide current + new password | UpdateExpiryPasswordScreen | Server detects expired password |
Important: The SDK automatically determines which authentication method to use based on:
Login Method | Enrolled Methods | Step-Up Authentication Method | SDK Behavior |
Password | Password only | Password | SDK triggers |
LDA | LDA only | LDA | SDK prompts biometric internally, no |
Password | Both Password & LDA | Password | SDK triggers |
LDA | Both Password & LDA | LDA (with Password fallback) | SDK attempts LDA first. If user cancels, SDK directly triggers |
The REL-ID SDK triggers these main events during step-up authentication:
Event Type | Description | User Action Required |
Password required for notification action authorization | User re-enters password for verification | |
Notification action result (success/failure/auth errors) | System handles response and displays result |
Step-up authentication can fail with these critical errors:
Error/Status Code | Type | Meaning | SDK Behavior | Action Required |
| Status | Success - action completed | Continue normal flow | Display success message |
| Status | Password expired during action | SDK triggers logout | Show alert BEFORE logout |
| Status | Attempts exhausted | SDK triggers logout | Show alert BEFORE logout |
| Error | LDA cancelled and Password NOT enrolled | No fallback available | Show alert, allow retry |
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.
The StepUpPasswordDialog needs to:
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:

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.
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
}
}
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 ...
}
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"
}
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.
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
)
}
}
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.
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()
}
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.
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")
}
}
)
}
}
}
}
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.
Before testing, ensure your REL-ID server is configured for step-up authentication:
Test the basic password step-up flow:
getNotifications() succeededgetPassword event triggered again with erroronUpdateNotification event with statusCode 100Test biometric authentication step-up:
getPassword event triggeredonUpdateNotification event with statusCode 100Test automatic fallback when user cancels biometric (requires both Password & LDA enrolled):
getPassword with challengeMode 3onUpdateNotification event with statusCode 100Test error handling when password expires during action:
onUpdateNotification receives statusCode 110onUserLoggedOff eventTest error handling when authentication attempts are exhausted:
onUpdateNotification receives statusCode 153onUserLoggedOff eventTest that keyboard doesn't hide action buttons:
Use this checklist to verify your implementation:
Let's understand why we chose ViewModel-level handling for challengeMode = 3 instead of global handling.
The implementation handles getPassword with challengeMode = 3 at the ViewModel level (GetNotificationsViewModel) rather than globally. This is a deliberate architectural choice with significant benefits.
Advantages:
// 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
}
}
}
}
Disadvantages if we used global approach:
// 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)
}
}
}
}
}
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 |
ViewModel-level handlers are recommended when:
Global handlers are appropriate when:
Let's address common issues you might encounter when implementing step-up authentication.
Symptoms:
getPassword event logged but dialog doesn't displayPossible Causes & Solutions:
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
}
}
// β Wrong - Comparing ordinal values
if (eventData.mode == 3) { ... }
// β
Correct - Comparing enum
if (eventData.mode == RDNA.RDNAChallengeOpMode.RDNA_OP_AUTHORIZE_NOTIFICATION) { ... }
// β Wrong - No lifecycle awareness
val uiState = viewModel.uiState.collectAsState()
// β
Correct - Lifecycle-aware collection
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Symptoms:
getPassword triggers againSolution: 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 = ""
}
}
}
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(...)) { ... }
}
Symptoms:
getPassword events for other modes not handledSolution: 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
}
}
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
}
}
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."
)
)
}
}
Symptoms:
Solution: Implement BackHandler in StepUpPasswordDialog
// β
Already implemented in StepUpPasswordDialog
@Composable
fun StepUpPasswordDialog(...) {
// Disable back button during submission
BackHandler(enabled = !isSubmitting) {
onCancel()
}
}
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.
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
}
}
}
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
}
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.") }
)
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
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
}
)
)
}
}
}
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."
)
)
}
}
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")
}
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"}"
)
Always test these security scenarios:
Let's optimize the step-up authentication implementation for better performance.
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) }
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)
}
}
}
}
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
)
}
}
}
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
}
}
}
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
}
}
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}"
)
}
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.
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
Authentication Method Selection:
Error Handling:
Architecture Pattern:
Security Best Practices:
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! π