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.

What You'll Build

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

What You'll Learn

By completing this codelab, you'll master:

  1. Advanced SDK Event Handling: Managing addNewDeviceOptions callbacks and device activation flows
  2. REL-ID Verify Workflows: Implementing automatic push notification-based device approval
  3. Fallback Strategies: Building robust alternative activation methods for various user scenarios
  4. Notification Systems: Creating comprehensive server notification management with user interactions
  5. iOS Navigation Patterns: Integrating side menu navigation with notification access points
  6. Production Patterns: Implementing error handling, status management, and user experience optimizations

Prerequisites

Before starting this codelab, ensure you have:

Get the Code from GitHub

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-ios.git

Navigate to the relid-MFA-additional-device-activation folder in the repository you cloned earlier

Codelab Architecture Overview

This codelab extends your MFA application with three core device activation components:

  1. VerifyAuthViewController: Automatic REL-ID Verify activation with real-time status updates
  2. GetNotificationsViewController: Server notification management with interactive action modals
  3. Enhanced Event Handling: addNewDeviceOptions callback processing and navigation coordination

Before implementing device activation screens, let's understand the key SDK callbacks and APIs that power the additional device activation workflow.

Device Activation callbacks Flow

The device activation process follows this event-driven pattern:

User Completes MFA on Primary Device → SDK Detects New Device On Secondary Device → addNewDeviceOptions Callback → VerifyAuthViewController →
Push Notifications Sent → User Approves the Notification On  Primary Device → Continue MFA Flow -> Device Activated

Core Device Activation Callback Signatures

Understanding the SDK callback signatures is essential for device activation:

// RDNACallbacks Protocol - Device Activation Methods

/**
 * Device activation options callback
 * Triggered when SDK detects unregistered device during authentication
 */
func getAddNewDeviceOptions(
    _ userID: String,
    newDeviceOptions: [String],
    challengeInfo: [RDNAChallengeInfo]
)

/**
 * Get notifications response callback
 * Triggered after getNotifications API call completes
 */
func onGetNotifications(_ status: RDNAStatusGetNotifications)

/**
 * Update notification response callback
 * Triggered after updateNotification API call completes
 */
func onUpdateNotification(_ status: RDNAStatusUpdateNotification)

Understanding addNewDeviceOptions Callback

The addNewDeviceOptions callback is the cornerstone of device activation:

When It Triggers

Callback Parameters

userID: String                          // User identifier
newDeviceOptions: [String]              // Available activation methods
challengeInfo: [RDNAChallengeInfo]      // Challenge metadata

REL-ID Verify Workflow

REL-ID Verify enables secure device-to-device approval:

  1. Push Notification Sent: SDK sends approval request to user's registered devices
  2. User Receives Notification: Registered device shows activation approval request
  3. User Approves/Rejects: User makes decision on registered device
  4. Response Processed: New device receives approval status
  5. Activation Completed: Device registration finalized, MFA flow continues

Enhance your existing RDNAService with device activation APIs. These methods handle REL-ID Verify workflows and notification management.

Adding Device Activation APIs

Extend your RDNAService class with these device activation methods:

// RDNAService.swift (device activation additions)

/**
 * 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)
 * @returns RDNAError
 */
func performVerifyAuth(_ verifyAuthStatus: Bool) -> RDNAError {
    print("RDNAService - Performing verify auth with status: \(verifyAuthStatus)")

    let error = rdna.performVerifyAuth(verifyAuthStatus)

    if error.longErrorCode == 0 {
        print("RDNAService - PerformVerifyAuth successful")
    } else {
        print("RDNAService - PerformVerifyAuth failed: \(error.errorString)")
    }

    return error
}

/**
 * Initiates fallback device activation flow
 * Alternative method when REL-ID Verify is not available/accessible
 * @returns RDNAError
 */
