From 00e35d4dbb590a140dd2af7502eff77a3eba6c65 Mon Sep 17 00:00:00 2001 From: vibhorgoswami Date: Thu, 7 May 2026 16:24:05 -0700 Subject: [PATCH] SDKS-5031: Add Authenticator app to Sample apps. --- android/kotlin-authenticatorapp/.gitignore | 15 + android/kotlin-authenticatorapp/README.md | 275 ++++ .../kotlin-authenticatorapp/app/.gitignore | 1 + .../app/build.gradle.kts | 91 ++ .../app/google-services.json | 29 + .../app/proguard-rules.pro | 21 + .../app/src/main/AndroidManifest.xml | 85 ++ .../authenticatorapp/AuthenticatorApp.kt | 406 ++++++ .../authenticatorapp/MainActivity.kt | 253 ++++ .../data/AuthenticatorViewModel.kt | 1210 +++++++++++++++++ .../authenticatorapp/data/BackupModels.kt | 29 + .../authenticatorapp/data/DiagnosticLogger.kt | 121 ++ .../authenticatorapp/data/LoginViewModel.kt | 460 +++++++ .../authenticatorapp/data/UiModels.kt | 399 ++++++ .../authenticatorapp/data/UserPreferences.kt | 262 ++++ .../managers/AccountGroupingManager.kt | 191 +++ .../managers/JourneyManager.kt | 249 ++++ .../authenticatorapp/managers/OathManager.kt | 507 +++++++ .../authenticatorapp/managers/PushManager.kt | 762 +++++++++++ .../managers/TestAccountFactory.kt | 122 ++ .../notification/BiometricPromptActivity.kt | 244 ++++ .../NotificationActionReceiver.kt | 122 ++ .../notification/NotificationHelper.kt | 206 +++ .../notification/PushNotificationActivity.kt | 239 ++++ .../service/LocationService.kt | 90 ++ .../service/PushNotificationService.kt | 175 +++ .../authenticatorapp/ui/AboutScreen.kt | 173 +++ .../ui/AccountDetailScreen.kt | 397 ++++++ .../authenticatorapp/ui/AccountsScreen.kt | 511 +++++++ .../ui/AuthenticatorNavHost.kt | 257 ++++ .../ui/DiagnosticLogsScreen.kt | 266 ++++ .../authenticatorapp/ui/EditAccountsScreen.kt | 371 +++++ .../ui/InitializationErrorScreen.kt | 336 +++++ .../authenticatorapp/ui/LoginScreen.kt | 329 +++++ .../authenticatorapp/ui/ManualEntryScreen.kt | 288 ++++ .../ui/NotificationResponseScreen.kt | 696 ++++++++++ .../ui/PushNotificationsScreen.kt | 136 ++ .../authenticatorapp/ui/QrScannerScreen.kt | 289 ++++ .../authenticatorapp/ui/SettingsScreen.kt | 229 ++++ .../authenticatorapp/ui/TestScreen.kt | 970 +++++++++++++ .../ui/components/AccountAvatar.kt | 113 ++ .../ui/components/AccountGroupItem.kt | 323 +++++ .../ui/components/BackNavigationTopAppBar.kt | 44 + .../ui/components/CallbackRenderers.kt | 226 +++ .../ui/components/CircularProgressTimer.kt | 70 + .../ui/components/DetailRow.kt | 51 + .../ui/components/EditAccountDialog.kt | 132 ++ .../ui/components/EditableAccountItem.kt | 208 +++ .../ui/components/EmptyStateMessage.kt | 59 + .../ui/components/ErrorAlertDialog.kt | 38 + .../ui/components/InfoCard.kt | 56 + .../ui/components/LoadingIndicator.kt | 50 + .../ui/components/NotificationCard.kt | 181 +++ .../ui/components/NotificationHistoryCard.kt | 189 +++ .../ui/components/SettingItem.kt | 111 ++ .../ui/components/StatusIndicator.kt | 70 + .../authenticatorapp/ui/theme/Color.kt | 18 + .../authenticatorapp/ui/theme/Theme.kt | 75 + .../authenticatorapp/ui/theme/Type.kt | 48 + .../authenticatorapp/util/DateUtils.kt | 26 + .../util/NavigationAnimations.kt | 71 + .../authenticatorapp/util/QrCodeAnalyzer.kt | 75 + .../app/src/main/res/drawable/ic_check.xml | 10 + .../app/src/main/res/drawable/ic_close.xml | 10 + .../src/main/res/drawable/ic_fingerprint.xml | 10 + .../res/drawable/ic_launcher_background.xml | 170 +++ .../res/drawable/ic_launcher_foreground.xml | 21 + .../src/main/res/drawable/ic_notification.xml | 10 + .../app/src/main/res/drawable/ping_logo.xml | 28 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + .../app/src/main/res/values-night/themes.xml | 5 + .../res/values/ic_launcher_background.xml | 4 + .../app/src/main/res/values/strings.xml | 171 +++ .../app/src/main/res/values/themes.xml | 5 + .../kotlin-authenticatorapp/build.gradle.kts | 4 + .../kotlin-authenticatorapp/gradle.properties | 15 + .../gradle/gradle-daemon-jvm.properties | 12 + .../gradle/libs.versions.toml | 72 + .../gradle/wrapper/gradle-wrapper.properties | 9 + android/kotlin-authenticatorapp/gradlew | 251 ++++ android/kotlin-authenticatorapp/gradlew.bat | 94 ++ .../settings.gradle.kts | 27 + 83 files changed, 14984 insertions(+) create mode 100644 android/kotlin-authenticatorapp/.gitignore create mode 100644 android/kotlin-authenticatorapp/README.md create mode 100644 android/kotlin-authenticatorapp/app/.gitignore create mode 100644 android/kotlin-authenticatorapp/app/build.gradle.kts create mode 100644 android/kotlin-authenticatorapp/app/google-services.json create mode 100644 android/kotlin-authenticatorapp/app/proguard-rules.pro create mode 100644 android/kotlin-authenticatorapp/app/src/main/AndroidManifest.xml create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/AuthenticatorApp.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/MainActivity.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/data/AuthenticatorViewModel.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/data/BackupModels.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/data/DiagnosticLogger.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/data/LoginViewModel.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/data/UiModels.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/data/UserPreferences.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/managers/AccountGroupingManager.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/managers/JourneyManager.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/managers/OathManager.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/managers/PushManager.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/managers/TestAccountFactory.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/notification/BiometricPromptActivity.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/notification/NotificationActionReceiver.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/notification/NotificationHelper.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/notification/PushNotificationActivity.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/service/LocationService.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/service/PushNotificationService.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/AboutScreen.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/AccountDetailScreen.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/AccountsScreen.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/AuthenticatorNavHost.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/DiagnosticLogsScreen.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/EditAccountsScreen.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/InitializationErrorScreen.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/LoginScreen.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/ManualEntryScreen.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/NotificationResponseScreen.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/PushNotificationsScreen.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/QrScannerScreen.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/SettingsScreen.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/TestScreen.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/AccountAvatar.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/AccountGroupItem.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/BackNavigationTopAppBar.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/CallbackRenderers.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/CircularProgressTimer.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/DetailRow.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/EditAccountDialog.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/EditableAccountItem.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/EmptyStateMessage.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/ErrorAlertDialog.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/InfoCard.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/LoadingIndicator.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/NotificationCard.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/NotificationHistoryCard.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/SettingItem.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/StatusIndicator.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/theme/Color.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/theme/Theme.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/theme/Type.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/util/DateUtils.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/util/NavigationAnimations.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/util/QrCodeAnalyzer.kt create mode 100644 android/kotlin-authenticatorapp/app/src/main/res/drawable/ic_check.xml create mode 100644 android/kotlin-authenticatorapp/app/src/main/res/drawable/ic_close.xml create mode 100644 android/kotlin-authenticatorapp/app/src/main/res/drawable/ic_fingerprint.xml create mode 100644 android/kotlin-authenticatorapp/app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 android/kotlin-authenticatorapp/app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 android/kotlin-authenticatorapp/app/src/main/res/drawable/ic_notification.xml create mode 100644 android/kotlin-authenticatorapp/app/src/main/res/drawable/ping_logo.xml create mode 100644 android/kotlin-authenticatorapp/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 android/kotlin-authenticatorapp/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 android/kotlin-authenticatorapp/app/src/main/res/values-night/themes.xml create mode 100644 android/kotlin-authenticatorapp/app/src/main/res/values/ic_launcher_background.xml create mode 100644 android/kotlin-authenticatorapp/app/src/main/res/values/strings.xml create mode 100644 android/kotlin-authenticatorapp/app/src/main/res/values/themes.xml create mode 100644 android/kotlin-authenticatorapp/build.gradle.kts create mode 100644 android/kotlin-authenticatorapp/gradle.properties create mode 100644 android/kotlin-authenticatorapp/gradle/gradle-daemon-jvm.properties create mode 100644 android/kotlin-authenticatorapp/gradle/libs.versions.toml create mode 100644 android/kotlin-authenticatorapp/gradle/wrapper/gradle-wrapper.properties create mode 100755 android/kotlin-authenticatorapp/gradlew create mode 100644 android/kotlin-authenticatorapp/gradlew.bat create mode 100644 android/kotlin-authenticatorapp/settings.gradle.kts 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 @@ +[![Ping Identity](https://www.pingidentity.com/content/dam/picr/nav/Ping-Logo-2.svg)](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>(emptyMap()) + val generatedCodes: StateFlow> = _generatedCodes.asStateFlow() + + private val _lastAddedOathCredential = MutableStateFlow(null) + val lastAddedOathCredential: StateFlow = _lastAddedOathCredential.asStateFlow() + + /** + * Sets the OATH client instance and optionally the storage instance. + * + * @param client The OATH client instance + * @param storage Optional storage instance for backup operations + */ + fun setClient(client: OathClient, storage: SQLOathStorage? = null) { + this.oathClient = client + this.oathStorage = storage + } + + /** + * Loads all OATH credentials from the SDK. + */ + suspend fun loadCredentials(): Result> { + val client = oathClient ?: return Result.failure(Exception("OATH client not initialized")) + _isLoadingOathCredentials.value = true + return try { + val result = withContext(Dispatchers.IO) { + diagnosticLogger.d("Loading OATH credentials from OathClient") + client.getCredentials() + } + + result.onSuccess { credentials -> + _oathCredentials.value = credentials + } + + _isLoadingOathCredentials.value = false + result + } catch (e: Exception) { + _isLoadingOathCredentials.value = false + Result.failure(e) + } + } + + /** + * Adds an OATH credential from a URI. + */ + suspend fun addCredentialFromUri(uri: String): Result { + val client = oathClient ?: return Result.failure(Exception("OATH client not initialized")) + return try { + val result = withContext(Dispatchers.IO) { + diagnosticLogger.d("Adding OATH credential from URI: ${maskUri(uri)}") + client.addCredentialFromUri(uri) + } + + result.onSuccess { credential -> + _lastAddedOathCredential.value = credential + // Reload credentials to refresh the list + loadCredentials() + } + + result + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Removes an OATH credential from the SDK. + */ + suspend fun removeCredential(credentialId: String): Result { + val client = oathClient ?: return Result.failure(Exception("OATH client not initialized")) + return try { + val result = withContext(Dispatchers.IO) { + diagnosticLogger.d("Removing OATH credential: $credentialId") + client.deleteCredential(credentialId) + } + + result.onSuccess { removed -> + if (removed) { + // Reload credentials to refresh the list + loadCredentials() + } + } + + result + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Updates an OATH credential in the SDK. + */ + suspend fun updateCredential(credential: OathCredential): Result { + val client = oathClient ?: return Result.failure(Exception("OATH client not initialized")) + return try { + val result = withContext(Dispatchers.IO) { + diagnosticLogger.d("Updating OATH credential: $credential") + client.saveCredential(credential) + } + + result.onSuccess { + // Reload credentials to refresh the list + loadCredentials() + } + + result + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Generates a code for a credential. + */ + suspend fun generateCode(credentialId: String): Result { + val client = oathClient ?: return Result.failure(Exception("OATH client not initialized")) + return try { + val result = withContext(Dispatchers.IO) { + client.generateCodeWithValidity(credentialId) + } + + result.onSuccess { codeInfo -> + val updatedCodes = _generatedCodes.value.toMutableMap() + updatedCodes[credentialId] = codeInfo + _generatedCodes.value = updatedCodes + } + + result + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Clears the last added OATH credential. + */ + fun clearLastAddedCredential() { + _lastAddedOathCredential.value = null + } + + /** + * Closes the OATH client and releases resources. + */ + suspend fun close() { + try { + oathClient?.close() + } catch (e: Exception) { + diagnosticLogger.e("Error closing OATH client", e) + } + } + + /** + * Masks sensitive information in a URI for logging. + */ + private fun maskUri(uri: String): String { + return uri.replace(Regex("secret=[^&]*"), "secret=*****") + } + + /** + * Gets the list of backup files for OATH database. + * Requires storage instance to be set via setClient() or constructor. + */ + suspend fun getBackupFiles(): List { + return withContext(Dispatchers.IO) { + try { + val storage = oathStorage + if (storage == null) { + diagnosticLogger.w("OATH storage not available. Pass storage to setClient() to enable backup operations.") + return@withContext emptyList() + } + + val backupFiles = storage.listBackupFiles() + + backupFiles.map { file -> + BackupFileInfo( + name = file.name, + sizeBytes = file.length(), + timestamp = parseBackupTimestamp(file.name) + ) + } + } catch (e: Exception) { + diagnosticLogger.e("Error getting OATH backup files", e) + emptyList() + } + } + } + + /** + * Parses timestamp from backup filename. + * Format: {databaseName}_backup_{timestamp}.db + */ + private fun parseBackupTimestamp(filename: String): Long { + return try { + val timestampStr = filename + .substringAfter("_backup_") + .substringBefore(".db") + timestampStr.toLongOrNull() ?: 0L + } catch (_: Exception) { + 0L + } + } + + /** + * Restores OATH database from the latest backup. + * This method creates a temporary storage instance to access backup files without requiring + * full database initialization. This allows restoration even when the database is corrupted. + * + * @param context Android context needed to create temporary storage instance + */ + suspend fun restoreFromBackup(context: android.content.Context): Boolean { + return withContext(Dispatchers.IO) { + try { + // Try to use existing storage if available + var storage = oathStorage + + // If storage is not available (e.g., initialization failed), create a temporary instance + // just for accessing backup restoration functionality using centralized config + if (storage == null) { + diagnosticLogger.i("Creating temporary storage instance for backup restoration") + storage = AuthenticatorApp.createOathStorage( + context = context, + autoRestoreFromBackup = false, + allowDestructiveRecovery = false, + logger = diagnosticLogger + ) + } + + val success = storage.attemptBackupRestoration() + + if (success) { + diagnosticLogger.i("Successfully restored OATH database from backup") + } else { + diagnosticLogger.w("Failed to restore OATH database from backup or no backups available") + } + + success + } catch (e: Exception) { + diagnosticLogger.e("Error restoring OATH backup", e) + false + } + } + } + + /** + * Corrupts the OATH database for testing error handling. + * Creates a backup first to ensure recovery is possible. + * Requires storage instance to be set via setClient() or constructor. + */ + suspend fun corruptDatabase() { + return withContext(Dispatchers.IO) { + try { + val storage = oathStorage + if (storage == null) { + diagnosticLogger.w("OATH storage not available. Pass storage to setClient() to enable backup operations.") + return@withContext + } + + // Create backup FIRST to ensure recovery is possible + diagnosticLogger.i("Creating backup before corrupting database") + storage.createDatabaseBackup() + diagnosticLogger.i("Backup created successfully") + + val contextField = storage.javaClass.superclass?.getDeclaredField("context") + contextField?.isAccessible = true + val context = contextField?.get(storage) as? android.content.Context + + val databaseNameField = storage.javaClass.superclass?.getDeclaredField("databaseName") + databaseNameField?.isAccessible = true + val databaseName = databaseNameField?.get(storage) as? String ?: "pingidentity_oath.db" + + if (context != null) { + val dbFile = context.getDatabasePath(databaseName) + if (dbFile.exists()) { + oathClient?.close() + dbFile.writeBytes(ByteArray(1024) { 0xFF.toByte() }) + diagnosticLogger.w("Corrupted OATH database for testing: ${dbFile.absolutePath}") + diagnosticLogger.w("⚠️ App will need to restore from backup on next launch") + } else { + diagnosticLogger.w("OATH database file not found: ${dbFile.absolutePath}") + } + } else { + diagnosticLogger.w("Unable to access storage context") + } + } catch (e: Exception) { + diagnosticLogger.e("Error corrupting OATH database", e) + throw e + } + } + } + + /** + * Creates a manual backup of the OATH database. + * Requires storage instance to be set via setClient() or constructor. + */ + suspend fun createManualBackup() { + return withContext(Dispatchers.IO) { + try { + val storage = oathStorage + if (storage == null) { + diagnosticLogger.w("OATH storage not available. Pass storage to setClient() to enable backup operations.") + return@withContext + } + + storage.createDatabaseBackup() + diagnosticLogger.i("Manual OATH backup created successfully") + } catch (e: Exception) { + diagnosticLogger.e("Error creating manual OATH backup", e) + throw e + } + } + } + + /** + * Makes the OATH database read-only for testing. + * Creates a backup first to ensure recovery is possible. + * Requires storage instance to be set via setClient() or constructor. + */ + suspend fun makeDatabaseReadOnly() { + return withContext(Dispatchers.IO) { + try { + val storage = oathStorage + if (storage == null) { + diagnosticLogger.w("OATH storage not available. Pass storage to setClient() to enable backup operations.") + return@withContext + } + + // Create backup FIRST to ensure recovery is possible + diagnosticLogger.i("Creating backup before making database read-only") + storage.createDatabaseBackup() + diagnosticLogger.i("Backup created successfully") + + // Access context and database name via reflection (storage internals) + val contextField = storage.javaClass.superclass?.getDeclaredField("context") + contextField?.isAccessible = true + val context = contextField?.get(storage) as? android.content.Context + + val databaseNameField = storage.javaClass.superclass?.getDeclaredField("databaseName") + databaseNameField?.isAccessible = true + val databaseName = databaseNameField?.get(storage) as? String ?: "pingidentity_oath.db" + + if (context != null) { + val dbFile = context.getDatabasePath(databaseName) + if (dbFile.exists()) { + dbFile.setReadOnly() + diagnosticLogger.i("Made OATH database read-only: ${dbFile.absolutePath}") + diagnosticLogger.w("⚠️ App will need to restore from backup on next launch") + } else { + diagnosticLogger.w("OATH database file not found: ${dbFile.absolutePath}") + } + } else { + diagnosticLogger.w("Unable to access storage context") + } + } catch (e: Exception) { + diagnosticLogger.e("Error making OATH database read-only", e) + throw e + } + } + } + + /** + * Clears all OATH backup files. + * Requires storage instance to be set via setClient() or constructor. + */ + suspend fun clearBackups(): Int { + return withContext(Dispatchers.IO) { + try { + val storage = oathStorage + if (storage == null) { + diagnosticLogger.w("OATH storage not available. Pass storage to setClient() to enable backup operations.") + return@withContext 0 + } + + val backups = getBackupFiles() + if (backups.isEmpty()) { + diagnosticLogger.i("No OATH backup files to clear") + return@withContext 0 + } + + val contextField = storage.javaClass.superclass?.getDeclaredField("context") + contextField?.isAccessible = true + val context = contextField?.get(storage) as? android.content.Context + + val databaseNameField = storage.javaClass.superclass?.getDeclaredField("databaseName") + databaseNameField?.isAccessible = true + val databaseName = databaseNameField?.get(storage) as? String ?: "pingidentity_oath.db" + + if (context != null) { + val dbDir = context.getDatabasePath(databaseName).parentFile + var deletedCount = 0 + + backups.forEach { backup -> + val backupFile = File(dbDir, backup.name) + if (backupFile.exists() && backupFile.delete()) { + deletedCount++ + diagnosticLogger.d("Deleted OATH backup: ${backup.name}") + } + } + + diagnosticLogger.i("Cleared $deletedCount OATH backup files") + return@withContext deletedCount + } + + diagnosticLogger.w("Unable to access storage context for clearing backups") + 0 + } catch (e: Exception) { + diagnosticLogger.e("Error clearing OATH backups", e) + 0 + } + } + } + + /** + * Gets information about the OATH database. + * Requires storage instance to be set via setClient() or constructor. + */ + suspend fun getDatabaseInfo(): DbInfo { + return withContext(Dispatchers.IO) { + try { + val storage = oathStorage + if (storage == null) { + diagnosticLogger.w("OATH storage not available. Pass storage to setClient() to enable backup operations.") + return@withContext DbInfo(path = "unknown", size = 0L, backupCount = 0) + } + + val contextField = storage.javaClass.superclass?.getDeclaredField("context") + contextField?.isAccessible = true + val context = contextField?.get(storage) as? android.content.Context + + val databaseNameField = storage.javaClass.superclass?.getDeclaredField("databaseName") + databaseNameField?.isAccessible = true + val databaseName = databaseNameField?.get(storage) as? String ?: "pingidentity_oath.db" + + if (context != null) { + val dbFile = context.getDatabasePath(databaseName) + val size = if (dbFile.exists()) dbFile.length() else 0L + val backups = getBackupFiles() + + return@withContext DbInfo( + path = databaseName, + size = size, + backupCount = backups.size + ) + } + + DbInfo( + path = "pingidentity_oath.db", + size = 0L, + backupCount = 0 + ) + } catch (e: Exception) { + diagnosticLogger.e("Error getting OATH database info", e) + DbInfo(path = "unknown", size = 0L, backupCount = 0) + } + } + } +} + +/** + * Database information. + */ +data class DbInfo( + val path: String, + val size: Long, + val backupCount: Int +) \ No newline at end of file diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/managers/PushManager.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/managers/PushManager.kt new file mode 100644 index 00000000..a8619129 --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/managers/PushManager.kt @@ -0,0 +1,762 @@ +/* + * 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.managers + +import android.util.Log +import com.google.firebase.messaging.FirebaseMessaging +import com.pingidentity.authenticatorapp.AuthenticatorApp +import com.pingidentity.authenticatorapp.data.BackupFileInfo +import com.pingidentity.authenticatorapp.data.DiagnosticLogger +import com.pingidentity.authenticatorapp.data.PushNotificationItem +import com.pingidentity.authenticatorapp.data.toUiItems +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import com.pingidentity.mfa.push.PushClient +import com.pingidentity.mfa.push.PushCredential +import com.pingidentity.mfa.push.PushNotification +import com.pingidentity.mfa.push.storage.SQLPushStorage +import java.io.File + +/** + * Manager class for handling all Push credential and notification operations. + * Encapsulates Push-specific business logic and state management. + * + * @param pushClient The Push MFA client instance + * @param pushStorage The Push storage instance (optional, for backup operations) + * @param diagnosticLogger DiagnosticLogger for logging + */ +class PushManager( + private var pushClient: PushClient? = null, + private var pushStorage: SQLPushStorage? = null, + private val diagnosticLogger: DiagnosticLogger +) { + + private val _pushCredentials = MutableStateFlow>(emptyList()) + val pushCredentials: StateFlow> = _pushCredentials.asStateFlow() + + private val _isLoadingPushCredentials = MutableStateFlow(false) + val isLoadingPushCredentials: StateFlow = _isLoadingPushCredentials.asStateFlow() + + private val _pushNotifications = MutableStateFlow>(emptyList()) + val pushNotifications: StateFlow> = _pushNotifications.asStateFlow() + + private val _pendingNotifications = MutableStateFlow>(emptyList()) + val pendingNotifications: StateFlow> = _pendingNotifications.asStateFlow() + + private val _isLoadingNotifications = MutableStateFlow(false) + val isLoadingNotifications: StateFlow = _isLoadingNotifications.asStateFlow() + + private val _pushNotificationItems = MutableStateFlow>(emptyList()) + val pushNotificationItems: StateFlow> = _pushNotificationItems.asStateFlow() + + private val _pendingNotificationItems = MutableStateFlow>(emptyList()) + val pendingNotificationItems: StateFlow> = _pendingNotificationItems.asStateFlow() + + private val _lastAddedPushCredential = MutableStateFlow(null) + val lastAddedPushCredential: StateFlow = _lastAddedPushCredential.asStateFlow() + + /** + * Sets the Push client instance and optionally the storage instance. + * + * @param client The Push client instance + * @param storage Optional storage instance for backup operations + */ + fun setClient(client: PushClient, storage: SQLPushStorage? = null) { + this.pushClient = client + this.pushStorage = storage + } + + /** + * Loads all Push credentials from the SDK. + */ + suspend fun loadCredentials(): Result> { + val client = pushClient ?: return Result.failure(Exception("Push client not initialized")) + _isLoadingPushCredentials.value = true + return try { + val result = withContext(Dispatchers.IO) { + diagnosticLogger.d("Loading Push credentials from PushClient") + client.getCredentials() + } + + result.onSuccess { credentials -> + _pushCredentials.value = credentials + // Update notification items when credentials change + updateNotificationItems() + } + + _isLoadingPushCredentials.value = false + result + } catch (e: Exception) { + _isLoadingPushCredentials.value = false + Result.failure(e) + } + } + + /** + * Adds a Push credential from a URI. + */ + suspend fun addCredentialFromUri(uri: String): Result { + val client = pushClient ?: return Result.failure(Exception("Push client not initialized")) + return try { + val result = withContext(Dispatchers.IO) { + diagnosticLogger.d("Adding Push credential from URI: ${maskUri(uri)}") + client.addCredentialFromUri(uri) + } + + result.onSuccess { credential -> + _lastAddedPushCredential.value = credential + // Reload credentials to refresh the list + loadCredentials() + } + + result + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Removes a Push credential from the SDK. + */ + suspend fun removeCredential(credentialId: String): Result { + val client = pushClient ?: return Result.failure(Exception("Push client not initialized")) + return try { + val result = withContext(Dispatchers.IO) { + diagnosticLogger.d("Removing Push credential: $credentialId") + client.deleteCredential(credentialId) + } + + result.onSuccess { removed -> + if (removed) { + // Reload credentials to refresh the list + loadCredentials() + } + } + + result + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Updates a Push credential in the SDK. + */ + suspend fun updateCredential(credential: PushCredential): Result { + val client = pushClient ?: return Result.failure(Exception("Push client not initialized")) + return try { + val result = withContext(Dispatchers.IO) { + diagnosticLogger.d("Updating Push credential: $credential") + client.saveCredential(credential) + } + + result.onSuccess { + // Reload credentials to refresh the list + loadCredentials() + } + + result + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Loads pending push notifications from the SDK. + */ + suspend fun loadPushNotifications(): Result> { + val client = pushClient ?: return Result.failure(Exception("Push client not initialized")) + _isLoadingNotifications.value = true + return try { + val result = withContext(Dispatchers.IO) { + diagnosticLogger.d("Loading push notifications from PushClient") + client.getPendingNotifications() + } + + result.onSuccess { notifications -> + _pendingNotifications.value = notifications + updateNotificationItems() + } + + _isLoadingNotifications.value = false + result + } catch (e: Exception) { + _isLoadingNotifications.value = false + Result.failure(e) + } + } + + /** + * Loads all push notifications (not just pending ones). + */ + suspend fun loadAllPushNotifications(): Result> { + val client = pushClient ?: return Result.failure(Exception("Push client not initialized")) + _isLoadingNotifications.value = true + return try { + val result = withContext(Dispatchers.IO) { + client.getAllNotifications() + } + + result.onSuccess { allNotifications -> + val pendingNotifications = allNotifications.filter { it.pending } + _pushNotifications.value = allNotifications + _pendingNotifications.value = pendingNotifications + updateNotificationItems() + } + + _isLoadingNotifications.value = false + result + } catch (e: Exception) { + _isLoadingNotifications.value = false + Result.failure(e) + } + } + + /** + * Approves a push notification. + */ + suspend fun approveNotification(notificationId: String): Result { + val client = pushClient ?: return Result.failure(Exception("Push client not initialized")) + return try { + val result = withContext(Dispatchers.IO) { + diagnosticLogger.d("Approving push notification: $notificationId") + client.approveNotification(notificationId) + } + + result.onSuccess { success -> + if (success) { + // Reload notifications after approving + loadPushNotifications() + loadAllPushNotifications() + } + } + + result + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Approves a push notification with a challenge response. + */ + suspend fun approveChallengeNotification(notificationId: String, challengeResponse: String): Result { + val client = pushClient ?: return Result.failure(Exception("Push client not initialized")) + return try { + val result = withContext(Dispatchers.IO) { + diagnosticLogger.d("Approving challenge push notification: $notificationId") + client.approveChallengeNotification(notificationId, challengeResponse) + } + + result.onSuccess { success -> + if (success) { + // Reload notifications after approving + loadPushNotifications() + loadAllPushNotifications() + } + } + + result + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Denies a push notification. + */ + suspend fun denyNotification(notificationId: String): Result { + val client = pushClient ?: return Result.failure(Exception("Push client not initialized")) + return try { + val result = withContext(Dispatchers.IO) { + diagnosticLogger.d("Denying push notification: $notificationId") + client.denyNotification(notificationId) + } + + result.onSuccess { success -> + if (success) { + // Reload notifications after denying + loadPushNotifications() + loadAllPushNotifications() + } + } + + result + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Cleans up old notifications. + */ + suspend fun cleanupNotifications(): Result { + val client = pushClient ?: return Result.failure(Exception("Push client not initialized")) + return try { + withContext(Dispatchers.IO) { + client.cleanupNotifications() + }.also { result -> + result.onSuccess { + // Reload notifications after cleanup + loadPushNotifications() + } + } + } catch (e: Exception) { + Result.failure(e) + } + } + + /** + * Gets the current device token used for push notifications. + */ + suspend fun getDeviceToken(): Result { + val client = pushClient + return try { + withContext(Dispatchers.IO) { + client?.getDeviceToken() ?: Result.success("Not available") + }.also { result -> + result.onSuccess { token -> + diagnosticLogger.d("Retrieved device token from PushClient: $token") + }.onFailure { e -> + diagnosticLogger.e("Error retrieving device token from PushClient: ${e.message}") + } + } + } catch (e: Exception) { + diagnosticLogger.e("Error retrieving device token from PushClient: ${e.message}") + Result.failure(e) + } + } + + /** + * Forces a renewal of the Firebase device token. + */ + suspend fun forceDeviceTokenRenew(): Result { + return try { + diagnosticLogger.d("Attempting to force device token renew.") + + // Delete current token + val deleteResult = deleteDeviceToken() + if (!deleteResult.getOrDefault(false)) { + return Result.failure(Exception("Failed to delete existing device token, renewal aborted.")) + } + + diagnosticLogger.d("Previous device token deleted successfully. Fetching new token.") + + // Get new token + val newToken = suspendCancellableCoroutine { continuation -> + FirebaseMessaging.getInstance().token.addOnCompleteListener { task -> + if (task.isSuccessful) { + continuation.resume(task.result) + } else { + continuation.resumeWithException(task.exception ?: Exception("Failed to get token")) + } + } + } + + if (newToken == null) { + return Result.failure(Exception("Failed to fetch new FCM token (token is null)")) + } + + diagnosticLogger.d("New FCM token received. Setting it in PushClient.") + + // Set new token in PushClient + val setResult = withContext(Dispatchers.IO) { + val client = pushClient + client?.setDeviceToken(newToken) + } + + if (setResult != null) { + setResult.onSuccess { + diagnosticLogger.d("Successfully set new device token in PushClient.") + }.onFailure { e -> + diagnosticLogger.e("Failed to set new device token in PushClient: ${e.message}") + } + setResult.map { } + } else { + val errorMessage = "PushClient is not available or does not support setDeviceToken." + diagnosticLogger.e(errorMessage) + Result.failure(Exception(errorMessage)) + } + } catch (e: Exception) { + diagnosticLogger.e("Exception while setting new device token: ${e.message}") + Result.failure(e) + } + } + + /** + * Gets a specific push notification item by its ID. + */ + fun getNotificationItemById(notificationId: String): PushNotificationItem? { + return _pushNotificationItems.value.find { it.notification.id == notificationId } + } + + /** + * Clears the last added Push credential. + */ + fun clearLastAddedCredential() { + _lastAddedPushCredential.value = null + } + + /** + * Updates the notification items in the state based on current push notifications. + */ + private fun updateNotificationItems() { + val pendingItems = _pendingNotifications.value.toUiItems(_pushCredentials.value) + val allItems = _pushNotifications.value.toUiItems(_pushCredentials.value) + + _pushNotificationItems.value = allItems + _pendingNotificationItems.value = pendingItems + + // Log the number of pending notifications + Log.d("PushManager", "Pending notifications: ${pendingItems.size}") + } + + /** + * Deletes the Firebase device token. + */ + private suspend fun deleteDeviceToken(): Result { + return try { + suspendCancellableCoroutine { continuation -> + FirebaseMessaging.getInstance().deleteToken().addOnCompleteListener { task -> + if (task.isSuccessful) { + continuation.resume(Unit) + } else { + continuation.resumeWithException(task.exception ?: Exception("Failed to delete token")) + } + } + } + diagnosticLogger.d("Firebase device token deleted successfully.") + Result.success(true) + } catch (e: Exception) { + diagnosticLogger.e("Firebase device token deletion failed: ${e.message}") + Result.success(false) + } + } + + /** + * Closes the Push client and releases resources. + */ + suspend fun close() { + try { + pushClient?.close() + } catch (e: Exception) { + diagnosticLogger.e("Error closing Push client", e) + } + } + + /** + * Masks sensitive information in a URI for logging. + */ + private fun maskUri(uri: String): String { + return uri.replace(Regex("secret=[^&]*"), "secret=*****") + } + + /** + * Gets the list of backup files for Push database. + * Requires storage instance to be set via setClient() or constructor. + */ + suspend fun getBackupFiles(): List { + return withContext(Dispatchers.IO) { + try { + val storage = pushStorage + if (storage == null) { + diagnosticLogger.w("Push storage not available. Pass storage to setClient() to enable backup operations.") + return@withContext emptyList() + } + + val backupFiles = storage.listBackupFiles() + + backupFiles.map { file -> + BackupFileInfo( + name = file.name, + sizeBytes = file.length(), + timestamp = parseBackupTimestamp(file.name) + ) + } + } catch (e: Exception) { + diagnosticLogger.e("Error getting Push backup files", e) + emptyList() + } + } + } + + /** + * Parses timestamp from backup filename. + * Format: {databaseName}_backup_{timestamp}.db + */ + private fun parseBackupTimestamp(filename: String): Long { + return try { + val timestampStr = filename + .substringAfter("_backup_") + .substringBefore(".db") + timestampStr.toLongOrNull() ?: 0L + } catch (_: Exception) { + 0L + } + } + + /** + * Restores Push database from the latest backup. + * This method creates a temporary storage instance to access backup files without requiring + * full database initialization. This allows restoration even when the database is corrupted. + * + * @param context Android context needed to create temporary storage instance + */ + suspend fun restoreFromBackup(context: android.content.Context): Boolean { + return withContext(Dispatchers.IO) { + try { + // Try to use existing storage if available + var storage = pushStorage + + // If storage is not available (e.g., initialization failed), create a temporary instance + // just for accessing backup restoration functionality using centralized config + if (storage == null) { + diagnosticLogger.i("Creating temporary storage instance for backup restoration") + storage = AuthenticatorApp.createPushStorage( + context = context, + autoRestoreFromBackup = false, + allowDestructiveRecovery = false, + logger = diagnosticLogger + ) + } + + val success = storage.attemptBackupRestoration() + + if (success) { + diagnosticLogger.i("Successfully restored Push database from backup") + } else { + diagnosticLogger.w("Failed to restore Push database from backup or no backups available") + } + + success + } catch (e: Exception) { + diagnosticLogger.e("Error restoring Push backup", e) + false + } + } + } + + /** + * Creates a manual backup of the PUSH database. + * Requires storage instance to be set via setClient() or constructor. + */ + suspend fun createManualBackup() { + return withContext(Dispatchers.IO) { + try { + val storage = pushStorage + if (storage == null) { + diagnosticLogger.w("Push storage not available. Pass storage to setClient() to enable backup operations.") + return@withContext + } + + storage.createDatabaseBackup() + diagnosticLogger.i("Manual Push backup created successfully") + } catch (e: Exception) { + diagnosticLogger.e("Error creating manual Push backup", e) + throw e + } + } + } + + /** + * Makes the Push database read-only for testing error handling. + * Creates a backup first to ensure recovery is possible. + * Requires storage instance to be set via setClient() or constructor. + */ + suspend fun makeDatabaseReadOnly() { + return withContext(Dispatchers.IO) { + try { + val storage = pushStorage + if (storage == null) { + diagnosticLogger.w("Push storage not available. Pass storage to setClient() to enable backup operations.") + return@withContext + } + + // Create backup FIRST to ensure recovery is possible + diagnosticLogger.i("Creating backup before making database read-only") + storage.createDatabaseBackup() + diagnosticLogger.i("Backup created successfully") + + val contextField = storage.javaClass.superclass?.getDeclaredField("context") + contextField?.isAccessible = true + val context = contextField?.get(storage) as? android.content.Context + + val databaseNameField = storage.javaClass.superclass?.getDeclaredField("databaseName") + databaseNameField?.isAccessible = true + val databaseName = databaseNameField?.get(storage) as? String ?: "pingidentity_push.db" + + if (context != null) { + val dbFile = context.getDatabasePath(databaseName) + if (dbFile.exists()) { + pushClient?.close() + dbFile.setReadOnly() + diagnosticLogger.w("Made Push database read-only for testing: ${dbFile.absolutePath}") + diagnosticLogger.w("⚠️ App will fail on next write attempt") + } else { + diagnosticLogger.w("Push database file not found: ${dbFile.absolutePath}") + } + } else { + diagnosticLogger.w("Unable to access storage context") + } + } catch (e: Exception) { + diagnosticLogger.e("Error making Push database read-only", e) + throw e + } + } + } + + /** + * Corrupts the Push database for testing error handling. + * Creates a backup first to ensure recovery is possible. + * Requires storage instance to be set via setClient() or constructor. + */ + suspend fun corruptDatabase() { + return withContext(Dispatchers.IO) { + try { + val storage = pushStorage + if (storage == null) { + diagnosticLogger.w("Push storage not available. Pass storage to setClient() to enable backup operations.") + return@withContext + } + + // Create backup FIRST to ensure recovery is possible + diagnosticLogger.i("Creating backup before corrupting database") + storage.createDatabaseBackup() + diagnosticLogger.i("Backup created successfully") + + val contextField = storage.javaClass.superclass?.getDeclaredField("context") + contextField?.isAccessible = true + val context = contextField?.get(storage) as? android.content.Context + + val databaseNameField = storage.javaClass.superclass?.getDeclaredField("databaseName") + databaseNameField?.isAccessible = true + val databaseName = databaseNameField?.get(storage) as? String ?: "pingidentity_push.db" + + if (context != null) { + val dbFile = context.getDatabasePath(databaseName) + if (dbFile.exists()) { + pushClient?.close() + dbFile.writeBytes(ByteArray(1024) { 0xFF.toByte() }) + diagnosticLogger.w("Corrupted Push database for testing: ${dbFile.absolutePath}") + diagnosticLogger.w("⚠️ App will need to restore from backup on next launch") + } else { + diagnosticLogger.w("Push database file not found: ${dbFile.absolutePath}") + } + } else { + diagnosticLogger.w("Unable to access storage context") + } + } catch (e: Exception) { + diagnosticLogger.e("Error corrupting Push database", e) + throw e + } + } + } + + /** + * Clears all Push backup files. + * Requires storage instance to be set via setClient() or constructor. + */ + suspend fun clearBackups(): Int { + return withContext(Dispatchers.IO) { + try { + val storage = pushStorage + if (storage == null) { + diagnosticLogger.w("Push storage not available. Pass storage to setClient() to enable backup operations.") + return@withContext 0 + } + + val backups = getBackupFiles() + if (backups.isEmpty()) { + diagnosticLogger.i("No Push backup files to clear") + return@withContext 0 + } + + val contextField = storage.javaClass.superclass?.getDeclaredField("context") + contextField?.isAccessible = true + val context = contextField?.get(storage) as? android.content.Context + + val databaseNameField = storage.javaClass.superclass?.getDeclaredField("databaseName") + databaseNameField?.isAccessible = true + val databaseName = databaseNameField?.get(storage) as? String ?: "pingidentity_push.db" + + if (context != null) { + val dbDir = context.getDatabasePath(databaseName).parentFile + var deletedCount = 0 + + backups.forEach { backup -> + val backupFile = File(dbDir, backup.name) + if (backupFile.exists() && backupFile.delete()) { + deletedCount++ + diagnosticLogger.d("Deleted Push backup: ${backup.name}") + } + } + + diagnosticLogger.i("Cleared $deletedCount Push backup files") + return@withContext deletedCount + } + + diagnosticLogger.w("Unable to access storage context for clearing backups") + 0 + } catch (e: Exception) { + diagnosticLogger.e("Error clearing Push backups", e) + 0 + } + } + } + + /** + * Gets information about the Push database. + * Requires storage instance to be set via setClient() or constructor. + */ + suspend fun getDatabaseInfo(): DbInfo { + return withContext(Dispatchers.IO) { + try { + val storage = pushStorage + if (storage == null) { + diagnosticLogger.w("Push storage not available. Pass storage to setClient() to enable backup operations.") + return@withContext DbInfo(path = "unknown", size = 0L, backupCount = 0) + } + + val contextField = storage.javaClass.superclass?.getDeclaredField("context") + contextField?.isAccessible = true + val context = contextField?.get(storage) as? android.content.Context + + val databaseNameField = storage.javaClass.superclass?.getDeclaredField("databaseName") + databaseNameField?.isAccessible = true + val databaseName = databaseNameField?.get(storage) as? String ?: "pingidentity_push.db" + + if (context != null) { + val dbFile = context.getDatabasePath(databaseName) + val size = if (dbFile.exists()) dbFile.length() else 0L + val backups = getBackupFiles() + + return@withContext DbInfo( + path = databaseName, + size = size, + backupCount = backups.size + ) + } + + DbInfo( + path = "pingidentity_push.db", + size = 0L, + backupCount = 0 + ) + } catch (e: Exception) { + diagnosticLogger.e("Error getting Push database info", e) + DbInfo(path = "unknown", size = 0L, backupCount = 0) + } + } + } +} \ No newline at end of file diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/managers/TestAccountFactory.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/managers/TestAccountFactory.kt new file mode 100644 index 00000000..a919070d --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/managers/TestAccountFactory.kt @@ -0,0 +1,122 @@ +/* + * 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 android.util.Base64 +import com.pingidentity.mfa.oath.OathAlgorithm +import com.pingidentity.mfa.oath.OathCredential +import com.pingidentity.mfa.oath.OathType +import com.pingidentity.mfa.push.PushCredential +import java.util.UUID + +/** + * Factory class for creating test accounts for development and testing purposes. + * Provides utilities to generate random OATH, Push, and combined MFA accounts. + */ +class TestAccountFactory { + + companion object { + private val RANDOM_ACCOUNT_RANGE = 1000..9999 + private const val TEST_SERVER_ENDPOINT = "https://test.example.com/push" + private const val SECRET_LENGTH = 32 + } + + /** + * Creates a random OATH account for testing. + */ + fun createRandomOathAccount(): Pair { + // Generate a random TOTP URI + val randomNumber = RANDOM_ACCOUNT_RANGE.random() + val issuer = "TestIssuer-${randomNumber}" + val accountName = "test.user${randomNumber}@example.com" + val secret = generateRandomBase32Secret() + val uri = "otpauth://totp/$issuer:$accountName?secret=$secret&issuer=$issuer&algorithm=SHA1&digits=6&period=30" + + return Pair(uri, "Random OATH account created: $issuer") + } + + /** + * Creates a random Push credential for testing. + */ + fun createRandomPushCredential(): Pair { + // Generate a random account name and issuer + val randomNumber = RANDOM_ACCOUNT_RANGE.random() + val issuer = "TestIssuer-${randomNumber}" + val accountName = "test.user${randomNumber}@example.com" + val userId = "user-${UUID.randomUUID().toString().substring(0, 8)}" + + // Generate a random shared secret + val sharedSecret = generateRandomBase32Secret() + + // Create a fake registration endpoint and authentication endpoint + val serverEndpoint = TEST_SERVER_ENDPOINT + + // Create a new PushCredential + val credential = PushCredential( + id = UUID.randomUUID().toString(), + accountName = accountName, + issuer = issuer, + userId = userId, + sharedSecret = Base64.encodeToString(sharedSecret.toByteArray(), Base64.NO_WRAP), + serverEndpoint = serverEndpoint + ) + + return Pair(credential, "Created test push account: $accountName") + } + + /** + * Creates random combined OATH and Push credentials for the same account. + */ + fun createRandomCombinedMfaCredentials(): Triple { + // Generate a random account name and issuer + val randomNumber = RANDOM_ACCOUNT_RANGE.random() + val issuer = "TestIssuer-${randomNumber}" + val accountName = "test.user${randomNumber}@example.com" + val userId = "user-${UUID.randomUUID().toString().substring(0, 8)}" + + // Generate a random shared secret + val sharedSecret = generateRandomBase32Secret() + + // Create a fake registration endpoint and authentication endpoint + val serverEndpoint = TEST_SERVER_ENDPOINT + + // Create a new PushCredential + val pushCredential = PushCredential( + id = UUID.randomUUID().toString(), + accountName = accountName, + issuer = issuer, + userId = userId, + sharedSecret = Base64.encodeToString(sharedSecret.toByteArray(), Base64.NO_WRAP), + serverEndpoint = serverEndpoint + ) + + // Create a new OATH Credential + val oathCredential = OathCredential( + id = UUID.randomUUID().toString(), + accountName = accountName, + issuer = issuer, + oathType = OathType.TOTP, + oathAlgorithm = OathAlgorithm.SHA1, + digits = 6, + period = 30, + secret = sharedSecret + ) + + return Triple(pushCredential, oathCredential, "Created test combined account: $accountName") + } + + /** + * Generates a random Base32 string for OATH secrets + */ + private fun generateRandomBase32Secret(): String { + val base32Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" + return (1..SECRET_LENGTH) + .map { base32Chars.random() } + .joinToString("") + } +} \ No newline at end of file diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/notification/BiometricPromptActivity.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/notification/BiometricPromptActivity.kt new file mode 100644 index 00000000..5c3a9751 --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/notification/BiometricPromptActivity.kt @@ -0,0 +1,244 @@ +/* + * 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.notification + +import android.content.pm.PackageManager +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat +import com.pingidentity.authenticatorapp.AuthenticatorApp +import com.pingidentity.authenticatorapp.data.DiagnosticLogger +import com.pingidentity.authenticatorapp.ui.theme.PingIdentityAuthenticatorTheme +import com.pingidentity.mfa.commons.exception.CredentialNotFoundException +import com.pingidentity.mfa.push.exception.NotificationExpiredException +import com.pingidentity.mfa.push.exception.NotificationNotFoundException +import com.pingidentity.mfa.push.PushClient +import kotlinx.coroutines.launch + +/** + * Activity to handle biometric authentication for push notifications. + * Shows a biometric prompt and approves/denies the notification based on the result. + */ +class BiometricPromptActivity : AppCompatActivity() { + + private lateinit var pushClient: PushClient + + private val diagnosticLogger = DiagnosticLogger + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Get notification ID from intent early + val notificationId = intent?.getStringExtra(NotificationActionReceiver.EXTRA_NOTIFICATION_ID) + + // If no notification ID, log and finish + if (notificationId == null) { + diagnosticLogger.w("No notification ID provided") + finish() + return + } + + setContent { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + var isLoading by remember { mutableStateOf(true) } + var errorMessage by remember { mutableStateOf(null) } + var failureMessage by remember { mutableStateOf(null) } + + // Initialize and handle biometric authentication + LaunchedEffect(Unit) { + try { + pushClient = AuthenticatorApp.getPushClient(application as AuthenticatorApp) + + // Check if biometric authentication is available + val biometricManager = BiometricManager.from(context) + when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)) { + BiometricManager.BIOMETRIC_SUCCESS -> { + isLoading = false + showBiometricPrompt(notificationId, coroutineScope) { message -> + failureMessage = message + } + } + else -> { + diagnosticLogger.w("Biometric authentication not available") + errorMessage = "Biometric authentication not available" + isLoading = false + finish() + } + } + } catch (e: Exception) { + diagnosticLogger.e("Failed to initialize PushClient: ${e.message}", e) + errorMessage = "Failed to initialize. Please try again." + isLoading = false + finish() + } + } + + PingIdentityAuthenticatorTheme { + Surface { + when { + isLoading -> { + // Show loading indicator + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + errorMessage != null -> { + // Show error message + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text(text = errorMessage!!) + } + } + failureMessage != null -> { + // Show failure message + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text(text = failureMessage!!) + } + } + } + } + } + } + } + + /** + * Shows the biometric prompt on the main thread. + */ + private fun showBiometricPrompt( + notificationId: String, + coroutineScope: kotlinx.coroutines.CoroutineScope, + onFailure: (String) -> Unit + ) { + val executor = ContextCompat.getMainExecutor(this) + + val callback = object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + + coroutineScope.launch { + try { + // Approve the notification with biometric authentication + val authMethod = getBiometricMethodName() + approveBiometricNotification(notificationId, authMethod) + finish() + } catch (e: NotificationExpiredException) { + diagnosticLogger.e("Notification expired: ${e.message}", e) + onFailure("This notification has expired and can no longer be approved.") + } catch (e: NotificationNotFoundException) { + diagnosticLogger.e("Notification not found: ${e.message}", e) + onFailure("This notification is no longer available.") + } catch (e: CredentialNotFoundException) { + diagnosticLogger.e("Credential not found: ${e.message}", e) + onFailure("Credential not found. Please register again.") + } catch (e: Exception) { + diagnosticLogger.e("Failed to process approval: ${e.message}", e) + onFailure("Failed to approve notification: ${e.message}") + } + } + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + super.onAuthenticationError(errorCode, errString) + diagnosticLogger.w("Authentication error: $errString") + + // Show error message for non-cancellation errors + if (errorCode != BiometricPrompt.ERROR_USER_CANCELED && + errorCode != BiometricPrompt.ERROR_CANCELED && + errorCode != BiometricPrompt.ERROR_NEGATIVE_BUTTON) { + onFailure("Authentication error: $errString") + } else { + finish() + } + } + + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + diagnosticLogger.w("Authentication failed") + onFailure("Biometric authentication failed. Please try again.") + } + } + + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle("Authenticate") + .setSubtitle("Confirm your identity to approve the authentication request") + .setNegativeButtonText("Cancel") + .setConfirmationRequired(true) + .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG) + .build() + + val biometricPrompt = BiometricPrompt(this, executor, callback) + biometricPrompt.authenticate(promptInfo) + } + + /** + * Determines the biometric method name from the authentication result. + * Note: Android's BiometricPrompt API doesn't directly expose which method was used. + * This implementation checks device capabilities to make an educated guess. + */ + private fun getBiometricMethodName(): String { + // Check device features to determine likely biometric method + val packageManager = packageManager + + val hasFingerprint = packageManager.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT) + val hasFace = packageManager.hasSystemFeature(PackageManager.FEATURE_FACE) + val hasIris = packageManager.hasSystemFeature(PackageManager.FEATURE_IRIS) + + return when { + // If only one type is available, likely that was used + hasFingerprint && !hasFace && !hasIris -> "fingerprint" + hasFace && !hasFingerprint && !hasIris -> "face" + hasIris && !hasFingerprint && !hasFace -> "iris" + + // If multiple are available, fingerprint is most common default + hasFingerprint -> "fingerprint" + hasFace -> "face" + + // Fallback for unknown or generic biometric + else -> "biometric" + } + } + + /** + * Approves the notification with biometric authentication. + */ + private suspend fun approveBiometricNotification(notificationId: String, authMethod: String) { + try { + pushClient.approveBiometricNotification(notificationId, authMethod) + } catch (e: Exception) { + diagnosticLogger.e("Error approving biometric notification: ${e.message}", e) + throw e + } + } + +} diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/notification/NotificationActionReceiver.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/notification/NotificationActionReceiver.kt new file mode 100644 index 00000000..a348b671 --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/notification/NotificationActionReceiver.kt @@ -0,0 +1,122 @@ +/* + * 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.notification + +import android.app.Application +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationManagerCompat +import com.pingidentity.authenticatorapp.AuthenticatorApp +import com.pingidentity.authenticatorapp.data.DiagnosticLogger +import com.pingidentity.mfa.commons.exception.CredentialNotFoundException +import com.pingidentity.mfa.push.exception.NotificationExpiredException +import com.pingidentity.mfa.push.exception.NotificationNotFoundException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +/** + * BroadcastReceiver to handle notification actions. + */ +class NotificationActionReceiver : BroadcastReceiver() { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val diagnosticLogger = DiagnosticLogger + + companion object { + const val ACTION_APPROVE = "com.pingidentity.authenticatorapp.ACTION_APPROVE" + const val ACTION_DENY = "com.pingidentity.authenticatorapp.ACTION_DENY" + const val ACTION_BIOMETRIC = "com.pingidentity.authenticatorapp.ACTION_BIOMETRIC" + const val EXTRA_NOTIFICATION_ID = "notification_id" + } + + override fun onReceive(context: Context, intent: Intent) { + val notificationId = intent.getStringExtra(EXTRA_NOTIFICATION_ID) ?: return + val notificationHashCode = notificationId.hashCode() + + // Cancel the notification immediately to provide feedback that the action was received + NotificationManagerCompat.from(context).cancel(notificationHashCode) + + when (intent.action) { + ACTION_APPROVE -> { + diagnosticLogger.d("Approve action received for notification: $notificationId") + approveNotification(context, notificationId) + } + ACTION_DENY -> { + diagnosticLogger.d("Deny action received for notification: $notificationId") + denyNotification(context, notificationId) + } + ACTION_BIOMETRIC -> { + diagnosticLogger.d("Biometric action received for notification: $notificationId") + handleBiometricAuthentication(context, notificationId) + } + } + } + + /** + * Approves the notification with the given ID. + */ + private fun approveNotification(context: Context, notificationId: String) { + scope.launch { + try { + val applicationContext = context.applicationContext + val pushClient = AuthenticatorApp.getPushClient(applicationContext as Application) + pushClient.approveNotification(notificationId) + } catch (e: NotificationExpiredException) { + diagnosticLogger.w("Notification expired: ${e.message}", e) + // Notification has expired - user may see it was removed or marked expired in the app + } catch (e: NotificationNotFoundException) { + diagnosticLogger.w("Notification not found: ${e.message}", e) + // Notification was not found - may have been deleted + } catch (e: CredentialNotFoundException) { + diagnosticLogger.w("Credential not found: ${e.message}", e) + // Credential was not found - user needs to re-register + } catch (e: Exception) { + diagnosticLogger.e("Error approving notification: ${e.message}", e) + } + } + } + + /** + * Denies the notification with the given ID. + */ + private fun denyNotification(context: Context, notificationId: String) { + scope.launch { + try { + val applicationContext = context.applicationContext + val pushClient = AuthenticatorApp.getPushClient(applicationContext as Application) + pushClient.denyNotification(notificationId) + } catch (e: NotificationExpiredException) { + diagnosticLogger.w("Notification expired: ${e.message}", e) + // Notification has expired - user may see it was removed or marked expired in the app + } catch (e: NotificationNotFoundException) { + diagnosticLogger.w("Notification not found: ${e.message}", e) + // Notification was not found - may have been deleted + } catch (e: CredentialNotFoundException) { + diagnosticLogger.w("Credential not found: ${e.message}", e) + // Credential was not found - user needs to re-register + } catch (e: Exception) { + diagnosticLogger.e("Error denying notification: ${e.message}", e) + } + } + } + + /** + * Handles biometric authentication for the notification with the given ID. + * This launches the BiometricPrompt activity. + */ + private fun handleBiometricAuthentication(context: Context, notificationId: String) { + val intent = Intent(context, BiometricPromptActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + putExtra(EXTRA_NOTIFICATION_ID, notificationId) + } + context.startActivity(intent) + } +} diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/notification/NotificationHelper.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/notification/NotificationHelper.kt new file mode 100644 index 00000000..31cbee53 --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/notification/NotificationHelper.kt @@ -0,0 +1,206 @@ +/* + * 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.notification + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.annotation.RequiresPermission +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.pingidentity.authenticatorapp.R +import com.pingidentity.authenticatorapp.notification.NotificationActionReceiver.Companion.ACTION_APPROVE +import com.pingidentity.authenticatorapp.notification.NotificationActionReceiver.Companion.ACTION_DENY +import com.pingidentity.authenticatorapp.notification.NotificationActionReceiver.Companion.EXTRA_NOTIFICATION_ID +import com.pingidentity.mfa.push.PushNotification +import com.pingidentity.mfa.push.PushType + +/** + * Helper class for managing and displaying system notifications. + */ +class NotificationHelper(private val context: Context) { + + companion object { + const val CHANNEL_ID = "com.pingidentity.authenticatorapp.PUSH_NOTIFICATIONS" + const val NOTIFICATION_GROUP = "com.pingidentity.authenticatorapp.PUSH_NOTIFICATION_GROUP" + } + + /** + * Creates the notification channels needed by the app. + * This should be called at app startup. + */ + fun createNotificationChannels() { + val name = context.getString(R.string.notification_channel_name) + val descriptionText = context.getString(R.string.notification_channel_description) + val importance = NotificationManager.IMPORTANCE_HIGH // High importance for auth requests + + val channel = NotificationChannel(CHANNEL_ID, name, importance).apply { + description = descriptionText + enableVibration(true) + enableLights(true) + } + + // Register the channel with the system + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } + + /** + * Shows a notification for a push authentication request. + * + * @param notification The push notification to display + * @param issuer The issuer of the authentication request (if available) + * @param accountName The account name for the authentication request (if available) + */ + @RequiresPermission(android.Manifest.permission.POST_NOTIFICATIONS) + fun showPushAuthenticationNotification( + notification: PushNotification, + issuer: String?, + accountName: String? + ) { + val notificationId = notification.id.hashCode() + + // Create an intent that opens the PushNotificationActivity directly + val intent = Intent(context, PushNotificationActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + // Add notification ID + putExtra(EXTRA_NOTIFICATION_ID, notification.id) + } + + val pendingIntent = PendingIntent.getActivity( + context, notificationId, intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + // Build the notification title and content + val title = issuer ?: context.getString(R.string.system_notification_title) + + val content = when { + accountName != null -> "${context.getString(R.string.system_notification_content_for)} $accountName" + else -> context.getString(R.string.system_notification_content) + } + + // Build the notification + val builder = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(title) + .setContentText(content) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_CALL) // Authentication is similar to a call + .setAutoCancel(true) + .setContentIntent(pendingIntent) + .setGroup(NOTIFICATION_GROUP) + + // Add appropriate actions based on push type + when (notification.pushType) { + PushType.DEFAULT -> { + // For DEFAULT type, add approve and deny buttons + addDefaultTypeActions(builder, notification.id) + } + + PushType.BIOMETRIC -> { + // For BIOMETRIC type, add biometric authentication action + addBiometricTypeAction(builder, notification.id) + } + + PushType.CHALLENGE -> { + // For CHALLENGE type, we don't add actions - user must open app + builder.setContentText("$content ${context.getString(R.string.system_notification_challenge_required)}") + } + } + + // Show the notification + with(NotificationManagerCompat.from(context)) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + // Check for notification permission on Android 13+ + if (NotificationManagerCompat.from(context).areNotificationsEnabled()) { + notify(notificationId, builder.build()) + } + } else { + notify(notificationId, builder.build()) + } + } + } + + /** + * Adds approve and deny actions to a notification for DEFAULT push type. + */ + private fun addDefaultTypeActions(builder: NotificationCompat.Builder, notificationId: String) { + // Approve action + val approveIntent = Intent(context, NotificationActionReceiver::class.java).apply { + action = ACTION_APPROVE + putExtra(EXTRA_NOTIFICATION_ID, notificationId) + } + val approvePendingIntent = PendingIntent.getBroadcast( + context, + notificationId.hashCode(), + approveIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + // Deny action + val denyIntent = Intent(context, NotificationActionReceiver::class.java).apply { + action = ACTION_DENY + putExtra(EXTRA_NOTIFICATION_ID, notificationId) + } + val denyPendingIntent = PendingIntent.getBroadcast( + context, + notificationId.hashCode() + 1, // Ensure a different request code + denyIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + // Add the actions to the notification + builder + .addAction( + R.drawable.ic_close, // Use appropriate icon + context.getString(R.string.system_notification_deny), + denyPendingIntent + ) + .addAction( + R.drawable.ic_check, // Use appropriate icon + context.getString(R.string.system_notification_approve), + approvePendingIntent + ) + } + + /** + * Adds biometric authentication action to a notification for BIOMETRIC push type. + */ + private fun addBiometricTypeAction( + builder: NotificationCompat.Builder, + notificationId: String + ) { + // Instead of using BroadcastReceiver, directly create an activity intent for biometric authentication + val biometricIntent = Intent(context, BiometricPromptActivity::class.java).apply { + // Add flags to ensure the activity is shown when the device is locked or screen is off + flags = Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_CLEAR_TASK + putExtra(EXTRA_NOTIFICATION_ID, notificationId) + } + + // Create a PendingIntent for the activity + val biometricPendingIntent = PendingIntent.getActivity( + context, + notificationId.hashCode(), + biometricIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + // Add the action to the notification + builder.addAction( + R.drawable.ic_fingerprint, // Use appropriate icon + context.getString(R.string.system_notification_authenticate), + biometricPendingIntent + ) + } +} diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/notification/PushNotificationActivity.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/notification/PushNotificationActivity.kt new file mode 100644 index 00000000..a28bb499 --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/notification/PushNotificationActivity.kt @@ -0,0 +1,239 @@ +/* + * 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.notification + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import com.pingidentity.authenticatorapp.AuthenticatorApp +import com.pingidentity.authenticatorapp.data.DiagnosticLogger +import com.pingidentity.authenticatorapp.data.PushNotificationItem +import com.pingidentity.authenticatorapp.data.createPushNotificationItem +import com.pingidentity.authenticatorapp.notification.NotificationActionReceiver.Companion.EXTRA_NOTIFICATION_ID +import com.pingidentity.authenticatorapp.ui.NotificationResponseScreen +import com.pingidentity.authenticatorapp.ui.theme.PingIdentityAuthenticatorTheme +import com.pingidentity.mfa.commons.exception.CredentialNotFoundException +import com.pingidentity.mfa.push.exception.NotificationExpiredException +import com.pingidentity.mfa.push.exception.NotificationNotFoundException +import com.pingidentity.mfa.push.PushClient +import kotlinx.coroutines.launch + +/** + * Activity to handle full-screen display of push notifications. + * This activity is launched when a notification is received or when the user clicks on a notification. + */ +class PushNotificationActivity : ComponentActivity() { + + private lateinit var pushClient: PushClient + + private val diagnosticLogger = DiagnosticLogger + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Get notification ID from intent + val notificationId = intent?.getStringExtra(EXTRA_NOTIFICATION_ID) + + // If no notification ID, log and finish + if (notificationId == null) { + diagnosticLogger.w("No notification ID provided") + finish() + return + } + + // Set content to show notification details + setContent { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + var isLoading by remember { mutableStateOf(true) } + var notificationItemState by remember { mutableStateOf(null) } + var errorMessage by remember { mutableStateOf(null) } + + // Load the notification when the composable is first launched + LaunchedEffect(Unit) { + try { + pushClient = AuthenticatorApp.getPushClient(application) + notificationItemState = loadNotification(notificationId) + isLoading = false + } catch (e: Exception) { + diagnosticLogger.w("Error loading notification: ${e.message}") + errorMessage = "Failed to load notification: ${e.message}" + isLoading = false + } + } + + PingIdentityAuthenticatorTheme { + Surface { + val currentNotificationItem = notificationItemState // Use a local copy for smart casting + when { + isLoading -> { + // Show loading indicator + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + errorMessage != null -> { + // Show error message + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text(text = errorMessage!!) + } + } + currentNotificationItem != null -> { + // Display the unified notification screen + NotificationResponseScreen( + notificationItem = currentNotificationItem, + onDismiss = { finish() }, + onApprove = { + coroutineScope.launch { + try { + pushClient.approveNotification(notificationId) + .onSuccess { finish() } + .onFailure { e -> + diagnosticLogger.e("Error approving notification: ${e.message}", e) + errorMessage = when (e) { + is NotificationExpiredException -> "This notification has expired and can no longer be approved." + is NotificationNotFoundException -> "This notification is no longer available." + is CredentialNotFoundException -> "Credential not found. Please register again." + else -> "Failed to approve: ${e.message}" + } + } + } catch (e: Exception) { + diagnosticLogger.e("Error approving notification: ${e.message}", e) + errorMessage = when (e) { + is NotificationExpiredException -> "This notification has expired and can no longer be approved." + is NotificationNotFoundException -> "This notification is no longer available." + is CredentialNotFoundException -> "Credential not found. Please register again." + else -> "Failed to approve: ${e.message}" + } + } + } + }, + onBiometricApprove = { + launchBiometricPrompt(notificationId) + }, + onDeny = { + coroutineScope.launch { + try { + pushClient.denyNotification(notificationId) + .onSuccess { finish() } + .onFailure { e -> + diagnosticLogger.e("Error denying notification: ${e.message}", e) + errorMessage = when (e) { + is NotificationExpiredException -> "This notification has expired and can no longer be denied." + is NotificationNotFoundException -> "This notification is no longer available." + is CredentialNotFoundException -> "Credential not found. Please register again." + else -> "Failed to deny: ${e.message}" + } + } + } catch (e: Exception) { + diagnosticLogger.e("Error denying notification: ${e.message}", e) + errorMessage = when (e) { + is NotificationExpiredException -> "This notification has expired and can no longer be denied." + is NotificationNotFoundException -> "This notification is no longer available." + is CredentialNotFoundException -> "Credential not found. Please register again." + else -> "Failed to deny: ${e.message}" + } + } + } + }, + onChallengeSolution = { solution -> + coroutineScope.launch { + try { + pushClient.approveChallengeNotification( + notificationId, + solution + ).onSuccess { + finish() + }.onFailure { e -> + diagnosticLogger.e("Error approving with challenge: ${e.message}", e) + errorMessage = when (e) { + is NotificationExpiredException -> "This notification has expired and can no longer be approved." + is NotificationNotFoundException -> "This notification is no longer available." + is CredentialNotFoundException -> "Credential not found. Please register again." + else -> "Failed to approve: ${e.message}" + } + } + } catch (e: Exception) { + diagnosticLogger.e("Error approving with challenge: ${e.message}", e) + errorMessage = when (e) { + is NotificationExpiredException -> "This notification has expired and can no longer be approved." + is NotificationNotFoundException -> "This notification is no longer available." + is CredentialNotFoundException -> "Credential not found. Please register again." + else -> "Failed to approve: ${e.message}" + } + } + } + } + ) + } + } + } + } + } + } + + /** + * Loads a notification by ID and wraps it in a PushNotificationItem + */ + private suspend fun loadNotification(notificationId: String): PushNotificationItem? { + return try { + // Handle Result for getNotification + val notificationResult = pushClient.getNotification(notificationId) + val notification = notificationResult.getOrNull() // Extract value or null if error + + // Handle Result for getCredentials + val credentialsResult = pushClient.getCredentials() + val credentials = credentialsResult.getOrNull() // Extract value or null if error + + if (notification != null && credentials != null) { + createPushNotificationItem(credentials, notification) + } else { + // Log specific errors if results were failures + notificationResult.onFailure { e -> diagnosticLogger.e( "Error fetching notification: ${e.message}", e) } + credentialsResult.onFailure { e -> diagnosticLogger.e("Error fetching credentials: ${e.message}", e) } + null + } + } catch (e: Exception) { // Catch any other synchronous exceptions + diagnosticLogger.e("Error loading notification data", e) + null + } + } + + /** + * Launches the BiometricPromptActivity for biometric authentication. + */ + private fun launchBiometricPrompt(notificationId: String) { + val intent = Intent(this, BiometricPromptActivity::class.java).apply { + putExtra(EXTRA_NOTIFICATION_ID, notificationId) + } + startActivity(intent) + finish() // finish current activity before launching new one. + } + +} diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/service/LocationService.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/service/LocationService.kt new file mode 100644 index 00000000..c520ca41 --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/service/LocationService.kt @@ -0,0 +1,90 @@ +/* + * 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.service + +import com.pingidentity.authenticatorapp.data.LocationAddress +import com.pingidentity.authenticatorapp.data.NominatimResponse +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.parameter +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json + +/** + * Service for performing reverse geocoding using OpenStreetMap's Nominatim API + */ +class LocationService { + + companion object { + private const val NOMINATIM_BASE_URL = "https://nominatim.openstreetmap.org" + private const val TIMEOUT_SECONDS = 10_000L + + // Nominatim usage policy requires setting a User-Agent + private const val USER_AGENT = "PingAuthenticatorSampleApp/1.0" + } + + private val httpClient = HttpClient(CIO) { + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + coerceInputValues = true + }) + } + // Configure timeout + install(HttpTimeout) { + connectTimeoutMillis = TIMEOUT_SECONDS / 1000 + requestTimeoutMillis = TIMEOUT_SECONDS / 1000 + } + } + + /** + * Performs reverse geocoding to convert latitude/longitude to a human-readable address + * + * @param latitude The latitude coordinate + * @param longitude The longitude coordinate + * @return LocationAddress with city, state, and country, or null if unable to resolve + */ + suspend fun reverseGeocode(latitude: Double, longitude: Double): LocationAddress? { + return withContext(Dispatchers.IO) { + try { + val response = httpClient.get("$NOMINATIM_BASE_URL/reverse") { + parameter("lat", latitude) + parameter("lon", longitude) + parameter("format", "json") + parameter("addressdetails", "1") + parameter("zoom", "10") + + // Required by Nominatim usage policy + header("User-Agent", USER_AGENT) + } + + val nominatimResponse = response.body() + LocationAddress.fromNominatim(nominatimResponse) + + } catch (e: Exception) { + // Log error but don't crash - return null to show coordinates as fallback + println("LocationService: Failed to reverse geocode lat=$latitude, lon=$longitude: ${e.message}") + null + } + } + } + + /** + * Clean up resources when done + */ + fun close() { + httpClient.close() + } +} \ No newline at end of file diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/service/PushNotificationService.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/service/PushNotificationService.kt new file mode 100644 index 00000000..7f75008b --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/service/PushNotificationService.kt @@ -0,0 +1,175 @@ +package com.pingidentity.authenticatorapp.service + +import android.app.ActivityManager +import android.content.Intent +import androidx.annotation.RequiresPermission +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import com.pingidentity.authenticatorapp.AuthenticatorApp +import com.pingidentity.authenticatorapp.data.DiagnosticLogger +import com.pingidentity.authenticatorapp.notification.NotificationActionReceiver +import com.pingidentity.authenticatorapp.notification.NotificationHelper +import com.pingidentity.authenticatorapp.notification.PushNotificationActivity +import com.pingidentity.mfa.push.PushClient +import com.pingidentity.mfa.push.PushNotification +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +/** + * Service to handle incoming Firebase Cloud Messaging notifications. + */ +class PushNotificationService : FirebaseMessagingService() { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var pushClient: PushClient? = null + + private val diagnosticLogger = DiagnosticLogger + + private lateinit var notificationHelper: NotificationHelper + + + override fun onCreate() { + super.onCreate() + diagnosticLogger.d("PushNotificationService instance created") + + notificationHelper = NotificationHelper(this) + notificationHelper.createNotificationChannels() + + scope.launch { + pushClient = AuthenticatorApp.Companion.getPushClient(application) + } + } + + override fun onDestroy() { + super.onDestroy() + diagnosticLogger.d("PushNotificationService instance destroyed") + } + + /** + * Checks if the application is currently in foreground. + * + * @return True if the app is in foreground, false otherwise + */ + private fun isAppInForeground(): Boolean { + val activityManager = getSystemService(ACTIVITY_SERVICE) as ActivityManager + val appProcesses = activityManager.runningAppProcesses ?: return false + val packageName = packageName + + for (appProcess in appProcesses) { + if (appProcess.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND && + appProcess.processName == packageName) { + return true + } + } + return false + } + + /** + * Called when a new token is generated. + */ + override fun onNewToken(token: String) { + diagnosticLogger.d("New FCM token: ${token.take(8)}...${token.takeLast(4)}") + scope.launch { + // Update the device token in the PushClient + pushClient?.setDeviceToken(token) + } + } + + /** + * Called when a message is received. + */ + @RequiresPermission(android.Manifest.permission.POST_NOTIFICATIONS) + override fun onMessageReceived(remoteMessage: RemoteMessage) { + diagnosticLogger.d("Message received from: ${remoteMessage.from}") + + // Handle the message data payload + if (remoteMessage.data.isNotEmpty()) { + diagnosticLogger.d("Message data payload: ${remoteMessage.data}") + + scope.launch { + try { + // Process the notification using PushClient directly + val result = pushClient?.processNotification(remoteMessage.data as Map)?.getOrNull() + result?.let { notification -> + handleNotification(notification) + } + } catch (e: Exception) { + diagnosticLogger.e("Error processing notification: ${e.message}") + } + } + } + } + + /** + * Displays a system notification for the push authentication request. + */ + @RequiresPermission(android.Manifest.permission.POST_NOTIFICATIONS) + private fun displaySystemNotification(notification: PushNotification) { + // Find the associated credential to get issuer and account name + scope.launch(Dispatchers.Main) { + try { + val credentials = pushClient?.getCredentials()?.getOrElse { emptyList() } ?: emptyList() + val credential = credentials.find { it.id == notification.credentialId } + + // Display the notification with credential info if available + notificationHelper.showPushAuthenticationNotification( + notification = notification, + issuer = credential?.issuer, + accountName = credential?.accountName + ) + } catch (e: Exception) { + diagnosticLogger.e("Error displaying notification: ${e.message}") + // Fall back to showing a notification without credential details + notificationHelper.showPushAuthenticationNotification( + notification = notification, + issuer = null, + accountName = null + ) + } + } + } + + /** + * Shows a full-screen notification when the app is in the foreground. + * This launches the PushNotificationActivity directly. + * + * @param notification The push notification to display + */ + private fun showFullScreenNotification(notification: PushNotification) { + scope.launch(Dispatchers.Main) { + try { + diagnosticLogger.d("Showing full screen notification: ${notification.id}") + + // Launch the PushNotificationActivity with the notification ID + val intent = Intent(applicationContext, PushNotificationActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP + putExtra(NotificationActionReceiver.Companion.EXTRA_NOTIFICATION_ID, notification.id) + } + + startActivity(intent) + } catch (e: Exception) { + diagnosticLogger.e("Error showing full-screen notification: ${e.message}") + } + } + } + + /** + * Handle a notification that's already been processed. + * This displays system notifications and launches full-screen notifications when appropriate. + */ + @RequiresPermission(android.Manifest.permission.POST_NOTIFICATIONS) + fun handleNotification(notification: PushNotification) { + diagnosticLogger.d("Handling notification: ${notification.id}") + + // If app is in foreground, also display the notification full screen immediately + if (isAppInForeground()) { + diagnosticLogger.d("App is in foreground, launching notification activity") + showFullScreenNotification(notification) + } else { + diagnosticLogger.d("App is in background, displaying system notification") + displaySystemNotification(notification) + } + } +} \ No newline at end of file diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/AboutScreen.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/AboutScreen.kt new file mode 100644 index 00000000..adc3ddad --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/AboutScreen.kt @@ -0,0 +1,173 @@ +/* + * 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.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.pingidentity.authenticatorapp.R + +/** + * Screen displaying information about the application. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AboutScreen( + onDismiss: () -> Unit +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(id = R.string.about_screen_title)) }, + navigationIcon = { + IconButton(onClick = onDismiss) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(id = R.string.back) + ) + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // App Logo + Image( + painter = painterResource(id = R.drawable.ping_logo), + contentDescription = "Ping Identity Logo", + modifier = Modifier.size(80.dp) + ) + + // App Name and Version + Text( + text = stringResource(id = R.string.app_name), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + + Text( + text = stringResource(id = R.string.app_version), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Description Card + Card( + modifier = Modifier.padding(horizontal = 8.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = stringResource(id = R.string.about_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Text( + text = stringResource(id = R.string.about_description), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Justify + ) + } + } + + // Features Card + Card( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 8.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = stringResource(id = R.string.features_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Text( + text = stringResource(id = R.string.feature_oath), + style = MaterialTheme.typography.bodyMedium + ) + + Text( + text = stringResource(id = R.string.feature_push), + style = MaterialTheme.typography.bodyMedium + ) + + Text( + text = stringResource(id = R.string.feature_qr), + style = MaterialTheme.typography.bodyMedium + ) + + Text( + text = stringResource(id = R.string.feature_account_management), + style = MaterialTheme.typography.bodyMedium + ) + + Text( + text = stringResource(id = R.string.feature_secure_storage), + style = MaterialTheme.typography.bodyMedium + ) + } + } + + // Copyright + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(id = R.string.copyright), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + } + } +} \ No newline at end of file diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/AccountDetailScreen.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/AccountDetailScreen.kt new file mode 100644 index 00000000..5578d708 --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/AccountDetailScreen.kt @@ -0,0 +1,397 @@ +/* + * 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.ui + +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Snackbar +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.pingidentity.authenticatorapp.R +import com.pingidentity.authenticatorapp.data.AuthenticatorViewModel +import com.pingidentity.authenticatorapp.ui.components.AccountAvatar +import com.pingidentity.authenticatorapp.ui.components.BackNavigationTopAppBar +import com.pingidentity.authenticatorapp.ui.components.CircularProgressTimer +import com.pingidentity.authenticatorapp.ui.components.DetailRow +import com.pingidentity.authenticatorapp.ui.components.ErrorAlertDialog +import com.pingidentity.authenticatorapp.ui.components.InfoCard +import com.pingidentity.mfa.oath.OathCodeInfo +import com.pingidentity.mfa.oath.OathCredential +import com.pingidentity.mfa.oath.OathType +import com.pingidentity.mfa.push.PushCredential +import kotlinx.coroutines.delay + +/** + * Screen for displaying account details with both OATH and PUSH credentials. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AccountDetailScreen( + issuer: String, + accountName: String, + viewModel: AuthenticatorViewModel, + onDismiss: () -> Unit +) { + val uiState by viewModel.uiState.collectAsState() + rememberCoroutineScope() + + // Find all credentials matching the issuer and account name + val oathCredentials = uiState.oathCredentials.filter { + it.issuer == issuer && it.accountName == accountName + } + val pushCredentials = uiState.pushCredentials.filter { + it.issuer == issuer && it.accountName == accountName + } + + // Get codes for all OATH credentials + val oathCodesMap = oathCredentials.associateWith { credential -> + uiState.generatedCodes[credential.id] + } + + // Clipboard manager to copy codes + val clipboardManager = LocalClipboardManager.current + var showCopyConfirmation by remember { mutableStateOf(false) } + + // Auto-refresh for TOTP codes for all OATH credentials + LaunchedEffect(oathCredentials) { + if (oathCredentials.isNotEmpty()) { + while (true) { + oathCredentials.forEach { credential -> + if (credential.oathType == OathType.TOTP) { + viewModel.generateCode(credential.id) + } + } + delay(1000) + } + } + } + + // Generate initial codes for HOTP credentials + LaunchedEffect(oathCredentials) { + oathCredentials.forEach { credential -> + if (credential.oathType == OathType.HOTP && oathCodesMap[credential] == null) { + viewModel.generateCode(credential.id) + } + } + } + + + // Copy toast timeout + LaunchedEffect(showCopyConfirmation) { + if (showCopyConfirmation) { + delay(2000) + showCopyConfirmation = false + } + } + + // Get display names from the first available credential + val displayIssuer = oathCredentials.firstOrNull()?.displayIssuer + ?: pushCredentials.firstOrNull()?.displayIssuer + ?: issuer + val displayAccountName = oathCredentials.firstOrNull()?.displayAccountName + ?: pushCredentials.firstOrNull()?.displayAccountName + ?: accountName + + // Use the display issuer for the title + val accountIssuer = displayIssuer.ifEmpty { stringResource(id = R.string.account_detail_empty_issuer) } + + Scaffold( + topBar = { + BackNavigationTopAppBar( + title = accountIssuer, + onBackClick = onDismiss + ) + } + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + if (oathCredentials.isEmpty() && pushCredentials.isEmpty()) { + // Account not found + Text( + text = stringResource(id = R.string.account_detail_no_credentials), + modifier = Modifier + .align(Alignment.Center) + .padding(16.dp) + ) + } else { + // Account details + Column( + modifier = Modifier + .fillMaxSize() + .padding(8.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Account Image/Avatar + val imageUrl = oathCredentials.firstOrNull()?.imageURL + ?: pushCredentials.firstOrNull()?.imageURL + + AccountAvatar( + issuer = displayIssuer, + accountName = displayAccountName, + imageUrl = imageUrl, + size = 60.dp + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Issuer and Account Name below the logo + Text( + text = displayIssuer, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + + Text( + text = displayAccountName, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // OATH Section + if (oathCredentials.isNotEmpty()) { + OathCredentialsSection( + oathCredentials = oathCredentials, + oathCodesMap = oathCodesMap, + onGenerateCode = { credentialId -> viewModel.generateCode(credentialId) }, + onCopyCode = { code -> + clipboardManager.setText(AnnotatedString(code)) + showCopyConfirmation = true + } + ) + + Spacer(modifier = Modifier.height(8.dp)) + } + + // PUSH Section + if (pushCredentials.isNotEmpty()) { + PushCredentialsSection( + pushCredentials = pushCredentials + ) + } + } + } + + // Show copy confirmation + if (showCopyConfirmation) { + Snackbar( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(16.dp) + ) { + Text(stringResource(id = R.string.account_detail_code_copied)) + } + } + + + // Error handling + if (uiState.error != null) { + ErrorAlertDialog( + errorMessage = uiState.error!!, + onDismiss = { viewModel.clearError() } + ) + } + } + } +} + +@Composable +fun OathCredentialsSection( + oathCredentials: List, + oathCodesMap: Map, + onGenerateCode: (String) -> Unit, + onCopyCode: (String) -> Unit +) { + val context = LocalContext.current + InfoCard( + title = stringResource(id = R.string.account_detail_oath) + ) { + Column { + + oathCredentials.forEachIndexed { index, credential -> + if (index > 0) { + Spacer(modifier = Modifier.height(16.dp)) + } + + // Display code if available + val codeInfo = oathCodesMap[credential] + codeInfo?.let { info -> + // Calculate progress for TOTP + val progress = if (credential.oathType == OathType.TOTP) { + info.progress.toFloat() + } else { + 0f + } + + // Code with countdown timer + Box( + modifier = Modifier + .padding(vertical = 16.dp) + .size(150.dp) + .align(Alignment.CenterHorizontally), + contentAlignment = Alignment.Center + ) { + // Circular progress indicator for TOTP + if (credential.oathType == OathType.TOTP) { + CircularProgressTimer( + progress = progress, + modifier = Modifier.matchParentSize() + ) + } + + // Show the actual code + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = info.code, + style = MaterialTheme.typography.headlineMedium + ) + } + } + + // Action buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally) + ) { + Button( + onClick = { onCopyCode(info.code) } + ) { + Icon(Icons.Default.ContentCopy, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(id = R.string.copy)) + } + + // Refresh button for HOTP + if (credential.oathType == OathType.HOTP) { + Button( + onClick = { onGenerateCode(credential.id) } + ) { + Icon(Icons.Default.Refresh, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(id = R.string.new_code)) + } + } + } + } ?: run { + // No code available, show generate button + Button( + onClick = { onGenerateCode(credential.id) }, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(16.dp) + ) { + Text(stringResource(id = R.string.generate_code)) + } + } + + // Credential details + Spacer(modifier = Modifier.height(16.dp)) + DetailRow(label = stringResource(id = R.string.detail_row_type), value = credential.oathType.name) + DetailRow(label = stringResource(id = R.string.detail_row_algorithm), value = credential.oathAlgorithm.name) + DetailRow(label = stringResource(id = R.string.detail_row_digits), value = credential.digits.toString()) + if (credential.oathType == OathType.TOTP) { + DetailRow(label = stringResource(id = R.string.detail_row_period), value = stringResource(id = R.string.period_seconds, credential.period)) + } + DetailRow(label = stringResource(id = R.string.detail_row_created), value = formatDate(context, credential.createdAt)) + credential.userId?.let { userId -> + DetailRow(label = stringResource(id = R.string.detail_row_user_id), value = userId) + } + } + } + } +} + +@Composable +fun PushCredentialsSection( + pushCredentials: List +) { + val context = LocalContext.current + InfoCard( + title = stringResource(id = R.string.account_detail_push) + ) { + Column { + pushCredentials.forEachIndexed { index, credential -> + if (index > 0) { + Spacer(modifier = Modifier.height(16.dp)) + } + + DetailRow(label = stringResource(id = R.string.detail_row_platform), value = formatPlatform(context, credential.platform)) + DetailRow(label = stringResource(id = R.string.detail_row_created), value = formatDate(context, credential.createdAt)) + credential.userId?.let { userId -> + DetailRow(label = stringResource(id = R.string.detail_row_user_id), value = userId) + } + } + } + } +} + +// Helper function to format platform name +private fun formatPlatform(context: Context, platform: String): String { + return when (platform) { + "PING_AM" -> context.getString(R.string.platform_ping_am) + "PING_ONE" -> context.getString(R.string.platform_ping_one) + else -> platform + } +} + +// Helper function to format date +private fun formatDate(context: Context, date: java.util.Date): String { + val now = java.util.Date() + val diffInMillis = now.time - date.time + val diffInDays = diffInMillis / (1000 * 60 * 60 * 24) + + return when { + diffInDays == 0L -> context.getString(R.string.date_today) + diffInDays == 1L -> context.getString(R.string.date_yesterday) + diffInDays < 7 -> context.getString(R.string.date_days_ago, diffInDays) + diffInDays < 30 -> context.getString(R.string.date_weeks_ago, diffInDays / 7) + diffInDays < 365 -> context.getString(R.string.date_months_ago, diffInDays / 30) + else -> context.getString(R.string.date_years_ago, diffInDays / 365) + } +} + diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/AccountsScreen.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/AccountsScreen.kt new file mode 100644 index 00000000..ee5326d8 --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/AccountsScreen.kt @@ -0,0 +1,511 @@ +/* + * 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.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Keyboard +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.QrCodeScanner +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.pingidentity.authenticatorapp.R +import com.pingidentity.authenticatorapp.data.AuthenticatorViewModel +import com.pingidentity.authenticatorapp.ui.components.AccountGroupItem +import com.pingidentity.authenticatorapp.ui.components.EmptyStateMessage +import com.pingidentity.authenticatorapp.ui.components.ErrorAlertDialog +import com.pingidentity.authenticatorapp.ui.components.LoadingIndicator +import com.pingidentity.mfa.oath.OathType +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.net.URLEncoder + +private const val TOTP_REFRESH_INTERVAL_MS = 30_000L + +/** + * Screen for displaying a list of accounts and push notifications. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AccountsScreen( + viewModel: AuthenticatorViewModel, + onScanQrCode: () -> Unit, + onAddManually: () -> Unit, + onAccountClick: (String) -> Unit, + onNotificationsClick: () -> Unit, + onSettingsClick: () -> Unit, + onAboutClick: () -> Unit, + onEditAccountsClick: () -> Unit, + onTestModeClick: () -> Unit = {}, + onNavigateToLogin: () -> Unit = {} +) { + val context = LocalContext.current + val uiState by viewModel.uiState.collectAsState() + val coroutineScope = rememberCoroutineScope() + + // Collect settings state + val copyOtpEnabled by viewModel.copyOtp.collectAsState() + val tapToRevealEnabled by viewModel.tapToReveal.collectAsState() + + // State for triggering progress bar updates + var currentTimeMillis by remember { mutableLongStateOf(System.currentTimeMillis()) } + + // Initial generation of codes (HOTP always, TOTP when missing) + LaunchedEffect(uiState.oathCredentials) { + uiState.oathCredentials.forEach { credential -> + when (credential.oathType) { + OathType.HOTP -> { + // Always generate HOTP codes when credentials change + viewModel.generateCode(credential.id) + } + OathType.TOTP -> { + // Generate TOTP codes if not locked and no code exists yet + if (!credential.isLocked && uiState.generatedCodes[credential.id] == null) { + viewModel.generateCode(credential.id) + } + } + } + } + } + + // Auto-refresh TOTP codes with intelligent delay + LaunchedEffect(uiState.oathCredentials) { + while (isActive) { + // Get the current list of TOTP credentials + val totpCredentials = uiState.oathCredentials.filter { it.oathType == OathType.TOTP } + + // If no TOTP credentials, just delay for a default longer period and check again. + if (totpCredentials.isEmpty()) { + delay(TOTP_REFRESH_INTERVAL_MS) + continue + } + + val currentTimeSeconds = System.currentTimeMillis() / 1000L + + // Calculate the minimum time remaining before any TOTP code expires. + // This is period - (currentTimeSeconds % period) for each credential. + val minRemainingTimeMillis = totpCredentials.mapNotNull { credential -> + val periodSeconds = credential.period.toLong() + // Ensure period is valid for TOTP (e.g., greater than 0) + if (periodSeconds <= 0) { + return@mapNotNull null + } + + // Calculate time elapsed in the current code's validity window + val timeIntoCurrentPeriodSlot = currentTimeSeconds % periodSeconds + // Calculate time remaining until this specific code expires + val remainingTimeInSlotSeconds = periodSeconds - timeIntoCurrentPeriodSlot + + remainingTimeInSlotSeconds * 1000L // Convert to milliseconds + }.minOrNull() // Find the smallest remaining time among all credentials + + // Determine the actual delay duration. + // Use a fallback if calculation yields null (e.g., no valid periods found), + // and ensure a minimum delay to prevent extremely rapid loops. + val delayDuration = maxOf(1000L, minRemainingTimeMillis ?: TOTP_REFRESH_INTERVAL_MS) + + delay(delayDuration) // Wait until the soonest OTP is expected to change + + // After the delay, at least one code has likely expired or is just about to. + // It's time to regenerate/refresh codes for all active TOTP credentials. + // Re-filter the credentials from uiState in case the list changed during the delay. + // (Though if uiState.oathCredentials itself changes, LaunchedEffect will restart). + val credentialsToRefresh = uiState.oathCredentials.filter { it.oathType == OathType.TOTP } + credentialsToRefresh.forEach { credential -> + // Check isActive again in case the coroutine was cancelled during the delay or processing + if (!isActive) return@forEach + viewModel.generateCode(credential.id) + } + } + } + + // Update progress bars every second for smooth countdown without regenerating codes + LaunchedEffect(Unit) { + while (isActive) { + delay(1000) + currentTimeMillis = System.currentTimeMillis() // Trigger recomposition + } + } + + // Show fab menu state + var showFabMenu by remember { mutableStateOf(false) } + + // Show hamburger menu state + var showHamburgerMenu by remember { mutableStateOf(false) } + + // Snackbar state + val snackbarHostState = remember { SnackbarHostState() } + + // Handle success messages + LaunchedEffect(uiState.message) { + uiState.message?.let { message -> + snackbarHostState.showSnackbar(message) + viewModel.clearMessage() + } + } + + // Handle error messages + LaunchedEffect(uiState.error) { + uiState.error?.let { error -> + snackbarHostState.showSnackbar(error) + viewModel.clearError() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Image( + painter = painterResource(id = R.drawable.ping_logo), + contentDescription = "Ping Identity Logo", + modifier = Modifier + .size(32.dp) + .padding(end = 4.dp) + ) + Text(text = stringResource(id = R.string.accounts_screen_title)) + } + }, + actions = { + // Actions only visible when test mode is enabled + val testModeEnabled by viewModel.testMode.collectAsState() + if (testModeEnabled) { + // Refresh button to manually refresh codes and check for notifications + IconButton(onClick = { + viewModel.refreshCredentials() + viewModel.refreshNotifications() + }) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "Refresh" + ) + } + // Test mode button + IconButton(onClick = { onTestModeClick() }) { + Icon( + imageVector = Icons.Default.BugReport, + contentDescription = "Test Mode" + ) + } + } + + // Hamburger menu + Box { + IconButton(onClick = { showHamburgerMenu = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = "Menu" + ) + } + + DropdownMenu( + expanded = showHamburgerMenu, + onDismissRequest = { showHamburgerMenu = false } + ) { + // Notifications with badge + DropdownMenuItem( + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Notifications, + contentDescription = null, + modifier = Modifier.padding(end = 12.dp) + ) + Text("Notifications") + + // Show a badge if there are pending notifications + if (uiState.pushNotificationItems.isNotEmpty()) { + Box( + modifier = Modifier + .size(8.dp) + .background( + color = MaterialTheme.colorScheme.error, + shape = CircleShape + ) + .padding(start = 8.dp) + ) + } + } + }, + onClick = { + showHamburgerMenu = false + onNotificationsClick() + } + ) + + DropdownMenuItem( + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = null, + modifier = Modifier.padding(end = 12.dp) + ) + Text("Edit Accounts") + } + }, + onClick = { + showHamburgerMenu = false + onEditAccountsClick() + } + ) + + DropdownMenuItem( + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = null, + modifier = Modifier.padding(end = 12.dp) + ) + Text("Settings") + } + }, + onClick = { + showHamburgerMenu = false + onSettingsClick() + } + ) + + DropdownMenuItem( + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + modifier = Modifier.padding(end = 12.dp) + ) + Text(stringResource(id = R.string.menu_about)) + } + }, + onClick = { + showHamburgerMenu = false + onAboutClick() + } + ) + } + } + } + ) + }, + floatingActionButton = { + Column(horizontalAlignment = Alignment.End) { + AnimatedVisibility( + visible = showFabMenu, + enter = fadeIn(), + exit = fadeOut() + ) { + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Scan QR code option + FloatingActionButton( + onClick = { + showFabMenu = false + onScanQrCode() + }, + modifier = Modifier.size(48.dp), + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) { + Icon( + imageVector = Icons.Default.QrCodeScanner, + contentDescription = "Scan QR Code" + ) + } + + // Manual entry option + FloatingActionButton( + onClick = { + showFabMenu = false + onAddManually() + }, + modifier = Modifier.size(48.dp), + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) { + Icon( + imageVector = Icons.Default.Keyboard, + contentDescription = "Add Manually" + ) + } + + // Login option + FloatingActionButton( + onClick = { + showFabMenu = false + onNavigateToLogin() + }, + modifier = Modifier.size(48.dp), + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) { + Icon( + imageVector = Icons.Default.Person, + contentDescription = "Journey Login" + ) + } + } + } + + // Primary FAB + FloatingActionButton( + onClick = { showFabMenu = !showFabMenu }, + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(id = R.string.content_description_add_account) + ) + } + } + }, + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) + } + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + // Loading progress indicator at the top when refreshing + if (uiState.isRefreshing) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) + } + + when { + uiState.isInitialLoading -> { + LoadingIndicator( + message = stringResource(id = R.string.loading_credentials) + ) + } + uiState.accountGroups.isEmpty() -> { + EmptyStateMessage( + title = "No accounts added yet", + subtitle = stringResource(id = R.string.accounts_empty_state_subtitle) + ) + } + else -> { + // List of account groups + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items( + items = uiState.accountGroups, + key = { accountGroup -> + // Create a unique key using issuer, account name, and all credential IDs + val oathIds = accountGroup.oathCredentials.map { it.id }.sorted().joinToString(",") + val pushIds = accountGroup.pushCredentials.map { it.id }.sorted().joinToString(",") + "${accountGroup.issuer}-${accountGroup.accountName}-oath:$oathIds-push:$pushIds" + } + ) { accountGroup -> + AccountGroupItem( + accountGroup = accountGroup, + codes = uiState.generatedCodes, + onRefreshCode = { credentialId -> + coroutineScope.launch { + viewModel.generateCode(credentialId) + } + }, + onItemClick = { + // Pass the account group issuer and account name for navigation + // This allows the detail screen to display all credentials for this account + val encodedIssuer = URLEncoder.encode(accountGroup.issuer, "UTF-8") + val encodedAccountName = URLEncoder.encode(accountGroup.accountName, "UTF-8") + onAccountClick("$encodedIssuer/$encodedAccountName") + }, + onCopyToClipboard = { text, label -> + viewModel.copyToClipboard(context, text, label) + }, + copyOtpEnabled = copyOtpEnabled, + tapToRevealEnabled = tapToRevealEnabled, + currentTimeMillis = currentTimeMillis, + modifier = Modifier.animateItem( + fadeInSpec = null, fadeOutSpec = null, placementSpec = spring( + stiffness = Spring.StiffnessMediumLow, + visibilityThreshold = IntOffset.VisibilityThreshold + ) + ) + ) + } + } + } + } + + // Error handling + if (uiState.error != null) { + ErrorAlertDialog( + errorMessage = uiState.error!!, + onDismiss = { viewModel.clearError() } + ) + } + } + } +} \ No newline at end of file diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/AuthenticatorNavHost.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/AuthenticatorNavHost.kt new file mode 100644 index 00000000..25b3a337 --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/AuthenticatorNavHost.kt @@ -0,0 +1,257 @@ +/* + * 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.ui + +import android.content.Intent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.pingidentity.authenticatorapp.data.AuthenticatorViewModel +import com.pingidentity.authenticatorapp.data.LoginViewModel +import com.pingidentity.authenticatorapp.notification.BiometricPromptActivity +import com.pingidentity.authenticatorapp.notification.NotificationActionReceiver.Companion.EXTRA_NOTIFICATION_ID +import com.pingidentity.authenticatorapp.util.NavigationAnimations + +/** + * Main entry point for the app. + */ +@Composable +fun AuthenticatorNavHost( + authenticatorViewModel: AuthenticatorViewModel = viewModel(), + loginViewModel: LoginViewModel = viewModel(), + initialDestination: String = "accounts" +) { + // Check for initialization errors + val uiState by authenticatorViewModel.uiState.collectAsState() + + // If there's an initialization error, show the error screen instead + if (uiState.initializationError != null) { + InitializationErrorScreen( + viewModel = authenticatorViewModel, + initializationError = uiState.initializationError!! + ) + return + } + + // Create the NavController + val navController = rememberNavController() + + // Define the navigation + NavHost(navController = navController, startDestination = initialDestination) { + + // Main accounts list screen + composable("accounts") { + AccountsScreen( + viewModel = authenticatorViewModel, + onScanQrCode = { navController.navigate("scanner") }, + onAddManually = { navController.navigate("manual-entry") }, + onAccountClick = { accountInfo -> navController.navigate("account/$accountInfo") }, + onNotificationsClick = { navController.navigate("notifications") }, + onSettingsClick = { navController.navigate("settings") }, + onAboutClick = { navController.navigate("about") }, + onEditAccountsClick = { navController.navigate("edit-accounts") }, + onTestModeClick = { navController.navigate("test") }, + onNavigateToLogin = { navController.navigate("login") } + ) + } + + // QR code scanner screen + composable( + route = "scanner", + enterTransition = NavigationAnimations.enterTransition, + exitTransition = NavigationAnimations.exitTransition, + popEnterTransition = NavigationAnimations.popEnterTransition, + popExitTransition = NavigationAnimations.popExitTransition + ) { + QrScannerScreen( + viewModel = authenticatorViewModel, + onScanComplete = { navController.popBackStack() }, + onDismiss = { navController.popBackStack() } + ) + } + + // Manual entry screen + composable( + route = "manual-entry", + enterTransition = NavigationAnimations.enterTransition, + exitTransition = NavigationAnimations.exitTransition, + popEnterTransition = NavigationAnimations.popEnterTransition, + popExitTransition = NavigationAnimations.popExitTransition + ) { + ManualEntryScreen( + viewModel = authenticatorViewModel, + onEntryComplete = { navController.popBackStack() }, + onDismiss = { navController.popBackStack() } + ) + } + + // Journey login screen + composable( + route = "login", + enterTransition = NavigationAnimations.enterTransition, + exitTransition = NavigationAnimations.exitTransition, + popEnterTransition = NavigationAnimations.popEnterTransition, + popExitTransition = NavigationAnimations.popExitTransition + ) { + LoginScreen( + viewModel = loginViewModel, + onNavigateBack = { navController.popBackStack() } + ) + } + + // Account detail screen with encoded parameters + composable( + route = "account/{issuer}/{accountName}", + enterTransition = NavigationAnimations.enterTransition, + exitTransition = NavigationAnimations.exitTransition, + popEnterTransition = NavigationAnimations.popEnterTransition, + popExitTransition = NavigationAnimations.popExitTransition + ) { backStackEntry -> + val encodedIssuer = backStackEntry.arguments?.getString("issuer") ?: "" + val encodedAccountName = backStackEntry.arguments?.getString("accountName") ?: "" + val issuer = java.net.URLDecoder.decode(encodedIssuer, "UTF-8") + val accountName = java.net.URLDecoder.decode(encodedAccountName, "UTF-8") + + AccountDetailScreen( + issuer = issuer, + accountName = accountName, + viewModel = authenticatorViewModel, + onDismiss = { navController.popBackStack() } + ) + } + + // Push notification screens + composable( + route = "notifications", + enterTransition = NavigationAnimations.enterTransition, + exitTransition = NavigationAnimations.exitTransition, + popEnterTransition = NavigationAnimations.popEnterTransition, + popExitTransition = NavigationAnimations.popExitTransition + ) { + PushNotificationsScreen( + viewModel = authenticatorViewModel, + onNotificationClick = { notificationId -> + navController.navigate("notification/$notificationId") + }, + onDismiss = { navController.popBackStack() } + ) + } + + // Individual notification screen + composable( + route = "notification/{notificationId}", + enterTransition = NavigationAnimations.enterTransition, + exitTransition = NavigationAnimations.exitTransition, + popEnterTransition = NavigationAnimations.popEnterTransition, + popExitTransition = NavigationAnimations.popExitTransition + ) { backStackEntry -> + val context = LocalContext.current + val notificationId = backStackEntry.arguments?.getString("notificationId") ?: "" + authenticatorViewModel.getNotificationItemById(notificationId)?.let { notificationItem -> + NotificationResponseScreen( + notificationItem = notificationItem, + onDismiss = { navController.popBackStack() }, + onApprove = { + authenticatorViewModel.approveNotification(notificationId) + navController.popBackStack() + }, + onBiometricApprove = { + // Launch BiometricPromptActivity for biometric authentication + val intent = Intent(context, BiometricPromptActivity::class.java).apply { + putExtra(EXTRA_NOTIFICATION_ID, notificationId) + } + context.startActivity(intent) + navController.popBackStack() + }, + onDeny = { + authenticatorViewModel.denyNotification(notificationId) + navController.popBackStack() + }, + onChallengeSolution = { solution -> + authenticatorViewModel.approveChallengeNotification(notificationId, solution) + navController.popBackStack() + } + ) + } + } + + // Settings screen + composable( + route = "settings", + enterTransition = NavigationAnimations.enterTransition, + exitTransition = NavigationAnimations.exitTransition, + popEnterTransition = NavigationAnimations.popEnterTransition, + popExitTransition = NavigationAnimations.popExitTransition + ) { + SettingsScreen( + viewModel = authenticatorViewModel, + onDismiss = { navController.popBackStack() }, + onDiagnosticLogsClick = { navController.navigate("diagnostic-logs") } + ) + } + + // Diagnostic logs screen + composable( + route = "diagnostic-logs", + enterTransition = NavigationAnimations.enterTransition, + exitTransition = NavigationAnimations.exitTransition, + popEnterTransition = NavigationAnimations.popEnterTransition, + popExitTransition = NavigationAnimations.popExitTransition + ) { + DiagnosticLogsScreen( + onDismiss = { navController.popBackStack() } + ) + } + + // Test mode screen + composable( + route = "test", + enterTransition = NavigationAnimations.enterTransition, + exitTransition = NavigationAnimations.exitTransition, + popEnterTransition = NavigationAnimations.popEnterTransition, + popExitTransition = NavigationAnimations.popExitTransition + ) { + TestScreen( + viewModel = authenticatorViewModel, + onDismiss = { navController.popBackStack() } + ) + } + + // About screen + composable( + route = "about", + enterTransition = NavigationAnimations.enterTransition, + exitTransition = NavigationAnimations.exitTransition, + popEnterTransition = NavigationAnimations.popEnterTransition, + popExitTransition = NavigationAnimations.popExitTransition + ) { + AboutScreen( + onDismiss = { navController.popBackStack() } + ) + } + + // Edit Accounts screen + composable( + route = "edit-accounts", + enterTransition = NavigationAnimations.enterTransition, + exitTransition = NavigationAnimations.exitTransition, + popEnterTransition = NavigationAnimations.popEnterTransition, + popExitTransition = NavigationAnimations.popExitTransition + ) { + EditAccountsScreen( + viewModel = authenticatorViewModel, + onDismiss = { navController.popBackStack() } + ) + } + } +} diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/DiagnosticLogsScreen.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/DiagnosticLogsScreen.kt new file mode 100644 index 00000000..83e78b3f --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/DiagnosticLogsScreen.kt @@ -0,0 +1,266 @@ +/* + * 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.ui + +import android.content.Intent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.CleaningServices +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.pingidentity.authenticatorapp.R +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.pingidentity.authenticatorapp.data.DiagnosticLogger +import com.pingidentity.authenticatorapp.data.LogEntry + +/** + * Screen displaying diagnostic logs with options to share or clear them. + * + * @param onDismiss Callback invoked when the user wants to exit the screen. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DiagnosticLogsScreen( + onDismiss: () -> Unit +) { + val context = LocalContext.current + val diagnosticLogger = DiagnosticLogger + val logs by diagnosticLogger.logs.collectAsState() + val listState = rememberLazyListState() + + // Auto-scroll to bottom when new logs are added + LaunchedEffect(logs.size) { + if (logs.isNotEmpty()) { + listState.animateScrollToItem(logs.size - 1) + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + stringResource( + id = R.string.diagnostic_logs_screen_title, + logs.size + ) + ) + }, + navigationIcon = { + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(id = R.string.back) + ) + } + }, + actions = { + // Share logs button + IconButton( + onClick = { + val subject = context.getString(R.string.diagnostic_logs_share_subject) + val shareText = diagnosticLogger.exportLogs() + val shareIntent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, shareText) + putExtra(Intent.EXTRA_SUBJECT, subject) + } + context.startActivity( + Intent.createChooser( + shareIntent, + context.getString(R.string.content_description_share_logs) + ) + ) + } + ) { + Icon( + imageVector = Icons.Default.Share, + contentDescription = stringResource(id = R.string.content_description_share_logs) + ) + } + + // Optional, clear logs button + IconButton( + onClick = { + diagnosticLogger.clearLogs() + } + ) { + Icon( + imageVector = Icons.Default.CleaningServices, + contentDescription = stringResource(id = R.string.content_description_clear_logs) + ) + } + } + ) + } + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + if (logs.isEmpty()) { + // Empty state + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(id = R.string.diagnostic_logs_empty_state_title), + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = stringResource(id = R.string.diagnostic_logs_empty_state_subtitle), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 8.dp) + ) + } + } else { + // List of logs + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items( + items = logs, + key = { log -> log.id } + ) { logEntry -> + LogEntryCard(logEntry = logEntry) + } + } + } + } + } +} + +/** + * Card displaying a single log entry. + */ +@Composable +private fun LogEntryCard( + logEntry: LogEntry, + modifier: Modifier = Modifier +) { + val levelColor = when (logEntry.level) { + "ERROR" -> MaterialTheme.colorScheme.error + "WARN" -> Color(0xFFFF9800) // Orange + "INFO" -> MaterialTheme.colorScheme.primary + "DEBUG" -> MaterialTheme.colorScheme.secondary + else -> MaterialTheme.colorScheme.onSurface + } + + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp) + ) { + // Header with timestamp and level + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = logEntry.timestamp, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = FontFamily.Monospace + ) + + Box( + modifier = Modifier + .background( + color = levelColor.copy(alpha = 0.1f), + shape = RoundedCornerShape(4.dp) + ) + .padding(horizontal = 8.dp, vertical = 2.dp) + ) { + Text( + text = logEntry.level, + style = MaterialTheme.typography.labelSmall, + color = levelColor, + fontFamily = FontFamily.Monospace + ) + } + } + + // Log message + Text( + text = logEntry.message, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + modifier = Modifier.padding(top = 8.dp), + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + + // Exception details if present + logEntry.throwable?.let { throwable -> + Text( + text = throwable, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.error, + modifier = Modifier + .padding(top = 8.dp) + .background( + color = MaterialTheme.colorScheme.error.copy(alpha = 0.1f), + shape = RoundedCornerShape(4.dp) + ) + .padding(8.dp), + maxLines = 5, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} \ No newline at end of file diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/EditAccountsScreen.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/EditAccountsScreen.kt new file mode 100644 index 00000000..1da2eb3a --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/EditAccountsScreen.kt @@ -0,0 +1,371 @@ +/* + * 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.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.unit.dp +import com.pingidentity.authenticatorapp.data.AccountGroup +import com.pingidentity.authenticatorapp.data.AuthenticatorViewModel +import com.pingidentity.authenticatorapp.ui.components.EditAccountDialog +import com.pingidentity.authenticatorapp.ui.components.EditableAccountItem +import kotlinx.coroutines.launch + +/** + * Enum representing the type of deletion for an account. + * OATH_ONLY: Delete only OATH credentials. + * PUSH_ONLY: Delete only Push credentials. + * BOTH: Delete all credentials (OATH and Push). + */ +enum class DeleteType { + OATH_ONLY, PUSH_ONLY, BOTH +} + +/** + * Composable that displays a screen for editing accounts. + * Users can reorder accounts via move up/down buttons, + * edit display names, and delete accounts with confirmation. + * + * @param viewModel The AuthenticatorViewModel providing the UI state and actions. + * @param onDismiss Callback invoked when the user navigates back from this screen. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EditAccountsScreen( + viewModel: AuthenticatorViewModel, + onDismiss: () -> Unit +) { + val uiState by viewModel.uiState.collectAsState() + val coroutineScope = rememberCoroutineScope() + + // State for reordering + val hapticFeedback = LocalHapticFeedback.current + + // State for deletion + var accountToDelete by remember { mutableStateOf(null) } + var deleteType by remember { mutableStateOf(null) } + + // State for editing + var accountToEdit by remember { mutableStateOf(null) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Edit Accounts") }, + navigationIcon = { + IconButton(onClick = onDismiss) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + } + ) + } + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + if (uiState.accountGroups.isEmpty()) { + // No accounts message + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "No accounts to edit", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Add accounts from the main screen to manage them here.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + // Account list with reordering capability + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + itemsIndexed( + items = uiState.accountGroups, + key = { _, accountGroup -> + // Create a unique key using issuer, account name, and all credential IDs + val oathIds = accountGroup.oathCredentials.map { it.id }.sorted().joinToString(",") + val pushIds = accountGroup.pushCredentials.map { it.id }.sorted().joinToString(",") + "${accountGroup.issuer}-${accountGroup.accountName}-oath:$oathIds-push:$pushIds" + } + ) { index, accountGroup -> + EditableAccountItem( + accountGroup = accountGroup, + onDeleteClick = { + // Only allow deletion if account is not locked + if (!accountGroup.isLocked) { + accountToDelete = accountGroup + // Determine what types of credentials this account has + val hasOath = accountGroup.oathCredentials.isNotEmpty() + val hasPush = accountGroup.pushCredentials.isNotEmpty() + deleteType = if (hasOath && hasPush) { + null // Will show selection dialog + } else if (hasOath) { + DeleteType.OATH_ONLY + } else { + DeleteType.PUSH_ONLY + } + } + }, + onEditClick = { + // Only allow editing if account is not locked + if (!accountGroup.isLocked) { + accountToEdit = accountGroup + } + }, + onMoveUp = { + val newList = uiState.accountGroups.toMutableList() + val currentIndex = newList.indexOf(accountGroup) + if (currentIndex > 0) { + val item = newList.removeAt(currentIndex) + newList.add(currentIndex - 1, item) + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + // Update the ViewModel with new order + viewModel.updateAccountGroupOrder(newList) + } + }, + onMoveDown = { + val newList = uiState.accountGroups.toMutableList() + val currentIndex = newList.indexOf(accountGroup) + if (currentIndex < newList.size - 1) { + val item = newList.removeAt(currentIndex) + newList.add(currentIndex + 1, item) + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + // Update the ViewModel with new order + viewModel.updateAccountGroupOrder(newList) + } + }, + canMoveUp = index > 0, + canMoveDown = index < uiState.accountGroups.size - 1 + ) + } + } + } + + // Delete type selection dialog (when account has both OATH and Push) + if (accountToDelete != null && deleteType == null) { + val account = accountToDelete!! + AlertDialog( + onDismissRequest = { + accountToDelete = null + deleteType = null + }, + title = { Text("Choose what to delete") }, + text = { + Column { + Text("This account has multiple authentication methods:") + if (account.oathCredentials.isNotEmpty()) { + Text("• ${account.oathCredentials.size} OATH credential(s)") + } + if (account.pushCredentials.isNotEmpty()) { + Text("• ${account.pushCredentials.size} Push credential(s)") + } + Spacer(modifier = Modifier.height(8.dp)) + Text("What would you like to delete?") + } + }, + confirmButton = { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + if (account.oathCredentials.isNotEmpty()) { + Button( + onClick = { deleteType = DeleteType.OATH_ONLY }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Delete OATH Only") + } + } + if (account.pushCredentials.isNotEmpty()) { + Button( + onClick = { deleteType = DeleteType.PUSH_ONLY }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Delete Push Only") + } + } + Button( + onClick = { deleteType = DeleteType.BOTH }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) + ) { + Text("Delete All") + } + } + }, + dismissButton = { + TextButton(onClick = { + accountToDelete = null + deleteType = null + }) { + Text("Cancel") + } + } + ) + } + + // Delete confirmation dialog + if (accountToDelete != null && deleteType != null) { + val account = accountToDelete!! + val type = deleteType!! + + val (itemsToDelete, description) = when (type) { + DeleteType.OATH_ONLY -> Pair( + account.oathCredentials.size, + "OATH credential(s)" + ) + DeleteType.PUSH_ONLY -> Pair( + account.pushCredentials.size, + "Push credential(s)" + ) + DeleteType.BOTH -> Pair( + account.oathCredentials.size + account.pushCredentials.size, + "credential(s) (all authentication methods)" + ) + } + + AlertDialog( + onDismissRequest = { + accountToDelete = null + deleteType = null + }, + title = { Text("Confirm Deletion") }, + text = { + Text("Are you sure you want to delete $itemsToDelete $description for \"${account.issuer} - ${account.accountName}\"?") + }, + confirmButton = { + Button( + onClick = { + coroutineScope.launch { + when (type) { + DeleteType.OATH_ONLY -> { + account.oathCredentials.forEach { credential -> + viewModel.removeOathCredential(credential.id) + } + } + DeleteType.PUSH_ONLY -> { + account.pushCredentials.forEach { credential -> + viewModel.removePushCredential(credential.id) + } + } + DeleteType.BOTH -> { + account.oathCredentials.forEach { credential -> + viewModel.removeOathCredential(credential.id) + } + account.pushCredentials.forEach { credential -> + viewModel.removePushCredential(credential.id) + } + } + } + + accountToDelete = null + deleteType = null + } + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) + ) { + Text("Delete") + } + }, + dismissButton = { + TextButton(onClick = { + accountToDelete = null + deleteType = null + }) { + Text("Cancel") + } + } + ) + } + + // Edit account dialog + accountToEdit?.let { account -> + EditAccountDialog( + account = account, + onDismiss = { accountToEdit = null }, + onConfirm = { newDisplayIssuer, newDisplayAccountName -> + coroutineScope.launch { + // Update OATH credentials + account.oathCredentials.forEach { credential -> + val updatedCredential = credential.copy( + displayIssuer = newDisplayIssuer, + displayAccountName = newDisplayAccountName + ) + viewModel.updateOathCredential(updatedCredential) + } + + // Update Push credentials + account.pushCredentials.forEach { credential -> + val updatedCredential = credential.copy( + displayIssuer = newDisplayIssuer, + displayAccountName = newDisplayAccountName + ) + viewModel.updatePushCredential(updatedCredential) + } + + accountToEdit = null + } + } + ) + } + } + } +} \ No newline at end of file diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/InitializationErrorScreen.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/InitializationErrorScreen.kt new file mode 100644 index 00000000..1875265d --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/InitializationErrorScreen.kt @@ -0,0 +1,336 @@ +/* + * 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.ui + +import android.app.Activity +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.RestorePage +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.pingidentity.authenticatorapp.data.AuthenticatorViewModel +import com.pingidentity.authenticatorapp.data.InitializationError +import com.pingidentity.authenticatorapp.data.InitializationErrorType +import kotlinx.coroutines.launch +import kotlin.system.exitProcess + +/** + * Full-screen error UI shown when database initialization fails. + * Provides recovery options including backup restoration and destructive recovery. + */ +@Composable +fun InitializationErrorScreen( + viewModel: AuthenticatorViewModel, + initializationError: InitializationError +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + + var showDestructiveRecoveryDialog by remember { mutableStateOf(false) } + var isProcessing by remember { mutableStateOf(false) } + + Scaffold( + snackbarHost = { SnackbarHost(hostState = snackbarHostState) } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + // Error icon + Icon( + imageVector = Icons.Default.Error, + contentDescription = "Error", + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(72.dp) + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Error title + Text( + text = "Database Error", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Error description + Text( + text = getErrorDescription(initializationError.type), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(32.dp)) + + // Error details card + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Error Details", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onErrorContainer + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = initializationError.message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + // Recovery options + Text( + text = "Recovery Options", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Restore from backup button + if (initializationError.canRestoreFromBackup) { + Button( + onClick = { + isProcessing = true + scope.launch { + viewModel.attemptRestoreFromBackup() + .onSuccess { + snackbarHostState.showSnackbar( + "Backup restored successfully. Please restart the app." + ) + // Restart the app + (context as? Activity)?.let { activity -> + activity.finishAffinity() + exitProcess(0) + } + } + .onFailure { e -> + snackbarHostState.showSnackbar( + "Backup restoration failed: ${e.message}" + ) + isProcessing = false + } + } + }, + modifier = Modifier.fillMaxWidth(), + enabled = !isProcessing + ) { + Icon( + imageVector = Icons.Default.RestorePage, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.size(8.dp)) + Text("Restore from Backup") + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Destructive recovery button + if (initializationError.canUseDestructiveRecovery) { + OutlinedButton( + onClick = { showDestructiveRecoveryDialog = true }, + modifier = Modifier.fillMaxWidth(), + enabled = !isProcessing, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Icon( + imageVector = Icons.Default.DeleteForever, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.size(8.dp)) + Text("Clear All Data and Start Fresh") + } + } else { + // Show message that destructive recovery is disabled + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Destructive Recovery Disabled", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Destructive recovery is currently disabled. You can enable it in Settings > Enable destructive database recovery.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + + // Destructive recovery confirmation dialog + if (showDestructiveRecoveryDialog) { + AlertDialog( + onDismissRequest = { showDestructiveRecoveryDialog = false }, + icon = { + Icon( + imageVector = Icons.Default.DeleteForever, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + }, + title = { + Text("Clear All Data?") + }, + text = { + Text( + "This will permanently delete all your accounts and credentials. " + + "This action cannot be undone.\n\n" + + "The app will restart and you'll need to add your accounts again." + ) + }, + confirmButton = { + TextButton( + onClick = { + showDestructiveRecoveryDialog = false + isProcessing = true + scope.launch { + viewModel.enableDestructiveRecoveryAndRestart() + .onSuccess { + snackbarHostState.showSnackbar( + "Destructive recovery enabled. Restarting app..." + ) + // Restart the app + (context as? Activity)?.let { activity -> + activity.finishAffinity() + exitProcess(0) + } + } + .onFailure { e -> + snackbarHostState.showSnackbar( + "Failed to enable destructive recovery: ${e.message}" + ) + isProcessing = false + } + } + }, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Text("Clear All Data") + } + }, + dismissButton = { + TextButton(onClick = { showDestructiveRecoveryDialog = false }) { + Text("Cancel") + } + } + ) + } +} } + +/** + * Gets a user-friendly description for the error type. + */ +private fun getErrorDescription(errorType: InitializationErrorType): String { + return when (errorType) { + InitializationErrorType.OATH_DATABASE_CORRUPTED -> + "The OATH credentials database is corrupted and cannot be opened. " + + "You can try restoring from a backup or clear all data to start fresh." + + InitializationErrorType.PUSH_DATABASE_CORRUPTED -> + "The Push notifications database is corrupted and cannot be opened. " + + "You can try restoring from a backup or clear all data to start fresh." + + InitializationErrorType.BOTH_DATABASES_CORRUPTED -> + "Both OATH and Push databases are corrupted and cannot be opened. " + + "You can try restoring from a backup or clear all data to start fresh." + + InitializationErrorType.OATH_INITIALIZATION_FAILED -> + "Failed to initialize the OATH credential system. " + + "Please try again or contact support if the problem persists." + + InitializationErrorType.PUSH_INITIALIZATION_FAILED -> + "Failed to initialize the Push notification system. " + + "Please try again or contact support if the problem persists." + + InitializationErrorType.FIREBASE_CONFIGURATION_ERROR -> + "Firebase is not configured properly. " + + "Push notifications will not work until this is resolved." + + InitializationErrorType.JOURNEY_INITIALIZATION_FAILED -> + "Failed to initialize the Journey system. " + + "Please try again or contact support if the problem persists." + + InitializationErrorType.UNKNOWN_ERROR -> + "An unknown error occurred during initialization. " + + "Please try restarting the app or contact support." + } +} \ No newline at end of file diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/LoginScreen.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/LoginScreen.kt new file mode 100644 index 00000000..5912749a --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/LoginScreen.kt @@ -0,0 +1,329 @@ +/* + * 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.ui + +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.ui.res.painterResource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Error +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.pingidentity.authenticatorapp.R +import com.pingidentity.authenticatorapp.ui.components.BackNavigationTopAppBar +import com.pingidentity.authenticatorapp.ui.components.ContinueNodeRenderer +import com.pingidentity.authenticatorapp.data.LoginViewModel +import com.pingidentity.orchestrate.ContinueNode + +/** + * Screen for Journey-based authentication and credential enrollment + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LoginScreen( + viewModel: LoginViewModel, + onNavigateBack: () -> Unit +) { + val uiState by viewModel.uiState.collectAsState() + + // Start the journey when screen is first displayed + LaunchedEffect(Unit) { + viewModel.startJourney() + } + + Scaffold( + topBar = { + BackNavigationTopAppBar( + title = stringResource(id = R.string.login_title), + onBackClick = onNavigateBack + ) + } + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + // Add Ping logo at the top center + Image( + painter = painterResource(id = R.drawable.ping_logo), + contentDescription = "Ping Identity Logo", + modifier = Modifier + .align(Alignment.TopCenter) + .size(80.dp) + .padding(top = 16.dp) + ) + when { + uiState.isSuccess -> { + SuccessContent( + message = uiState.message ?: stringResource(id = R.string.login_success_message), + onDone = { + // Logout the user to clear session after successful MFA registration + viewModel.logout() + onNavigateBack() + } + ) + } + + uiState.error != null -> { + val currentError = uiState.error ?: stringResource(id = R.string.login_unknown_error) + ErrorContent( + error = currentError, + onRetry = { + viewModel.reset() + viewModel.startJourney() + }, + onDone = onNavigateBack + ) + } + + uiState.isLoading -> { + LoadingContent( + message = uiState.message ?: stringResource(id = R.string.login_loading_message), + isPolling = uiState.isPolling + ) + } + + uiState.isMfaRegistering -> { + LoadingContent( + message = uiState.message ?: stringResource(id = R.string.login_registering_message), + isPolling = false + ) + } + + uiState.currentNode is ContinueNode -> { + // Show journey callbacks for user interaction + val continueNode = uiState.currentNode as ContinueNode + ContinueNodeRenderer( + node = continueNode, + onNodeUpdated = { viewModel.refreshNode() }, + onNext = { viewModel.nextStep() } + ) + } + + else -> { + // Initial state + LoadingContent( + message = stringResource(id = R.string.login_initial_message), + isPolling = false + ) + } + } + } + } +} + +/** + * Success state content + */ +@Composable +private fun SuccessContent( + message: String, + onDone: () -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(64.dp) + ) + + Text( + text = "Success!", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.primary + ) + + Text( + text = message, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Button( + onClick = onDone, + modifier = Modifier.fillMaxWidth() + ) { + Text("Done") + } + } + } +} + +/** + * Error state content + */ +@Composable +private fun ErrorContent( + error: String, + onRetry: () -> Unit, + onDone: () -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Icon( + imageVector = Icons.Default.Error, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(64.dp) + ) + + Text( + text = "Authentication Failed", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.error + ) + + Text( + text = error, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + Button( + onClick = onRetry, + modifier = Modifier.fillMaxWidth() + ) { + Text( stringResource(R.string.login_retry)) + } + + Button( + onClick = onDone, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(id = R.string.login_cancel)) + } + } + } + } +} + +/** + * Loading state content + */ +@Composable +private fun LoadingContent( + message: String, + isPolling: Boolean +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (isPolling) { + // Animated progress indicator for polling + val infiniteTransition = rememberInfiniteTransition(label = "polling") + val progressAnimationValue by infiniteTransition.animateFloat( + initialValue = 0.0f, + targetValue = 1.0f, + animationSpec = infiniteRepeatable(animation = tween(2000)), + label = "polling_progress" + ) + + CircularProgressIndicator( + progress = { progressAnimationValue }, + modifier = Modifier.size(64.dp), + strokeWidth = 6.dp + ) + } else { + // Indeterminate progress indicator + CircularProgressIndicator( + modifier = Modifier.size(64.dp), + strokeWidth = 6.dp + ) + } + + Text( + text = if (isPolling) stringResource(R.string.login_wait_message) else stringResource(R.string.login_loading_message), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.primary + ) + + Text( + text = message, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface + ) + } + } +} diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/ManualEntryScreen.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/ManualEntryScreen.kt new file mode 100644 index 00000000..9fa479f2 --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/ManualEntryScreen.kt @@ -0,0 +1,288 @@ +/* + * 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.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.pingidentity.authenticatorapp.R +import com.pingidentity.authenticatorapp.data.AuthenticatorViewModel +import com.pingidentity.authenticatorapp.data.DiagnosticLogger +import com.pingidentity.authenticatorapp.ui.components.BackNavigationTopAppBar +import com.pingidentity.authenticatorapp.ui.components.ErrorAlertDialog +import com.pingidentity.mfa.commons.UriScheme +import com.pingidentity.mfa.oath.OathAlgorithm +import com.pingidentity.mfa.oath.OathType + +/** + * A screen that allows users to manually enter details for adding a new OTP credential. + * The screen includes fields for issuer, account name, secret key, OTP type, algorithm, digits, and period. + * Upon submission, the entered details are used to create an otpauth URI and add the credential via the ViewModel. + * + * @param viewModel The AuthenticatorViewModel instance for managing state and actions. + * @param onEntryComplete Callback invoked when the entry is successfully completed. + * @param onDismiss Callback invoked when the user chooses to dismiss the screen. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ManualEntryScreen( + viewModel: AuthenticatorViewModel, + onEntryComplete: () -> Unit, + onDismiss: () -> Unit +) { + var issuer by remember { mutableStateOf("") } + var accountName by remember { mutableStateOf("") } + var secret by remember { mutableStateOf("") } + var oathType by remember { mutableStateOf(OathType.TOTP) } + var algorithm by remember { mutableStateOf(OathAlgorithm.SHA1) } + var digits by remember { mutableStateOf("6") } + var period by remember { mutableStateOf("30") } + + val uiState by viewModel.uiState.collectAsState() + val context = LocalContext.current + val diagnosticLogger = DiagnosticLogger + val snackbarHostState = remember { SnackbarHostState() } + + // Watch for credential addition success + LaunchedEffect(uiState.lastAddedOathCredential) { + if (uiState.lastAddedOathCredential != null) { + snackbarHostState.showSnackbar(context.getString(R.string.manual_entry_account_added_successfully)) + viewModel.clearLastAddedOathCredential() + onEntryComplete() + } + } + + Scaffold( + topBar = { + BackNavigationTopAppBar( + title = stringResource(id = R.string.manual_entry_screen_title), + onBackClick = onDismiss + ) + }, + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Issuer field + OutlinedTextField( + value = issuer, + onValueChange = { issuer = it }, + label = { Text(stringResource(id = R.string.manual_entry_issuer_label)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + // Account name field + OutlinedTextField( + value = accountName, + onValueChange = { accountName = it }, + label = { Text(stringResource(id = R.string.manual_entry_account_name_label)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + // Secret key field + OutlinedTextField( + value = secret, + onValueChange = { secret = it }, + label = { Text(stringResource(id = R.string.manual_entry_secret_key_label)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + // OTP Type selection + Text( + text = stringResource(id = R.string.manual_entry_otp_type_label), + style = MaterialTheme.typography.bodyLarge + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OathType.entries.forEach { type -> + FilterChip( + selected = oathType == type, + onClick = { oathType = type }, + label = { Text(type.name) } + ) + } + } + + // Algorithm selection + Text( + text = stringResource(id = R.string.manual_entry_algorithm_label), + style = MaterialTheme.typography.bodyLarge + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OathAlgorithm.entries.forEach { alg -> + FilterChip( + selected = algorithm == alg, + onClick = { algorithm = alg }, + label = { Text(alg.name) } + ) + } + } + + // Digits selection + OutlinedTextField( + value = digits, + onValueChange = { if (it.isBlank() || it.toIntOrNull() != null) digits = it }, + label = { Text("Digits") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + singleLine = true, + supportingText = { + val digitsValue = digits.toIntOrNull() + if (digitsValue != null && digitsValue != 6 && digitsValue != 8) { + Text( + text = "Digits must be 6 or 8 (RFC 4226/6238)", + color = MaterialTheme.colorScheme.error + ) + } else { + Text("Number of digits in the generated OTP code (6 or 8)") + } + }, + isError = digits.toIntOrNull()?.let { it != 6 && it != 8 } ?: false + ) + + // Period selection (only for TOTP) + if (oathType == OathType.TOTP) { + OutlinedTextField( + value = period, + onValueChange = { if (it.isBlank() || it.toIntOrNull() != null) period = it }, + label = { Text(stringResource(id = R.string.manual_entry_period_label)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + singleLine = true, + supportingText = { + val periodValue = period.toIntOrNull() + if (periodValue != null && periodValue <= 0) { + Text( + text = "Period must be greater than 0", + color = MaterialTheme.colorScheme.error + ) + } else { + Text("Time in seconds for code validity (typically 30)") + } + }, + isError = period.toIntOrNull()?.let { it <= 0 } ?: false + ) + } + + // Submit button + Button( + onClick = { + // Create otpauth URI and add credential + val uri = buildOtpauthUri( + issuer = issuer, + accountName = accountName, + secret = secret, + oathType = oathType, + algorithm = algorithm, + digits = digits.toIntOrNull() ?: 6, + period = period.toIntOrNull() ?: 30 + ) + diagnosticLogger.d("ManualEntryScreen: Adding credential from URI") + viewModel.addOathCredentialFromUri(uri) + }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + enabled = issuer.isNotBlank() && + accountName.isNotBlank() && + secret.isNotBlank() && + digits.toIntOrNull()?.let { it == 6 || it == 8 } ?: false && + (oathType == OathType.HOTP || period.toIntOrNull()?.let { it > 0 } ?: false) + ) { + Text(stringResource(id = R.string.manual_entry_add_account_button)) + } + } + + // Error handling + if (uiState.error != null) { + ErrorAlertDialog( + errorMessage = uiState.error!!, + onDismiss = { viewModel.clearError() } + ) + } + } +} + +/** + * Builds an otpauth URI from the provided parameters. + * Format: otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example&algorithm=SHA1&digits=6&period=30 + */ +private fun buildOtpauthUri( + issuer: String, + accountName: String, + secret: String, + oathType: OathType, + algorithm: OathAlgorithm, + digits: Int, + period: Int +): String { + return buildString { + append(UriScheme.OTPAUTH.value) + append(oathType.name.lowercase()) + append("/") + append(issuer) + append(":") + append(accountName) + append("?secret=") + append(secret) + append("&issuer=") + append(issuer) + append("&algorithm=") + append(algorithm.name) + append("&digits=") + append(digits) + + if (oathType == OathType.TOTP) { + append("&period=") + append(period) + } + } +} diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/NotificationResponseScreen.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/NotificationResponseScreen.kt new file mode 100644 index 00000000..a45bb311 --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/NotificationResponseScreen.kt @@ -0,0 +1,696 @@ +/* + * 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.ui + +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Path +import android.graphics.drawable.Drawable +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Alarm +import androidx.compose.material.icons.filled.AlarmOn +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.DesktopMac +import androidx.compose.material.icons.filled.Laptop +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.PhoneAndroid +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Pin +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Fingerprint +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DividerDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import com.pingidentity.authenticatorapp.R +import com.pingidentity.authenticatorapp.data.LocationAddress +import com.pingidentity.authenticatorapp.data.NotificationStatus +import com.pingidentity.authenticatorapp.data.PushNotificationItem +import com.pingidentity.authenticatorapp.service.LocationService +import com.pingidentity.authenticatorapp.ui.components.AccountAvatar +import com.pingidentity.authenticatorapp.ui.components.StatusIndicator +import com.pingidentity.mfa.commons.policy.BiometricAvailablePolicy +import com.pingidentity.mfa.commons.policy.DeviceTamperingPolicy +import com.pingidentity.mfa.push.PushType +import org.osmdroid.config.Configuration +import org.osmdroid.tileprovider.tilesource.TileSourceFactory +import org.osmdroid.util.GeoPoint +import org.osmdroid.views.MapView +import org.osmdroid.views.overlay.Marker + +/** + * Unified screen for displaying push notification details. + * Handles both standard authentication and challenge-based notifications. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NotificationResponseScreen( + notificationItem: PushNotificationItem, + onDismiss: () -> Unit, + onApprove: (() -> Unit)? = null, + onBiometricApprove: (() -> Unit)? = null, + onDeny: (() -> Unit)? = null, + onChallengeSolution: ((String) -> Unit)? = null +) { + val context = LocalContext.current + // State for location address + var locationAddress by remember { mutableStateOf(null) } + var isLoadingAddress by remember { mutableStateOf(false) } + var addressError by remember { mutableStateOf(null) } + + // Location service + val locationService = remember { LocationService() } + + // Clean up LocationService when the composable is disposed + DisposableEffect(Unit) { + onDispose { + locationService.close() + } + } + + // Load address when screen opens if location is available + LaunchedEffect(notificationItem.latitude, notificationItem.longitude) { + if (notificationItem.hasLocationInfo && + notificationItem.latitude != null && + notificationItem.longitude != null) { + + isLoadingAddress = true + addressError = null + + try { + val address = locationService.reverseGeocode( + notificationItem.latitude, + notificationItem.longitude + ) + locationAddress = address + if (address == null) { + addressError = context.getString(R.string.notification_response_location_error) + } + } catch (_: Exception) { + addressError = context.getString(R.string.notification_response_location_failed) + } finally { + isLoadingAddress = false + } + } + } + + val isChallenge = notificationItem.notification.pushType == PushType.CHALLENGE + val challengeNumbers = if (isChallenge) notificationItem.notification.getNumbersChallenge() else emptyList() + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(id = R.string.notification_response_screen_title)) }, + navigationIcon = { + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(id = R.string.back) + ) + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()), + horizontalAlignment = if (isChallenge) Alignment.CenterHorizontally else Alignment.Start + ) { + // Header with issuer, account, and location map + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + // Status indicator and account header + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + AccountAvatar( + issuer = notificationItem.credential?.issuer ?: stringResource(id = R.string.notification_response_unknown_issuer), + accountName = notificationItem.credential?.accountName ?: stringResource(id = R.string.notification_response_unknown_account), + imageUrl = notificationItem.credential?.imageURL, + size = 36.dp + ) + + Spacer(modifier = Modifier.width(16.dp)) + + // Issuer and account name + Column(modifier = Modifier.weight(1f)) { + val issuer = notificationItem.credential?.issuer ?: stringResource(id = R.string.notification_response_unknown_issuer) + val accountName = notificationItem.credential?.accountName ?: stringResource(id = R.string.notification_response_unknown_account) + + Text( + text = issuer, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + Text( + text = accountName, + style = MaterialTheme.typography.bodyLarge + ) + } + + // Status indicator + StatusIndicator(status = notificationItem.status) + } + + // Divider + HorizontalDivider( + modifier = Modifier.padding(vertical = 16.dp), + thickness = DividerDefaults.Thickness, + color = DividerDefaults.color + ) + + // Message + Text( + text = notificationItem.notification.messageText ?: + if (isChallenge) stringResource(id = R.string.notification_response_message_verify) else stringResource(id = R.string.notification_response_message_default), + style = MaterialTheme.typography.bodyLarge, + ) + + // Time sent + notificationItem.notification.sentAt?.let { sentAt -> + Spacer(modifier = Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Alarm, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = sentAt.toString(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Response time + notificationItem.notification.respondedAt?.let { respondedAt -> + Spacer(modifier = Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.AlarmOn, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = respondedAt.toString(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Authentication method + Row(verticalAlignment = Alignment.CenterVertically) { + val (icon, text) = when { + notificationItem.requiresBiometric -> Pair( + Icons.Outlined.Fingerprint, + stringResource(id = R.string.notification_response_auth_method_biometric) + ) + notificationItem.requiresChallenge -> Pair( + Icons.Default.Pin, + stringResource(id = R.string.notification_response_auth_method_challenge) + ) + else -> Pair( + Icons.Default.CheckCircle, + stringResource(id = R.string.notification_response_auth_method_standard) + ) + } + Icon( + icon, + text, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // Device information + notificationItem.deviceInfo?.let { + Spacer(modifier = Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + val deviceIcon = when(it.os) { + stringResource(id = R.string.notification_response_device_os_macos) -> Icons.Default.DesktopMac + stringResource(id = R.string.notification_response_device_os_windows), stringResource(id = R.string.notification_response_device_os_linux) -> Icons.Default.Laptop + stringResource(id = R.string.notification_response_device_os_android), stringResource(id = R.string.notification_response_device_os_ios) -> Icons.Default.PhoneAndroid + else -> Icons.Default.Laptop + } + Icon( + imageVector = deviceIcon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "${it.os} - ${it.browser} ${it.browserVersion}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Location address information (only if location is available) + if (notificationItem.hasLocationInfo && + notificationItem.latitude != null && + notificationItem.longitude != null) { + + // Location divider + HorizontalDivider( + modifier = Modifier.padding(vertical = 16.dp), + thickness = DividerDefaults.Thickness, + color = DividerDefaults.color + ) + + // Location address information + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + imageVector = Icons.Default.LocationOn, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.width(8.dp)) + + when { + isLoadingAddress -> { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(id = R.string.notification_response_loading_location), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + locationAddress != null -> { + Text( + text = locationAddress?.formatForDisplay() ?: "", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + addressError != null -> { + Text( + text = stringResource(id = R.string.notification_response_location_lat_lng, notificationItem.latitude.toString(), notificationItem.longitude.toString()), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + else -> { + Text( + text = stringResource(id = R.string.notification_response_location_lat_lng, notificationItem.latitude.toString(), notificationItem.longitude.toString()), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + AndroidView( + modifier = Modifier + .fillMaxWidth() + .height(120.dp), + factory = { context -> + Configuration.getInstance().load(context, context.getSharedPreferences("osm", 0)) + + MapView(context).apply { + setTileSource(TileSourceFactory.MAPNIK) + setMultiTouchControls(false) + isClickable = false + isFocusable = false + isFocusableInTouchMode = false + + val location = GeoPoint( + notificationItem.latitude, + notificationItem.longitude + ) + controller.setCenter(location) + controller.setZoom(18.0) + + // Lock zoom level to prevent user changes + minZoomLevel = 18.0 + maxZoomLevel = 18.0 + + val marker = Marker(this) + marker.position = location + marker.title = context.getString(R.string.notification_response_map_marker_title) + marker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) + + // Create custom red location pin drawable + val customIcon = createLocationPinDrawable(Color.Red.toArgb()) + marker.icon = customIcon + + overlays.add(marker) + + invalidate() + } + } + ) + } + } + } + } + + // Action buttons based on type + if (isChallenge && notificationItem.status == NotificationStatus.PENDING) { + // Challenge selection UI + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(id = R.string.notification_response_challenge_prompt), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 16.dp) + ) + + Spacer(modifier = Modifier.height(24.dp)) + + if (challengeNumbers.isNotEmpty()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + challengeNumbers.forEach { number -> + ChallengeNumberButton( + number = number, + onClick = { onChallengeSolution?.invoke(number.toString()) } + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + OutlinedButton( + onClick = onDismiss, + modifier = Modifier.fillMaxWidth(0.7f), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.error) + ) { + Text(stringResource(id = R.string.notification_response_cancel_authentication)) + } + } else { + Text( + text = stringResource(id = R.string.notification_response_no_challenge_numbers), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = onDismiss, + modifier = Modifier.fillMaxWidth(0.7f) + ) { + Text(stringResource(id = R.string.close)) + } + } + } + } else if (notificationItem.credential?.isLocked == true) { + // Show lock message for locked credentials + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier + .fillMaxWidth(0.9f) + .background( + color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f), + shape = RoundedCornerShape(8.dp) + ) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Lock, + contentDescription = stringResource(id = R.string.account_locked_indicator), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + val lockMessage = when (notificationItem.credential.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, notificationItem.credential.lockingPolicy!!) + } + Text( + text = lockMessage, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = onDismiss, + modifier = Modifier.fillMaxWidth(0.7f) + ) { + Text(stringResource(id = R.string.close)) + } + } + } else if (notificationItem.status == NotificationStatus.PENDING) { + // Standard approve/deny buttons + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Button( + onClick = { + onDeny?.invoke() + }, + modifier = Modifier + .weight(1f) + .padding(end = 8.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) + ) { + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = stringResource(id = R.string.deny) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = stringResource(id = R.string.deny)) + } + + Button( + onClick = { + when { + onBiometricApprove != null && notificationItem.requiresBiometric -> { + onBiometricApprove() + } + onApprove != null -> { + onApprove() + } + } + }, + modifier = Modifier + .weight(1f) + .padding(start = 8.dp) + ) { + Icon( + imageVector = if (notificationItem.requiresBiometric) + Icons.Outlined.Fingerprint else Icons.Outlined.Check, + contentDescription = stringResource(id = R.string.approve) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = if (notificationItem.requiresBiometric) stringResource(id = R.string.verify) else stringResource(id = R.string.approve)) + } + } + } + } + } +} + +/** + * A button displaying a challenge number. + */ +@Composable +private fun ChallengeNumberButton( + number: Int, + onClick: () -> Unit +) { + OutlinedButton( + onClick = onClick, + modifier = Modifier.size(80.dp), + shape = CircleShape, + border = BorderStroke(2.dp, MaterialTheme.colorScheme.primary), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.primary, + containerColor = Color.Transparent + ) + ) { + Text( + text = number.toString(), + fontSize = 24.sp, + fontWeight = FontWeight.Bold + ) + } +} + +/** + * Creates a custom location pin drawable with the specified color + */ +private fun createLocationPinDrawable(color: Int): Drawable { + return object : Drawable() { + private val paint = Paint().apply { + this.color = color + isAntiAlias = true + style = Paint.Style.FILL + } + + private val strokePaint = Paint().apply { + this.color = android.graphics.Color.WHITE + isAntiAlias = true + style = Paint.Style.STROKE + strokeWidth = 3f + } + + override fun draw(canvas: Canvas) { + val bounds = getBounds() + val centerX = bounds.centerX().toFloat() + val width = bounds.width().toFloat() + val height = bounds.height().toFloat() + + // Create location pin shape + val path = Path().apply { + // Top circle part + val circleRadius = width * 0.3f + val circleY = height * 0.3f + addCircle(centerX, circleY, circleRadius, Path.Direction.CW) + + // Bottom triangle part + moveTo(centerX - circleRadius * 0.5f, circleY + circleRadius * 0.5f) + lineTo(centerX, height * 0.9f) + lineTo(centerX + circleRadius * 0.5f, circleY + circleRadius * 0.5f) + close() + } + + // Draw the pin with stroke first, then fill + canvas.drawPath(path, strokePaint) + canvas.drawPath(path, paint) + + // Draw inner circle (location dot) + val innerCircleRadius = width * 0.15f + val innerY = height * 0.3f + canvas.drawCircle(centerX, innerY, innerCircleRadius, strokePaint) + } + + override fun setAlpha(alpha: Int) { + paint.alpha = alpha + strokePaint.alpha = alpha + } + + override fun setColorFilter(colorFilter: android.graphics.ColorFilter?) { + paint.colorFilter = colorFilter + strokePaint.colorFilter = colorFilter + } + + override fun getOpacity(): Int = android.graphics.PixelFormat.TRANSLUCENT + + override fun getIntrinsicWidth(): Int = 48 + override fun getIntrinsicHeight(): Int = 60 + } +} diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/PushNotificationsScreen.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/PushNotificationsScreen.kt new file mode 100644 index 00000000..ab11b88c --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/PushNotificationsScreen.kt @@ -0,0 +1,136 @@ +/* + * 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.ui + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.pingidentity.authenticatorapp.R +import com.pingidentity.authenticatorapp.data.AuthenticatorViewModel +import com.pingidentity.authenticatorapp.data.NotificationStatus +import com.pingidentity.authenticatorapp.data.PushNotificationItem +import com.pingidentity.authenticatorapp.ui.components.BackNavigationTopAppBar +import com.pingidentity.authenticatorapp.ui.components.EmptyStateMessage +import com.pingidentity.authenticatorapp.ui.components.NotificationCard +import com.pingidentity.authenticatorapp.ui.components.NotificationHistoryCard + +/** + * Screen that displays a list of push notifications, grouped by pending requests and history. + * Pending requests are shown at the top, followed by the notification history. + * Each notification card shows issuer, account name, message, status, time ago, + * and indicators for biometric/challenge authentication and location info. + * + * @param viewModel The AuthenticatorViewModel providing the UI state and actions. + * @param onNotificationClick Callback invoked when a notification is clicked, passing the notification ID. + * @param onDismiss Callback invoked when the user navigates back from this screen. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PushNotificationsScreen( + viewModel: AuthenticatorViewModel, + onNotificationClick: (String) -> Unit, + onDismiss: () -> Unit +) { + // Refresh notifications when this screen is shown + LaunchedEffect(Unit) { + viewModel.refreshNotifications() + } + + val uiState by viewModel.uiState.collectAsState() + + Scaffold( + topBar = { + BackNavigationTopAppBar( + title = "Push Notifications", + onBackClick = onDismiss + ) + } + ) { paddingValues -> + if (uiState.pushNotificationItems.isEmpty()) { + EmptyStateMessage( + title = stringResource(id = R.string.push_notifications_empty_state), + modifier = Modifier.padding(paddingValues) + ) + } else { + // Sort notifications with pending first, then by date + val sortedItems = remember(uiState.pushNotificationItems) { + uiState.pushNotificationItems.sortedWith( + compareBy { + // Sort pending notifications first + if (it.status == NotificationStatus.PENDING) 0 else 1 + }.thenByDescending { + // Then sort by creation date (newest first) + it.notification.createdAt.time + } + ) + } + + // Group notifications by status + val (pendingItems, historyItems) = remember(sortedItems) { + sortedItems.partition { it.status == NotificationStatus.PENDING } + } + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + if (pendingItems.isNotEmpty()) { + item { + Text( + text = stringResource(id = R.string.push_notifications_pending_requests), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + } + + items(pendingItems) { item -> + NotificationCard( + notificationItem = item, + onNotificationClick = { onNotificationClick(item.notification.id) } + ) + } + } + + if (historyItems.isNotEmpty()) { + item { + Text( + text = stringResource(id = R.string.push_notifications_notification_history), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + } + + items(historyItems) { item -> + NotificationHistoryCard( + notificationItem = item, + onNotificationClick = { onNotificationClick(item.notification.id) } + ) + } + } + } + } + } +} + diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/QrScannerScreen.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/QrScannerScreen.kt new file mode 100644 index 00000000..cb579d1f --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/QrScannerScreen.kt @@ -0,0 +1,289 @@ +/* + * 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.ui + +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import com.pingidentity.authenticatorapp.R +import com.pingidentity.authenticatorapp.data.AuthenticatorViewModel +import com.pingidentity.authenticatorapp.data.DiagnosticLogger +import com.pingidentity.authenticatorapp.ui.components.BackNavigationTopAppBar +import com.pingidentity.authenticatorapp.util.QrCodeAnalyzer +import com.pingidentity.mfa.commons.UriScheme +import java.util.concurrent.Executors + +/** + * A screen that uses the device camera to scan QR codes for adding new credentials. + * It handles camera permissions, displays a camera preview, and processes detected QR codes. + * + * @param viewModel The AuthenticatorViewModel instance for managing state and actions. + * @param onScanComplete Callback invoked when a QR code is successfully scanned and processed. + * @param onDismiss Callback invoked when the user wants to exit the scanner screen. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun QrScannerScreen( + viewModel: AuthenticatorViewModel, + onScanComplete: () -> Unit, + onDismiss: () -> Unit +) { + val context = LocalContext.current + val diagnosticLogger = DiagnosticLogger + val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current + val snackbarHostState = remember { SnackbarHostState() } + + // Camera permission state + var hasCameraPermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission( + context, + Manifest.permission.CAMERA + ) == PackageManager.PERMISSION_GRANTED + ) + } + + // Request camera permission + val requestPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + onResult = { isGranted -> + hasCameraPermission = isGranted + } + ) + + // Create an executor for background operations + val cameraExecutor = remember { Executors.newSingleThreadExecutor() } + + // Cleanup resources when leaving the screen + LaunchedEffect(Unit) { + if (!hasCameraPermission) { + requestPermissionLauncher.launch(Manifest.permission.CAMERA) + } + } + + // Show error message if viewModel has an error + val uiState by viewModel.uiState.collectAsState() + + // Show success message if a credential was added + LaunchedEffect(uiState.lastAddedOathCredential) { + if (uiState.lastAddedOathCredential != null) { + snackbarHostState.showSnackbar(context.getString(R.string.qr_scanner_account_added_successfully)) + viewModel.clearLastAddedOathCredential() + onScanComplete() + } + } + + // Also check for push credentials + LaunchedEffect(uiState.lastAddedPushCredential) { + if (uiState.lastAddedPushCredential != null) { + snackbarHostState.showSnackbar(context.getString(R.string.qr_scanner_account_added_successfully)) + viewModel.clearLastAddedPushCredential() + onScanComplete() + } + } + + Scaffold( + topBar = { + BackNavigationTopAppBar( + title = stringResource(id = R.string.content_description_scan_qr), + onBackClick = onDismiss + ) + }, + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) + } + ) { paddingValues -> + Box(modifier = Modifier + .fillMaxSize() + .padding(paddingValues)) { + if (hasCameraPermission) { + // Camera preview + AndroidView( + factory = { context -> + val previewView = PreviewView(context).apply { + implementationMode = PreviewView.ImplementationMode.PERFORMANCE + scaleType = PreviewView.ScaleType.FILL_CENTER + } + + val preview = Preview.Builder() + .build() + .also { + it.surfaceProvider = previewView.surfaceProvider + } + + val selector = CameraSelector.Builder() + .requireLensFacing(CameraSelector.LENS_FACING_BACK) + .build() + + // Configure image analysis with higher resolution for large QR codes + val imageAnalysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + + imageAnalysis.setAnalyzer( + cameraExecutor, + QrCodeAnalyzer { qrCodeResult -> + // Process the QR code result (otpauth URI, pushauth URI, or mfauth URI) + when { + qrCodeResult.startsWith(UriScheme.OTPAUTH.value) -> { + diagnosticLogger.d("QrScannerScreen: Detected OATH QR code") + viewModel.addOathCredentialFromUri(qrCodeResult) + onScanComplete() + } + + qrCodeResult.startsWith(UriScheme.PUSHAUTH.value) -> { + diagnosticLogger.d("QrScannerScreen: Detected Push QR code") + viewModel.addPushCredentialFromUri(qrCodeResult) + onScanComplete() + } + + qrCodeResult.startsWith(UriScheme.MFAUTH.value) -> { + diagnosticLogger.d("QrScannerScreen: Detected MFA QR code") + viewModel.addMfaCredentialFromUri(qrCodeResult) + onScanComplete() + } + + else -> { + // Show error for invalid QR code format + diagnosticLogger.d("QrScannerScreen: Invalid QR code format") + viewModel.setError("Invalid QR code format. Please scan a valid OATH, Push, or MFA authentication QR code.") + } + } + } + ) + + try { + // Bind camera use cases + val cameraProvider = ProcessCameraProvider.getInstance(context).get() + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle( + lifecycleOwner, + selector, + preview, + imageAnalysis + ) + } catch (e: Exception) { + diagnosticLogger.e( + "QrScannerScreen: Failed to bind camera use cases", + e + ) + viewModel.setError( + context.getString( + R.string.qr_scanner_error_camera_init, + e.message + ) + ) + } + + previewView + }, + modifier = Modifier.fillMaxSize() + ) + + // Scanning overlay + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxSize() + .padding(32.dp) + ) { + Text( + text = stringResource(id = R.string.qr_scanner_overlay_text), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), + textAlign = TextAlign.Center, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 24.dp) + ) + } + } else { + // Show permission denied message + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(id = R.string.qr_scanner_permission_required), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge + ) + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = { + requestPermissionLauncher.launch(Manifest.permission.CAMERA) + } + ) { + Text(text = stringResource(id = R.string.qr_scanner_request_permission_button)) + } + } + } + + // Error message + if (uiState.error != null) { + AlertDialog( + onDismissRequest = { viewModel.clearError() }, + title = { Text("Error") }, + text = { Text(uiState.error!!) }, + confirmButton = { + Button(onClick = { viewModel.clearError() }) { + Text(stringResource(id = R.string.ok)) + } + } + ) + } + } + } + + // Clean up camera executor when leaving the screen + DisposableEffect(lifecycleOwner) { + onDispose { + cameraExecutor.shutdown() + } + } +} diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/SettingsScreen.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/SettingsScreen.kt new file mode 100644 index 00000000..5a526d7a --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/SettingsScreen.kt @@ -0,0 +1,229 @@ +/* + * 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.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ListAlt +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.DarkMode +import androidx.compose.material.icons.filled.Dns +import androidx.compose.material.icons.filled.GroupWork +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.pingidentity.authenticatorapp.data.AuthenticatorViewModel +import com.pingidentity.authenticatorapp.data.ThemeMode +import com.pingidentity.authenticatorapp.ui.components.BackNavigationTopAppBar +import com.pingidentity.authenticatorapp.ui.components.SettingItem + +/** + * The settings screen for the Authenticator app. + * This screen allows users to configure various settings related to the app's behavior and appearance. + * + * @param viewModel The ViewModel that provides the settings state and handles updates. + * @param onDismiss Callback invoked when the user wants to exit the settings screen. + * @param onDiagnosticLogsClick Callback invoked when the user wants to view diagnostic logs. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + viewModel: AuthenticatorViewModel, + onDismiss: () -> Unit, + onDiagnosticLogsClick: () -> Unit = {} +) { + // Collect all settings as state + val copyOtp by viewModel.copyOtp.collectAsState() + val tapToReveal by viewModel.tapToReveal.collectAsState() + val combineAccounts by viewModel.combineAccounts.collectAsState() + val diagnosticLogging by viewModel.diagnosticLogging.collectAsState() + val testMode by viewModel.testMode.collectAsState() + val themeMode by viewModel.themeMode.collectAsState() + + // Dialog state for theme selection + var showThemeDialog by remember { mutableStateOf(false) } + + Scaffold( + topBar = { + BackNavigationTopAppBar( + title = "Settings", + onBackClick = onDismiss + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + ) { + // Copy OTP Setting + SettingItem( + icon = Icons.Default.ContentCopy, + title = "Copy OTP tokens when tapped", + description = "To copy the OTP token to the clipboard, tap the token", + checked = copyOtp, + onToggle = { viewModel.setCopyOtp(it) } + ) + + HorizontalDivider() + + // Tap to Reveal Setting + SettingItem( + icon = Icons.Default.VisibilityOff, + title = "Tap to reveal", + description = "OTP codes are hidden by default. To reveal the code, tap on the card", + checked = tapToReveal, + onToggle = { viewModel.setTapToReveal(it) } + ) + + HorizontalDivider() + + // Theme Setting + SettingItem( + icon = Icons.Default.DarkMode, + title = "Theme", + description = "Choose between light, dark, or follow system theme: ${getThemeDisplayName(themeMode)}", + hasNavigation = true, + onNavigate = { showThemeDialog = true } + ) + + HorizontalDivider() + + // Combine Accounts Setting + SettingItem( + icon = Icons.Default.GroupWork, + title = "Combine accounts", + description = "Group accounts with the same issuer and account name into a single entry", + checked = combineAccounts, + onToggle = { viewModel.setCombineAccounts(it) } + ) + + HorizontalDivider() + + // Diagnostic Logging Setting + SettingItem( + icon = Icons.Default.Dns, + title = "Enable diagnostic logging", + description = "Automatically collect errors from the app and save in place developers can collect", + checked = diagnosticLogging, + onToggle = { viewModel.setDiagnosticLogging(it) } + ) + + // View Diagnostic Logs (only visible when diagnostic logging is enabled) + if (diagnosticLogging) { + SettingItem( + icon = Icons.AutoMirrored.Filled.ListAlt, + title = "View diagnostic logs", + description = "View and export captured diagnostic logs", + hasNavigation = true, + onNavigate = onDiagnosticLogsClick + ) + } + + HorizontalDivider() + + // Test Mode Setting + SettingItem( + icon = Icons.Default.BugReport, + title = "Enable Test mode", + description = "Enable some developer features to test the app", + checked = testMode, + onToggle = { viewModel.setTestMode(it) } + ) + } + } + + // Theme selection dialog + if (showThemeDialog) { + ThemeSelectionDialog( + currentTheme = themeMode, + onThemeSelected = { selectedTheme -> + viewModel.setThemeMode(selectedTheme) + showThemeDialog = false + }, + onDismiss = { showThemeDialog = false } + ) + } +} + +/** + * Dialog for selecting the app theme + */ +@Composable +private fun ThemeSelectionDialog( + currentTheme: ThemeMode, + onThemeSelected: (ThemeMode) -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text("Choose Theme") + }, + text = { + Column { + ThemeMode.entries.forEach { theme -> + Row( + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { onThemeSelected(theme) } + .padding(vertical = 4.dp) + ) { + RadioButton( + selected = currentTheme == theme, + onClick = { onThemeSelected(theme) } + ) + Text( + text = getThemeDisplayName(theme), + modifier = Modifier.padding(start = 8.dp) + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} + +/** + * Get display name for theme mode + */ +private fun getThemeDisplayName(themeMode: ThemeMode): String { + return when (themeMode) { + ThemeMode.LIGHT -> "Light" + ThemeMode.DARK -> "Dark" + ThemeMode.SYSTEM -> "Follow System" + } +} \ No newline at end of file diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/TestScreen.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/TestScreen.kt new file mode 100644 index 00000000..8e448b1d --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/TestScreen.kt @@ -0,0 +1,970 @@ +/* + * 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.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.CleaningServices +import androidx.compose.material.icons.filled.Dashboard +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.FindInPage +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.GroupWork +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.LockOpen +import androidx.compose.material.icons.filled.RestorePage +import androidx.compose.material.icons.filled.Security +import androidx.compose.material.icons.filled.Sms +import androidx.compose.material.icons.filled.Sync +import androidx.compose.material.icons.filled.Timelapse +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.pingidentity.authenticatorapp.R +import com.pingidentity.authenticatorapp.data.AccountGroup +import com.pingidentity.authenticatorapp.data.AuthenticatorViewModel +import com.pingidentity.authenticatorapp.data.BackupFileInfo +import com.pingidentity.authenticatorapp.data.DatabaseInfo +import com.pingidentity.authenticatorapp.ui.components.SettingItem +import com.pingidentity.mfa.commons.policy.BiometricAvailablePolicy +import com.pingidentity.mfa.commons.policy.DeviceTamperingPolicy + +private const val LOCKING_POLICY_CUSTOM = "customPolicy" + +/** + * Screen for developer testing features. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TestScreen( + viewModel: AuthenticatorViewModel, + onDismiss: () -> Unit +) { + var deviceToken by remember { mutableStateOf(null) } + val snackbarHostState = remember { SnackbarHostState() } + val uiState by viewModel.uiState.collectAsState() + val destructiveRecovery by viewModel.destructiveRecovery.collectAsState() + val autoRestoreFromBackup by viewModel.autoRestoreFromBackup.collectAsState() + + // Account locking dialog states + var showAccountSelectionDialog by remember { mutableStateOf(false) } + var selectedAccount by remember { mutableStateOf(null) } + var showPolicySelectionDialog by remember { mutableStateOf(false) } + + // Backup management dialog states + var showOathBackupsDialog by remember { mutableStateOf(false) } + var showPushBackupsDialog by remember { mutableStateOf(false) } + var showDatabaseInfoDialog by remember { mutableStateOf(false) } + var showDestructiveRecoveryDialog by remember { mutableStateOf(false) } + var showAutoRestoreDialog by remember { mutableStateOf(false) } + var oathBackups by remember { mutableStateOf>(emptyList()) } + var pushBackups by remember { mutableStateOf>(emptyList()) } + var databaseInfo by remember { mutableStateOf(null) } + + // Handle success messages + LaunchedEffect(uiState.message) { + uiState.message?.let { message -> + snackbarHostState.showSnackbar(message) + viewModel.clearMessage() + } + } + + // Handle error messages + LaunchedEffect(uiState.error) { + uiState.error?.let { error -> + snackbarHostState.showSnackbar(error) + viewModel.clearError() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(id = R.string.test_screen_title)) }, + navigationIcon = { + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(id = R.string.back) + ) + } + } + ) + }, + snackbarHost = { + androidx.compose.material3.SnackbarHost(hostState = snackbarHostState) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(16.dp) + ) { + // Account actions + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = stringResource(id = R.string.test_screen_test_accounts_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = { viewModel.createRandomOathAccount() }, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + imageVector = Icons.Default.Timelapse, + contentDescription = stringResource(id = R.string.test_screen_create_random_oath) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(id = R.string.test_screen_create_random_oath)) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Button( + onClick = { viewModel.createRandomPushAccount() }, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + imageVector = Icons.Default.Sms, + contentDescription = stringResource(id = R.string.test_screen_create_random_push) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(id = R.string.test_screen_create_random_push)) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Button( + onClick = { viewModel.createRandomCombinedMfaAccount() }, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + imageVector = Icons.Default.GroupWork, + contentDescription = stringResource(id = R.string.test_screen_create_random_mfa) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(id = R.string.test_screen_create_random_mfa)) + } + } + } + + // Account locking section + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = stringResource(id = R.string.test_screen_lock_accounts_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = { showAccountSelectionDialog = true }, + modifier = Modifier.fillMaxWidth(), + enabled = uiState.accountGroups.isNotEmpty() + ) { + Icon( + imageVector = Icons.Default.Security, + contentDescription = stringResource(id = R.string.test_screen_lock_accounts_button) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(id = R.string.test_screen_lock_accounts_button)) + } + + if (uiState.accountGroups.isEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(id = R.string.test_screen_no_accounts_available), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Device token section + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = "Device Token", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = deviceToken + ?: stringResource(id = R.string.test_screen_loading_token), + style = MaterialTheme.typography.bodySmall, + maxLines = 5, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Row (modifier = Modifier.fillMaxWidth()) { + Button( + onClick = { + viewModel.getDeviceToken { token -> + deviceToken = token + } + } + ) { + Icon( + imageVector = Icons.Default.FindInPage, + contentDescription = stringResource(id = R.string.test_screen_get_token_button) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(id = R.string.test_screen_get_token_button)) + } + + Spacer(modifier = Modifier.width(8.dp)) + + Button( + onClick = { + viewModel.forceDeviceTokenRenew() + } + ) { + Icon( + imageVector = Icons.Default.Sync, + contentDescription = stringResource(id = R.string.test_screen_refresh_token_button) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(id = R.string.test_screen_refresh_token_button)) + } + } + + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Notification actions + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = stringResource(id = R.string.test_screen_notifications_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Call cleanup notifications + Button( + onClick = { viewModel.cleanupNotifications() }, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + imageVector = Icons.Default.CleaningServices, + contentDescription = stringResource(id = R.string.test_screen_clean_up_button) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(id = R.string.test_screen_clean_up_button)) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Database & Backup Management + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = "Database & Backup Management", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Destructive Recovery Setting + SettingItem( + icon = Icons.Default.Warning, + title = "Enable destructive database recovery", + description = "Automatically delete and recreate corrupted databases on initialization errors. Warning: This will cause data loss if corruption occurs.", + checked = destructiveRecovery, + onToggle = { enabled -> + if (enabled) { + showDestructiveRecoveryDialog = true + } else { + viewModel.setDestructiveRecovery(false) + } + } + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Auto-Restore From Backup Setting + SettingItem( + icon = if (autoRestoreFromBackup) Icons.Default.Sync else Icons.Default.Dashboard, + title = "Auto-restore from backup", + description = if (autoRestoreFromBackup) { + "SDK will automatically restore from backups on database errors" + } else { + "Disabled - You will see error screen and can choose recovery method" + }, + checked = autoRestoreFromBackup, + onToggle = { enabled -> + if (!enabled) { + showAutoRestoreDialog = true + } else { + viewModel.setAutoRestoreFromBackup(true) + } + } + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Create manual backup section + Button( + onClick = { viewModel.createManualBackups() }, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + imageVector = Icons.Default.Folder, + contentDescription = "Create Backup" + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Create Manual Backup") + } + + Spacer(modifier = Modifier.height(16.dp)) + + // View backups section + Text( + text = "Backup Files", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = { + viewModel.getOathBackupFiles { backups -> + oathBackups = backups + showOathBackupsDialog = true + } + }, + modifier = Modifier.weight(1f) + ) { + Icon( + imageVector = Icons.Default.Folder, + contentDescription = "View OATH Backups" + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("OATH", maxLines = 1) + } + + Button( + onClick = { + viewModel.getPushBackupFiles { backups -> + pushBackups = backups + showPushBackupsDialog = true + } + }, + modifier = Modifier.weight(1f) + ) { + Icon( + imageVector = Icons.Default.Folder, + contentDescription = "View PUSH Backups" + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("PUSH", maxLines = 1) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Restore from backup section + Text( + text = "Restore from Backup", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton( + onClick = { viewModel.restoreOathFromBackup() }, + modifier = Modifier.weight(1f) + ) { + Icon( + imageVector = Icons.Default.RestorePage, + contentDescription = "Restore OATH" + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("OATH", maxLines = 1) + } + + OutlinedButton( + onClick = { viewModel.restorePushFromBackup() }, + modifier = Modifier.weight(1f) + ) { + Icon( + imageVector = Icons.Default.RestorePage, + contentDescription = "Restore PUSH" + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("PUSH", maxLines = 1) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Error simulation section + Text( + text = "Simulate Error Scenarios", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.error + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Grid layout: 2x2 for database error simulation + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton( + onClick = { viewModel.simulateOathDatabaseReadOnly() }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Column(horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally) { + Text( + text = "OATH", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold + ) + Text( + text = "Read-Only", + style = MaterialTheme.typography.bodySmall + ) + } + } + + OutlinedButton( + onClick = { viewModel.simulateOathDatabaseCorruption() }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Column(horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally) { + Text( + text = "OATH", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold + ) + Text( + text = "Corrupt", + style = MaterialTheme.typography.bodySmall + ) + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton( + onClick = { viewModel.simulatePushDatabaseReadOnly() }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Column(horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally) { + Text( + text = "Push", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold + ) + Text( + text = "Read-Only", + style = MaterialTheme.typography.bodySmall + ) + } + } + + OutlinedButton( + onClick = { viewModel.simulatePushDatabaseCorruption() }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Column(horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally) { + Text( + text = "Push", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold + ) + Text( + text = "Corrupt", + style = MaterialTheme.typography.bodySmall + ) + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedButton( + onClick = { viewModel.clearAllBackups() }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Clear Backups" + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Clear All Backups") + } + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedButton( + onClick = { + viewModel.getDatabaseInfo { info -> + databaseInfo = info + showDatabaseInfoDialog = true + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + imageVector = Icons.Default.Dashboard, + contentDescription = "Database Info" + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("View Database Info") + } + } + } + + } + } + + // Account selection dialog + if (showAccountSelectionDialog) { + AlertDialog( + onDismissRequest = { showAccountSelectionDialog = false }, + title = { Text(stringResource(id = R.string.test_screen_select_account_to_lock)) }, + text = { + Column { + uiState.accountGroups.forEach { accountGroup -> + val isLocked = accountGroup.isLocked + OutlinedButton( + onClick = { + selectedAccount = accountGroup + showAccountSelectionDialog = false + if (isLocked) { + // Unlock immediately + viewModel.unlockAccountGroup(accountGroup) + } else { + // Show policy selection + showPolicySelectionDialog = true + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + colors = if (isLocked) { + ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + } else { + ButtonDefaults.outlinedButtonColors() + } + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = accountGroup.displayIssuer, + style = MaterialTheme.typography.titleSmall + ) + Text( + text = accountGroup.displayAccountName, + style = MaterialTheme.typography.bodySmall + ) + } + Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + Icon( + imageVector = if (isLocked) Icons.Default.Lock else Icons.Default.LockOpen, + contentDescription = if (isLocked) "Locked" else "Unlocked", + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = if (isLocked) "Locked" else "Unlocked", + style = MaterialTheme.typography.bodySmall + ) + } + } + } + } + } + }, + confirmButton = { + TextButton(onClick = { showAccountSelectionDialog = false }) { + Text("Cancel") + } + } + ) + } + + // Policy selection dialog + if (showPolicySelectionDialog && selectedAccount != null) { + val policyOptions = listOf( + BiometricAvailablePolicy.POLICY_NAME to stringResource(id = R.string.test_screen_policy_biometric), + DeviceTamperingPolicy.POLICY_NAME to stringResource(id = R.string.test_screen_policy_tampering), + LOCKING_POLICY_CUSTOM to stringResource(id = R.string.test_screen_policy_custom) + ) + var selectedPolicy by remember { mutableStateOf(policyOptions[0].first) } + + AlertDialog( + onDismissRequest = { + showPolicySelectionDialog = false + selectedAccount = null + }, + title = { Text(stringResource(id = R.string.test_screen_select_policy)) }, + text = { + Column { + policyOptions.forEach { (policy, displayName) -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + ) { + RadioButton( + selected = selectedPolicy == policy, + onClick = { selectedPolicy = policy } + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(displayName) + } + } + } + }, + confirmButton = { + TextButton( + onClick = { + selectedAccount?.let { account -> + viewModel.lockAccountGroup(account, selectedPolicy) + } + showPolicySelectionDialog = false + selectedAccount = null + } + ) { + Text(stringResource(id = R.string.test_screen_lock_account)) + } + }, + dismissButton = { + TextButton( + onClick = { + showPolicySelectionDialog = false + selectedAccount = null + } + ) { + Text("Cancel") + } + } + ) + } + + // OATH backups dialog + if (showOathBackupsDialog) { + AlertDialog( + onDismissRequest = { showOathBackupsDialog = false }, + title = { Text("OATH Backup Files") }, + text = { + if (oathBackups.isEmpty()) { + Text("No backup files found") + } else { + Column { + oathBackups.forEach { backup -> + Text( + text = "${backup.name}\n${backup.sizeBytes / 1024} KB", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(vertical = 4.dp) + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = { showOathBackupsDialog = false }) { + Text("Close") + } + } + ) + } + + // PUSH backups dialog + if (showPushBackupsDialog) { + AlertDialog( + onDismissRequest = { showPushBackupsDialog = false }, + title = { Text("PUSH Backup Files") }, + text = { + if (pushBackups.isEmpty()) { + Text("No backup files found") + } else { + Column { + pushBackups.forEach { backup -> + Text( + text = "${backup.name}\n${backup.sizeBytes / 1024} KB", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(vertical = 4.dp) + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = { showPushBackupsDialog = false }) { + Text("Close") + } + } + ) + } + + // Destructive recovery confirmation dialog + if (showDestructiveRecoveryDialog) { + AlertDialog( + onDismissRequest = { showDestructiveRecoveryDialog = false }, + icon = { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + }, + title = { + Text("Enable Destructive Recovery?") + }, + text = { + Text( + "WARNING: When enabled, if database corruption is detected during app initialization, " + + "the app will automatically delete all your credentials and start fresh.\n\n" + + "This helps the app recover from errors automatically, but you will lose all " + + "stored accounts if corruption occurs.\n\n" + + "This setting is intended for testing and development purposes." + ) + }, + confirmButton = { + TextButton( + onClick = { + viewModel.setDestructiveRecovery(true) + showDestructiveRecoveryDialog = false + }, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Text("Enable") + } + }, + dismissButton = { + TextButton(onClick = { showDestructiveRecoveryDialog = false }) { + Text("Cancel") + } + } + ) + } + + // Auto-restore confirmation dialog + if (showAutoRestoreDialog) { + AlertDialog( + onDismissRequest = { showAutoRestoreDialog = false }, + icon = { + Icon( + imageVector = Icons.Default.Dashboard, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + }, + title = { + Text("Disable Auto-Restore From Backup?") + }, + text = { + Text( + "When disabled, the SDK will NOT automatically restore from backups when database errors occur.\n\n" + + "✓ You will see an error screen when corruption occurs\n" + + "✓ You can choose to manually restore from backup or use destructive recovery\n" + + "✓ Backups are still created and available\n\n" + + "This gives you better control over the recovery process and allows testing the error handling UI." + ) + }, + confirmButton = { + TextButton( + onClick = { + viewModel.setAutoRestoreFromBackup(false) + showAutoRestoreDialog = false + } + ) { + Text("Disable") + } + }, + dismissButton = { + TextButton(onClick = { showAutoRestoreDialog = false }) { + Text("Cancel") + } + } + ) + } + + // Database info dialog + if (showDatabaseInfoDialog && databaseInfo != null) { + AlertDialog( + onDismissRequest = { showDatabaseInfoDialog = false }, + title = { Text("Database Information") }, + text = { + Column { + Text( + text = "OATH Database", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold + ) + Text("Path: ${databaseInfo?.oathDbPath}") + Text("Size: ${(databaseInfo?.oathDbSize ?: 0) / 1024} KB") + Text("Backups: ${databaseInfo?.oathBackupCount}") + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "PUSH Database", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold + ) + Text("Path: ${databaseInfo?.pushDbPath}") + Text("Size: ${(databaseInfo?.pushDbSize ?: 0) / 1024} KB") + Text("Backups: ${databaseInfo?.pushBackupCount}") + } + }, + confirmButton = { + TextButton(onClick = { showDatabaseInfoDialog = false }) { + Text("Close") + } + } + ) + } +} diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/AccountAvatar.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/AccountAvatar.kt new file mode 100644 index 00000000..24bc08e6 --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/AccountAvatar.kt @@ -0,0 +1,113 @@ +/* + * 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.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import coil.compose.SubcomposeAsyncImage +import coil.request.ImageRequest +import kotlin.math.absoluteValue + +/** + * Composable for displaying an account avatar image or a colored background with initials + */ +@Composable +fun AccountAvatar( + issuer: String, + accountName: String, + imageUrl: String? = null, + size: androidx.compose.ui.unit.Dp = 40.dp, + modifier: Modifier = Modifier +) { + val backgroundColor = generateBackgroundColor(issuer, accountName) + val initials = getInitials(issuer) + + Box( + modifier = modifier + .size(size) + .background( + color = backgroundColor, + shape = RoundedCornerShape(8.dp) + ), + contentAlignment = Alignment.Center + ) { + if (imageUrl != null) { + SubcomposeAsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(imageUrl) + .crossfade(true) + .build(), + contentDescription = "Account logo", + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Crop, + loading = { + LoadingIndicator() + }, + error = { + InitialsText(initials) + } + ) + } else { + // Fallback to initials if no image URL + InitialsText(initials) + } + } +} + +@Composable +fun InitialsText(text: String) { + Text( + text = text, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onPrimary + ) +} + +@Composable +fun LoadingIndicator() { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp + ) +} + +/** + * Generates a background color from the issuer and account name. + */ +private fun generateBackgroundColor(issuer: String, accountName: String): Color { + val hash = (issuer.hashCode() + accountName.hashCode()).absoluteValue % 360 + return Color.hsl(hash.toFloat(), 0.6f, 0.55f) +} + +/** + * Gets the initials from a string. + */ +private fun getInitials(text: String): String { + return text.split(" ") + .filter { it.isNotEmpty() } + .take(2) + .joinToString("") { it.first().uppercaseChar().toString() } +} + diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/AccountGroupItem.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/AccountGroupItem.kt new file mode 100644 index 00000000..babb1c8e --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/AccountGroupItem.kt @@ -0,0 +1,323 @@ +/* + * 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.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Timelapse +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.pingidentity.authenticatorapp.R +import com.pingidentity.authenticatorapp.data.AccountGroup +import com.pingidentity.authenticatorapp.data.getLockMessage +import com.pingidentity.mfa.oath.OathCodeInfo +import com.pingidentity.mfa.oath.OathType + +/** + * Composable for displaying an account group item with OATH and Push credentials. + * + * @param accountGroup The account group containing OATH and Push credentials. + * @param codes Map of OATH code information keyed by credential ID. + * @param onRefreshCode Callback to refresh the OATH code for a given credential ID. + * @param onItemClick Callback when the item is clicked. + * @param onCopyToClipboard Callback to copy text to clipboard. + * @param copyOtpEnabled Whether OTP copying on tap is enabled. + * @param tapToRevealEnabled Whether tap-to-reveal is enabled. + * @param modifier Modifier to apply to the composable. + */ +@Composable +fun AccountGroupItem( + accountGroup: AccountGroup, + codes: Map, + onRefreshCode: (String) -> Unit, + onItemClick: () -> Unit, + onCopyToClipboard: (String, String) -> Unit = { _, _ -> }, + copyOtpEnabled: Boolean = false, + tapToRevealEnabled: Boolean = false, + currentTimeMillis: Long = System.currentTimeMillis(), + modifier: Modifier = Modifier +) { + LocalContext.current + + // Find the first TOTP code to display if available + val firstOathCredential = accountGroup.oathCredentials.firstOrNull() + val firstOathCode = firstOathCredential?.let { codes[it.id] } + + // State for tap-to-reveal functionality + // Reset revealed state when credential is unlocked or code changes + var isRevealed by remember(firstOathCredential?.isLocked, firstOathCode?.code) { + mutableStateOf(!tapToRevealEnabled || (firstOathCredential?.isLocked == false && firstOathCode != null)) + } + + val progress = if (firstOathCode != null && firstOathCredential != null && firstOathCredential.oathType == OathType.TOTP) { + // Calculate real-time progress based on current time and credential period + val currentTimeSeconds = currentTimeMillis / 1000L + val periodSeconds = firstOathCredential.period.toLong() + if (periodSeconds > 0) { + val timeIntoCurrentPeriod = currentTimeSeconds % periodSeconds + val progressValue = timeIntoCurrentPeriod.toFloat() / periodSeconds.toFloat() + progressValue + } else { + 0f + } + } else { + 0f + } + + val hasOathCredentials = accountGroup.oathCredentials.isNotEmpty() + val hasPushCredentials = accountGroup.pushCredentials.isNotEmpty() + + // Determine which image URL to use (use OATH first if available, otherwise Push) + val imageUrl = when { + firstOathCredential?.imageURL != null -> firstOathCredential.imageURL + accountGroup.pushCredentials.firstOrNull()?.imageURL != null -> + accountGroup.pushCredentials.first().imageURL + else -> null + } + + Card( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onItemClick), + colors = CardDefaults.cardColors( + containerColor = if (accountGroup.isLocked) + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + else + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // Account logo from imageUrl or initials as fallback + AccountAvatar( + issuer = accountGroup.displayIssuer, + accountName = accountGroup.displayAccountName, + imageUrl = imageUrl, + size = 48.dp + ) + + Spacer(modifier = Modifier.width(16.dp)) + + // Issuer and account name + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = accountGroup.displayIssuer, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Text( + text = accountGroup.displayAccountName, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + // Authentication type indicators + Row( + modifier = Modifier.padding(top = 4.dp), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + if (hasOathCredentials) { + Box( + modifier = Modifier + .background( + color = MaterialTheme.colorScheme.primaryContainer, + shape = RoundedCornerShape(4.dp) + ) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = stringResource(id = R.string.account_group_item_oath), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + Spacer(modifier = Modifier.width(4.dp)) + } + + if (hasPushCredentials) { + Box( + modifier = Modifier + .background( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = RoundedCornerShape(4.dp) + ) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = stringResource(id = R.string.account_group_item_push), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } + } + } + + // OATH code display (only if there are OATH credentials and account is not locked) + if (hasOathCredentials && !accountGroup.isLocked) { + Box( + modifier = Modifier + .padding(start = 4.dp) + .wrapContentWidth() + ) { + firstOathCode?.let { info -> + val otpCodeLabel = stringResource(id = R.string.account_group_item_otp_code_label) + Column( + modifier = Modifier.offset(x = 12.dp), + horizontalAlignment = Alignment.End + ) { + val displayText = if (tapToRevealEnabled && !isRevealed) { + stringResource(id = R.string.account_group_item_otp_placeholder) + } else { + info.code + } + + Text( + text = displayText, + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.clickable { + when { + tapToRevealEnabled && !isRevealed -> { + // Reveal the code + isRevealed = true + } + copyOtpEnabled && isRevealed -> { + // Copy the code to clipboard + onCopyToClipboard(info.code, otpCodeLabel) + } + !tapToRevealEnabled && copyOtpEnabled -> { + // Copy the code to clipboard + onCopyToClipboard(info.code, otpCodeLabel) + } + else -> { + // Default behavior - open detail screen + onItemClick() + } + } + } + ) + + if (firstOathCredential.oathType == OathType.TOTP) { + // Progress indicator for TOTP + LinearProgressIndicator( + progress = { 1f - progress }, // Reverse progress (countdown) + modifier = Modifier + .width(80.dp) + .padding(top = 4.dp), + color = MaterialTheme.colorScheme.primary + ) + } + } + } ?: run { + // If no code is generated yet, show a code placeholder button + TextButton(onClick = { + if (firstOathCredential != null) { + onRefreshCode(firstOathCredential.id) + } + }) { + Text(stringResource(id = R.string.account_group_item_otp_placeholder)) + } + } + } + + // Refresh or timer icon for OATH codes + Column( + modifier = Modifier + .wrapContentWidth() + .offset(x = 12.dp), + horizontalAlignment = Alignment.End + ) { + if (firstOathCredential?.oathType == OathType.TOTP) { + IconButton(onClick = {}) { + Icon(Icons.Default.Timelapse, contentDescription = null) + } + } else if (firstOathCredential != null) { + IconButton(onClick = { onRefreshCode(firstOathCredential.id) }) { + Icon(Icons.Default.Refresh, contentDescription = null) + } + } + } + } + } + + // Show lock message if account is locked + if (accountGroup.isLocked) { + Row( + modifier = Modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f), + shape = RoundedCornerShape(4.dp) + ) + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Lock, + contentDescription = stringResource(id = R.string.account_locked_indicator), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + val lockMessage = getLockMessage(accountGroup.lockingPolicy) + Text( + text = lockMessage, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } + } + } +} \ No newline at end of file diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/BackNavigationTopAppBar.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/BackNavigationTopAppBar.kt new file mode 100644 index 00000000..cca7f5ee --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/BackNavigationTopAppBar.kt @@ -0,0 +1,44 @@ +/* + * 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.ui.components + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.pingidentity.authenticatorapp.R + +/** + * A TopAppBar with a back navigation icon and a title. + * + * @param title The title to display in the app bar. + * @param onBackClick Callback invoked when the back icon is clicked. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BackNavigationTopAppBar( + title: String, + onBackClick: () -> Unit +) { + TopAppBar( + title = { Text(text = title) }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(id = R.string.back) + ) + } + } + ) +} \ No newline at end of file diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/CallbackRenderers.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/CallbackRenderers.kt new file mode 100644 index 00000000..2708303d --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/CallbackRenderers.kt @@ -0,0 +1,226 @@ +/* + * 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.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import com.pingidentity.journey.callback.NameCallback +import com.pingidentity.journey.callback.PasswordCallback +import com.pingidentity.journey.callback.TextInputCallback +import com.pingidentity.journey.callback.TextOutputCallback +import com.pingidentity.journey.plugin.callbacks +import com.pingidentity.orchestrate.ContinueNode + +/** + * Composable that renders a ContinueNode with its callbacks + */ +@Composable +fun ContinueNodeRenderer( + node: ContinueNode, + onNodeUpdated: () -> Unit, + onNext: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Render each callback + node.callbacks.forEach { callback -> + when (callback) { + is NameCallback -> { + NameCallbackRenderer( + callback = callback, + onValueChanged = onNodeUpdated + ) + } + is PasswordCallback -> { + PasswordCallbackRenderer( + callback = callback, + onValueChanged = onNodeUpdated + ) + } + is TextInputCallback -> { + TextInputCallbackRenderer( + callback = callback, + onValueChanged = onNodeUpdated + ) + } + is TextOutputCallback -> { + TextOutputCallbackRenderer(callback = callback) + } + else -> { + // For unhandled callbacks, show basic info + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Text( + text = "Callback: ${callback.javaClass.simpleName}", + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + } + + // Next button + Button( + onClick = onNext, + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp) + ) { + Text("Next") + } + } +} + +/** + * Renders a NameCallback (username input) + */ +@Composable +private fun NameCallbackRenderer( + callback: NameCallback, + onValueChanged: () -> Unit +) { + var textValue by remember(callback) { mutableStateOf(callback.name) } + + OutlinedTextField( + value = textValue, + onValueChange = { value -> + textValue = value + callback.name = value + onValueChanged() + }, + label = { Text(callback.prompt) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) +} + +/** + * Renders a PasswordCallback (password input) + */ +@Composable +private fun PasswordCallbackRenderer( + callback: PasswordCallback, + onValueChanged: () -> Unit +) { + var passwordVisibility by remember { mutableStateOf(false) } + var passwordValue by remember(callback) { mutableStateOf(callback.password) } + + OutlinedTextField( + value = passwordValue, + onValueChange = { value -> + passwordValue = value + callback.password = value + onValueChanged() + }, + label = { Text(callback.prompt) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + visualTransformation = if (passwordVisibility) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + trailingIcon = { + IconButton(onClick = { passwordVisibility = !passwordVisibility }) { + Icon( + imageVector = if (passwordVisibility) { + Icons.Default.Visibility + } else { + Icons.Default.VisibilityOff + }, + contentDescription = if (passwordVisibility) { + "Hide password" + } else { + "Show password" + } + ) + } + } + ) +} + +/** + * Renders a TextInputCallback (generic text input) + */ +@Composable +private fun TextInputCallbackRenderer( + callback: TextInputCallback, + onValueChanged: () -> Unit +) { + var textValue by remember(callback) { mutableStateOf(callback.text) } + + OutlinedTextField( + value = textValue, + onValueChange = { value -> + textValue = value + callback.text = value + onValueChanged() + }, + label = { Text(callback.prompt) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) +} + +/** + * Renders a TextOutputCallback (display text) + */ +@Composable +private fun TextOutputCallbackRenderer( + callback: TextOutputCallback +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Text( + text = callback.message, + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodyMedium + ) + } +} diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/CircularProgressTimer.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/CircularProgressTimer.kt new file mode 100644 index 00000000..a65a114c --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/CircularProgressTimer.kt @@ -0,0 +1,70 @@ +/* + * 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.ui.components + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke + +/** + * A composable that displays a circular progress indicator that animates to the given progress value. + * This is useful for showing a countdown timer or similar progress indication. + * + * @param progress The current progress value between 0f and 1f. + * @param modifier Optional modifier to apply to the composable. + */ +@Composable +fun CircularProgressTimer( + progress: Float, + modifier: Modifier = Modifier +) { + val animatedProgress = remember(progress) { + Animatable(initialValue = progress) + } + + LaunchedEffect(progress) { + animatedProgress.animateTo( + targetValue = progress, + animationSpec = tween(durationMillis = 500, easing = LinearEasing) + ) + } + + val color = MaterialTheme.colorScheme.primary + val trackColor = MaterialTheme.colorScheme.surfaceVariant + + Box( + modifier = modifier.drawBehind { + // Draw background track + drawArc( + color = trackColor, + startAngle = 0f, + sweepAngle = 360f, + useCenter = false, + style = Stroke(width = 10f, cap = StrokeCap.Round) + ) + + // Draw progress + drawArc( + color = color, + startAngle = -90f, + sweepAngle = 360f * (1f - animatedProgress.value), + useCenter = false, + style = Stroke(width = 10f, cap = StrokeCap.Round) + ) + } + ) +} \ No newline at end of file diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/DetailRow.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/DetailRow.kt new file mode 100644 index 00000000..d9fbce30 --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/DetailRow.kt @@ -0,0 +1,51 @@ +/* + * 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.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * A composable that displays a label and its corresponding value in a row. + * The label is styled with a secondary text color, while the value uses the default text style. + * + * @param label The label text to display on the left side. + * @param value The value text to display on the right side. + * @param modifier Optional modifier to apply to the row. + */ +@Composable +fun DetailRow( + label: String, + value: String, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = value, + style = MaterialTheme.typography.bodyMedium + ) + } +} \ No newline at end of file diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/EditAccountDialog.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/EditAccountDialog.kt new file mode 100644 index 00000000..5db10c39 --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/EditAccountDialog.kt @@ -0,0 +1,132 @@ +/* + * 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.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import com.pingidentity.authenticatorapp.data.AccountGroup + +/** + * A dialog that allows editing the display issuer and account name for a given account. + * + * @param account The AccountGroup containing the original issuer and account name. + * @param onDismiss Callback invoked when the dialog is dismissed without saving changes. + * @param onConfirm Callback invoked with the new display issuer and account name when changes are saved. + */ +@Composable +fun EditAccountDialog( + account: AccountGroup, + onDismiss: () -> Unit, + onConfirm: (String, String) -> Unit +) { + // Use the current display names if available, otherwise fall back to original names + val currentDisplayIssuer = account.oathCredentials.firstOrNull()?.displayIssuer + ?: account.pushCredentials.firstOrNull()?.displayIssuer + ?: account.issuer + + val currentDisplayAccountName = account.oathCredentials.firstOrNull()?.displayAccountName + ?: account.pushCredentials.firstOrNull()?.displayAccountName + ?: account.accountName + + var displayIssuer by remember { mutableStateOf(currentDisplayIssuer) } + var displayAccountName by remember { mutableStateOf(currentDisplayAccountName) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Edit Account Display Names") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text( + text = "Edit how this account appears in the app. The original names will be preserved.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + OutlinedTextField( + value = displayIssuer, + onValueChange = { displayIssuer = it }, + label = { Text("Display Issuer") }, + placeholder = { Text("e.g., My Company") }, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + singleLine = true + ) + + OutlinedTextField( + value = displayAccountName, + onValueChange = { displayAccountName = it }, + label = { Text("Display Account Name") }, + placeholder = { Text("e.g., My Account") }, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + singleLine = true + ) + + // Show original values for reference + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = "Original Values:", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = "Issuer: ${account.issuer}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "Account: ${account.accountName}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + }, + confirmButton = { + Button( + onClick = { + onConfirm(displayIssuer.trim(), displayAccountName.trim()) + }, + enabled = displayIssuer.trim().isNotEmpty() && displayAccountName.trim().isNotEmpty() + ) { + Text("Save Changes") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} \ No newline at end of file diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/EditableAccountItem.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/EditableAccountItem.kt new file mode 100644 index 00000000..4e7588e1 --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/EditableAccountItem.kt @@ -0,0 +1,208 @@ +/* + * 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.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.pingidentity.authenticatorapp.R +import com.pingidentity.authenticatorapp.data.AccountGroup + +/** + * Composable that displays an editable account item with avatar, issuer, account name, + * credential counts, and buttons for edit, delete, and reorder (move up/down). + * + * @param accountGroup The AccountGroup to display. + * @param onDeleteClick Callback when the delete button is clicked. + * @param onEditClick Callback when the edit button is clicked. + * @param onMoveUp Callback when the move up button is clicked. + * @param onMoveDown Callback when the move down button is clicked. + * @param canMoveUp Whether the account can be moved up (not the first item). + * @param canMoveDown Whether the account can be moved down (not the last item). + */ +@Composable +fun EditableAccountItem( + accountGroup: AccountGroup, + onDeleteClick: () -> Unit, + onEditClick: () -> Unit, + onMoveUp: () -> Unit, + onMoveDown: () -> Unit, + canMoveUp: Boolean, + canMoveDown: Boolean +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = if (accountGroup.isLocked) + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + else + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Reorder controls + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(end = 12.dp) + ) { + IconButton( + onClick = onMoveUp, + enabled = canMoveUp, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.KeyboardArrowUp, + contentDescription = "Move Up", + tint = if (canMoveUp) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) + ) + } + IconButton( + onClick = onMoveDown, + enabled = canMoveDown, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = "Move Down", + tint = if (canMoveDown) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) + ) + } + } + + Spacer(modifier = Modifier.width(8.dp)) + + // Account avatar + val imageUrl = accountGroup.oathCredentials.firstOrNull()?.imageURL + ?: accountGroup.pushCredentials.firstOrNull()?.imageURL + + AccountAvatar( + issuer = accountGroup.displayIssuer, + accountName = accountGroup.displayAccountName, + imageUrl = imageUrl, + size = 48.dp + ) + + Spacer(modifier = Modifier.width(16.dp)) + + // Account info + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = accountGroup.displayIssuer, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + Text( + text = accountGroup.displayAccountName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + // Show credential counts + val oathCount = accountGroup.oathCredentials.size + val pushCount = accountGroup.pushCredentials.size + val credentialInfo = buildString { + if (oathCount > 0) append("$oathCount OATH") + if (oathCount > 0 && pushCount > 0) append(", ") + if (pushCount > 0) append("$pushCount Push") + } + + Text( + text = credentialInfo, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + // Show lock indicator if account is locked + if (accountGroup.isLocked) { + Row( + modifier = Modifier.padding(top = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Lock, + contentDescription = stringResource(id = R.string.account_locked_indicator), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(14.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = stringResource(id = R.string.account_locked_indicator), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } + } + + // Edit button - disabled for locked accounts + IconButton( + onClick = onEditClick, + enabled = !accountGroup.isLocked + ) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = "Edit Account", + tint = if (accountGroup.isLocked) + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) + else + MaterialTheme.colorScheme.primary + ) + } + + // Delete button - disabled for locked accounts + IconButton( + onClick = onDeleteClick, + enabled = !accountGroup.isLocked + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Delete Account", + tint = if (accountGroup.isLocked) + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) + else + MaterialTheme.colorScheme.error + ) + } + } + } +} \ No newline at end of file diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/EmptyStateMessage.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/EmptyStateMessage.kt new file mode 100644 index 00000000..a2f0154e --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/EmptyStateMessage.kt @@ -0,0 +1,59 @@ +/* + * 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.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +/** + * A composable that displays a centered empty state message with an optional subtitle. + * This is useful for indicating that there is no data to display in a list or screen. + * + * @param title The main title text to display. + * @param subtitle Optional subtitle text to display below the title. + * @param modifier Optional modifier to apply to the column layout. + */ +@Composable +fun EmptyStateMessage( + title: String, + subtitle: String? = null, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + subtitle?.let { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center + ) + } + } +} \ No newline at end of file diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/ErrorAlertDialog.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/ErrorAlertDialog.kt new file mode 100644 index 00000000..626e34b9 --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/ErrorAlertDialog.kt @@ -0,0 +1,38 @@ +/* + * 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.ui.components + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.pingidentity.authenticatorapp.R + +/** + * A composable that displays an error alert dialog with a given error message and a dismiss button. + * + * @param errorMessage The error message to display in the dialog. + * @param onDismiss Callback invoked when the dialog is dismissed. + */ +@Composable +fun ErrorAlertDialog( + errorMessage: String, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(id = R.string.error_title)) }, + text = { Text(errorMessage) }, + confirmButton = { + Button(onClick = onDismiss) { + Text(stringResource(id = R.string.ok)) + } + } + ) +} \ No newline at end of file diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/InfoCard.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/InfoCard.kt new file mode 100644 index 00000000..0298295e --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/InfoCard.kt @@ -0,0 +1,56 @@ +/* + * 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.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +/** + * A reusable card component that displays a title and content. + * The card has a semi-transparent background to subtly distinguish it from the surrounding UI. + * + * @param title The title text to display at the top of the card. + * @param modifier Optional modifier to apply to the card. + * @param content A composable lambda that defines the content to display within the card. + */ +@Composable +fun InfoCard( + title: String, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 12.dp) + ) + content() + } + } +} \ No newline at end of file diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/LoadingIndicator.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/LoadingIndicator.kt new file mode 100644 index 00000000..341ada5e --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/LoadingIndicator.kt @@ -0,0 +1,50 @@ +/* + * 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.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * A composable that displays a centered loading indicator with a message. + * This is useful for indicating that a background operation is in progress. + * + * @param message The message to display below the loading indicator. + * @param modifier Optional modifier to apply to the column layout. + */ +@Composable +fun LoadingIndicator( + message: String, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator() + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium + ) + } +} \ No newline at end of file diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/NotificationCard.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/NotificationCard.kt new file mode 100644 index 00000000..b2a50a6f --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/NotificationCard.kt @@ -0,0 +1,181 @@ +/* + * 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.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccessTime +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Pin +import androidx.compose.material.icons.outlined.Fingerprint +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.pingidentity.authenticatorapp.R +import com.pingidentity.authenticatorapp.data.PushNotificationItem + +/** + * Composable that displays a single push notification card with issuer, account name, message, + * time ago, and indicators for biometric/challenge authentication and location info. + * + * @param notificationItem The PushNotificationItem to display. + * @param onNotificationClick Callback when the notification card is clicked. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NotificationCard( + notificationItem: PushNotificationItem, + onNotificationClick: () -> Unit +) { + Card( + onClick = onNotificationClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + // Issuer and account name + Row( + verticalAlignment = Alignment.CenterVertically + ) { + val issuer = notificationItem.credential?.displayIssuer ?: stringResource(id = R.string.notification_response_unknown_issuer) + val accountName = notificationItem.credential?.displayAccountName + ?: stringResource(id = R.string.notification_response_unknown_account) + AccountAvatar( + issuer = issuer, + accountName = accountName, + imageUrl = notificationItem.credential?.imageURL, + size = 32.dp + ) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text( + text = issuer, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = accountName, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Message + Text( + text = notificationItem.notification.messageText + ?: stringResource(id = R.string.notification_response_message_default), + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(4.dp)) + + // Row for time ago and location indicator + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 4.dp) + ) { + // Time ago + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + Icon( + imageVector = Icons.Default.AccessTime, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = notificationItem.timeAgo, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // Authentication type indicators + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 8.dp) + ) { + val (icon, text) = when { + notificationItem.requiresBiometric -> Pair( + Icons.Outlined.Fingerprint, + stringResource(id = R.string.notification_response_auth_method_biometric) + ) + + notificationItem.requiresChallenge -> Pair( + Icons.Default.Pin, + stringResource(id = R.string.notification_response_auth_method_challenge) + ) + + else -> Pair( + Icons.Default.CheckCircle, + stringResource(id = R.string.notification_response_auth_method_standard) + ) + } + Icon( + icon, + text, + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier.size(16.dp) + ) + } + + // Location indicator if available + if (notificationItem.hasLocationInfo) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 8.dp) + ) { + Icon( + imageVector = Icons.Default.LocationOn, + contentDescription = stringResource(id = R.string.content_description_location_available), + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier.size(16.dp) + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/NotificationHistoryCard.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/NotificationHistoryCard.kt new file mode 100644 index 00000000..6e3f897d --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/NotificationHistoryCard.kt @@ -0,0 +1,189 @@ +/* + * 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.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccessTime +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Pin +import androidx.compose.material.icons.outlined.Fingerprint +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.pingidentity.authenticatorapp.R +import com.pingidentity.authenticatorapp.data.PushNotificationItem + +/** + * A card that displays a summary of a push notification, including issuer, account name, + * message, status, time ago, and indicators for biometric/challenge authentication and location info. + * + * @param notificationItem The push notification item to display. + * @param onNotificationClick Callback invoked when the card is clicked. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NotificationHistoryCard( + notificationItem: PushNotificationItem, + onNotificationClick: () -> Unit +) { + Card( + onClick = onNotificationClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + // Status indicator and account header + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + AccountAvatar( + issuer = notificationItem.credential?.displayIssuer ?: stringResource(id = R.string.notification_response_unknown_issuer), + accountName = notificationItem.credential?.displayAccountName + ?: stringResource(id = R.string.notification_response_unknown_account), + imageUrl = notificationItem.credential?.imageURL, + size = 32.dp + ) + + Spacer(modifier = Modifier.width(8.dp)) + + // Issuer and account name + Column(modifier = Modifier.weight(1f)) { + val issuer = notificationItem.credential?.displayIssuer ?: stringResource(id = R.string.notification_response_unknown_issuer) + val accountName = + notificationItem.credential?.displayAccountName ?: stringResource(id = R.string.notification_response_unknown_account) + + Text( + text = issuer, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = accountName, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + // Status indicator + StatusIndicator(status = notificationItem.status) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Message + Text( + text = notificationItem.notification.messageText ?: stringResource(id = R.string.notification_response_message_default), + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(4.dp)) + + // Row for time ago and location indicator + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 4.dp) + ) { + // Created time + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + Icon( + imageVector = Icons.Default.AccessTime, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = notificationItem.notification.createdAt.toString(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // Authentication type indicators + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 8.dp) + ) { + val (icon, text) = when { + notificationItem.requiresBiometric -> Pair( + Icons.Outlined.Fingerprint, + stringResource(id = R.string.notification_response_auth_method_biometric) + ) + + notificationItem.requiresChallenge -> Pair( + Icons.Default.Pin, + stringResource(id = R.string.notification_response_auth_method_challenge) + ) + + else -> Pair( + Icons.Default.CheckCircle, + stringResource(id = R.string.notification_response_auth_method_standard) + ) + } + Icon( + icon, + text, + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier.size(16.dp) + ) + } + + // Location indicator if available + if (notificationItem.hasLocationInfo) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 8.dp) + ) { + Icon( + imageVector = Icons.Default.LocationOn, + contentDescription = "Location information available", + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier.size(16.dp) + ) + } + } + } + } + } +} diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/SettingItem.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/SettingItem.kt new file mode 100644 index 00000000..ff9446c7 --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/SettingItem.kt @@ -0,0 +1,111 @@ +/* + * 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.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp + +/** + * A reusable setting item component that displays an icon, title, description, + * and either a toggle switch or a navigation arrow. + * + * @param icon The icon to display on the left side of the setting item. + * @param title The title text of the setting item. + * @param description The description text of the setting item. + * @param checked The current state of the toggle switch (if applicable). + * @param hasNavigation Whether to show a navigation arrow instead of a toggle switch. + * @param onToggle Optional callback invoked when the toggle switch is changed. + * @param onNavigate Optional callback invoked when the item is clicked for navigation. + * @param modifier Optional modifier to apply to the entire setting item. + */ +@Composable +fun SettingItem( + icon: ImageVector, + title: String, + description: String, + checked: Boolean = false, + hasNavigation: Boolean = false, + onToggle: ((Boolean) -> Unit)? = null, + onNavigate: (() -> Unit)? = null, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = hasNavigation && onNavigate != null) { + if (hasNavigation && onNavigate != null) { + onNavigate() + } + } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Icon + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.width(16.dp)) + + // Title and description + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // Toggle or navigation arrow + if (hasNavigation && onNavigate != null) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = "Navigate", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else if (onToggle != null) { + Spacer(modifier = Modifier.width(4.dp)) + Switch( + checked = checked, + onCheckedChange = onToggle + ) + } + } + } +} diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/StatusIndicator.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/StatusIndicator.kt new file mode 100644 index 00000000..e754877d --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/StatusIndicator.kt @@ -0,0 +1,70 @@ +/* + * 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.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccessTime +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.pingidentity.authenticatorapp.data.NotificationStatus + +/** + * Status indicator for push notifications. + */ +@Composable +fun StatusIndicator(status: NotificationStatus) { + val (icon, color, label) = when (status) { + NotificationStatus.PENDING -> Triple( + Icons.Default.AccessTime, + MaterialTheme.colorScheme.tertiary, + "Pending" + ) + NotificationStatus.APPROVED -> Triple( + Icons.Default.CheckCircle, + MaterialTheme.colorScheme.primary, + "Approved" + ) + NotificationStatus.DENIED -> Triple( + Icons.Outlined.Close, + MaterialTheme.colorScheme.error, + "Denied" + ) + NotificationStatus.EXPIRED -> Triple( + Icons.Default.Error, + MaterialTheme.colorScheme.onSurfaceVariant, + "Expired" + ) + } + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(32.dp) + .background(color = color.copy(alpha = 0.1f), shape = CircleShape) + .padding(4.dp) + ) { + Icon( + imageVector = icon, + contentDescription = label, + tint = color, + modifier = Modifier.size(16.dp) + ) + } +} \ No newline at end of file diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/theme/Color.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/theme/Color.kt new file mode 100644 index 00000000..7c1d4e9d --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/theme/Color.kt @@ -0,0 +1,18 @@ +/* + * 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.ui.theme + +import androidx.compose.ui.graphics.Color + +// Ping Identity Colors +val PingBlue = Color(0xFF006AC8) +val PingGreen = Color(0xFF00BB86) +val PingOrange = Color(0xFFF96700) +val PingLightBlue = Color(0xFF0096FF) +val PingDarkBlue = Color(0xFF032B75) +val PingRed = Color(0xFFCC0937) diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/theme/Theme.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/theme/Theme.kt new file mode 100644 index 00000000..05f01b9c --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/theme/Theme.kt @@ -0,0 +1,75 @@ +/* + * 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.ui.theme + +import android.app.Activity +import com.pingidentity.authenticatorapp.data.ThemeMode +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +private val DarkColorScheme = darkColorScheme( + primary = PingBlue, + secondary = PingGreen, + tertiary = PingOrange +) + +private val LightColorScheme = lightColorScheme( + primary = PingBlue, + secondary = PingGreen, + tertiary = PingOrange +) + +/** + * Custom theme for the Ping Identity Authenticator app. + */ +@Composable +fun PingIdentityAuthenticatorTheme( + themeMode: ThemeMode = ThemeMode.SYSTEM, + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val darkTheme = when (themeMode) { + ThemeMode.LIGHT -> false + ThemeMode.DARK -> true + ThemeMode.SYSTEM -> isSystemInDarkTheme() + } + + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.primary.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/theme/Type.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/theme/Type.kt new file mode 100644 index 00000000..f065d41e --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/ui/theme/Type.kt @@ -0,0 +1,48 @@ +/* + * 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.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +/** + * Custom typography for the Ping Identity Authenticator app. + */ +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ), + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ), + headlineMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 28.sp, + lineHeight = 36.sp, + letterSpacing = 0.sp + ) +) diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/util/DateUtils.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/util/DateUtils.kt new file mode 100644 index 00000000..0743daa1 --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/util/DateUtils.kt @@ -0,0 +1,26 @@ +/* + * 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.util + +import java.time.Instant +import java.util.Date + +/** + * Helper function to format time ago string from a timestamp. + */ +fun getTimeAgoString(timestamp: Date): String { + val now = Date.from(Instant.now()).time + val diffInMillis = now - timestamp.time + + return when { + diffInMillis < 60_000 -> "just now" + diffInMillis < 3_600_000 -> "${diffInMillis / 60_000} minutes ago" + diffInMillis < 86_400_000 -> "${diffInMillis / 3_600_000} hours ago" + else -> "${diffInMillis / 86_400_000} days ago" + } +} diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/util/NavigationAnimations.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/util/NavigationAnimations.kt new file mode 100644 index 00000000..efc441eb --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/util/NavigationAnimations.kt @@ -0,0 +1,71 @@ +/* + * 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.util + +import androidx.compose.animation.* +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.navigation.NavBackStackEntry + +/** + * Custom animation specifications for app navigation transitions. + */ +object NavigationAnimations { + + /** + * Standard slide-in animation for entering a screen from the right. + */ + val enterTransition: AnimatedContentTransitionScope.() -> EnterTransition = { + slideIntoContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Left, + animationSpec = tween( + durationMillis = 300, + easing = FastOutSlowInEasing + ) + ) + } + + /** + * Standard slide-out animation for exiting a screen to the left. + */ + val exitTransition: AnimatedContentTransitionScope.() -> ExitTransition = { + slideOutOfContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Left, + animationSpec = tween( + durationMillis = 300, + easing = FastOutSlowInEasing + ) + ) + } + + /** + * Animation for returning to a screen from the left. + */ + val popEnterTransition: AnimatedContentTransitionScope.() -> EnterTransition = { + slideIntoContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Right, + animationSpec = tween( + durationMillis = 300, + easing = FastOutSlowInEasing + ) + ) + } + + /** + * Animation for navigating away from a screen to the right. + */ + val popExitTransition: AnimatedContentTransitionScope.() -> ExitTransition = { + slideOutOfContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Right, + animationSpec = tween( + durationMillis = 300, + easing = FastOutSlowInEasing + ) + ) + } +} diff --git a/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/util/QrCodeAnalyzer.kt b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/util/QrCodeAnalyzer.kt new file mode 100644 index 00000000..6b30435a --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/kotlin/com/pingidentity/authenticatorapp/util/QrCodeAnalyzer.kt @@ -0,0 +1,75 @@ +/* + * 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.util + +import androidx.annotation.OptIn +import android.annotation.SuppressLint +import androidx.camera.core.ExperimentalGetImage +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.common.InputImage +import com.pingidentity.mfa.commons.UriScheme +import java.util.concurrent.TimeUnit + +/** + * Analyzes camera images to detect and decode QR codes. + * + * @param onQrCodeDetected Callback that will be invoked when a QR code is successfully scanned + */ +class QrCodeAnalyzer(private val onQrCodeDetected: (String) -> Unit) : ImageAnalysis.Analyzer { + + private val scanner = BarcodeScanning.getClient() + + // Track when we last detected a QR code to avoid duplicate scans + private var lastAnalyzedTimestamp = 0L + + @SuppressLint("UnsafeOptInUsageError") + @OptIn(ExperimentalGetImage::class) + override fun analyze(imageProxy: ImageProxy) { + val currentTimestamp = System.currentTimeMillis() + + // Only analyze if enough time has passed since the last detection + // to avoid multiple rapid scans of the same code + if (currentTimestamp - lastAnalyzedTimestamp >= TimeUnit.SECONDS.toMillis(1)) { + imageProxy.image?.let { image -> + val inputImage = InputImage.fromMediaImage(image, imageProxy.imageInfo.rotationDegrees) + + scanner.process(inputImage) + .addOnSuccessListener { barcodes -> + // Process QR codes and find the first valid barcode + val foundQrCode = barcodes.find { barcode -> + barcode.format == Barcode.FORMAT_QR_CODE && + barcode.rawValue != null && ( + barcode.rawValue?.startsWith(UriScheme.OTPAUTH.value) == true || + barcode.rawValue?.startsWith(UriScheme.PUSHAUTH.value) == true || + barcode.rawValue?.startsWith(UriScheme.MFAUTH.value) == true + ) + } + + // If we found a matching QR code, process it + foundQrCode?.rawValue?.let { qrContent -> + lastAnalyzedTimestamp = currentTimestamp + onQrCodeDetected(qrContent) + } + } + .addOnFailureListener { exception -> + // Handle any errors during scanning + exception.printStackTrace() + } + .addOnCompleteListener { + // Close the image when done with analysis regardless of success or failure + imageProxy.close() + } + } ?: imageProxy.close() + } else { + imageProxy.close() + } + } +} diff --git a/android/kotlin-authenticatorapp/app/src/main/res/drawable/ic_check.xml b/android/kotlin-authenticatorapp/app/src/main/res/drawable/ic_check.xml new file mode 100644 index 00000000..117e40b7 --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/res/drawable/ic_check.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/kotlin-authenticatorapp/app/src/main/res/drawable/ic_close.xml b/android/kotlin-authenticatorapp/app/src/main/res/drawable/ic_close.xml new file mode 100644 index 00000000..351a06ab --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/res/drawable/ic_close.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/kotlin-authenticatorapp/app/src/main/res/drawable/ic_fingerprint.xml b/android/kotlin-authenticatorapp/app/src/main/res/drawable/ic_fingerprint.xml new file mode 100644 index 00000000..a628a03b --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/res/drawable/ic_fingerprint.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/kotlin-authenticatorapp/app/src/main/res/drawable/ic_launcher_background.xml b/android/kotlin-authenticatorapp/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..07d5da9c --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/kotlin-authenticatorapp/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/kotlin-authenticatorapp/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..1353083f --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/android/kotlin-authenticatorapp/app/src/main/res/drawable/ic_notification.xml b/android/kotlin-authenticatorapp/app/src/main/res/drawable/ic_notification.xml new file mode 100644 index 00000000..d91b7fa2 --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/kotlin-authenticatorapp/app/src/main/res/drawable/ping_logo.xml b/android/kotlin-authenticatorapp/app/src/main/res/drawable/ping_logo.xml new file mode 100644 index 00000000..a21fde54 --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/res/drawable/ping_logo.xml @@ -0,0 +1,28 @@ + + + + + + + + diff --git a/android/kotlin-authenticatorapp/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/kotlin-authenticatorapp/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..5ed0a2df --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/kotlin-authenticatorapp/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/kotlin-authenticatorapp/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..5ed0a2df --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/kotlin-authenticatorapp/app/src/main/res/values-night/themes.xml b/android/kotlin-authenticatorapp/app/src/main/res/values-night/themes.xml new file mode 100644 index 00000000..1ef4ed68 --- /dev/null +++ b/android/kotlin-authenticatorapp/app/src/main/res/values-night/themes.xml @@ -0,0 +1,5 @@ + + +