Welcome to the REL-ID Additional Device Activation codelab! This tutorial builds upon the foundational MFA implementation to add sophisticated device onboarding capabilities using REL-ID Verify's push notification system.
In this codelab, you'll enhance your existing MFA application with:
By completing this codelab, you'll master:
addNewDeviceOptions events and device activation flowsBefore starting this codelab, ensure you have:
The code to get started is stored in a GitHub repository.
You can clone the repository using the following command:
git clone https://github.com/uniken-public/codelab-android.git
Navigate to the relid-MFA-additional-device-activation folder in the repository you cloned earlier
This codelab extends your MFA application with three core device activation components:
addNewDeviceOptions event processing and navigation coordinationBefore implementing device activation screens, let's understand the key SDK callbacks and APIs that power the additional device activation workflow.
The device activation process follows this event-driven pattern:
User Completes MFA on Primary Device → SDK Detects New Device On Secondary Device → addNewDeviceOptions Event → VerifyAuthScreen →
Push Notifications Sent → User Approves the Notification On Primary Device → Continue MFA Flow → Device Activated
Add these Kotlin data classes to understand device activation data structures:
// app/src/main/java/com/relidcodelab/uniken/models/RDNAModels.kt (device activation additions)
/**
* Device activation options event data
* Triggered when SDK detects unregistered device during authentication
*/
data class AddNewDeviceOptionsEventData(
val userId: String?,
val newDeviceOptions: Array<out String>?,
val response: RDNA.RDNAChallengeResponse?,
val error: RDNA.RDNAError?
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as AddNewDeviceOptionsEventData
if (userId != other.userId) return false
if (newDeviceOptions != null) {
if (other.newDeviceOptions == null) return false
if (!newDeviceOptions.contentEquals(other.newDeviceOptions)) return false
} else if (other.newDeviceOptions != null) return false
if (response != other.response) return false
if (error != other.error) return false
return true
}
override fun hashCode(): Int {
var result = userId?.hashCode() ?: 0
result = 31 * result + (newDeviceOptions?.contentHashCode() ?: 0)
result = 31 * result + (response?.hashCode() ?: 0)
result = 31 * result + (error?.hashCode() ?: 0)
return result
}
}
/**
* Notification body with localized content
*/
data class NotificationBody(
val lng: String,
val subject: String,
val message: String,
val label: Map<String, String>
)
/**
* Notification action available to the user
*/
data class NotificationAction(
val label: String,
val action: String,
val authlevel: String
)
/**
* Individual notification item from server
*/
data class NotificationItem(
val notification_uuid: String,
val create_ts: String,
val expiry_timestamp: String,
val create_ts_epoch: Long,
val expiry_timestamp_epoch: Long,
val body: List<NotificationBody>,
val actions: List<NotificationAction>,
val action_performed: String,
val ds_required: Boolean
)
The addNewDeviceOptions callback is the cornerstone of device activation:
The callback signature from RDNA.RDNACallbacks interface:
override fun addNewDeviceOptions(
userId: String?,
newDeviceOptions: Array<out String>?,
challengeInfo: HashMap<String, String>?
)
REL-ID Verify enables secure device-to-device approval:
Enhance your existing RDNAService with device activation APIs. These methods handle REL-ID Verify workflows and notification management.
Extend your RDNAService object with these device activation methods:
// app/src/main/java/com/relidcodelab/uniken/services/RDNAService.kt (device activation additions)
object RDNAService {
// ... existing methods ...
/**
* Performs REL-ID Verify authentication for device activation
* Sends push notifications to registered devices for approval
* @param verifyAuthStatus User's decision (true = proceed with verification, false = cancel)
* @return RDNA.RDNAError
*/
fun performVerifyAuth(verifyAuthStatus: Boolean): RDNA.RDNAError {
Log.d(TAG, "Performing verify auth with status: $verifyAuthStatus")
val error = rdna.PerformVerifyAuth(verifyAuthStatus)
if (error.longErrorCode == 0) {
Log.d(TAG, "PerformVerifyAuth sync response success, waiting for async events")
} else {
Log.e(TAG, "PerformVerifyAuth sync response error: ${error.errorString}")
}
return error
}
/**
* Initiates fallback device activation flow
* Alternative method when REL-ID Verify is not available/accessible
* @return RDNA.RDNAError
*/
fun fallbackNewDeviceActivationFlow(): RDNA.RDNAError {
Log.d(TAG, "Starting fallback new device activation flow")
val error = rdna.FallbackNewDeviceActivationFlow()
if (error.longErrorCode == 0) {
Log.d(TAG, "FallbackNewDeviceActivationFlow sync response success, alternative activation started")
} else {
Log.e(TAG, "FallbackNewDeviceActivationFlow sync response error: ${error.errorString}")
}
return error
}
/**
* Retrieves server notifications for the current user
* Loads all pending notifications with actions
* @param recordCount Number of records to fetch (0 = all active notifications)
* @param startIndex Index to begin fetching from (must be >= 1)
* @return RDNA.RDNAError
*/
fun getNotifications(
recordCount: Int = 0,
startIndex: Int = 1
): RDNA.RDNAError {
Log.d(TAG, "Fetching notifications with recordCount: $recordCount, startIndex: $startIndex")
val error = rdna.GetNotifications(
recordCount, // recordCount
"", // enterpriseID (optional)
startIndex, // startIndex
"", // startDate (optional)
"" // endDate (optional)
)
if (error.longErrorCode == 0) {
Log.d(TAG, "GetNotifications sync response success, waiting for onGetNotifications event")
} else {
Log.e(TAG, "GetNotifications sync response error: ${error.errorString}")
}
return error
}
/**
* Updates a notification with user action
* Processes user decision on notification actions
* @param notificationId Notification identifier (UUID)
* @param response Action response value selected by user
* @return RDNA.RDNAError
*/
fun updateNotification(notificationId: String, response: String): RDNA.RDNAError {
Log.d(TAG, "Updating notification: $notificationId with response: $response")
val error = rdna.UpdateNotification(notificationId, response)
if (error.longErrorCode == 0) {
Log.d(TAG, "UpdateNotification sync response success, waiting for onUpdateNotification event")
} else {
Log.e(TAG, "UpdateNotification sync response error: ${error.errorString}")
}
return error
}
}
verifyAuthStatus (Boolean) - automatically start verificationonGetNotifications callbackRDNA.RDNAStatusGetNotificationsonUpdateNotification callbackAll device activation APIs follow the established REL-ID SDK pattern:
longErrorCode == 0 means API call succeededEnhance your existing callback manager to handle device activation events. Add support for addNewDeviceOptions, notification retrieval, and notification updates.
Extend your RDNACallbackManager class with device activation event handling:
// app/src/main/java/com/relidcodelab/uniken/services/RDNACallbackManager.kt (device activation additions)
class RDNACallbackManager(
private val context: Context,
private val currentActivity: Activity?
) : RDNA.RDNACallbacks {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
// ... existing event flows ...
// Device activation event flows
private val _addNewDeviceOptionsEvent = MutableSharedFlow<AddNewDeviceOptionsEventData>()
val addNewDeviceOptionsEvent: SharedFlow<AddNewDeviceOptionsEventData> = _addNewDeviceOptionsEvent.asSharedFlow()
private val _getNotificationsEvent = MutableSharedFlow<RDNA.RDNAStatusGetNotifications>()
val getNotificationsEvent: SharedFlow<RDNA.RDNAStatusGetNotifications> = _getNotificationsEvent.asSharedFlow()
private val _updateNotificationEvent = MutableSharedFlow<RDNA.RDNAStatusUpdateNotification>()
val updateNotificationEvent: SharedFlow<RDNA.RDNAStatusUpdateNotification> = _updateNotificationEvent.asSharedFlow()
/**
* Handles device activation options event
* Triggered when SDK detects unregistered device during authentication
*/
override fun addNewDeviceOptions(
userId: String?,
newDeviceOptions: Array<out String>?,
challengeInfo: HashMap<String, String>?
) {
Log.d(TAG, "Add new device options event received")
Log.d(TAG, "UserID: $userId")
Log.d(TAG, "Available options: ${newDeviceOptions?.size ?: 0}")
// Log each activation option for debugging
newDeviceOptions?.forEachIndexed { index, option ->
Log.d(TAG, "Option ${index + 1}: $option")
}
scope.launch {
_addNewDeviceOptionsEvent.emit(
AddNewDeviceOptionsEventData(
userId = userId,
newDeviceOptions = newDeviceOptions,
response = null,
error = null
)
)
}
}
/**
* Handles get notifications response
* Triggered after getNotifications API call completes
*/
override fun onGetNotifications(status: RDNA.RDNAStatusGetNotifications): Int {
Log.d(TAG, "Get notifications event received")
val notificationCount = status.responseData?.notifications?.size ?: 0
Log.d(TAG, "Notification count: $notificationCount")
scope.launch {
_getNotificationsEvent.emit(status)
}
return 0
}
/**
* Handles update notification response
* Triggered after updateNotification API call completes
*/
override fun onUpdateNotification(status: RDNA.RDNAStatusUpdateNotification): Int {
Log.d(TAG, "Update notification event received")
Log.d(TAG, "Status code: ${status.statusCode}")
Log.d(TAG, "Status message: ${status.statusMsg}")
scope.launch {
_updateNotificationEvent.emit(status)
}
return 0
}
}
getNotifications() API callupdateNotification() API callThe device activation events integrate with existing event management using SharedFlow:
// Example of comprehensive event collection in SDKEventProvider
lifecycleOwner.lifecycleScope.launch {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
// Existing MFA event handlers
launch { handleGetUserEvents(...) }
launch { handleGetPasswordEvents(...) }
// ... other MFA handlers ...
// Device activation event handlers
launch { handleAddNewDeviceOptionsEvents(...) }
launch { handleGetNotificationsEvents(...) }
launch { handleUpdateNotificationEvents(...) }
}
}
Create the VerifyAuthScreen that handles REL-ID Verify device activation with automatic push notification processing and fallback options.
// app/src/main/java/com/relidcodelab/tutorial/viewmodels/VerifyAuthViewModel.kt
data class VerifyAuthUiState(
val isProcessing: Boolean = false,
val error: String = "",
val userId: String? = null,
val options: Array<out String>? = null
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as VerifyAuthUiState
if (isProcessing != other.isProcessing) return false
if (error != other.error) return false
if (userId != other.userId) return false
if (options != null) {
if (other.options == null) return false
if (!options.contentEquals(other.options)) return false
} else if (other.options != null) return false
return true
}
override fun hashCode(): Int {
var result = isProcessing.hashCode()
result = 31 * result + error.hashCode()
result = 31 * result + (userId?.hashCode() ?: 0)
result = 31 * result + (options?.contentHashCode() ?: 0)
return result
}
}
class VerifyAuthViewModel(
private val rdnaService: RDNAService,
private val callbackManager: RDNACallbackManager,
private val initialEventData: AddNewDeviceOptionsEventData?
) : ViewModel() {
private val _uiState = MutableStateFlow(VerifyAuthUiState())
val uiState: StateFlow<VerifyAuthUiState> = _uiState.asStateFlow()
companion object {
private const val TAG = "VerifyAuthViewModel"
}
init {
processActivationData()
}
private fun processActivationData() {
initialEventData?.let { data ->
Log.d(TAG, "Processing activation data for user: ${data.userId}")
_uiState.update {
it.copy(
userId = data.userId,
options = data.newDeviceOptions
)
}
// Automatically call performVerifyAuth(true) when data is processed
performVerifyAuth(true)
}
}
fun performVerifyAuth(status: Boolean) {
if (_uiState.value.isProcessing) return
viewModelScope.launch {
_uiState.update { it.copy(isProcessing = true, error = "") }
try {
Log.d(TAG, "Performing verify auth: $status")
val error = rdnaService.performVerifyAuth(status)
if (error.longErrorCode != 0) {
_uiState.update {
it.copy(
isProcessing = false,
error = error.errorString ?: "Unknown error"
)
}
} else {
_uiState.update { it.copy(isProcessing = false) }
if (status) {
Log.d(TAG, "REL-ID Verify notification has been sent to registered devices")
}
}
} catch (e: Exception) {
Log.e(TAG, "PerformVerifyAuth error", e)
_uiState.update {
it.copy(
isProcessing = false,
error = e.message ?: "Unknown error"
)
}
}
}
}
fun fallbackNewDeviceActivationFlow() {
if (_uiState.value.isProcessing) return
viewModelScope.launch {
_uiState.update { it.copy(isProcessing = true, error = "") }
try {
Log.d(TAG, "Initiating fallback new device activation flow")
val error = rdnaService.fallbackNewDeviceActivationFlow()
if (error.longErrorCode != 0) {
_uiState.update {
it.copy(
isProcessing = false,
error = error.errorString ?: "Unknown error"
)
}
} else {
_uiState.update { it.copy(isProcessing = false) }
Log.d(TAG, "Alternative device activation process has been initiated")
}
} catch (e: Exception) {
Log.e(TAG, "FallbackNewDeviceActivationFlow error", e)
_uiState.update {
it.copy(
isProcessing = false,
error = e.message ?: "Unknown error"
)
}
}
}
}
fun resetAuthState() {
viewModelScope.launch {
try {
Log.d(TAG, "Calling resetAuthState")
val error = rdnaService.resetAuthState()
if (error.longErrorCode == 0) {
Log.d(TAG, "ResetAuthState successful")
} else {
Log.e(TAG, "ResetAuthState error: ${error.errorString}")
}
} catch (e: Exception) {
Log.e(TAG, "ResetAuthState exception", e)
}
}
}
}
// app/src/main/java/com/relidcodelab/tutorial/screens/mfa/VerifyAuthScreen.kt
@Composable
fun VerifyAuthScreen(
viewModel: VerifyAuthViewModel,
title: String = "Additional Device Activation",
subtitle: String = "Activate this device for secure access"
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
BackHandler(enabled = true) {
// Disable back button during processing
}
Scaffold(
containerColor = PageBackground
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.imePadding()
.verticalScroll(rememberScrollState())
.padding(20.dp)
) {
// Close Button
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.TopEnd
) {
IconButton(
onClick = { viewModel.resetAuthState() },
enabled = !uiState.isProcessing
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Close"
)
}
}
Spacer(modifier = Modifier.height(40.dp))
// Title
Text(
text = title,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
// Subtitle
Text(
text = subtitle,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(30.dp))
// Error Display
if (uiState.error.isNotEmpty()) {
Card(
colors = CardDefaults.cardColors(
containerColor = ErrorBackground
),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = uiState.error,
color = ErrorText,
modifier = Modifier.padding(16.dp)
)
}
Spacer(modifier = Modifier.height(20.dp))
}
// Processing Status
if (uiState.isProcessing) {
Card(
colors = CardDefaults.cardColors(
containerColor = InfoBackground
),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "Processing device activation...",
color = InfoText
)
}
}
Spacer(modifier = Modifier.height(20.dp))
}
// Activation Information
if (uiState.userId != null) {
// Processing Message
Card(
colors = CardDefaults.cardColors(
containerColor = Color(0xFFE3F2FD)
),
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(20.dp)
) {
Text(
text = "REL-ID Verify Authentication",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = Color(0xFF1976D2)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "REL-ID Verify notification has been sent to your registered devices. Please approve it to activate this device.",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF1565C0),
lineHeight = 24.sp
)
}
}
Spacer(modifier = Modifier.height(20.dp))
// Fallback Option
Card(
colors = CardDefaults.cardColors(
containerColor = Color(0xFFF5F5F5)
),
border = BorderStroke(1.dp, Color(0xFFE0E0E0)),
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Device Not Handy?",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = Color(0xFF2C3E50)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "If you don't have access to your registered devices, you can use an alternative activation method.",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF7F8C8D),
textAlign = TextAlign.Center,
lineHeight = 20.sp
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedButton(
onClick = { viewModel.fallbackNewDeviceActivationFlow() },
enabled = !uiState.isProcessing
) {
Text("Activate using fallback method")
}
}
}
}
}
}
}
performVerifyAuth(true) when screen loadsThe following image showcases screen from the sample application:

Create the GetNotificationsScreen that automatically loads server notifications and provides interactive action modals for user responses.
// app/src/main/java/com/relidcodelab/tutorial/viewmodels/GetNotificationsViewModel.kt
data class GetNotificationsUiState(
val isLoading: Boolean = true,
val isRefreshing: Boolean = false,
val error: String = "",
val notifications: List<NotificationItem> = emptyList(),
val selectedNotification: NotificationItem? = null,
val selectedAction: String = "",
val isProcessingAction: Boolean = false,
val showActionModal: Boolean = false
)
class GetNotificationsViewModel(
private val rdnaService: RDNAService,
private val callbackManager: RDNACallbackManager,
private val userID: String
) : ViewModel() {
private val _uiState = MutableStateFlow(GetNotificationsUiState())
val uiState: StateFlow<GetNotificationsUiState> = _uiState.asStateFlow()
companion object {
private const val TAG = "GetNotificationsViewModel"
}
init {
setupEventHandlers()
loadNotifications()
}
private fun setupEventHandlers() {
viewModelScope.launch {
// Get notifications event handler
callbackManager.getNotificationsEvent.collect { status ->
handleGetNotificationsResponse(status)
}
}
viewModelScope.launch {
// Update notification event handler
callbackManager.updateNotificationEvent.collect { status ->
handleUpdateNotificationResponse(status)
}
}
}
private fun handleGetNotificationsResponse(status: RDNA.RDNAStatusGetNotifications) {
Log.d(TAG, "Received notifications event")
val notificationList = status.responseData?.notifications?.map { item ->
NotificationItem(
notification_uuid = item.notification_uuid,
create_ts = item.create_ts,
expiry_timestamp = item.expiry_timestamp,
create_ts_epoch = item.create_ts_epoch,
expiry_timestamp_epoch = item.expiry_timestamp_epoch,
body = item.body.map { body ->
NotificationBody(
lng = body.lng,
subject = body.subject,
message = body.message,
label = body.label
)
},
actions = item.actions.map { action ->
NotificationAction(
label = action.label,
action = action.action,
authlevel = action.authlevel
)
},
action_performed = item.action_performed,
ds_required = item.ds_required
)
} ?: emptyList()
Log.d(TAG, "Received ${notificationList.size} notifications")
_uiState.update {
it.copy(
notifications = notificationList,
isLoading = false,
isRefreshing = false
)
}
}
private fun handleUpdateNotificationResponse(status: RDNA.RDNAStatusUpdateNotification) {
Log.d(TAG, "Received update notification event")
_uiState.update { it.copy(isProcessingAction = false) }
if (status.statusCode == 100) {
Log.d(TAG, "Update notification success: ${status.statusMsg}")
_uiState.update { it.copy(showActionModal = false) }
loadNotifications()
} else {
Log.e(TAG, "Update notification error: ${status.statusMsg}")
_uiState.update {
it.copy(
error = status.statusMsg ?: "Failed to update notification",
showActionModal = false
)
}
}
}
fun loadNotifications() {
viewModelScope.launch {
try {
_uiState.update { it.copy(error = "") }
Log.d(TAG, "Loading notifications for user: $userID")
val error = rdnaService.getNotifications()
if (error.longErrorCode != 0) {
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
error = error.errorString ?: "Failed to load notifications"
)
}
}
} catch (e: Exception) {
Log.e(TAG, "Error loading notifications", e)
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
error = e.message ?: "Failed to load notifications"
)
}
}
}
}
fun refresh() {
_uiState.update { it.copy(isRefreshing = true) }
loadNotifications()
}
fun selectNotification(notification: NotificationItem) {
if (notification.actions.isEmpty()) {
_uiState.update { it.copy(error = "This notification has no available actions") }
return
}
if (notification.action_performed.isNotEmpty() && notification.action_performed != "PENDING") {
_uiState.update { it.copy(error = "This notification has already been processed") }
return
}
_uiState.update {
it.copy(
selectedNotification = notification,
selectedAction = "",
showActionModal = true,
error = ""
)
}
}
fun selectAction(actionId: String) {
_uiState.update { it.copy(selectedAction = actionId) }
}
fun dismissActionModal() {
if (!_uiState.value.isProcessingAction) {
_uiState.update {
it.copy(
showActionModal = false,
selectedNotification = null,
selectedAction = ""
)
}
}
}
fun processNotificationAction() {
val notification = _uiState.value.selectedNotification ?: return
val actionId = _uiState.value.selectedAction
if (actionId.isEmpty()) {
_uiState.update { it.copy(error = "Please select an action to proceed") }
return
}
viewModelScope.launch {
_uiState.update { it.copy(isProcessingAction = true, error = "") }
try {
Log.d(TAG, "Processing notification action: notificationId=${notification.notification_uuid}, actionId=$actionId")
val error = rdnaService.updateNotification(notification.notification_uuid, actionId)
if (error.longErrorCode != 0) {
_uiState.update {
it.copy(
isProcessingAction = false,
error = error.errorString ?: "Failed to process action"
)
}
}
} catch (e: Exception) {
Log.e(TAG, "Error processing action", e)
_uiState.update {
it.copy(
isProcessingAction = false,
error = e.message ?: "Failed to process action"
)
}
}
}
}
}
// app/src/main/java/com/relidcodelab/tutorial/screens/notification/GetNotificationsScreen.kt
@Composable
fun GetNotificationsScreen(
viewModel: GetNotificationsViewModel
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
topBar = {
Column(
modifier = Modifier
.fillMaxWidth()
.background(Color.White)
.shadow(3.dp)
.padding(horizontal = 20.dp, vertical = 16.dp)
.padding(top = 44.dp)
) {
Text(
text = "Notifications",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = Color(0xFF333333)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Manage your REL-ID notifications",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF666666)
)
}
},
containerColor = Color(0xFFF5F5F5)
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(20.dp)
) {
// Error Display
if (uiState.error.isNotEmpty()) {
Card(
colors = CardDefaults.cardColors(
containerColor = ErrorBackground
),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = uiState.error,
color = ErrorText,
modifier = Modifier.padding(16.dp)
)
}
Spacer(modifier = Modifier.height(16.dp))
}
// Loading State
if (uiState.isLoading && !uiState.isRefreshing) {
Card(
colors = CardDefaults.cardColors(
containerColor = InfoBackground
),
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "Loading notifications...",
color = InfoText
)
}
}
} else {
// Notification List
if (uiState.notifications.isEmpty()) {
// Empty State
Column(
modifier = Modifier
.fillMaxSize()
.padding(vertical = 60.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "No Notifications",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF333333)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "You don't have any notifications at the moment.",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF666666),
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(20.dp))
OutlinedButton(onClick = { viewModel.refresh() }) {
Text("Refresh")
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(
items = uiState.notifications,
key = { it.notification_uuid }
) { notification ->
NotificationItemCard(
notification = notification,
onClick = { viewModel.selectNotification(notification) }
)
}
}
}
}
}
}
// Action Modal
if (uiState.showActionModal && uiState.selectedNotification != null) {
NotificationActionModal(
notification = uiState.selectedNotification!!,
selectedAction = uiState.selectedAction,
isProcessing = uiState.isProcessingAction,
onActionSelect = { viewModel.selectAction(it) },
onSubmit = { viewModel.processNotificationAction() },
onDismiss = { viewModel.dismissActionModal() }
)
}
}
@Composable
private fun NotificationItemCard(
notification: NotificationItem,
onClick: () -> Unit
) {
val primaryBody = notification.body.firstOrNull()
val subject = primaryBody?.subject ?: "No Subject"
val message = primaryBody?.message ?: "No Message"
Card(
onClick = onClick,
colors = CardDefaults.cardColors(
containerColor = Color.White
),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp)
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = subject,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF333333),
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = formatTimestamp(notification.create_ts),
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF8E8E93)
)
}
Spacer(modifier = Modifier.height(8.dp))
// Message
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF666666),
maxLines = 3,
overflow = TextOverflow.Ellipsis,
lineHeight = 20.sp
)
Spacer(modifier = Modifier.height(12.dp))
// Footer
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "${notification.actions.size} action${if (notification.actions.size != 1) "s" else ""} available",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF8E8E93)
)
Text(
text = notification.action_performed.ifEmpty { "Pending" },
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF8E8E93)
)
}
if (notification.expiry_timestamp.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Expires: ${formatTimestamp(notification.expiry_timestamp)}",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF8E8E93)
)
}
}
}
}
private fun formatTimestamp(timestamp: String): String {
return try {
val instant = Instant.parse(timestamp.replace("UTC", "Z"))
val localDateTime = LocalDateTime.ofInstant(instant, ZoneId.systemDefault())
val formatter = DateTimeFormatter.ofPattern("MMM dd, yyyy hh:mm a")
localDateTime.format(formatter)
} catch (e: Exception) {
timestamp
}
}
getNotifications() when screen loads in ViewModel initonGetNotifications and onUpdateNotification callbacksThe following images showcase screens from the sample application:
|
|
|
Extend your existing SDKEventProvider to handle device activation events and coordinate navigation for the additional device activation workflow.
Enhance your SDKEventProvider with device activation event handling:
// app/src/main/java/com/relidcodelab/uniken/providers/SDKEventProvider.kt (device activation additions)
object SDKEventProvider {
private var checkUserViewModel: CheckUserViewModel? = null
private var verifyAuthViewModel: VerifyAuthViewModel? = null
private var getNotificationsViewModel: GetNotificationsViewModel? = null
fun initialize(
lifecycleOwner: LifecycleOwner,
rdnaService: RDNAService,
callbackManager: RDNACallbackManager,
navController: NavController
) {
lifecycleOwner.lifecycleScope.launch {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
// Existing MFA event handlers
launch { handleGetUserEvents(rdnaService, callbackManager, navController) }
launch { handleGetPasswordEvents(rdnaService, callbackManager, navController) }
launch { handleUserLoggedInEvents(rdnaService, callbackManager, navController) }
// ... other MFA handlers ...
// Device activation event handlers
launch { handleAddNewDeviceOptionsEvents(rdnaService, callbackManager, navController) }
}
}
}
private suspend fun handleAddNewDeviceOptionsEvents(
rdnaService: RDNAService,
callbackManager: RDNACallbackManager,
navController: NavController
) {
callbackManager.addNewDeviceOptionsEvent.collect { eventData ->
Log.d(TAG, "Add new device options event received for user: ${eventData.userId}")
Log.d(TAG, "Available options: ${eventData.newDeviceOptions?.size ?: 0}")
// Create ViewModel with event data if it doesn't exist
if (verifyAuthViewModel == null) {
verifyAuthViewModel = VerifyAuthViewModel(
rdnaService = rdnaService,
callbackManager = callbackManager,
initialEventData = eventData
)
// Navigate to VerifyAuthScreen
navController.navigate(Routes.VERIFY_AUTH_SCREEN)
} else {
// ViewModel already exists, update it with new event data
Log.d(TAG, "VerifyAuthViewModel already exists, updating with new event data")
}
}
}
private suspend fun handleUserLoggedInEvents(
rdnaService: RDNAService,
callbackManager: RDNACallbackManager,
navController: NavController
) {
callbackManager.userLoggedInEvent.collect { eventData ->
Log.d(TAG, "User logged in event received for user: ${eventData.userId}")
val sessionID = eventData.response?.session?.sessionID ?: ""
val sessionType = eventData.response?.session?.sessionType ?: 0
val userRole = eventData.response?.additionalInfo?.idvUserRole ?: ""
val currentWorkFlow = eventData.response?.additionalInfo?.currentWorkFlow ?: ""
// Navigate to Dashboard (part of DrawerNavigator)
// DrawerNavigator includes GetNotifications screen
navController.navigate(Routes.DASHBOARD)
// Note: GetNotifications is accessible via drawer menu
// Users can manually navigate to it from Dashboard
}
}
fun getVerifyAuthViewModel(): VerifyAuthViewModel? = verifyAuthViewModel
fun getGetNotificationsViewModel(): GetNotificationsViewModel? = getNotificationsViewModel
fun createGetNotificationsViewModel(
rdnaService: RDNAService,
callbackManager: RDNACallbackManager,
userID: String
): GetNotificationsViewModel {
if (getNotificationsViewModel == null) {
getNotificationsViewModel = GetNotificationsViewModel(
rdnaService = rdnaService,
callbackManager = callbackManager,
userID = userID
)
}
return getNotificationsViewModel!!
}
fun clearViewModels() {
checkUserViewModel = null
verifyAuthViewModel = null
getNotificationsViewModel = null
}
}
Update your navigation types to support the new device activation screens:
// app/src/main/java/com/relidcodelab/tutorial/navigation/Routes.kt
object Routes {
const val TUTORIAL_HOME = "TutorialHome"
const val CHECK_USER = "CheckUser"
const val VERIFY_AUTH_SCREEN = "VerifyAuthScreen"
const val DASHBOARD = "Dashboard"
const val GET_NOTIFICATIONS = "GetNotifications"
// ... other routes ...
}
Update your AppNavigation to include the device activation screens:
// app/src/main/java/com/relidcodelab/tutorial/navigation/AppNavigation.kt
@Composable
fun AppNavigation(
lifecycleOwner: LifecycleOwner,
rdnaService: RDNAService,
callbackManager: RDNACallbackManager,
navController: NavHostController = rememberNavController()
): NavHostController {
NavHost(
navController = navController,
startDestination = Routes.TUTORIAL_HOME
) {
// ... existing screens ...
// Device Activation Screen
composable(Routes.VERIFY_AUTH_SCREEN) {
val viewModel = SDKEventProvider.getVerifyAuthViewModel()
if (viewModel != null) {
VerifyAuthScreen(viewModel = viewModel)
} else {
// Fallback UI
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text("Loading...")
}
}
}
// Dashboard with Drawer (includes GetNotifications)
composable(Routes.DASHBOARD) {
// DrawerNavigator provides access to GetNotifications
DrawerNavigator(
rdnaService = rdnaService,
callbackManager = callbackManager,
navController = navController
)
}
// GetNotifications Screen (accessible via drawer)
composable(Routes.GET_NOTIFICATIONS) {
// Extract userID from saved state or navigation arguments
val userID = "user@example.com" // In practice, get from session or arguments
val viewModel = SDKEventProvider.createGetNotificationsViewModel(
rdnaService = rdnaService,
callbackManager = callbackManager,
userID = userID
)
GetNotificationsScreen(viewModel = viewModel)
}
}
return navController
}
The enhanced SDKEventProvider coordinates these device activation flows:
addNewDeviceOptionsThe provider uses a layered event handling approach:
addNewDeviceOptionsgetNotificationsTest your device activation implementation to ensure REL-ID Verify workflows, fallback methods, and notification management work correctly across different scenarios.
Test the complete automatic device activation flow:
# Ensure you have multiple physical devices
# Device A: Already registered with REL-ID
# Device B: New device for activation testing
# Build and deploy to both devices
./gradlew installDebug
# Or use Android Studio Run on each device
addNewDeviceOptions eventperformVerifyAuth(true) called automatically in logsTest the fallback activation when REL-ID Verify is not accessible:
fallbackNewDeviceActivationFlow() called in logsD/VerifyAuthViewModel: Initiating fallback new device activation flow
D/RDNAService: FallbackNewDeviceActivationFlow sync response success, alternative activation started
Test the GetNotificationsScreen functionality:
getNotifications() API called again// Check if device is already registered
// Verify MFA flow completion before device detection
// Ensure proper connection profile configuration
// Check Logcat for SDK callback logs
// Check event handler setup in ViewModel
viewModelScope.launch {
callbackManager.getNotificationsEvent.collect { status ->
// Handle notifications
}
}
// Verify API call execution
val error = rdnaService.getNotifications()
addNewDeviceOptions callback triggers during MFAperformVerifyAuth(true) executes automaticallyCongratulations! You've successfully implemented a comprehensive Additional Device Activation system with REL-ID Verify push notifications, fallback methods, and notification management.
✅ REL-ID Verify Integration: Automatic push notification-based device activation ✅ VerifyAuthScreen Implementation: Auto-starting activation with real-time status updates ✅ Fallback Activation Methods: Alternative activation when registered devices aren't accessible ✅ GetNotificationsScreen: Server notification management with interactive action processing ✅ Enhanced Drawer Navigation: Seamless access to notifications via enhanced navigation
context.resourcesrepeatOnLifecycleYou've mastered Advanced Device Activation with REL-ID Verify and built a production-ready Android system that provides:
Your application now provides enterprise-grade device activation capabilities that enhance security while maintaining user convenience. You're ready to deploy this solution in production environments and scale to support thousands of users across multiple devices.
🚀 You're now equipped to build sophisticated device activation workflows that combine security, usability, and reliability with modern Android development patterns!