func fallbackNewDeviceActivationFlow() -> RDNAError {
    print("RDNAService - Starting fallback new device activation flow")

    let error = rdna.fallbackNewDeviceActivationFlow()

    if error.longErrorCode == 0 {
        print("RDNAService - FallbackNewDeviceActivationFlow successful, alternative activation started")
    } else {
        print("RDNAService - FallbackNewDeviceActivationFlow failed: \(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)
 * @param startDate Start date filter (optional)
 * @param endDate End date filter (optional)
 * @returns RDNAError
 */
func getNotifications(
    recordCount: Int = 0,
    startIndex: Int = 1,
    startDate: String = "",
    endDate: String = ""
) -> RDNAError {
    print("RDNAService - Fetching notifications with recordCount: \(recordCount), startIndex: \(startIndex)")

    let error = rdna.getNotifications(
        Int32(recordCount),
        withEnterpriseID: "",
        withStart: Int32(startIndex),
        withStartDate: startDate,
        withEndDate: endDate
    )

    if error.longErrorCode == 0 {
        print("RDNAService - GetNotifications request successful, waiting for onGetNotifications callback")
    } else {
        print("RDNAService - GetNotifications failed: \(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
 * @returns RDNAError
 */
func updateNotification(_ notificationID: String, response: String) -> RDNAError {
    print("RDNAService - Updating notification: \(notificationID) with response: \(response)")

    let error = rdna.updateNotification(notificationID, withResponse: response)

    if error.longErrorCode == 0 {
        print("RDNAService - UpdateNotification request successful, waiting for onUpdateNotification callback")
    } else {
        print("RDNAService - UpdateNotification failed: \(error.errorString)")
    }

    return error
}

Understanding Device Activation APIs

performVerifyAuth API

fallbackNewDeviceActivationFlow API

getNotifications API

updateNotification API

API Response Pattern

All device activation APIs follow the established REL-ID SDK pattern:

  1. Immediate Sync Response: Returns RDNAError indicating if API call was accepted by SDK
  2. Success Check: longErrorCode == 0 means API call succeeded
  3. Async Callback Processing: Actual results delivered via SDK callbacks
  4. Error Handling: Non-zero error codes indicate immediate failures

Enhance your existing delegate manager to handle device activation callbacks. Add support for addNewDeviceOptions, notification retrieval, and notification updates.

Adding Device Activation Callback Handlers

Extend your RDNADelegateManager class with device activation callback handling:

// RDNADelegateManager.swift (device activation additions)

class RDNADelegateManager: NSObject, RDNACallbacks {
    static let shared = RDNADelegateManager()

    // MARK: - Device Activation Closures

    var onAddNewDeviceOptions: ((_ userID: String, _ options: [String],
                                 _ info: [RDNAChallengeInfo]) -> Void)?
    var onGetNotifications: ((RDNAStatusGetNotifications) -> Void)?
    var onUpdateNotification: ((RDNAStatusUpdateNotification) -> Void)?

    // MARK: - Device Activation Callback Implementations

    /**
     * Handles device activation options callback
     * Triggered when SDK detects unregistered device during authentication
     */
    func getAddNewDeviceOptions(
        _ userID: String,
        newDeviceOptions: [String],
        challengeInfo: [RDNAChallengeInfo]
    ) {
        print("RDNADelegateManager - Add new device options callback received")
        print("RDNADelegateManager - UserID: \(userID)")
        print("RDNADelegateManager - Available options: \(newDeviceOptions.count)")
        print("RDNADelegateManager - Challenge info count: \(challengeInfo.count)")

        // Log each activation option for debugging
        newDeviceOptions.enumerated().forEach { index, option in
            print("RDNADelegateManager - Option \(index + 1): \(option)")
        }

        DispatchQueue.main.async { [weak self] in
            self?.onAddNewDeviceOptions?(userID, newDeviceOptions, challengeInfo)
        }
    }

    /**
     * Handles get notifications response
     * Triggered after getNotifications API call completes
     */
    func onGetNotifications(_ status: RDNAStatusGetNotifications) {
        print("RDNADelegateManager - Get notifications callback received")

        // Parse notification data
        let notificationCount = status.notificationResponse?.notifications?.count ?? 0
        print("RDNADelegateManager - Notification count: \(notificationCount)")

        DispatchQueue.main.async { [weak self] in
            self?.onGetNotifications?(status)
        }
    }

    /**
     * Handles update notification response
     * Triggered after updateNotification API call completes
     */
    func onUpdateNotification(_ status: RDNAStatusUpdateNotification) {
        print("RDNADelegateManager - Update notification callback received")
        print("RDNADelegateManager - Status code: \(status.statusCode)")
        print("RDNADelegateManager - Status message: \(status.statusMessage ?? "")")

        DispatchQueue.main.async { [weak self] in
            self?.onUpdateNotification?(status)
        }
    }
}

Cleanup Methods

Add cleanup methods to properly manage device activation callbacks:

// Enhanced cleanup method to clear device activation handlers
func clearDeviceActivationHandlers() {
    onAddNewDeviceOptions = nil
    onGetNotifications = nil
    onUpdateNotification = nil
}

// Enhanced cleanup method to clear all handlers
func cleanup() {
    // Clear existing MFA handlers
    clearActivationHandlers()

    // Clear device activation handlers
    clearDeviceActivationHandlers()

    // Clear existing MTD handlers
    clearMTDHandlers()
}

Understanding Device Activation Callbacks

addNewDeviceOptions Callback

getNotifications Callback

updateNotification Callback

Delegate Manager Integration Pattern

The device activation callbacks integrate with existing event management:

// Example of comprehensive callback setup in AppCoordinator
func setupGlobalCallbackNavigation() {
    let delegateManager = RDNADelegateManager.shared

    // Existing MFA callback handlers
    delegateManager.onGetUser = { [weak self] userNames, recentUser, response, error in
        self?.showCheckUser(userNames: userNames, recentlyLoggedInUser: recentUser,
                          response: response, error: error)
    }

    delegateManager.onGetPassword = { [weak self] userID, challengeMode, attemptsLeft, response, error in
        self?.showPasswordScreen(userID: userID, challengeMode: challengeMode,
                                attemptsLeft: attemptsLeft, response: response, error: error)
    }

    // Device activation callback handlers
    delegateManager.onAddNewDeviceOptions = { [weak self] userID, options, info in
        self?.showVerifyAuth(userID: userID, options: options, info: info)
    }

    delegateManager.onGetNotifications = { [weak self] status in
        // GetNotificationsViewController handles this directly
        print("AppCoordinator - Get notifications callback handled by screen")
    }

    delegateManager.onUpdateNotification = { [weak self] status in
        // GetNotificationsViewController handles this directly
        print("AppCoordinator - Update notification callback handled by screen")
    }
}

Create the VerifyAuthViewController that handles REL-ID Verify device activation with automatic push notification processing and fallback options.

VerifyAuthViewController Implementation

// VerifyAuthViewController.swift
import UIKit

class VerifyAuthViewController: UIViewController {

    // MARK: - IBOutlets

    @IBOutlet weak var titleLabel: UILabel!
    @IBOutlet weak var subtitleLabel: UILabel!
    @IBOutlet weak var messageContainer: UIView!
    @IBOutlet weak var messageTitle: UILabel!
    @IBOutlet weak var messageText: UILabel!
    @IBOutlet weak var fallbackContainer: UIView!
    @IBOutlet weak var fallbackTitle: UILabel!
    @IBOutlet weak var fallbackDescription: UILabel!
    @IBOutlet weak var fallbackButton: UIButton!
    @IBOutlet weak var processingContainer: UIView!
    @IBOutlet weak var processingLabel: UILabel!
    @IBOutlet weak var activityIndicator: UIActivityIndicatorView!
    @IBOutlet weak var resultContainer: UIView!
    @IBOutlet weak var resultLabel: UILabel!
    @IBOutlet weak var closeButton: UIButton!

    // MARK: - Properties

    var responseData: (userID: String, options: [String], info: [RDNAChallengeInfo])?
    private var isProcessing: Bool = false {
        didSet {
            updateProcessingState()
        }
    }

    // MARK: - Lifecycle

    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
        processActivationData()
    }

    // MARK: - Setup

    private func setupUI() {
        view.backgroundColor = UIColor(hex: "#f8f9fa")

        // Title
        titleLabel.text = "Additional Device Activation"
        titleLabel.font = .boldSystemFont(ofSize: 28)
        titleLabel.textColor = UIColor(hex: "#2c3e50")

        subtitleLabel.text = "Activate this device for secure access"
        subtitleLabel.font = .systemFont(ofSize: 16)
        subtitleLabel.textColor = UIColor(hex: "#7f8c8d")

        // Close button
        closeButton.setTitle("✕", for: .normal)
        closeButton.titleLabel?.font = .systemFont(ofSize: 28)
        closeButton.setTitleColor(UIColor(hex: "#95a5a6"), for: .normal)
        closeButton.layer.cornerRadius = 20
        closeButton.backgroundColor = .clear

        // Message container
        messageContainer.backgroundColor = UIColor(hex: "#e3f2fd")
        messageContainer.layer.cornerRadius = 12
        messageContainer.layer.borderWidth = 4
        messageContainer.layer.borderColor = UIColor(hex: "#2196f3")?.cgColor

        messageTitle.text = "REL-ID Verify Authentication"
        messageTitle.font = .boldSystemFont(ofSize: 18)
        messageTitle.textColor = UIColor(hex: "#1976d2")

        messageText.text = "REL-ID Verify notification has been sent to your registered devices. Please approve it to activate this device."
        messageText.font = .systemFont(ofSize: 16)
        messageText.textColor = UIColor(hex: "#1565c0")
        messageText.numberOfLines = 0

        // Fallback container
        fallbackContainer.backgroundColor = UIColor(hex: "#f5f5f5")
        fallbackContainer.layer.cornerRadius = 12
        fallbackContainer.layer.borderWidth = 1
        fallbackContainer.layer.borderColor = UIColor(hex: "#e0e0e0")?.cgColor

        fallbackTitle.text = "Device Not Handy?"
        fallbackTitle.font = .boldSystemFont(ofSize: 18)
        fallbackTitle.textColor = UIColor(hex: "#2c3e50")

        fallbackDescription.text = "If you don't have access to your registered devices, you can use an alternative activation method."
        fallbackDescription.font = .systemFont(ofSize: 14)
        fallbackDescription.textColor = UIColor(hex: "#7f8c8d")
        fallbackDescription.numberOfLines = 0

        fallbackButton.setTitle("Activate using fallback method", for: .normal)
        fallbackButton.titleLabel?.font = .boldSystemFont(ofSize: 16)
        fallbackButton.setTitleColor(UIColor(hex: "#3498db"), for: .normal)
        fallbackButton.layer.cornerRadius = 8
        fallbackButton.layer.borderWidth = 2
        fallbackButton.layer.borderColor = UIColor(hex: "#3498db")?.cgColor
        fallbackButton.backgroundColor = .white

        // Processing container
        processingContainer.backgroundColor = UIColor(hex: "#e3f2fd")
        processingContainer.layer.cornerRadius = 12
        processingContainer.isHidden = true

        processingLabel.text = "Processing device activation..."
        processingLabel.font = .systemFont(ofSize: 16)
        processingLabel.textColor = UIColor(hex: "#1976d2")

        // Result container (initially hidden)
        resultContainer.isHidden = true
        resultContainer.layer.cornerRadius = 12
    }

    private func processActivationData() {
        guard let data = responseData else {
            showResult("No activation data available", isSuccess: false)
            return
        }

        print("VerifyAuthViewController - Processing activation data")
        print("VerifyAuthViewController - UserID: \(data.userID)")
        print("VerifyAuthViewController - Options: \(data.options)")

        // Automatically call performVerifyAuth(true) when data is processed
        handleVerifyAuth(true)
    }

    // MARK: - Actions

    @IBAction func closeButtonTapped(_ sender: UIButton) {
        handleClose()
    }

    @IBAction func fallbackButtonTapped(_ sender: UIButton) {
        handleFallbackFlow()
    }

    private func handleClose() {
        print("VerifyAuthViewController - Calling resetAuthState")
        let error = RDNAService.shared.resetAuthState()

        if error.longErrorCode == 0 {
            print("VerifyAuthViewController - ResetAuthState successful")
        } else {
            print("VerifyAuthViewController - ResetAuthState error: \(error.errorString)")
        }
    }

    // MARK: - Device Activation Methods

    private func handleVerifyAuth(_ proceed: Bool) {
        guard !isProcessing else { return }

        isProcessing = true
        hideResult()

        print("VerifyAuthViewController - Performing verify auth: \(proceed)")

        let error = RDNAService.shared.performVerifyAuth(proceed)

        if error.longErrorCode != 0 {
            // Sync error
            print("VerifyAuthViewController - PerformVerifyAuth sync error: \(error.errorString)")
            showResult(error.errorString ?? "Failed to start verification", isSuccess: false)
            isProcessing = false
        } else {
            // Success - waiting for async events
            print("VerifyAuthViewController - PerformVerifyAuth successful, waiting for async events")

            if proceed {
                print("VerifyAuthViewController - REL-ID Verify notification has been sent to registered devices")
            }
        }
    }

    private func handleFallbackFlow() {
        guard !isProcessing else { return }

        isProcessing = true
        hideResult()

        print("VerifyAuthViewController - Initiating fallback new device activation flow")

        let error = RDNAService.shared.fallbackNewDeviceActivationFlow()

        if error.longErrorCode != 0 {
            // Sync error
            print("VerifyAuthViewController - FallbackNewDeviceActivationFlow sync error: \(error.errorString)")
            showResult(error.errorString ?? "Failed to start fallback activation", isSuccess: false)
            isProcessing = false
        } else {
            // Success - waiting for async events
            print("VerifyAuthViewController - FallbackNewDeviceActivationFlow successful, waiting for async events")
            print("VerifyAuthViewController - Alternative device activation process has been initiated")
        }
    }

    // MARK: - UI Updates

    private func updateProcessingState() {
        processingContainer.isHidden = !isProcessing
        fallbackButton.isEnabled = !isProcessing
        fallbackButton.alpha = isProcessing ? 0.5 : 1.0
        closeButton.isEnabled = !isProcessing

        if isProcessing {
            activityIndicator.startAnimating()
        } else {
            activityIndicator.stopAnimating()
        }
    }

    private func showResult(_ message: String, isSuccess: Bool) {
        resultLabel.text = message

        if isSuccess {
            resultContainer.backgroundColor = UIColor(hex: "#f0f8f0")
            resultLabel.textColor = UIColor(hex: "#27ae60")
        } else {
            resultContainer.backgroundColor = UIColor(hex: "#fff0f0")
            resultLabel.textColor = UIColor(hex: "#e74c3c")
        }

        resultContainer.isHidden = false
    }

    private func hideResult() {
        resultContainer.isHidden = true
    }
}

Key VerifyAuthViewController Features

Automatic Activation Flow

Fallback Integration

User Experience Enhancements

The following image showcases screen from the sample application:

Device Activtion Verify Screen

Create the GetNotificationsViewController that automatically loads server notifications and provides interactive action modals for user responses.

GetNotificationsViewController Implementation

// GetNotificationsViewController.swift
import UIKit

struct NotificationItem {
    let notificationUUID: String
    let subject: String
    let message: String
    let createTimestamp: String
    let expiryTimestamp: String?
    let actionPerformed: String?
    let actions: [NotificationAction]
    let createEpoch: TimeInterval

    struct NotificationAction {
        let action: String
        let label: String
    }
}

class GetNotificationsViewController: UIViewController {

    // MARK: - IBOutlets

    @IBOutlet weak var titleLabel: UILabel!
    @IBOutlet weak var subtitleLabel: UILabel!
    @IBOutlet weak var userInfoLabel: UILabel!
    @IBOutlet weak var tableView: UITableView!
    @IBOutlet weak var emptyStateView: UIView!
    @IBOutlet weak var emptyTitleLabel: UILabel!
    @IBOutlet weak var emptyMessageLabel: UILabel!
    @IBOutlet weak var refreshButton: UIButton!
    @IBOutlet weak var loadingContainer: UIView!
    @IBOutlet weak var activityIndicator: UIActivityIndicatorView!
    @IBOutlet weak var backButton: UIButton!

    // MARK: - Properties

    var userID: String = ""
    var sessionID: String = ""
    var sessionType: Int = 0
    var jwtToken: String = ""

    private var notifications: [NotificationItem] = []
    private var isLoading: Bool = false {
        didSet {
            updateLoadingState()
        }
    }
    private var selectedNotification: NotificationItem?
    private var selectedAction: String = ""
    private var isProcessingAction: Bool = false

    // MARK: - Lifecycle

    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
        setupTableView()
        setupCallbacks()
        loadNotifications()
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        clearCallbacks()
    }

    // MARK: - Setup

    private func setupUI() {
        view.backgroundColor = UIColor(hex: "#f5f5f5")

        // Title
        titleLabel.text = "Notifications"
        titleLabel.font = .boldSystemFont(ofSize: 24)
        titleLabel.textColor = UIColor(hex: "#333333")

        subtitleLabel.text = "Manage your REL-ID notifications"
        subtitleLabel.font = .systemFont(ofSize: 16)
        subtitleLabel.textColor = UIColor(hex: "#666666")

        userInfoLabel.text = "User: \(userID)"
        userInfoLabel.font = .systemFont(ofSize: 14, weight: .medium)
        userInfoLabel.textColor = UIColor(hex: "#007AFF")

        // Back button
        backButton.setTitle("←", for: .normal)
        backButton.titleLabel?.font = .systemFont(ofSize: 32)
        backButton.setTitleColor(UIColor(hex: "#007AFF"), for: .normal)

        // Empty state
        emptyTitleLabel.text = "No Notifications"
        emptyTitleLabel.font = .boldSystemFont(ofSize: 18)
        emptyTitleLabel.textColor = UIColor(hex: "#333333")

        emptyMessageLabel.text = "You don't have any notifications at the moment."
        emptyMessageLabel.font = .systemFont(ofSize: 14)
        emptyMessageLabel.textColor = UIColor(hex: "#666666")
        emptyMessageLabel.numberOfLines = 0

        refreshButton.setTitle("Refresh", for: .normal)
        refreshButton.titleLabel?.font = .boldSystemFont(ofSize: 16)
        refreshButton.setTitleColor(UIColor(hex: "#007AFF"), for: .normal)
        refreshButton.layer.cornerRadius = 8
        refreshButton.layer.borderWidth = 2
        refreshButton.layer.borderColor = UIColor(hex: "#007AFF")?.cgColor
        refreshButton.backgroundColor = .white

        emptyStateView.isHidden = true
    }

    private func setupTableView() {
        tableView.delegate = self
        tableView.dataSource = self
        tableView.separatorStyle = .none
        tableView.backgroundColor = .clear
        tableView.register(NotificationCell.self, forCellReuseIdentifier: "NotificationCell")
    }

    private func setupCallbacks() {
        let delegateManager = RDNADelegateManager.shared

        // Set up notification callback handlers
        delegateManager.onGetNotifications = { [weak self] status in
            self?.handleGetNotificationsResponse(status)
        }

        delegateManager.onUpdateNotification = { [weak self] status in
            self?.handleUpdateNotificationResponse(status)
        }
    }

    private func clearCallbacks() {
        let delegateManager = RDNADelegateManager.shared
        delegateManager.onGetNotifications = nil
        delegateManager.onUpdateNotification = nil
    }

    // MARK: - Actions

    @IBAction func backButtonTapped(_ sender: UIButton) {
        navigationController?.popViewController(animated: true)
    }

    @IBAction func refreshButtonTapped(_ sender: UIButton) {
        loadNotifications()
    }

    // MARK: - Notifications Loading

    private func loadNotifications() {
        guard !isLoading else { return }

        isLoading = true
        emptyStateView.isHidden = true

        print("GetNotificationsViewController - Loading notifications for user: \(userID)")

        let error = RDNAService.shared.getNotifications()

        if error.longErrorCode != 0 {
            print("GetNotificationsViewController - Error loading notifications: \(error.errorString)")
            isLoading = false
            showAlert(title: "Error", message: error.errorString ?? "Failed to load notifications")
        } else {
            print("GetNotificationsViewController - GetNotifications API called, waiting for response")
        }
    }

    private func handleGetNotificationsResponse(_ status: RDNAStatusGetNotifications) {
        print("GetNotificationsViewController - Received notifications callback")
        isLoading = false

        // Parse notification response
        guard let notificationResponse = status.notificationResponse,
              let notificationsList = notificationResponse.notifications else {
            print("GetNotificationsViewController - No notifications available")
            notifications = []
            updateEmptyState()
            return
        }

        print("GetNotificationsViewController - Received \(notificationsList.count) notifications")

        // Convert to NotificationItem objects
        notifications = notificationsList.compactMap { notification in
            guard let bodies = notification.body, let firstBody = bodies.first,
                  let subject = firstBody.subject,
                  let message = firstBody.message else {
                return nil
            }

            let actions = (notification.actions ?? []).compactMap { action -> NotificationItem.NotificationAction? in
                guard let actionLabel = action.label,
                      let actionValue = action.action else {
                    return nil
                }
                return NotificationItem.NotificationAction(action: actionValue, label: actionLabel)
            }

            return NotificationItem(
                notificationUUID: notification.notification_uuid ?? "",
                subject: subject,
                message: message,
                createTimestamp: notification.create_ts ?? "",
                expiryTimestamp: notification.expiry_timestamp,
                actionPerformed: notification.action_performed,
                actions: actions,
                createEpoch: TimeInterval(notification.create_ts_epoch)
            )
        }

        // Sort by create epoch (most recent first)
        notifications.sort { $0.createEpoch > $1.createEpoch }

        updateEmptyState()
        tableView.reloadData()
    }

    // MARK: - Notification Actions

    private func openActionModal(for notification: NotificationItem) {
        guard !notification.actions.isEmpty else {
            showAlert(title: "No Actions", message: "This notification has no available actions.")
            return
        }

        guard notification.actionPerformed == nil || notification.actionPerformed?.isEmpty == true else {
            showAlert(title: "Already Processed", message: "This notification has already been processed.")
            return
        }

        selectedNotification = notification
        selectedAction = ""

        // Show action selection modal
        let alertController = UIAlertController(
            title: "Notification Actions",
            message: notification.subject,
            preferredStyle: .actionSheet
        )

        for action in notification.actions {
            let alertAction = UIAlertAction(title: action.label, style: .default) { [weak self] _ in
                self?.selectedAction = action.action
                self?.executeNotificationAction()
            }
            alertController.addAction(alertAction)
        }

        let cancelAction = UIAlertAction(title: "Cancel", style: .cancel)
        alertController.addAction(cancelAction)

        present(alertController, animated: true)
    }

    private func executeNotificationAction() {
        guard let notification = selectedNotification,
              !selectedAction.isEmpty else {
            return
        }

        isProcessingAction = true

        print("GetNotificationsViewController - Processing notification action:")
        print("  notificationID: \(notification.notificationUUID)")
        print("  action: \(selectedAction)")

        let error = RDNAService.shared.updateNotification(notification.notificationUUID, response: selectedAction)

        if error.longErrorCode != 0 {
            print("GetNotificationsViewController - Error processing action: \(error.errorString)")
            isProcessingAction = false
            showAlert(title: "Error", message: error.errorString ?? "Failed to process notification action")
        } else {
            print("GetNotificationsViewController - UpdateNotification API called, waiting for response")
        }
    }

    private func handleUpdateNotificationResponse(_ status: RDNAStatusUpdateNotification) {
        print("GetNotificationsViewController - Received update notification callback")
        isProcessingAction = false

        let isSuccess = status.statusCode == 100

        if isSuccess {
            print("GetNotificationsViewController - Update notification success")
            showAlert(title: "Success", message: status.statusMessage ?? "Notification updated successfully") {
                [weak self] in
                self?.loadNotifications()
            }
        } else {
            print("GetNotificationsViewController - Update notification failed: \(status.statusMessage ?? "")")
            showAlert(title: "Update Failed", message: status.statusMessage ?? "Failed to update notification")
        }
    }

    // MARK: - UI Updates

    private func updateLoadingState() {
        if isLoading {
            loadingContainer.isHidden = false
            activityIndicator.startAnimating()
        } else {
            loadingContainer.isHidden = true
            activityIndicator.stopAnimating()
        }
    }

    private func updateEmptyState() {
        let isEmpty = notifications.isEmpty
        emptyStateView.isHidden = !isEmpty
        tableView.isHidden = isEmpty
    }

    private func showAlert(title: String, message: String, completion: (() -> Void)? = nil) {
        let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
        alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in
            completion?()
        })
        present(alert, animated: true)
    }
}

// MARK: - UITableViewDelegate, UITableViewDataSource

extension GetNotificationsViewController: UITableViewDelegate, UITableViewDataSource {

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return notifications.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "NotificationCell", for: indexPath) as! NotificationCell
        let notification = notifications[indexPath.row]
        cell.configure(with: notification)
        return cell
    }

    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
        let notification = notifications[indexPath.row]
        openActionModal(for: notification)
    }

    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        return UITableView.automaticDimension
    }
}

