diff --git a/android/kotlin-authenticatorapp/.gitignore b/android/kotlin-authenticatorapp/.gitignore
new file mode 100644
index 00000000..aa724b77
--- /dev/null
+++ b/android/kotlin-authenticatorapp/.gitignore
@@ -0,0 +1,15 @@
+*.iml
+.gradle
+/local.properties
+/.idea/caches
+/.idea/libraries
+/.idea/modules.xml
+/.idea/workspace.xml
+/.idea/navEditor.xml
+/.idea/assetWizardSettings.xml
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
diff --git a/android/kotlin-authenticatorapp/README.md b/android/kotlin-authenticatorapp/README.md
new file mode 100644
index 00000000..cf3365cd
--- /dev/null
+++ b/android/kotlin-authenticatorapp/README.md
@@ -0,0 +1,275 @@
+[](https://github.com/ForgeRock/ping-android-sdk)
+
+# Ping Authenticator Sample App
+
+This sample application demonstrates how to implement multi-factor authentication using the Ping Identity SDK. The app allows users to register and manage both OATH credentials (TOTP/HOTP) and Push authentication credentials.
+
+## Disclaimer
+
+This application is a sample and not intended for production use. It is provided for educational purposes to demonstrate the use of the Ping Identity SDK.
+
+The application uses a public reverse geocoding service for location mapping. This service is not guaranteed to be accurate or available. For a production application, it is recommended to use a more robust and reliable geocoding service.
+
+## Features
+
+### OATH Authentication
+- **QR Code Scanning**: Register accounts by scanning QR codes containing OATH credentials
+- **Manual Entry**: Manually enter account details
+- **Journey Authentication**: Register accounts through authenticated Journey login flows
+- **TOTP Support**: Automatic generation of time-based one-time passwords with countdown timer
+- **HOTP Support**: Counter-based one-time passwords with refresh capability
+- **Copy OTP**: Copy to clipboard functionality
+
+### Push Authentication
+- **QR Code Registration**: Register for push authentication by scanning QR codes
+- **Journey Authentication**: Register for push authentication through authenticated Journey login flows
+- **Push Notifications**: Receive and respond to authentication requests
+- **System Notifications**: Display system notifications when push requests are received
+- **Direct Actions**: Approve or deny authentication requests directly from system notification tray (DEFAULT type)
+- **Push Biometric Authentication**: Authenticate using fingerprint or face recognition (BIOMETRIC type)
+- **Push Challenge Verification**: Verify challenge numbers for enhanced security (CHALLENGE type)
+- **Location Display**: View location information when provided in push notifications
+- **Notification Management**: Clean up old notifications via Settings screen
+- **Device Token Management**: View device token information
+
+### Journey-based Credential Enrollment
+- **User Authentication**: Allow the app to authenticate users through Journey flows
+- **Seamless Integration**: MFA registration integrated directly into authentication flows
+- **User Association**: Journey-registered credentials are automatically associated with the authenticated user
+
+### Common
+- **Account Management**: View, organize, and delete accounts
+- **Account Grouping**: Group MFA accounts with the same issuer/account name
+
+## Architecture overview
+
+The Ping Authenticator App sample is a modular Android application built on Model-View-ViewModel architecture with Kotlin, Jetpack Compose, and the Ping SDK for secure multi-factor authentication (MFA).
+
+```
+┌─────────────────────────────┐
+│ Presentation Layer │ ← UI: Jetpack Compose screens, navigation
+├─────────────────────────────┤
+│ Domain Layer │ ← ViewModels, business logic, state
+├─────────────────────────────┤
+│ Data/Service Layer │ ← Managers, services, secure storage
+├─────────────────────────────┤
+│ SDK Layer │ ← Ping SDK: push, oath, and journey modules
+└─────────────────────────────┘
+```
+
+- **Presentation Layer**: Android Activities/Fragments for user interaction.
+- **Domain Layer**: Handles business logic, orchestrates feature flows, and manages state.
+- **Data/Service Layer**: Integrates with Ping SDK modules (`push`, `oath`, `journey`) and other services.
+- **SDK Layer**: Abstracts the complexity to deal with MFA capabilities and comunication with Ping backend.
+
+The application follows modern Android development practices:
+
+- **Kotlin**: 100% Kotlin codebase
+- **Jetpack Compose**: Declarative UI toolkit for building native UI
+- **ViewModel**: Architecture component for managing UI-related data in a lifecycle conscious way
+- **Coroutines**: For asynchronous operations
+- **Navigation**: For handling navigation between screens
+- **Material 3**: For modern, adaptive UI components
+- **Firebase Cloud Messaging**: For receiving push notifications
+- **OpenStreetMap**: For displaying location information in push notifications
+
+## Implementation Details
+
+### Code Structure Overview
+
+```
+src/main/kotlin/com/pingidentity/authenticatorapp/
+├── AuthenticatorApp.kt # App initialization, SDK clients (Push, OATH, Journey)
+├── managers/
+│ ├── JourneyManager.kt # Journey logic, state, integration
+│ ├── PushManager.kt # Push logic, state, integration
+│ └── OathManager.kt # OATH logic, state, integration
+├── ui/
+│ ├── AccountsScreen.kt # Account management UI
+│ ├── PushNotificationsScreen.kt # Push notification UI
+│ └── ... # Other Compose screens
+├── data/
+│ ├── AuthenticatorViewModel.kt # Coordinates between Push and OATH managers and handles UI-specific logic
+│ ├── LoginViewModel.kt # Coordinates between Journey and other managers handling UI-specific logic
+│ ├── DiagnosticLogger.kt # Logging
+│ └── UserPreferences.kt # Preferences
+└── ...
+```
+
+**Key Classes & Structure:**
+
+- `AuthenticatorApp.kt`: Initializes Push, OATH, and Journey clients, manages global app state.
+- `managers/`: Integrates Ping SDK modules.
+- `managers/JourneyManager.kt`: Handles Journey lifecycle with MFA registration.
+- `managers/PushManager.kt`: Encapsulates push notification logic and state.
+- `managers/OathManager.kt`: Handles OATH token lifecycle and OTP generation.
+- `ui/`: Compose screens and components for account and notification management.
+- `data/`: Models, preferences, logging.
+
+### Push Module
+- **Device Registration**: Registers device with Ping backend for push authentication.
+- **Notification Handling**: Listens for push requests, displays actionable notifications.
+- **User Actions**: Approve/deny requests from notification or app UI.
+- **Result Reporting**: Communicates user decisions to Ping backend securely.
+
+**Class:** `PushManager.kt`
+
+**Flow Diagram (textual):**
+```
+Push Request → PushManager → Notification UI → User Action → Ping Backend
+```
+
+#### Push Authentication Types
+
+The app handles three different types of push authentication:
+
+1. **DEFAULT**: Simple approval/denial directly from the notification
+ ```kotlin
+ // Approve a standard notification
+ pushClient.approveNotification(notificationId)
+ ```
+
+2. **BIOMETRIC**: Authentication using biometric verification
+ ```kotlin
+ // Approve with biometric authentication
+ pushClient.approveBiometricNotification(notificationId)
+ ```
+
+3. **CHALLENGE**: Verification using challenge numbers
+ ```kotlin
+ // Get challenge numbers
+ val numbers = pushNotification.getNumbersChallenge()
+
+ // Approve with challenge response
+ pushClient.approveChallengeNotification(notificationId, challengeResponse)
+ ```
+
+
+### OATH Module
+- **Token Provisioning**: Enrolls OATH tokens via QR/manual entry or Journey authentication flows.
+- **Code Generation**: Generates OTP codes (TOTP/HOTP) for authentication.
+- **Token Management**: UI for listing, renaming, deleting tokens.
+- **Security**: OTP codes can be hidden (optional).
+
+**Class:** `OathManager.kt`
+
+**Flow Diagram (textual):**
+```
+Enroll Token → OathManager → Generate OTP → Display in UI → User enters code
+Journey Flow → Auto-Register → Associate with User → Mark as Journey-enabled
+```
+
+### Journey Module
+- **Authentication Flows**: Handles PingOne Advanced Identity Cloud (AIC) authentication journeys.
+- **MFA Registration**: Automatically registers MFA credentials during authentication flows.
+- **User Association**: Associates registered credentials with authenticated users.
+
+**Class:** `LoginViewModel.kt`
+
+**Flow Diagram (textual):**
+```
+Start Journey → Authentication Steps → MFA Registration → Success → Associate Credentials
+```
+
+### QR Code Scanning
+
+The app uses CameraX and ML Kit to scan and decode QR codes:
+
+#### OATH QR Codes (otpauth:// URIs):
+```
+otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example&algorithm=SHA1&digits=6&period=30
+```
+
+#### Push QR Codes (pushauth:// URIs):
+```
+pushauth://push/Example:bob@example.com?pushauth_uri=https://example.com/push&client_id=clientId123
+```
+
+### Journey-Based Registration
+
+The app also supports registering MFA credentials through authenticated Journey flows:
+
+#### Journey Authentication Flow:
+1. **Start Journey**: Initiate authentication with PingOne Advanced Identity Cloud
+2. **Authentication Steps**: Complete required authentication steps (username/password, etc.)
+3. **MFA Registration**: Journey automatically provides MFA registration URIs during the flow
+4. **Auto-Registration**: App automatically registers OATH/Push credentials from Journey callbacks
+5. **User Association**: Successfully authenticated credentials are associated with the user session
+
+This provides a seamless user experience where MFA credentials are automatically registered during the authentication process without requiring separate QR code scanning.
+
+## Getting Started
+
+### Prerequisites
+
+- Android Studio Koala | 2024.1.1 or newer
+- Android SDK 29 or higher
+- Gradle 8.7 or newer
+
+### Building the App
+
+1. Clone the repository
+2. Open the project in Android Studio
+3. Build and run on your device or emulator
+
+## Testing
+
+### Testing OATH Functionality
+
+To test the app's OATH functionality, you can:
+
+1. **QR Code Method**:
+ - Use any TOTP/HOTP QR code generator
+ - Create test credentials using command line tools like `oathtool`
+ - Use online TOTP testing services
+
+2. **Journey Method**:
+ - Configure a PingOne Advanced Identity Cloud environment with OATH MFA
+ - Set up Journey flows that include MFA registration steps
+ - Test the full authentication flow including automatic credential registration
+
+### Testing Push Functionality
+
+To test the app's Push functionality, you need:
+
+1. **QR Code Method**:
+ - A PingAM account with push authentication configured
+ - FCM configured for your Android application
+ - The app properly registered with FCM to receive push notifications
+
+2. **Journey Method**:
+ - A PingOne Advanced Identity Cloud environment with Push MFA configured
+ - Journey flows that include Push registration steps
+ - FCM properly configured to receive push notifications
+ - Test environment to generate push authentication requests
+
+### Testing Journey Integration
+
+To test the Journey-based MFA registration:
+
+1. Set up a PingOne Advanced Identity Cloud environment
+2. Configure Journey flows with MFA registration callbacks
+3. Test the complete flow: authentication → MFA registration → credential association
+4. Verify that registered credentials show user session indicators in the UI
+
+## Contributing
+
+Contributions are welcome! Please read the [contributing guidelines](../../CONTRIBUTING.md) for more information.
+
+## Troubleshooting
+
+- **Push notifications are not being received**:
+ - Ensure that your device has a valid internet connection.
+ - Verify that the device token is correctly registered with the push notification service.
+ - Check the server logs to see if the push notification is being sent successfully.
+- **QR code is not scanning**:
+ - Make sure that the QR code is well-lit and in focus.
+ - Try scanning the QR code from a different distance or angle.
+ - Ensure that the QR code is in the correct format.
+
+## License
+
+Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
+This software may be modified and distributed under the terms of the MIT license. See the [LICENSE](../LICENSE) file for details.
+
+© Copyright 2025-2026 Ping Identity Corporation. All Rights Reserved
diff --git a/android/kotlin-authenticatorapp/app/.gitignore b/android/kotlin-authenticatorapp/app/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/android/kotlin-authenticatorapp/app/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/android/kotlin-authenticatorapp/app/build.gradle.kts b/android/kotlin-authenticatorapp/app/build.gradle.kts
new file mode 100644
index 00000000..86a73cad
--- /dev/null
+++ b/android/kotlin-authenticatorapp/app/build.gradle.kts
@@ -0,0 +1,91 @@
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.compose.compiler)
+ alias(libs.plugins.googleServices)
+ alias(libs.plugins.kotlinSerialization)
+}
+
+android {
+ namespace = "com.pingidentity.authenticatorapp"
+ compileSdk {
+ version = release(36) {
+ minorApiLevel = 1
+ }
+ }
+
+ defaultConfig {
+ applicationId = "com.pingidentity.authenticatorapp"
+ minSdk = 29
+ targetSdk = 36
+ versionCode = 1
+ versionName = "1.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+}
+
+dependencies {
+ // Core Android dependencies
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.appcompat)
+ implementation(libs.material)
+
+ // Kotlinx Serialization
+ implementation(libs.kotlinx.serialization.json)
+
+ // Ping Identity SDK dependencies
+ implementation(libs.ping.oath)
+ implementation(libs.ping.push)
+ implementation(libs.ping.journey)
+
+ // Compose
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.compose.ui)
+ implementation(libs.androidx.ui.graphics)
+ implementation(libs.androidx.ui.tooling.preview)
+ implementation(libs.compose.material3)
+ implementation(libs.androidx.navigation.compose)
+ implementation(libs.androidx.material.icons.extended)
+
+ // CameraX dependencies for QR scanning
+ implementation(libs.androidx.camera.camera2)
+ implementation(libs.androidx.camera.lifecycle)
+ implementation(libs.androidx.camera.view)
+ implementation(libs.barcode.scanning)
+
+ // ViewModel
+ implementation(libs.androidx.lifecycle.viewmodel.compose)
+
+ // HTTP client for reverse geocoding API calls
+ implementation(libs.ktor.client.cio)
+ implementation(libs.ktor.client.core)
+ implementation(libs.ktor.client.content.negotiation)
+ implementation(libs.ktor.serialization.kotlinx.json)
+
+ // Image loading
+ implementation(libs.coil.compose)
+
+ // Firebase Cloud Messaging for push notifications
+ implementation(platform(libs.firebase.bom))
+ implementation(libs.firebase.messaging)
+
+ // Maps for location display - using free OpenStreetMap
+ implementation(libs.osmdroid.android)
+
+ // Biometric
+ implementation(libs.androidx.biometric)
+}
\ No newline at end of file
diff --git a/android/kotlin-authenticatorapp/app/google-services.json b/android/kotlin-authenticatorapp/app/google-services.json
new file mode 100644
index 00000000..460835d2
--- /dev/null
+++ b/android/kotlin-authenticatorapp/app/google-services.json
@@ -0,0 +1,29 @@
+{
+ "project_info": {
+ "project_number": "",
+ "project_id": "",
+ "storage_bucket": ""
+ },
+ "client": [
+ {
+ "client_info": {
+ "mobilesdk_app_id": "",
+ "android_client_info": {
+ "package_name": "com.pingidentity.authenticatorapp"
+ }
+ },
+ "oauth_client": [],
+ "api_key": [
+ {
+ "current_key": ""
+ }
+ ],
+ "services": {
+ "appinvite_service": {
+ "other_platform_oauth_client": []
+ }
+ }
+ }
+ ],
+ "configuration_version": "1"
+}
\ No newline at end of file
diff --git a/android/kotlin-authenticatorapp/app/proguard-rules.pro b/android/kotlin-authenticatorapp/app/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/android/kotlin-authenticatorapp/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/android/kotlin-authenticatorapp/app/src/main/AndroidManifest.xml b/android/kotlin-authenticatorapp/app/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..d2cef7f8
--- /dev/null
+++ b/android/kotlin-authenticatorapp/app/src/main/AndroidManifest.xml
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/AuthenticatorApp.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/AuthenticatorApp.kt
new file mode 100644
index 00000000..6993e8ea
--- /dev/null
+++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/AuthenticatorApp.kt
@@ -0,0 +1,406 @@
+/*
+ * Copyright (c) 2025-2026 Ping Identity Corporation. All rights reserved.
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license. See the LICENSE file for details.
+ */
+
+package com.pingidentity.authenticatorapp
+
+import android.app.Application
+import com.google.firebase.FirebaseApp
+import com.google.firebase.messaging.FirebaseMessaging
+import com.pingidentity.authenticatorapp.data.DiagnosticLogger
+import com.pingidentity.authenticatorapp.data.UserPreferences
+import com.pingidentity.journey.Journey
+import com.pingidentity.journey.module.Oidc
+import com.pingidentity.logger.Logger
+import com.pingidentity.logger.STANDARD
+import com.pingidentity.mfa.oath.OathClient
+import com.pingidentity.mfa.oath.storage.SQLOathStorage
+import com.pingidentity.mfa.push.PushClient
+import com.pingidentity.mfa.push.storage.SQLPushStorage
+import com.pingidentity.storage.sqlite.passphrase.KeyStorePassphraseProvider
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.tasks.await
+
+/**
+ * Main application class for the Authenticator app.
+ * Initializes the Push and OATH MFA clients on application startup and provide access to them throughout the app.
+ * It also allow the clients to be accessed in background services or other components that require MFA functionality.
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+class AuthenticatorApp : Application() {
+ @Volatile
+ private lateinit var pushClient: PushClient
+
+ @Volatile
+ private lateinit var oathClient: OathClient
+
+ @Volatile
+ private lateinit var journey: Journey
+
+ @Volatile
+ private lateinit var oathStorage: SQLOathStorage
+
+ @Volatile
+ private lateinit var pushStorage: SQLPushStorage
+
+ private val pushClientDeferred = CompletableDeferred()
+ private val oathClientDeferred = CompletableDeferred()
+ private val journeyDeferred = CompletableDeferred()
+ private val oathStorageDeferred = CompletableDeferred()
+ private val pushStorageDeferred = CompletableDeferred()
+
+ // Track initialization errors
+ private val initializationErrors = mutableListOf()
+
+ override fun onCreate() {
+ super.onCreate()
+
+ // Initialize diagnostic logging if enabled
+ val userPreferences = UserPreferences(this)
+ val diagnosticLogger = if (userPreferences.isDiagnosticLoggingEnabled()) {
+ DiagnosticLogger
+ } else {
+ Logger.STANDARD
+ }
+
+ // Set the global logger
+ Logger.logger = diagnosticLogger
+
+ // Log initial startup
+ if (userPreferences.isDiagnosticLoggingEnabled()) {
+ diagnosticLogger.i("AuthenticatorApp: Diagnostic logging enabled")
+ diagnosticLogger.i("AuthenticatorApp: Starting SDK initialization")
+ }
+
+ CoroutineScope(Dispatchers.Default).launch {
+ // TODO: Update with your Journey configuration
+ // Initialize Journey SDK
+ try {
+ journey = Journey {
+ logger = diagnosticLogger
+ serverUrl = "" // e.g. https://openam.example.com/am
+ realm = "" // e.g. /alpha
+ cookie = "" // e.g. iPlanetDirectoryPro
+ // Oidc as module
+ module(Oidc) {
+ clientId = "" // e.g. myclient
+ discoveryEndpoint = "" // e.g. https://openam.example.com/am/oauth2/.well-known/openid-configuration?realm=/alpha
+ // Scopes to request - adjust as needed
+ scopes = mutableSetOf("openid", "email", "address", "profile", "phone")
+ redirectUri = "" // e.g. myapp://callback
+ }
+ }
+ journeyDeferred.complete(journey)
+ diagnosticLogger.i("AuthenticatorApp: Journey client initialized")
+ } catch (e: Exception) {
+ diagnosticLogger.e("AuthenticatorApp: Failed to initialize Journey", e)
+ journeyDeferred.completeExceptionally(e)
+ }
+
+ // Get destructive recovery setting
+ val destructiveRecoveryEnabled = userPreferences.isDestructiveRecoveryEnabled()
+ diagnosticLogger.i("AuthenticatorApp: Destructive recovery enabled: $destructiveRecoveryEnabled")
+
+ // Get auto-restore from backup setting
+ val autoRestoreEnabled = userPreferences.isAutoRestoreFromBackupEnabled()
+ diagnosticLogger.i("AuthenticatorApp: Auto-restore from backup enabled: $autoRestoreEnabled")
+
+ // Create OATH storage instance
+ try {
+ oathStorage = createOathStorage(
+ context = this@AuthenticatorApp,
+ autoRestoreFromBackup = autoRestoreEnabled,
+ allowDestructiveRecovery = destructiveRecoveryEnabled,
+ logger = diagnosticLogger
+ )
+ oathStorageDeferred.complete(oathStorage)
+ diagnosticLogger.i("AuthenticatorApp: OATH storage created")
+ } catch (e: Exception) {
+ diagnosticLogger.e("AuthenticatorApp: Failed to create OATH storage", e)
+ synchronized(initializationErrors) {
+ initializationErrors.add(com.pingidentity.authenticatorapp.data.ComponentError("OATH", e))
+ }
+ oathStorageDeferred.completeExceptionally(e)
+ }
+
+ // Create Push storage instance
+ try {
+ pushStorage = createPushStorage(
+ context = this@AuthenticatorApp,
+ autoRestoreFromBackup = autoRestoreEnabled,
+ allowDestructiveRecovery = destructiveRecoveryEnabled,
+ logger = diagnosticLogger
+ )
+ pushStorageDeferred.complete(pushStorage)
+ diagnosticLogger.i("AuthenticatorApp: Push storage created")
+ } catch (e: Exception) {
+ diagnosticLogger.e("AuthenticatorApp: Failed to create Push storage", e)
+ synchronized(initializationErrors) {
+ initializationErrors.add(com.pingidentity.authenticatorapp.data.ComponentError("Push", e))
+ }
+ pushStorageDeferred.completeExceptionally(e)
+ }
+
+ // Initialize OATH client with storage (only if storage succeeded)
+ val oathError = synchronized(initializationErrors) {
+ initializationErrors.find { it.component == "OATH" }
+ }
+ if (oathError == null) {
+ try {
+ oathClient = OathClient {
+ // Use the pre-created storage instance
+ storage = oathStorage
+ // Enable credential caching
+ enableCredentialCache = true
+ // Set diagnostic logger if enabled, otherwise standard logger
+ this.logger = diagnosticLogger
+ }
+ oathClientDeferred.complete(oathClient)
+ diagnosticLogger.i("AuthenticatorApp: OATH client initialized")
+ } catch (e: Exception) {
+ diagnosticLogger.e("AuthenticatorApp: Failed to initialize OATH client", e)
+ synchronized(initializationErrors) {
+ initializationErrors.add(com.pingidentity.authenticatorapp.data.ComponentError("OATH", e))
+ }
+ oathClientDeferred.completeExceptionally(e)
+ }
+ } else {
+ oathClientDeferred.completeExceptionally(oathError.exception)
+ }
+
+ // Initialize Push client with storage (only if storage succeeded)
+ val pushError = synchronized(initializationErrors) {
+ initializationErrors.find { it.component == "Push" }
+ }
+ if (pushError == null) {
+ try {
+ pushClient = PushClient {
+ // Use the pre-created storage instance
+ storage = pushStorage
+ // Enable credential caching
+ enableCredentialCache = true
+ // Set diagnostic logger if enabled, otherwise standard logger
+ this.logger = diagnosticLogger
+ }
+ pushClientDeferred.complete(pushClient)
+ diagnosticLogger.i("AuthenticatorApp: Push client initialized")
+ } catch (e: Exception) {
+ diagnosticLogger.e("AuthenticatorApp: Failed to initialize Push client", e)
+ synchronized(initializationErrors) {
+ initializationErrors.add(com.pingidentity.authenticatorapp.data.ComponentError("Push", e))
+ }
+ pushClientDeferred.completeExceptionally(e)
+ }
+ } else {
+ pushClientDeferred.completeExceptionally(pushError.exception)
+ }
+
+ // Obtain the device token from Firebase and set it in the Push client
+ val hasPushError = synchronized(initializationErrors) {
+ initializationErrors.any { it.component == "Push" }
+ }
+ if (!hasPushError) {
+ try {
+ FirebaseApp.getInstance()
+ pushClient.setDeviceToken(FirebaseMessaging.getInstance().token.await())
+ diagnosticLogger.i("AuthenticatorApp: Firebase device token set")
+ } catch (e: IllegalStateException) {
+ diagnosticLogger.e("Firebase not configured properly", e)
+ synchronized(initializationErrors) {
+ initializationErrors.add(com.pingidentity.authenticatorapp.data.ComponentError("Firebase", e))
+ }
+ }
+ }
+
+ diagnosticLogger.i("AuthenticatorApp: SDK initialization complete")
+ }
+ }
+
+ companion object {
+ /**
+ * Creates a configured SQLOathStorage instance with standard settings.
+ * This ensures consistency between initialization and backup restoration.
+ *
+ * @param context Android application context
+ * @param autoRestoreFromBackup Whether to auto-restore from backup on corruption
+ * @param allowDestructiveRecovery Whether to allow destructive recovery
+ * @param logger Logger instance to use
+ */
+ fun createOathStorage(
+ context: android.content.Context,
+ autoRestoreFromBackup: Boolean = true,
+ allowDestructiveRecovery: Boolean = false,
+ logger: Logger = DiagnosticLogger
+ ): SQLOathStorage {
+ return SQLOathStorage {
+ this.context = context
+ this.passphraseProvider = KeyStorePassphraseProvider(
+ context,
+ logger = logger
+ )
+ this.autoRestoreFromBackup = autoRestoreFromBackup
+ this.allowDestructiveRecovery = allowDestructiveRecovery
+ this.backupOnError = false
+ this.maxBackupCount = 5
+ this.logger = logger
+ }
+ }
+
+ /**
+ * Creates a configured SQLPushStorage instance with standard settings.
+ * This ensures consistency between initialization and backup restoration.
+ *
+ * @param context Android application context
+ * @param autoRestoreFromBackup Whether to auto-restore from backup on corruption
+ * @param allowDestructiveRecovery Whether to allow destructive recovery
+ * @param logger Logger instance to use
+ */
+ fun createPushStorage(
+ context: android.content.Context,
+ autoRestoreFromBackup: Boolean = true,
+ allowDestructiveRecovery: Boolean = false,
+ logger: Logger = DiagnosticLogger
+ ): SQLPushStorage {
+ return SQLPushStorage {
+ this.context = context
+ this.passphraseProvider = KeyStorePassphraseProvider(
+ context,
+ logger = logger
+ )
+ this.autoRestoreFromBackup = autoRestoreFromBackup
+ this.allowDestructiveRecovery = allowDestructiveRecovery
+ this.backupOnError = false
+ this.maxBackupCount = 5
+ this.logger = logger
+ }
+ }
+
+ /*
+ * Helper method to access the initialized PushClient from application context.
+ * This method suspend until the respective component is fully initialized.
+ * @param context Application context
+ * Throws IllegalStateException if the context is not AuthenticatorApp.
+ */
+ suspend fun getPushClient(context: Application): PushClient {
+ val app = context as? AuthenticatorApp
+ ?: throw IllegalStateException("Context must be AuthenticatorApp")
+
+ if (app.pushClientDeferred.isCompleted) {
+ return app.pushClientDeferred.getCompleted()
+ }
+ return app.pushClientDeferred.await()
+ }
+
+ /*
+ * Helper method to access the initialized OathClient from application context.
+ * This method suspend until the respective component is fully initialized.
+ * @param context Application context
+ * Throws IllegalStateException if the context is not AuthenticatorApp.
+ */
+ suspend fun getOathClient(context: Application): OathClient {
+ val app = context as AuthenticatorApp
+ if (app.oathClientDeferred.isCompleted) {
+ return app.oathClientDeferred.getCompleted()
+ }
+ return app.oathClientDeferred.await()
+ }
+
+ /*
+ * Helper method to access the initialized Journey from application context.
+ * This method suspend until the respective component is fully initialized.
+ * @param context Application context
+ * Throws IllegalStateException if the context is not AuthenticatorApp.
+ */
+ suspend fun getJourney(context: Application): Journey {
+ val app = context as AuthenticatorApp
+ if (app.journeyDeferred.isCompleted) {
+ return app.journeyDeferred.getCompleted()
+ }
+ return app.journeyDeferred.await()
+ }
+
+ /*
+ * Helper method to access the initialized SQLOathStorage from application context.
+ * This method suspend until the respective component is fully initialized.
+ * @param context Application context
+ * Throws IllegalStateException if the context is not AuthenticatorApp.
+ */
+ suspend fun getOathStorage(context: Application): SQLOathStorage {
+ val app = context as? AuthenticatorApp
+ ?: throw IllegalStateException("Context must be AuthenticatorApp")
+ if (app.oathStorageDeferred.isCompleted) {
+ return app.oathStorageDeferred.getCompleted()
+ }
+ return app.oathStorageDeferred.await()
+ }
+
+ /*
+ * Helper method to access the initialized SQLPushStorage from application context.
+ * This method suspend until the respective component is fully initialized.
+ * @param context Application context
+ * Throws IllegalStateException if the context is not AuthenticatorApp.
+ */
+ suspend fun getPushStorage(context: Application): SQLPushStorage {
+ val app = context as? AuthenticatorApp
+ ?: throw IllegalStateException("Context must be AuthenticatorApp")
+ if (app.pushStorageDeferred.isCompleted) {
+ return app.pushStorageDeferred.getCompleted()
+ }
+ return app.pushStorageDeferred.await()
+ }
+
+ /**
+ * Checks if there were any initialization errors.
+ * Returns the initialization error if present, null otherwise.
+ */
+ fun getInitializationError(context: Application): com.pingidentity.authenticatorapp.data.InitializationError? {
+ val app = context as? AuthenticatorApp
+ ?: throw IllegalStateException("Context must be AuthenticatorApp")
+
+ val errors = synchronized(app.initializationErrors) {
+ app.initializationErrors.toList()
+ }
+
+ if (errors.isEmpty()) {
+ return null
+ }
+
+ // Determine error type based on which components failed
+ val hasOathError = errors.any { it.component == "OATH" }
+ val hasPushError = errors.any { it.component == "Push" }
+ val hasJourneyError = errors.any { it.component == "Journey" }
+
+ val errorType = when {
+ hasOathError && hasPushError -> com.pingidentity.authenticatorapp.data.InitializationErrorType.BOTH_DATABASES_CORRUPTED
+ hasOathError -> com.pingidentity.authenticatorapp.data.InitializationErrorType.OATH_DATABASE_CORRUPTED
+ hasPushError -> com.pingidentity.authenticatorapp.data.InitializationErrorType.PUSH_DATABASE_CORRUPTED
+ hasJourneyError -> com.pingidentity.authenticatorapp.data.InitializationErrorType.JOURNEY_INITIALIZATION_FAILED
+ else -> com.pingidentity.authenticatorapp.data.InitializationErrorType.UNKNOWN_ERROR
+ }
+
+ // Build message from all errors
+ val message = errors.joinToString("\n") { error ->
+ "${error.component}: ${error.exception.message}"
+ }
+
+ // Check if destructive recovery is available
+ val userPreferences = com.pingidentity.authenticatorapp.data.UserPreferences(context)
+ val canUseDestructiveRecovery = !userPreferences.isDestructiveRecoveryEnabled()
+
+ return com.pingidentity.authenticatorapp.data.InitializationError(
+ type = errorType,
+ message = message,
+ errors = errors,
+ canRestoreFromBackup = true, // We always have backup capability
+ canUseDestructiveRecovery = canUseDestructiveRecovery
+ )
+ }
+ }
+}
diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/MainActivity.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/MainActivity.kt
new file mode 100644
index 00000000..7a3306f9
--- /dev/null
+++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/MainActivity.kt
@@ -0,0 +1,253 @@
+/*
+ * Copyright (c) 2025-2026 Ping Identity Corporation. All rights reserved.
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license. See the LICENSE file for details.
+ */
+
+package com.pingidentity.authenticatorapp
+
+import android.Manifest
+import android.app.Application
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.lifecycleScope
+import com.pingidentity.authenticatorapp.data.AuthenticatorViewModel
+import com.pingidentity.authenticatorapp.data.DiagnosticLogger
+import com.pingidentity.authenticatorapp.data.LoginViewModel
+import com.pingidentity.authenticatorapp.data.ThemeMode
+import com.pingidentity.authenticatorapp.data.UserPreferences
+import com.pingidentity.authenticatorapp.managers.AccountGroupingManager
+import com.pingidentity.authenticatorapp.managers.JourneyManager
+import com.pingidentity.authenticatorapp.managers.OathManager
+import com.pingidentity.authenticatorapp.managers.PushManager
+import com.pingidentity.authenticatorapp.managers.TestAccountFactory
+import com.pingidentity.authenticatorapp.notification.NotificationHelper
+import com.pingidentity.authenticatorapp.ui.AuthenticatorNavHost
+import com.pingidentity.authenticatorapp.ui.theme.PingIdentityAuthenticatorTheme
+import kotlinx.coroutines.launch
+
+/**
+ * Main activity for the Authenticator app.
+ * Sets up the content view with Jetpack Compose and handles notification permissions.
+ */
+class MainActivity : ComponentActivity() {
+
+ private lateinit var authenticatorViewModel: AuthenticatorViewModel
+ private lateinit var loginViewModel: LoginViewModel
+ private var areViewModelsInitialized by mutableStateOf(false)
+
+ // Register for notification permission result
+ private val requestPermissionLauncher = registerForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { isGranted: Boolean ->
+ // Check if ViewModel is initialized before using it
+ if (::authenticatorViewModel.isInitialized) {
+ if (isGranted) {
+ // Permission granted, notifications can be shown
+ authenticatorViewModel.setMessage(getString(R.string.notification_permission_granted))
+ } else {
+ // Permission denied
+ authenticatorViewModel.setMessage(getString(R.string.notification_permission_denied))
+ }
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // Setup ViewModels with dependencies
+ setupViewModels(application)
+
+ // Initialize notification channels
+ NotificationHelper(this).createNotificationChannels()
+
+ // Check notification permission for Android 13+
+ checkNotificationPermission()
+
+ setContent {
+ if (areViewModelsInitialized) {
+ val themeMode by authenticatorViewModel.themeMode.collectAsState()
+ PingIdentityAuthenticatorTheme(themeMode = themeMode) {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ AuthenticatorNavHost(
+ authenticatorViewModel = authenticatorViewModel,
+ loginViewModel = loginViewModel,
+ initialDestination = getInitialDestination()
+ )
+ }
+ }
+ } else {
+ // Show a basic loading screen with system theme while ViewModels initialize
+ PingIdentityAuthenticatorTheme(themeMode = ThemeMode.SYSTEM) {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ // You could add a proper loading screen here if needed
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Sets up the ViewModels with their dependencies.
+ */
+ private fun setupViewModels(application: Application) {
+ // Initialize clients and ViewModels asynchronously
+ lifecycleScope.launch {
+ val diagnosticLogger = DiagnosticLogger
+ val userPreferences = UserPreferences(application)
+ val oathManager = OathManager(diagnosticLogger = diagnosticLogger)
+ val pushManager = PushManager(diagnosticLogger = diagnosticLogger)
+ val journeyManager = JourneyManager(diagnosticLogger = diagnosticLogger)
+
+ // Check for initialization errors first
+ val initError = AuthenticatorApp.getInitializationError(application)
+
+ if (initError != null) {
+ // Create ViewModels even with errors so we can show the error screen
+ authenticatorViewModel = AuthenticatorViewModel(
+ application = application,
+ userPreferences = userPreferences,
+ oathManager = oathManager,
+ pushManager = pushManager,
+ accountGroupingManager = AccountGroupingManager(userPreferences, diagnosticLogger),
+ testAccountFactory = TestAccountFactory()
+ )
+
+ loginViewModel = LoginViewModel(
+ application = application,
+ journeyManager = journeyManager,
+ oathManager = oathManager,
+ pushManager = pushManager
+ )
+
+ // Set the initialization error in the ViewModel
+ authenticatorViewModel.setInitializationError(initError)
+
+ // Mark ViewModels as initialized
+ areViewModelsInitialized = true
+ return@launch
+ }
+
+ // Get storage instances (will throw if initialization failed)
+ try {
+ val oathStorage = AuthenticatorApp.getOathStorage(application)
+ val pushStorage = AuthenticatorApp.getPushStorage(application)
+
+ // Initialize the clients in the managers
+ val journeyClient = AuthenticatorApp.getJourney(application)
+ journeyManager.setClient(journeyClient)
+ val oauthClient = AuthenticatorApp.getOathClient(application)
+ oathManager.setClient(oauthClient, oathStorage)
+ val pushClient = AuthenticatorApp.getPushClient(application)
+ pushManager.setClient(pushClient, pushStorage)
+
+ // Create ViewModels with clients already set
+ authenticatorViewModel = AuthenticatorViewModel(
+ application = application,
+ userPreferences = userPreferences,
+ oathManager = oathManager,
+ pushManager = pushManager,
+ accountGroupingManager = AccountGroupingManager(userPreferences, diagnosticLogger),
+ testAccountFactory = TestAccountFactory()
+ )
+
+ // Create LoginViewModel
+ loginViewModel = LoginViewModel(
+ application = application,
+ journeyManager = journeyManager,
+ oathManager = oathManager,
+ pushManager = pushManager
+ )
+
+ // Mark ViewModels as initialized and trigger UI update
+ areViewModelsInitialized = true
+ } catch (e: Exception) {
+ diagnosticLogger.e("Failed to initialize ViewModels", e)
+
+ // Check if there are initialization errors to display
+ val errorAfterException = AuthenticatorApp.getInitializationError(application)
+
+ // Create basic ViewModels to show error
+ authenticatorViewModel = AuthenticatorViewModel(
+ application = application,
+ userPreferences = userPreferences,
+ oathManager = oathManager,
+ pushManager = pushManager,
+ accountGroupingManager = AccountGroupingManager(userPreferences, diagnosticLogger),
+ testAccountFactory = TestAccountFactory()
+ )
+
+ loginViewModel = LoginViewModel(
+ application = application,
+ journeyManager = journeyManager,
+ oathManager = oathManager,
+ pushManager = pushManager
+ )
+
+ // Set the error if we found one
+ if (errorAfterException != null) {
+ authenticatorViewModel.setInitializationError(errorAfterException)
+ }
+
+ areViewModelsInitialized = true
+ }
+ }
+ }
+
+ /**
+ * Checks if notification permission is granted and requests if not
+ * (required for Android 13+/API 33+)
+ */
+ private fun checkNotificationPermission() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ val permissionState = ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
+
+ if (permissionState != PackageManager.PERMISSION_GRANTED) {
+ requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
+ }
+ }
+ }
+
+ /**
+ * Determines the initial destination based on intent extras
+ * (e.g., when opened from a notification)
+ */
+ private fun getInitialDestination(): String {
+ // Check if opened from a notification
+ intent?.extras?.let { extras ->
+ if (extras.containsKey("NAVIGATE_TO")) {
+ val destination = extras.getString("NAVIGATE_TO") ?: return "accounts"
+
+ // If we have a notification ID, navigate to that notification
+ if (destination == "notifications" && extras.containsKey("NOTIFICATION_ID")) {
+ val notificationId = extras.getString("NOTIFICATION_ID") ?: return "notifications"
+ return "notification/$notificationId"
+ }
+
+ return destination
+ }
+ }
+
+ return "accounts"
+ }
+}
diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/data/AuthenticatorViewModel.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/data/AuthenticatorViewModel.kt
new file mode 100644
index 00000000..773a59d0
--- /dev/null
+++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/data/AuthenticatorViewModel.kt
@@ -0,0 +1,1210 @@
+/*
+ * Copyright (c) 2025-2026 Ping Identity Corporation. All rights reserved.
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license. See the LICENSE file for details.
+ */
+
+package com.pingidentity.authenticatorapp.data
+
+import android.app.Application
+import android.content.Context
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import com.pingidentity.authenticatorapp.R
+import com.pingidentity.authenticatorapp.managers.AccountGroupingManager
+import com.pingidentity.authenticatorapp.managers.OathManager
+import com.pingidentity.authenticatorapp.managers.PushManager
+import com.pingidentity.authenticatorapp.managers.TestAccountFactory
+import com.pingidentity.logger.Logger
+import com.pingidentity.logger.STANDARD
+import com.pingidentity.mfa.commons.exception.CredentialLockedException
+import com.pingidentity.mfa.commons.exception.DuplicateCredentialException
+import com.pingidentity.mfa.oath.OathCodeInfo
+import com.pingidentity.mfa.oath.OathCredential
+import com.pingidentity.mfa.push.PushCredential
+import com.pingidentity.mfa.push.PushNotification
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+/**
+ * Enum representing different types of initialization errors.
+ */
+enum class InitializationErrorType {
+ OATH_DATABASE_CORRUPTED,
+ PUSH_DATABASE_CORRUPTED,
+ BOTH_DATABASES_CORRUPTED,
+ OATH_INITIALIZATION_FAILED,
+ PUSH_INITIALIZATION_FAILED,
+ JOURNEY_INITIALIZATION_FAILED,
+ FIREBASE_CONFIGURATION_ERROR,
+ UNKNOWN_ERROR
+}
+
+/**
+ * Represents an error from a specific component during initialization.
+ */
+data class ComponentError(
+ val component: String, // "OATH", "Push", "Journey", "Firebase"
+ val exception: Exception
+)
+
+/**
+ * Data class representing an initialization error with recovery options.
+ */
+data class InitializationError(
+ val type: InitializationErrorType,
+ val message: String,
+ val errors: List = emptyList(),
+ val canRestoreFromBackup: Boolean = false,
+ val canUseDestructiveRecovery: Boolean = false,
+ val timestamp: Long = System.currentTimeMillis()
+)
+
+/**
+ * ViewModel for the Authenticator app.
+ * Coordinates between different managers and handles UI-specific logic.
+ *
+ * @param application The application context for accessing app-level resources
+ * @param userPreferences Injected UserPreferences dependency for settings management
+ * @param oathManager Manager for OATH credential operations
+ * @param pushManager Manager for Push credential and notification operations
+ * @param accountGroupingManager Manager for account grouping and ordering
+ * @param testAccountFactory Factory for creating test accounts
+ */
+class AuthenticatorViewModel(
+ application: Application,
+ private val userPreferences: UserPreferences,
+ private val oathManager: OathManager,
+ private val pushManager: PushManager,
+ private val accountGroupingManager: AccountGroupingManager,
+ private val testAccountFactory: TestAccountFactory
+) : AndroidViewModel(application), ViewModelProvider.Factory {
+
+ private val _uiState = MutableStateFlow(AuthenticatorUiState())
+ private val diagnosticLogger = DiagnosticLogger
+
+ // Track loading states to batch account group updates
+ private var oathCredentialsLoaded = false
+ private var pushCredentialsLoaded = false
+
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ // Expose all settings preferences as StateFlows
+ val copyOtp: StateFlow
+ get() = userPreferences.copyOtpFlow
+
+ val tapToReveal: StateFlow
+ get() = userPreferences.tapToRevealFlow
+
+ val combineAccounts: StateFlow
+ get() = userPreferences.combineAccountsFlow
+
+ val diagnosticLogging: StateFlow
+ get() = userPreferences.diagnosticLoggingFlow
+
+ val testMode: StateFlow
+ get() = userPreferences.testModeFlow
+
+ val themeMode: StateFlow
+ get() = userPreferences.themeModeFlow
+
+ val destructiveRecovery: StateFlow
+ get() = userPreferences.destructiveRecoveryFlow
+
+ val autoRestoreFromBackup: StateFlow
+ get() = userPreferences.autoRestoreFromBackupFlow
+
+
+ /**
+ * Initializes the ViewModel by setting up state flows and loading initial data.
+ */
+ init {
+ setupStateFlows()
+ loadInitialData()
+ }
+
+ /**
+ * Sets up the state flows to observe manager states and update UI state accordingly.
+ */
+ private fun setupStateFlows() {
+ // Observe credential changes and update account groups
+ viewModelScope.launch {
+ combine(
+ oathManager.oathCredentials,
+ pushManager.pushCredentials
+ ) { oathCreds, pushCreds ->
+ Pair(oathCreds, pushCreds)
+ }.collect { (oathCreds, pushCreds) ->
+ accountGroupingManager.updateAccountGroups(oathCreds, pushCreds)
+ // Update UI state when credentials change
+ updateUiStateFromManagers()
+ }
+ }
+
+ // Observe combine accounts setting changes and update account groups
+ viewModelScope.launch {
+ userPreferences.combineAccountsFlow.collect { _ ->
+ // Force re-grouping when combine accounts setting changes
+ val currentState = _uiState.value
+ accountGroupingManager.updateAccountGroups(
+ currentState.oathCredentials,
+ currentState.pushCredentials
+ )
+ updateUiStateFromManagers()
+ }
+ }
+
+ // Observe account groups from AccountGroupingManager
+ viewModelScope.launch {
+ accountGroupingManager.accountGroups.collect { accountGroups ->
+ _uiState.update { it.copy(accountGroups = accountGroups) }
+ }
+ }
+
+ // Observe individual state changes
+ viewModelScope.launch {
+ oathManager.generatedCodes.collect { codes ->
+ _uiState.update { it.copy(generatedCodes = codes) }
+ }
+ }
+
+ viewModelScope.launch {
+ oathManager.lastAddedOathCredential.collect { credential ->
+ _uiState.update { it.copy(lastAddedOathCredential = credential) }
+ }
+ }
+
+ viewModelScope.launch {
+ pushManager.lastAddedPushCredential.collect { credential ->
+ _uiState.update { it.copy(lastAddedPushCredential = credential) }
+ }
+ }
+
+ viewModelScope.launch {
+ oathManager.isLoadingOathCredentials.collect { loading ->
+ _uiState.update { it.copy(isLoadingOathCredentials = loading) }
+ }
+ }
+
+ viewModelScope.launch {
+ pushManager.isLoadingPushCredentials.collect { loading ->
+ _uiState.update { it.copy(isLoadingPushCredentials = loading) }
+ }
+ }
+
+ viewModelScope.launch {
+ pushManager.isLoadingNotifications.collect { loading ->
+ _uiState.update { it.copy(isLoadingNotifications = loading) }
+ }
+ }
+
+ viewModelScope.launch {
+ pushManager.pushNotifications.collect { notifications ->
+ _uiState.update { it.copy(pushNotifications = notifications) }
+ }
+ }
+
+ viewModelScope.launch {
+ pushManager.pendingNotifications.collect { notifications ->
+ _uiState.update { it.copy(pendingNotifications = notifications) }
+ }
+ }
+
+ viewModelScope.launch {
+ pushManager.pushNotificationItems.collect { items ->
+ _uiState.update { it.copy(pushNotificationItems = items) }
+ }
+ }
+
+ viewModelScope.launch {
+ pushManager.pendingNotificationItems.collect { items ->
+ _uiState.update { it.copy(pendingNotificationItems = items) }
+ }
+ }
+ }
+
+ /**
+ * Updates the UI state from all manager states.
+ */
+ private fun updateUiStateFromManagers() {
+ _uiState.update { currentState ->
+ currentState.copy(
+ oathCredentials = oathManager.oathCredentials.value,
+ pushCredentials = pushManager.pushCredentials.value
+ )
+ }
+ }
+
+ /**
+ * Loads initial data from all managers.
+ */
+ private fun loadInitialData() {
+ viewModelScope.launch {
+ try {
+ // Set initial loading state
+ _uiState.update { it.copy(isInitialLoading = true) }
+
+ // Load all credentials and notifications
+ loadOathCredentials()
+ loadPushCredentials()
+ loadPushNotifications()
+
+ // Clear initial loading state once everything is loaded
+ _uiState.update { it.copy(isInitialLoading = false) }
+ } catch (e: Exception) {
+ _uiState.update { it.copy(error = e.message ?: "Failed to initialize", isInitialLoading = false) }
+ }
+ }
+ }
+
+ /**
+ * Loads all OATH credentials from the SDK.
+ */
+ private fun loadOathCredentials() {
+ viewModelScope.launch {
+ oathManager.loadCredentials().onSuccess {
+ oathCredentialsLoaded = true
+ _uiState.update { it.copy(error = null) }
+ }.onFailure { e ->
+ _uiState.update { it.copy(error = e.message ?: "Failed to load OATH credentials") }
+ }
+ }
+ }
+
+ /**
+ * Loads all Push credentials from the SDK.
+ */
+ private fun loadPushCredentials() {
+ viewModelScope.launch {
+ pushManager.loadCredentials().onSuccess {
+ pushCredentialsLoaded = true
+ _uiState.update { it.copy(error = null) }
+ }.onFailure { e ->
+ _uiState.update { it.copy(error = e.message ?: "Failed to load Push credentials") }
+ }
+ }
+ }
+
+ /**
+ * Loads all push notifications from the SDK.
+ */
+ private fun loadPushNotifications() {
+ viewModelScope.launch {
+ pushManager.loadPushNotifications().onSuccess {
+ _uiState.update { it.copy(error = null) }
+ }.onFailure { e ->
+ _uiState.update { it.copy(error = e.message ?: "Failed to load push notifications") }
+ }
+ }
+ }
+
+
+ /**
+ * Update the account groups order immediately in the UI state.
+ * This provides immediate feedback while the order is being persisted.
+ */
+ fun updateAccountGroupOrder(newAccountGroups: List) {
+ accountGroupingManager.updateAccountGroupOrder(newAccountGroups)
+ // Also save to preferences asynchronously
+ viewModelScope.launch {
+ accountGroupingManager.saveAccountOrder(newAccountGroups)
+ }
+ }
+
+ /**
+ * Updates the copy OTP setting
+ */
+ fun setCopyOtp(enabled: Boolean) {
+ viewModelScope.launch {
+ diagnosticLogger.d("SettingsScreen: setCopyOtp: $enabled")
+ userPreferences.setCopyOtp(enabled)
+ }
+ }
+
+ /**
+ * Updates the tap to reveal setting
+ */
+ fun setTapToReveal(enabled: Boolean) {
+ viewModelScope.launch {
+ diagnosticLogger.d("SettingsScreen: setTapToReveal: $enabled")
+ userPreferences.setTapToReveal(enabled)
+ }
+ }
+
+ /**
+ * Set whether auto-restore from backup is enabled.
+ */
+ fun setAutoRestoreFromBackup(enabled: Boolean) {
+ viewModelScope.launch {
+ userPreferences.setAutoRestoreFromBackup(enabled)
+ }
+ }
+
+ /**
+ * Updates the combine accounts setting
+ */
+ fun setCombineAccounts(enabled: Boolean) {
+ viewModelScope.launch {
+ diagnosticLogger.d("SettingsScreen: setCombineAccounts: $enabled")
+ userPreferences.setCombineAccounts(enabled)
+ }
+ }
+
+ /**
+ * Updates the diagnostic logging setting
+ */
+ fun setDiagnosticLogging(enabled: Boolean) {
+ viewModelScope.launch {
+ diagnosticLogger.d("SettingsScreen: setDiagnosticLogging: $enabled")
+ userPreferences.setDiagnosticLogging(enabled)
+
+ // Set the global logger based on the diagnostic logging setting
+ Logger.logger = if (enabled) {
+ DiagnosticLogger
+ } else {
+ Logger.STANDARD
+ }
+ }
+ }
+
+ /**
+ * Updates the test mode setting
+ */
+ fun setTestMode(enabled: Boolean) {
+ viewModelScope.launch {
+ diagnosticLogger.d("SettingsScreen: setTestMode: $enabled")
+ userPreferences.setTestMode(enabled)
+ }
+ }
+
+ /**
+ * Updates the theme mode setting
+ */
+ fun setThemeMode(themeMode: ThemeMode) {
+ viewModelScope.launch {
+ diagnosticLogger.d("SettingsScreen: setThemeMode: $themeMode")
+ userPreferences.setThemeMode(themeMode)
+ }
+ }
+
+ /**
+ * Refreshes all credentials (OATH and Push).
+ */
+ fun refreshCredentials() {
+ viewModelScope.launch {
+ try {
+ // Set refresh loading state
+ _uiState.update { it.copy(isRefreshing = true) }
+
+ // Reset loading states before refreshing
+ oathCredentialsLoaded = false
+ pushCredentialsLoaded = false
+ loadOathCredentials()
+ loadPushCredentials()
+
+ // Clear refresh loading state
+ _uiState.update { it.copy(isRefreshing = false) }
+ } catch (e: Exception) {
+ _uiState.update { it.copy(
+ isRefreshing = false,
+ error = e.message ?: "Failed to refresh credentials"
+ ) }
+ }
+ }
+ }
+
+ /**
+ * Refreshes push notifications, loading both pending and historical notifications.
+ * Call this when entering the notifications screen to ensure all notifications are loaded.
+ */
+ fun refreshNotifications() {
+ viewModelScope.launch {
+ try {
+ diagnosticLogger.d("Refreshing all notifications")
+ pushManager.loadAllPushNotifications().onFailure { e ->
+ _uiState.update { it.copy(error = e.message ?: "Failed to refresh notifications") }
+ }
+ } catch (e: Exception) {
+ _uiState.update { it.copy(
+ error = e.message ?: "Failed to refresh notifications"
+ ) }
+ }
+ }
+ }
+
+ /**
+ * Gets the current device token used for push notifications.
+ */
+ internal fun getDeviceToken(onTokenReceived: (String?) -> Unit) {
+ viewModelScope.launch {
+ pushManager.getDeviceToken().onSuccess { token ->
+ _uiState.update { it.copy(message = getApplication().getString(R.string.test_screen_device_token_retrieved)) }
+ onTokenReceived(token)
+ }.onFailure { e ->
+ _uiState.update { it.copy(error = e.message ?: "Failed to get device token") }
+ onTokenReceived(null)
+ }
+ }
+ }
+
+
+ /**
+ * Forces a renewal of the Firebase device token.
+ */
+ internal fun forceDeviceTokenRenew() {
+ viewModelScope.launch {
+ pushManager.forceDeviceTokenRenew().onSuccess {
+ _uiState.update { it.copy(message = getApplication().getString(R.string.test_screen_device_token_renewed)) }
+ }.onFailure { e ->
+ _uiState.update { it.copy(error = e.message ?: "Failed to renew device token") }
+ }
+ }
+ }
+
+ /**
+ * Gets a specific push notification item by its ID.
+ * This is used to retrieve the notification details for display in the UI.
+ */
+ fun getNotificationItemById(notificationId: String): PushNotificationItem? {
+ return pushManager.getNotificationItemById(notificationId)
+ }
+
+ /**
+ * Adds an OATH credential from a URI.
+ */
+ fun addOathCredentialFromUri(uri: String) {
+ viewModelScope.launch {
+ oathManager.addCredentialFromUri(uri).onSuccess {
+ _uiState.update { it.copy(error = null) }
+ }.onFailure { e ->
+ updateErrorMessage(e, "Failed to add OATH credential")
+ }
+ }
+ }
+
+ /**
+ * Adds a Push credential from a URI.
+ */
+ fun addPushCredentialFromUri(uri: String) {
+ viewModelScope.launch {
+ pushManager.addCredentialFromUri(uri).onSuccess {
+ _uiState.update { it.copy(error = null) }
+ }.onFailure { e ->
+ updateErrorMessage(e, "Failed to add Push credential")
+ }
+ }
+ }
+
+ /**
+ * Adds both OATH and Push credentials from a URI.
+ * Ensures that at least the OATH credential is registered even if Push fails.
+ */
+ fun addMfaCredentialFromUri(uri: String) {
+ viewModelScope.launch {
+ try {
+ // Attempt to add OATH credential first
+ oathManager.addCredentialFromUri(uri).onSuccess {
+ _uiState.update { it.copy(error = null) }
+ }.onFailure { e ->
+ updateErrorMessage(e, "Failed to add OATH credential")
+ return@launch
+ }
+
+ // Attempt to add Push credential
+ pushManager.addCredentialFromUri(uri).onSuccess {
+ _uiState.update { it.copy(error = null) }
+ }.onFailure { e ->
+ _uiState.update { it.copy(error = "OATH credential added, but failed to add Push credential: ${e.message}") }
+ }
+ } catch (e: Exception) {
+ _uiState.update { it.copy(error = e.message ?: "Unexpected error while adding MFA credential") }
+ }
+ }
+ }
+
+ /**
+ * Removes an OATH credential from the SDK.
+ */
+ fun removeOathCredential(credentialId: String) {
+ viewModelScope.launch {
+ oathManager.removeCredential(credentialId).onFailure { e ->
+ _uiState.update { it.copy(error = e.message ?: "Failed to remove OATH credential") }
+ }
+ }
+ }
+
+ /**
+ * Removes a Push credential from the SDK.
+ */
+ fun removePushCredential(credentialId: String) {
+ viewModelScope.launch {
+ pushManager.removeCredential(credentialId).onFailure { e ->
+ _uiState.update { it.copy(error = e.message ?: "Failed to remove Push credential") }
+ }
+ }
+ }
+
+ /**
+ * Updates an OATH credential in the SDK.
+ */
+ fun updateOathCredential(credential: OathCredential) {
+ viewModelScope.launch {
+ oathManager.updateCredential(credential).onFailure { e ->
+ _uiState.update { it.copy(error = e.message ?: "Failed to update OATH credential") }
+ }
+ }
+ }
+
+ /**
+ * Updates a Push credential in the SDK.
+ */
+ fun updatePushCredential(credential: PushCredential) {
+ viewModelScope.launch {
+ pushManager.updateCredential(credential).onFailure { e ->
+ _uiState.update { it.copy(error = e.message ?: "Failed to update Push credential") }
+ }
+ }
+ }
+
+ /**
+ * Locks an account by applying the specified policy to all credentials in the account group.
+ *
+ * @param accountGroup The account group to lock
+ * @param policyName The name of the locking policy to apply
+ */
+ fun lockAccountGroup(accountGroup: AccountGroup, policyName: String) {
+ viewModelScope.launch {
+ try {
+ // Lock all OATH credentials in the group
+ accountGroup.oathCredentials.forEach { credential ->
+ val lockedCredential = credential.copy()
+ lockedCredential.lockCredential(policyName)
+ oathManager.updateCredential(lockedCredential).onFailure { e ->
+ throw e
+ }
+ }
+
+ // Lock all Push credentials in the group
+ accountGroup.pushCredentials.forEach { credential ->
+ val lockedCredential = credential.copy()
+ lockedCredential.lockCredential(policyName)
+ pushManager.updateCredential(lockedCredential).onFailure { e ->
+ throw e
+ }
+ }
+
+ _uiState.update {
+ it.copy(message = getApplication().getString(R.string.test_screen_account_locked_success))
+ }
+ } catch (e: Exception) {
+ _uiState.update {
+ it.copy(error = e.message ?: "Failed to lock account")
+ }
+ }
+ }
+ }
+
+ /**
+ * Unlocks an account by removing the lock from all credentials in the account group.
+ *
+ * @param accountGroup The account group to unlock
+ */
+ fun unlockAccountGroup(accountGroup: AccountGroup) {
+ viewModelScope.launch {
+ try {
+ // Unlock all OATH credentials in the group
+ accountGroup.oathCredentials.forEach { credential ->
+ val unlockedCredential = credential.copy()
+ unlockedCredential.unlockCredential()
+ oathManager.updateCredential(unlockedCredential).onFailure { e ->
+ throw e
+ }
+ }
+
+ // Unlock all Push credentials in the group
+ accountGroup.pushCredentials.forEach { credential ->
+ val unlockedCredential = credential.copy()
+ unlockedCredential.unlockCredential()
+ pushManager.updateCredential(unlockedCredential).onFailure { e ->
+ throw e
+ }
+ }
+
+ // Generate codes immediately for unlocked OATH credentials
+ accountGroup.oathCredentials.forEach { credential ->
+ generateCode(credential.id)
+ }
+
+ _uiState.update {
+ it.copy(message = getApplication().getString(R.string.test_screen_account_unlocked_success))
+ }
+ } catch (e: Exception) {
+ _uiState.update {
+ it.copy(error = e.message ?: "Failed to unlock account")
+ }
+ }
+ }
+ }
+
+ /**
+ * Generates a code for a credential.
+ */
+ fun generateCode(credentialId: String) {
+ viewModelScope.launch {
+ oathManager.generateCode(credentialId).onFailure { e ->
+ if (e is CredentialLockedException) {
+ // Ignore locked credential errors for code generation
+ diagnosticLogger.d("Credential $credentialId is locked, cannot generate code")
+ } else {
+ _uiState.update {
+ it.copy(error = e.message ?: "Failed to generate code")
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Approves a push notification.
+ */
+ fun approveNotification(notificationId: String) {
+ viewModelScope.launch {
+ pushManager.approveNotification(notificationId).onSuccess { success ->
+ if (!success) {
+ _uiState.update { it.copy(error = "Failed to approve notification") }
+ }
+ }.onFailure { e ->
+ _uiState.update { it.copy(error = e.message ?: "Failed to approve notification") }
+ }
+ }
+ }
+
+ /**
+ * Approves a push notification with a challenge response.
+ */
+ fun approveChallengeNotification(notificationId: String, challengeResponse: String) {
+ viewModelScope.launch {
+ pushManager.approveChallengeNotification(notificationId, challengeResponse).onSuccess { success ->
+ if (!success) {
+ _uiState.update { it.copy(error = "Failed to approve challenge notification") }
+ }
+ }.onFailure { e ->
+ _uiState.update { it.copy(error = e.message ?: "Failed to approve challenge notification") }
+ }
+ }
+ }
+
+ /**
+ * Denies a push notification.
+ */
+ fun denyNotification(notificationId: String) {
+ viewModelScope.launch {
+ pushManager.denyNotification(notificationId).onSuccess { success ->
+ if (!success) {
+ _uiState.update { it.copy(error = "Failed to deny notification") }
+ }
+ }.onFailure { e ->
+ _uiState.update { it.copy(error = e.message ?: "Failed to deny notification") }
+ }
+ }
+ }
+
+ /**
+ * Cleans up old notifications.
+ */
+ fun cleanupNotifications() {
+ viewModelScope.launch {
+ pushManager.cleanupNotifications().onSuccess {
+ _uiState.update { it.copy(message = getApplication().getString(R.string.test_screen_notifications_cleaned_up)) }
+ }.onFailure { e ->
+ _uiState.update { it.copy(error = e.message ?: "Failed to clean up notifications") }
+ }
+ }
+ }
+
+ /**
+ * Sets the error message in the UI state.
+ */
+ fun setError(errorMessage: String) {
+ _uiState.update { it.copy(error = errorMessage) }
+ }
+
+ /**
+ * Clears the error message in the UI state.
+ */
+ fun clearError() {
+ _uiState.update { it.copy(error = null) }
+ }
+
+ /**
+ * Sets the message in the UI state.
+ */
+ fun setMessage(message: String) {
+ _uiState.update { it.copy(message = message) }
+ }
+
+ /**
+ * Clears the message in the UI state.
+ */
+ fun clearMessage() {
+ _uiState.update { it.copy(message = null) }
+ }
+
+ /**
+ * Clears the last added OATH credential in the UI state.
+ */
+ fun clearLastAddedOathCredential() {
+ oathManager.clearLastAddedCredential()
+ }
+
+ /**
+ * Copies the specified text to the clipboard
+ */
+ fun copyToClipboard(context: Context, text: String, label: String = "ADB Command") {
+ diagnosticLogger.d("Copying code to Clipboard")
+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
+ val clip = android.content.ClipData.newPlainText(label, text)
+ clipboard.setPrimaryClip(clip)
+ }
+
+ /**
+ * Clears the last added Push credential in the UI state.
+ */
+ fun clearLastAddedPushCredential() {
+ pushManager.clearLastAddedCredential()
+ }
+
+ /**
+ * Test function: Creates a random OATH account for testing
+ */
+ fun createRandomOathAccount() {
+ viewModelScope.launch {
+ try {
+ val (uri, message) = testAccountFactory.createRandomOathAccount()
+ addOathCredentialFromUri(uri)
+ _uiState.update { it.copy(message = message) }
+ } catch (e: Exception) {
+ _uiState.update { it.copy(error = e.message ?: "Failed to create random OATH account") }
+ }
+ }
+ }
+
+ /**
+ * Test function: Creates a random PUSH account for testing
+ */
+ fun createRandomPushAccount() {
+ viewModelScope.launch {
+ try {
+ val (credential, message) = testAccountFactory.createRandomPushCredential()
+
+ pushManager.updateCredential(credential).onSuccess {
+ _uiState.update { it.copy(message = message) }
+ }.onFailure { e ->
+ _uiState.update { it.copy(error = e.message ?: "Failed to create test push account") }
+ }
+ } catch (e: Exception) {
+ _uiState.update { it.copy(error = e.message ?: "Failed to create test push account") }
+ }
+ }
+ }
+
+ /**
+ * Test function: Creates a random combined OATH + PUSH account for testing
+ */
+ fun createRandomCombinedMfaAccount() {
+ viewModelScope.launch {
+ try {
+ val (pushCredential, oathCredential, message) = testAccountFactory.createRandomCombinedMfaCredentials()
+
+ // Save both credentials
+ var hasError = false
+ pushManager.updateCredential(pushCredential).onFailure { e ->
+ _uiState.update { it.copy(error = e.message ?: "Failed to create test push account") }
+ hasError = true
+ }
+
+ if (!hasError) {
+ oathManager.updateCredential(oathCredential).onSuccess {
+ _uiState.update { it.copy(message = message) }
+ }.onFailure { e ->
+ _uiState.update { it.copy(error = e.message ?: "Failed to create test OATH account") }
+ }
+ }
+ } catch (e: Exception) {
+ _uiState.update { it.copy(error = e.message ?: "Failed to create test combined account") }
+ }
+ }
+ }
+
+
+ /**
+ * Called when the ViewModel is cleared.
+ */
+ override fun onCleared() {
+ super.onCleared()
+
+ viewModelScope.launch {
+ try {
+ // Close managers to release resources
+ oathManager.close()
+ pushManager.close()
+ } catch (e: Exception) {
+ // Log any errors during cleanup
+ diagnosticLogger.e("Error closing managers", e)
+ }
+ }
+ }
+
+ /**
+ * Gets the list of OATH backup files.
+ * Returns a list of backup file info (name, size, timestamp).
+ */
+ fun getOathBackupFiles(callback: (List) -> Unit) {
+ viewModelScope.launch {
+ try {
+ val backups = oathManager.getBackupFiles()
+ callback(backups)
+ } catch (e: Exception) {
+ _uiState.update { it.copy(error = "Failed to get OATH backups: ${e.message}") }
+ callback(emptyList())
+ }
+ }
+ }
+
+ /**
+ * Gets the list of PUSH backup files.
+ * Returns a list of backup file info (name, size, timestamp).
+ */
+ fun getPushBackupFiles(callback: (List) -> Unit) {
+ viewModelScope.launch {
+ try {
+ val backups = pushManager.getBackupFiles()
+ callback(backups)
+ } catch (e: Exception) {
+ _uiState.update { it.copy(error = "Failed to get PUSH backups: ${e.message}") }
+ callback(emptyList())
+ }
+ }
+ }
+
+ /**
+ * Restores OATH database from the latest backup.
+ */
+ fun restoreOathFromBackup() {
+ viewModelScope.launch {
+ try {
+ val success = oathManager.restoreFromBackup(getApplication())
+ if (success) {
+ _uiState.update { it.copy(message = "OATH database restored successfully") }
+ // Reload credentials after restoration
+ loadOathCredentials()
+ } else {
+ _uiState.update { it.copy(error = "No OATH backup available to restore") }
+ }
+ } catch (e: Exception) {
+ _uiState.update { it.copy(error = "Failed to restore OATH backup: ${e.message}") }
+ }
+ }
+ }
+
+ /**
+ * Restores PUSH database from the latest backup.
+ */
+ fun restorePushFromBackup() {
+ viewModelScope.launch {
+ try {
+ val success = pushManager.restoreFromBackup(getApplication())
+ if (success) {
+ _uiState.update { it.copy(message = "PUSH database restored successfully") }
+ // Reload credentials after restoration
+ loadPushCredentials()
+ } else {
+ _uiState.update { it.copy(error = "No PUSH backup available to restore") }
+ }
+ } catch (e: Exception) {
+ _uiState.update { it.copy(error = "Failed to restore PUSH backup: ${e.message}") }
+ }
+ }
+ }
+
+ /**
+ * Simulates making the OATH database read-only for testing error handling.
+ */
+ fun simulateOathDatabaseReadOnly() {
+ viewModelScope.launch {
+ try {
+ oathManager.makeDatabaseReadOnly()
+ _uiState.update {
+ it.copy(message = "OATH database is now read-only. Restart app to test error handling.")
+ }
+ } catch (e: Exception) {
+ _uiState.update { it.copy(error = "Failed to make OATH DB read-only: ${e.message}") }
+ }
+ }
+ }
+
+ /**
+ * Simulates corrupting the OATH database for testing error handling.
+ */
+ fun simulateOathDatabaseCorruption() {
+ viewModelScope.launch {
+ try {
+ oathManager.corruptDatabase()
+ _uiState.update {
+ it.copy(message = "OATH database corrupted. Restart app to test recovery.")
+ }
+ } catch (e: Exception) {
+ _uiState.update { it.copy(error = "Failed to corrupt OATH database: ${e.message}") }
+ }
+ }
+ }
+
+ /**
+ * Simulates making the PUSH database read-only for testing error handling.
+ */
+ fun simulatePushDatabaseReadOnly() {
+ viewModelScope.launch {
+ try {
+ pushManager.makeDatabaseReadOnly()
+ _uiState.update {
+ it.copy(message = "Push database is now read-only. Restart app to test error handling.")
+ }
+ } catch (e: Exception) {
+ _uiState.update { it.copy(error = "Failed to make Push DB read-only: ${e.message}") }
+ }
+ }
+ }
+
+ /**
+ * Simulates corrupting the PUSH database for testing error handling.
+ */
+ fun simulatePushDatabaseCorruption() {
+ viewModelScope.launch {
+ try {
+ pushManager.corruptDatabase()
+ _uiState.update {
+ it.copy(message = "PUSH database corrupted. Restart app to test error handling.")
+ }
+ } catch (e: Exception) {
+ _uiState.update { it.copy(error = "Failed to corrupt PUSH DB: ${e.message}") }
+ }
+ }
+ }
+
+ /**
+ * Clears all backup files for both OATH and PUSH.
+ */
+ fun clearAllBackups() {
+ viewModelScope.launch {
+ try {
+ val oathCleared = oathManager.clearBackups()
+ val pushCleared = pushManager.clearBackups()
+ val total = oathCleared + pushCleared
+ _uiState.update {
+ it.copy(message = "Cleared $total backup file(s) ($oathCleared OATH, $pushCleared PUSH)")
+ }
+ } catch (e: Exception) {
+ _uiState.update { it.copy(error = "Failed to clear backups: ${e.message}") }
+ }
+ }
+ }
+
+ /**
+ * Creates manual backups for both OATH and PUSH databases.
+ */
+ fun createManualBackups() {
+ viewModelScope.launch {
+ try {
+ var oathBackupCount = 0
+ var pushBackupCount = 0
+
+ // Get current backup counts
+ val oathInfo = oathManager.getDatabaseInfo()
+ val pushInfo = pushManager.getDatabaseInfo()
+
+ val beforeOathCount = oathInfo.backupCount
+ val beforePushCount = pushInfo.backupCount
+
+ // Create OATH backup
+ try {
+ oathManager.createManualBackup()
+ val afterOathInfo = oathManager.getDatabaseInfo()
+ oathBackupCount = afterOathInfo.backupCount - beforeOathCount
+ } catch (e: Exception) {
+ diagnosticLogger.w("Failed to create OATH backup: ${e.message}")
+ }
+
+ // Create PUSH backup
+ try {
+ pushManager.createManualBackup()
+ val afterPushInfo = pushManager.getDatabaseInfo()
+ pushBackupCount = afterPushInfo.backupCount - beforePushCount
+ } catch (e: Exception) {
+ diagnosticLogger.w("Failed to create PUSH backup: ${e.message}")
+ }
+
+ val message = buildString {
+ append("Manual backup created successfully!")
+ if (oathBackupCount > 0 || pushBackupCount > 0) {
+ append(" (")
+ if (oathBackupCount > 0) append("OATH: +$oathBackupCount")
+ if (oathBackupCount > 0 && pushBackupCount > 0) append(", ")
+ if (pushBackupCount > 0) append("PUSH: +$pushBackupCount")
+ append(")")
+ }
+ }
+
+ _uiState.update { it.copy(message = message) }
+ } catch (e: Exception) {
+ _uiState.update { it.copy(error = "Failed to create backups: ${e.message}") }
+ }
+ }
+ }
+
+ /**
+ * Gets database information for display.
+ */
+ fun getDatabaseInfo(callback: (DatabaseInfo) -> Unit) {
+ viewModelScope.launch {
+ try {
+ val oathInfo = oathManager.getDatabaseInfo()
+ val pushInfo = pushManager.getDatabaseInfo()
+
+ val info = DatabaseInfo(
+ oathDbPath = oathInfo.path,
+ oathDbSize = oathInfo.size,
+ oathBackupCount = oathInfo.backupCount,
+ pushDbPath = pushInfo.path,
+ pushDbSize = pushInfo.size,
+ pushBackupCount = pushInfo.backupCount
+ )
+ callback(info)
+ } catch (e: Exception) {
+ _uiState.update { it.copy(error = "Failed to get database info: ${e.message}") }
+ }
+ }
+ }
+
+ /**
+ * Sets the initialization error state.
+ */
+ fun setInitializationError(error: InitializationError) {
+ _uiState.update { it.copy(initializationError = error) }
+ }
+
+ /**
+ * Attempts to restore from backup.
+ */
+ suspend fun attemptRestoreFromBackup(): Result {
+ val initError = _uiState.value.initializationError ?: return Result.failure(
+ IllegalStateException("No initialization error present")
+ )
+
+ return try {
+ var oathRestored = true
+ var pushRestored = true
+
+ // Check which components need restoration based on error list
+ val hasOathError = initError.errors.any { it.component == "OATH" }
+ val hasPushError = initError.errors.any { it.component == "Push" }
+
+ // Restore OATH if it failed
+ if (hasOathError) {
+ oathRestored = oathManager.restoreFromBackup(getApplication())
+ diagnosticLogger.i("OATH restore result: $oathRestored")
+ }
+
+ // Restore Push if it failed
+ if (hasPushError) {
+ pushRestored = pushManager.restoreFromBackup(getApplication())
+ diagnosticLogger.i("Push restore result: $pushRestored")
+ }
+
+ val success = oathRestored && pushRestored
+ if (success) {
+ diagnosticLogger.i("Successfully restored from backup")
+ Result.success(true)
+ } else {
+ val failedComponents = mutableListOf()
+ if (hasOathError && !oathRestored) failedComponents.add("OATH")
+ if (hasPushError && !pushRestored) failedComponents.add("Push")
+ Result.failure(Exception("Backup restoration failed for: ${failedComponents.joinToString(", ")}"))
+ }
+ } catch (e: Exception) {
+ diagnosticLogger.e("Error during backup restoration", e)
+ Result.failure(e)
+ }
+ }
+
+ /**
+ * Enables destructive recovery and triggers app restart.
+ * This will delete corrupted databases and start fresh.
+ */
+ suspend fun enableDestructiveRecoveryAndRestart(): Result {
+ return try {
+ // Enable destructive recovery in settings
+ userPreferences.setDestructiveRecovery(true)
+ diagnosticLogger.i("Destructive recovery enabled - app will restart")
+ Result.success(Unit)
+ } catch (e: Exception) {
+ diagnosticLogger.e("Error enabling destructive recovery", e)
+ Result.failure(e)
+ }
+ }
+
+ /**
+ * Sets the destructive recovery setting.
+ */
+ fun setDestructiveRecovery(enabled: Boolean) {
+ viewModelScope.launch {
+ userPreferences.setDestructiveRecovery(enabled)
+ }
+ }
+
+ /**
+ * Updates the error message in the UI state.
+ */
+ private fun updateErrorMessage(throwable: Throwable, message: String) {
+ val errorMessage = when {
+ throwable is DuplicateCredentialException || throwable.cause is DuplicateCredentialException -> {
+ val dupException = (throwable as? DuplicateCredentialException)
+ ?: (throwable.cause as DuplicateCredentialException)
+ "Account already exists: ${dupException.issuer} - ${dupException.accountName}"
+ }
+
+ else -> throwable.message ?: message
+ }
+ _uiState.update { it.copy(error = errorMessage) }
+ }
+}
+
+/**
+ * Data class representing the UI state of the Authenticator app.
+ */
+data class AuthenticatorUiState(
+ val oathCredentials: List = emptyList(),
+ val pushCredentials: List = emptyList(),
+ val accountGroups: List = emptyList(),
+ val generatedCodes: Map = emptyMap(),
+ val pushNotifications: List = emptyList(),
+ val pendingNotifications: List = emptyList(),
+ val pushNotificationItems: List = emptyList(),
+ val pendingNotificationItems: List = emptyList(),
+ val lastAddedOathCredential: OathCredential? = null,
+ val lastAddedPushCredential: PushCredential? = null,
+ val error: String? = null,
+ val message: String? = null,
+ val initializationError: InitializationError? = null,
+ // Loading states for better UX
+ val isInitialLoading: Boolean = false,
+ val isRefreshing: Boolean = false,
+ val isLoadingOathCredentials: Boolean = false,
+ val isLoadingPushCredentials: Boolean = false,
+ val isLoadingNotifications: Boolean = false
+)
diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/data/BackupModels.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/data/BackupModels.kt
new file mode 100644
index 00000000..08bc54e4
--- /dev/null
+++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/data/BackupModels.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2026 Ping Identity Corporation. All rights reserved.
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license. See the LICENSE file for details.
+ */
+
+package com.pingidentity.authenticatorapp.data
+
+/**
+ * Represents information about a backup file.
+ */
+data class BackupFileInfo(
+ val name: String,
+ val sizeBytes: Long,
+ val timestamp: Long
+)
+
+/**
+ * Represents information about a database.
+ */
+data class DatabaseInfo(
+ val oathDbPath: String,
+ val oathDbSize: Long,
+ val oathBackupCount: Int,
+ val pushDbPath: String,
+ val pushDbSize: Long,
+ val pushBackupCount: Int
+)
diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/data/DiagnosticLogger.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/data/DiagnosticLogger.kt
new file mode 100644
index 00000000..6de78a7f
--- /dev/null
+++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/data/DiagnosticLogger.kt
@@ -0,0 +1,121 @@
+/*
+ * Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license. See the LICENSE file for details.
+ */
+
+package com.pingidentity.authenticatorapp.data
+
+import android.annotation.SuppressLint
+import com.pingidentity.logger.Logger
+import com.pingidentity.logger.Standard
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.UUID.randomUUID
+import java.util.concurrent.ConcurrentLinkedQueue
+
+/**
+ * Data class representing a log entry.
+ */
+data class LogEntry(
+ val id: String = randomUUID().toString(),
+ val timestamp: String,
+ val level: String,
+ val message: String,
+ val throwable: String? = null
+)
+
+/**
+ * Diagnostic logger that captures logs in memory for debugging purposes.
+ * This logger wraps the standard logger and also stores logs for later viewing.
+ */
+object DiagnosticLogger : Logger {
+ private val standardLogger = Standard()
+ private val logEntries = ConcurrentLinkedQueue()
+
+ @SuppressLint("ConstantLocale")
+ private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault())
+
+ private const val MAX_LOG_ENTRIES = 1000
+
+ private val _logs = MutableStateFlow>(emptyList())
+ val logs: StateFlow> = _logs.asStateFlow()
+
+ private fun addLogEntry(level: String, message: String, throwable: Throwable? = null) {
+ val timestamp = dateFormat.format(Date())
+ val throwableString = throwable?.let {
+ "${it.javaClass.simpleName}: ${it.message}\n${it.stackTraceToString()}"
+ }
+
+ val logEntry = LogEntry(
+ timestamp = timestamp,
+ level = level,
+ message = message,
+ throwable = throwableString
+ )
+
+ logEntries.add(logEntry)
+
+ // Keep only the last MAX_LOG_ENTRIES entries
+ while (logEntries.size > MAX_LOG_ENTRIES) {
+ logEntries.poll()
+ }
+
+ // Update the StateFlow
+ _logs.value = logEntries.toList()
+ }
+
+ override fun d(message: String) {
+ standardLogger.d(message)
+ addLogEntry("DEBUG", message)
+ }
+
+ override fun i(message: String) {
+ standardLogger.i(message)
+ addLogEntry("INFO", message)
+ }
+
+ override fun w(message: String, throwable: Throwable?) {
+ standardLogger.w(message, throwable)
+ addLogEntry("WARN", message, throwable)
+ }
+
+ override fun e(message: String, throwable: Throwable?) {
+ standardLogger.e(message, throwable)
+ addLogEntry("ERROR", message, throwable)
+ }
+
+ /**
+ * Clear all captured log entries.
+ */
+ fun clearLogs() {
+ logEntries.clear()
+ _logs.value = emptyList()
+ }
+
+ /**
+ * Export all logs as a formatted string.
+ */
+ fun exportLogs(): String {
+ val sb = StringBuilder()
+ sb.appendLine("=== Diagnostic Logs Export ===")
+ sb.appendLine("Exported at: ${dateFormat.format(Date())}")
+ sb.appendLine("Total entries: ${logEntries.size}")
+ sb.appendLine()
+
+ logEntries.forEach { entry ->
+ sb.appendLine("[${entry.timestamp}] ${entry.level}: ${entry.message}")
+ entry.throwable?.let { throwable ->
+ sb.appendLine("Exception: $throwable")
+ }
+ sb.appendLine()
+ }
+
+ return sb.toString()
+ }
+}
\ No newline at end of file
diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/data/LoginViewModel.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/data/LoginViewModel.kt
new file mode 100644
index 00000000..4dbae112
--- /dev/null
+++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/data/LoginViewModel.kt
@@ -0,0 +1,460 @@
+/*
+ * Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license. See the LICENSE file for details.
+ */
+
+package com.pingidentity.authenticatorapp.data
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import com.pingidentity.authenticatorapp.managers.JourneyManager
+import com.pingidentity.authenticatorapp.managers.OathManager
+import com.pingidentity.authenticatorapp.managers.PushManager
+import com.pingidentity.journey.callback.HiddenValueCallback
+import com.pingidentity.journey.plugin.callbacks
+import com.pingidentity.journey.user
+import com.pingidentity.mfa.commons.UriScheme
+import com.pingidentity.orchestrate.ContinueNode
+import com.pingidentity.orchestrate.Node
+import com.pingidentity.utils.Result
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.serialization.json.jsonPrimitive
+
+/* Constant for the default Journey name used for MFA registration.
+ * This should match the name of the Journey configured in the PingAM / PingAIC Identity platforms.
+ */
+private const val AUTHENTICATOR_AUTH = "Authenticator-Authn"
+
+/* Constant for the ID of the HiddenValueCallback used for MFA device registration.
+ * This should match the ID for the callbacks created by Push Registration, Oath Registration,
+ * and Combined MFA Registration nodes.
+ */
+private const val MFA_CALLBACK_ID = "mfaDeviceRegistration"
+
+/**
+ * ViewModel for handling Journey-based authentication and credential enrollment
+ */
+class LoginViewModel(
+ application: Application,
+ private val journeyManager: JourneyManager,
+ private val oathManager: OathManager,
+ private val pushManager: PushManager
+) : AndroidViewModel(application), ViewModelProvider.Factory {
+
+ private val diagnosticLogger = DiagnosticLogger
+
+ private val _uiState = MutableStateFlow(LoginUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ // Track credentials added during this Journey session
+ private val journeyCredentialIds = mutableSetOf()
+
+ init {
+ // Observe Journey manager state and update UI accordingly
+ setupStateFlows()
+ }
+
+ /**
+ * Sets up state flows to observe Journey manager state changes.
+ */
+ private fun setupStateFlows() {
+ viewModelScope.launch {
+ journeyManager.currentNode.collect { node ->
+ _uiState.value = _uiState.value.copy(currentNode = node)
+ // Handle MFA registration and polling logic when node changes
+ if (node is ContinueNode) {
+ handleContinueNode(node)
+ }
+ }
+ }
+
+ viewModelScope.launch {
+ journeyManager.isLoading.collect { isLoading ->
+ _uiState.value = _uiState.value.copy(isLoading = isLoading)
+ }
+ }
+
+ viewModelScope.launch {
+ journeyManager.isPolling.collect { isPolling ->
+ _uiState.value = _uiState.value.copy(isPolling = isPolling)
+ }
+ }
+
+ viewModelScope.launch {
+ journeyManager.isSuccess.collect { isSuccess ->
+ _uiState.value = _uiState.value.copy(isSuccess = isSuccess)
+ if (isSuccess) {
+ handleSuccessNode()
+ }
+ }
+ }
+
+ viewModelScope.launch {
+ journeyManager.error.collect { error ->
+ _uiState.value = _uiState.value.copy(error = error)
+ }
+ }
+
+ viewModelScope.launch {
+ journeyManager.message.collect { message ->
+ _uiState.value = _uiState.value.copy(message = message)
+ }
+ }
+ }
+
+ /**
+ * Starts the journey authentication flow
+ */
+ fun startJourney(journeyName: String = AUTHENTICATOR_AUTH) {
+ viewModelScope.launch {
+ journeyManager.startJourney(journeyName)
+ }
+ }
+
+ /**
+ * Continues the journey flow with the current node
+ */
+ fun nextStep() {
+ viewModelScope.launch {
+ journeyManager.continueJourney()
+ }
+ }
+
+ /**
+ * Refreshes the current node state (useful when callbacks are updated)
+ */
+ fun refreshNode() {
+ _uiState.value = _uiState.value.copy(currentNode = journeyManager.currentNode.value)
+ }
+
+ /**
+ * Handles continue nodes, processing callbacks and polling
+ */
+ private suspend fun handleContinueNode(node: ContinueNode) {
+ diagnosticLogger.d("Processing continue node with ${node.callbacks.size} callbacks")
+
+ // Check for MFA registration callback first (highest priority)
+ val hiddenValueCallback = node.callbacks
+ .filterIsInstance()
+ .find { it.id == MFA_CALLBACK_ID }
+
+ if (hiddenValueCallback != null && hiddenValueCallback.value.isNotEmpty()) {
+ diagnosticLogger.d("Found MFA device registration URI - processing immediately")
+ // Set MFA registration state to hide callbacks and show loading message
+ _uiState.value = _uiState.value.copy(
+ isMfaRegistering = true,
+ message = "Registering MFA credentials..."
+ )
+
+ handleMfaRegistration(hiddenValueCallback.value)
+ return
+ }
+
+ // Check for polling wait callback
+ val pollingCallback = journeyManager.getPollingCallback(node)
+ if (pollingCallback != null) {
+ diagnosticLogger.d("Polling callback found - waiting for credential registration to complete")
+ val message = pollingCallback.message.ifEmpty { "Waiting for credential registration..." }
+ journeyManager.setPollingState(true, message)
+
+ // Instead of using the server's wait time, use a shorter interval to check more frequently
+ // for credential registration completion
+ diagnosticLogger.d("Using shorter polling interval (3s) instead of server suggested ${pollingCallback.waitTime}ms")
+ delay(3000L)
+
+ journeyManager.setPollingState(false)
+ journeyManager.continueJourney()
+ return
+ }
+ }
+
+ /**
+ * Handles MFA credential registration from URI
+ */
+ private suspend fun handleMfaRegistration(uri: String) {
+ diagnosticLogger.d("Processing MFA registration URI: ${maskUri(uri)}")
+
+ try {
+ when {
+ uri.startsWith(UriScheme.OTPAUTH.value) -> {
+ // OATH credential registration
+ val result = oathManager.addCredentialFromUri(uri)
+ result.onSuccess { credential ->
+ diagnosticLogger.d("Successfully added OATH credential: ${credential.issuer}/${credential.accountName}")
+ journeyCredentialIds.add(credential.id) // Track this credential as Journey-registered
+ journeyManager.setMessage("OATH credential registered - continuing journey...")
+ // Clear MFA registration state and continue the journey
+ _uiState.value = _uiState.value.copy(isMfaRegistering = false)
+ journeyManager.continueJourney()
+ }.onFailure { exception ->
+ diagnosticLogger.e("Failed to add OATH credential", exception)
+ // Clear MFA registration state on failure
+ _uiState.value = _uiState.value.copy(isMfaRegistering = false)
+ journeyManager.setError("Failed to register OATH credential: ${exception.message}")
+ }
+ }
+
+ uri.startsWith(UriScheme.PUSHAUTH.value) -> {
+ // Push credential registration
+ val result = pushManager.addCredentialFromUri(uri)
+ result.onSuccess { credential ->
+ diagnosticLogger.d("Successfully added Push credential: ${credential.issuer}/${credential.accountName}")
+ journeyCredentialIds.add(credential.id) // Track this credential as Journey-registered
+ journeyManager.setMessage("Push credential registered - continuing journey...")
+ // Clear MFA registration state and continue the journey
+ _uiState.value = _uiState.value.copy(isMfaRegistering = false)
+ journeyManager.continueJourney()
+ }.onFailure { exception ->
+ diagnosticLogger.e("Failed to add Push credential", exception)
+ // Clear MFA registration state on failure
+ _uiState.value = _uiState.value.copy(isMfaRegistering = false)
+ journeyManager.setError("Failed to register Push credential: ${exception.message}")
+ }
+ }
+
+ uri.startsWith(UriScheme.MFAUTH.value) -> {
+ // Combined MFA registration - try both
+ journeyManager.setMessage("Registering combined MFA credentials...")
+
+ var oathSuccess = false
+ var pushSuccess = false
+ var lastError: Throwable? = null
+
+ // Try OATH first
+ val oathResult = oathManager.addCredentialFromUri(uri)
+ oathResult.onSuccess { credential ->
+ oathSuccess = true
+ journeyCredentialIds.add(credential.id) // Track this credential as Journey-registered
+ diagnosticLogger.d("Successfully added OATH credential from combined URI")
+ }.onFailure {
+ lastError = it
+ diagnosticLogger.w("Failed to add OATH credential from combined URI", it)
+ }
+
+ // Try Push second
+ val pushResult = pushManager.addCredentialFromUri(uri)
+ pushResult.onSuccess { credential ->
+ pushSuccess = true
+ journeyCredentialIds.add(credential.id) // Track this credential as Journey-registered
+ diagnosticLogger.d("Successfully added Push credential from combined URI")
+ }.onFailure {
+ lastError = it
+ diagnosticLogger.w("Failed to add Push credential from combined URI", it)
+ }
+
+ // Determine final result
+ when {
+ oathSuccess && pushSuccess -> {
+ diagnosticLogger.d("Both OATH and Push credentials registered successfully")
+ journeyManager.setMessage("Both credentials registered - continuing journey...")
+ // Clear MFA registration state and continue the journey
+ _uiState.value = _uiState.value.copy(isMfaRegistering = false)
+ journeyManager.continueJourney()
+ }
+ oathSuccess || pushSuccess -> {
+ val type = if (oathSuccess) "OATH" else "Push"
+ diagnosticLogger.d("$type credential registered successfully (partial success)")
+ journeyManager.setMessage("$type credential registered - continuing journey...")
+ // Clear MFA registration state and continue the journey
+ _uiState.value = _uiState.value.copy(isMfaRegistering = false)
+ journeyManager.continueJourney()
+ }
+ else -> {
+ // Clear MFA registration state on failure
+ _uiState.value = _uiState.value.copy(isMfaRegistering = false)
+ journeyManager.setError("Failed to register MFA credentials: ${lastError?.message ?: "Unknown error"}")
+ }
+ }
+ }
+
+ else -> {
+ diagnosticLogger.w("Unsupported URI scheme: $uri")
+ // Clear MFA registration state on unsupported URI
+ _uiState.value = _uiState.value.copy(isMfaRegistering = false)
+ journeyManager.setError("Unsupported credential type")
+ }
+ }
+ } catch (e: Exception) {
+ diagnosticLogger.e("Unexpected error during MFA registration", e)
+ // Clear MFA registration state on unexpected error
+ _uiState.value = _uiState.value.copy(isMfaRegistering = false)
+ journeyManager.setError("Unexpected error: ${e.message}")
+ }
+ }
+
+ /**
+ * Handles successful authentication
+ */
+ private fun handleSuccessNode() {
+ diagnosticLogger.d("Journey completed successfully")
+
+ // Associate userId with Journey-registered credentials
+ viewModelScope.launch {
+ try {
+ associateUserWithCredentials()
+ } catch (e: Exception) {
+ diagnosticLogger.w("Failed to associate userId with Journey-registered credentials", e)
+ // Don't fail the whole authentication flow for this issue
+ }
+ }
+ }
+
+ /**
+ * Logs out the current user (if any)
+ */
+ fun logout() {
+ viewModelScope.launch {
+ journeyManager.logout()
+ }
+ }
+
+
+ /**
+ * Associate credentials registered during this Journey with the authenticated user
+ * by setting their userId to enable user session functionality.
+ */
+ private suspend fun associateUserWithCredentials() {
+ try {
+ // If no credentials were registered during this Journey, nothing to do
+ if (journeyCredentialIds.isEmpty()) {
+ diagnosticLogger.d("No credentials registered during this Journey - skipping user session association")
+ return
+ }
+
+ // Get the user ID from the Journey session
+ val journey = journeyManager.getJourneyClient()
+ if (journey == null) {
+ diagnosticLogger.w("No Journey client available - cannot mark credentials as user session enabled")
+ return
+ }
+
+ val user = journey.user()
+ if (user == null) {
+ diagnosticLogger.w("No user available from Journey - cannot mark credentials as user session enabled")
+ return
+ }
+
+ val userInfo = user.userinfo(cache = false)
+ val userId = when (userInfo) {
+ is Result.Success -> {
+ val sub = userInfo.value["sub"]?.jsonPrimitive?.content
+ if (sub == null) {
+ diagnosticLogger.w("No 'sub' field in user info - cannot get user ID")
+ return
+ }
+ sub
+ }
+ is Result.Failure -> {
+ diagnosticLogger.w("Failed to get user info: ${userInfo.value}")
+ return
+ }
+ }
+
+ // Get all credentials and filter to only those registered during this Journey session
+ val oathCredentialsResult = oathManager.loadCredentials()
+ val pushCredentialsResult = pushManager.loadCredentials()
+
+ val oathCredentials = oathCredentialsResult.getOrNull() ?: emptyList()
+ val pushCredentials = pushCredentialsResult.getOrNull() ?: emptyList()
+
+ // Filter to only credentials that were registered during this Journey session and have no userId set
+ val oathCredentialsToUpdate = oathCredentials.filter { it.id in journeyCredentialIds && it.userId == null }
+ val pushCredentialsToUpdate = pushCredentials.filter { it.id in journeyCredentialIds && it.userId == null }
+
+ // If no credentials need updating, nothing to do
+ if (oathCredentialsToUpdate.isEmpty() && pushCredentialsToUpdate.isEmpty()) {
+ diagnosticLogger.d("No Journey-registered credentials found that need user session association")
+ return
+ }
+
+ // Update each credential to set the userId
+ diagnosticLogger.d("Found ${oathCredentialsToUpdate.size} OATH credentials and ${pushCredentialsToUpdate.size} Push credentials to update")
+ diagnosticLogger.d("Associating userId [$userId] with Journey-registered credentials")
+
+ // Update OATH credentials
+ oathCredentialsToUpdate.forEach { credential ->
+ val updatedCredential = credential.copy(userId = userId)
+ val result = oathManager.updateCredential(updatedCredential)
+ result.onSuccess {
+ diagnosticLogger.d("Updated OATH credential ${credential.id} for user session")
+ }.onFailure {
+ diagnosticLogger.w("Failed to update OATH credential ${credential.id}", it)
+ }
+ }
+
+ // Update Push credentials
+ pushCredentialsToUpdate.forEach { credential ->
+ val updatedCredential = credential.copy(userId = userId)
+ val result = pushManager.updateCredential(updatedCredential)
+ result.onSuccess {
+ diagnosticLogger.d("Updated Push credential ${credential.id} for user session")
+ }.onFailure {
+ diagnosticLogger.w("Failed to update Push credential ${credential.id}", it)
+ }
+ }
+
+ val totalUpdated = oathCredentialsToUpdate.size + pushCredentialsToUpdate.size
+ if (totalUpdated > 0) {
+ diagnosticLogger.d("Successfully associated $totalUpdated credentials with the user")
+ }
+ } catch (e: Exception) {
+ diagnosticLogger.e("Unexpected error associating user with Journey-registered credentials", e)
+ throw e
+ }
+ }
+
+ /**
+ * Resets the login state
+ */
+ fun reset() {
+ journeyCredentialIds.clear() // Clear tracked credential IDs
+ journeyManager.reset()
+ _uiState.value = LoginUiState()
+ }
+
+ /**
+ * Called when the ViewModel is being cleared, typically when the owner activity/fragment
+ * is destroyed. This ensures proper cleanup of resources.
+ */
+ override fun onCleared() {
+ super.onCleared()
+
+ try {
+ // Close Journey manager to release resources, let other managers be handled
+ // by Authenticator ViewModel
+ journeyManager.close()
+ diagnosticLogger.d("LoginViewModel cleared and resources released")
+ } catch (e: Exception) {
+ // Log any errors during cleanup
+ diagnosticLogger.e("Error closing manager", e)
+ }
+ }
+
+ /**
+ * Masks sensitive information in URIs for logging
+ */
+ private fun maskUri(uri: String): String {
+ return uri.replace(Regex("secret=[^&]*"), "secret=*****")
+ }
+}
+
+/**
+ * UI state for the login screen
+ */
+data class LoginUiState(
+ val isLoading: Boolean = false,
+ val isPolling: Boolean = false,
+ val isSuccess: Boolean = false,
+ val error: String? = null,
+ val message: String? = null,
+ val currentNode: Node? = null,
+ val isMfaRegistering: Boolean = false
+)
diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/data/UiModels.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/data/UiModels.kt
new file mode 100644
index 00000000..c25f87cb
--- /dev/null
+++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/data/UiModels.kt
@@ -0,0 +1,399 @@
+/*
+ * Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license. See the LICENSE file for details.
+ */
+
+package com.pingidentity.authenticatorapp.data
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import com.pingidentity.authenticatorapp.R
+import com.pingidentity.authenticatorapp.util.getTimeAgoString
+import com.pingidentity.mfa.commons.policy.BiometricAvailablePolicy
+import com.pingidentity.mfa.commons.policy.DeviceTamperingPolicy
+import com.pingidentity.mfa.oath.OathCredential
+import com.pingidentity.mfa.push.PushCredential
+import com.pingidentity.mfa.push.PushNotification
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+
+private val jsonParser = Json { ignoreUnknownKeys = true }
+
+/**
+ * Enum representing the status of a push notification.
+ */
+enum class NotificationStatus {
+ PENDING,
+ APPROVED,
+ DENIED,
+ EXPIRED
+}
+
+/**
+ * Data class to represent a group of credentials (OATH or Push) with the same issuer/account.
+ * This is used to display a unified account view regardless of authentication method.
+ */
+data class AccountGroup(
+ val issuer: String,
+ val accountName: String,
+ val displayIssuer: String,
+ val displayAccountName: String,
+ val oathCredentials: List = emptyList(),
+ val pushCredentials: List = emptyList()
+) {
+ /**
+ * Check if this account group has any locked credentials.
+ */
+ val isLocked: Boolean
+ get() = oathCredentials.any { it.isLocked } || pushCredentials.any { it.isLocked }
+
+ /**
+ * Get the locking policy name from the first locked credential.
+ */
+ val lockingPolicy: String?
+ get() = oathCredentials.firstOrNull { it.isLocked }?.lockingPolicy
+ ?: pushCredentials.firstOrNull { it.isLocked }?.lockingPolicy
+}
+
+/**
+ * Data class for push notification UI display with additional UI-specific fields.
+ */
+data class PushNotificationItem(
+ val notification: PushNotification,
+ val credential: PushCredential? = null,
+ val timeAgo: String = "",
+ val requiresChallenge: Boolean = false,
+ val requiresBiometric: Boolean = false,
+ val hasLocationInfo: Boolean = false,
+ val latitude: Double? = null,
+ val longitude: Double? = null,
+ val status: NotificationStatus = NotificationStatus.PENDING,
+ val deviceInfo: DeviceInfo? = null
+)
+
+/**
+ * Data class to represent location data parsed from contextInfo JSON.
+ */
+@Serializable
+private data class LocationData(
+ val latitude: Double,
+ val longitude: Double
+)
+
+@Serializable
+private data class ContextWrapper(
+ @SerialName("location") val location: LocationData? = null,
+ @SerialName("remoteIp") val remoteIp: String? = null,
+ @SerialName("userAgent") val userAgent: String? = null
+)
+
+/**
+ * Extension function to convert a list of push notifications into UI items with additional info.
+ */
+fun List.toUiItems(pushCredentials: List): List {
+ return map {
+ // Find the credential associated with this notification
+ createPushNotificationItem(pushCredentials, it)
+ }
+}
+
+/**
+ * Function to create a PushNotificationItem from a PushNotification and its associated credentials.
+ *
+ */
+fun createPushNotificationItem(
+ pushCredentials: List,
+ notification: PushNotification
+): PushNotificationItem {
+ val credential = pushCredentials.find { it.id == notification.credentialId }
+
+ // Calculate time ago string
+ val timeAgo = getTimeAgoString(notification.createdAt)
+
+ // Determine notification characteristics based on push type
+ val pushTypeStr = notification.pushType.toString()
+ val requiresChallenge = pushTypeStr.lowercase().contains("challenge")
+ val requiresBiometric = pushTypeStr.lowercase().contains("biometric")
+
+ // Check if location info is available
+ var hasLocationInfo = false
+ var latitude: Double? = null
+ var longitude: Double? = null
+ var deviceInfo: DeviceInfo? = null
+
+ notification.contextInfo?.let { contextInfoString ->
+ val unescapedContextInfo = contextInfoString.replace("\\\"","\"")
+ try {
+ val parsedContext = jsonParser.decodeFromString(unescapedContextInfo)
+ parsedContext.location?.let {
+ latitude = it.latitude
+ longitude = it.longitude
+ hasLocationInfo = true
+ }
+ parsedContext.userAgent?.let { userAgent ->
+ deviceInfo = parseUserAgent(userAgent)
+ }
+ } catch (_: Exception) {
+ // Ignore errors from decodeFromString if content is not valid JSON
+ }
+ }
+
+ // Determine notification status
+ val status = when {
+ notification.approved -> NotificationStatus.APPROVED
+ (notification.expired && notification.pending) -> NotificationStatus.EXPIRED
+ notification.pending -> NotificationStatus.PENDING
+ else -> NotificationStatus.DENIED
+ }
+
+ return PushNotificationItem(
+ notification = notification,
+ credential = credential,
+ timeAgo = timeAgo,
+ requiresChallenge = requiresChallenge,
+ requiresBiometric = requiresBiometric,
+ hasLocationInfo = hasLocationInfo,
+ latitude = latitude,
+ longitude = longitude,
+ status = status,
+ deviceInfo = deviceInfo
+ )
+}
+
+/**
+ * Function to group credentials by issuer and account name.
+ *
+ * @param oathCredentials List of OATH credentials to group
+ * @param pushCredentials List of Push credentials to group
+ * @param shouldCombine Whether to combine credentials with the same issuer and account name
+ */
+fun groupCredentialsByAccount(
+ oathCredentials: List,
+ pushCredentials: List,
+ shouldCombine: Boolean = true
+): List {
+ val accountGroups = mutableMapOf, AccountGroup>()
+
+ // If we're not combining accounts, create separate account groups
+ if (!shouldCombine) {
+ val separateGroups = mutableMapOf()
+
+ // Create individual OATH account groups with unique identifiers
+ oathCredentials.forEach { credential ->
+ val groupKey = "${credential.issuer}-${credential.accountName}-oath-${credential.id}"
+ separateGroups[groupKey] = AccountGroup(
+ issuer = credential.issuer,
+ accountName = credential.accountName,
+ displayIssuer = credential.displayIssuer,
+ displayAccountName = credential.displayAccountName,
+ oathCredentials = listOf(credential),
+ pushCredentials = emptyList()
+ )
+ }
+
+ // Create individual Push account groups with unique identifiers
+ pushCredentials.forEach { credential ->
+ val groupKey = "${credential.issuer}-${credential.accountName}-push-${credential.id}"
+ separateGroups[groupKey] = AccountGroup(
+ issuer = credential.issuer,
+ accountName = credential.accountName,
+ displayIssuer = credential.displayIssuer,
+ displayAccountName = credential.displayAccountName,
+ oathCredentials = emptyList(),
+ pushCredentials = listOf(credential)
+ )
+ }
+
+ return separateGroups.values.toList()
+ }
+
+ // Otherwise, combine accounts with the same issuer and account name
+ // Add OATH credentials to groups
+ for (credential in oathCredentials) {
+ val key = Pair(credential.issuer, credential.accountName)
+ val existingGroup = accountGroups[key] ?: AccountGroup(
+ issuer = credential.issuer,
+ accountName = credential.accountName,
+ displayIssuer = credential.displayIssuer,
+ displayAccountName = credential.displayAccountName,
+ oathCredentials = emptyList(),
+ pushCredentials = emptyList()
+ )
+ // Update display names if the new credential has them
+ val updatedDisplayIssuer = credential.displayIssuer
+ val updatedDisplayAccountName = credential.displayAccountName
+
+ accountGroups[key] = existingGroup.copy(
+ displayIssuer = updatedDisplayIssuer,
+ displayAccountName = updatedDisplayAccountName,
+ oathCredentials = existingGroup.oathCredentials + credential
+ )
+ }
+
+ // Add Push credentials to groups
+ for (credential in pushCredentials) {
+ val key = Pair(credential.issuer, credential.accountName)
+ val existingGroup = accountGroups[key] ?: AccountGroup(
+ issuer = credential.issuer,
+ accountName = credential.accountName,
+ displayIssuer = credential.displayIssuer,
+ displayAccountName = credential.displayAccountName,
+ oathCredentials = emptyList(),
+ pushCredentials = emptyList()
+ )
+ // Update display names if the new credential has them
+ val updatedDisplayIssuer = credential.displayIssuer
+ val updatedDisplayAccountName = credential.displayAccountName
+
+ accountGroups[key] = existingGroup.copy(
+ displayIssuer = updatedDisplayIssuer,
+ displayAccountName = updatedDisplayAccountName,
+ pushCredentials = existingGroup.pushCredentials + credential
+ )
+ }
+
+ return accountGroups.values.toList()
+}
+
+/**
+ * Data class to hold parsed user agent information.
+ */
+data class DeviceInfo(
+ val userAgent: String,
+ val browser: String? = null,
+ val os: String? = null,
+ val browserVersion: String? = null
+)
+
+fun parseUserAgent(userAgent: String): DeviceInfo {
+ val browser = getBrowser(userAgent)
+ val os = getOs(userAgent)
+ val browserVersion = getBrowserVersion(userAgent, browser)
+ return DeviceInfo(userAgent, browser, os, browserVersion)
+}
+
+private fun getBrowser(userAgent: String): String {
+ return when {
+ userAgent.contains("Chrome") -> "Chrome"
+ userAgent.contains("Firefox") -> "Firefox"
+ userAgent.contains("Safari") -> "Safari"
+ userAgent.contains("Edge") -> "Edge"
+ userAgent.contains("MSIE") || userAgent.contains("Trident") -> "Internet Explorer"
+ else -> "Unknown"
+ }
+}
+
+private fun getOs(userAgent: String): String {
+ return when {
+ userAgent.contains("Windows") -> "Windows"
+ userAgent.contains("Macintosh") -> "macOS"
+ userAgent.contains("Linux") -> "Linux"
+ userAgent.contains("Android") -> "Android"
+ userAgent.contains("iPhone") || userAgent.contains("iPad") -> "iOS"
+ else -> "Unknown"
+ }
+}
+
+private fun getBrowserVersion(userAgent: String, browser: String): String? {
+ return try {
+ val regex = when (browser) {
+ "Chrome" -> "Chrome/(\\S+)"
+ "Firefox" -> "Firefox/(\\S+)"
+ "Safari" -> "Version/(\\S+)"
+ "Edge" -> "Edge/(\\S+)"
+ "Internet Explorer" -> "MSIE (\\S+);|rv:(\\S+)"
+ else -> return null
+ }
+ val matchResult = Regex(regex).find(userAgent)
+ matchResult?.groups?.get(1)?.value
+ } catch (_: Exception) {
+ null
+ }
+}
+
+/**
+ * Data classes for Nominatim reverse geocoding API response
+ */
+@Serializable
+data class NominatimAddress(
+ @SerialName("city") val city: String? = null,
+ @SerialName("town") val town: String? = null,
+ @SerialName("village") val village: String? = null,
+ @SerialName("state") val state: String? = null,
+ @SerialName("state_district") val stateDistrict: String? = null,
+ @SerialName("county") val county: String? = null,
+ @SerialName("country") val country: String? = null,
+ @SerialName("country_code") val countryCode: String? = null
+)
+
+@Serializable
+data class NominatimResponse(
+ @SerialName("place_id") val placeId: Long? = null,
+ @SerialName("licence") val licence: String? = null,
+ @SerialName("osm_type") val osmType: String? = null,
+ @SerialName("osm_id") val osmId: Long? = null,
+ @SerialName("lat") val lat: String? = null,
+ @SerialName("lon") val lon: String? = null,
+ @SerialName("display_name") val displayName: String? = null,
+ @SerialName("address") val address: NominatimAddress? = null
+)
+
+/**
+ * Data class with simplified address data for UI display
+ */
+data class LocationAddress(
+ val city: String,
+ val state: String,
+ val country: String
+) {
+ companion object {
+ fun fromNominatim(response: NominatimResponse): LocationAddress? {
+ val address = response.address ?: return null
+
+ // Get city (try city, town, then village)
+ val city = address.city
+ ?: address.town
+ ?: address.village
+ ?: return null
+
+ // Get state/province (try state, then state_district, then county)
+ val state = address.state
+ ?: address.stateDistrict
+ ?: address.county
+ ?: return null
+
+ // Get country
+ val country = address.country ?: return null
+
+ return LocationAddress(
+ city = city,
+ state = state,
+ country = country
+ )
+ }
+ }
+
+ /**
+ * Format address for display in UI
+ */
+ fun formatForDisplay(): String {
+ return "$city, $state, $country"
+ }
+}
+
+/**
+ * Get the appropriate lock message for a locked account based on the locking policy.
+ * Returns the string resource ID for the appropriate message.
+ */
+@Composable
+fun getLockMessage(lockingPolicy: String?): String {
+ return when (lockingPolicy?.lowercase()) {
+ BiometricAvailablePolicy.POLICY_NAME -> stringResource(id = R.string.account_locked_biometric_available)
+ DeviceTamperingPolicy.POLICY_NAME -> stringResource(id = R.string.account_locked_device_tampering)
+ null -> stringResource(id = R.string.account_locked_unknown_policy)
+ else -> stringResource(id = R.string.account_locked_generic_policy, lockingPolicy)
+ }
+}
\ No newline at end of file
diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/data/UserPreferences.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/data/UserPreferences.kt
new file mode 100644
index 00000000..1cb5cb9b
--- /dev/null
+++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/data/UserPreferences.kt
@@ -0,0 +1,262 @@
+/*
+ * Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license. See the LICENSE file for details.
+ */
+
+package com.pingidentity.authenticatorapp.data
+
+import android.content.Context
+import android.content.SharedPreferences
+import androidx.core.content.edit
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.withContext
+
+/**
+ * Theme modes for the app
+ */
+enum class ThemeMode {
+ LIGHT,
+ DARK,
+ SYSTEM
+}
+
+/**
+ * Manages user preferences for the Authenticator app using SharedPreferences.
+ */
+class UserPreferences(context: Context) {
+
+ private val prefs: SharedPreferences = context.getSharedPreferences(
+ PREFS_NAME, Context.MODE_PRIVATE
+ )
+
+ // StateFlows for all settings
+ private val _copyOtpFlow = MutableStateFlow(isCopyOtpEnabled())
+ val copyOtpFlow: StateFlow = _copyOtpFlow
+
+ private val _tapToRevealFlow = MutableStateFlow(isTapToRevealEnabled())
+ val tapToRevealFlow: StateFlow = _tapToRevealFlow
+
+ private val _combineAccountsFlow = MutableStateFlow(isCombineAccountsEnabled())
+ val combineAccountsFlow: StateFlow = _combineAccountsFlow
+
+ private val _diagnosticLoggingFlow = MutableStateFlow(isDiagnosticLoggingEnabled())
+ val diagnosticLoggingFlow: StateFlow = _diagnosticLoggingFlow
+
+ private val _testModeFlow = MutableStateFlow(isTestModeEnabled())
+ val testModeFlow: StateFlow = _testModeFlow
+
+ private val _themeModeFlow = MutableStateFlow(getThemeMode())
+ val themeModeFlow: StateFlow = _themeModeFlow
+
+ private val _destructiveRecoveryFlow = MutableStateFlow(isDestructiveRecoveryEnabled())
+ val destructiveRecoveryFlow: StateFlow = _destructiveRecoveryFlow
+
+ private val _autoRestoreFromBackupFlow = MutableStateFlow(isAutoRestoreFromBackupEnabled())
+ val autoRestoreFromBackupFlow: StateFlow = _autoRestoreFromBackupFlow
+
+ /**
+ * Check if copy OTP on tap is enabled.
+ * Defaults to false if not set.
+ */
+ fun isCopyOtpEnabled(): Boolean {
+ return prefs.getBoolean(KEY_COPY_OTP, false)
+ }
+
+ /**
+ * Set whether copy OTP on tap is enabled.
+ */
+ suspend fun setCopyOtp(enabled: Boolean) {
+ withContext(Dispatchers.IO) {
+ prefs.edit {
+ putBoolean(KEY_COPY_OTP, enabled)
+ }
+ _copyOtpFlow.value = enabled
+ }
+ }
+
+ /**
+ * Check if tap to reveal is enabled.
+ * Defaults to false if not set.
+ */
+ fun isTapToRevealEnabled(): Boolean {
+ return prefs.getBoolean(KEY_TAP_TO_REVEAL, false)
+ }
+
+ /**
+ * Set whether tap to reveal is enabled.
+ */
+ suspend fun setTapToReveal(enabled: Boolean) {
+ withContext(Dispatchers.IO) {
+ prefs.edit {
+ putBoolean(KEY_TAP_TO_REVEAL, enabled)
+ }
+ _tapToRevealFlow.value = enabled
+ }
+ }
+
+ /**
+ * Check if accounts should be combined.
+ * Defaults to false if not set.
+ */
+ fun isCombineAccountsEnabled(): Boolean {
+ return prefs.getBoolean(KEY_COMBINE_ACCOUNTS, false)
+ }
+
+ /**
+ * Set whether accounts should be combined.
+ */
+ suspend fun setCombineAccounts(enabled: Boolean) {
+ withContext(Dispatchers.IO) {
+ prefs.edit {
+ putBoolean(KEY_COMBINE_ACCOUNTS, enabled)
+ }
+ _combineAccountsFlow.value = enabled
+ }
+ }
+
+ /**
+ * Check if diagnostic logging is enabled.
+ * Defaults to false if not set.
+ */
+ fun isDiagnosticLoggingEnabled(): Boolean {
+ return prefs.getBoolean(KEY_DIAGNOSTIC_LOGGING, false)
+ }
+
+ /**
+ * Set whether diagnostic logging is enabled.
+ */
+ suspend fun setDiagnosticLogging(enabled: Boolean) {
+ withContext(Dispatchers.IO) {
+ prefs.edit {
+ putBoolean(KEY_DIAGNOSTIC_LOGGING, enabled)
+ }
+ _diagnosticLoggingFlow.value = enabled
+ }
+ }
+
+ /**
+ * Check if test mode is enabled.
+ * Defaults to false if not set.
+ */
+ fun isTestModeEnabled(): Boolean {
+ return prefs.getBoolean(KEY_TEST_MODE, false)
+ }
+
+ /**
+ * Set whether test mode is enabled.
+ */
+ suspend fun setTestMode(enabled: Boolean) {
+ withContext(Dispatchers.IO) {
+ prefs.edit {
+ putBoolean(KEY_TEST_MODE, enabled)
+ }
+ _testModeFlow.value = enabled
+ }
+ }
+
+ /**
+ * Get the current theme mode.
+ * Defaults to SYSTEM if not set.
+ */
+ fun getThemeMode(): ThemeMode {
+ val themeName = prefs.getString(KEY_THEME_MODE, ThemeMode.SYSTEM.name) ?: ThemeMode.SYSTEM.name
+ return try {
+ ThemeMode.valueOf(themeName)
+ } catch (e: IllegalArgumentException) {
+ ThemeMode.SYSTEM
+ }
+ }
+
+ /**
+ * Set the theme mode.
+ */
+ suspend fun setThemeMode(themeMode: ThemeMode) {
+ withContext(Dispatchers.IO) {
+ prefs.edit {
+ putString(KEY_THEME_MODE, themeMode.name)
+ }
+ _themeModeFlow.value = themeMode
+ }
+ }
+
+ /**
+ * Get the saved account order as a list of account keys (issuer-accountName).
+ */
+ fun getAccountOrder(): List {
+ val orderString = prefs.getString(KEY_ACCOUNT_ORDER, "") ?: ""
+ return if (orderString.isEmpty()) {
+ emptyList()
+ } else {
+ orderString.split(ACCOUNT_ORDER_SEPARATOR)
+ }
+ }
+
+ /**
+ * Save the account order as a list of account keys (issuer-accountName).
+ */
+ suspend fun setAccountOrder(accountOrder: List) {
+ withContext(Dispatchers.IO) {
+ prefs.edit {
+ putString(KEY_ACCOUNT_ORDER, accountOrder.joinToString(ACCOUNT_ORDER_SEPARATOR))
+ }
+ }
+ }
+
+ /**
+ * Check if destructive recovery is enabled.
+ * Defaults to false for safety.
+ */
+ fun isDestructiveRecoveryEnabled(): Boolean {
+ return prefs.getBoolean(KEY_DESTRUCTIVE_RECOVERY, false)
+ }
+
+ /**
+ * Set whether destructive recovery is enabled.
+ */
+ suspend fun setDestructiveRecovery(enabled: Boolean) {
+ withContext(Dispatchers.IO) {
+ prefs.edit {
+ putBoolean(KEY_DESTRUCTIVE_RECOVERY, enabled)
+ }
+ _destructiveRecoveryFlow.value = enabled
+ }
+ }
+
+ /**
+ * Check if auto-restore from backup is enabled.
+ * Defaults to true for backward compatibility with SDK behavior.
+ */
+ fun isAutoRestoreFromBackupEnabled(): Boolean {
+ return prefs.getBoolean(KEY_AUTO_RESTORE_FROM_BACKUP, true)
+ }
+
+ /**
+ * Set whether auto-restore from backup is enabled.
+ */
+ suspend fun setAutoRestoreFromBackup(enabled: Boolean) {
+ withContext(Dispatchers.IO) {
+ prefs.edit {
+ putBoolean(KEY_AUTO_RESTORE_FROM_BACKUP, enabled)
+ }
+ _autoRestoreFromBackupFlow.value = enabled
+ }
+ }
+
+ companion object {
+ private const val PREFS_NAME = "authenticator_preferences"
+ private const val KEY_COPY_OTP = "copy_otp"
+ private const val KEY_TAP_TO_REVEAL = "tap_to_reveal"
+ private const val KEY_COMBINE_ACCOUNTS = "combine_accounts"
+ private const val KEY_DIAGNOSTIC_LOGGING = "diagnostic_logging"
+ private const val KEY_TEST_MODE = "test_mode"
+ private const val KEY_THEME_MODE = "theme_mode"
+ private const val KEY_ACCOUNT_ORDER = "account_order"
+ private const val KEY_DESTRUCTIVE_RECOVERY = "destructive_recovery"
+ private const val KEY_AUTO_RESTORE_FROM_BACKUP = "auto_restore_from_backup"
+ private const val ACCOUNT_ORDER_SEPARATOR = "|||"
+ }
+}
diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/managers/AccountGroupingManager.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/managers/AccountGroupingManager.kt
new file mode 100644
index 00000000..9d0be18b
--- /dev/null
+++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/managers/AccountGroupingManager.kt
@@ -0,0 +1,191 @@
+/*
+ * Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license. See the LICENSE file for details.
+ */
+
+package com.pingidentity.authenticatorapp.managers
+
+import com.pingidentity.authenticatorapp.data.AccountGroup
+import com.pingidentity.authenticatorapp.data.DiagnosticLogger
+import com.pingidentity.authenticatorapp.data.UserPreferences
+import com.pingidentity.authenticatorapp.data.groupCredentialsByAccount
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import com.pingidentity.mfa.oath.OathCredential
+import com.pingidentity.mfa.push.PushCredential
+
+/**
+ * Manager class for handling account grouping logic and account ordering.
+ * Encapsulates the business logic for combining and organizing account groups.
+ *
+ * @param userPreferences UserPreferences dependency for settings management
+ * @param diagnosticLogger DiagnosticLogger for logging
+ */
+class AccountGroupingManager(
+ private val userPreferences: UserPreferences,
+ private val diagnosticLogger: DiagnosticLogger
+) {
+
+ private val _accountGroups = MutableStateFlow>(emptyList())
+ val accountGroups: StateFlow> = _accountGroups.asStateFlow()
+
+ /**
+ * Updates the account groups based on current credentials and user preferences.
+ */
+ fun updateAccountGroups(
+ oathCredentials: List,
+ pushCredentials: List
+ ) {
+ val shouldCombine = userPreferences.isCombineAccountsEnabled()
+ diagnosticLogger.d("updateAccountGroups: shouldCombine=$shouldCombine")
+
+ // Skip update if we have no data to avoid unnecessary recomposition
+ if (oathCredentials.isEmpty() && pushCredentials.isEmpty()) {
+ diagnosticLogger.d("Skipping account group update - no credentials loaded yet")
+ return
+ }
+
+ val newAccountGroups = groupCredentialsByAccount(
+ oathCredentials, pushCredentials, shouldCombine
+ )
+
+ // Apply saved account order
+ val orderedAccountGroups = applyAccountOrder(newAccountGroups)
+
+ // Debug logging to help identify duplicate group issues
+ diagnosticLogger.d("updateAccountGroups: shouldCombine=$shouldCombine, " +
+ "oathCredentials=${oathCredentials.size}, " +
+ "pushCredentials=${pushCredentials.size}, " +
+ "resultingGroups=${orderedAccountGroups.size}")
+
+ // Validate that we don't have duplicate account groups with the same issuer+account name
+ val groupKeys = orderedAccountGroups.map { "${it.issuer}-${it.accountName}" }
+ val duplicateKeys = groupKeys.groupBy { it }.filter { it.value.size > 1 }.keys
+ if (duplicateKeys.isNotEmpty()) {
+ diagnosticLogger.w("Warning: Found duplicate account groups with keys: $duplicateKeys")
+ // This is expected when shouldCombine is false and there are multiple credentials
+ // for the same account, but worth logging for debugging
+ }
+
+ _accountGroups.value = orderedAccountGroups
+ }
+
+ /**
+ * Updates the account group order immediately.
+ * This provides immediate feedback while the order is being persisted.
+ */
+ fun updateAccountGroupOrder(newAccountGroups: List) {
+ diagnosticLogger.d("Update AccountGroupOrder")
+ _accountGroups.value = newAccountGroups
+ }
+
+ /**
+ * Save the current account order to preferences.
+ */
+ suspend fun saveAccountOrder(accountGroups: List) {
+ val orderKeys = accountGroups.map { "${it.issuer}-${it.accountName}" }
+ userPreferences.setAccountOrder(orderKeys)
+ }
+
+ /**
+ * Apply saved account order to the list of account groups.
+ */
+ private fun applyAccountOrder(accountGroups: List): List {
+ val savedOrder = userPreferences.getAccountOrder()
+ if (savedOrder.isEmpty()) {
+ return accountGroups
+ }
+
+ // Check if we have separate groups (when combine accounts is disabled)
+ val hasSeparateGroups = accountGroups.any { group ->
+ accountGroups.count { it.issuer == group.issuer && it.accountName == group.accountName } > 1
+ }
+
+ return if (hasSeparateGroups) {
+ applySeparateGroupOrdering(accountGroups, savedOrder)
+ } else {
+ applyCombinedGroupOrdering(accountGroups, savedOrder)
+ }
+ }
+
+ /**
+ * Apply ordering for separate account groups (when combine accounts is disabled).
+ * Groups OATH and Push cards from the same account together and maintains saved order.
+ */
+ private fun applySeparateGroupOrdering(
+ accountGroups: List,
+ savedOrder: List
+ ): List {
+ // Group the separate cards by account (issuer + accountName)
+ val groupsByAccount = accountGroups.groupBy { "${it.issuer}-${it.accountName}" }
+
+ val orderedList = mutableListOf()
+ val processedAccounts = mutableSetOf()
+
+ // Process accounts in saved order
+ savedOrder.forEach { accountKey ->
+ groupsByAccount[accountKey]?.let { accountCards ->
+ // Sort cards within account: OATH first, then Push
+ val sortedCards = accountCards.sortedWith { a, b ->
+ when {
+ a.oathCredentials.isNotEmpty() && b.pushCredentials.isNotEmpty() -> -1 // OATH first
+ a.pushCredentials.isNotEmpty() && b.oathCredentials.isNotEmpty() -> 1 // Push second
+ else -> 0 // Same type, maintain existing order
+ }
+ }
+ orderedList.addAll(sortedCards)
+ processedAccounts.add(accountKey)
+ }
+ }
+
+ // Add any new accounts not in saved order
+ groupsByAccount.entries.forEach { (accountKey, accountCards) ->
+ if (!processedAccounts.contains(accountKey)) {
+ val sortedCards = accountCards.sortedWith { a, b ->
+ when {
+ a.oathCredentials.isNotEmpty() && b.pushCredentials.isNotEmpty() -> -1
+ a.pushCredentials.isNotEmpty() && b.oathCredentials.isNotEmpty() -> 1
+ else -> 0
+ }
+ }
+ orderedList.addAll(sortedCards)
+ }
+ }
+
+ return orderedList
+ }
+
+ /**
+ * Apply ordering for combined account groups (when combine accounts is enabled).
+ */
+ private fun applyCombinedGroupOrdering(
+ accountGroups: List,
+ savedOrder: List
+ ): List {
+ // Create a map for quick lookup (only for combined accounts)
+ val accountMap = accountGroups.associateBy { "${it.issuer}-${it.accountName}" }
+ val orderedList = mutableListOf()
+ val addedKeys = mutableSetOf()
+
+ // Add accounts in saved order
+ savedOrder.forEach { key ->
+ accountMap[key]?.let { accountGroup ->
+ orderedList.add(accountGroup)
+ addedKeys.add(key)
+ }
+ }
+
+ // Add any new accounts that weren't in the saved order
+ accountGroups.forEach { accountGroup ->
+ val key = "${accountGroup.issuer}-${accountGroup.accountName}"
+ if (!addedKeys.contains(key)) {
+ orderedList.add(accountGroup)
+ }
+ }
+
+ return orderedList
+ }
+}
\ No newline at end of file
diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/managers/JourneyManager.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/managers/JourneyManager.kt
new file mode 100644
index 00000000..e88be1e7
--- /dev/null
+++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/managers/JourneyManager.kt
@@ -0,0 +1,249 @@
+/*
+ * Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license. See the LICENSE file for details.
+ */
+
+package com.pingidentity.authenticatorapp.managers
+
+import com.pingidentity.authenticatorapp.data.DiagnosticLogger
+import com.pingidentity.journey.Journey
+import com.pingidentity.journey.callback.PollingWaitCallback
+import com.pingidentity.journey.plugin.callbacks
+import com.pingidentity.journey.start
+import com.pingidentity.journey.user
+import com.pingidentity.orchestrate.ContinueNode
+import com.pingidentity.orchestrate.ErrorNode
+import com.pingidentity.orchestrate.FailureNode
+import com.pingidentity.orchestrate.Node
+import com.pingidentity.orchestrate.SuccessNode
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.withContext
+
+/**
+ * Manager class for handling Journey-based authentication operations.
+ * Encapsulates Journey-specific business logic and state management.
+ *
+ * @param journey The Journey client instance
+ * @param diagnosticLogger DiagnosticLogger for logging
+ */
+class JourneyManager(
+ private var journey: Journey? = null,
+ private val diagnosticLogger: DiagnosticLogger
+) {
+
+ private val _currentNode = MutableStateFlow(null)
+ val currentNode: StateFlow = _currentNode.asStateFlow()
+
+ private val _isLoading = MutableStateFlow(false)
+ val isLoading: StateFlow = _isLoading.asStateFlow()
+
+ private val _isPolling = MutableStateFlow(false)
+ val isPolling: StateFlow = _isPolling.asStateFlow()
+
+ private val _isSuccess = MutableStateFlow(false)
+ val isSuccess: StateFlow = _isSuccess.asStateFlow()
+
+ private val _error = MutableStateFlow(null)
+ val error: StateFlow = _error.asStateFlow()
+
+ private val _message = MutableStateFlow(null)
+ val message: StateFlow = _message.asStateFlow()
+
+ /**
+ * Sets the Journey client instance.
+ */
+ fun setClient(client: Journey) {
+ this.journey = client
+ }
+
+ /**
+ * Starts a Journey authentication flow.
+ */
+ suspend fun startJourney(journeyName: String): Result {
+ val client = journey ?: return Result.failure(Exception("Journey client not initialized"))
+
+ return try {
+ _isLoading.value = true
+ _error.value = null
+ _isSuccess.value = false
+ _message.value = "Starting authentication..."
+
+ val result = withContext(Dispatchers.IO) {
+ diagnosticLogger.d("Starting journey: $journeyName")
+ client.start(journeyName)
+ }
+
+ _currentNode.value = result
+ updateStateFromNode(result)
+
+ Result.success(result)
+ } catch (e: Exception) {
+ diagnosticLogger.e("Failed to start journey", e)
+ _isLoading.value = false
+ _error.value = "Failed to start authentication: ${e.message}"
+ Result.failure(e)
+ }
+ }
+
+ /**
+ * Continues the Journey flow with the current node.
+ */
+ suspend fun continueJourney(): Result {
+ val currentNode = _currentNode.value
+ if (currentNode !is ContinueNode) {
+ return Result.failure(Exception("Cannot continue - current node is not a continue node"))
+ }
+
+ return try {
+ _isLoading.value = true
+
+ val result = withContext(Dispatchers.IO) {
+ diagnosticLogger.d("Continuing journey with ${currentNode.callbacks.size} callbacks")
+ currentNode.next()
+ }
+
+ _currentNode.value = result
+ updateStateFromNode(result)
+
+ Result.success(result)
+ } catch (e: Exception) {
+ diagnosticLogger.e("Failed to continue journey", e)
+ _isLoading.value = false
+ _error.value = "Authentication failed: ${e.message}"
+ Result.failure(e)
+ }
+ }
+
+ /**
+ * Gets the polling callback from the current node if it exists.
+ */
+ fun getPollingCallback(node: Node): PollingWaitCallback? {
+ return if (node is ContinueNode) {
+ node.callbacks.find { it is PollingWaitCallback } as? PollingWaitCallback
+ } else null
+ }
+
+ /**
+ * Sets polling state.
+ */
+ fun setPollingState(isPolling: Boolean, message: String? = null) {
+ _isPolling.value = isPolling
+ if (message != null) {
+ _message.value = message
+ }
+ }
+
+ /**
+ * Gets the Journey instance for external use (e.g., by LoginViewModel for user operations).
+ */
+ fun getJourneyClient(): Journey? {
+ return journey
+ }
+
+ /**
+ * Logs out the current user.
+ */
+ suspend fun logout(): Result {
+ val client = journey ?: return Result.failure(Exception("Journey client not initialized"))
+
+ return try {
+ val user = withContext(Dispatchers.IO) {
+ client.user()
+ }
+
+ if (user != null) {
+ withContext(Dispatchers.IO) {
+ user.logout()
+ }
+ diagnosticLogger.d("User logged out successfully")
+ Result.success(Unit)
+ } else {
+ diagnosticLogger.d("No user to log out")
+ Result.success(Unit)
+ }
+ } catch (e: Exception) {
+ diagnosticLogger.e("Failed to log out user", e)
+ Result.failure(e)
+ }
+ }
+
+ /**
+ * Resets the Journey state.
+ */
+ fun reset() {
+ _currentNode.value = null
+ _isLoading.value = false
+ _isPolling.value = false
+ _isSuccess.value = false
+ _error.value = null
+ _message.value = null
+ }
+
+ /**
+ * Sets error message.
+ */
+ fun setError(errorMessage: String) {
+ _error.value = errorMessage
+ _isLoading.value = false
+ _isPolling.value = false
+ }
+
+ /**
+ * Sets message.
+ */
+ fun setMessage(message: String) {
+ _message.value = message
+ }
+
+ /**
+ * Updates internal state based on the current node type.
+ */
+ private fun updateStateFromNode(node: Node) {
+ diagnosticLogger.d("Handling node: ${node.javaClass.simpleName}")
+
+ when (node) {
+ is ContinueNode -> {
+ _isLoading.value = false
+ _message.value = "Please provide required information"
+ }
+ is SuccessNode -> {
+ diagnosticLogger.d("Journey completed successfully")
+ _isLoading.value = false
+ _isSuccess.value = true
+ _message.value = "Authentication completed successfully"
+ }
+ is ErrorNode -> {
+ diagnosticLogger.w("Journey failed with error: ${node.message}")
+ _isLoading.value = false
+ _error.value = "Authentication error: ${node.message}"
+ }
+ is FailureNode -> {
+ diagnosticLogger.e("Journey failed with exception", node.cause)
+ _isLoading.value = false
+ _error.value = "Authentication failed: ${node.cause.message}"
+ }
+ }
+ }
+
+ /**
+ * Closes the Journey client and releases resources.
+ * This should be called when the associated ViewModel is cleared
+ * or when the application no longer needs the Journey client.
+ * It ensures proper cleanup of resources and prevents memory leaks.
+ */
+ fun close() {
+ try {
+ // Journey doesn't require explicit closing, but we should
+ // release our reference to allow proper garbage collection
+ diagnosticLogger.d("Closing Journey client and releasing resources")
+ journey = null
+ } catch (e: Exception) {
+ diagnosticLogger.e("Error closing Journey client", e)
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/managers/OathManager.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/managers/OathManager.kt
new file mode 100644
index 00000000..1a2cae61
--- /dev/null
+++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/managers/OathManager.kt
@@ -0,0 +1,507 @@
+/*
+ * Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license. See the LICENSE file for details.
+ */
+
+package com.pingidentity.authenticatorapp.managers
+
+import com.pingidentity.authenticatorapp.AuthenticatorApp
+import com.pingidentity.authenticatorapp.data.BackupFileInfo
+import com.pingidentity.authenticatorapp.data.DiagnosticLogger
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.withContext
+import com.pingidentity.mfa.oath.OathCodeInfo
+import com.pingidentity.mfa.oath.OathCredential
+import com.pingidentity.mfa.oath.OathClient
+import com.pingidentity.mfa.oath.storage.SQLOathStorage
+import java.io.File
+
+/**
+ * Manager class for handling all OATH credential operations.
+ * Encapsulates OATH-specific business logic and state management.
+ *
+ * @param oathClient The OATH MFA client instance
+ * @param oathStorage The OATH storage instance (optional, for backup operations)
+ * @param diagnosticLogger DiagnosticLogger for logging
+ */
+class OathManager(
+ private var oathClient: OathClient? = null,
+ private var oathStorage: SQLOathStorage? = null,
+ private val diagnosticLogger: DiagnosticLogger
+) {
+
+ private val _oathCredentials = MutableStateFlow>(emptyList())
+ val oathCredentials: StateFlow> = _oathCredentials.asStateFlow()
+
+ private val _isLoadingOathCredentials = MutableStateFlow(false)
+ val isLoadingOathCredentials: StateFlow = _isLoadingOathCredentials.asStateFlow()
+
+ private val _generatedCodes = MutableStateFlow