// MARK: - NotificationCell

class NotificationCell: UITableViewCell {

    private let containerView = UIView()
    private let subjectLabel = UILabel()
    private let messageLabel = UILabel()
    private let timestampLabel = UILabel()
    private let actionsLabel = UILabel()

    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        setupUI()
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    private func setupUI() {
        backgroundColor = .clear
        selectionStyle = .none

        // Container view
        containerView.backgroundColor = .white
        containerView.layer.cornerRadius = 12
        containerView.layer.shadowColor = UIColor.black.cgColor
        containerView.layer.shadowOffset = CGSize(width: 0, height: 1)
        containerView.layer.shadowOpacity = 0.05
        containerView.layer.shadowRadius = 2
        contentView.addSubview(containerView)

        // Subject label
        subjectLabel.font = .boldSystemFont(ofSize: 16)
        subjectLabel.textColor = UIColor(hex: "#333333")
        subjectLabel.numberOfLines = 1
        containerView.addSubview(subjectLabel)

        // Message label
        messageLabel.font = .systemFont(ofSize: 14)
        messageLabel.textColor = UIColor(hex: "#666666")
        messageLabel.numberOfLines = 3
        containerView.addSubview(messageLabel)

        // Timestamp label
        timestampLabel.font = .systemFont(ofSize: 12)
        timestampLabel.textColor = UIColor(hex: "#8E8E93")
        containerView.addSubview(timestampLabel)

        // Actions label
        actionsLabel.font = .systemFont(ofSize: 12, weight: .medium)
        actionsLabel.textColor = UIColor(hex: "#007AFF")
        containerView.addSubview(actionsLabel)

        setupConstraints()
    }

    private func setupConstraints() {
        containerView.translatesAutoresizingMaskIntoConstraints = false
        subjectLabel.translatesAutoresizingMaskIntoConstraints = false
        messageLabel.translatesAutoresizingMaskIntoConstraints = false
        timestampLabel.translatesAutoresizingMaskIntoConstraints = false
        actionsLabel.translatesAutoresizingMaskIntoConstraints = false

        NSLayoutConstraint.activate([
            // Container view
            containerView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 6),
            containerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12),
            containerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12),
            containerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -6),

            // Subject label
            subjectLabel.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 16),
            subjectLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 16),
            subjectLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -16),

            // Timestamp label
            timestampLabel.topAnchor.constraint(equalTo: subjectLabel.bottomAnchor, constant: 4),
            timestampLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 16),
            timestampLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -16),

            // Message label
            messageLabel.topAnchor.constraint(equalTo: timestampLabel.bottomAnchor, constant: 8),
            messageLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 16),
            messageLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -16),

            // Actions label
            actionsLabel.topAnchor.constraint(equalTo: messageLabel.bottomAnchor, constant: 12),
            actionsLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 16),
            actionsLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -16),
            actionsLabel.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -16)
        ])
    }

    func configure(with notification: NotificationItem) {
        subjectLabel.text = notification.subject
        messageLabel.text = notification.message

        // Format timestamp
        if let createDate = ISO8601DateFormatter().date(from: notification.createTimestamp.replacingOccurrences(of: "UTC", with: "Z")) {
            let formatter = DateFormatter()
            formatter.dateStyle = .short
            formatter.timeStyle = .short
            timestampLabel.text = formatter.string(from: createDate)
        } else {
            timestampLabel.text = notification.createTimestamp
        }

        // Actions count
        let actionCount = notification.actions.count
        actionsLabel.text = "\(actionCount) action\(actionCount != 1 ? "s" : "") available"

        // Gray out if already processed
        if let actionPerformed = notification.actionPerformed, !actionPerformed.isEmpty {
            containerView.alpha = 0.6
            actionsLabel.text = "Processed: \(actionPerformed)"
        } else {
            containerView.alpha = 1.0
        }
    }
}

Key GetNotificationsViewController Features

Automatic Notification Loading

Interactive Action Processing

Enhanced User Experience

The following images showcase screens from the sample application:

Get Notifications Dashboard Menu

Get Notifictions Screen

Get Notifiction Actions Screen

Extend your existing AppCoordinator to handle device activation callbacks and coordinate navigation for the additional device activation workflow.

Adding Device Activation Navigation Methods

Enhance your AppCoordinator with device activation navigation coordination:

// AppCoordinator.swift (device activation additions)

class AppCoordinator {

    static let shared = AppCoordinator()

    private weak var navigationController: UINavigationController?
    private let storyboard = UIStoryboard(name: "Main", bundle: nil)

    // MARK: - Device Activation Navigation Methods

    func showVerifyAuth(userID: String, options: [String], info: [RDNAChallengeInfo]) {
        print("AppCoordinator - Showing VerifyAuthViewController for user: \(userID)")
        print("AppCoordinator - Available options: \(options.count)")

        guard let vc = storyboard.instantiateViewController(
            withIdentifier: "VerifyAuthViewController"
        ) as? VerifyAuthViewController else {
            print("AppCoordinator - Failed to instantiate VerifyAuthViewController")
            return
        }

        vc.responseData = (userID: userID, options: options, info: info)
        navigationController?.pushViewController(vc, animated: true)
    }

    func showGetNotifications(userID: String, sessionID: String, sessionType: Int, jwtToken: String) {
        print("AppCoordinator - Showing GetNotificationsViewController")

        guard let vc = storyboard.instantiateViewController(
            withIdentifier: "GetNotificationsViewController"
        ) as? GetNotificationsViewController else {
            print("AppCoordinator - Failed to instantiate GetNotificationsViewController")
            return
        }

        vc.userID = userID
        vc.sessionID = sessionID
        vc.sessionType = sessionType
        vc.jwtToken = jwtToken

        navigationController?.pushViewController(vc, animated: true)
    }

    // MARK: - Enhanced Global Callback Navigation Setup

    private func setupGlobalCallbackNavigation() {
        let delegateManager = RDNADelegateManager.shared

        // Existing MFA callback handlers
        delegateManager.onInitialized = { [weak self] response in
            print("AppCoordinator - onInitialized called")
            // Wait for getUser callback
        }

        delegateManager.onGetUser = { [weak self] userNames, recentlyLoggedInUser, response, error in
            print("AppCoordinator - onGetUser called")
            self?.showCheckUser(
                userNames: userNames,
                recentlyLoggedInUser: recentlyLoggedInUser,
                response: response,
                error: error
            )
        }

        delegateManager.onGetActivationCode = { [weak self] userID, verificationKey, attemptsLeft, response, error in
            print("AppCoordinator - onGetActivationCode called")
            self?.showActivationCode(
                userID: userID,
                verificationKey: verificationKey,
                attemptsLeft: attemptsLeft,
                response: response,
                error: error
            )
        }

        delegateManager.onGetPassword = { [weak self] userID, challengeMode, attemptsLeft, response, error in
            print("AppCoordinator - onGetPassword called (mode: \(challengeMode.rawValue))")
            self?.showPasswordScreen(
                userID: userID,
                challengeMode: challengeMode,
                attemptsLeft: attemptsLeft,
                response: response,
                error: error
            )
        }

        delegateManager.onUserLoggedIn = { [weak self] userID, response, error in
            print("AppCoordinator - onUserLoggedIn called")
            self?.showDashboard(userID: userID, response: response, error: error)
        }

        // Device activation callback handlers
        delegateManager.onAddNewDeviceOptions = { [weak self] userID, options, info in
            print("AppCoordinator - onAddNewDeviceOptions called")
            print("AppCoordinator - UserID: \(userID)")
            print("AppCoordinator - Options: \(options)")
            self?.showVerifyAuth(userID: userID, options: options, info: info)
        }

        delegateManager.onGetNotifications = { [weak self] status in
            print("AppCoordinator - onGetNotifications called")
            // GetNotificationsViewController handles this directly
            print("AppCoordinator - Get notifications callback handled by screen")
        }

        delegateManager.onUpdateNotification = { [weak self] status in
            print("AppCoordinator - onUpdateNotification called")
            // GetNotificationsViewController handles this directly
            print("AppCoordinator - Update notification callback handled by screen")
        }

        // MTD threats
        delegateManager.onTerminateWithThreats = { [weak self] threats in
            print("AppCoordinator - onTerminateWithThreats called")
            self?.showSecurityExit()
        }
    }
}

Enhanced Dashboard Integration

Update your DashboardViewController to include navigation to GetNotifications:

// DashboardViewController.swift (enhanced with GetNotifications)

class DashboardViewController: UIViewController {

    @IBOutlet weak var menuButton: UIButton!
    @IBOutlet weak var userInfoValue: UILabel!
    @IBOutlet weak var sessionIDValue: UILabel!
    @IBOutlet weak var sessionTypeValue: UILabel!

    // MARK: - Properties

    private var userID: String = ""
    private var response: RDNAChallengeResponse?

    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
        MenuViewController.setupSideMenu(for: self)
        updateSessionInfo()
    }

    // MARK: - Navigation to GetNotifications

    func navigateToNotifications() {
        print("DashboardViewController - Navigating to GetNotifications")

        guard let response = response else {
            print("DashboardViewController - No response data available")
            return
        }

        let sessionInfo = response.sessionInfo
        AppCoordinator.shared.showGetNotifications(
            userID: userID,
            sessionID: sessionInfo.sessionID,
            sessionType: Int(sessionInfo.sessionType.rawValue),
            jwtToken: ""
        )
    }

    @IBAction func menuTapped(_ sender: UIButton) {
        MenuViewController.presentMenu(from: self)
    }
}

Key AppCoordinator Enhancements

Device Activation Callback Integration

Notification Callback Handling

Enhanced MFA Integration

Callback Flow Coordination

The enhanced AppCoordinator coordinates these device activation flows:

  1. MFA Authentication Flow: User completes username/password → MFA validation
  2. Device Detection: SDK detects unregistered device → triggers addNewDeviceOptions
  3. Automatic Navigation: AppCoordinator navigates to VerifyAuthViewController with options
  4. Device Activation: User completes REL-ID Verify or fallback activation
  5. MFA Continuation: Flow continues to LDA consent or final authentication
  6. Dashboard Access: User reaches dashboard with menu navigation including GetNotifications

Test your device activation implementation to ensure REL-ID Verify workflows, fallback methods, and notification management work correctly across different scenarios.

Device Activation Test Scenarios

Scenario 1: Automatic REL-ID Verify Activation

Test the complete automatic device activation flow:

  1. Prepare Test Environment:
    # 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
    # For Device A:
    xcodebuild -workspace YourApp.xcworkspace -scheme YourApp -sdk iphoneos -destination 'platform=iOS,name=Device-A-Name'
    
    # For Device B:
    xcodebuild -workspace YourApp.xcworkspace -scheme YourApp -sdk iphoneos -destination 'platform=iOS,name=Device-B-Name'
    
  2. Execute Test Flow:
    • On Device B (New Device): Complete MFA username
    • Verify Callback Trigger: Check console for addNewDeviceOptions callback
    • Automatic Navigation: Confirm VerifyAuthViewController loads automatically
    • REL-ID Verify Start: Verify performVerifyAuth(true) called automatically
    • Push Notification: Check Device A receives activation approval request
    • User Approval: On Device A, approve the device activation request
    • Activation Success: Verify Device B completes activation and continues MFA
  3. Expected Console Output:
    AppCoordinator - onAddNewDeviceOptions called
    AppCoordinator - UserID: testuser@example.com
    AppCoordinator - Available options: 2
    VerifyAuthViewController - Auto-starting REL-ID Verify for user: testuser@example.com
    RDNAService - PerformVerifyAuth successful
    

Scenario 2: Fallback Activation Method

Test the fallback activation when REL-ID Verify is not accessible:

  1. Test Setup:
    • Use Device B (new device) without accessible registered devices
    • Or simulate scenario where Device A is offline/unreachable
  2. Execute Fallback Test:
    • Start REL-ID Verify: Allow automatic verification to start
    • Use Fallback: Tap "Activate using fallback method" button
    • Fallback Processing: Verify fallbackNewDeviceActivationFlow() called
    • Alternative Method: Complete server-configured alternative activation
  3. Expected Behavior:
    VerifyAuthViewController - Starting fallback activation for user: testuser@example.com
    RDNAService - FallbackNewDeviceActivationFlow successful
    

Scenario 3: Notification Management Testing

Test the GetNotificationsViewController functionality:

  1. Access Notifications:
    • Complete device activation and reach dashboard
    • Open side menu
    • Tap "Get Notifications" menu item
    • Verify automatic navigation to GetNotificationsViewController
  2. Test Notification Loading:
    • Auto-load: Verify notifications load automatically on screen entry
    • Loading State: Check loading indicator displays during API call
    • Data Display: Confirm notifications appear in chronological order
  3. Test Notification Actions:
    • Select Notification: Tap on notification with available actions
    • Action Sheet: Verify action sheet opens with action options
    • Action Selection: Select an action and confirm
    • Processing State: Check processing indicator during update
    • Success Feedback: Verify success message and notification status update
  4. Test Manual Refresh:
    • Tap refresh button
    • Verify refresh indicator appears
    • Confirm getNotifications() API called again

Debug and Troubleshooting

Common Device Activation Issues

  1. addNewDeviceOptions Callback Not Triggered:
    // Check if device is already registered
    // Verify MFA flow completion before device detection
    // Ensure proper connection profile configuration
    
  2. REL-ID Verify Push Notifications Not Received:
    • Verify registered device has push notifications enabled
    • Check network connectivity on both devices
    • Confirm REL-ID Verify service configuration
  3. Fallback Activation Fails:
    • Check server configuration for fallback methods
    • Verify network connectivity and SDK configuration
    • Review error logs for specific failure reasons
  4. Notification Loading Issues:
    // Check GetNotificationsViewController callback handler setup
    delegateManager.onGetNotifications = { [weak self] status in
        self?.handleGetNotificationsResponse(status)
    }
    
    // Verify API call execution
    let error = RDNAService.shared.getNotifications()
    

Testing Best Practices

  1. Use Physical Devices: REL-ID Verify requires real device-to-device communication
  2. Test Network Conditions: Test with different network conditions and connectivity
  3. Error Scenarios: Test error conditions like network failures and server timeouts
  4. User Experience: Test complete user flows from start to finish
  5. Performance: Monitor performance impact on existing MFA flows

Validation Checklist

Production Deployment Considerations

Security Validation

User Experience

Congratulations! You've successfully implemented a comprehensive Additional Device Activation system with REL-ID Verify push notifications, fallback methods, and notification management.

What You've Accomplished

Core Device Activation Features

REL-ID Verify Integration: Automatic push notification-based device activation

VerifyAuthViewController Implementation: Auto-starting activation with real-time status updates

Fallback Activation Methods: Alternative activation when registered devices aren't accessible

GetNotificationsViewController: Server notification management with interactive action processing

Enhanced Navigation: Seamless access to notifications via enhanced menu navigation

Key iOS Patterns Mastered

  1. Delegate-Based Event Handling: RDNACallbacks protocol with closure dispatch
  2. Push Notification Workflows: Real device-to-device communication and approval systems
  3. Fallback Strategy Implementation: Robust alternative activation methods for various scenarios
  4. Interactive Notification Management: Server notification retrieval with action processing
  5. UIKit Navigation Patterns: UINavigationController-based navigation with AppCoordinator

Advanced Device Activation Scenarios

Your implementation now handles these production scenarios:

Seamless MFA Integration

Multi-Device Management

Network Resilience

Next Steps and Advanced Features

Potential Enhancements

  1. Advanced Notification Features: Push notification content customization
  2. Biometric Integration: Enhanced biometric authentication during device activation
  3. Admin Dashboard: Administrative interface for managing device activations
  4. Advanced Analytics: Machine learning-based fraud detection during activation
  5. Multi-Tenant Support: Enterprise-grade multi-organization support

Resources for Continued Learning

REL-ID Documentation

Congratulations! 🎉

You've mastered Advanced Device Activation with REL-ID Verify and built a production-ready 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!