From ceb969b33563a6e43f17b968644267c9c642605d Mon Sep 17 00:00:00 2001 From: Evgeniy Mishustin <33718049+EvgeniyMish@users.noreply.github.com> Date: Mon, 2 Feb 2026 09:53:00 +0200 Subject: [PATCH 01/15] Introduce the first phase of a proof of concept for integrating the PingOneMFA SDK into the orchestration SDK. * initial commit POC PingOneMFA Android SDK is wrapped in the DV SDK, created new sample app * Introduce the first phase of a proof of concept for integrating the PingOneMFA SDK into the orchestration SDK. This change adds: - A pingonemfa module that wraps the PingOneMFA SDK APIs required for core flows: - Account pairing - Retrieval of paired accounts - Push authentication - OTP authentication - A PingOneMFApp: sample application demonstrating the supported flows In this initial phase, all flows operate against the PingOne MFA SSO policy. * Introduce the first phase of a proof of concept for integrating the PingOneMFA SDK into the orchestration SDK. This change adds: - A pingonemfa module that wraps the PingOneMFA SDK APIs required for core flows: - Account pairing - Retrieval of paired accounts - Push authentication - OTP authentication - A PingOneMFApp: sample application demonstrating the supported flows In this initial phase, all flows operate against the PingOne MFA SSO policy. * Introduce the first phase of a proof of concept for integrating the PingOneMFA SDK into the orchestration SDK. This change adds: - A pingonemfa module that wraps the PingOneMFA SDK APIs required for core flows: - Account pairing - Retrieval of paired accounts - Push authentication - OTP authentication - A PingOneMFApp: sample application demonstrating the supported flows In this initial phase, all flows operate against the PingOne MFA SSO policy. * added comments and backward compatibility support * added comments and backward compatibility support --------- Signed-off-by: Evgeniy Mishustin <33718049+EvgeniyMish@users.noreply.github.com> --- gradle/libs.versions.toml | 6 +- pingonemfa/.gitignore | 1 + pingonemfa/build.gradle.kts | 28 ++ pingonemfa/src/main/AndroidManifest.xml | 19 + .../pingonemfa/commons/PingOneMFA.kt | 215 +++++++++ .../pingonemfa/commons/PingOneMFAException.kt | 6 + .../pingonemfa/commons/PingOneMfaAccount.kt | 13 + .../pingonemfa/otp/OtpCodeInfo.kt | 9 + .../pingonemfa/push/PushApprovalService.kt | 121 +++++ .../pingonemfa/push/PushNotification.kt | 86 ++++ .../pingidentity/pingonemfa/push/PushType.kt | 8 + .../pingonemfa/util/AccountParser.kt | 29 ++ .../com/pingidentity/protect/Protect.kt | 3 +- samples/pingonemfapp/.gitignore | 2 + samples/pingonemfapp/README.md | 192 ++++++++ samples/pingonemfapp/build.gradle.kts | 119 +++++ .../pingonemfapp/src/main/AndroidManifest.xml | 90 ++++ .../pingidentity/pingonemfapp/MainActivity.kt | 164 +++++++ .../pingidentity/pingonemfapp/PingOneMFApp.kt | 71 +++ .../pingonemfapp/data/DiagnosticLogger.kt | 121 +++++ .../pingonemfapp/data/MainViewModel.kt | 208 +++++++++ .../pingonemfapp/data/UiModels.kt | 49 +++ .../pingonemfapp/data/UserPreferences.kt | 162 +++++++ .../pingonemfapp/managers/AccountsManager.kt | 60 +++ .../pingonemfapp/managers/OTPManager.kt | 70 +++ .../notification/BiometricPromptActivity.kt | 236 ++++++++++ .../NotificationActionReceiver.kt | 74 ++++ .../notification/NotificationHelper.kt | 220 ++++++++++ .../notification/PushNotificationActivity.kt | 181 ++++++++ .../service/PushNotificationService.kt | 153 +++++++ .../pingonemfapp/ui/AboutScreen.kt | 163 +++++++ .../pingonemfapp/ui/AccountsScreen.kt | 308 +++++++++++++ .../pingonemfapp/ui/AuthenticatorNavHost.kt | 114 +++++ .../pingonemfapp/ui/DiagnosticLogsScreen.kt | 266 +++++++++++ .../pingonemfapp/ui/LoginScreen.kt | 217 +++++++++ .../ui/NotificationResponseScreen.kt | 413 ++++++++++++++++++ .../pingidentity/pingonemfapp/ui/OtpScreen.kt | 65 +++ .../pingonemfapp/ui/QrScannerScreen.kt | 259 +++++++++++ .../pingonemfapp/ui/SettingsScreen.kt | 183 ++++++++ .../ui/components/AccountAvatar.kt | 89 ++++ .../pingonemfapp/ui/components/AccountCard.kt | 84 ++++ .../ui/components/BackNavigationTopAppBar.kt | 44 ++ .../ui/components/EmptyStateMessage.kt | 59 +++ .../ui/components/ErrorAlertDialog.kt | 38 ++ .../ui/components/ExpiringOtpCode.kt | 76 ++++ .../ui/components/LoadingIndicator.kt | 50 +++ .../pingonemfapp/ui/components/SettingItem.kt | 111 +++++ .../pingonemfapp/ui/theme/Color.kt | 18 + .../pingonemfapp/ui/theme/Theme.kt | 75 ++++ .../pingonemfapp/ui/theme/Type.kt | 48 ++ .../pingonemfapp/util/NavigationAnimations.kt | 71 +++ .../pingonemfapp/util/QrCodeAnalyzer.kt | 69 +++ .../src/main/res/drawable/ic_check.xml | 10 + .../src/main/res/drawable/ic_close.xml | 10 + .../src/main/res/drawable/ic_fingerprint.xml | 10 + .../res/drawable/ic_launcher_foreground.xml | 21 + .../src/main/res/drawable/ic_notification.xml | 10 + .../src/main/res/drawable/ping_logo.xml | 28 ++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + .../res/values/ic_launcher_background.xml | 4 + .../src/main/res/values/strings.xml | 171 ++++++++ .../src/main/res/values/themes.xml | 5 + settings.gradle.kts | 2 + 64 files changed, 5814 insertions(+), 3 deletions(-) create mode 100644 pingonemfa/.gitignore create mode 100644 pingonemfa/build.gradle.kts create mode 100644 pingonemfa/src/main/AndroidManifest.xml create mode 100644 pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/PingOneMFA.kt create mode 100644 pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/PingOneMFAException.kt create mode 100644 pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/PingOneMfaAccount.kt create mode 100644 pingonemfa/src/main/java/com/pingidentity/pingonemfa/otp/OtpCodeInfo.kt create mode 100644 pingonemfa/src/main/java/com/pingidentity/pingonemfa/push/PushApprovalService.kt create mode 100644 pingonemfa/src/main/java/com/pingidentity/pingonemfa/push/PushNotification.kt create mode 100644 pingonemfa/src/main/java/com/pingidentity/pingonemfa/push/PushType.kt create mode 100644 pingonemfa/src/main/java/com/pingidentity/pingonemfa/util/AccountParser.kt create mode 100644 samples/pingonemfapp/.gitignore create mode 100644 samples/pingonemfapp/README.md create mode 100644 samples/pingonemfapp/build.gradle.kts create mode 100644 samples/pingonemfapp/src/main/AndroidManifest.xml create mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/MainActivity.kt create mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/PingOneMFApp.kt create mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/data/DiagnosticLogger.kt create mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/data/MainViewModel.kt create mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/data/UiModels.kt create mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/data/UserPreferences.kt create mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/managers/AccountsManager.kt create mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/managers/OTPManager.kt create mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/BiometricPromptActivity.kt create mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/NotificationActionReceiver.kt create mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/NotificationHelper.kt create mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/PushNotificationActivity.kt create mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/service/PushNotificationService.kt create mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/AboutScreen.kt create mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/AccountsScreen.kt create mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/AuthenticatorNavHost.kt create mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/DiagnosticLogsScreen.kt create mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/LoginScreen.kt create mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/NotificationResponseScreen.kt create mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/OtpScreen.kt create mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/QrScannerScreen.kt create mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/SettingsScreen.kt create mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/AccountAvatar.kt create mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/AccountCard.kt create mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/BackNavigationTopAppBar.kt create mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/EmptyStateMessage.kt create mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/ErrorAlertDialog.kt create mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/ExpiringOtpCode.kt create mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/LoadingIndicator.kt create mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/SettingItem.kt create mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/theme/Color.kt create mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/theme/Theme.kt create mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/theme/Type.kt create mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/util/NavigationAnimations.kt create mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/util/QrCodeAnalyzer.kt create mode 100644 samples/pingonemfapp/src/main/res/drawable/ic_check.xml create mode 100644 samples/pingonemfapp/src/main/res/drawable/ic_close.xml create mode 100644 samples/pingonemfapp/src/main/res/drawable/ic_fingerprint.xml create mode 100644 samples/pingonemfapp/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 samples/pingonemfapp/src/main/res/drawable/ic_notification.xml create mode 100644 samples/pingonemfapp/src/main/res/drawable/ping_logo.xml create mode 100644 samples/pingonemfapp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 samples/pingonemfapp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 samples/pingonemfapp/src/main/res/values/ic_launcher_background.xml create mode 100644 samples/pingonemfapp/src/main/res/values/strings.xml create mode 100644 samples/pingonemfapp/src/main/res/values/themes.xml diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b47d44c14..cf4812736 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -54,6 +54,7 @@ coilCompose = "2.7.0" startupRuntime = "1.2.0" com-pingidentity-signals = "5.2.0" +com-pingidentity-pingonemfa = "2.2.0" kotlin-playservices-coroutine = "1.10.2" barcodeScanning = "17.3.0" @@ -70,7 +71,7 @@ security-crypto = "1.1.0" play-services-location = "21.3.0" google-android-recaptcha = "18.6.1" # This version is compatible with API Level 29 and does not require desugaring. - +google-gson-lib = "2.10.1" [libraries] androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" } @@ -137,6 +138,7 @@ googleid = { group = "com.google.android.libraries.identity.googleid", name = "g facebook-login = { module = "com.facebook.android:facebook-login", version.ref = "facebook-login" } androidx-startup-runtime = { group = "androidx.startup", name = "startup-runtime", version.ref = "startupRuntime" } com-pingidentity-signals = { group = "com.pingidentity.signals", name = "android-sdk", version.ref = "com-pingidentity-signals" } +com-pingidentity-pingonemfa = { group = "com.pingidentity.pingonemfa", name = "android-sdk", version.ref = "com-pingidentity-pingonemfa" } androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "cameraCamera2" } androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "cameraCamera2" } androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = "cameraCamera2" } @@ -158,7 +160,7 @@ nimbus-jose-jwt = { module = "com.nimbusds:nimbus-jose-jwt", version.ref = "nimb play-services-location = { module = "com.google.android.gms:play-services-location", version.ref = "play-services-location" } google-android-recaptcha = { module = "com.google.android.recaptcha:recaptcha", version.ref = "google-android-recaptcha" } - +google-gson-lib = {module = "com.google.code.gson:gson", version.ref = "google-gson-lib"} [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } androidLibrary = { id = "com.android.library", version.ref = "agp" } diff --git a/pingonemfa/.gitignore b/pingonemfa/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/pingonemfa/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/pingonemfa/build.gradle.kts b/pingonemfa/build.gradle.kts new file mode 100644 index 000000000..4531fd936 --- /dev/null +++ b/pingonemfa/build.gradle.kts @@ -0,0 +1,28 @@ +/* + * 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. + */ +description = "Ping Identity PingOneMFA SDK for Android" + +plugins { + id("com.pingidentity.convention.android.library") + id("kotlin-parcelize") + alias(libs.plugins.androidLibrary) + alias(libs.plugins.kotlinAndroid) +} +android { + namespace = "com.pingidentity.pingonemfa" +} +dependencies { + implementation(project(":foundation:android")) + implementation(libs.com.pingidentity.pingonemfa) + + implementation(libs.kotlinx.coroutines.core) + implementation(libs.google.gson.lib) + + // Firebase Cloud Messaging for push notifications + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.messaging) +} diff --git a/pingonemfa/src/main/AndroidManifest.xml b/pingonemfa/src/main/AndroidManifest.xml new file mode 100644 index 000000000..ac273bbfe --- /dev/null +++ b/pingonemfa/src/main/AndroidManifest.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/PingOneMFA.kt b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/PingOneMFA.kt new file mode 100644 index 000000000..8f57cd20d --- /dev/null +++ b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/PingOneMFA.kt @@ -0,0 +1,215 @@ +package com.pingidentity.pingonemfa.commons + +import android.content.Intent +import androidx.core.content.ContextCompat +import com.google.firebase.messaging.RemoteMessage +import com.pingidentity.android.ContextProvider +import com.pingidentity.pingidsdkv2.PingOne +import com.pingidentity.pingidsdkv2.PingOneGeo +import com.pingidentity.pingidsdkv2.types.NotificationProvider +import com.pingidentity.pingonemfa.otp.OtpCodeInfo +import com.pingidentity.pingonemfa.push.PushApprovalService +import com.pingidentity.pingonemfa.push.PushNotification +import com.pingidentity.pingonemfa.util.AccountParser +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.json.JSONObject +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +object PingOneMFA { + + private var isInitialized: Boolean = false + private var lock = Mutex() + + //SDK must be initialized once and cannot handle parallel configure calls + suspend fun initialize(): Unit = lock.withLock { + if (isInitialized) { + return + } + return suspendCancellableCoroutine { init -> + PingOne.configure( + ContextProvider.context, + // for demonstration purposes we simply hardcode the North America geo + PingOneGeo.NORTH_AMERICA + ) { error -> + if (error == null) { + isInitialized = true + init.resume(Unit) + }else{ + init.resumeWithException(PingOneMFAException(error.message)) + } + } + } + } + + /* + * Registers push token with PingOne. Should be called each time the token is refreshed. + */ + suspend fun register(pushToken: String) : Boolean = withContext(Dispatchers.IO) { + suspendCancellableCoroutine { cont -> + try { + PingOne.setDeviceToken( + ContextProvider.context, + pushToken, + NotificationProvider.FCM + ) { errors -> + val success = errors == null || errors.isEmpty() || errors.all { it == null } + if (cont.isActive) { + cont.resume(success) + } + } + } catch (_: Exception) { + if (cont.isActive) { + cont.resume(false) + } + } + } + } + + /* + * Starts pairing process with PingOne. + */ + suspend fun pair(pairingKey: String): Result = suspendCancellableCoroutine { cont -> + try { + PingOne.pair( + ContextProvider.context, + pairingKey + ) { pairingInfo, error -> + if (!cont.isActive) { + return@pair + } + if (error == null) { + cont.resume(Result.success(Unit)) + } else { + cont.resume(Result.failure(Exception(error.message))) + } + } + } catch (e: Exception) { + if (cont.isActive) { + cont.resume(Result.failure(e)) + } + } + } + + /* + * Retrieves all paired accounts from PingOne + */ + suspend fun getAccounts(): Result> = + suspendCancellableCoroutine { cont -> + try { + PingOne.getInfo( + ContextProvider.context + ) { deviceInfo, errors -> + run { + if (!cont.isActive) return@getInfo + if (deviceInfo!= null){ + val accounts = AccountParser().parseAccounts(deviceInfo) + cont.resume(Result.success(accounts)) + }else{ + cont.resume(Result.failure(PingOneMFAException(errors[0]?.message))) + } + } + } + }catch (e: Exception){ + if (cont.isActive) { + cont.resume(Result.failure(e)) + } + } + } + + /* + * Retrieves OTP code from PingOne. + */ + suspend fun collectOtp(): Result = suspendCancellableCoroutine { cont -> + PingOne.getOneTimePassCode(ContextProvider.context) { otpInfo, error -> + if (!cont.isActive) return@getOneTimePassCode + val result = if (otpInfo != null) { + Result.success(OtpCodeInfo( + otpInfo.passcode, + ((otpInfo.validUntil * 1000 - System.currentTimeMillis()) / 1000).toInt() + )) + } else { + Result.failure(PingOneMFAException(error?.message)) + } + cont.resume(result) + } + } + + /* + * Transforms received FCM Remote Message object from PingOne into PushNotification object + */ + suspend fun collectPush(message: RemoteMessage): Result = + suspendCancellableCoroutine { cont -> + try { + PingOne.processRemoteNotification( + ContextProvider.context, + message + ) { notificationObject, error -> + if (!cont.isActive) return@processRemoteNotification + if (notificationObject != null) { + cont.resume( + Result.success( + PushNotification( + notificationObject = notificationObject, + title = getTitleFromRemoteMessageData(message.data["aps"]), + message = getBodyFromRemoteMessageData(message.data["aps"]) + ) + ) + ) + return@processRemoteNotification + } + cont.resume(Result.failure(PingOneMFAException(error?.message))) + } + }catch (e: Exception){ + if (cont.isActive) { + cont.resume(Result.failure(PingOneMFAException(e.message))) + } + } + } + + /* + * Approves MFA push notification. Should be called from notification action if application is in the background. + */ + fun approvePushNotificationFromBanner(notification: PushNotification?){ + val appContext = ContextProvider.context + val intent = Intent(appContext, PushApprovalService::class.java).apply { + putExtra("notification", notification) + putExtra("auth_method", "banner") + putExtra("user_action", "approve") + } + ContextCompat.startForegroundService(appContext, intent) + } + + /* + * Denies MFA push notification. Should be called from notification action if application is in the background. + */ + fun denyPushNotificationFromBanner(notification: PushNotification?){ + val appContext = ContextProvider.context + val intent = Intent(appContext, PushApprovalService::class.java).apply { + putExtra("notification", notification) + putExtra("auth_method", "banner") + putExtra("user_action", "deny") + } + ContextCompat.startForegroundService(appContext, intent) + } + + private fun getTitleFromRemoteMessageData(data: String?): String?{ + return if (data == null) { + null + }else{ + JSONObject(data).getJSONObject("alert").getString("title") + } + } + + private fun getBodyFromRemoteMessageData(data: String?): String?{ + return if (data == null) { + null + }else{ + JSONObject(data).getJSONObject("alert").getString("body") + } + } +} \ No newline at end of file diff --git a/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/PingOneMFAException.kt b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/PingOneMFAException.kt new file mode 100644 index 000000000..0576aa0e5 --- /dev/null +++ b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/PingOneMFAException.kt @@ -0,0 +1,6 @@ +package com.pingidentity.pingonemfa.commons +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * Wrapper for error message into exception class + */ +class PingOneMFAException(message: String?) : Exception(message) \ No newline at end of file diff --git a/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/PingOneMfaAccount.kt b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/PingOneMfaAccount.kt new file mode 100644 index 000000000..badf9f067 --- /dev/null +++ b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/PingOneMfaAccount.kt @@ -0,0 +1,13 @@ +package com.pingidentity.pingonemfa.commons + +/* + * Represents a PingOne MFA account. + */ +data class PingOneMfaAccount( + val region: String, + val id: String, + val deviceId: String, + val environment: String, + val name: String, + val family: String +) \ No newline at end of file diff --git a/pingonemfa/src/main/java/com/pingidentity/pingonemfa/otp/OtpCodeInfo.kt b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/otp/OtpCodeInfo.kt new file mode 100644 index 000000000..be4861fc1 --- /dev/null +++ b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/otp/OtpCodeInfo.kt @@ -0,0 +1,9 @@ +package com.pingidentity.pingonemfa.otp + +/* + * Very simple model for OTP code information. + */ +data class OtpCodeInfo( + val code: String, + val secondsRemaining: Int +) \ No newline at end of file diff --git a/pingonemfa/src/main/java/com/pingidentity/pingonemfa/push/PushApprovalService.kt b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/push/PushApprovalService.kt new file mode 100644 index 000000000..7ad650037 --- /dev/null +++ b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/push/PushApprovalService.kt @@ -0,0 +1,121 @@ +package com.pingidentity.pingonemfa.push + +//noinspection SuspiciousImport +import android.R +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Intent +import android.os.Build +import android.util.Log +import androidx.core.app.NotificationCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/* + * Service for handling push notifications actions. This service exists to solve ONE specific Android restriction: + * Android does NOT allow network calls in the background (from notification actions in particular). + */ +internal class PushApprovalService : Service(){ + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + override fun onBind(p0: Intent?) = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + startForeground(NOTIFICATION_ID, createForegroundNotification()) + + val notificationObject = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent?.getParcelableExtra("notification", PushNotification::class.java) + } else { + intent?.getParcelableExtra("notification") + } + val authMethod = intent?.getStringExtra("auth_method") ?: "" + val userAction = intent?.getStringExtra("user_action") ?: "" + if (notificationObject == null) { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf(startId) + return START_NOT_STICKY + } + + scope.launch { + try { + if (userAction.equals("approve", ignoreCase = true)) { + approveNotificationWithAppInBackground(notificationObject, authMethod) + }else{ + denyNotificationWithAppInBackground(notificationObject) + } + } catch (e: Exception) { + Log.e("MfaApprovalService", "approval failed: ${e.message}", e) + } finally { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf(startId) + } + } + + return START_NOT_STICKY + } + + private fun createForegroundNotification(): Notification { + val channelId = "mfa_approval_channel" + val manager = getSystemService(NotificationManager::class.java) + val channel = NotificationChannel( + channelId, + "MFA Approval", + NotificationManager.IMPORTANCE_HIGH + ) + manager.createNotificationChannel(channel) + + return NotificationCompat.Builder(this, channelId) + .setContentTitle("Approving login…") + .setContentText("Contacting server") + .setSmallIcon(R.drawable.ic_lock_idle_lock) + .setOngoing(true) + .build() + } + + private suspend fun approveNotificationWithAppInBackground( + notification: PushNotification, + auth: String + ) = suspendCancellableCoroutine { cont -> + try { + notification.notificationObject.approve( + this, + auth, + null + ) { error -> + if (!cont.isActive) return@approve + if (error == null) cont.resume(Unit) + else cont.resumeWithException(Exception(error.message ?: "Approval failed")) + } + } catch (e: Exception) { + if (cont.isActive) cont.resumeWithException(e) + } + } + private suspend fun denyNotificationWithAppInBackground( + notification: PushNotification + ) = suspendCancellableCoroutine { cont -> + try { + notification.notificationObject.deny( + this){ error -> + if (!cont.isActive) return@deny + if (error == null) cont.resume(Unit) + else cont.resumeWithException(Exception(error.message ?: "Deny action failed")) + } + } catch (e: Exception) { + if (cont.isActive) cont.resumeWithException(e) + } + } + + companion object { + private const val NOTIFICATION_ID = 7001 + } + +} \ No newline at end of file diff --git a/pingonemfa/src/main/java/com/pingidentity/pingonemfa/push/PushNotification.kt b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/push/PushNotification.kt new file mode 100644 index 000000000..d1abb9e3b --- /dev/null +++ b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/push/PushNotification.kt @@ -0,0 +1,86 @@ +package com.pingidentity.pingonemfa.push + +import android.content.Context +import android.os.Parcelable +import com.pingidentity.pingidsdkv2.NotificationObject +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.parcelize.Parcelize +import java.util.UUID +import kotlin.coroutines.resume + +/* + * Simple model for a push notification. Implements Parcelable so it can be passed between components. + */ +@Parcelize +data class PushNotification( + val id: String = UUID.randomUUID().toString(), + val notificationObject: NotificationObject, + val title: String?, + val message: String?, + val sentAt: Long = System.currentTimeMillis(), + val respondedAt: Long? = null +): Parcelable { + + suspend fun approveNotification( + context: Context, + authenticationMethod: String, + numberChallenge: Int? = null) : Result = suspendCancellableCoroutine { cont -> + try { + notificationObject.approve( + context, + authenticationMethod, + numberChallenge + ) { error -> + if (!cont.isActive) return@approve + if (error == null) { + cont.resume(Result.success(Unit)) + } else { + cont.resume(Result.failure(Exception(error.userInfo.toString()))) + } + } + } catch (e: Exception) { + if (cont.isActive) { + cont.resume(Result.failure(e)) + } + } + } + + suspend fun denyNotification(context: Context) : Result = suspendCancellableCoroutine { cont -> + try { + notificationObject.deny( + context + ) { error -> + if (!cont.isActive) return@deny + if (error == null) { + cont.resume(Result.success(Unit)) + } else { + cont.resume(Result.failure(Exception(error.userInfo.toString()))) + } + } + } catch (e: Exception) { + if (cont.isActive) { + cont.resume(Result.failure(e)) + } + } + } + + fun requiresBiometric() : Boolean{ + return !isChallenge() + } + + fun isChallenge() : Boolean{ + return notificationObject.numberMatchingType!=null + } + + fun getNumbersChallenge(): IntArray? { + return notificationObject.numberMatchingOptions + } + + fun getPushType () : PushType { + return when { + notificationObject.isTest -> PushType.DRY // TODO handle in Sample App + notificationObject.numberMatchingType != null -> PushType.CHALLENGE + else -> PushType.DEFAULT // TODO handle how to receive BIOMETRIC enforcement + } + } +} diff --git a/pingonemfa/src/main/java/com/pingidentity/pingonemfa/push/PushType.kt b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/push/PushType.kt new file mode 100644 index 000000000..fb62338bc --- /dev/null +++ b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/push/PushType.kt @@ -0,0 +1,8 @@ +package com.pingidentity.pingonemfa.push + +enum class PushType { + DEFAULT, + DRY, + CHALLENGE, + BIOMETRIC +} \ No newline at end of file diff --git a/pingonemfa/src/main/java/com/pingidentity/pingonemfa/util/AccountParser.kt b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/util/AccountParser.kt new file mode 100644 index 000000000..ab2b6060a --- /dev/null +++ b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/util/AccountParser.kt @@ -0,0 +1,29 @@ +package com.pingidentity.pingonemfa.util + +import com.google.gson.JsonObject +import com.pingidentity.pingonemfa.commons.PingOneMfaAccount + +internal class AccountParser { + fun parseAccounts(json: JsonObject): List { + val result = mutableListOf() + + json.entrySet().forEach { (region, regionElement) -> + val users = regionElement.asJsonObject + .getAsJsonArray("users") + ?.mapNotNull { it.asJsonObject } + ?: emptyList() + + users.forEach { user -> + result += PingOneMfaAccount( + region = region, + id = user.get("id")?.asString ?: "", + environment = user.getAsJsonObject("environment")?.get("id")?.asString ?: "", + deviceId = user.getAsJsonObject("device")?.get("id")?.asString ?: "", + name = user.getAsJsonObject("name")?.get("given")?.asString ?: "", + family = user.getAsJsonObject("name")?.get("family")?.asString ?: "" + ) + } + } + return result + } +} \ No newline at end of file diff --git a/protect/src/main/kotlin/com/pingidentity/protect/Protect.kt b/protect/src/main/kotlin/com/pingidentity/protect/Protect.kt index cff9d8219..3598d3f58 100644 --- a/protect/src/main/kotlin/com/pingidentity/protect/Protect.kt +++ b/protect/src/main/kotlin/com/pingidentity/protect/Protect.kt @@ -8,7 +8,8 @@ package com.pingidentity.protect import com.pingidentity.android.ContextProvider -import com.pingidentity.orchestrate.Module +import com.pingidentity.protect.Protect.config +import com.pingidentity.protect.Protect.initialize import com.pingidentity.signalssdk.sdk.GetDataCallback import com.pingidentity.signalssdk.sdk.InitCallback import com.pingidentity.signalssdk.sdk.POInitParams diff --git a/samples/pingonemfapp/.gitignore b/samples/pingonemfapp/.gitignore new file mode 100644 index 000000000..65d12b954 --- /dev/null +++ b/samples/pingonemfapp/.gitignore @@ -0,0 +1,2 @@ +/build +google-services.json \ No newline at end of file diff --git a/samples/pingonemfapp/README.md b/samples/pingonemfapp/README.md new file mode 100644 index 000000000..605d5a70d --- /dev/null +++ b/samples/pingonemfapp/README.md @@ -0,0 +1,192 @@ +[![Ping Identity](https://www.pingidentity.com/content/dam/picr/nav/Ping-Logo-2.svg)](https://github.com/pingidentity/pingone-mobile-sdk-android) + +# PingOne MFA 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. + +## Features + +### OATH Authentication +- **QR Code Scanning**: Register accounts by scanning QR codes +- **TOTP Support**: Automatic generation of time-based one-time passwords with countdown timer + +### Push Authentication +- **QR Code Registration**: Register for push authentication by scanning QR codes +- **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) + +## 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`, `otp`). +- **SDK Layer**: Abstracts the complexity to deal with MFA capabilities and communication 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 +- **Biometric**: For fingerprint/face recognition + +## Implementation Details + +### Code Structure Overview + +``` +src/main/kotlin/com/pingidentity/authenticatorapp/ +├── PingOneMFApp.kt # App initialization +├── managers/ +│ ├── AccountsManager.kt # Pairing account and accounts retrieval from the SDK +│ └── OtpManager.kt # OTP generation and auto-refresh +├── ui/ +│ ├── AccountsScreen.kt # Account management UI +│ ├── OtpScreen.kt # OTP presentation screen +│ └── ... # Other Compose screens +├── data/ +│ ├── MainViewModel.kt # Acts as the central coordinator between the PingOne MFA SDK managers and the UI +│ ├── DiagnosticLogger.kt # Logging +│ └── UserPreferences.kt # Preferences +└── ... +``` + +**Key Classes & Structure:** + +- `PingOneMFApp.kt`: Configures logging, initializes the PingOne MFA SDK, and registers the Firebase push token. +- `managers/`: Integrates Ping SDK modules. +- `managers/AccountsManager.kt`: wraps the PingOne MFA SDK to pair users and load MFA accounts. +- `managers/OtpManager.kt`: continuously fetches OTP codes from the PingOne MFA SDK, maintains their countdown lifecycle, and exposes the current OTP state to the UI via a reactive flow. +- `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:** `PushNotificationService.kt` + +**Flow Diagram (textual):** +``` +Push Request → PushNotificationService → SDK module → 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 + notification.approveNotification(notification, authMethod) + ``` + ***important:*** +If you're approving the notification from the notification banner button (notification action) you must call: + ```kotlin + // Approve a background notification + PingOneMFA.approvePushNotificationFromBanner(notification) + ``` + +2. **BIOMETRIC**: Authentication using biometric verification + ```kotlin + // Approve with biometric authentication + notification.approveBiometricNotification(notification, authMethod) + ``` + +3. **CHALLENGE**: Verification using challenge numbers + ```kotlin + // Get challenge numbers + val numbers = pushNotification.getNumbersChallenge() + + // Approve with challenge response + notification.approveNotification(notification, authMethod, challengeResponse) + ``` + + +### OTP Module +- **Token Retrieval**: Retrieves OTP from the SDK and displays it to the user. +- **Token Refreshment**: Refreshes the OTP code when it expires. + +**Class:** `OtpManager.kt` + +**Flow Diagram (textual):** +``` +Enroll User → OtpManager → Ping One SDK → Token Retrieval → Display in UI → User enters code +``` + +### QR Code Scanning + +The app uses CameraX and ML Kit to scan and decode QR codes. + + +## Getting Started + +### Prerequisites + +- Android Studio Koala | 2024.1.1 or newer +- Android SDK 29 or higher +- Gradle 8.7 or newer +- google-services.json file (to work with FCM push notifications) + +### 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 Functionality + +To test the app's functionality, you need: + +- A PingOne account with MFA enabled +- FCM configured for your Android application +- The app properly registered with FCM to receive push notifications + + +## 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. \ No newline at end of file diff --git a/samples/pingonemfapp/build.gradle.kts b/samples/pingonemfapp/build.gradle.kts new file mode 100644 index 000000000..8c0d3e086 --- /dev/null +++ b/samples/pingonemfapp/build.gradle.kts @@ -0,0 +1,119 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +/* + * 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. + */ + +plugins { + alias(libs.plugins.androidApplication) + alias(libs.plugins.kotlinAndroid) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.googleServices) + alias(libs.plugins.kotlinSerialization) +} +@Suppress("UnstableApiUsage") +android { + namespace = "com.pingidentity.pingonemfapp" + compileSdk = 36 + + defaultConfig { + applicationId = "com.pingidentity.pingonemfapp" + minSdk = 29 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + lint { + disable += "NullSafeMutableLiveData" + // To avoid lint errors during the build + abortOnError = false + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + } + buildFeatures { + compose = true + } + composeOptions { + // Matching the Compose plugin version + kotlinCompilerExtensionVersion = "1.5.8" + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +configurations.all { + resolutionStrategy { + force("com.google.android.gms:play-services-basement:18.4.0") + force("com.google.android.gms:play-services-tasks:18.2.0") + force("com.google.android.gms:play-services-base:18.5.0") + } +} + +dependencies { + + // Ping SDK dependencies + implementation(project(":pingonemfa")) + implementation(project(":foundation:logger")) + + // Kotlinx Serialization + implementation(libs.kotlinx.serialization.json) + + // Core Android dependencies + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + + // 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) + + // Firebase Cloud Messaging for push notifications + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.messaging) + + // Biometric + implementation(libs.androidx.biometric) +} diff --git a/samples/pingonemfapp/src/main/AndroidManifest.xml b/samples/pingonemfapp/src/main/AndroidManifest.xml new file mode 100644 index 000000000..d0720a0e0 --- /dev/null +++ b/samples/pingonemfapp/src/main/AndroidManifest.xml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/MainActivity.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/MainActivity.kt new file mode 100644 index 000000000..c2f35052a --- /dev/null +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/MainActivity.kt @@ -0,0 +1,164 @@ +/* + * 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.pingonemfapp + +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.pingonemfapp.data.DiagnosticLogger +import com.pingidentity.pingonemfapp.data.PingOneMFAViewModel +import com.pingidentity.pingonemfapp.data.ThemeMode +import com.pingidentity.pingonemfapp.data.UserPreferences +import com.pingidentity.pingonemfapp.managers.AccountsManager +import com.pingidentity.pingonemfapp.managers.OTPManager +import com.pingidentity.pingonemfapp.notification.NotificationHelper +import com.pingidentity.pingonemfapp.ui.AuthenticatorNavHost +import com.pingidentity.pingonemfapp.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: PingOneMFAViewModel + 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, + 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 accountsManager = AccountsManager(diagnosticLogger = diagnosticLogger) + val otpManager = OTPManager(diagnosticLogger = diagnosticLogger) + + // Create ViewModels with clients already set + authenticatorViewModel = PingOneMFAViewModel( + application = application, + userPreferences = userPreferences, + accountsManager = accountsManager, + otpManager = otpManager, + ) + + // Mark ViewModels as initialized and trigger UI update + 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/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/PingOneMFApp.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/PingOneMFApp.kt new file mode 100644 index 000000000..1acb04da8 --- /dev/null +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/PingOneMFApp.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.pingonemfapp + +import android.app.Application +import com.google.firebase.FirebaseApp +import com.google.firebase.messaging.FirebaseMessaging +import com.pingidentity.logger.Logger +import com.pingidentity.logger.STANDARD +import com.pingidentity.pingonemfa.commons.PingOneMFA +import com.pingidentity.pingonemfapp.data.DiagnosticLogger +import com.pingidentity.pingonemfapp.data.UserPreferences +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 PingOne MFA Authenticator app. + * Initializes the PingOne SDK on application startup. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class PingOneMFApp : Application() { + + 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 { + // initialize PingOneMFA SDK + try { + PingOneMFA.initialize() + diagnosticLogger.i("PingOneMFA SDK initialized") + }catch (e: Exception){ + diagnosticLogger.e("PingOneMFA SDK initialization failed", e) + } + + // Obtain the device token from Firebase and set register it with PingOneMFA SDK + try { + FirebaseApp.initializeApp(this@PingOneMFApp) + PingOneMFA.register(FirebaseMessaging.getInstance().token.await()) + diagnosticLogger.i("PingOneMFA SDK: Firebase device token set") + } catch (e: IllegalStateException) { + diagnosticLogger.e("Firebase not configured properly", e) + } + diagnosticLogger.i("AuthenticatorApp: SDK initialization complete") + } + } +} diff --git a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/data/DiagnosticLogger.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/data/DiagnosticLogger.kt new file mode 100644 index 000000000..801e25daa --- /dev/null +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/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.pingonemfapp.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/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/data/MainViewModel.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/data/MainViewModel.kt new file mode 100644 index 000000000..2b3433531 --- /dev/null +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/data/MainViewModel.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.pingonemfapp.data + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.pingidentity.logger.Logger +import com.pingidentity.logger.STANDARD +import com.pingidentity.pingonemfapp.managers.AccountsManager +import com.pingidentity.pingonemfapp.managers.OTPManager +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * ViewModel for the PingOneMFA 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 accountsManager Manager for PingOneMFA SDK paired accounts + * @param otpManager Manager for OTP from PingOneMFA SDK + */ +class PingOneMFAViewModel( + application: Application, + private val userPreferences: UserPreferences, + + private val accountsManager: AccountsManager, + private val otpManager: OTPManager, +) : AndroidViewModel(application), ViewModelProvider.Factory { + + private val _uiState = MutableStateFlow(AuthenticatorUiState()) + private val diagnosticLogger = DiagnosticLogger + + private var pingOneMfaAccountsLoaded = false + + val uiState: StateFlow = _uiState.asStateFlow() + + val otpState: StateFlow = otpManager.otpState + + // Expose all settings preferences as StateFlows + val copyOtp: StateFlow + get() = userPreferences.copyOtpFlow + + val diagnosticLogging: StateFlow + get() = userPreferences.diagnosticLoggingFlow + + val themeMode: StateFlow + get() = userPreferences.themeModeFlow + + + /** + * 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 PingOne MFA paired accounts from PingOneMFA SDK + viewModelScope.launch { + accountsManager.mfaAccountsUi.collect { accounts -> + _uiState.update { it.copy(pingOneMfaAccounts = accounts) } + } + } + } + + /** + * Loads initial data from all managers. + */ + private fun loadInitialData() { + viewModelScope.launch { + // Set initial loading state + _uiState.update { it.copy(isInitialLoading = true) } + try { + loadPingOneMfaAccounts() + } finally { + // Clear initial loading state once everything is loaded + _uiState.update { it.copy(isInitialLoading = false) } + } + } + } + + /** + * Loads all paired accounts from the PingOneMFA SDK. + */ + private suspend fun loadPingOneMfaAccounts() { + accountsManager.loadAccounts().onSuccess { + pingOneMfaAccountsLoaded = true + _uiState.update { it.copy(pingOneMfaAccounts = it.pingOneMfaAccounts, error = null) } + }.onFailure { e -> + _uiState.update { it.copy(error = e.message ?: "Failed to load MFA accounts") } + } + } + + fun startOtpSequence(){ + diagnosticLogger.d("startOtpSequence") + otpManager.startAutoRefresh(viewModelScope) + } + fun stopOtpSequence(){ + diagnosticLogger.d("stopOtpSequence") + otpManager.stop() + } + + /** + * 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 theme mode setting + */ + fun setThemeMode(themeMode: ThemeMode) { + viewModelScope.launch { + diagnosticLogger.d("SettingsScreen: setThemeMode: $themeMode") + userPreferences.setThemeMode(themeMode) + } + } + + + fun tryToPairUserForPingOneMFA(pairingKey: String){ + diagnosticLogger.d("tryToPairUserForPingOneMFA: $pairingKey") + // Attempt to pair user + _uiState.update { it.copy(isLoadingPingOneAccounts = true) } + viewModelScope.launch { + accountsManager.addAccountFromPairingKeyScan(pairingKey).onSuccess { + loadPingOneMfaAccounts() + _uiState.update { it.copy(isLoadingPingOneAccounts = false, error = null) } + }.onFailure { + _uiState.update { it.copy(isLoadingPingOneAccounts = false, error = it.error ?: "Failed to pair user") } + } + } + } + + /** + * 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) } + } + + + // clean-up OTP refresh Job on ViewModel destroy + override fun onCleared() { + otpManager.stop() + super.onCleared() + } + +} + +/** + * Data class representing the UI state of the Authenticator app. + */ +data class AuthenticatorUiState( + val pingOneMfaAccounts: List = emptyList(), + val error: String? = null, + val message: String? = null, + // Loading states for better UX + val isInitialLoading: Boolean = false, + val isLoadingPingOneAccounts: Boolean = false, + val isRefreshing: Boolean = false +) diff --git a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/data/UiModels.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/data/UiModels.kt new file mode 100644 index 000000000..8e0139c97 --- /dev/null +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/data/UiModels.kt @@ -0,0 +1,49 @@ +/* + * 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.pingonemfapp.data + +import com.pingidentity.pingonemfa.commons.PingOneMfaAccount + +/* + * Simple data class to represent an MFA account of PingOneMFA SDK. + */ +data class AccountItem( + val id: String, + val deviceId: String, + val environment: String, + val region: String, + val name: String, + val lastName: String +) +/* + * Data class for OTP UI display with additional UI-specific fields. + */ +data class OtpUiState( + val otp: String = "", + val secondsRemaining: Int = 0, + val isLoading: Boolean = false, + val error: String? = null +) + +fun List.toUiItems(): List { + return map { + createAccountItem(it) + } +} + +fun createAccountItem(account: PingOneMfaAccount): AccountItem { + return AccountItem( + region = account.region, + name = account.name, + lastName = account.family, + id = account.id, + deviceId = account.deviceId, + environment = account.environment + ) +} + diff --git a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/data/UserPreferences.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/data/UserPreferences.kt new file mode 100644 index 000000000..37304b58d --- /dev/null +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/data/UserPreferences.kt @@ -0,0 +1,162 @@ +/* + * 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.pingonemfapp.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 _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 + + /** + * 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) + } + + /** + * 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) + } + + /** + * 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 + } + } + + 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" + } +} diff --git a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/managers/AccountsManager.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/managers/AccountsManager.kt new file mode 100644 index 000000000..f2f8e1d78 --- /dev/null +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/managers/AccountsManager.kt @@ -0,0 +1,60 @@ +package com.pingidentity.pingonemfapp.managers + +import com.pingidentity.pingonemfa.commons.PingOneMFA +import com.pingidentity.pingonemfa.commons.PingOneMfaAccount +import com.pingidentity.pingonemfapp.data.AccountItem +import com.pingidentity.pingonemfapp.data.DiagnosticLogger +import com.pingidentity.pingonemfapp.data.toUiItems +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext + +class AccountsManager( + private val diagnosticLogger: DiagnosticLogger +) { + + private val _isLoadingMfaAccounts = MutableStateFlow(false) + val isLoadingMfaAccounts: StateFlow = _isLoadingMfaAccounts.asStateFlow() + + private val _mfaAccounts = MutableStateFlow>(emptyList()) + val mfaAccounts: StateFlow> = _mfaAccounts.asStateFlow() + + private val _mfaAccountsUi = MutableStateFlow>(emptyList()) + val mfaAccountsUi: StateFlow> = _mfaAccountsUi.asStateFlow() + + suspend fun addAccountFromPairingKeyScan(pairingKey: String): Result { + diagnosticLogger.d("addAccountFromPairingKeyScan: $pairingKey") + return PingOneMFA.pair(pairingKey) + } + + suspend fun loadAccounts(): Result> { + _isLoadingMfaAccounts.value = true + return try { + val result = withContext(Dispatchers.IO) { + diagnosticLogger.d("Loading MFA accounts from PingOneMFA") + PingOneMFA.getAccounts() + } + result.onSuccess { accounts -> + _mfaAccounts.value = accounts + diagnosticLogger.d("Loaded ${accounts.size} MFA accounts from PingOneMFA") + updatePingOneMFAAccounts() + } + result.onFailure { + diagnosticLogger.e("Failed to load MFA accounts from PingOneMFA", it) + _mfaAccounts.value = emptyList() + } + _isLoadingMfaAccounts.value = false + result + } catch (e: Exception) { + _isLoadingMfaAccounts.value = false + Result.failure(e) + } + } + + private fun updatePingOneMFAAccounts() { + val mfaAccountsUi = _mfaAccounts.value.toUiItems() + _mfaAccountsUi.value = mfaAccountsUi + } +} \ No newline at end of file diff --git a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/managers/OTPManager.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/managers/OTPManager.kt new file mode 100644 index 000000000..220780006 --- /dev/null +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/managers/OTPManager.kt @@ -0,0 +1,70 @@ +package com.pingidentity.pingonemfapp.managers + +import com.pingidentity.pingonemfa.commons.PingOneMFA +import com.pingidentity.pingonemfapp.data.DiagnosticLogger +import com.pingidentity.pingonemfapp.data.OtpUiState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +class OTPManager( + private val diagnosticLogger: DiagnosticLogger +) { + private val _otpState = MutableStateFlow(OtpUiState()) + val otpState: StateFlow = _otpState.asStateFlow() + + var otpRefreshJob: Job? = null + + fun startAutoRefresh(scope: CoroutineScope){ + if (otpRefreshJob?.isActive == true) return + diagnosticLogger.d("startAutoRefresh") + otpRefreshJob = scope.launch { + fetchOtpAndStartCountDown() + } + } + + fun stop() { + otpRefreshJob?.cancel() + otpRefreshJob = null + } + suspend fun fetchOtpAndStartCountDown() { + _otpState.update { it.copy(isLoading = true, error = null) } + + val result = PingOneMFA.collectOtp() + if (result.isSuccess) { + val otp = result.getOrThrow() + _otpState.update { it.copy( + otp = otp.code, + secondsRemaining = otp.secondsRemaining, + isLoading = false, + error = null + ) } + startCountDown(otp.secondsRemaining) + // when countdown ends → fetch again + fetchOtpAndStartCountDown() + } else { + _otpState.update { + it.copy( + isLoading = false, + error = result.exceptionOrNull()?.message + ) + } + } + } + private suspend fun startCountDown(seconds: Int) { + var remaining = seconds + while (remaining > 0 && currentCoroutineContext().isActive) { + delay(1000) + remaining-- + // if we want to show countdown in UI + //_otpState.update { it.copy(secondsRemaining = remaining) } + } + } +} \ No newline at end of file diff --git a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/BiometricPromptActivity.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/BiometricPromptActivity.kt new file mode 100644 index 000000000..b7784f051 --- /dev/null +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/BiometricPromptActivity.kt @@ -0,0 +1,236 @@ +/* + * 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.pingonemfapp.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.pingonemfa.push.PushNotification +import com.pingidentity.pingonemfapp.data.DiagnosticLogger +import com.pingidentity.pingonemfapp.ui.theme.PingIdentityAuthenticatorTheme +import kotlinx.coroutines.CoroutineScope +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 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) + + // Get notification object from intent + val notification = intent?.getParcelableExtra(NotificationActionReceiver.EXTRA_NOTIFICATION, PushNotification::class.java) + // 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 { + + // Check if biometric authentication is available + val biometricManager = BiometricManager.from(context) + when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)) { + BiometricManager.BIOMETRIC_SUCCESS -> { + isLoading = false + showBiometricPrompt(notification, 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( + notification: PushNotification?, + coroutineScope: 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(notification, authMethod) + finish() + } 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(notification: PushNotification?, authMethod: String) { + val result = notification?.approveNotification( + this, + authMethod + ) + when { + result?.isSuccess == true -> { + finish() + } + result?.isFailure == true -> { + diagnosticLogger.e("Error approving with challenge: ${result.exceptionOrNull()?.stackTrace}") + //errorMessage = "Failed to approve: ${result.exceptionOrNull()?.message}" + } + } + } + +} diff --git a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/NotificationActionReceiver.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/NotificationActionReceiver.kt new file mode 100644 index 000000000..cc9770abb --- /dev/null +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/NotificationActionReceiver.kt @@ -0,0 +1,74 @@ +/* + * 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.pingonemfapp.notification + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationManagerCompat +import com.pingidentity.pingonemfa.commons.PingOneMFA +import com.pingidentity.pingonemfa.push.PushNotification +import com.pingidentity.pingonemfapp.data.DiagnosticLogger + +/** + * BroadcastReceiver to handle notification actions. + */ +class NotificationActionReceiver : BroadcastReceiver() { + + private val diagnosticLogger = DiagnosticLogger + + companion object { + const val ACTION_APPROVE = "com.pingidentity.pingonemfapp.ACTION_APPROVE" + const val ACTION_DENY = "com.pingidentity.pingonemfapp.ACTION_DENY" + const val ACTION_BIOMETRIC = "com.pingidentity.pingonemfapp.ACTION_BIOMETRIC" + const val EXTRA_NOTIFICATION_ID = "notification_id" + + const val EXTRA_NOTIFICATION = "com.pingidentity.pingonemfapp.notification" + } + + override fun onReceive(context: Context, intent: Intent) { + val notification = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(EXTRA_NOTIFICATION, PushNotification::class.java) + } else { + intent.getParcelableExtra(EXTRA_NOTIFICATION) + } + 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") + PingOneMFA.approvePushNotificationFromBanner(notification = notification) + } + ACTION_DENY -> { + diagnosticLogger.d("Deny action received for notification: $notificationId") + PingOneMFA.denyPushNotificationFromBanner(notification = notification) + } + ACTION_BIOMETRIC -> { + diagnosticLogger.d("Biometric action received for notification: $notificationId") + handleBiometricAuthentication(context, notification) + } + } + } + + /** + * Handles biometric authentication for the notification with the given ID. + * This launches the BiometricPrompt activity. + */ + private fun handleBiometricAuthentication(context: Context, notification: PushNotification?) { + val intent = Intent(context, BiometricPromptActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + putExtra(EXTRA_NOTIFICATION_ID, notification?.id) + putExtra(EXTRA_NOTIFICATION, notification) + } + context.startActivity(intent) + } +} diff --git a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/NotificationHelper.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/NotificationHelper.kt new file mode 100644 index 000000000..629b0fab6 --- /dev/null +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/NotificationHelper.kt @@ -0,0 +1,220 @@ +/* + * 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.pingonemfapp.notification + +import android.Manifest +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.pingonemfapp.R +import com.pingidentity.pingonemfapp.notification.NotificationActionReceiver.Companion.ACTION_APPROVE +import com.pingidentity.pingonemfapp.notification.NotificationActionReceiver.Companion.ACTION_DENY +import com.pingidentity.pingonemfapp.notification.NotificationActionReceiver.Companion.EXTRA_NOTIFICATION_ID +import com.pingidentity.pingonemfa.push.PushNotification +import com.pingidentity.pingonemfa.push.PushType + +/** + * Helper class for managing and displaying system notifications. + */ +class NotificationHelper(private val context: Context) { + + companion object { + const val CHANNEL_ID = "com.pingidentity.pingonemfapp.PUSH_NOTIFICATIONS" + const val NOTIFICATION_GROUP = "com.pingidentity.pingonemfapp.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 title The title of the authentication request (if available) + * @param body The body message of the authentication request (if available) + */ + @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS) + fun showPushAuthenticationNotification( + notification: PushNotification, + title: String?, + body: 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) + // Add notification object + putExtra(NotificationActionReceiver.EXTRA_NOTIFICATION, notification) + } + + val pendingIntent = PendingIntent.getActivity( + context, notificationId, intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + // Build the notification title and content + val title = title ?: context.getString(R.string.system_notification_title) + val content = when { + body != null -> body + 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.getPushType()) { + PushType.DEFAULT -> { + // For DEFAULT type, add approve and deny buttons + addDefaultTypeActions(builder, notification.id, notification) + } + + PushType.BIOMETRIC -> { + // For BIOMETRIC type, add biometric authentication action + addBiometricTypeAction(builder, notification.id, notification) + } + + 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)}") + } + + else -> { + // For other types, we don't add actions + builder.setContentText(content) + } + } + + // 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, + notification: PushNotification) { + // Approve action + val approveIntent = Intent(context, NotificationActionReceiver::class.java).apply { + action = ACTION_APPROVE + putExtra(EXTRA_NOTIFICATION_ID, notificationId) + putExtra(NotificationActionReceiver.EXTRA_NOTIFICATION, notification) + } + + 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) + putExtra(NotificationActionReceiver.EXTRA_NOTIFICATION, notification) + } + 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, + notification: PushNotification + ) { + // 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) + putExtra(NotificationActionReceiver.EXTRA_NOTIFICATION, notification) + } + + // 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/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/PushNotificationActivity.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/PushNotificationActivity.kt new file mode 100644 index 000000000..1de2711b1 --- /dev/null +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/PushNotificationActivity.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.pingonemfapp.notification + +import android.content.Intent +import android.os.Build +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.pingonemfapp.data.DiagnosticLogger +import com.pingidentity.pingonemfapp.notification.NotificationActionReceiver.Companion.EXTRA_NOTIFICATION_ID +import com.pingidentity.pingonemfapp.ui.NotificationResponseScreen +import com.pingidentity.pingonemfapp.ui.theme.PingIdentityAuthenticatorTheme +import com.pingidentity.pingonemfa.push.PushNotification +import com.pingidentity.pingonemfapp.notification.NotificationActionReceiver.Companion.EXTRA_NOTIFICATION +import kotlinx.coroutines.launch + +/** + * Activity to handle full-screen display of push notifications. + * This activity is launched when a notification is received while app is open or when the user + * clicks on a notification. + */ +class PushNotificationActivity : ComponentActivity() { + + + private val diagnosticLogger = DiagnosticLogger + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Get notification ID from intent + val notificationId = intent?.getStringExtra(EXTRA_NOTIFICATION_ID) + + val notification = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent?.getParcelableExtra(EXTRA_NOTIFICATION, PushNotification::class.java) + } else { + intent?.getParcelableExtra(EXTRA_NOTIFICATION) + } + // 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 { + notificationItemState = notification + 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 { + val result = notification?.approveNotification( + context, + "user" + ) + when { + result?.isSuccess == true -> { + finish() + } + result?.isFailure == true -> { + diagnosticLogger.e("Error approving with challenge: ${result.exceptionOrNull()?.stackTrace}") + } + } + } + }, + onBiometricApprove = { + launchBiometricPrompt(notificationId, notification) + }, + onDeny = { + coroutineScope.launch { + val result = notification?.denyNotification(context) + when { + result?.isSuccess == true -> { + finish() + } + result?.isFailure == true -> { + diagnosticLogger.e("Error approving with challenge: ${result.exceptionOrNull()?.stackTrace}") + } + } + } + }, + onChallengeSolution = { solution -> + coroutineScope.launch { + val result = notification?.approveNotification( + context, + "user", + solution.toInt() + ) + when { + result?.isSuccess == true -> { + finish() + } + result?.isFailure == true -> { + diagnosticLogger.e("Error approving with challenge: ${result.exceptionOrNull()?.stackTrace}") + } + } + } + } + ) + } + } + } + } + } + } + + /** + * Launches the BiometricPromptActivity for biometric authentication. + */ + private fun launchBiometricPrompt(notificationId: String, notification: PushNotification?) { + val intent = Intent(this, BiometricPromptActivity::class.java).apply { + putExtra(EXTRA_NOTIFICATION_ID, notificationId) + putExtra(EXTRA_NOTIFICATION, notification) + } + startActivity(intent) + finish() // finish current activity before launching new one. + } + +} diff --git a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/service/PushNotificationService.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/service/PushNotificationService.kt new file mode 100644 index 000000000..0b825cf22 --- /dev/null +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/service/PushNotificationService.kt @@ -0,0 +1,153 @@ +package com.pingidentity.pingonemfapp.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.pingonemfa.commons.PingOneMFA +import com.pingidentity.pingonemfa.push.PushNotification +import com.pingidentity.pingonemfapp.data.DiagnosticLogger +import com.pingidentity.pingonemfapp.notification.NotificationActionReceiver +import com.pingidentity.pingonemfapp.notification.NotificationHelper +import com.pingidentity.pingonemfapp.notification.PushNotificationActivity +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 val diagnosticLogger = DiagnosticLogger + + private lateinit var notificationHelper: NotificationHelper + + + override fun onCreate() { + super.onCreate() + diagnosticLogger.d("PushNotificationService instance created") + + notificationHelper = NotificationHelper(this) + notificationHelper.createNotificationChannels() + } + + 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 PingOneMFA SDK + val success = PingOneMFA.register(token) + diagnosticLogger.d("Device token registration success: $success") + } + } + + /** + * 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 { + // Process the notification via PingOneMFA SDK + val result = PingOneMFA.collectPush(remoteMessage) + result.onSuccess { + pushNotification -> handleNotification(pushNotification) + }.onFailure { + diagnosticLogger.e("Error processing notification: ${it.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) { + notificationHelper.showPushAuthenticationNotification( + notification = notification, + title = notification.title, + body = notification.message + ) + } + } + + /** + * 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 and notification object + val intent = Intent(applicationContext, PushNotificationActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP + putExtra(NotificationActionReceiver.EXTRA_NOTIFICATION_ID, notification.id) + putExtra(NotificationActionReceiver.EXTRA_NOTIFICATION, notification) + } + + 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/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/AboutScreen.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/AboutScreen.kt new file mode 100644 index 000000000..d95c60346 --- /dev/null +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/AboutScreen.kt @@ -0,0 +1,163 @@ +/* + * 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.pingonemfapp.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.pingonemfapp.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_otp), + 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 + ) + } + } + + // 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/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/AccountsScreen.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/AccountsScreen.kt new file mode 100644 index 000000000..2176282fc --- /dev/null +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/AccountsScreen.kt @@ -0,0 +1,308 @@ +/* + * 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.pingonemfapp.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Image +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.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.QrCodeScanner +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.dp +import com.pingidentity.pingonemfapp.R +import com.pingidentity.pingonemfapp.data.PingOneMFAViewModel +import com.pingidentity.pingonemfapp.ui.components.AccountCard +import com.pingidentity.pingonemfapp.ui.components.EmptyStateMessage +import com.pingidentity.pingonemfapp.ui.components.ErrorAlertDialog +import com.pingidentity.pingonemfapp.ui.components.LoadingIndicator +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive + +/** + * Screen for displaying a list of accounts and push notifications. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AccountsScreen( + viewModel: PingOneMFAViewModel, + onScanQrCode: () -> Unit, + onAccountClick: () -> Unit, + onSettingsClick: () -> Unit, + onAboutClick: () -> Unit, +) { + val context = LocalContext.current + val uiState by viewModel.uiState.collectAsState() + val coroutineScope = rememberCoroutineScope() + + // Collect settings state + val copyOtpEnabled by viewModel.copyOtp.collectAsState() + + // State for triggering progress bar updates + var currentTimeMillis by remember { mutableLongStateOf(System.currentTimeMillis()) } + + // 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 = { + // Hamburger menu + Box { + IconButton(onClick = { showHamburgerMenu = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = "Menu" + ) + } + + DropdownMenu( + expanded = showHamburgerMenu, + onDismissRequest = { showHamburgerMenu = false } + ) { + 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" + ) + } + } + } + + // Primary FAB + FloatingActionButton( + onClick = { + showFabMenu = false + onScanQrCode() + }, + 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_accounts) + ) + } + uiState.isLoadingPingOneAccounts -> { + LoadingIndicator( + message = stringResource(id = R.string.loading_accounts) + ) + } + uiState.pingOneMfaAccounts.isEmpty() -> { + EmptyStateMessage( + title = "No accounts added yet", + subtitle = stringResource(id = R.string.accounts_empty_state_subtitle) + ) + } + else -> { + // List of accounts + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items( + items = uiState.pingOneMfaAccounts, + key = { account -> + // Create a unique key using issuer, account name, and all credential IDs + val userId = account.id + val deviceId = account.deviceId + "$userId-$deviceId" + } + ) { account -> + AccountCard( + accountItem = account, + onCardClick = { + // Navigate to the OTP screen + onAccountClick() + } + ) + } + } + } + } + + // Error handling + if (uiState.error != null) { + ErrorAlertDialog( + errorMessage = uiState.error!!, + onDismiss = { viewModel.clearError() } + ) + } + } + } +} \ No newline at end of file diff --git a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/AuthenticatorNavHost.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/AuthenticatorNavHost.kt new file mode 100644 index 000000000..be323524f --- /dev/null +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/AuthenticatorNavHost.kt @@ -0,0 +1,114 @@ +/* + * 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.pingonemfapp.ui + +import androidx.compose.runtime.Composable +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.pingidentity.pingonemfapp.data.PingOneMFAViewModel +import com.pingidentity.pingonemfapp.util.NavigationAnimations + +/** + * Main entry point for the app. + */ +@Composable +fun AuthenticatorNavHost( + authenticatorViewModel: PingOneMFAViewModel = viewModel(), + initialDestination: String = "accounts" +) { + // 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") }, + onAccountClick = {navController.navigate("otp") }, + onSettingsClick = { navController.navigate("settings") }, + onAboutClick = { navController.navigate("about") } + ) + } + + // 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() } + ) + } + + // OTP screen + composable( + route = "otp", + enterTransition = NavigationAnimations.enterTransition, + exitTransition = NavigationAnimations.exitTransition, + popEnterTransition = NavigationAnimations.popEnterTransition, + popExitTransition = NavigationAnimations.popExitTransition + ) { + OtpScreen( + viewModel = authenticatorViewModel, + onDismiss = { 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() } + ) + } + + // About screen + composable( + route = "about", + enterTransition = NavigationAnimations.enterTransition, + exitTransition = NavigationAnimations.exitTransition, + popEnterTransition = NavigationAnimations.popEnterTransition, + popExitTransition = NavigationAnimations.popExitTransition + ) { + AboutScreen( + onDismiss = { navController.popBackStack() } + ) + } + } +} diff --git a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/DiagnosticLogsScreen.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/DiagnosticLogsScreen.kt new file mode 100644 index 000000000..1598fbfbf --- /dev/null +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/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.pingonemfapp.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.pingonemfapp.R +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.pingidentity.pingonemfapp.data.DiagnosticLogger +import com.pingidentity.pingonemfapp.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/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/LoginScreen.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/LoginScreen.kt new file mode 100644 index 000000000..a2d23f2bc --- /dev/null +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/LoginScreen.kt @@ -0,0 +1,217 @@ +/* + * 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.pingonemfapp.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.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.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.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.pingonemfapp.R + +/** + * 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/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/NotificationResponseScreen.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/NotificationResponseScreen.kt new file mode 100644 index 000000000..00f52178a --- /dev/null +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/NotificationResponseScreen.kt @@ -0,0 +1,413 @@ +/* + * 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.pingonemfapp.ui + +import androidx.compose.foundation.BorderStroke +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.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.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.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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +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 com.pingidentity.pingonemfa.push.PushNotification +import com.pingidentity.pingonemfapp.R +import com.pingidentity.pingonemfapp.ui.components.AccountAvatar + +/** + * Unified screen for displaying push notification details. + * Handles both standard authentication and challenge-based notifications. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NotificationResponseScreen( + notificationItem: PushNotification, + onDismiss: () -> Unit, + onApprove: (() -> Unit)? = null, + onBiometricApprove: (() -> Unit)? = null, + onDeny: (() -> Unit)? = null, + onChallengeSolution: ((String) -> Unit)? = null +) { + + val isChallenge = notificationItem.isChallenge() + val challengeNumbers = if (isChallenge) notificationItem.getNumbersChallenge()?.toList() 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) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + AccountAvatar( + issuer = notificationItem.title ?:stringResource(id = R.string.notification_response_unknown_issuer), + accountName = notificationItem.message ?: stringResource(id = R.string.notification_response_unknown_account), + imageUrl = null + //notificationItem.credential?.imageURL, + //size = 36.dp + ) + Spacer(modifier = Modifier.width(16.dp)) + Column { + val issuer = notificationItem.title ?:stringResource(id = R.string.notification_response_unknown_issuer) + val accountName = notificationItem.message ?: 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 + ) + } + } + + // Divider + HorizontalDivider( + modifier = Modifier.padding(vertical = 16.dp), + thickness = DividerDefaults.Thickness, + color = DividerDefaults.color + ) + + // Message + Text( + text = //notificationItem.messageText ?: + if (isChallenge) stringResource(id = R.string.notification_response_message_verify) else stringResource(id = R.string.notification_response_message_default), + style = MaterialTheme.typography.bodyLarge, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Time sent + 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 = notificationItem.sentAt.toString(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // Response time + notificationItem.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.isChallenge() -> 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 + ) + } + } + } + + // Action buttons based on type + if (isChallenge) { + // 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!=null && 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{ + // 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 + ) + } +} + diff --git a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/OtpScreen.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/OtpScreen.kt new file mode 100644 index 000000000..915abd222 --- /dev/null +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/OtpScreen.kt @@ -0,0 +1,65 @@ +package com.pingidentity.pingonemfapp.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import com.pingidentity.pingonemfapp.data.PingOneMFAViewModel +import com.pingidentity.pingonemfapp.ui.components.ErrorAlertDialog +import com.pingidentity.pingonemfapp.ui.components.ExpiringOtpCode +import com.pingidentity.pingonemfapp.ui.components.LoadingIndicator + +@Composable +fun OtpScreen( + viewModel: PingOneMFAViewModel, + onDismiss: () -> Unit +) { + + val state by viewModel.otpState.collectAsState() + + val lifecycle = androidx.lifecycle.compose.LocalLifecycleOwner.current.lifecycle + DisposableEffect(lifecycle) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_START -> viewModel.startOtpSequence() + Lifecycle.Event.ON_STOP -> viewModel.stopOtpSequence() + else -> {} + } + } + lifecycle.addObserver(observer) + onDispose { lifecycle.removeObserver(observer) } + } + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + when { + state.isLoading -> { + LoadingIndicator( + message = "Loading OTP code..." + ) + } + state.error != null -> { + ErrorAlertDialog( + errorMessage = state.error!!, + onDismiss = { viewModel.clearError() } + ) + } + else -> { + ExpiringOtpCode( + code = state.otp, + remainingSeconds = state.secondsRemaining, + totalDurationMs = 30 + + ) + } + } + } +} \ No newline at end of file diff --git a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/QrScannerScreen.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/QrScannerScreen.kt new file mode 100644 index 000000000..cdb9975a1 --- /dev/null +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/QrScannerScreen.kt @@ -0,0 +1,259 @@ +/* + * 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.pingonemfapp.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 androidx.lifecycle.compose.LocalLifecycleOwner +import com.pingidentity.pingonemfapp.R +import com.pingidentity.pingonemfapp.data.DiagnosticLogger +import com.pingidentity.pingonemfapp.data.PingOneMFAViewModel +import com.pingidentity.pingonemfapp.ui.components.BackNavigationTopAppBar +import com.pingidentity.pingonemfapp.util.QrCodeAnalyzer +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: PingOneMFAViewModel, + onScanComplete: () -> Unit, + onDismiss: () -> Unit +) { + val context = LocalContext.current + val diagnosticLogger = DiagnosticLogger + val lifecycleOwner = 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() +// } +// } + + + 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 + diagnosticLogger.d("QrScannerScreen: QR code result: $qrCodeResult") + viewModel.tryToPairUserForPingOneMFA(pairingKey = qrCodeResult) + onScanComplete() + } + ) + + 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/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/SettingsScreen.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/SettingsScreen.kt new file mode 100644 index 000000000..ef6a3371e --- /dev/null +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/SettingsScreen.kt @@ -0,0 +1,183 @@ +/* + * 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.pingonemfapp.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.ContentCopy +import androidx.compose.material.icons.filled.DarkMode +import androidx.compose.material.icons.filled.Dns +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.pingidentity.pingonemfapp.data.PingOneMFAViewModel +import com.pingidentity.pingonemfapp.data.ThemeMode +import com.pingidentity.pingonemfapp.ui.components.BackNavigationTopAppBar +import com.pingidentity.pingonemfapp.ui.components.SettingItem + +/** + * The settings screen for the PingOneMFApp. + * 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: PingOneMFAViewModel, + onDismiss: () -> Unit, + onDiagnosticLogsClick: () -> Unit = {} +) { + // Collect all settings as state + val copyOtp by viewModel.copyOtp.collectAsState() + val diagnosticLogging by viewModel.diagnosticLogging.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()) + ) { + + // 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() + + // 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() + } + } + + // 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 = 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/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/AccountAvatar.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/AccountAvatar.kt new file mode 100644 index 000000000..82393fbfb --- /dev/null +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/AccountAvatar.kt @@ -0,0 +1,89 @@ +/* + * 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.pingonemfapp.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +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.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +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: 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 + ) { + // 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/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/AccountCard.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/AccountCard.kt new file mode 100644 index 000000000..00a620617 --- /dev/null +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/AccountCard.kt @@ -0,0 +1,84 @@ +/* + * 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.pingonemfapp.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.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +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.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.pingidentity.pingonemfapp.data.AccountItem + +/** + * Composable that displays a single account card with account name and last name info. + * + * @param accountItem The Account information to display. + * @param onCardClick Callback when the account card is clicked. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AccountCard( + accountItem: AccountItem, + onCardClick: () -> Unit +) { + Card( + onClick = onCardClick, + 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 + ) { + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text( + text = accountItem.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = accountItem.lastName, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = accountItem.id, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } +} \ No newline at end of file diff --git a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/BackNavigationTopAppBar.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/BackNavigationTopAppBar.kt new file mode 100644 index 000000000..912e6cb2f --- /dev/null +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/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.pingonemfapp.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.pingonemfapp.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/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/EmptyStateMessage.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/EmptyStateMessage.kt new file mode 100644 index 000000000..1f066fa8a --- /dev/null +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/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.pingonemfapp.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/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/ErrorAlertDialog.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/ErrorAlertDialog.kt new file mode 100644 index 000000000..f26705614 --- /dev/null +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/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.pingonemfapp.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.pingonemfapp.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/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/ExpiringOtpCode.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/ExpiringOtpCode.kt new file mode 100644 index 000000000..d6fd3fad7 --- /dev/null +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/ExpiringOtpCode.kt @@ -0,0 +1,76 @@ +package com.pingidentity.pingonemfapp.ui.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.graphics.lerp +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.pingidentity.pingonemfapp.ui.theme.PingLightBlue +import com.pingidentity.pingonemfapp.ui.theme.PingRed + +@Composable +fun ExpiringOtpCode( + code: String, + totalDurationMs: Long, + remainingSeconds: Int, + modifier: Modifier = Modifier +) { + // Progress goes from 1.0 -> 0.0 as time runs out + val progress = (remainingSeconds.toFloat() / totalDurationMs.toFloat()).coerceIn(0f, 1f) + + val interpolatedColor = lerp( + start = PingLightBlue, + stop = PingRed, + fraction = 1f - progress + ) + + // Interpolate between Green -> Yellow -> Red + val displayColor by animateColorAsState( + targetValue = interpolatedColor, + label = "otpColor" + ) + + // Slight fade-out near end (optional) + val alpha by animateFloatAsState( + targetValue = if (progress < 0.2f) 0.8f else 1f, + label = "otpAlpha" + ) + + Row( + modifier = modifier.padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + code.forEach { digit -> + Box( + modifier = Modifier + .size(42.dp) + .border(1.dp, displayColor.copy(alpha = 0.5f), RoundedCornerShape(6.dp)) + .background(Color.Black.copy(alpha = 0.05f), RoundedCornerShape(6.dp)), + contentAlignment = Alignment.Center + ) { + Text( + text = digit.toString(), + color = displayColor.copy(alpha = alpha), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + } + } + } + +} \ No newline at end of file diff --git a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/LoadingIndicator.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/LoadingIndicator.kt new file mode 100644 index 000000000..e3567be3b --- /dev/null +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/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.pingonemfapp.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/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/SettingItem.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/SettingItem.kt new file mode 100644 index 000000000..fa4c7f6d4 --- /dev/null +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/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.pingonemfapp.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/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/theme/Color.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/theme/Color.kt new file mode 100644 index 000000000..61300fad8 --- /dev/null +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/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.pingonemfapp.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/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/theme/Theme.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/theme/Theme.kt new file mode 100644 index 000000000..91d154a60 --- /dev/null +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/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.pingonemfapp.ui.theme + +import android.app.Activity +import com.pingidentity.pingonemfapp.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/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/theme/Type.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/theme/Type.kt new file mode 100644 index 000000000..4e12f3d8e --- /dev/null +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/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.pingonemfapp.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/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/util/NavigationAnimations.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/util/NavigationAnimations.kt new file mode 100644 index 000000000..12dbe1428 --- /dev/null +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/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.pingonemfapp.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/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/util/QrCodeAnalyzer.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/util/QrCodeAnalyzer.kt new file mode 100644 index 000000000..ae474b0e9 --- /dev/null +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/util/QrCodeAnalyzer.kt @@ -0,0 +1,69 @@ +/* + * 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.pingonemfapp.util + +import android.annotation.SuppressLint +import androidx.annotation.OptIn +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 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 + } + + // 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/samples/pingonemfapp/src/main/res/drawable/ic_check.xml b/samples/pingonemfapp/src/main/res/drawable/ic_check.xml new file mode 100644 index 000000000..117e40b7b --- /dev/null +++ b/samples/pingonemfapp/src/main/res/drawable/ic_check.xml @@ -0,0 +1,10 @@ + + + diff --git a/samples/pingonemfapp/src/main/res/drawable/ic_close.xml b/samples/pingonemfapp/src/main/res/drawable/ic_close.xml new file mode 100644 index 000000000..351a06ab3 --- /dev/null +++ b/samples/pingonemfapp/src/main/res/drawable/ic_close.xml @@ -0,0 +1,10 @@ + + + diff --git a/samples/pingonemfapp/src/main/res/drawable/ic_fingerprint.xml b/samples/pingonemfapp/src/main/res/drawable/ic_fingerprint.xml new file mode 100644 index 000000000..a628a03bd --- /dev/null +++ b/samples/pingonemfapp/src/main/res/drawable/ic_fingerprint.xml @@ -0,0 +1,10 @@ + + + diff --git a/samples/pingonemfapp/src/main/res/drawable/ic_launcher_foreground.xml b/samples/pingonemfapp/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 000000000..1353083fe --- /dev/null +++ b/samples/pingonemfapp/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/samples/pingonemfapp/src/main/res/drawable/ic_notification.xml b/samples/pingonemfapp/src/main/res/drawable/ic_notification.xml new file mode 100644 index 000000000..d91b7fa2b --- /dev/null +++ b/samples/pingonemfapp/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,10 @@ + + + diff --git a/samples/pingonemfapp/src/main/res/drawable/ping_logo.xml b/samples/pingonemfapp/src/main/res/drawable/ping_logo.xml new file mode 100644 index 000000000..a21fde540 --- /dev/null +++ b/samples/pingonemfapp/src/main/res/drawable/ping_logo.xml @@ -0,0 +1,28 @@ + + + + + + + + diff --git a/samples/pingonemfapp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/samples/pingonemfapp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..5ed0a2df7 --- /dev/null +++ b/samples/pingonemfapp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/samples/pingonemfapp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/samples/pingonemfapp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 000000000..5ed0a2df7 --- /dev/null +++ b/samples/pingonemfapp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/samples/pingonemfapp/src/main/res/values/ic_launcher_background.xml b/samples/pingonemfapp/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 000000000..f42ada656 --- /dev/null +++ b/samples/pingonemfapp/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + diff --git a/samples/pingonemfapp/src/main/res/values/strings.xml b/samples/pingonemfapp/src/main/res/values/strings.xml new file mode 100644 index 000000000..3878b9412 --- /dev/null +++ b/samples/pingonemfapp/src/main/res/values/strings.xml @@ -0,0 +1,171 @@ + + + Push authentication requests from Ping Identity + APush Authentication + "Authentication Request" + You have a new authentication request + Authentication request for + (Challenge verification required) + Approve + Deny + Authenticate + Notification permission granted + Notification permission denied. Push notifications will not be displayed. + PingOne MFA Authenticator + Version 1.0.0 + About this app + The Ping Authenticator app provides secure multi-factor authentication using OTP and Push notification methods. This sample application demonstrates the capabilities of the Ping Identity Android SDK. + Features + • OTP Authentication (TOTP/HOTP) + • Push Notifications + • QR Code Scanning + • Account Management + • Secure Storage + © 2026 Ping Identity Corporation. All rights reserved. + About + Back + Account Details + No credentials found for this account + OATH + PUSH + Code copied to clipboard + Error + OK + Copy + New Code + Generate Code + Type + Algorithm + Digits + Period + %d seconds + Created + Platform + User ID + Ping Access Management + PingOne + Today + Yesterday + %d days ago + %d weeks ago + %d months ago + %d years ago + PingOne MFA Authenticator + No accounts added yet + Add an account by scanning a QR code + Loading accounts… + Refresh + Test Mode + Menu + Notifications + Edit Accounts + Settings + About + Scan QR Code + Add Manually + Add Account + Diagnostic Logs (%d) + Authenticator App Diagnostic Logs + Share Logs + Clear Logs + No logs captured yet + Logs will appear here when diagnostic logging is enabled + Account added successfully + Add Account Manually + Issuer (e.g. Company) + Account Name (e.g. email@example.com) + Secret Key + OTP Type + Algorithm + Digits + Period (seconds) + Add Account + Unable to resolve location + Failed to load location details + Authentication Request + Unknown Issuer + Unknown Account + Please verify your identity + Authentication request + Biometric authentication + Challenge authentication + Standard authentication + macOS + Windows + Linux + Android + iOS + Loading location details… + Lat: %1$s, Lng: %2$s + Login Location + Select the number that appears on your other device: + Cancel Authentication + No challenge numbers available + Close + Deny + Approve + Verify + Login + Cancel + Try Again + MFA credential registered successfully + Authenticating… + Preparing authentication… + Unknown error + Please Wait + Registering MFA credentials… + Push Notifications + No push notifications + Pending Requests + Notification History + Location information available + Account added successfully + Scan QR Code + Invalid QR code format. Please scan a valid OATH, Push, or MFA authentication QR code. + Failed to initialize camera: %s + Position QR code within frame + Camera permission is required to scan QR codes + Request Permission + Test Mode + Test Accounts + Create OATH + Create Random OATH + Create PUSH + Create Random PUSH + Create Combined MFA + Create Random Combined + Device Token + Click on `Get Token` to retrieve the token… + Requested new device token from FCM + Renew Token + Get Token + Device token retrieved successfully + Device token renewed successfully + Notifications cleaned up successfully + Notifications + Clean up + Clean Up Old Notifications + Account Locking + Manage Account Locks + Select Account to Lock/Unlock + Lock Account + Unlock Account + Select Locking Policy + Biometric Available + Device Tampering + Custom Policy + Account locked successfully + Account unlocked successfully + No accounts available to lock + OATH + PUSH + • • • • • • + OTP Code + + + Biometrics are required but are unavailable. Please contact your account administrator for help. + The device might have been tampered with or rooted. Please contact the account administrator for help. + Account locked by the following policy: %s. Please contact the account administrator for help. + Account is locked. Please contact the account administrator for help. + Account Locked + \ No newline at end of file diff --git a/samples/pingonemfapp/src/main/res/values/themes.xml b/samples/pingonemfapp/src/main/res/values/themes.xml new file mode 100644 index 000000000..dced681e1 --- /dev/null +++ b/samples/pingonemfapp/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + - \ No newline at end of file diff --git a/samples/app/src/main/res/values/colors.xml b/samples/app/src/main/res/values/colors.xml deleted file mode 100644 index 3d9221df5..000000000 --- a/samples/app/src/main/res/values/colors.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - #DB4332 - #B3282D - #DB4332 - #69747D - #505D68 - #051727 - #FFFFFFFF - \ No newline at end of file diff --git a/samples/app/src/main/res/values/strings.xml b/samples/app/src/main/res/values/strings.xml deleted file mode 100644 index 58f0c011d..000000000 --- a/samples/app/src/main/res/values/strings.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - DaVinci - submit - - Sign On - Welcome to Ping Identity - Password Reset - Enter your username, and we\'ll send password reset instructions to your email address. - Create Your Profile - - Enter your information to create your account. - - Verification Code - We’ve sent a verification code to your email address. Please verify your email to finish setting up your account. - - Enter New Password - If you have an active account with a valid email address, you will receive an email with a recovery code which you may enter here, along with a new password. - If you do not have an account or email, please contact your administrator to recover your password. - - - - {facebook client id} - fb{facebook client id} - {client token} - - \ No newline at end of file diff --git a/samples/app/src/main/res/values/themes.xml b/samples/app/src/main/res/values/themes.xml deleted file mode 100644 index bd8498d7f..000000000 --- a/samples/app/src/main/res/values/themes.xml +++ /dev/null @@ -1,40 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/samples/authenticatorapp/README.md b/samples/authenticatorapp/README.md deleted file mode 100644 index c2ac6fe44..000000000 --- a/samples/authenticatorapp/README.md +++ /dev/null @@ -1,273 +0,0 @@ -[![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. \ No newline at end of file diff --git a/samples/authenticatorapp/build.gradle.kts b/samples/authenticatorapp/build.gradle.kts deleted file mode 100644 index 657ec462f..000000000 --- a/samples/authenticatorapp/build.gradle.kts +++ /dev/null @@ -1,127 +0,0 @@ -/* - * 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. - */ - -plugins { - alias(libs.plugins.androidApplication) - alias(libs.plugins.kotlinAndroid) - alias(libs.plugins.compose.compiler) - alias(libs.plugins.googleServices) - alias(libs.plugins.kotlinSerialization) -} - -android { - namespace = "com.pingidentity.authenticatorapp" - compileSdk = 36 - - defaultConfig { - applicationId = "com.pingidentity.authenticatorapp" - minSdk = 29 - targetSdk = 36 - versionCode = 1 - versionName = "1.0" - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - vectorDrawables { - useSupportLibrary = true - } - } - - buildTypes { - release { - isMinifyEnabled = false - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - } - } - - lint { - disable += "NullSafeMutableLiveData" - // To avoid lint errors during the build - abortOnError = false - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - kotlinOptions { - jvmTarget = "17" - } - buildFeatures { - compose = true - } - composeOptions { - // Matching the Compose plugin version - kotlinCompilerExtensionVersion = "1.5.8" - } - packaging { - resources { - excludes += "/META-INF/{AL2.0,LGPL2.1}" - } - } -} - -configurations.all { - resolutionStrategy { - force("com.google.android.gms:play-services-basement:18.4.0") - force("com.google.android.gms:play-services-tasks:18.2.0") - force("com.google.android.gms:play-services-base:18.5.0") - } -} - -dependencies { - // Ping SDK dependencies - implementation(project(":mfa:oath")) - implementation(project(":mfa:push")) - implementation(project(":journey")) - - // Kotlinx Serialization - implementation(libs.kotlinx.serialization.json) - - // Core Android dependencies - implementation(libs.androidx.core.ktx) - implementation(libs.androidx.lifecycle.runtime.ktx) - implementation(libs.androidx.activity.compose) - - // 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) -} diff --git a/samples/authenticatorapp/google-services.json b/samples/authenticatorapp/google-services.json deleted file mode 100644 index 0f880c816..000000000 --- a/samples/authenticatorapp/google-services.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "project_info": { - "project_number": "425224784438", - "project_id": "acme-authenticator-c2d88", - "storage_bucket": "acme-authenticator-c2d88.appspot.com" - }, - "client": [ - { - "client_info": { - "mobilesdk_app_id": "1:425224784438:android:56870e9c9484be9b816697", - "android_client_info": { - "package_name": "com.pingidentity.authenticatorapp" - } - }, - "oauth_client": [], - "api_key": [ - { - "current_key": "AIzaSyACtwpp8SibmOgIMottpiqS4Evm5KUcDgs" - } - ], - "services": { - "appinvite_service": { - "other_platform_oauth_client": [] - } - } - } - ], - "configuration_version": "1" -} \ No newline at end of file diff --git a/samples/authenticatorapp/src/main/AndroidManifest.xml b/samples/authenticatorapp/src/main/AndroidManifest.xml deleted file mode 100644 index 4240093b3..000000000 --- a/samples/authenticatorapp/src/main/AndroidManifest.xml +++ /dev/null @@ -1,84 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/AuthenticatorApp.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/AuthenticatorApp.kt deleted file mode 100644 index 6993e8ea2..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/AuthenticatorApp.kt +++ /dev/null @@ -1,406 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/MainActivity.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/MainActivity.kt deleted file mode 100644 index 7a3306f99..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/MainActivity.kt +++ /dev/null @@ -1,253 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/data/AuthenticatorViewModel.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/data/AuthenticatorViewModel.kt deleted file mode 100644 index 773a59d0e..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/data/AuthenticatorViewModel.kt +++ /dev/null @@ -1,1210 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/data/BackupModels.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/data/BackupModels.kt deleted file mode 100644 index 08bc54e49..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/data/BackupModels.kt +++ /dev/null @@ -1,29 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/data/DiagnosticLogger.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/data/DiagnosticLogger.kt deleted file mode 100644 index 6de78a7f5..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/data/DiagnosticLogger.kt +++ /dev/null @@ -1,121 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/data/LoginViewModel.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/data/LoginViewModel.kt deleted file mode 100644 index 4dbae1126..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/data/LoginViewModel.kt +++ /dev/null @@ -1,460 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/data/UiModels.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/data/UiModels.kt deleted file mode 100644 index c25f87cb8..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/data/UiModels.kt +++ /dev/null @@ -1,399 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/data/UserPreferences.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/data/UserPreferences.kt deleted file mode 100644 index 1cb5cb9bd..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/data/UserPreferences.kt +++ /dev/null @@ -1,262 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/managers/AccountGroupingManager.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/managers/AccountGroupingManager.kt deleted file mode 100644 index 9d0be18b3..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/managers/AccountGroupingManager.kt +++ /dev/null @@ -1,191 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/managers/JourneyManager.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/managers/JourneyManager.kt deleted file mode 100644 index e88be1e78..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/managers/JourneyManager.kt +++ /dev/null @@ -1,249 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/managers/OathManager.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/managers/OathManager.kt deleted file mode 100644 index 1a2cae61a..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/managers/OathManager.kt +++ /dev/null @@ -1,507 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/managers/PushManager.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/managers/PushManager.kt deleted file mode 100644 index a86191298..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/managers/PushManager.kt +++ /dev/null @@ -1,762 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/managers/TestAccountFactory.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/managers/TestAccountFactory.kt deleted file mode 100644 index a919070d5..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/managers/TestAccountFactory.kt +++ /dev/null @@ -1,122 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/notification/BiometricPromptActivity.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/notification/BiometricPromptActivity.kt deleted file mode 100644 index 5c3a9751c..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/notification/BiometricPromptActivity.kt +++ /dev/null @@ -1,244 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/notification/NotificationActionReceiver.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/notification/NotificationActionReceiver.kt deleted file mode 100644 index a348b6713..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/notification/NotificationActionReceiver.kt +++ /dev/null @@ -1,122 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/notification/NotificationHelper.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/notification/NotificationHelper.kt deleted file mode 100644 index 31cbee534..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/notification/NotificationHelper.kt +++ /dev/null @@ -1,206 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/notification/PushNotificationActivity.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/notification/PushNotificationActivity.kt deleted file mode 100644 index a28bb499b..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/notification/PushNotificationActivity.kt +++ /dev/null @@ -1,239 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/service/LocationService.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/service/LocationService.kt deleted file mode 100644 index c520ca41e..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/service/LocationService.kt +++ /dev/null @@ -1,90 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/service/PushNotificationService.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/service/PushNotificationService.kt deleted file mode 100644 index 7f75008bd..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/service/PushNotificationService.kt +++ /dev/null @@ -1,175 +0,0 @@ -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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/AboutScreen.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/AboutScreen.kt deleted file mode 100644 index adc3ddad2..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/AboutScreen.kt +++ /dev/null @@ -1,173 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/AccountDetailScreen.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/AccountDetailScreen.kt deleted file mode 100644 index 5578d7089..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/AccountDetailScreen.kt +++ /dev/null @@ -1,397 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/AccountsScreen.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/AccountsScreen.kt deleted file mode 100644 index ee5326d8a..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/AccountsScreen.kt +++ /dev/null @@ -1,511 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/AuthenticatorNavHost.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/AuthenticatorNavHost.kt deleted file mode 100644 index 25b3a337d..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/AuthenticatorNavHost.kt +++ /dev/null @@ -1,257 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/DiagnosticLogsScreen.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/DiagnosticLogsScreen.kt deleted file mode 100644 index 83e78b3f9..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/DiagnosticLogsScreen.kt +++ /dev/null @@ -1,266 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/EditAccountsScreen.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/EditAccountsScreen.kt deleted file mode 100644 index 1da2eb3ab..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/EditAccountsScreen.kt +++ /dev/null @@ -1,371 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/InitializationErrorScreen.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/InitializationErrorScreen.kt deleted file mode 100644 index 1875265d3..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/InitializationErrorScreen.kt +++ /dev/null @@ -1,336 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/LoginScreen.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/LoginScreen.kt deleted file mode 100644 index 5912749ab..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/LoginScreen.kt +++ /dev/null @@ -1,329 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/ManualEntryScreen.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/ManualEntryScreen.kt deleted file mode 100644 index 9fa479f26..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/ManualEntryScreen.kt +++ /dev/null @@ -1,288 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/NotificationResponseScreen.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/NotificationResponseScreen.kt deleted file mode 100644 index a45bb3116..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/NotificationResponseScreen.kt +++ /dev/null @@ -1,696 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/PushNotificationsScreen.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/PushNotificationsScreen.kt deleted file mode 100644 index ab11b88c5..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/PushNotificationsScreen.kt +++ /dev/null @@ -1,136 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/QrScannerScreen.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/QrScannerScreen.kt deleted file mode 100644 index cb579d1f4..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/QrScannerScreen.kt +++ /dev/null @@ -1,289 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/SettingsScreen.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/SettingsScreen.kt deleted file mode 100644 index 5a526d7a7..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/SettingsScreen.kt +++ /dev/null @@ -1,229 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/TestScreen.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/TestScreen.kt deleted file mode 100644 index 8e448b1da..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/TestScreen.kt +++ /dev/null @@ -1,970 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/AccountAvatar.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/AccountAvatar.kt deleted file mode 100644 index 24bc08e64..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/AccountAvatar.kt +++ /dev/null @@ -1,113 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/AccountGroupItem.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/AccountGroupItem.kt deleted file mode 100644 index babb1c8ec..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/AccountGroupItem.kt +++ /dev/null @@ -1,323 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/BackNavigationTopAppBar.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/BackNavigationTopAppBar.kt deleted file mode 100644 index cca7f5ee3..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/BackNavigationTopAppBar.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/CallbackRenderers.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/CallbackRenderers.kt deleted file mode 100644 index 2708303d3..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/CallbackRenderers.kt +++ /dev/null @@ -1,226 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/CircularProgressTimer.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/CircularProgressTimer.kt deleted file mode 100644 index a65a114ce..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/CircularProgressTimer.kt +++ /dev/null @@ -1,70 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/DetailRow.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/DetailRow.kt deleted file mode 100644 index d9fbce303..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/DetailRow.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/EditAccountDialog.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/EditAccountDialog.kt deleted file mode 100644 index 5db10c393..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/EditAccountDialog.kt +++ /dev/null @@ -1,132 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/EditableAccountItem.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/EditableAccountItem.kt deleted file mode 100644 index 4e7588e1f..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/EditableAccountItem.kt +++ /dev/null @@ -1,208 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/EmptyStateMessage.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/EmptyStateMessage.kt deleted file mode 100644 index a2f0154e6..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/EmptyStateMessage.kt +++ /dev/null @@ -1,59 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/ErrorAlertDialog.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/ErrorAlertDialog.kt deleted file mode 100644 index 626e34b95..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/ErrorAlertDialog.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/InfoCard.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/InfoCard.kt deleted file mode 100644 index 0298295ec..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/InfoCard.kt +++ /dev/null @@ -1,56 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/LoadingIndicator.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/LoadingIndicator.kt deleted file mode 100644 index 341ada5ec..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/LoadingIndicator.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/NotificationCard.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/NotificationCard.kt deleted file mode 100644 index b2a50a6f0..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/NotificationCard.kt +++ /dev/null @@ -1,181 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/NotificationHistoryCard.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/NotificationHistoryCard.kt deleted file mode 100644 index 6e3f897dc..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/NotificationHistoryCard.kt +++ /dev/null @@ -1,189 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/SettingItem.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/SettingItem.kt deleted file mode 100644 index ff9446c76..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/SettingItem.kt +++ /dev/null @@ -1,111 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/StatusIndicator.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/StatusIndicator.kt deleted file mode 100644 index e754877d6..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/components/StatusIndicator.kt +++ /dev/null @@ -1,70 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/theme/Color.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/theme/Color.kt deleted file mode 100644 index 7c1d4e9d4..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/theme/Color.kt +++ /dev/null @@ -1,18 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/theme/Theme.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/theme/Theme.kt deleted file mode 100644 index 05f01b9cf..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/theme/Theme.kt +++ /dev/null @@ -1,75 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/theme/Type.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/theme/Type.kt deleted file mode 100644 index f065d41e4..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/ui/theme/Type.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/util/DateUtils.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/util/DateUtils.kt deleted file mode 100644 index 0743daa19..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/util/DateUtils.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/util/NavigationAnimations.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/util/NavigationAnimations.kt deleted file mode 100644 index efc441eb9..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/util/NavigationAnimations.kt +++ /dev/null @@ -1,71 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/util/QrCodeAnalyzer.kt b/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/util/QrCodeAnalyzer.kt deleted file mode 100644 index 6b30435a2..000000000 --- a/samples/authenticatorapp/src/main/kotlin/com/pingidentity/authenticatorapp/util/QrCodeAnalyzer.kt +++ /dev/null @@ -1,75 +0,0 @@ -/* - * 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/samples/authenticatorapp/src/main/res/drawable/ic_check.xml b/samples/authenticatorapp/src/main/res/drawable/ic_check.xml deleted file mode 100644 index 117e40b7b..000000000 --- a/samples/authenticatorapp/src/main/res/drawable/ic_check.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/samples/authenticatorapp/src/main/res/drawable/ic_close.xml b/samples/authenticatorapp/src/main/res/drawable/ic_close.xml deleted file mode 100644 index 351a06ab3..000000000 --- a/samples/authenticatorapp/src/main/res/drawable/ic_close.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/samples/authenticatorapp/src/main/res/drawable/ic_fingerprint.xml b/samples/authenticatorapp/src/main/res/drawable/ic_fingerprint.xml deleted file mode 100644 index a628a03bd..000000000 --- a/samples/authenticatorapp/src/main/res/drawable/ic_fingerprint.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/samples/authenticatorapp/src/main/res/drawable/ic_launcher_foreground.xml b/samples/authenticatorapp/src/main/res/drawable/ic_launcher_foreground.xml deleted file mode 100644 index 1353083fe..000000000 --- a/samples/authenticatorapp/src/main/res/drawable/ic_launcher_foreground.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - diff --git a/samples/authenticatorapp/src/main/res/drawable/ic_notification.xml b/samples/authenticatorapp/src/main/res/drawable/ic_notification.xml deleted file mode 100644 index d91b7fa2b..000000000 --- a/samples/authenticatorapp/src/main/res/drawable/ic_notification.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/samples/authenticatorapp/src/main/res/drawable/ping_logo.xml b/samples/authenticatorapp/src/main/res/drawable/ping_logo.xml deleted file mode 100644 index a21fde540..000000000 --- a/samples/authenticatorapp/src/main/res/drawable/ping_logo.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - diff --git a/samples/authenticatorapp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/samples/authenticatorapp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index 5ed0a2df7..000000000 --- a/samples/authenticatorapp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/samples/authenticatorapp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/samples/authenticatorapp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml deleted file mode 100644 index 5ed0a2df7..000000000 --- a/samples/authenticatorapp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/samples/authenticatorapp/src/main/res/values/ic_launcher_background.xml b/samples/authenticatorapp/src/main/res/values/ic_launcher_background.xml deleted file mode 100644 index f42ada656..000000000 --- a/samples/authenticatorapp/src/main/res/values/ic_launcher_background.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - #FFFFFF - diff --git a/samples/authenticatorapp/src/main/res/values/strings.xml b/samples/authenticatorapp/src/main/res/values/strings.xml deleted file mode 100644 index 44a09f7d0..000000000 --- a/samples/authenticatorapp/src/main/res/values/strings.xml +++ /dev/null @@ -1,171 +0,0 @@ - - - Push authentication requests from Ping Identity - APush Authentication - "Authentication Request" - You have a new authentication request - Authentication request for - (Challenge verification required) - Approve - Deny - Authenticate - Notification permission granted - Notification permission denied. Push notifications will not be displayed. - Ping Authenticator - Version 1.0.0 - About this app - The Ping Authenticator app provides secure multi-factor authentication using OATH (TOTP/HOTP) and Push notification methods. This sample application demonstrates the capabilities of the Ping Identity Android SDK. - Features - • OATH Authentication (TOTP/HOTP) - • Push Notifications - • QR Code Scanning - • Account Management - • Secure Storage - © 2025 Ping Identity Corporation. All rights reserved. - About - Back - Account Details - No credentials found for this account - OATH - PUSH - Code copied to clipboard - Error - OK - Copy - New Code - Generate Code - Type - Algorithm - Digits - Period - %d seconds - Created - Platform - User ID - Ping Access Management - PingOne - Today - Yesterday - %d days ago - %d weeks ago - %d months ago - %d years ago - Authenticator - No accounts added yet - Add an account by scanning a QR code or entering details manually - Loading credentials… - Refresh - Test Mode - Menu - Notifications - Edit Accounts - Settings - About - Scan QR Code - Add Manually - Add Account - Diagnostic Logs (%d) - Authenticator App Diagnostic Logs - Share Logs - Clear Logs - No logs captured yet - Logs will appear here when diagnostic logging is enabled - Account added successfully - Add Account Manually - Issuer (e.g. Company) - Account Name (e.g. email@example.com) - Secret Key - OTP Type - Algorithm - Digits - Period (seconds) - Add Account - Unable to resolve location - Failed to load location details - Authentication Request - Unknown Issuer - Unknown Account - Please verify your identity - Authentication request - Biometric authentication - Challenge authentication - Standard authentication - macOS - Windows - Linux - Android - iOS - Loading location details… - Lat: %1$s, Lng: %2$s - Login Location - Select the number that appears on your other device: - Cancel Authentication - No challenge numbers available - Close - Deny - Approve - Verify - Login - Cancel - Try Again - MFA credential registered successfully - Authenticating… - Preparing authentication… - Unknown error - Please Wait - Registering MFA credentials… - Push Notifications - No push notifications - Pending Requests - Notification History - Location information available - Account added successfully - Scan QR Code - Invalid QR code format. Please scan a valid OATH, Push, or MFA authentication QR code. - Failed to initialize camera: %s - Position QR code within frame - Camera permission is required to scan QR codes - Request Permission - Test Mode - Test Accounts - Create OATH - Create Random OATH - Create PUSH - Create Random PUSH - Create Combined MFA - Create Random Combined - Device Token - Click on `Get Token` to retrieve the token… - Requested new device token from FCM - Renew Token - Get Token - Device token retrieved successfully - Device token renewed successfully - Notifications cleaned up successfully - Notifications - Clean up - Clean Up Old Notifications - Account Locking - Manage Account Locks - Select Account to Lock/Unlock - Lock Account - Unlock Account - Select Locking Policy - Biometric Available - Device Tampering - Custom Policy - Account locked successfully - Account unlocked successfully - No accounts available to lock - OATH - PUSH - • • • • • • - OTP Code - - - Biometrics are required but are unavailable. Please contact your account administrator for help. - The device might have been tampered with or rooted. Please contact the account administrator for help. - Account locked by the following policy: %s. Please contact the account administrator for help. - Account is locked. Please contact the account administrator for help. - Account Locked - \ No newline at end of file diff --git a/samples/authenticatorapp/src/main/res/values/themes.xml b/samples/authenticatorapp/src/main/res/values/themes.xml deleted file mode 100644 index dced681e1..000000000 --- a/samples/authenticatorapp/src/main/res/values/themes.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - \ No newline at end of file diff --git a/samples/journeyapp/src/main/res/values/colors.xml b/samples/journeyapp/src/main/res/values/colors.xml deleted file mode 100644 index 3d9221df5..000000000 --- a/samples/journeyapp/src/main/res/values/colors.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - #DB4332 - #B3282D - #DB4332 - #69747D - #505D68 - #051727 - #FFFFFFFF - \ No newline at end of file diff --git a/samples/journeyapp/src/main/res/values/strings.xml b/samples/journeyapp/src/main/res/values/strings.xml deleted file mode 100644 index eb91fca6d..000000000 --- a/samples/journeyapp/src/main/res/values/strings.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - Journey - - - {facebook client id} - fb{facebook client id} - {client token} - \ No newline at end of file diff --git a/samples/journeyapp/src/main/res/values/themes.xml b/samples/journeyapp/src/main/res/values/themes.xml deleted file mode 100644 index bd8498d7f..000000000 --- a/samples/journeyapp/src/main/res/values/themes.xml +++ /dev/null @@ -1,40 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/samples/journeyapp/src/test/java/com/pingidentity/samples/journeyapp/ExampleUnitTest.kt b/samples/journeyapp/src/test/java/com/pingidentity/samples/journeyapp/ExampleUnitTest.kt deleted file mode 100644 index dacaee75b..000000000 --- a/samples/journeyapp/src/test/java/com/pingidentity/samples/journeyapp/ExampleUnitTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright (c) 2024 - 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.samples.journeyapp - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file diff --git a/samples/pingsampleapp/README.md b/samples/pingsampleapp/README.md index 7f8e777b8..8cd1a79c2 100644 --- a/samples/pingsampleapp/README.md +++ b/samples/pingsampleapp/README.md @@ -298,3 +298,5 @@ For issues, questions, or contributions, please refer to the main Ping Android S This sample application is licensed under the MIT License. See LICENSE file for details. + +© Copyright 2025-2026 Ping Identity Corporation. All Rights Reserved diff --git a/samples/pingsampleapp/build.gradle.kts b/samples/pingsampleapp/build.gradle.kts index c4c1c0c87..1ed791fc9 100644 --- a/samples/pingsampleapp/build.gradle.kts +++ b/samples/pingsampleapp/build.gradle.kts @@ -32,6 +32,16 @@ android { ) } } + + signingConfigs { + getByName("debug") { + storeFile = file("debug.jks") + storePassword = "android" + keyAlias = "androiddebugkey" + keyPassword = "android" + } + } + compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 @@ -52,6 +62,9 @@ android { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } + jniLibs { + pickFirsts += "lib/*/libtool-file.so" + } } } @@ -85,6 +98,8 @@ dependencies { implementation(project(":mfa:fido")) implementation(project(":mfa:oath")) implementation(project(":mfa:push")) + implementation(project(":mfa:auth-migration")) + implementation(project(":foundation:migration")) //Application Pin implementation(libs.bcpkix.jdk18on) diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/PingSampleApplication.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/PingSampleApplication.kt index 5746bd77b..671728695 100644 --- a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/PingSampleApplication.kt +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/PingSampleApplication.kt @@ -21,12 +21,14 @@ import com.pingidentity.samples.pingsampleapp.authenticator.managers.JourneyMana import com.pingidentity.samples.pingsampleapp.authenticator.managers.OathManager import com.pingidentity.samples.pingsampleapp.authenticator.managers.PushManager import com.pingidentity.samples.pingsampleapp.authenticator.managers.TestAccountFactory +import com.pingidentity.samples.pingsampleapp.config.initConfigs 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 +import kotlinx.coroutines.withContext /** * Main Application class for PingSampleApp. @@ -87,9 +89,14 @@ class PingSampleApplication : Application() { // Initialize SDK clients and managers asynchronously CoroutineScope(Dispatchers.Default).launch { + withContext(Dispatchers.IO) { + // Load persisted SDK configs (Journey, DaVinci, OIDC Web) immediately so + // all flows are ready before the user visits the Configuration screen. + initConfigs() + } initializeSdkClients() initializeManagers() - initializeViewModel() + initializeAuthenticatorViewModel() } } @@ -164,7 +171,7 @@ class PingSampleApplication : Application() { /** * Initializes the AuthenticatorViewModel. */ - private fun initializeViewModel() { + private fun initializeAuthenticatorViewModel() { try { authenticatorViewModel = AuthenticatorViewModel( application = this, @@ -216,6 +223,32 @@ class PingSampleApplication : Application() { } return instance.viewModelDeferred.await() } + + /** + * Closes the existing MFA clients (clearing their in-memory credential caches and + * SQLite database connections), then re-initializes fresh instances and wires them + * back into the managers. + * + * Call this after a data migration so that stale caches are purged and the new + * database connections can see the migrated credentials. Follow up with a call to + * [AuthenticatorViewModel.refreshCredentials] to push the new data into the UI. + */ + suspend fun reinitializeMfaClients() { + with(instance) { + // Close old clients — this clears the in-memory credential caches and + // releases the SQLite database connections. + oathManager.close() + pushManager.close() + + // Create fresh clients with new database connections. + initializeSdkClients() + + // Wire the new client instances into the managers so subsequent + // operations use the freshly-opened databases. + oathManager.setClient(oathClient) + pushManager.setClient(pushClient) + } + } } } diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/authmigration/AuthMigrationScreen.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/authmigration/AuthMigrationScreen.kt new file mode 100644 index 000000000..6b98dcb46 --- /dev/null +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/authmigration/AuthMigrationScreen.kt @@ -0,0 +1,479 @@ +/* + * 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.samples.pingsampleapp.authmigration + +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.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.shape.RoundedCornerShape +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.CheckCircle +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material.icons.filled.Sync +import androidx.compose.material.icons.filled.TaskAlt +import androidx.compose.material.icons.filled.Warning +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.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.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.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +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 com.pingidentity.samples.pingsampleapp.R + +/** + * Screen that allows developers to test migration of legacy FR Authenticator credentials + * (OATH and Push) to the modern Ping SDK storage format. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AuthMigrationScreen( + viewModel: AuthMigrationViewModel, + onBack: (() -> Unit)? = null +) { + val state by viewModel.state.collectAsState() + val context = LocalContext.current + + LaunchedEffect(Unit) { + viewModel.checkMigrationNeeded(context) + } + + Scaffold( + topBar = { + if (onBack != null) { + TopAppBar( + title = { Text(stringResource(R.string.auth_migration_screen_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back) + ) + } + } + ) + } + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + InstructionsCard() + MigrationStatusCard(state = state, onStartMigration = { viewModel.startMigration(context) }) + if (state.stepResults.isNotEmpty()) { + ProgressCard(stepResults = state.stepResults) + } + state.summaryMessage?.let { message -> + ResultCard(message = message, isError = false) + } + state.errorMessage?.let { message -> + ResultCard(message = message, isError = true) + } + } + } +} + +// region Instructions Card + +@Composable +private fun InstructionsCard() { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.auth_migration_instructions_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary + ) + } + + HorizontalDivider() + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + InstructionRow(number = "1", text = stringResource(R.string.auth_migration_instruction_1)) + InstructionRow(number = "2", text = stringResource(R.string.auth_migration_instruction_2)) + InstructionRow(number = "3", text = stringResource(R.string.auth_migration_instruction_3)) + InstructionRow(number = "4", text = stringResource(R.string.auth_migration_instruction_4)) + InstructionRow(number = "5", text = stringResource(R.string.auth_migration_instruction_5)) + } + + Text( + text = stringResource(R.string.auth_migration_instructions_note), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp) + ) + } + } +} + +@Composable +private fun InstructionRow(number: String, text: String) { + Row( + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Box( + modifier = Modifier + .size(22.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center + ) { + Text( + text = number, + color = Color.White, + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + textAlign = TextAlign.Center + ) + } + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } +} + +// endregion + +// region Migration Status Card + +@Composable +private fun MigrationStatusCard( + state: AuthMigrationState, + onStartMigration: () -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Storage, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.auth_migration_legacy_data), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + } + + MigrationStatusBadge(state = state) + } + + HorizontalDivider() + + StartMigrationButton(state = state, onClick = onStartMigration) + } + } +} + +@Composable +private fun MigrationStatusBadge(state: AuthMigrationState) { + when (state.migrationStatus) { + MigrationStatus.CHECKING -> { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + CircularProgressIndicator(modifier = Modifier.size(14.dp), strokeWidth = 2.dp) + Text( + text = stringResource(R.string.auth_migration_status_checking), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + MigrationStatus.IDLE -> { + val needed = state.isMigrationNeeded + if (needed != null) { + if (needed) { + StatusChip( + text = stringResource(R.string.auth_migration_status_found), + backgroundColor = Color(0xFFFFF3E0), + textColor = Color(0xFFE65100) + ) + } else { + StatusChip( + text = stringResource(R.string.auth_migration_status_none), + backgroundColor = MaterialTheme.colorScheme.surfaceVariant, + textColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + MigrationStatus.RUNNING -> { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + CircularProgressIndicator(modifier = Modifier.size(14.dp), strokeWidth = 2.dp) + Text( + text = stringResource(R.string.auth_migration_status_migrating), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary + ) + } + } + + MigrationStatus.COMPLETED -> { + StatusChip( + text = stringResource(R.string.auth_migration_status_completed), + backgroundColor = Color(0xFFE8F5E9), + textColor = Color(0xFF2E7D32) + ) + } + + MigrationStatus.FAILED -> { + StatusChip( + text = stringResource(R.string.auth_migration_status_failed), + backgroundColor = Color(0xFFFFEBEE), + textColor = Color(0xFFC62828) + ) + } + } +} + +@Composable +private fun StatusChip(text: String, backgroundColor: Color, textColor: Color) { + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = textColor, + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(backgroundColor) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) +} + +@Composable +private fun StartMigrationButton( + state: AuthMigrationState, + onClick: () -> Unit +) { + val isDisabled = state.migrationStatus == MigrationStatus.RUNNING + || state.migrationStatus == MigrationStatus.COMPLETED + || state.migrationStatus == MigrationStatus.CHECKING + + Button( + onClick = onClick, + enabled = !isDisabled, + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.buttonColors( + disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant, + disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) { + Icon( + imageVector = Icons.Default.Sync, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.auth_migration_start_button), + fontWeight = FontWeight.SemiBold + ) + } +} + +// endregion + +// region Progress Card + +@Composable +private fun ProgressCard(stepResults: List) { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.TaskAlt, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.auth_migration_progress_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + } + + HorizontalDivider() + + stepResults.forEach { step -> + Row( + modifier = Modifier.padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + StepStatusIcon(status = step.status) + Text( + text = step.description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + } + } +} + +@Composable +private fun StepStatusIcon(status: StepStatus) { + when (status) { + StepStatus.IN_PROGRESS -> { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp + ) + } + + StepStatus.COMPLETED -> { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + tint = Color(0xFF4CAF50), + modifier = Modifier.size(20.dp) + ) + } + + StepStatus.FAILED -> { + Icon( + imageVector = Icons.Default.Error, + contentDescription = null, + tint = Color(0xFFF44336), + modifier = Modifier.size(20.dp) + ) + } + } +} + +// endregion + +// region Result Card + +@Composable +private fun ResultCard(message: String, isError: Boolean) { + val backgroundColor = if (isError) Color(0xFFFFEBEE) else Color(0xFFE8F5E9) + val iconColor = if (isError) Color(0xFFF44336) else Color(0xFF4CAF50) + val icon = if (isError) Icons.Default.Warning else Icons.Default.CheckCircle + + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = backgroundColor) + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = iconColor, + modifier = Modifier.size(24.dp) + ) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) + } + } +} + +// endregion diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/authmigration/AuthMigrationViewModel.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/authmigration/AuthMigrationViewModel.kt new file mode 100644 index 000000000..fae516817 --- /dev/null +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/authmigration/AuthMigrationViewModel.kt @@ -0,0 +1,201 @@ +/* + * 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.samples.pingsampleapp.authmigration + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.pingidentity.auth.migration.AuthMigration +import com.pingidentity.auth.migration.DefaultStorageClientProvider +import com.pingidentity.logger.Logger +import com.pingidentity.logger.STANDARD +import com.pingidentity.migration.MigrationProgress +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.UUID + +/** + * Represents the current state of the migration process. + */ +enum class MigrationStatus { + IDLE, + CHECKING, + RUNNING, + COMPLETED, + FAILED +} + +/** + * Represents the status of an individual migration step. + */ +enum class StepStatus { + IN_PROGRESS, + COMPLETED, + FAILED +} + +/** + * Represents a single migration step's result for UI display. + */ +data class StepResult( + val id: String = UUID.randomUUID().toString(), + val description: String, + val status: StepStatus +) + +/** + * State for the Auth Migration screen. + */ +data class AuthMigrationState( + val migrationStatus: MigrationStatus = MigrationStatus.IDLE, + val isMigrationNeeded: Boolean? = null, + val stepResults: List = emptyList(), + val summaryMessage: String? = null, + val errorMessage: String? = null +) + +/** + * ViewModel that manages the auth migration process. + * + * Provides functionality to check if legacy FR Authenticator data exists + * and to run the full migration pipeline with progress tracking. + */ +class AuthMigrationViewModel : ViewModel() { + + var state = MutableStateFlow(AuthMigrationState()) + private set + + /** + * Checks whether legacy FR Authenticator data exists in SharedPreferences. + */ + fun checkMigrationNeeded(context: Context) { + viewModelScope.launch { + state.update { it.copy(migrationStatus = MigrationStatus.CHECKING) } + try { + val needed = withContext(Dispatchers.IO) { + val provider = DefaultStorageClientProvider(context.applicationContext) + provider.isMigrationRequired(context.applicationContext) + } + state.update { + it.copy( + migrationStatus = MigrationStatus.IDLE, + isMigrationNeeded = needed + ) + } + } catch (_: Exception) { + state.update { + it.copy( + migrationStatus = MigrationStatus.IDLE, + isMigrationNeeded = false + ) + } + } + } + } + + /** + * Starts the migration pipeline with progress tracking. + */ + fun startMigration(context: Context) { + viewModelScope.launch { + // Reset state + state.update { + it.copy( + migrationStatus = MigrationStatus.RUNNING, + stepResults = emptyList(), + summaryMessage = null, + errorMessage = null + ) + } + + try { + AuthMigration.start(context.applicationContext) { + logger = Logger.STANDARD + progress = FlowCollector { progress -> + when (progress) { + is MigrationProgress.Started -> { + // Migration beginning - no UI update needed + } + + is MigrationProgress.InProgress -> { + state.update { currentState -> + val updatedSteps = currentState.stepResults + StepResult( + description = progress.step.description, + status = StepStatus.IN_PROGRESS + ) + currentState.copy(stepResults = updatedSteps) + } + } + + is MigrationProgress.StepCompleted -> { + state.update { currentState -> + val updatedSteps = currentState.stepResults.map { step -> + if (step.description == progress.step.description) { + step.copy(status = StepStatus.COMPLETED) + } else { + step + } + } + currentState.copy(stepResults = updatedSteps) + } + } + + is MigrationProgress.Success -> { + state.update { + it.copy( + migrationStatus = MigrationStatus.COMPLETED, + summaryMessage = progress.message + ?: "Migration completed successfully", + isMigrationNeeded = false + ) + } + } + + is MigrationProgress.Error -> { + state.update { currentState -> + val updatedSteps = currentState.stepResults.map { step -> + if (step.description == progress.step.description) { + step.copy(status = StepStatus.FAILED) + } else { + step + } + } + currentState.copy( + migrationStatus = MigrationStatus.FAILED, + stepResults = updatedSteps, + errorMessage = "Failed at \"${progress.step.description}\": ${progress.error.message}" + ) + } + } + } + } + } + + // If migration completed without explicit success/error + if (state.value.migrationStatus == MigrationStatus.RUNNING) { + state.update { + it.copy( + migrationStatus = MigrationStatus.COMPLETED, + summaryMessage = it.summaryMessage ?: "No legacy data to migrate." + ) + } + } + } catch (e: Exception) { + state.update { + it.copy( + migrationStatus = MigrationStatus.FAILED, + errorMessage = e.message ?: "Migration failed with an unknown error." + ) + } + } + } + } +} diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/config/Env.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/config/Env.kt index 0f0ce69c5..c3e2ebef7 100644 --- a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/config/Env.kt +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/config/Env.kt @@ -6,45 +6,104 @@ package com.pingidentity.samples.pingsampleapp.config +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.lazy.LazyColumn +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.CheckBoxOutlineBlank -import androidx.compose.material.icons.filled.Done +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.RadioButtonUnchecked +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults 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.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberModalBottomSheetState 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.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel -import com.pingidentity.oidc.OidcClientConfig -import com.pingidentity.samples.pingsampleapp.R import com.pingidentity.samples.pingsampleapp.theme.AppTheme +import kotlinx.coroutines.launch import java.net.URL +// --------------------------------------------------------------------------- +// Bottom sheet content discriminator +// --------------------------------------------------------------------------- + +// Blank Journey config used when opening the "Add" bottom sheet. +// Intentionally has no default values so the new entry is distinct from presets. +private val blankJourneyConfig = JourneyConfigState( + serverUrl = "", realm = "", cookie = "", clientId = "", + discoveryEndpoint = "", scopes = "", redirectUri = "", display = "" +) + +private sealed class SheetContent { + data class JourneySheet( + val config: JourneyConfigState = blankJourneyConfig, + val customIndex: Int? = null, + ) : SheetContent() + + data class DaVinciSheet( + val config: OidcConfigState = OidcConfigState(), + val customIndex: Int? = null, + ) : SheetContent() + + data class WebSheet( + val config: OidcConfigState = OidcConfigState(), + val customIndex: Int? = null, + ) : SheetContent() +} + +// --------------------------------------------------------------------------- +// Main screen +// --------------------------------------------------------------------------- + @OptIn(ExperimentalMaterial3Api::class) @Composable fun Env( - envViewModel: EnvViewModel = viewModel(), + envViewModel: EnvViewModel = viewModel(), onBack: (() -> Unit)? = null, ) { + var sheetContent by remember { mutableStateOf(null) } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() + + fun dismiss() { + scope.launch { sheetState.hide() }.invokeOnCompletion { sheetContent = null } + } + AppTheme { Scaffold( topBar = { @@ -53,162 +112,485 @@ fun Env( title = { Text("Configuration") }, navigationIcon = { IconButton(onClick = onBack) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" - ) + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } - } + }, ) } - } + }, ) { paddingValues -> Column( modifier = Modifier .fillMaxWidth() .padding(paddingValues) - .padding(8.dp) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Journey card + JourneyCard( + presets = envViewModel.journeyPresets, + customConfigs = envViewModel.customJourneyConfigs, + appliedConfig = envViewModel.appliedJourneyConfig, + onSelect = { envViewModel.selectJourneyConfig(it) }, + onEdit = { cfg, idx -> sheetContent = SheetContent.JourneySheet(cfg, idx) }, + onDelete = { envViewModel.deleteCustomJourneyConfig(it) }, + onAdd = { sheetContent = SheetContent.JourneySheet() }, + ) + + // DaVinci card + OidcCard( + title = "DaVinci", + presets = envViewModel.daVinciPresets, + customConfigs = envViewModel.customDaVinciConfigs, + appliedConfig = envViewModel.appliedDaVinciConfig, + onSelect = { envViewModel.selectDaVinciConfig(it) }, + onEdit = { cfg, idx -> sheetContent = SheetContent.DaVinciSheet(cfg, idx) }, + onDelete = { envViewModel.deleteCustomDaVinciConfig(it) }, + onAdd = { sheetContent = SheetContent.DaVinciSheet() }, + ) + + // OIDC (Web) card + OidcCard( + title = "OIDC (Web)", + presets = envViewModel.webPresets, + customConfigs = envViewModel.customWebConfigs, + appliedConfig = envViewModel.appliedWebConfig, + onSelect = { envViewModel.selectWebConfig(it) }, + onEdit = { cfg, idx -> sheetContent = SheetContent.WebSheet(cfg, idx) }, + onDelete = { envViewModel.deleteCustomWebConfig(it) }, + onAdd = { sheetContent = SheetContent.WebSheet() }, + ) + + Spacer(Modifier.height(8.dp)) + } + } + + // Bottom sheet (outside Scaffold to avoid inset conflicts) + if (sheetContent != null) { + ModalBottomSheet( + onDismissRequest = { sheetContent = null }, + sheetState = sheetState, + ) { + when (val content = sheetContent) { + is SheetContent.JourneySheet -> JourneySheetContent( + initial = content.config, + isEdit = content.customIndex != null, + onSave = { cfg -> + envViewModel.saveCustomJourneyConfig(cfg, content.customIndex) + dismiss() + }, + onDismiss = ::dismiss, + ) + is SheetContent.DaVinciSheet -> OidcSheetContent( + title = "DaVinci Config", + initial = content.config, + isEdit = content.customIndex != null, + showArcValue = true, + onSave = { cfg -> + envViewModel.saveCustomDaVinciConfig(cfg, content.customIndex) + dismiss() + }, + onDismiss = ::dismiss, + ) + is SheetContent.WebSheet -> OidcSheetContent( + title = "OIDC (Web) Config", + initial = content.config, + isEdit = content.customIndex != null, + onSave = { cfg -> + envViewModel.saveCustomWebConfig(cfg, content.customIndex) + dismiss() + }, + onDismiss = ::dismiss, + ) + null -> Unit + } + } + } + } +} + +// --------------------------------------------------------------------------- +// Journey card +// --------------------------------------------------------------------------- + +@Composable +private fun JourneyCard( + presets: List, + customConfigs: List, + appliedConfig: JourneyConfigState?, + onSelect: (JourneyConfigState) -> Unit, + onEdit: (JourneyConfigState, Int) -> Unit, + onDelete: (Int) -> Unit, + onAdd: () -> Unit, +) { + ConfigCard(title = "Journey", appliedDisplay = appliedConfig?.display, onAdd = onAdd) { + if (presets.isNotEmpty()) { + SectionLabel("Presets") + presets.forEach { config -> + ConfigRow( + display = config.display, + subtitle = "${extractHost(config.discoveryEndpoint)} · ${config.clientId}", + isApplied = appliedConfig == config, + isPreset = true, + onSelect = { onSelect(config) }, + onEdit = null, + onDelete = null, + ) + } + } + if (customConfigs.isNotEmpty()) { + if (presets.isNotEmpty()) HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + SectionLabel("Custom") + customConfigs.forEachIndexed { index, config -> + ConfigRow( + display = config.display, + subtitle = "${extractHost(config.discoveryEndpoint)} · ${config.clientId}", + isApplied = appliedConfig == config, + isPreset = false, + onSelect = { onSelect(config) }, + onEdit = { onEdit(config, index) }, + onDelete = { onDelete(index) }, + ) + } + } + } +} + +// --------------------------------------------------------------------------- +// Generic OIDC card (DaVinci / Web) +// --------------------------------------------------------------------------- + +@Composable +private fun OidcCard( + title: String, + presets: List, + customConfigs: List, + appliedConfig: OidcConfigState?, + onSelect: (OidcConfigState) -> Unit, + onEdit: (OidcConfigState, Int) -> Unit, + onDelete: (Int) -> Unit, + onAdd: () -> Unit, +) { + ConfigCard(title = title, appliedDisplay = appliedConfig?.display, onAdd = onAdd) { + if (presets.isNotEmpty()) { + SectionLabel("Presets") + presets.forEach { config -> + ConfigRow( + display = config.display, + subtitle = "${extractHost(config.discoveryEndpoint)} · ${config.clientId}", + isApplied = appliedConfig == config, + isPreset = true, + onSelect = { onSelect(config) }, + onEdit = null, + onDelete = null, + ) + } + } + if (customConfigs.isNotEmpty()) { + if (presets.isNotEmpty()) HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + SectionLabel("Custom") + customConfigs.forEachIndexed { index, config -> + ConfigRow( + display = config.display, + subtitle = "${extractHost(config.discoveryEndpoint)} · ${config.clientId}", + isApplied = appliedConfig == config, + isPreset = false, + onSelect = { onSelect(config) }, + onEdit = { onEdit(config, index) }, + onDelete = { onDelete(index) }, + ) + } + } + } +} + +// --------------------------------------------------------------------------- +// Shared card shell +// --------------------------------------------------------------------------- + +@Composable +private fun ConfigCard( + title: String, + appliedDisplay: String?, + onAdd: () -> Unit, + content: @Composable () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column(modifier = Modifier.padding(16.dp)) { + // Header: title, applied badge, add button + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, ) { Text( - text = stringResource(R.string.text_configuration_selected_environment), - style = MaterialTheme.typography.titleMedium, + text = title, + style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, - modifier = Modifier.padding(8.dp), - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f), ) - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - ) { - // Journey Configurations - val journeyConfigs = envViewModel.oidcConfigs.filter { - it.display?.contains("Journey", ignoreCase = true) == true || - it.display?.contains("Forgerock", ignoreCase = true) == true || - it.display?.contains("Localhost", ignoreCase = true) == true - } - if (journeyConfigs.isNotEmpty()) { - item { - ConfigurationSectionHeader("Journey") - } - journeyConfigs.forEach { config -> - item { - ServerSetting( - option = config, - envViewModel.current.display == config.display - ) { - envViewModel.select(it) - } - } - } - } - - // DaVinci Configurations - val daVinciConfigs = envViewModel.oidcConfigs.filter { - it.display?.contains("DaVinci", ignoreCase = true) == true || - it.display?.contains("Social", ignoreCase = true) == true - } - if (daVinciConfigs.isNotEmpty()) { - item { - ConfigurationSectionHeader("DaVinci") - } - daVinciConfigs.forEach { config -> - item { - ServerSetting( - option = config, - envViewModel.current.display == config.display - ) { - envViewModel.select(it) - } - } - } - } - - // OIDC (Web) Configurations - val oidcConfigs = envViewModel.oidcConfigs.filter { - it.display?.contains("OIDC", ignoreCase = true) == true || - it.display?.contains("PingOne", ignoreCase = true) == true - } - if (oidcConfigs.isNotEmpty()) { - item { - ConfigurationSectionHeader("OIDC (Web)") - } - oidcConfigs.forEach { config -> - item { - ServerSetting( - option = config, - envViewModel.current.display == config.display - ) { - envViewModel.select(it) - } - } - } - } + if (appliedDisplay != null) { + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(4.dp)) + Text( + text = appliedDisplay, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(8.dp)) + } + IconButton(onClick = onAdd) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = "Add config", + tint = MaterialTheme.colorScheme.primary, + ) } } + HorizontalDivider() + Spacer(Modifier.height(4.dp)) + content() } + } +} + +// --------------------------------------------------------------------------- +// Single config row +// --------------------------------------------------------------------------- +@Composable +private fun ConfigRow( + display: String, + subtitle: String, + isApplied: Boolean, + isPreset: Boolean, + onSelect: () -> Unit, + onEdit: (() -> Unit)?, + onDelete: (() -> Unit)?, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier + .weight(1f) + .wrapContentHeight(), + ) { + Text( + text = display, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (!isPreset) { + if (onEdit != null) { + IconButton(onClick = onEdit) { + Icon( + imageVector = Icons.Filled.Edit, + contentDescription = "Edit", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (onDelete != null) { + IconButton(onClick = onDelete) { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = "Delete", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.error, + ) + } + } + } + IconButton(onClick = onSelect) { + Icon( + imageVector = if (isApplied) Icons.Filled.CheckCircle else Icons.Filled.RadioButtonUnchecked, + contentDescription = if (isApplied) "Applied" else "Select", + tint = if (isApplied) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } + HorizontalDivider(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)) } @Composable -private fun ConfigurationSectionHeader(title: String) { +private fun SectionLabel(text: String) { Text( - text = title, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, + text = text.uppercase(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp, bottom = 2.dp), + ) +} + +// --------------------------------------------------------------------------- +// Bottom sheet: Journey +// --------------------------------------------------------------------------- + +@Composable +private fun JourneySheetContent( + initial: JourneyConfigState, + isEdit: Boolean, + onSave: (JourneyConfigState) -> Unit, + onDismiss: () -> Unit, +) { + var cfg by remember { mutableStateOf(initial) } + val canSave = + cfg.serverUrl.isNotBlank() && + cfg.realm.isNotBlank() && + cfg.clientId.isNotBlank() && + cfg.discoveryEndpoint.isNotBlank() && + cfg.redirectUri.isNotBlank() && + cfg.display.isNotBlank() + + Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 12.dp) - ) + .navigationBarsPadding() + .imePadding() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = if (isEdit) "Edit Journey Config" else "Add Journey Config", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + ConfigField("Server URL", cfg.serverUrl) { cfg = cfg.copy(serverUrl = it) } + ConfigField("Realm", cfg.realm) { cfg = cfg.copy(realm = it) } + ConfigField("Cookie", cfg.cookie) { cfg = cfg.copy(cookie = it) } + ConfigField("Client ID", cfg.clientId) { cfg = cfg.copy(clientId = it) } + ConfigField("Discovery Endpoint", cfg.discoveryEndpoint) { cfg = cfg.copy(discoveryEndpoint = it) } + ConfigField("Scopes (comma-separated)", cfg.scopes) { cfg = cfg.copy(scopes = it) } + ConfigField("Redirect URI", cfg.redirectUri) { cfg = cfg.copy(redirectUri = it) } + ConfigField("Display Name", cfg.display) { cfg = cfg.copy(display = it) } + SheetActions(onDismiss = onDismiss, onSave = { onSave(cfg) }, canSave = canSave) + Spacer(Modifier.height(8.dp)) + } } +// --------------------------------------------------------------------------- +// Bottom sheet: DaVinci / OIDC Web +// --------------------------------------------------------------------------- + @Composable -private fun ServerSetting( - option: OidcClientConfig, - selected: Boolean = false, - onServerSelected: (OidcClientConfig) -> Unit +private fun OidcSheetContent( + title: String, + initial: OidcConfigState, + isEdit: Boolean, + showArcValue: Boolean = false, + onSave: (OidcConfigState) -> Unit, + onDismiss: () -> Unit, ) { - Column { - val host = URL(option.discoveryEndpoint).host - Row(modifier = Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) { - Text( - text = "${option.display}\n$host\n${option.clientId}", - modifier = Modifier - .weight(1f) - .wrapContentHeight(), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface - ) - Spacer(Modifier.width(8.dp)) - SelectServerButton(option, selected, onServerSelected) + var cfg by remember { mutableStateOf(initial) } + val canSave = + cfg.clientId.isNotBlank() && + cfg.discoveryEndpoint.isNotBlank() && + cfg.redirectUri.isNotBlank() && + cfg.display.isNotBlank() + + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .imePadding() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = if (isEdit) "Edit $title" else "Add $title", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + ConfigField("Client ID", cfg.clientId) { cfg = cfg.copy(clientId = it) } + ConfigField("Discovery Endpoint", cfg.discoveryEndpoint) { cfg = cfg.copy(discoveryEndpoint = it) } + ConfigField("Scopes (comma-separated)", cfg.scopes) { cfg = cfg.copy(scopes = it) } + ConfigField("Redirect URI", cfg.redirectUri) { cfg = cfg.copy(redirectUri = it) } + ConfigField("Display Name", cfg.display) { cfg = cfg.copy(display = it) } + if (showArcValue) { + ConfigField("ACR Value", cfg.arcValue) { cfg = cfg.copy(arcValue = it) } } - HorizontalDivider(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f)) + SheetActions(onDismiss = onDismiss, onSave = { onSave(cfg) }, canSave = canSave) + Spacer(Modifier.height(8.dp)) } } +// --------------------------------------------------------------------------- +// Shared bottom sheet Save / Cancel row +// --------------------------------------------------------------------------- + @Composable -private fun SelectServerButton( - option: OidcClientConfig, - selected: Boolean, - onServerSelected: (OidcClientConfig) -> Unit +private fun SheetActions( + onDismiss: () -> Unit, + onSave: () -> Unit, + canSave: Boolean, ) { - val icon = if (selected) Icons.Filled.Done else Icons.Filled.CheckBoxOutlineBlank - IconButton( - onClick = { onServerSelected(option) }) { - Icon( - icon, - contentDescription = option.display, - tint = MaterialTheme.colorScheme.onSurface - ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedButton(onClick = onDismiss, modifier = Modifier.weight(1f)) { + Text("Cancel") + } + Button(onClick = onSave, modifier = Modifier.weight(1f), enabled = canSave) { + Text("Save & Apply") + } } } +// --------------------------------------------------------------------------- +// Reusable single-line text field +// --------------------------------------------------------------------------- + +@Composable +private fun ConfigField( + label: String, + value: String, + onValueChange: (String) -> Unit, +) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(label) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + textStyle = MaterialTheme.typography.bodyMedium, + ) +} + +// --------------------------------------------------------------------------- +// Helper +// --------------------------------------------------------------------------- + +private fun extractHost(url: String): String = + runCatching { URL(url).host }.getOrDefault(url) + @Preview @Composable fun PreviewEnv() { - Env() { - - } + Env() } \ No newline at end of file diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/config/EnvViewModel.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/config/EnvViewModel.kt index b1723228c..ddfad8688 100644 --- a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/config/EnvViewModel.kt +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/config/EnvViewModel.kt @@ -16,6 +16,7 @@ import androidx.core.net.toUri import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.pingidentity.android.ContextProvider import com.pingidentity.davinci.DaVinci import com.pingidentity.davinci.plugin.DaVinci @@ -23,346 +24,572 @@ import com.pingidentity.journey.Journey import com.pingidentity.logger.Logger import com.pingidentity.logger.STANDARD import com.pingidentity.oidc.OidcClient -import com.pingidentity.oidc.OidcClientConfig import com.pingidentity.oidc.OidcWebClient import com.pingidentity.oidc.module.Web -import com.pingidentity.orchestrate.Workflow import com.pingidentity.samples.pingsampleapp.settingDataStore -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject import com.pingidentity.davinci.module.Oidc as DaVinciOidc -import com.pingidentity.davinci.user as daVinciUser import com.pingidentity.journey.module.Oidc as JourneyOidc -import com.pingidentity.journey.user as journeyUser -val testDaVinci by lazy { - DaVinci { - timeout = 30.seconds.inWholeMilliseconds - logger = Logger.STANDARD - module(DaVinciOidc) { - clientId = "dummy" - discoveryEndpoint = - "https://auth.test-one-pingone.com/dummy/as/.well-known/openid-configuration" - scopes = mutableSetOf("openid", "email", "address") - redirectUri = "org.forgerock.demo://oauth2redirect" - display = "DaVinci Test Config" - storage { - fileName = "daVinci" - } - } +// --------------------------------------------------------------------------- +// Config state data classes +// --------------------------------------------------------------------------- + +data class JourneyConfigState( + val serverUrl: String = "https://www.example.com/am", + val realm: String = "alpha", + val cookie: String = "", + val clientId: String = "dummy", + val discoveryEndpoint: String = "https://www.example.com/am/oauth2/alpha/.well-known/openid-configuration", + val scopes: String = "openid,email,address,profile,phone", + val redirectUri: String = "org.forgerock.demo:/oauth2redirect", + val display: String = "Journey Test Config", +) + +data class OidcConfigState( + val clientId: String = "", + val discoveryEndpoint: String = "", + val scopes: String = "", + val redirectUri: String = "", + val display: String = "", + val arcValue: String = "" +) + +// --------------------------------------------------------------------------- +// Default preset configs (used as fallbacks when no saved config exists) +// --------------------------------------------------------------------------- + +internal val defaultJourneyConfig = JourneyConfigState() + +internal val defaultDaVinciConfig = OidcConfigState( + clientId = "dummy", + discoveryEndpoint = "https://auth.pingone.ca/dummy/as/.well-known/openid-configuration", + scopes = "openid,email,address,phone,profile", + redirectUri = "org.forgerock.demo://oauth2redirect", + arcValue = "123", + display = "DaVinci Test Config", +) + +internal val defaultWebConfig = OidcConfigState( + clientId = "dummy", + discoveryEndpoint = "https://www.example.com/am/oauth2/alpha/.well-known/openid-configuration", + scopes = "openid,email,address,profile,phone", + redirectUri = "org.forgerock.demo:/oauth2redirect", + display = "OIDC Forgeblock", +) + +// --------------------------------------------------------------------------- +// Global SDK instance holders (consumed by other ViewModels) +// --------------------------------------------------------------------------- + +var journey: Journey = Journey { + logger = Logger.STANDARD + serverUrl = defaultJourneyConfig.serverUrl + realm = defaultJourneyConfig.realm + cookie = defaultJourneyConfig.cookie + module(JourneyOidc) { + clientId = defaultJourneyConfig.clientId + discoveryEndpoint = defaultJourneyConfig.discoveryEndpoint + scopes = defaultJourneyConfig.scopes.toScopeSet() + redirectUri = defaultJourneyConfig.redirectUri + display = defaultJourneyConfig.display + storage { fileName = "journey" } } } +var oidcClient: OidcClient? = null +var daVinci: DaVinci? = null +var web: OidcWebClient? = null +/** Used by Journey's IdP (social identity provider) callback. Set only by [buildJourney]. */ +lateinit var redirectUri: Uri +/** Used by DaVinci's Social Login button. Set only by [buildDaVinci]. Never overwritten by Journey. */ +lateinit var daVinciRedirectUri: Uri -val journeyTest by lazy { - Journey { - logger = Logger.STANDARD +// --------------------------------------------------------------------------- +// Package-level SDK builders (called both at app startup and from ViewModel) +// --------------------------------------------------------------------------- - serverUrl = "https://openam-sdks.forgeblocks.com/am" - realm = "alpha" - cookie = "5421aeddf91aa20" - // Oidc as module - module(JourneyOidc) { - clientId = "AndroidTest" - discoveryEndpoint = - "https://openam-sdks.forgeblocks.com/am/oauth2/alpha/.well-known/openid-configuration" - scopes = mutableSetOf("openid", "email", "address", "profile", "phone") - redirectUri = "org.forgerock.demo:/oauth2redirect" - display = "Journey Test Config" - storage { - fileName = "journey" - } - } - } -} - -val localhost by lazy { - Journey { +internal fun buildJourney(config: JourneyConfigState) { + journey = Journey { logger = Logger.STANDARD - - serverUrl = "http://192.168.86.32:8080/openam" - realm = "root" - // Oidc as module + serverUrl = config.serverUrl + realm = config.realm + if (config.cookie.isNotBlank()) cookie = config.cookie module(JourneyOidc) { - clientId = "AndroidTest2" - discoveryEndpoint = - "http://192.168.86.32:8080/openam/oauth2/.well-known/openid-configuration" - scopes = mutableSetOf("openid", "email", "address", "profile", "phone") - redirectUri = "org.forgerock.demo:/oauth2redirect" - display = "Localhost" - storage { - fileName = "journey" - } + clientId = config.clientId + discoveryEndpoint = config.discoveryEndpoint + scopes = config.scopes.toScopeSet() + redirectUri = config.redirectUri + display = config.display + storage { fileName = "journey" } } } -} - -val prodDaVinci by lazy { - DaVinci { - logger = Logger.STANDARD - module(DaVinciOidc) { - clientId = "dummy" - discoveryEndpoint = - "https://auth.pingone.ca/dummy/as/.well-known/openid-configuration" - scopes = mutableSetOf("openid", "email", "address", "phone", "profile") - redirectUri = "org.forgerock.demo://oauth2redirect" - display = "DaVinci Prod Config" - storage { - fileName = "daVinci" - } - } + oidcClient = OidcClient { + clientId = config.clientId + discoveryEndpoint = config.discoveryEndpoint + scopes = config.scopes.toScopeSet() + redirectUri = config.redirectUri + display = config.display } + redirectUri = config.redirectUri.toUri() } -val social by lazy { - DaVinci { +internal fun buildDaVinci(config: OidcConfigState) { + daVinci = DaVinci { logger = Logger.STANDARD module(DaVinciOidc) { - clientId = "dummy" - discoveryEndpoint = - "https://auth.pingone.com/dummy/as/.well-known/openid-configuration" - scopes = mutableSetOf("openid", "email", "address") - redirectUri = "com.pingidentity.demo://oauth2redirect" - display = "Social Config" - storage { - fileName = "daVinci" - } + clientId = config.clientId + discoveryEndpoint = config.discoveryEndpoint + scopes = config.scopes.toScopeSet() + redirectUri = config.redirectUri + display = config.display + if (config.arcValue.isNotBlank()) acrValues = config.arcValue + storage { fileName = "daVinci" } } } + // Store in the DaVinci-specific global so it never overwrites Journey's redirectUri + daVinciRedirectUri = config.redirectUri.toUri() } -// Standalone OIDC configurations (not tied to Journey or DaVinci) -val oidcForgeBlock by lazy { - OidcWebClient { +internal fun buildWeb(config: OidcConfigState) { + web = OidcWebClient { logger = Logger.STANDARD module(com.pingidentity.oidc.module.Oidc) { - clientId = "AndroidTest" - discoveryEndpoint = "https://openam-sdks.forgeblocks.com/am/oauth2/alpha/.well-known/openid-configuration" - scopes = mutableSetOf("openid", "email", "address", "profile", "phone") - redirectUri = "org.forgerock.demo:/oauth2redirect" - display = "OIDC Forgeblock" + clientId = config.clientId + discoveryEndpoint = config.discoveryEndpoint + scopes = config.scopes.toScopeSet() + redirectUri = config.redirectUri + display = config.display } module(Web) { - customTabsCustomizer = { - setColorScheme(CustomTabsIntent.COLOR_SCHEME_DARK) - } - authTabCustomizer = { - setColorScheme(CustomTabsIntent.COLOR_SCHEME_DARK) - } + customTabsCustomizer = { setColorScheme(CustomTabsIntent.COLOR_SCHEME_DARK) } + authTabCustomizer = { setColorScheme(CustomTabsIntent.COLOR_SCHEME_DARK) } } } } -val oidcPingOne by lazy { - OidcWebClient { - logger = Logger.STANDARD - module(com.pingidentity.oidc.module.Oidc) { - clientId = "dummy" - discoveryEndpoint = - "https://auth.test-one-pingone.com/dummy/as/.well-known/openid-configuration" - scopes = mutableSetOf("openid", "email", "address") - redirectUri = "org.forgerock.demo://oauth2redirect" - display = "OIDC PingOne" - } - module(Web) { - customTabsCustomizer = { - setColorScheme(CustomTabsIntent.COLOR_SCHEME_DARK) - } - authTabCustomizer = { - setColorScheme(CustomTabsIntent.COLOR_SCHEME_DARK) - } - } - } -} +// --------------------------------------------------------------------------- +// App-startup initializer — call from Application.onCreate() +// --------------------------------------------------------------------------- -var journey = journeyTest -var oidcClient: OidcClient? = null -var daVinci: DaVinci? = null -var web: OidcWebClient? = null -lateinit var redirectUri: Uri //For Social Login redirect parameter using Auth Tab +/** + * Loads the last-saved configs from DataStore and applies them to the global + * SDK instances so all flows are ready immediately when the app starts, + * before the user ever visits the Configuration screen. + */ +suspend fun initConfigs() { + val prefs = ContextProvider.context.settingDataStore.data.first() + + val jConfig = prefs[stringPreferencesKey("j_clientId")]?.let { clientId -> + JourneyConfigState( + serverUrl = prefs[stringPreferencesKey("j_serverUrl")] ?: defaultJourneyConfig.serverUrl, + realm = prefs[stringPreferencesKey("j_realm")] ?: defaultJourneyConfig.realm, + cookie = prefs[stringPreferencesKey("j_cookie")] ?: defaultJourneyConfig.cookie, + clientId = clientId, + discoveryEndpoint = prefs[stringPreferencesKey("j_discoveryEndpoint")] ?: "", + scopes = prefs[stringPreferencesKey("j_scopes")] ?: "", + redirectUri = prefs[stringPreferencesKey("j_redirectUri")] ?: "", + display = prefs[stringPreferencesKey("j_display")] ?: "", + ) + } ?: defaultJourneyConfig + + val dvConfig = prefs[stringPreferencesKey("dv_clientId")]?.let { clientId -> + OidcConfigState( + clientId = clientId, + discoveryEndpoint = prefs[stringPreferencesKey("dv_discoveryEndpoint")] ?: "", + scopes = prefs[stringPreferencesKey("dv_scopes")] ?: "", + redirectUri = prefs[stringPreferencesKey("dv_redirectUri")] ?: "", + display = prefs[stringPreferencesKey("dv_display")] ?: "", + arcValue = prefs[stringPreferencesKey("dv_arcValue")] ?: "", + ) + } ?: defaultDaVinciConfig + + val wConfig = prefs[stringPreferencesKey("w_clientId")]?.let { clientId -> + OidcConfigState( + clientId = clientId, + discoveryEndpoint = prefs[stringPreferencesKey("w_discoveryEndpoint")] ?: "", + scopes = prefs[stringPreferencesKey("w_scopes")] ?: "", + redirectUri = prefs[stringPreferencesKey("w_redirectUri")] ?: "", + display = prefs[stringPreferencesKey("w_display")] ?: "", + ) + } ?: defaultWebConfig + + // Each builder writes to its own global; no ordering dependency. + buildJourney(jConfig) + buildWeb(wConfig) + buildDaVinci(dvConfig) +} +// --------------------------------------------------------------------------- +// ViewModel +// --------------------------------------------------------------------------- class EnvViewModel : ViewModel() { - private val journeyServers = listOf(journeyTest, localhost) - private val daVinciServers = listOf(testDaVinci, prodDaVinci, social) - private val oidcServers = listOf(oidcForgeBlock, oidcPingOne) - - val oidcConfigs = listOf( - journeyTest.oidcConfig(), - localhost.oidcConfig(), - testDaVinci.oidcConfig(), - prodDaVinci.oidcConfig(), - social.oidcConfig(), - oidcForgeBlock.oidcConfig(), - oidcPingOne.oidcConfig(), + + // -- Preset lists (read-only, built-in) ---------------------------------- + + val journeyPresets = listOf( + defaultJourneyConfig, + JourneyConfigState( + serverUrl = "http://192.168.86.32:8080/openam", + realm = "root", + cookie = "", + clientId = "AndroidTest2", + discoveryEndpoint = "http://192.168.86.32:8080/openam/oauth2/.well-known/openid-configuration", + scopes = "openid,email,address,profile,phone", + redirectUri = "org.forgerock.demo:/oauth2redirect", + display = "Localhost", + ), ) - var current by mutableStateOf(journeyTest.oidcConfig()) + val daVinciPresets = listOf( + defaultDaVinciConfig, + OidcConfigState( + clientId = "dummy", + discoveryEndpoint = "https://auth.pingone.ca/dummy/as/.well-known/openid-configuration", + scopes = "openid,email,address,phone,profile", + redirectUri = "org.forgerock.demo://oauth2redirect", + display = "DaVinci Prod Config", + ), + OidcConfigState( + clientId = "dummy", + discoveryEndpoint = "https://auth.pingone.com/dummy/as/.well-known/openid-configuration", + scopes = "openid,email,address", + redirectUri = "com.pingidentity.demo://oauth2redirect", + display = "Social Config", + ), + ) + + val webPresets = listOf( + defaultWebConfig, + OidcConfigState( + clientId = "dummy", + discoveryEndpoint = "https://auth.test-one-pingone.com/dummy/as/.well-known/openid-configuration", + scopes = "openid,email,address", + redirectUri = "org.forgerock.demo://oauth2redirect", + display = "OIDC PingOne", + ), + ) + + // -- Currently applied configs ------------------------------------------- + + var appliedJourneyConfig by mutableStateOf(null) + private set + + var appliedDaVinciConfig by mutableStateOf(null) + private set + + var appliedWebConfig by mutableStateOf(null) + private set + + // -- User-defined custom configs ----------------------------------------- + + var customJourneyConfigs by mutableStateOf>(emptyList()) + private set + + var customDaVinciConfigs by mutableStateOf>(emptyList()) + private set + + var customWebConfigs by mutableStateOf>(emptyList()) private set + // -- Init ---------------------------------------------------------------- + init { - CoroutineScope(Dispatchers.IO).launch { - current = readConfigFromDataStore() - select(current) + viewModelScope.launch(Dispatchers.IO) { + val jApplied = loadAppliedJourneyFromDataStore() + val dvApplied = loadAppliedDaVinciFromDataStore() + val wApplied = loadAppliedWebFromDataStore() + val jCustom = loadCustomJourneyFromDataStore() + val dvCustom = loadCustomDaVinciFromDataStore() + val wCustom = loadCustomWebFromDataStore() + + withContext(Dispatchers.Main) { + customJourneyConfigs = jCustom + customDaVinciConfigs = dvCustom + customWebConfigs = wCustom + appliedJourneyConfig = jApplied + appliedDaVinciConfig = dvApplied + appliedWebConfig = wApplied + buildJourneyInstance(jApplied) + buildDaVinciInstance(dvApplied) + buildWebInstance(wApplied) + } } } + // -- Select (apply a preset or custom config) ---------------------------- - /** - * Creates an OidcWebClient instance with the provided OIDC configuration. - * This is used to initialize centralized OIDC login for all authentication types. - */ - private fun createOidcWeb(oidcConfig: OidcClientConfig): OidcWebClient { - return OidcWebClient { - logger = Logger.STANDARD - module(com.pingidentity.oidc.module.Oidc) { - clientId = oidcConfig.clientId - discoveryEndpoint = oidcConfig.discoveryEndpoint - scopes = oidcConfig.scopes - redirectUri = oidcConfig.redirectUri - signOutRedirectUri = oidcConfig.signOutRedirectUri - loginHint = oidcConfig.loginHint - state = oidcConfig.state - nonce = oidcConfig.nonce - acrValues = oidcConfig.acrValues - prompt = oidcConfig.prompt - display = oidcConfig.display - uiLocales = oidcConfig.uiLocales - additionalParameters = oidcConfig.additionalParameters - } - module(Web) { - customTabsCustomizer = { - setColorScheme(CustomTabsIntent.COLOR_SCHEME_DARK) - } - authTabCustomizer = { - setColorScheme(CustomTabsIntent.COLOR_SCHEME_DARK) - } - } + fun selectJourneyConfig(config: JourneyConfigState) { + buildJourneyInstance(config) + appliedJourneyConfig = config + viewModelScope.launch(Dispatchers.IO) { persistAppliedJourney(config) } + } + + fun selectDaVinciConfig(config: OidcConfigState) { + buildDaVinciInstance(config) + appliedDaVinciConfig = config + viewModelScope.launch(Dispatchers.IO) { persistAppliedDaVinci(config) } + } + + fun selectWebConfig(config: OidcConfigState) { + buildWebInstance(config) + appliedWebConfig = config + viewModelScope.launch(Dispatchers.IO) { persistAppliedWeb(config) } + } + + // -- Custom config CRUD -------------------------------------------------- + + fun saveCustomJourneyConfig(config: JourneyConfigState, editIndex: Int?) { + customJourneyConfigs = if (editIndex == null) { + customJourneyConfigs + config + } else { + customJourneyConfigs.toMutableList().also { it[editIndex] = config } } + selectJourneyConfig(config) + viewModelScope.launch(Dispatchers.IO) { persistCustomJourneyConfigs(customJourneyConfigs) } } - fun select(config: OidcClientConfig) { - // Determine if this is a Journey, DaVinci, or standalone OIDC server - val journeyServer = journeyServers.firstOrNull { it.oidcConfig().display == config.display } - val daVinciServer = daVinciServers.firstOrNull { it.oidcConfig().display == config.display } - val oidcServer = oidcServers.firstOrNull { it.oidcConfig().display == config.display } - - when { - journeyServer != null -> { - journey = journeyServer - val oidcConfig = journeyServer.oidcConfig() - redirectUri = oidcConfig.redirectUri.toUri() - - oidcClient = OidcClient { - clientId = oidcConfig.clientId - discoveryEndpoint = oidcConfig.discoveryEndpoint - scopes = oidcConfig.scopes - redirectUri = oidcConfig.redirectUri - display = oidcConfig.display ?: "Journey" - } - - // Only logout if switching to a different Journey config - if (current.clientId != config.clientId && journeyServers.any { it.oidcConfig().clientId == current.clientId }) { - CoroutineScope(Dispatchers.Default).launch { - journey.journeyUser()?.logout() - } - } - } - daVinciServer != null -> { - daVinci = daVinciServer - val oidcConfig = daVinciServer.oidcConfig() - redirectUri = oidcConfig.redirectUri.toUri() - - // Only logout if switching to a different DaVinci config - if (current.clientId != config.clientId && daVinciServers.any { it.oidcConfig().clientId == current.clientId }) { - CoroutineScope(Dispatchers.Default).launch { - daVinci?.daVinciUser()?.logout() - } - } - } - oidcServer != null -> { - // Initialize standalone OIDC Web - web = createOidcWeb(oidcServer.oidcConfig()) - val oidcConfig = oidcServer.oidcConfig() - redirectUri = oidcConfig.redirectUri.toUri() - - // Only logout if switching to a different OIDC config - if (current.clientId != config.clientId && oidcServers.any { it.oidcConfig().clientId == current.clientId }) { - CoroutineScope(Dispatchers.Default).launch { - web?.user()?.logout() - } - } - } - else -> { - // Default to forgeblock - journey = journeyTest - val oidcConfig = journeyTest.oidcConfig() - redirectUri = oidcConfig.redirectUri.toUri() - - oidcClient = OidcClient { - clientId = oidcConfig.clientId - discoveryEndpoint = oidcConfig.discoveryEndpoint - scopes = oidcConfig.scopes - redirectUri = oidcConfig.redirectUri - display = oidcConfig.display ?: "Forgerock" - } - } + fun deleteCustomJourneyConfig(index: Int) { + val deleted = customJourneyConfigs[index] + customJourneyConfigs = customJourneyConfigs.toMutableList().also { it.removeAt(index) } + // If the deleted config was active, fall back to the first preset + if (appliedJourneyConfig?.display == deleted.display) { + selectJourneyConfig(journeyPresets[0]) } + viewModelScope.launch(Dispatchers.IO) { persistCustomJourneyConfigs(customJourneyConfigs) } + } - current = config + fun saveCustomDaVinciConfig(config: OidcConfigState, editIndex: Int?) { + customDaVinciConfigs = if (editIndex == null) { + customDaVinciConfigs + config + } else { + customDaVinciConfigs.toMutableList().also { it[editIndex] = config } + } + selectDaVinciConfig(config) + viewModelScope.launch(Dispatchers.IO) { persistCustomDaVinciConfigs(customDaVinciConfigs) } + } - CoroutineScope(Dispatchers.IO).launch { - ContextProvider.context.settingDataStore.edit { preferences -> - preferences[stringPreferencesKey("clientId")] = config.clientId - preferences[stringPreferencesKey("discoveryEndpoint")] = config.discoveryEndpoint - preferences[stringPreferencesKey("scopes")] = config.scopes.joinToString(",") - preferences[stringPreferencesKey("redirectUri")] = config.redirectUri - preferences[stringPreferencesKey("display")] = config.display ?: "Forgerock" - } + fun deleteCustomDaVinciConfig(index: Int) { + val deleted = customDaVinciConfigs[index] + customDaVinciConfigs = customDaVinciConfigs.toMutableList().also { it.removeAt(index) } + // If the deleted config was active, fall back to the first preset + if (appliedDaVinciConfig?.display == deleted.display) { + selectDaVinciConfig(daVinciPresets[0]) } + viewModelScope.launch(Dispatchers.IO) { persistCustomDaVinciConfigs(customDaVinciConfigs) } } - private suspend fun readConfigFromDataStore(): OidcClientConfig { - val preferences = ContextProvider.context.settingDataStore.data.first() - - val clientId = preferences[stringPreferencesKey("clientId")] - val discoveryEndpoint = preferences[stringPreferencesKey("discoveryEndpoint")] - val scopes = preferences[stringPreferencesKey("scopes")]?.split(",")?.toMutableSet() - val redirectUri = preferences[stringPreferencesKey("redirectUri")] - val display = preferences[stringPreferencesKey("display")] ?: "" - - return if (clientId != null && discoveryEndpoint != null && scopes != null && redirectUri != null) { - config { - this.clientId = clientId - this.discoveryEndpoint = discoveryEndpoint - this.scopes = scopes - this.redirectUri = redirectUri - this.display = display - } + fun saveCustomWebConfig(config: OidcConfigState, editIndex: Int?) { + customWebConfigs = if (editIndex == null) { + customWebConfigs + config } else { - journeyTest.oidcConfig() + customWebConfigs.toMutableList().also { it[editIndex] = config } } + selectWebConfig(config) + viewModelScope.launch(Dispatchers.IO) { persistCustomWebConfigs(customWebConfigs) } } -} -/** - * Get the current [OidcClientConfig] from a Workflow instance (Journey or DaVinci). - * Cannot use workflow.oidcClientConfig, since it requires the Workflow state to be initialized. - */ -private fun Workflow.oidcConfig(): OidcClientConfig { - return config.modules.first { it.config is OidcClientConfig }.config as OidcClientConfig -} + fun deleteCustomWebConfig(index: Int) { + val deleted = customWebConfigs[index] + customWebConfigs = customWebConfigs.toMutableList().also { it.removeAt(index) } + // If the deleted config was active, fall back to the first preset + if (appliedWebConfig?.display == deleted.display) { + selectWebConfig(webPresets[0]) + } + viewModelScope.launch(Dispatchers.IO) { persistCustomWebConfigs(customWebConfigs) } + } -/** - * Get the current [OidcClientConfig] from an OidcWebClient instance. - */ -private fun OidcWebClient.oidcConfig(): OidcClientConfig { - return config.modules.first { it.config is OidcClientConfig }.config as OidcClientConfig -} + // -- SDK instance builders (delegate to package-level functions) --------- + + private fun buildJourneyInstance(config: JourneyConfigState) = buildJourney(config) + private fun buildDaVinciInstance(config: OidcConfigState) = buildDaVinci(config) + private fun buildWebInstance(config: OidcConfigState) = buildWeb(config) + + // -- DataStore: load applied configs ------------------------------------- + + private suspend fun loadAppliedJourneyFromDataStore(): JourneyConfigState { + val prefs = ContextProvider.context.settingDataStore.data.first() + val clientId = prefs[stringPreferencesKey("j_clientId")] ?: return journeyPresets[0] + return JourneyConfigState( + serverUrl = prefs[stringPreferencesKey("j_serverUrl")] ?: journeyPresets[0].serverUrl, + realm = prefs[stringPreferencesKey("j_realm")] ?: journeyPresets[0].realm, + cookie = prefs[stringPreferencesKey("j_cookie")] ?: journeyPresets[0].cookie, + clientId = clientId, + discoveryEndpoint = prefs[stringPreferencesKey("j_discoveryEndpoint")] ?: "", + scopes = prefs[stringPreferencesKey("j_scopes")] ?: "", + redirectUri = prefs[stringPreferencesKey("j_redirectUri")] ?: "", + display = prefs[stringPreferencesKey("j_display")] ?: "", + ) + } + + private suspend fun loadAppliedDaVinciFromDataStore(): OidcConfigState { + val prefs = ContextProvider.context.settingDataStore.data.first() + val clientId = prefs[stringPreferencesKey("dv_clientId")] ?: return daVinciPresets[0] + return OidcConfigState( + clientId = clientId, + discoveryEndpoint = prefs[stringPreferencesKey("dv_discoveryEndpoint")] ?: "", + scopes = prefs[stringPreferencesKey("dv_scopes")] ?: "", + redirectUri = prefs[stringPreferencesKey("dv_redirectUri")] ?: "", + display = prefs[stringPreferencesKey("dv_display")] ?: "", + arcValue = prefs[stringPreferencesKey("dv_arcValue")] ?: "", + ) + } + + private suspend fun loadAppliedWebFromDataStore(): OidcConfigState { + val prefs = ContextProvider.context.settingDataStore.data.first() + val clientId = prefs[stringPreferencesKey("w_clientId")] ?: return webPresets[0] + return OidcConfigState( + clientId = clientId, + discoveryEndpoint = prefs[stringPreferencesKey("w_discoveryEndpoint")] ?: "", + scopes = prefs[stringPreferencesKey("w_scopes")] ?: "", + redirectUri = prefs[stringPreferencesKey("w_redirectUri")] ?: "", + display = prefs[stringPreferencesKey("w_display")] ?: "", + ) + } + + // -- DataStore: load custom configs -------------------------------------- + + private suspend fun loadCustomJourneyFromDataStore(): List { + val prefs = ContextProvider.context.settingDataStore.data.first() + val json = prefs[stringPreferencesKey("j_custom_configs")] ?: return emptyList() + return deserializeJourneyConfigs(json) + } + + private suspend fun loadCustomDaVinciFromDataStore(): List { + val prefs = ContextProvider.context.settingDataStore.data.first() + val json = prefs[stringPreferencesKey("dv_custom_configs")] ?: return emptyList() + return deserializeOidcConfigs(json) + } + + private suspend fun loadCustomWebFromDataStore(): List { + val prefs = ContextProvider.context.settingDataStore.data.first() + val json = prefs[stringPreferencesKey("w_custom_configs")] ?: return emptyList() + return deserializeOidcConfigs(json) + } + + // -- DataStore: persist applied configs ---------------------------------- + + private suspend fun persistAppliedJourney(config: JourneyConfigState) { + ContextProvider.context.settingDataStore.edit { prefs -> + prefs[stringPreferencesKey("j_serverUrl")] = config.serverUrl + prefs[stringPreferencesKey("j_realm")] = config.realm + prefs[stringPreferencesKey("j_cookie")] = config.cookie + prefs[stringPreferencesKey("j_clientId")] = config.clientId + prefs[stringPreferencesKey("j_discoveryEndpoint")] = config.discoveryEndpoint + prefs[stringPreferencesKey("j_scopes")] = config.scopes + prefs[stringPreferencesKey("j_redirectUri")] = config.redirectUri + prefs[stringPreferencesKey("j_display")] = config.display + } + } + + private suspend fun persistAppliedDaVinci(config: OidcConfigState) { + ContextProvider.context.settingDataStore.edit { prefs -> + prefs[stringPreferencesKey("dv_clientId")] = config.clientId + prefs[stringPreferencesKey("dv_discoveryEndpoint")] = config.discoveryEndpoint + prefs[stringPreferencesKey("dv_scopes")] = config.scopes + prefs[stringPreferencesKey("dv_redirectUri")] = config.redirectUri + prefs[stringPreferencesKey("dv_display")] = config.display + prefs[stringPreferencesKey("dv_arcValue")] = config.arcValue + } + } + + private suspend fun persistAppliedWeb(config: OidcConfigState) { + ContextProvider.context.settingDataStore.edit { prefs -> + prefs[stringPreferencesKey("w_clientId")] = config.clientId + prefs[stringPreferencesKey("w_discoveryEndpoint")] = config.discoveryEndpoint + prefs[stringPreferencesKey("w_scopes")] = config.scopes + prefs[stringPreferencesKey("w_redirectUri")] = config.redirectUri + prefs[stringPreferencesKey("w_display")] = config.display + } + } + + // -- DataStore: persist custom configs ----------------------------------- + + private suspend fun persistCustomJourneyConfigs(configs: List) { + ContextProvider.context.settingDataStore.edit { prefs -> + prefs[stringPreferencesKey("j_custom_configs")] = serializeJourneyConfigs(configs) + } + } -private fun config(block: OidcClientConfig.() -> Unit): OidcClientConfig { - return OidcClientConfig().apply(block) + private suspend fun persistCustomDaVinciConfigs(configs: List) { + ContextProvider.context.settingDataStore.edit { prefs -> + prefs[stringPreferencesKey("dv_custom_configs")] = serializeOidcConfigs(configs) + } + } + + private suspend fun persistCustomWebConfigs(configs: List) { + ContextProvider.context.settingDataStore.edit { prefs -> + prefs[stringPreferencesKey("w_custom_configs")] = serializeOidcConfigs(configs) + } + } + + // -- JSON serialization -------------------------------------------------- + + private fun serializeJourneyConfigs(configs: List): String { + val array = JSONArray() + configs.forEach { c -> + array.put(JSONObject().apply { + put("serverUrl", c.serverUrl); put("realm", c.realm); put("cookie", c.cookie) + put("clientId", c.clientId); put("discoveryEndpoint", c.discoveryEndpoint) + put("scopes", c.scopes); put("redirectUri", c.redirectUri); put("display", c.display) + }) + } + return array.toString() + } + + private fun deserializeJourneyConfigs(json: String): List = runCatching { + val array = JSONArray(json) + (0 until array.length()).map { + val o = array.getJSONObject(it) + JourneyConfigState( + serverUrl = o.optString("serverUrl", ""), + realm = o.optString("realm", ""), + cookie = o.optString("cookie", ""), + clientId = o.optString("clientId", ""), + discoveryEndpoint = o.optString("discoveryEndpoint", ""), + scopes = o.optString("scopes", ""), + redirectUri = o.optString("redirectUri", ""), + display = o.optString("display", ""), + ) + } + }.getOrDefault(emptyList()) + + private fun serializeOidcConfigs(configs: List): String { + val array = JSONArray() + configs.forEach { c -> + array.put(JSONObject().apply { + put("clientId", c.clientId); put("discoveryEndpoint", c.discoveryEndpoint) + put("scopes", c.scopes); put("redirectUri", c.redirectUri); put("display", c.display) + put("arcValue", c.arcValue) + }) + } + return array.toString() + } + + private fun deserializeOidcConfigs(json: String): List = runCatching { + val array = JSONArray(json) + (0 until array.length()).map { + val o = array.getJSONObject(it) + OidcConfigState( + clientId = o.optString("clientId", ""), + discoveryEndpoint = o.optString("discoveryEndpoint", ""), + scopes = o.optString("scopes", ""), + redirectUri = o.optString("redirectUri", ""), + display = o.optString("display", ""), + arcValue = o.optString("arcValue", ""), + ) + } + }.getOrDefault(emptyList()) } +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +private fun String.toScopeSet(): MutableSet = + split(",").map { it.trim() }.filter { it.isNotEmpty() }.toMutableSet() diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/davinci/DaVinci.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/davinci/DaVinci.kt index f7a114020..5de23e5ba 100644 --- a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/davinci/DaVinci.kt +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/davinci/DaVinci.kt @@ -40,6 +40,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState @@ -51,7 +52,6 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel -import com.pingidentity.davinci.plugin.DaVinci import com.pingidentity.orchestrate.ContinueNode import com.pingidentity.orchestrate.ErrorNode import com.pingidentity.orchestrate.FailureNode @@ -145,8 +145,10 @@ fun DaVinci( when (val node = state.node) { is ContinueNode -> { - Render(node = node, onNodeUpdated, onStart) { - onNext(node) + key(node) { + Render(node = node, onNodeUpdated, onStart) { + onNext(node) + } } } diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/davinci/RichText.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/davinci/RichText.kt new file mode 100644 index 000000000..d5a19c181 --- /dev/null +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/davinci/RichText.kt @@ -0,0 +1,119 @@ +/* + * 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.samples.pingsampleapp.davinci + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.fromHtml +import androidx.compose.ui.text.style.TextDecoration +import com.pingidentity.davinci.RichContent +import com.pingidentity.davinci.RichContentReplacement +import com.pingidentity.davinci.collector.escapeHtml + +object RichText { + + val tokenPattern = Regex("""\{\{(\w+)\}\}""") + val htmlTagPattern = Regex("""<[a-zA-Z][^>]*>""") + + /** + * Builds an [AnnotatedString] from a [richContent] template that may contain + * `{{tokenName}}` placeholders. Each token found in [com.pingidentity.davinci.REPLACEMENTS] is + * rendered as a clickable hyperlink using the token's [RichContentReplacement.href] + * and [RichContentReplacement.value]. Unresolved tokens fall back to their + * raw placeholder text. + */ + fun buildRichAnnotatedString( + richContent: RichContent, + ) = buildAnnotatedString { + var lastIndex = 0 + for (match in tokenPattern.findAll(richContent.content)) { + // Append any plain text before this token + append(richContent.content.substring(lastIndex, match.range.first)) + + val token = match.groupValues[1] + val replacement = richContent.replacements[token] + + if (replacement != null && replacement.href.isNotEmpty()) { + pushLink( + LinkAnnotation.Url( + url = replacement.href, + styles = TextLinkStyles( + style = SpanStyle(textDecoration = TextDecoration.Underline) + ) + ) + ) + append(replacement.value) + pop() + } else { + // No replacement found – render the raw placeholder or display value + append(replacement?.value?.takeIf { it.isNotEmpty() } ?: match.value) + } + + lastIndex = match.range.last + 1 + } + // Append any remaining plain text after the last token + append(richContent.content.substring(lastIndex)) + } + + /** + * Overload for building rich text from a generic [RichContent] with an optional fallback string + * if the content is null. This can be used for any field that supports rich content, not just labels. + * + * 1. **Token replacement** — `richText` contains `{{token}}` placeholders → + * inline clickable links are substituted from [richContent]. + * 2. **HTML** — `richText` contains HTML tags → + * rendered via [AnnotatedString.Companion.fromHtml]. + * 3. **Plain text** — everything else → rendered as-is. + * + * @param richContent The [RichContent] to render, which may contain tokens and/or HTML. + * @param fallbackContent A plain string to use if [richContent] is null. + * + * @return An [AnnotatedString] with tokens replaced and HTML rendered as appropriate, or the fallback content if [richContent] is null. + */ + fun buildRichTextLabel( + richContent: RichContent?, + fallbackContent: String = "", + ): AnnotatedString { + if (richContent == null) { + return if (htmlTagPattern.containsMatchIn(fallbackContent)) { + AnnotatedString.fromHtml(fallbackContent) + } else { + AnnotatedString(fallbackContent) + } + } + val hasTokens = tokenPattern.containsMatchIn(richContent.content) + val hasHtml = htmlTagPattern.containsMatchIn(richContent.content) + return when { + hasTokens && hasHtml -> + AnnotatedString + .fromHtml( + tokenPattern.replace(richContent.content) { match -> + val token = match.groupValues[1] + val replacement = richContent.replacements[token] + when { + replacement?.href?.isNotEmpty() == true -> { + val label = replacement.value.ifEmpty { match.value }.escapeHtml() + val safeHref = replacement.href.escapeHtml() + """$label""" + } + replacement?.value?.isNotEmpty() == true -> replacement.value.escapeHtml() + else -> match.value + } + } + ) + hasTokens -> + buildRichAnnotatedString(richContent) + hasHtml -> + AnnotatedString.fromHtml(richContent.content) + else -> + AnnotatedString(richContent.content) + } + } +} \ No newline at end of file diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/davinci/collector/DaVinciContinueNode.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/davinci/collector/DaVinciContinueNode.kt index dec9227cb..eb63cb87c 100644 --- a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/davinci/collector/DaVinciContinueNode.kt +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/davinci/collector/DaVinciContinueNode.kt @@ -29,6 +29,10 @@ import com.pingidentity.davinci.collector.LabelCollector import com.pingidentity.davinci.collector.MultiSelectCollector import com.pingidentity.davinci.collector.PasswordCollector import com.pingidentity.davinci.collector.PhoneNumberCollector +import com.pingidentity.davinci.collector.ReadOnlyTextCollector +import com.pingidentity.davinci.collector.PollingCollector +import com.pingidentity.davinci.collector.QRCodeCollector +import com.pingidentity.davinci.collector.BooleanCollector import com.pingidentity.davinci.collector.SingleSelectCollector import com.pingidentity.davinci.collector.SubmitCollector import com.pingidentity.davinci.collector.TextCollector @@ -96,6 +100,7 @@ fun DaVinciContinueNode( is SubmitCollector -> SubmitButton(it, onNext) is TextCollector -> Text(it, onNodeUpdated) is LabelCollector -> Label(it) + is ReadOnlyTextCollector -> ReadOnlyText(it) is MultiSelectCollector -> { if (it.type == "COMBOBOX") { ComboBox(it, onNodeUpdated) @@ -119,7 +124,10 @@ fun DaVinciContinueNode( is FidoAuthenticationCollector -> FidoAuthentication(it, onStart, onNext) is PhoneNumberCollector -> PhoneNumber(it, onNodeUpdated) is ProtectCollector -> Protect(it, onNodeUpdated) + is PollingCollector -> Polling(it, onNext) + is QRCodeCollector -> QRCode(it) + is BooleanCollector -> SingleCheckbox(it, onNodeUpdated) } if (it is Submittable) { hasAction = true diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/davinci/collector/Label.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/davinci/collector/Label.kt index 566d9cddf..c529694e0 100644 --- a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/davinci/collector/Label.kt +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/davinci/collector/Label.kt @@ -17,19 +17,21 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.pingidentity.davinci.collector.LabelCollector +import com.pingidentity.samples.pingsampleapp.davinci.RichText.buildRichTextLabel + @Composable -fun Label( - field: LabelCollector -) { +fun Label(field: LabelCollector) { Row( - modifier = - Modifier + modifier = Modifier .padding(4.dp) .fillMaxWidth(), ) { androidx.compose.material3.Text( - text = field.content, + text = buildRichTextLabel( + richContent = field.richContent, + fallbackContent = field.content, + ), style = MaterialTheme.typography.labelLarge, modifier = Modifier .wrapContentWidth(Alignment.CenterHorizontally) diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/davinci/collector/PhoneNumber.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/davinci/collector/PhoneNumber.kt index 399133e3c..50cbb72d5 100644 --- a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/davinci/collector/PhoneNumber.kt +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/davinci/collector/PhoneNumber.kt @@ -45,6 +45,7 @@ fun PhoneNumber(field: PhoneNumberCollector, onNodeUpdated: () -> Unit) { ) } var phone by remember(field) { mutableStateOf(field.phoneNumber) } + var extension by remember(field) { mutableStateOf(field.extension) } var isValid by remember(field) { mutableStateOf(true) @@ -52,7 +53,8 @@ fun PhoneNumber(field: PhoneNumberCollector, onNodeUpdated: () -> Unit) { LaunchedEffect(true) { field.countryCode = selectedCountryCode.countryCode - field.phoneNumber = field.phoneNumber + field.phoneNumber = phone + field.extension = extension } Column( @@ -122,6 +124,24 @@ fun PhoneNumber(field: PhoneNumberCollector, onNodeUpdated: () -> Unit) { modifier = Modifier.weight(1f) ) } + + // Extension + if (field.showExtension) { + OutlinedTextField( + value = extension, + onValueChange = { value -> + val extensionValue = value.take(10).filter { it.isDigit() } + extension = extensionValue + field.extension = extension + }, + label = { androidx.compose.material3.Text(field.extensionLabel) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) + } } } diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/davinci/collector/Polling.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/davinci/collector/Polling.kt new file mode 100644 index 000000000..cabde5345 --- /dev/null +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/davinci/collector/Polling.kt @@ -0,0 +1,104 @@ +/* + * 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.samples.pingsampleapp.davinci.collector + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.unit.dp +import com.pingidentity.davinci.collector.PollingCollector +import com.pingidentity.davinci.collector.PollingStatus +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +@Composable +fun Polling( + field: PollingCollector, + onNext: () -> Unit, +) { + var isPolling by remember { mutableStateOf(true) } + var statusMessage by remember { mutableStateOf("Polling...") } + var pollingJob: Job? by remember { mutableStateOf(null) } + + LaunchedEffect(field) { + pollingJob = launch { + field.pollStatus().collect { status -> + when (status) { + is PollingStatus.Continue -> { + statusMessage = "Polling... (${status.retryCount}/${status.maxRetries})" + } + is PollingStatus.Complete -> { + isPolling = false + onNext() + } + is PollingStatus.TimedOut -> { + isPolling = false + statusMessage = "Polling timedOut" + onNext() + } + is PollingStatus.Error -> { + isPolling = false + statusMessage = "Error: ${status.exception.message}" + onNext() + } + + is PollingStatus.Expired -> { + isPolling = false + statusMessage = "Polling Expired" + onNext() + } + + } + } + } + } + + // Cancel polling when the composable is disposed + DisposableEffect(field) { + onDispose { + pollingJob?.cancel() + isPolling = false + } + } + + if (isPolling) { + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(48.dp) + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = statusMessage, + style = MaterialTheme.typography.bodyMedium + ) + } + } +} + diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/davinci/collector/QRCode.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/davinci/collector/QRCode.kt new file mode 100644 index 000000000..c308e02ea --- /dev/null +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/davinci/collector/QRCode.kt @@ -0,0 +1,64 @@ +/* + * 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.samples.pingsampleapp.davinci.collector + +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.pingidentity.davinci.collector.QRCodeCollector + +@Composable +fun QRCode( + field: QRCodeCollector, +) { + val qrCodeBitmap = remember(field.content) { + field.bitmap() + } + + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + // Display QR Code image + qrCodeBitmap?.let { bitmap -> + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = "QR Code", + modifier = Modifier.size(256.dp) + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Display fallback text + if (field.fallbackText.isNotEmpty()) { + Text( + text = field.fallbackText, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + } +} + diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/davinci/collector/ReadOnlyText.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/davinci/collector/ReadOnlyText.kt new file mode 100644 index 000000000..b0e45c1d7 --- /dev/null +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/davinci/collector/ReadOnlyText.kt @@ -0,0 +1,72 @@ +/* + * 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.samples.pingsampleapp.davinci.collector + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +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 +import com.pingidentity.davinci.collector.ReadOnlyTextCollector + +@Composable +fun ReadOnlyText(field: ReadOnlyTextCollector) { + + OutlinedCard( + modifier = Modifier + .padding(8.dp) + .fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(16.dp)) { + + // Title (shown only when titleEnabled is true) + if (field.titleEnabled && field.title.isNotEmpty()) { + Text( + text = field.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Spacer(modifier = Modifier.height(8.dp)) + } + + // Scrollable agreement content + val scrollState = rememberScrollState() + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 200.dp) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline, + shape = RoundedCornerShape(4.dp), + ) + .verticalScroll(scrollState) + .padding(8.dp), + ) { + Text( + text = field.content, + style = MaterialTheme.typography.bodySmall, + ) + } + } + } +} + diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/davinci/collector/SingleCheckbox.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/davinci/collector/SingleCheckbox.kt new file mode 100644 index 000000000..6309de3ed --- /dev/null +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/davinci/collector/SingleCheckbox.kt @@ -0,0 +1,71 @@ +/* + * 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.samples.pingsampleapp.davinci.collector + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Switch +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.unit.dp +import com.pingidentity.davinci.collector.BooleanCollectorAppearance +import com.pingidentity.davinci.collector.BooleanCollector +import com.pingidentity.samples.pingsampleapp.davinci.RichText.buildRichTextLabel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SingleCheckbox(field: BooleanCollector, onNodeUpdated: () -> Unit) { + var isValid by remember(field) { + mutableStateOf(true) + } + var isChecked by remember(field) { + mutableStateOf(field.value) + } + + OutlinedCard ( + modifier = Modifier + .padding(8.dp) + .fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val onChange: (Boolean) -> Unit = { checked -> + isChecked = checked + field.value = checked + isValid = field.validate().isEmpty() + onNodeUpdated() + } + if (field.appearance == BooleanCollectorAppearance.SWITCH) { + Switch(checked = isChecked, onCheckedChange = onChange) + } else { + Checkbox(checked = isChecked, onCheckedChange = onChange) + } + val richText = buildRichTextLabel( + richContent = field.richContent, + fallbackContent = field.label, + ) + Text(text = richText) + } + if (!isValid) { + ErrorMessage(field.validate()) + } + } +} \ No newline at end of file diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/davinci/collector/SocialLoginButton.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/davinci/collector/SocialLoginButton.kt index 40eb76f18..10f41963d 100644 --- a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/davinci/collector/SocialLoginButton.kt +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/davinci/collector/SocialLoginButton.kt @@ -26,7 +26,7 @@ import androidx.compose.ui.unit.dp import com.pingidentity.browser.BrowserLauncher import com.pingidentity.idp.davinci.IdpCollector import com.pingidentity.samples.pingsampleapp.R -import com.pingidentity.samples.pingsampleapp.config.redirectUri +import com.pingidentity.samples.pingsampleapp.config.daVinciRedirectUri import kotlinx.coroutines.launch @Composable @@ -63,7 +63,7 @@ fun SocialLoginButton( setShowTitle(false) setUrlBarHidingEnabled(true) } - val result = idpCollector.authorize(redirectUri) + val result = idpCollector.authorize(daVinciRedirectUri) result.onSuccess { onNext() } @@ -93,7 +93,7 @@ fun SocialLoginButton( setShowTitle(false) setUrlBarHidingEnabled(true) } - val result = idpCollector.authorize(redirectUri) + val result = idpCollector.authorize(daVinciRedirectUri) result.onSuccess { onNext() } result.onFailure { Log.e( diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/home/HomeApp.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/home/HomeApp.kt index 38312a40c..e6e361e32 100644 --- a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/home/HomeApp.kt +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/home/HomeApp.kt @@ -39,6 +39,7 @@ import androidx.compose.material.icons.filled.Preview import androidx.compose.material.icons.filled.QrCodeScanner import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Storage +import androidx.compose.material.icons.filled.SwapHoriz import androidx.compose.material.icons.filled.Token import androidx.compose.material.icons.filled.VpnKey import androidx.compose.material3.Card @@ -97,6 +98,7 @@ fun HomeApp( onPushNotificationClick : () -> Unit, onDeviceIdClick : () -> Unit, onAuthTestScreenClick : () -> Unit, + onAuthMigrationClick : () -> Unit, ) { var deviceId by remember { mutableStateOf("Loading Device ID...") } var deviceStatus by remember { mutableStateOf("Loading device status...") } @@ -290,6 +292,12 @@ fun HomeApp( subtitle = "Authenticator app test mode", onClick = onAuthTestScreenClick ) + IconRowItem( + icon = Icons.Default.SwapHoriz, + title = stringResource(R.string.text_auth_migration_title), + subtitle = stringResource(R.string.text_auth_migration_subtitle), + onClick = onAuthMigrationClick + ) // Developer Tools Section Text( @@ -473,7 +481,8 @@ fun PreviewHomeApp() { onOathClick = {}, onPushNotificationClick = {}, onDeviceIdClick = {}, - onAuthTestScreenClick = {} + onAuthTestScreenClick = {}, + onAuthMigrationClick = {} ) } diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/navigation/Navigation.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/navigation/Navigation.kt index a15f382b2..8f04a4d78 100644 --- a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/navigation/Navigation.kt +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/navigation/Navigation.kt @@ -21,6 +21,7 @@ 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.mutableStateOf import androidx.compose.runtime.remember @@ -39,6 +40,9 @@ import androidx.navigation.navArgument import com.pingidentity.samples.pingsampleapp.PingSampleApplication import com.pingidentity.samples.pingsampleapp.authenticator.data.AuthenticatorViewModel import com.pingidentity.samples.pingsampleapp.authenticator.ui.AboutScreen +import com.pingidentity.samples.pingsampleapp.authmigration.AuthMigrationScreen +import com.pingidentity.samples.pingsampleapp.authmigration.AuthMigrationViewModel +import com.pingidentity.samples.pingsampleapp.authmigration.MigrationStatus import com.pingidentity.samples.pingsampleapp.authenticator.ui.AccountDetailScreen import com.pingidentity.samples.pingsampleapp.authenticator.ui.AccountsScreen import com.pingidentity.samples.pingsampleapp.authenticator.ui.EditAccountsScreen @@ -96,6 +100,7 @@ object Route { const val ROUTE_AUTH_APP_ACCOUNT = "account/{issuer}/{accountName}" fun routeForAuthAppAccount(accountName: String) = "account/$accountName" const val ROUTE_AUTH_TEST_APP = "route_auth_test_app" + const val AUTH_MIGRATION = "auth_migration" } @@ -170,6 +175,9 @@ fun AppNavigation( onAuthTestScreenClick = { navController.navigate(Route.ROUTE_AUTH_TEST_APP) }, + onAuthMigrationClick = { + navController.navigate(Route.AUTH_MIGRATION) + }, ) } @@ -493,6 +501,26 @@ fun AppNavigation( } } } + + composable(Route.AUTH_MIGRATION) { + val authMigrationViewModel = viewModel() + val migrationState by authMigrationViewModel.state.collectAsState() + + // When migration finishes successfully, close and re-initialize the MFA clients so + // their in-memory credential caches are cleared and fresh database connections are + // opened — then reload credentials into the UI-bound AuthenticatorViewModel. + LaunchedEffect(migrationState.migrationStatus) { + if (migrationState.migrationStatus == MigrationStatus.COMPLETED) { + PingSampleApplication.reinitializeMfaClients() + authenticatorViewModel?.refreshCredentials() + } + } + + AuthMigrationScreen( + viewModel = authMigrationViewModel, + onBack = { navController.popBackStack() } + ) + } } } diff --git a/samples/pingsampleapp/src/main/res/values/strings.xml b/samples/pingsampleapp/src/main/res/values/strings.xml index 306fc51a0..255e6de05 100644 --- a/samples/pingsampleapp/src/main/res/values/strings.xml +++ b/samples/pingsampleapp/src/main/res/values/strings.xml @@ -225,6 +225,28 @@ • • • • • • OTP Code + + Auth Migration + Migrate legacy authenticator data + Migration + How to Test + Install a legacy ForgeRock Authenticator app using the same application ID as this sample app. + Register OATH (TOTP/HOTP) and/or Push accounts in the legacy app. + Uninstall the legacy app from the device. + Install this sample app (same application ID ensures SharedPreferences data persists). + Open this screen and tap \"Start Migration\" to import the legacy credentials. + Note: Only one app with the same application ID can be installed at a time. SharedPreferences data persists across app installs/uninstalls as long as the application ID matches. + Legacy Data + Checking… + Found + None + Migrating… + Completed + Failed + Start Migration + Progress + No legacy data to migrate. + Biometrics are required but are unavailable. Please contact your account administrator for help. The device might have been tampered with or rooted. Please contact the account administrator for help. diff --git a/settings.gradle.kts b/settings.gradle.kts index 716f2ff11..390c25ee0 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -62,13 +62,9 @@ include(":foundation:testrail") include(":mfa") include(":mfa:fido") -include(":samples:app") -include(":samples:journeyapp") include(":mfa:binding") include(":mfa:binding-ui") include(":mfa:binding-migration") -include(":samples:authenticatorapp") -include(":samples:pingonemfapp") include(":pingonemfa") include(":samples:pingsampleapp") include(":mfa:auth-migration") From 62b8a2784e023c8e8afd774672e62820748cdd76 Mon Sep 17 00:00:00 2001 From: Evgeniy Mishustin Date: Mon, 1 Jun 2026 11:17:15 +0300 Subject: [PATCH 03/15] feat: pingonemfa sdk integration --- README.md | 1 + pingonemfa/README.md | 327 ++++++++++ pingonemfa/build.gradle.kts | 7 + .../pingidentity/pingonemfa/commons/Geo.kt | 39 ++ .../pingonemfa/commons/PingOneMFA.kt | 356 +++++++---- .../pingonemfa/commons/PingOneMFAException.kt | 34 +- .../pingonemfa/commons/PingOneMfaAccount.kt | 25 +- .../pingonemfa/otp/OtpCodeInfo.kt | 18 +- .../pingonemfa/push/PushApprovalService.kt | 41 +- .../pingonemfa/push/PushNotification.kt | 84 ++- .../pingidentity/pingonemfa/push/PushType.kt | 34 +- .../pingonemfa/util/AccountParser.kt | 71 ++- .../pingonemfa/commons/GeoTest.kt | 30 + .../pingonemfa/commons/PingOneMFATest.kt | 572 ++++++++++++++++++ .../push/PushApprovalServiceTest.kt | 161 +++++ .../pingonemfa/push/PushNotificationTest.kt | 220 +++++++ .../pingonemfa/util/AccountParserTest.kt | 185 ++++++ .../notification/BiometricPromptActivity.kt | 18 +- .../NotificationActionReceiver.kt | 17 +- .../notification/NotificationHelper.kt | 6 - .../notification/PushNotificationActivity.kt | 38 +- .../service/PushNotificationService.kt | 1 - samples/pingsampleapp/README.md | 46 +- samples/pingsampleapp/build.gradle.kts | 3 + .../src/main/AndroidManifest.xml | 18 + .../pingsampleapp/PingSampleApplication.kt | 17 + .../service/PushNotificationService.kt | 102 ++++ .../samples/pingsampleapp/home/HomeApp.kt | 50 +- .../pingsampleapp/navigation/Navigation.kt | 46 +- .../pingonemfa/PingOneMFAState.kt | 39 ++ .../pingonemfa/PingOneMFAViewModel.kt | 156 +++++ .../PingOneNotificationActionReceiver.kt | 62 ++ .../notification/PingOneNotificationHelper.kt | 147 +++++ .../PingOnePushNotificationActivity.kt | 113 ++++ .../notification/PushNotificationStore.kt | 47 ++ .../pingonemfa/ui/PingOneMFAAccountsScreen.kt | 185 ++++++ .../pingonemfa/ui/PingOneOTPScreen.kt | 159 +++++ .../pingonemfa/ui/PingOnePayloadScreen.kt | 180 ++++++ .../ui/PingOnePushNotificationScreen.kt | 206 +++++++ .../pingonemfa/ui/PingOneQrScannerScreen.kt | 375 ++++++++++++ .../ui/components/ApproveDenyRow.kt | 51 ++ .../ui/components/ManualNumberChallenge.kt | 91 +++ .../ui/components/NumberChallengeOptions.kt | 68 +++ .../util/PingOneMFAQrCodeAnalyzer.kt | 85 +++ .../src/main/res/values/strings.xml | 54 ++ 45 files changed, 4360 insertions(+), 225 deletions(-) create mode 100644 pingonemfa/README.md create mode 100644 pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/Geo.kt create mode 100644 pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/commons/GeoTest.kt create mode 100644 pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/commons/PingOneMFATest.kt create mode 100644 pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/push/PushApprovalServiceTest.kt create mode 100644 pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/push/PushNotificationTest.kt create mode 100644 pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/util/AccountParserTest.kt create mode 100644 samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/PingOneMFAState.kt create mode 100644 samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/PingOneMFAViewModel.kt create mode 100644 samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/notification/PingOneNotificationActionReceiver.kt create mode 100644 samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/notification/PingOneNotificationHelper.kt create mode 100644 samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/notification/PingOnePushNotificationActivity.kt create mode 100644 samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/notification/PushNotificationStore.kt create mode 100644 samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOneMFAAccountsScreen.kt create mode 100644 samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOneOTPScreen.kt create mode 100644 samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOnePayloadScreen.kt create mode 100644 samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOnePushNotificationScreen.kt create mode 100644 samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOneQrScannerScreen.kt create mode 100644 samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/components/ApproveDenyRow.kt create mode 100644 samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/components/ManualNumberChallenge.kt create mode 100644 samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/components/NumberChallengeOptions.kt create mode 100644 samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/util/PingOneMFAQrCodeAnalyzer.kt diff --git a/README.md b/README.md index b28c91bf1..c7b82714e 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ secured by PingOne. │ ├── oath # TOTP / HOTP one-time passwords │ └── push # Push notification authentication ├── protect # PingOne Protect fraud signals + ├── pingonemfa # PingOne MFA authentication ├── recaptcha-enterprise # reCAPTCHA Enterprise integration └── samples # Sample applications └── pingsampleapp # Combined Ping sample app diff --git a/pingonemfa/README.md b/pingonemfa/README.md new file mode 100644 index 000000000..964dfa4df --- /dev/null +++ b/pingonemfa/README.md @@ -0,0 +1,327 @@ +[![Ping Identity](https://www.pingidentity.com/content/dam/picr/nav/Ping-Logo-2.svg)](https://github.com/ForgeRock/ping-android-sdk) + +# PingOne MFA + +## Overview + +The `pingonemfa` module wraps the [PingOne MFA native SDK](https://github.com/pingidentity/pingone-customers-mobile-sdk-android) behind a clean, coroutine-friendly Kotlin API. It is the adapter layer between your application and the PingOne MFA platform. All PingOne SDK callbacks are bridged to `suspend` functions that return `Result` — callers never need a try/catch. + +--- + +## Features + +- **Device Pairing** — pair new MFA accounts by scanning a QR code or entering a pairing key manually +- **Paired Accounts List** — retrieve information about all currently paired accounts +- **OTP** — retrieve the current one-time passcode and its remaining validity window +- **Push Notifications (foreground and background)** — approve or deny incoming authentication requests +- **Mobile Payload** — generate a cryptographic mobile payload for server-side authentication flows + +--- + +## Architecture Overview + +``` +┌──────────────────────────────────────────────────┐ +│ Your Application │ +│ │ +│ ┌────────────────────────────────────────────┐ │ +│ │ Push / OTP / Pairing Handlers │ │ +│ │ (your app code) │ │ +│ └──────────────────────┬─────────────────────┘ │ +│ │ │ +│ ┌──────────────────────▼─────────────────────┐ │ +│ │ pingonemfa module │ │ +│ │ PingOneMFA (singleton object) │ │ +│ └──────────────────────┬─────────────────────┘ │ +└──────────────────────────┼───────────────────────┘ + │ + ┌────────────▼─────────┐ + │ PingOne MFA SDK │ + └──────────────────────┘ +``` + +The `pingonemfa` module is the only component in the Orchestration SDK that imports from native PingOne MFA SDK. All other layers depend on the typed domain models and coroutine API exposed by `PingOneMFA`. + +--- + +## Getting Started + +### Prerequisites + +- Android API level 24 or higher +- Firebase Cloud Messaging configured for your application (`google-services.json` present and matching your application ID) +- A PingOne environment with push notifications and/or MFA configured, for documentation on setting up PingOne MFA, see [PingOne MFA documentation](https://docs.pingidentity.com/pingone/strong_authentication_mfa/p1_strong_authentication_configure_mobile_applications.html). + +--- + +## Add Dependency + +```kotlin +dependencies { + implementation("com.pingidentity.sdks:pingonemfa:") + + // Firebase Cloud Messaging — required for push notification support + implementation(platform("com.google.firebase:firebase-bom:")) + implementation("com.google.firebase:firebase-messaging") +} +``` + +--- + +## Setup and Configuration + +### 1. Initialize the SDK + +Call `initialize()` once at application startup, before any other call. Pass the `Geo` that matches your PingOne environment's service region. The call is idempotent — repeated calls after a successful initialisation return immediately without re-entering the native SDK. + +```kotlin +val result = PingOneMFA.initialize(Geo.NORTH_AMERICA) +result.onFailure { e -> + Log.e("MFA", "Initialisation failed: ${e.message}") +} +``` + +Supported regions: + +| `Geo` | PingOne region | +|---|---| +| `Geo.NORTH_AMERICA` | North America | +| `Geo.EUROPE` | Europe | +| `Geo.CANADA` | Canada | +| `Geo.AUSTRALIA` | Australia | +| `Geo.SINGAPORE` | Singapore | + +### 2. Register the FCM Push Token + +Call `setDeviceToken()` each time Firebase delivers a new push token — typically from `FirebaseMessagingService.onNewToken`: + +```kotlin +override fun onNewToken(token: String) { + CoroutineScope(SupervisorJob()).launch { + PingOneMFA.setDeviceToken(token).onFailure { e -> + Log.e("MFA", "Token registration failed: ${e.message}") + } + } +} +``` + +--- + +## Usage + +### Device Pairing + +```kotlin +PingOneMFA.pair(pairingKey) + .onSuccess { + // Pairing succeeded — update UI as needed + } + .onFailure { e -> + Log.e("MFA", "Pairing failed: ${e.message}") + } +``` + +### Retrieve Paired Accounts + +```kotlin +PingOneMFA.getDeviceInfo().onSuccess { accounts -> + accounts.forEach { account -> + Log.d("MFA", "${account.username} | region: ${account.region}") + } +} +``` + +### OTP + +```kotlin +PingOneMFA.getOneTimePasscode().onSuccess { otp -> + showCode(otp.code, otp.secondsRemaining) +} +``` + +`OtpCodeInfo.secondsRemaining` is a snapshot computed at call time. Re-call `getOneTimePasscode()` when it reaches zero to receive the next code. + +### Mobile Payload + +```kotlin +PingOneMFA.generateMobilePayload().onSuccess { payload -> + // Submit payload to your server-side authentication flow +} +``` + +### Push Notifications — Foreground + +When your app is in the foreground, process the incoming `RemoteMessage` and present the appropriate UI based on push type: + +```kotlin +// In FirebaseMessagingService.onMessageReceived: +PingOneMFA.processRemoteNotification(remoteMessage).onSuccess { push -> + when (push.getPushType()) { + PushType.DEFAULT -> showApproveDenyUI(push) + PushType.CHALLENGE -> showNumberChallengeUI(push) + PushType.DRY -> { /* test push — no user action required */ } + } +} +``` + +After the user responds: + +```kotlin +// Approve (pass numberChallenge for CHALLENGE type, null for DEFAULT) +push.approveNotification( + context = applicationContext, + authenticationMethod = "user", // or "biometric", depending on your UI + numberChallenge = selectedNumber +).onSuccess { /* done */ } + +// Deny +push.denyNotification(applicationContext) + .onSuccess { /* done */ } +``` + +For number-matching challenge pushes, retrieve the options provided by the server: + +```kotlin +val options: IntArray? = push.getNumbersChallenge() +// options is null when the server expects free-form digit entry +``` + +### Push Notifications — Background (Notification Banner) + +When the user taps Approve or Deny on the system notification banner while the app is in the background, use the banner helpers. These route the network call through `PushApprovalService`, which runs as a foreground service and is exempt from Android's background network restrictions: + +```kotlin +// Called from your notification action BroadcastReceiver: +PingOneMFA.approvePushNotificationFromBanner(pushNotification) +// or +PingOneMFA.denyPushNotificationFromBanner(pushNotification) +``` + +> `PushApprovalService` completes the network call asynchronously and does not surface the outcome back to the UI. If your application needs to react to banner-approval results, add a custom broadcast or shared state mechanism. + +--- + +## Required Manifest Permissions + +The following permissions are declared in the module's `AndroidManifest.xml` and merged into your app automatically: + +```xml + + +``` + +These are required by `PushApprovalService` for background push handling. + +--- + +## Error Handling + +All `suspend` functions return `Result.failure(PingOneMFAException(...))` on error and never throw. `PingOneMFAException` contains a human-readable `message`. + +```kotlin +PingOneMFA.pair(pairingKey) + .onSuccess { + // success path + } + .onFailure { e -> + // e is PingOneMFAException — e.message is always non-null + Log.e("MFA", e.message) + } +``` + +--- + +## Sample Application + +PingOne MFA functionality is demonstrated in the [pingsampleapp](../samples/pingsampleapp) sample under the **PINGONE MFA** section of the home screen: + +- QR code scanning for device pairing +- Paired accounts list +- OTP display with live countdown +- Mobile payload generation screen +- Push notification handling for DEFAULT, CHALLENGE, and DRY push types +- Background push approval from the notification banner + +See the [pingsampleapp README](../samples/pingsampleapp/README.md) for build instructions. + +--- + +## API Reference + +### `PingOneMFA` + +| Function | Returns | Description | +|---|---|---| +| `suspend initialize(geo: Geo)` | `Result` | Configure the PingOne SDK for the selected service region. Idempotent after first success. | +| `suspend setDeviceToken(pushToken)` | `Result` | Register or refresh the FCM push token with PingOne. | +| `suspend pair(pairingKey)` | `Result` | Pair a new MFA account. | +| `suspend getDeviceInfo()` | `Result>` | Return all paired accounts. | +| `suspend getOneTimePasscode()` | `Result` | Return the current TOTP code and its remaining validity window. | +| `suspend processRemoteNotification(message)` | `Result` | Convert an FCM `RemoteMessage` to a typed `PushNotification`. | +| `suspend generateMobilePayload()` | `Result` | Generate a mobile payload for server-side authentication. | +| `approvePushNotificationFromBanner(notification)` | `Unit` | Start the background foreground service to approve a banner push. | +| `denyPushNotificationFromBanner(notification)` | `Unit` | Start the background foreground service to deny a banner push. | + +### `PingOneMfaAccount` + +| Field | Type | Description | +|---|---|---| +| `region` | `String` | Region key from the PingOne response (e.g. `"NA"`, `"EU"`) | +| `id` | `String` | PingOne user ID | +| `deviceId` | `String` | Device ID associated with this pairing within PingOne | +| `environment` | `String` | PingOne environment ID | +| `username` | `String` | The account's login username as returned by the PingOne server | +| `name` | `String?` | User's given (first) name, or `null` if not provided by the server | +| `family` | `String?` | User's family (last) name, or `null` if not provided by the server | + +### `OtpCodeInfo` + +| Field | Type | Description | +|---|---|---| +| `code` | `String` | Current TOTP passcode | +| `secondsRemaining` | `Int` | Seconds until the code expires (snapshot at call time); clamped to `0` if already expired | + +### `PushNotification` + +| Method / Field | Type | Description | +|---|---|--------------------------------------------------------------------------------------------------------------------------------------| +| `approveNotification(ctx, method, challenge?)` | `suspend Result` | Approve the push authentication request | +| `denyNotification(ctx)` | `suspend Result` | Deny the push authentication request | +| `isCancelAuthentication()` | `Boolean` | `true` when the server has cancelled active request (e.g. approved on another device) — dismiss the UI without requiring user action | +| `getNumbersChallenge()` | `IntArray?` | Options for a number-matching CHALLENGE push; `null` when free-form digit entry is expected | +| `getPushType()` | `PushType` | The interaction model required by this push (see `PushType`) | +| `id` | `String` | Wrapper-generated unique identifier for this push request | +| `title` | `String?` | Notification title extracted from the FCM payload | +| `message` | `String?` | Notification body extracted from the FCM payload | + +### `PushType` + +| Value | Description | +|---|---| +| `DEFAULT` | Standard authentication request — the user approves or denies with a single tap | +| `CHALLENGE` | Number-matching push — present the options from `getNumbersChallenge()`; a `null` return means free-form digit entry is expected | +| `DRY` | Silent test push sent by the server to verify push registration — no user action required | + +--- + +## Troubleshooting + +**Push notifications not received:** +- Verify the device token was registered via `PingOneMFA.setDeviceToken(token)`. +- Confirm `google-services.json` is present and matches your application ID. +- Check that the PingOne environment has FCM configured. + +**`getOneTimePasscode()` fails with a device-not-paired error:** +- Ensure `PingOneMFA.pair(pairingKey)` was called and succeeded before requesting an OTP. + +**`generateMobilePayload()` fails:** +- Ensure `PingOneMFA.initialize()` was called and succeeded before this call. +- Check network connectivity and PingOne service status. + +--- + +## License + +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](../LICENSE) file for details. diff --git a/pingonemfa/build.gradle.kts b/pingonemfa/build.gradle.kts index 4531fd936..efef1bc9b 100644 --- a/pingonemfa/build.gradle.kts +++ b/pingonemfa/build.gradle.kts @@ -11,18 +11,25 @@ plugins { id("kotlin-parcelize") alias(libs.plugins.androidLibrary) alias(libs.plugins.kotlinAndroid) + alias(libs.plugins.kotlinSerialization) } android { namespace = "com.pingidentity.pingonemfa" } dependencies { implementation(project(":foundation:android")) + implementation(project(":foundation:logger")) implementation(libs.com.pingidentity.pingonemfa) implementation(libs.kotlinx.coroutines.core) implementation(libs.google.gson.lib) + implementation(libs.kotlinx.serialization.json) // Firebase Cloud Messaging for push notifications implementation(platform(libs.firebase.bom)) implementation(libs.firebase.messaging) + + testImplementation(libs.kotlin.test) + testImplementation(libs.mockk) + testImplementation(libs.kotlinx.coroutines.test) } diff --git a/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/Geo.kt b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/Geo.kt new file mode 100644 index 000000000..7cb6d15f0 --- /dev/null +++ b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/Geo.kt @@ -0,0 +1,39 @@ +/* + * 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.pingonemfa.commons + +import com.pingidentity.pingidsdkv2.PingOneGeo + +/** + * PingOne MFA service region used when configuring the native SDK. + * + * This enum is part of the pingonemfa public API. It intentionally mirrors the + * native SDK regions without exposing the native PingOneGeo type. + */ +enum class Geo { + NORTH_AMERICA, + EUROPE, + CANADA, + AUSTRALIA, + SINGAPORE; + + /** + * Converts the public wrapper value to the native SDK value at the adapter boundary. + * + * Keep this mapping internal so apps can choose a PingOne region without taking a + * compile-time dependency on native PingOne MFA SDK API types. + */ + internal fun toPingOneGeo(): PingOneGeo = + when (this) { + NORTH_AMERICA -> PingOneGeo.NORTH_AMERICA + EUROPE -> PingOneGeo.EUROPE + CANADA -> PingOneGeo.CANADA + AUSTRALIA -> PingOneGeo.AUSTRALIA + SINGAPORE -> PingOneGeo.SINGAPORE + } +} diff --git a/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/PingOneMFA.kt b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/PingOneMFA.kt index 8f57cd20d..4b04f6afc 100644 --- a/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/PingOneMFA.kt +++ b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/PingOneMFA.kt @@ -1,11 +1,18 @@ +/* + * 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.pingonemfa.commons import android.content.Intent import androidx.core.content.ContextCompat import com.google.firebase.messaging.RemoteMessage import com.pingidentity.android.ContextProvider +import com.pingidentity.logger.Logger import com.pingidentity.pingidsdkv2.PingOne -import com.pingidentity.pingidsdkv2.PingOneGeo import com.pingidentity.pingidsdkv2.types.NotificationProvider import com.pingidentity.pingonemfa.otp.OtpCodeInfo import com.pingidentity.pingonemfa.push.PushApprovalService @@ -16,165 +23,284 @@ import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import org.json.JSONObject +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException +/** + * Entry point for all PingOne MFA operations. + * + * This is a singleton that wraps the native PingOne MFA SDK (`pingidsdkv2`) behind a + * coroutine-friendly API. All native callback-based operations are bridged to `suspend` + * functions that return [Result] — callers never need a try/catch. + * + * ## Lifecycle + * Call [initialize] once at application startup before invoking any other function. + * The call is guarded by a mutex and is idempotent — repeated calls after a successful + * initialization return [Result.success] immediately. + * + * ## Error handling + * All `suspend` functions return [Result.failure] wrapping a [PingOneMFAException] on error. + * The native [com.pingidentity.pingidsdkv2.PingOneSDKError] type is never exposed. + */ object PingOneMFA { - + private val logger: Logger = Logger.logger + @Volatile private var isInitialized: Boolean = false - private var lock = Mutex() + private val lock = Mutex() - //SDK must be initialized once and cannot handle parallel configure calls - suspend fun initialize(): Unit = lock.withLock { + /** + * Configures the PingOne MFA SDK for the given service [geo] region. + * + * Must be called once at application startup before any other [PingOneMFA] call. + * Subsequent calls after a successful initialization return [Result.success] immediately + * without re-entering the native SDK. The call is mutex-guarded so parallel invocations + * are safe — only one configure call will reach the native SDK. + */ + suspend fun initialize(geo: Geo): Result = lock.withLock { if (isInitialized) { - return + return Result.success(Unit) } - return suspendCancellableCoroutine { init -> - PingOne.configure( - ContextProvider.context, - // for demonstration purposes we simply hardcode the North America geo - PingOneGeo.NORTH_AMERICA - ) { error -> - if (error == null) { - isInitialized = true - init.resume(Unit) - }else{ - init.resumeWithException(PingOneMFAException(error.message)) + suspendCancellableCoroutine { continuation -> + try { + PingOne.configure( + ContextProvider.context, + geo.toPingOneGeo() + ) { error -> + continuation.resume( + error?.let { + logger.e("PingOne initialization failed: ${it.userInfo}") + Result.failure(PingOneMFAException(it)) + } ?: run { + isInitialized = true + Result.success(Unit) + } + ) } + }catch (e: Exception){ + logger.e("PingOne initialization failed", e) + continuation.resume(Result.failure(PingOneMFAException(e))) } } } - /* - * Registers push token with PingOne. Should be called each time the token is refreshed. + /** + * Registers or refreshes the FCM push [pushToken] with PingOne. + * + * Should be called each time Firebase delivers a new token via + * `FirebaseMessagingService.onNewToken`, and immediately after [initialize] succeeds. */ - suspend fun register(pushToken: String) : Boolean = withContext(Dispatchers.IO) { - suspendCancellableCoroutine { cont -> + suspend fun setDeviceToken(pushToken: String) : Result = withContext(Dispatchers.IO) { + suspendCancellableCoroutine { continuation -> try { PingOne.setDeviceToken( ContextProvider.context, pushToken, NotificationProvider.FCM ) { errors -> - val success = errors == null || errors.isEmpty() || errors.all { it == null } - if (cont.isActive) { - cont.resume(success) - } - } - } catch (_: Exception) { - if (cont.isActive) { - cont.resume(false) + val result = + errors + ?.firstOrNull { it != null } + ?.let { err -> + logger.e("PingOne push token registration failed: ${err.userInfo}") + Result.failure(PingOneMFAException(err)) + } + ?: Result.success(Unit) + + continuation.resume(result) + } + } catch (e : Exception) { + logger.e("PingOne push token registration failed", e) + continuation.resume(Result.failure(PingOneMFAException(e))) } } } - /* - * Starts pairing process with PingOne. + /** + * Pairs the device with a PingOne MFA account using [pairingKey]. + * + * The pairing key is typically obtained by scanning a QR code or from a DaVinci flow. + * On success, the account becomes available via [getDeviceInfo]. */ - suspend fun pair(pairingKey: String): Result = suspendCancellableCoroutine { cont -> + suspend fun pair(pairingKey: String): Result = suspendCancellableCoroutine { continuation -> try { PingOne.pair( ContextProvider.context, pairingKey - ) { pairingInfo, error -> - if (!cont.isActive) { - return@pair - } - if (error == null) { - cont.resume(Result.success(Unit)) - } else { - cont.resume(Result.failure(Exception(error.message))) - } + ) { _, error -> + val result = error?.let { err -> + logger.e("PingOne pairing failed: ${err.userInfo}") + Result.failure(PingOneMFAException(err)) + } ?: Result.success(Unit) + continuation.resume(result) } } catch (e: Exception) { - if (cont.isActive) { - cont.resume(Result.failure(e)) - } + logger.e("PingOne pairing failed", e) + continuation.resume(Result.failure(PingOneMFAException(e))) } } - /* - * Retrieves all paired accounts from PingOne + /** + * Returns metadata for all currently paired PingOne MFA accounts. + * Accounts are mapped into [PingOneMfaAccount] wrapper types. */ - suspend fun getAccounts(): Result> = - suspendCancellableCoroutine { cont -> + suspend fun getDeviceInfo(): Result> = + suspendCancellableCoroutine { continuation -> try { PingOne.getInfo( ContextProvider.context ) { deviceInfo, errors -> - run { - if (!cont.isActive) return@getInfo - if (deviceInfo!= null){ - val accounts = AccountParser().parseAccounts(deviceInfo) - cont.resume(Result.success(accounts)) - }else{ - cont.resume(Result.failure(PingOneMFAException(errors[0]?.message))) - } + /* + * Check errors first: if the SDK signaled a problem and deviceInfo is null or empty, + * treat the call as failed. + */ + val error = errors.firstOrNull { it != null } + val result = if (error != null && (deviceInfo == null || deviceInfo.isEmpty)) { + logger.e("PingOne getDeviceInfo failed: ${error.userInfo}") + Result.failure(PingOneMFAException(error)) + } else if (deviceInfo != null) { + Result.success(AccountParser().parseAccounts(deviceInfo.toString())) + } else { + // Neither errors nor deviceInfo — SDK misbehaved; avoid hanging the coroutine. + logger.e("PingOne getDeviceInfo failed: no data and no error") + Result.failure(PingOneMFAException(Exception("getDeviceInfo failed: no error details provided"))) } + continuation.resume(result) } }catch (e: Exception){ - if (cont.isActive) { - cont.resume(Result.failure(e)) - } + logger.e("PingOne getDeviceInfo failed", e) + continuation.resume(Result.failure(PingOneMFAException(e))) } } - /* - * Retrieves OTP code from PingOne. + /** + * Returns the current one-time passcode and its remaining validity window. + * + * [OtpCodeInfo.secondsRemaining] is a snapshot at call time, clamped to `0` if already + * expired. Re-call this function when the countdown reaches zero to receive the next code. */ - suspend fun collectOtp(): Result = suspendCancellableCoroutine { cont -> - PingOne.getOneTimePassCode(ContextProvider.context) { otpInfo, error -> - if (!cont.isActive) return@getOneTimePassCode - val result = if (otpInfo != null) { - Result.success(OtpCodeInfo( - otpInfo.passcode, - ((otpInfo.validUntil * 1000 - System.currentTimeMillis()) / 1000).toInt() - )) - } else { - Result.failure(PingOneMFAException(error?.message)) + suspend fun getOneTimePasscode(): Result = suspendCancellableCoroutine { continuation -> + try { + PingOne.getOneTimePassCode(ContextProvider.context) { otpInfo, error -> + val result = otpInfo?.let { + Result.success( + OtpCodeInfo( + otpInfo.passcode, + maxOf( + 0, + ((otpInfo.validUntil * 1000 - System.currentTimeMillis()) / 1000).toInt() + ) + ) + ) + } ?: run { + /* + * otpInfo is null but error may also be null if the SDK misbehaves; + * fall back to a generic exception so the coroutine is never left hanging + */ + logger.e("PingOne getOneTimePasscode failed: ${error?.userInfo}") + Result.failure(error?.let { + PingOneMFAException(it) + } ?: PingOneMFAException(Exception("getOneTimePasscode failed: no error details provided")) + ) + } + continuation.resume(result) } - cont.resume(result) + } catch (e: Exception) { + logger.e("PingOne getOneTimePasscode failed", e) + continuation.resume(Result.failure(PingOneMFAException(e))) } } - /* - * Transforms received FCM Remote Message object from PingOne into PushNotification object + /** + * Converts an incoming FCM [message] into a typed [PushNotification]. + * + * Call this from `FirebaseMessagingService.onMessageReceived` when the message data + * contains the `"PingOne"` key. The resulting [PushNotification] provides [PushNotification.getPushType], + * [PushNotification.approveNotification], [PushNotification.denyNotification], and + * [PushNotification.isCancelAuthentication] for the full push response lifecycle. */ - suspend fun collectPush(message: RemoteMessage): Result = - suspendCancellableCoroutine { cont -> + suspend fun processRemoteNotification(message: RemoteMessage): Result = + suspendCancellableCoroutine { continuation -> try { PingOne.processRemoteNotification( ContextProvider.context, message ) { notificationObject, error -> - if (!cont.isActive) return@processRemoteNotification - if (notificationObject != null) { - cont.resume( - Result.success( - PushNotification( - notificationObject = notificationObject, - title = getTitleFromRemoteMessageData(message.data["aps"]), - message = getBodyFromRemoteMessageData(message.data["aps"]) - ) + val result = notificationObject?.let { + Result.success( + PushNotification( + notificationObject = notificationObject, + /* + * Parse title and message from the "aps" field in the FCM data + * payload, which contains the original FCM payload sent by PingOne. + */ + title = getTitleFromRemoteMessageData(message.data["aps"]), + message = getBodyFromRemoteMessageData(message.data["aps"]) ) ) - return@processRemoteNotification + } ?: run { + /* + * notificationObject is null but error may also be null if the SDK + * misbehaves; fall back to a generic exception so the coroutine is + * never left hanging + */ + logger.e("PingOne processRemoteNotification failed: ${error?.userInfo}") + Result.failure(error?.let { + PingOneMFAException(it) + } ?: PingOneMFAException(Exception("processRemoteNotification failed: no error details provided")) + ) } - cont.resume(Result.failure(PingOneMFAException(error?.message))) + continuation.resume(result) } }catch (e: Exception){ - if (cont.isActive) { - cont.resume(Result.failure(PingOneMFAException(e.message))) + logger.e("PingOne processRemoteNotification failed", e) + continuation.resume(Result.failure(PingOneMFAException(e))) + } + } + + /** + * Generates a cryptographic mobile payload string from the native PingOne MFA SDK. + * + * The payload is intended for submission to a server-side authentication flow (e.g. DaVinci). + * Phase 1 exposes only the raw payload string — binding it to a Collector or continuation + * node is the responsibility of the calling layer. + */ + suspend fun generateMobilePayload(): Result = suspendCancellableCoroutine { continuation -> + try { + PingOne.generateMobilePayload(ContextProvider.context) { payload, error -> + val result = payload?.let { + Result.success(payload) + } ?: run { + /* + * payload is null but error may also be null if the SDK misbehaves; + * fall back to a generic exception so the coroutine is never left hanging + */ + logger.e("PingOne generateMobilePayload failed: ${error?.userInfo}") + Result.failure(error?.let { + PingOneMFAException(it) + } ?: PingOneMFAException(Exception("generateMobilePayload failed: no error details provided")) + ) } + continuation.resume(result) } + }catch (e: Exception){ + logger.e("PingOne generateMobilePayload failed", e) + continuation.resume(Result.failure(PingOneMFAException(e))) } + } - /* - * Approves MFA push notification. Should be called from notification action if application is in the background. + /** + * Approves the push authentication request represented by [notification] when the app is in + * the background (e.g. the user tapped Approve on the system notification banner). + * + * Starts [PushApprovalService] as a foreground service so the network call is permitted + * under Android's background execution restrictions. The outcome is not surfaced back to + * the UI — add a custom broadcast or shared state if your app needs to react to it. */ - fun approvePushNotificationFromBanner(notification: PushNotification?){ + fun approvePushNotificationFromBanner(notification: PushNotification){ val appContext = ContextProvider.context val intent = Intent(appContext, PushApprovalService::class.java).apply { putExtra("notification", notification) @@ -184,10 +310,15 @@ object PingOneMFA { ContextCompat.startForegroundService(appContext, intent) } - /* - * Denies MFA push notification. Should be called from notification action if application is in the background. + /** + * Denies the push authentication request represented by [notification] when the app is in + * the background (e.g. the user tapped Deny on the system notification banner). + * + * Starts [PushApprovalService] as a foreground service so the network call is permitted + * under Android's background execution restrictions. The outcome is not surfaced back to + * the UI — add a custom broadcast or shared state if your app needs to react to it. */ - fun denyPushNotificationFromBanner(notification: PushNotification?){ + fun denyPushNotificationFromBanner(notification: PushNotification){ val appContext = ContextProvider.context val intent = Intent(appContext, PushApprovalService::class.java).apply { putExtra("notification", notification) @@ -197,19 +328,24 @@ object PingOneMFA { ContextCompat.startForegroundService(appContext, intent) } - private fun getTitleFromRemoteMessageData(data: String?): String?{ - return if (data == null) { - null - }else{ - JSONObject(data).getJSONObject("alert").getString("title") + private fun getTitleFromRemoteMessageData(data: String?): String? = + data?.let { + Json.parseToJsonElement(it) + .jsonObject["alert"] + ?.jsonObject + ?.get("title") + ?.jsonPrimitive + ?.contentOrNull } - } - private fun getBodyFromRemoteMessageData(data: String?): String?{ - return if (data == null) { - null - }else{ - JSONObject(data).getJSONObject("alert").getString("body") + private fun getBodyFromRemoteMessageData(data: String?): String? = + data?.let { + Json.parseToJsonElement(it) + .jsonObject["alert"] + ?.jsonObject + ?.get("body") + ?.jsonPrimitive + ?.contentOrNull } - } -} \ No newline at end of file + +} diff --git a/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/PingOneMFAException.kt b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/PingOneMFAException.kt index 0576aa0e5..f27f3ce83 100644 --- a/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/PingOneMFAException.kt +++ b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/PingOneMFAException.kt @@ -1,6 +1,34 @@ -package com.pingidentity.pingonemfa.commons /* * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. - * Wrapper for error message into exception class + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +package com.pingidentity.pingonemfa.commons + +import com.pingidentity.pingidsdkv2.PingOneSDKError + +/** + * Exception thrown by suspend variants of the PingOne MFA APIs when an operation fails. + * + * The native [PingOneSDKError] type is intentionally not exposed, so hosting apps do not need + * a direct dependency on the `pingidsdkv2` AAR. All available error information is surfaced + * via the standard [message] property. + * + * ### Usage + * ```kotlin + * result.onFailure { e -> + * Log.e("MFA", e.message) + * } + * ``` */ -class PingOneMFAException(message: String?) : Exception(message) \ No newline at end of file +class PingOneMFAException(message: String?) : Exception(message) { + + // Internal factory constructors — accept native SDK type but keep them off the public API surface entirely. + internal constructor(error: PingOneSDKError) : this( + "Code=${error.code} \"${error.message}\" UserInfo=${error.userInfo}" + ) + + internal constructor(cause: Exception) : this(cause.message ?: "Unknown error") +} \ No newline at end of file diff --git a/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/PingOneMfaAccount.kt b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/PingOneMfaAccount.kt index badf9f067..d4321c2fc 100644 --- a/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/PingOneMfaAccount.kt +++ b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/PingOneMfaAccount.kt @@ -1,13 +1,30 @@ +/* + * 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.pingonemfa.commons -/* - * Represents a PingOne MFA account. +/** + * Metadata for a paired PingOne MFA account returned by + * [com.pingidentity.pingonemfa.commons.PingOneMFA.getDeviceInfo]. + * + * @property region Region key from the PingOne response (e.g. `"NA"`, `"EU"`). + * @property id PingOne user ID. + * @property deviceId Device ID associated with this pairing within PingOne. + * @property environment PingOne environment ID. + * @property username The account's login username as returned by the PingOne server. + * @property name User's given (first) name, or null if not provided by the server. + * @property family User's family (last) name, or null if not provided by the server. */ data class PingOneMfaAccount( val region: String, val id: String, val deviceId: String, val environment: String, - val name: String, - val family: String + val username: String, + val name: String?, + val family: String? ) \ No newline at end of file diff --git a/pingonemfa/src/main/java/com/pingidentity/pingonemfa/otp/OtpCodeInfo.kt b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/otp/OtpCodeInfo.kt index be4861fc1..5168f5409 100644 --- a/pingonemfa/src/main/java/com/pingidentity/pingonemfa/otp/OtpCodeInfo.kt +++ b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/otp/OtpCodeInfo.kt @@ -1,7 +1,21 @@ +/* + * 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.pingonemfa.otp -/* - * Very simple model for OTP code information. +/** + * The current one-time passcode returned by + * [com.pingidentity.pingonemfa.commons.PingOneMFA.getOneTimePasscode]. + * + * @property code The current TOTP passcode string (typically 6 digits). + * @property secondsRemaining Seconds until the code expires, computed at call time. + * Clamped to `0` if the code is already past its validity window. + * Re-call [com.pingidentity.pingonemfa.commons.PingOneMFA.getOneTimePasscode] when this + * reaches zero to receive the next code. */ data class OtpCodeInfo( val code: String, diff --git a/pingonemfa/src/main/java/com/pingidentity/pingonemfa/push/PushApprovalService.kt b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/push/PushApprovalService.kt index 7ad650037..45ac2679a 100644 --- a/pingonemfa/src/main/java/com/pingidentity/pingonemfa/push/PushApprovalService.kt +++ b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/push/PushApprovalService.kt @@ -1,3 +1,10 @@ +/* + * 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.pingonemfa.push //noinspection SuspiciousImport @@ -8,11 +15,14 @@ import android.app.NotificationManager import android.app.Service import android.content.Intent import android.os.Build -import android.util.Log import androidx.core.app.NotificationCompat +import com.pingidentity.logger.Logger +import com.pingidentity.pingidsdkv2.types.DenyReason +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume @@ -22,9 +32,12 @@ import kotlin.coroutines.resumeWithException * Service for handling push notifications actions. This service exists to solve ONE specific Android restriction: * Android does NOT allow network calls in the background (from notification actions in particular). */ -internal class PushApprovalService : Service(){ +internal class PushApprovalService( + dispatcher: CoroutineDispatcher = Dispatchers.IO +) : Service(){ - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val scope = CoroutineScope(SupervisorJob() + dispatcher) + private val logger: Logger = Logger.logger override fun onBind(p0: Intent?) = null @@ -53,7 +66,7 @@ internal class PushApprovalService : Service(){ denyNotificationWithAppInBackground(notificationObject) } } catch (e: Exception) { - Log.e("MfaApprovalService", "approval failed: ${e.message}", e) + logger.e("MfaApprovalService: push approval failed", e) } finally { stopForeground(STOP_FOREGROUND_REMOVE) stopSelf(startId) @@ -90,7 +103,7 @@ internal class PushApprovalService : Service(){ this, auth, null - ) { error -> + ) { _, error -> if (!cont.isActive) return@approve if (error == null) cont.resume(Unit) else cont.resumeWithException(Exception(error.message ?: "Approval failed")) @@ -104,7 +117,9 @@ internal class PushApprovalService : Service(){ ) = suspendCancellableCoroutine { cont -> try { notification.notificationObject.deny( - this){ error -> + this, + DenyReason.NONE + ){ error -> if (!cont.isActive) return@deny if (error == null) cont.resume(Unit) else cont.resumeWithException(Exception(error.message ?: "Deny action failed")) @@ -118,4 +133,18 @@ internal class PushApprovalService : Service(){ private const val NOTIFICATION_ID = 7001 } + /* + * Cancel the coroutine scope when Android destroys the service. + * + * Without this, the SupervisorJob stays alive after onDestroy() is called: + * any in-flight approve/deny network call would keep running against a dead + * service instance, holding a reference to it and leaking memory until the + * coroutine eventually completes or is garbage-collected. Cancelling here + * ensures all child coroutines are interrupted immediately and the scope + * cannot launch new work after the service is gone. + */ + override fun onDestroy() { + super.onDestroy() + scope.cancel() + } } \ No newline at end of file diff --git a/pingonemfa/src/main/java/com/pingidentity/pingonemfa/push/PushNotification.kt b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/push/PushNotification.kt index d1abb9e3b..e31c6053d 100644 --- a/pingonemfa/src/main/java/com/pingidentity/pingonemfa/push/PushNotification.kt +++ b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/push/PushNotification.kt @@ -1,26 +1,49 @@ +/* + * 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.pingonemfa.push import android.content.Context import android.os.Parcelable import com.pingidentity.pingidsdkv2.NotificationObject +import com.pingidentity.pingidsdkv2.types.DenyReason +import com.pingidentity.pingonemfa.commons.PingOneMFAException import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.parcelize.Parcelize import java.util.UUID import kotlin.coroutines.resume -/* - * Simple model for a push notification. Implements Parcelable so it can be passed between components. +/** + * Wrapper model for a PingOne MFA push authentication request. + * + * Produced by [com.pingidentity.pingonemfa.commons.PingOneMFA.processRemoteNotification] from an + * incoming FCM [com.google.firebase.messaging.RemoteMessage]. All user-facing operations + * (approve, deny) are exposed as suspend functions that return [Result]. + * + * @property id Wrapper-generated unique identifier for this push request. + * @property title Notification title extracted from the FCM data payload, or null if absent. + * @property message Notification body extracted from the FCM data payload, or null if absent. */ @Parcelize data class PushNotification( val id: String = UUID.randomUUID().toString(), val notificationObject: NotificationObject, val title: String?, - val message: String?, - val sentAt: Long = System.currentTimeMillis(), - val respondedAt: Long? = null + val message: String? ): Parcelable { + /** + * Approves this push authentication request. + * + * @param context Android context — use `applicationContext` when calling from a service. + * @param authenticationMethod The authentication method string sent to PingOne (e.g. `"app"`). + * @param numberChallenge The number selected by the user for a [PushType.CHALLENGE] push, + * or `null` for a [PushType.DEFAULT] push. + */ suspend fun approveNotification( context: Context, authenticationMethod: String, @@ -30,57 +53,68 @@ data class PushNotification( context, authenticationMethod, numberChallenge - ) { error -> - if (!cont.isActive) return@approve + ) { _, error -> if (error == null) { cont.resume(Result.success(Unit)) } else { - cont.resume(Result.failure(Exception(error.userInfo.toString()))) + cont.resume(Result.failure(PingOneMFAException(error))) } } } catch (e: Exception) { - if (cont.isActive) { - cont.resume(Result.failure(e)) - } + cont.resume(Result.failure(PingOneMFAException(e))) } } + /** + * Denies this push authentication request. + * + * @param context Android context — use `applicationContext` when calling from a service. + */ suspend fun denyNotification(context: Context) : Result = suspendCancellableCoroutine { cont -> try { notificationObject.deny( - context + context, + DenyReason.NONE ) { error -> - if (!cont.isActive) return@deny if (error == null) { cont.resume(Result.success(Unit)) } else { - cont.resume(Result.failure(Exception(error.userInfo.toString()))) + cont.resume(Result.failure(PingOneMFAException(error))) } } } catch (e: Exception) { - if (cont.isActive) { - cont.resume(Result.failure(e)) - } + cont.resume(Result.failure(PingOneMFAException(e))) } } - fun requiresBiometric() : Boolean{ - return !isChallenge() - } - - fun isChallenge() : Boolean{ - return notificationObject.numberMatchingType!=null + /** + * Returns `true` when the server has canceled this authentication request — for example, + * because the user approved it on another device. + * + * When this returns `true`, the app should dismiss any visible approval UI immediately + * without requiring the user to take action. + */ + fun isCancelAuthentication(): Boolean { + return notificationObject.isCancelAuth } + /** + * Returns the server-provided number options for a [PushType.CHALLENGE] push, or `null` + * when free-form digit entry is expected instead. + */ fun getNumbersChallenge(): IntArray? { return notificationObject.numberMatchingOptions } + /** + * Returns the [PushType] that describes the interaction model required by this push. + * UI components should switch on this to decide which approval flow to present. + */ fun getPushType () : PushType { return when { - notificationObject.isTest -> PushType.DRY // TODO handle in Sample App + notificationObject.isTest -> PushType.DRY notificationObject.numberMatchingType != null -> PushType.CHALLENGE - else -> PushType.DEFAULT // TODO handle how to receive BIOMETRIC enforcement + else -> PushType.DEFAULT } } } diff --git a/pingonemfa/src/main/java/com/pingidentity/pingonemfa/push/PushType.kt b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/push/PushType.kt index fb62338bc..0a971839a 100644 --- a/pingonemfa/src/main/java/com/pingidentity/pingonemfa/push/PushType.kt +++ b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/push/PushType.kt @@ -1,8 +1,36 @@ +/* + * 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.pingonemfa.push +/** + * Describes the interaction model required by a PingOne MFA push notification. + * + * The value is determined by the server and delivered inside the push payload. + * UI components should switch on this type to decide which approval flow to present. + */ enum class PushType { + /** + * A standard authentication request. The user approves or denies with a single tap. + */ DEFAULT, + + /** + * A silent test push sent by the server to verify the device's push registration. + * No user action is required — the notification should be dismissed automatically + * or shown with a "Dismiss" button only. + */ DRY, - CHALLENGE, - BIOMETRIC -} \ No newline at end of file + + /** + * A number-matching challenge. The server provides a set of numbers (or expects + * free-form digit input when none are provided), and the user must confirm the one + * that matches what they see on their other device. Use [PushNotification.getNumbersChallenge] + * to retrieve the options; a null return means free-form entry is expected. + */ + CHALLENGE +} diff --git a/pingonemfa/src/main/java/com/pingidentity/pingonemfa/util/AccountParser.kt b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/util/AccountParser.kt index ab2b6060a..e0e62b3ba 100644 --- a/pingonemfa/src/main/java/com/pingidentity/pingonemfa/util/AccountParser.kt +++ b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/util/AccountParser.kt @@ -1,29 +1,62 @@ +/* + * 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.pingonemfa.util -import com.google.gson.JsonObject import com.pingidentity.pingonemfa.commons.PingOneMfaAccount +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json -internal class AccountParser { - fun parseAccounts(json: JsonObject): List { - val result = mutableListOf() - - json.entrySet().forEach { (region, regionElement) -> - val users = regionElement.asJsonObject - .getAsJsonArray("users") - ?.mapNotNull { it.asJsonObject } - ?: emptyList() +internal class AccountParser( + private val json: Json = Json { + ignoreUnknownKeys = true + explicitNulls = false + } +) { + fun parseAccounts(rawJson: String): List { + val decoded: Map = json.decodeFromString(rawJson) - users.forEach { user -> - result += PingOneMfaAccount( + return decoded.flatMap { (region, regionDto) -> + regionDto.users.map { + PingOneMfaAccount( region = region, - id = user.get("id")?.asString ?: "", - environment = user.getAsJsonObject("environment")?.get("id")?.asString ?: "", - deviceId = user.getAsJsonObject("device")?.get("id")?.asString ?: "", - name = user.getAsJsonObject("name")?.get("given")?.asString ?: "", - family = user.getAsJsonObject("name")?.get("family")?.asString ?: "" + id = it.id.orEmpty(), + environment = it.environment?.id.orEmpty(), + deviceId = it.device?.id.orEmpty(), + username = it.username, + name = it.name?.given.orEmpty(), + family = it.name?.family.orEmpty() ) } } - return result } -} \ No newline at end of file +} + +@Serializable +internal data class RegionDto( + val users: List = emptyList() +) + +@Serializable +internal data class UserDto( + val id: String? = null, + val environment: IdContainer? = null, + val device: IdContainer? = null, + val username: String, + val name: NameDto? = null +) + +@Serializable +internal data class IdContainer( + val id: String? = null +) + +@Serializable +internal data class NameDto( + val given: String? = null, + val family: String? = null +) \ No newline at end of file diff --git a/pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/commons/GeoTest.kt b/pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/commons/GeoTest.kt new file mode 100644 index 000000000..89ac68810 --- /dev/null +++ b/pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/commons/GeoTest.kt @@ -0,0 +1,30 @@ +/* + * 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.pingonemfa.commons + +import com.pingidentity.pingidsdkv2.PingOneGeo +import org.junit.Test +import kotlin.test.assertEquals + +class GeoTest { + + @Test + fun `toPingOneGeo maps all public geos to native PingOneGeo values`() { + val mappings = mapOf( + Geo.NORTH_AMERICA to PingOneGeo.NORTH_AMERICA, + Geo.EUROPE to PingOneGeo.EUROPE, + Geo.CANADA to PingOneGeo.CANADA, + Geo.AUSTRALIA to PingOneGeo.AUSTRALIA, + Geo.SINGAPORE to PingOneGeo.SINGAPORE + ) + + mappings.forEach { (geo, expectedPingOneGeo) -> + assertEquals(expectedPingOneGeo, geo.toPingOneGeo()) + } + } +} diff --git a/pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/commons/PingOneMFATest.kt b/pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/commons/PingOneMFATest.kt new file mode 100644 index 000000000..2f3928ab9 --- /dev/null +++ b/pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/commons/PingOneMFATest.kt @@ -0,0 +1,572 @@ +package com.pingidentity.pingonemfa.commons + +import android.content.Context +import androidx.core.content.ContextCompat +import com.google.firebase.messaging.RemoteMessage +import com.google.gson.JsonObject +import com.pingidentity.android.ContextProvider +import com.pingidentity.pingidsdkv2.NotificationObject +import com.pingidentity.pingidsdkv2.PingOne +import com.pingidentity.pingidsdkv2.PingOneGeo +import com.pingidentity.pingidsdkv2.PingOneSDKError +import com.pingidentity.pingidsdkv2.types.NotificationProvider +import com.pingidentity.pingidsdkv2.types.OneTimePasscodeInfo +import com.pingidentity.pingidsdkv2.types.PairingInfo +import com.pingidentity.pingonemfa.push.PushNotification +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PingOneMFATest { + private val mockContext = mockk(relaxed = true) + private val mockRemoteMessage = mockk(relaxed = true) + private val mockNotificationObject = mockk(relaxed = true) + private val mockPairingInfo = mockk(relaxed = true) + private val mockDeviceInfo = mockk(relaxed = true) + @Before + fun setUp() { + MockKAnnotations.init(this, relaxed = true) + + // mock ContextProvider object and provide a mock Context + mockkObject(ContextProvider) + every { ContextProvider.context } returns mockContext + + // mock PingOne static functions (Java class) + mockkStatic("com.pingidentity.pingidsdkv2.PingOne") + + + // default remote message data with APS containing an alert/body + every { mockRemoteMessage.data } returns mapOf( + "aps" to """ + { + "alert": { + "title": "mocked title", + "body": "mocked body" + } + } + """.trimIndent() + ) + + every{ mockDeviceInfo.toString()} returns """ + { + "NA": { + "users": [ + { + "id": "user1", + "environment": { "id": "env1" }, + "device": { "id": "dev1" }, + "username": "jdoe", + "name": { "given": "John", "family": "Doe" } + } + ] + } + } + """.trimIndent() + + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `initialize configures once and returns success when callback error is null`() = runTest { + + // Reset singleton state FIRST + val field = PingOneMFA::class.java.getDeclaredField("isInitialized") + field.isAccessible = true + field.setBoolean(PingOneMFA, false) + + + every { + PingOne.configure(any(), any(), any()) + } answers { + val callback = arg(2) + callback.onComplete(null) + } + + // ACT + val first = PingOneMFA.initialize(Geo.NORTH_AMERICA) + val second = PingOneMFA.initialize(Geo.EUROPE) + + // ASSERT + assertTrue(first.isSuccess) + assertTrue(second.isSuccess) + + verify(exactly = 1) { + PingOne.configure(any(), any(), any()) + } + } + + @Test + fun `initialize maps geo to PingOneGeo`() = runTest { + + // Reset singleton state FIRST + val field = PingOneMFA::class.java.getDeclaredField("isInitialized") + field.isAccessible = true + field.setBoolean(PingOneMFA, false) + + every { + PingOne.configure(any(), any(), any()) + } answers { + val callback = arg(2) + callback.onComplete(null) + } + + // ACT + val result = PingOneMFA.initialize(Geo.EUROPE) + + // ASSERT + assertTrue(result.isSuccess) + + verify(exactly = 1) { + PingOne.configure(any(), PingOneGeo.EUROPE, any()) + } + } + + @Test + fun `initialize configures once and returns error`() = runTest { + + // Reset singleton state FIRST + val field = PingOneMFA::class.java.getDeclaredField("isInitialized") + field.isAccessible = true + field.setBoolean(PingOneMFA, false) + + + every { + PingOne.configure(any(), any(), any()) + } answers { + val callback = arg(2) + callback.onComplete(PingOneSDKError(100, "mockedError")) + } + + // ACT + val result = PingOneMFA.initialize(Geo.NORTH_AMERICA) + + // ASSERT — known SDK failure: message is the formatted string, no SDK type needed + assertTrue(result.isFailure) + assertTrue { result.exceptionOrNull() is PingOneMFAException } + assertTrue { result.exceptionOrNull()!!.message!!.contains("100") } + } + + @Test + fun `initialize configures once and throws exception`() = runTest { + + // Reset singleton state FIRST + val field = PingOneMFA::class.java.getDeclaredField("isInitialized") + field.isAccessible = true + field.setBoolean(PingOneMFA, false) + + every { + PingOne.configure(any(), any(), any()) + } throws RuntimeException("Simulated configuration failure") + + // ACT + val result = PingOneMFA.initialize(Geo.NORTH_AMERICA) + + // ASSERT — unexpected exception path: message is forwarded from the original exception + assertTrue(result.isFailure) + assertTrue { result.exceptionOrNull() is PingOneMFAException } + assertEquals("Simulated configuration failure", result.exceptionOrNull()!!.message) + } + + @Test + fun `register returns success when callback errors is null`() = runTest { + every { + PingOne.setDeviceToken(any(), any(), any(), any()) + } answers { + val callback = arg(3) + callback.onComplete(Array(1) { null }) + } + + val result = PingOneMFA.setDeviceToken("token") + + assertTrue(result.isSuccess) + verify { + PingOne.setDeviceToken(mockContext, "token", NotificationProvider.FCM, any()) + } + } + + @Test + fun `register returns error when callback errors is not null`() = runTest { + every { + PingOne.setDeviceToken(any(), any(), any(), any()) + } answers { + val callback = arg(3) + callback.onComplete(Array(1) { PingOneSDKError(10003, "mockedError") }) + } + + val result = PingOneMFA.setDeviceToken("token") + + // Known SDK failure: message contains the formatted SDK code + assertTrue(result.isFailure) + assertTrue { result.exceptionOrNull() is PingOneMFAException } + assertTrue { result.exceptionOrNull()!!.message!!.contains("10003") } + verify { + PingOne.setDeviceToken(mockContext, "token", NotificationProvider.FCM, any()) + } + } + + @Test + fun `register returns error when exception is thrown`() = runTest { + every { + PingOne.setDeviceToken(any(), any(), any(), any()) + } throws RuntimeException("Simulated network error") + + val result = PingOneMFA.setDeviceToken("token") + + // Unexpected exception path: message is forwarded from the original exception + assertTrue(result.isFailure) + assertTrue { result.exceptionOrNull() is PingOneMFAException } + assertEquals("Simulated network error", result.exceptionOrNull()!!.message) + verify { + PingOne.setDeviceToken(mockContext, "token", NotificationProvider.FCM, any()) + } + } + + @Test + fun `pair returns success when callback error is null`() = runTest { + every { + PingOne.pair(any(), any(), any()) + } answers { + val callback = arg(2) + callback.onComplete(mockPairingInfo, null) + } + + val result = PingOneMFA.pair("PAIR-KEY") + + assertTrue(result.isSuccess) + verify { + PingOne.pair(mockContext, "PAIR-KEY", any()) + } + } + + @Test + fun `pair returns error when callback error is not null`() = runTest { + every { + PingOne.pair(any(), any(), any()) + } answers { + val callback = arg(2) + callback.onComplete(null, PingOneSDKError(10003, "mockedError")) + } + + val result = PingOneMFA.pair("PAIR-KEY") + + // Known SDK failure: message contains the formatted SDK code + assertTrue(result.isFailure) + assertTrue { result.exceptionOrNull() is PingOneMFAException } + assertTrue { result.exceptionOrNull()!!.message!!.contains("10003") } + verify { + PingOne.pair(mockContext, "PAIR-KEY", any()) + } + } + + @Test + fun `pair returns error when exception is thrown`() = runTest { + every { + PingOne.pair(any(), any(), any()) + } throws RuntimeException("Simulated network error") + + val result = PingOneMFA.pair("PAIR-KEY") + + // Unexpected exception path: message is forwarded from the original exception + assertTrue(result.isFailure) + assertTrue { result.exceptionOrNull() is PingOneMFAException } + assertEquals("Simulated network error", result.exceptionOrNull()!!.message) + verify { + PingOne.pair(mockContext, "PAIR-KEY", any()) + } + } + + @Test + fun `getAccounts returns success when deviceInfo is present`() = runTest { + every { + PingOne.getInfo(any(), any()) + } answers { + val callback = arg(1) + callback.onComplete(mockDeviceInfo, Array(1) { null }) + } + + val result = PingOneMFA.getDeviceInfo() + + assertTrue(result.isSuccess) + + val accounts = result.getOrNull()!! + assertEquals(1, accounts.size) + assertEquals("user1", accounts.first().id) + assertEquals("env1", accounts.first().environment) + assertEquals("dev1", accounts.first().deviceId) + assertEquals("jdoe", accounts.first().username) + assertEquals("John", accounts.first().name) + assertEquals("Doe", accounts.first().family) + verify { + PingOne.getInfo(mockContext, any()) + } + } + + @Test + fun `getAccounts returns error when deviceInfo is not present`() = runTest { + every{ + PingOne.getInfo(any(), any()) + } answers { + val callback = arg(1) + callback.onComplete(null, Array(1){ PingOneSDKError(10003, "mockedError") }) + } + val result = PingOneMFA.getDeviceInfo() + // Known SDK failure: message contains the formatted SDK code + assertTrue(result.isFailure) + assertTrue { result.exceptionOrNull() is PingOneMFAException } + assertTrue { result.exceptionOrNull()!!.message!!.contains("10003") } + } + + @Test + fun `getAccounts returns error when errors list is empty`() = runTest { + // Defensive case: SDK returns null deviceInfo and an empty errors array — should not hang + every { + PingOne.getInfo(any(), any()) + } answers { + val callback = arg(1) + callback.onComplete(null, emptyArray()) + } + val result = PingOneMFA.getDeviceInfo() + // Fallback exception path: cause carries the generic message + assertTrue(result.isFailure) + assertTrue { result.exceptionOrNull() is PingOneMFAException } + assertEquals("getDeviceInfo failed: no error details provided", result.exceptionOrNull()!!.message + ) + } + + @Test + fun `getAccounts returns error when exception is thrown`() = runTest{ + every { + PingOne.getInfo(any(), any()) + } throws RuntimeException("Simulated network error") + val result = PingOneMFA.getDeviceInfo() + // Unexpected exception path: message is forwarded from the original exception + assertTrue(result.isFailure) + assertTrue { result.exceptionOrNull() is PingOneMFAException } + assertEquals("Simulated network error", result.exceptionOrNull()!!.message) + } + + + + + @Test + fun `collectOtp returns success when callback error is null`() = runTest { + every { + PingOne.getOneTimePassCode(any(), any()) + } answers { + val callback = arg(1) + callback.onComplete(OneTimePasscodeInfo("123456", 100000, 30), null) + } + val result = PingOneMFA.getOneTimePasscode() + assertTrue(result.isSuccess) + assertEquals(result.getOrNull()?.code, "123456") + } + + @Test + fun `collectOtp returns error when callback error is not null`() = runTest { + every { + PingOne.getOneTimePassCode(any(), any()) + } answers { + val callback = arg(1) + callback.onComplete(null, PingOneSDKError(10003, "mockedError")) + } + val result = PingOneMFA.getOneTimePasscode() + // Known SDK failure: message contains the formatted SDK code + assertTrue(result.isFailure) + assertTrue { result.exceptionOrNull() is PingOneMFAException } + assertTrue { result.exceptionOrNull()!!.message!!.contains("10003") } + } + + @Test + fun `collectOtp returns error when both otpInfo and error are null`() = runTest { + // Defensive case: SDK returns null otpInfo and null error — should not hang + every { + PingOne.getOneTimePassCode(any(), any()) + } answers { + val callback = arg(1) + callback.onComplete(null, null) + } + val result = PingOneMFA.getOneTimePasscode() + // Fallback exception path: cause carries the generic message + assertTrue(result.isFailure) + assertTrue { result.exceptionOrNull() is PingOneMFAException } + assertEquals("getOneTimePasscode failed: no error details provided", result.exceptionOrNull()!!.message) + } + + @Test + fun `collectOtp returns error when exception is thrown`() = runTest{ + every { + PingOne.getOneTimePassCode(any(), any()) + } throws RuntimeException("Simulated network error") + val result = PingOneMFA.getOneTimePasscode() + // Unexpected exception path: message is forwarded from the original exception + assertTrue(result.isFailure) + assertTrue { result.exceptionOrNull() is PingOneMFAException } + assertEquals("Simulated network error", result.exceptionOrNull()!!.message) + } + + @Test + fun `collectPush returns success when callback error is null`() = runTest{ + every { + PingOne.processRemoteNotification(any(), any(), any()) + } answers { + val callback = arg(2) + callback.onComplete(mockNotificationObject, null) + } + val result = PingOneMFA.processRemoteNotification(mockRemoteMessage) + assertTrue(result.isSuccess) + assertEquals(result.getOrNull()?.notificationObject, mockNotificationObject) + assertEquals("mocked title", result.getOrNull()?.title) + assertEquals("mocked body", result.getOrNull()?.message) + + verify { + PingOne.processRemoteNotification(mockContext, mockRemoteMessage, any()) + } + } + + @Test + fun `collectPush returns error when callback error is not null`() = runTest{ + every { + PingOne.processRemoteNotification(any(), any(), any()) + } answers { + val callback = arg(2) + callback.onComplete(null, PingOneSDKError(10003, "mockedError")) + } + val result = PingOneMFA.processRemoteNotification(mockRemoteMessage) + // Known SDK failure: message contains the formatted SDK code + assertTrue(result.isFailure) + assertTrue { result.exceptionOrNull() is PingOneMFAException } + assertTrue { result.exceptionOrNull()!!.message!!.contains("10003") } + } + + @Test + fun `collectPush returns error when both notificationObject and error are null`() = runTest { + // Defensive case: SDK returns null notification and null error — should not hang + every { + PingOne.processRemoteNotification(any(), any(), any()) + } answers { + val callback = arg(2) + callback.onComplete(null, null) + } + val result = PingOneMFA.processRemoteNotification(mockRemoteMessage) + // Fallback exception path: cause carries the generic message + assertTrue(result.isFailure) + assertTrue { result.exceptionOrNull() is PingOneMFAException } + assertEquals("processRemoteNotification failed: no error details provided", result.exceptionOrNull()!!.message) + } + + @Test + fun `collectPush returns error when exception is thrown`() = runTest{ + every { + PingOne.processRemoteNotification(any(), any(), any()) + } throws RuntimeException("Mocked Exception") + val result = PingOneMFA.processRemoteNotification(mockRemoteMessage) + // Unexpected exception path: message is forwarded from the original exception + assertTrue(result.isFailure) + assertTrue { result.exceptionOrNull() is PingOneMFAException } + assertEquals("Mocked Exception", result.exceptionOrNull()!!.message) + } + + @Test + fun `collectMobilePayload returns payload when error is null`() = runTest { + every { + PingOne.generateMobilePayload(any(), any()) + } answers { + val callback = arg(1) + callback.onComplete("mockPayload", null) + } + val result = PingOneMFA.generateMobilePayload() + assertTrue(result.isSuccess) + assertEquals(result.getOrNull(), "mockPayload") + } + + @Test + fun `collectMobilePayload returns error when error is not null`() = runTest { + every { + PingOne.generateMobilePayload(any(), any()) + } answers { + val callback = arg(1) + callback.onComplete(null, PingOneSDKError(10003, "mockedError")) + } + val result = PingOneMFA.generateMobilePayload() + // Known SDK failure: message contains the formatted SDK code + assertTrue(result.isFailure) + assertTrue { result.exceptionOrNull() is PingOneMFAException } + assertTrue { result.exceptionOrNull()!!.message!!.contains("10003") } + } + + @Test + fun `collectMobilePayload returns error when both payload and error are null`() = runTest { + // Defensive case: SDK returns null payload and null error — should not hang + every { + PingOne.generateMobilePayload(any(), any()) + } answers { + val callback = arg(1) + callback.onComplete(null, null) + } + val result = PingOneMFA.generateMobilePayload() + // Fallback exception path: cause carries the generic message + assertTrue(result.isFailure) + assertTrue { result.exceptionOrNull() is PingOneMFAException } + assertEquals("generateMobilePayload failed: no error details provided", result.exceptionOrNull()!!.message) + } + + @Test + fun `collectMobilePayload returns error when exception is thrown`() = runTest { + every { + PingOne.generateMobilePayload(any(), any()) + } throws RuntimeException("Mocked Exception") + val result = PingOneMFA.generateMobilePayload() + // Unexpected exception path: message is forwarded from the original exception + assertTrue(result.isFailure) + assertTrue { result.exceptionOrNull() is PingOneMFAException } + assertEquals("Mocked Exception", result.exceptionOrNull()!!.message) + } + + @Test + fun `approvePushNotificationFromBanner starts foreground service with correct intent`() { + val mockNotification = mockk(relaxed = true) + mockkStatic(ContextCompat::class) + every { + ContextCompat.startForegroundService(any(), any()) + } returns mockk() + + PingOneMFA.approvePushNotificationFromBanner(mockNotification) + + // verify service start + verify(exactly = 1) { + ContextCompat.startForegroundService(mockContext, any()) + } + } + + @Test + fun `denyPushNotificationFromBanner starts foreground service with intent`() { + val mockNotification = mockk(relaxed = true) + + mockkStatic(ContextCompat::class) + every { + ContextCompat.startForegroundService(any(), any()) + } returns mockk() + + // act + PingOneMFA.denyPushNotificationFromBanner(mockNotification) + + // verify wiring + verify(exactly = 1) { + ContextCompat.startForegroundService(mockContext, any()) + } + } +} diff --git a/pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/push/PushApprovalServiceTest.kt b/pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/push/PushApprovalServiceTest.kt new file mode 100644 index 000000000..04888e9de --- /dev/null +++ b/pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/push/PushApprovalServiceTest.kt @@ -0,0 +1,161 @@ +package com.pingidentity.pingonemfa.push + +import android.app.Notification +import android.app.NotificationManager +import android.content.Intent +import com.pingidentity.pingidsdkv2.NotificationObject +import com.pingidentity.pingidsdkv2.PingOne +import com.pingidentity.pingidsdkv2.types.DenyReason +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.spyk +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import kotlin.test.Test + +class PushApprovalServiceTest { + private val testDispatcher = StandardTestDispatcher() + + private lateinit var notificationObject: NotificationObject + private lateinit var pushNotification: PushNotification + + private lateinit var service: PushApprovalService + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + + notificationObject = mockk(relaxed = true) + + pushNotification = PushNotification( + notificationObject = notificationObject, + title = "title", + message = "message" + ) + + service = spyk(PushApprovalService(dispatcher = testDispatcher), recordPrivateCalls = true) + // CRITICAL: block Android notification creation + every { + service["createForegroundNotification"]() + } returns mockk(relaxed = true) + + // Prevent Android framework calls + every { service.startForeground(any(), any()) } just Runs + every { service.stopForeground(any()) } just Runs + every { service.stopSelf(any()) } just Runs + + // Notification plumbing + every { + service.getSystemService(NotificationManager::class.java) + } returns mockk(relaxed = true) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + @Test + fun `onStartCommand approves notification when user_action is approve`() = runTest { + every { + notificationObject.approve(any(), any(), any(), any()) + } answers { + val callback = arg(3) + callback.onComplete(null, null) + } + + val intent = spyk(Intent()) + + // Mock both overloads: the single-arg deprecated one (pre-Tiramisu) and the + // two-arg typed one (API 33+). onStartCommand branches on Build.VERSION.SDK_INT + // so only one path is exercised at runtime, but both must be stubbed so the test + // compiles and runs correctly regardless of which API level the host JVM reports. + every { + intent.getParcelableExtra("notification") + } returns pushNotification + every { + intent.getParcelableExtra("notification", PushNotification::class.java) + } returns pushNotification + + every { + intent.getStringExtra("auth_method") + } returns "banner" + + every { + intent.getStringExtra("user_action") + } returns "approve" + + service.onStartCommand(intent, 0, 1) + + testDispatcher.scheduler.advanceUntilIdle() + + // All arguments must use matchers when any() is present — mixing literals and + // matchers causes MockK to misinterpret null as a non-matcher and fail verification + // even when the call did occur with the expected values. + verify { + notificationObject.approve( + any(), + eq("banner"), + isNull(), + any() + ) + } + } + + @Test + fun `onStartCommand denies notification when user_action is deny`() = runTest { + every { + notificationObject.deny(any(), DenyReason.NONE, any()) + } answers { + val callback = args[2] + callback!! + .javaClass + .getMethod("onComplete", Any::class.java) + .invoke(callback, null) + } + + val intent = spyk(Intent()) + + // Mock both overloads: the single-arg deprecated one (pre-Tiramisu) and the + // two-arg typed one (API 33+). See approve test for full explanation. + every { + intent.getParcelableExtra("notification") + } returns pushNotification + every { + intent.getParcelableExtra("notification", PushNotification::class.java) + } returns pushNotification + + every { + intent.getStringExtra("auth_method") + } returns "banner" + + every { + intent.getStringExtra("user_action") + } returns "deny" + + service.onStartCommand(intent, 0, 1) + + testDispatcher.scheduler.advanceUntilIdle() + verify { + notificationObject.deny( + service, + DenyReason.NONE, + any() + ) + } + + } +} diff --git a/pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/push/PushNotificationTest.kt b/pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/push/PushNotificationTest.kt new file mode 100644 index 000000000..50fd4a5b7 --- /dev/null +++ b/pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/push/PushNotificationTest.kt @@ -0,0 +1,220 @@ +package com.pingidentity.pingonemfa.push + +import android.content.Context +import com.pingidentity.pingidsdkv2.NotificationObject +import com.pingidentity.pingidsdkv2.PingOne +import com.pingidentity.pingidsdkv2.PingOneSDKError +import com.pingidentity.pingidsdkv2.types.DenyReason +import com.pingidentity.pingonemfa.commons.PingOneMFAException +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertTrue +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PushNotificationTest { + private val context = mockk(relaxed = true) + private val notificationObject = mockk(relaxed = true) + + @Test + fun `approveNotification returns success when callback error is null`() = runTest { + every { + notificationObject.approve(any(), any(), any(), any()) + } answers { + val callback = arg(3) + callback.onComplete(null, null) + } + + val push = PushNotification( + notificationObject = notificationObject, + title = "t", + message = "m" + ) + + val result = push.approveNotification(context, "banner") + + assertTrue(result.isSuccess) + } + + @Test + fun `approveNotification returns error when callback error is not null`() = runTest { + every { + notificationObject.approve(any(), any(), any(), any()) + } answers { + val callback = arg(3) + callback.onComplete(null, PingOneSDKError(1003, "mock")) + } + val push = PushNotification( + notificationObject = notificationObject, + title = "t", + message = "m" + ) + val result = push.approveNotification(context, "banner") + assertTrue(result.isFailure) + // Known SDK failure: message contains the formatted SDK code + assertTrue { result.exceptionOrNull() is PingOneMFAException } + assertTrue { result.exceptionOrNull()!!.message!!.contains("1003") } + } + @Test + fun `approveNotification returns failure when exception is thrown`() = runTest { + every { + notificationObject.approve(any(), any(), any(), any()) + } throws RuntimeException("Simulated Network Error") + + val push = PushNotification( + notificationObject = notificationObject, + title = null, + message = null + ) + + val result = push.approveNotification(context, "banner") + + assertTrue(result.isFailure) + assertTrue { result.exceptionOrNull() is Exception } + assertTrue { result.exceptionOrNull()?.message == "Simulated Network Error" } + } + + @Test + fun `denyNotification returns success when callback error is null`() = runTest { + every { + notificationObject.deny(any(), DenyReason.NONE, any()) + } answers { + val callback = arg(2) + callback.onComplete(null) + } + val push = PushNotification( + notificationObject = notificationObject, + title = "t", + message = "m" + ) + val result = push.denyNotification(context) + assertTrue(result.isSuccess) + + } + @Test + fun `denyNotification returns error when callback error is not null`() = runTest { + every { + notificationObject.deny(any(), DenyReason.NONE, any()) + } answers { + val callback = arg(2) + callback.onComplete(PingOneSDKError(1003, "mock")) + } + val push = PushNotification( + notificationObject = notificationObject, + title = "t", + message = "m" + ) + val result = push.denyNotification(context) + assertTrue(result.isFailure) + // Known SDK failure: message contains the formatted SDK code + assertTrue { result.exceptionOrNull() is PingOneMFAException } + assertTrue { result.exceptionOrNull()!!.message!!.contains("1003") } + } + + @Test + fun `denyNotification returns failure when exception is thrown`() = runTest { + every { + notificationObject.deny(any(), DenyReason.NONE, any()) + } throws RuntimeException("Simulated Network Error") + + val push = PushNotification( + notificationObject = notificationObject, + title = null, + message = null + ) + val result = push.denyNotification(context) + assertTrue(result.isFailure) + assertTrue { result.exceptionOrNull() is Exception } + assertTrue { result.exceptionOrNull()?.message == "Simulated Network Error" } + } + + @Test + fun `getNumbersChallenge returns correct values when notificationObject numberMatchingType is not null`(){ + every { notificationObject.numberMatchingType } returns "mock" + every { notificationObject.numberMatchingOptions } returns intArrayOf(1,2,3) + val push = PushNotification( + notificationObject = notificationObject, + title = "t", + message = "m" + ) + assertTrue(push.getNumbersChallenge()?.contentEquals(intArrayOf(1,2,3)) ?: false) + } + + @Test + fun `getNumbersChallenge returns empty array when notificationObject numberMatchingType is null`(){ + every { notificationObject.numberMatchingType } returns null + val push = PushNotification( + notificationObject = notificationObject, + title = "t", + message = "m" + ) + assertTrue(notificationObject.numberMatchingOptions == null || notificationObject.numberMatchingOptions.isEmpty()) + } + + @Test + fun `getPushType returns DRY when isTest`() { + every { notificationObject.isTest } returns true + + val push = PushNotification( + notificationObject = notificationObject, + title = null, + message = null + ) + + assertEquals(PushType.DRY, push.getPushType()) + } + @Test + fun `getPushType returns CHALLENGE when numberMatchingType present`() { + every { notificationObject.isTest } returns false + every { notificationObject.numberMatchingType } returns "type" + + val push = PushNotification( + notificationObject = notificationObject, + title = null, + message = null + ) + + assertEquals(PushType.CHALLENGE, push.getPushType()) + } + @Test + fun `getPushType returns DEFAULT otherwise`() { + every { notificationObject.isTest } returns false + every { notificationObject.numberMatchingType } returns null + + val push = PushNotification( + notificationObject = notificationObject, + title = null, + message = null + ) + + assertEquals(PushType.DEFAULT, push.getPushType()) + } + + @Test + fun `isCancelAuthentication returns true when notificationObject isCancelAuth is true`() { + every { notificationObject.isCancelAuth } returns true + + val push = PushNotification( + notificationObject = notificationObject, + title = null, + message = null + ) + + assertTrue(push.isCancelAuthentication()) + } + + @Test + fun `isCancelAuthentication returns false when notificationObject isCancelAuth is false`() { + every { notificationObject.isCancelAuth } returns false + + val push = PushNotification( + notificationObject = notificationObject, + title = null, + message = null + ) + + assertTrue(!push.isCancelAuthentication()) + } +} diff --git a/pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/util/AccountParserTest.kt b/pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/util/AccountParserTest.kt new file mode 100644 index 000000000..6172f4eb8 --- /dev/null +++ b/pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/util/AccountParserTest.kt @@ -0,0 +1,185 @@ +package com.pingidentity.pingonemfa.util + +import kotlinx.serialization.json.Json +import org.junit.Assert.assertTrue +import kotlin.test.Test +import kotlin.test.assertEquals + +class AccountParserTest { + + private val parser = AccountParser() + + @Test + fun `single region single user`() { + val json = """ + { + "NA": { + "users": [ + { + "id": "u1", + "environment": { "id": "env1" }, + "device": { "id": "d1" }, + "username": "jdoe", + "name": { "given": "John", "family": "Doe" } + } + ] + } + } + """ + + val result = parser.parseAccounts(json) + + assertEquals(1, result.size) + val account = result.first() + + assertEquals("NA", account.region) + assertEquals("u1", account.id) + assertEquals("env1", account.environment) + assertEquals("d1", account.deviceId) + assertEquals("John", account.name) + assertEquals("Doe", account.family) + } + + @Test + fun `multiple regions multiple users`() { + val json = """ + { + "NA": { + "users": [{ "id": "u1", "username": "jdoe" }] + }, + "EU": { + "users": [{ "id": "u2", "username": "asmith" }] + } + } + """.trimIndent() + + val result = parser.parseAccounts(json) + + assertEquals(2, result.size) + assertTrue(result.any { it.region == "NA" && it.id == "u1" }) + assertTrue(result.any { it.region == "EU" && it.id == "u2" }) + } + + @Test + fun `missing users defaults to empty`() { + val json = """ + { + "NA": {} + } + """.trimIndent() + + val result = parser.parseAccounts(json) + + assertTrue(result.isEmpty()) + } + + @Test + fun `missing nested objects produce empty strings`() { + val json = """ + { + "NA": { + "users": [{ "id": "u1", "username": "jdoe" }] + } + } + """.trimIndent() + + val account = parser.parseAccounts(json).first() + + assertEquals("", account.environment) + assertEquals("", account.deviceId) + assertEquals("", account.name) + assertEquals("", account.family) + } + + @Test + fun `partial name object`() { + val json = """ + { + "NA": { + "users": [ + { "id": "u1", "username": "jdoe", "name": { "given": "Alice" } } + ] + } + } + """.trimIndent() + + val account = parser.parseAccounts(json).first() + + assertEquals("Alice", account.name) + assertEquals("", account.family) + } + + @Test + fun `unknown fields are ignored`() { + val json = """ + { + "NA": { + "users": [ + { + "id": "u1", + "unknown": "value", + "username": "jdoe", "environment": { "id": "env1", "extra": "x" } + } + ] + } + } + """.trimIndent() + + val account = parser.parseAccounts(json).first() + + assertEquals("env1", account.environment) + } + + @Test + fun `large payload`() { + val users = (1..100).joinToString(",") { + """{ "id": "user-$it", "username": "user$it" }""" + } + + val json = """ + { + "NA": { + "users": [$users] + } + } + """.trimIndent() + + val result = parser.parseAccounts(json) + + assertEquals(100, result.size) + assertEquals("user-1", result.first().id) + assertEquals("user-100", result.last().id) + } + + @Test + fun `RegionDto is serializable`() { + val json = Json { ignoreUnknownKeys = true } + + val original = mapOf( + "NA" to RegionDto( + users = listOf( + UserDto( + id = "u1", + environment = IdContainer("env"), + device = IdContainer("dev"), + username = "jdoe", + name = NameDto("John", "Doe") + ) + ) + ) + ) + + val encoded = json.encodeToString(original) + val decoded = json.decodeFromString>(encoded) + + assertEquals(original, decoded) + } + + @Test + fun `throws on invalid json`() { + val result = runCatching { + parser.parseAccounts("""{ "NA": "invalid" }""") + } + assertTrue(result.isFailure) + } +} \ No newline at end of file diff --git a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/BiometricPromptActivity.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/BiometricPromptActivity.kt index b7784f051..c2a3d2a96 100644 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/BiometricPromptActivity.kt +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/BiometricPromptActivity.kt @@ -8,6 +8,7 @@ package com.pingidentity.pingonemfapp.notification import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity @@ -44,15 +45,17 @@ class BiometricPromptActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - - // Get notification ID from intent early - val notificationId = intent?.getStringExtra(NotificationActionReceiver.EXTRA_NOTIFICATION_ID) // Get notification object from intent - val notification = intent?.getParcelableExtra(NotificationActionReceiver.EXTRA_NOTIFICATION, PushNotification::class.java) - // If no notification ID, log and finish - if (notificationId == null) { - diagnosticLogger.w("No notification ID provided") + val notification = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent?.getParcelableExtra(NotificationActionReceiver.EXTRA_NOTIFICATION, PushNotification::class.java) + } else { + @Suppress("DEPRECATION") // Suppress deprecation warning for backward compatibility + intent?.getParcelableExtra(NotificationActionReceiver.EXTRA_NOTIFICATION) + } + // If no notification, log and finish + if (notification == null) { + diagnosticLogger.w("No notification provided") finish() return } @@ -228,7 +231,6 @@ class BiometricPromptActivity : AppCompatActivity() { } result?.isFailure == true -> { diagnosticLogger.e("Error approving with challenge: ${result.exceptionOrNull()?.stackTrace}") - //errorMessage = "Failed to approve: ${result.exceptionOrNull()?.message}" } } } diff --git a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/NotificationActionReceiver.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/NotificationActionReceiver.kt index cc9770abb..e047ca5ea 100644 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/NotificationActionReceiver.kt +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/NotificationActionReceiver.kt @@ -27,7 +27,6 @@ class NotificationActionReceiver : BroadcastReceiver() { const val ACTION_APPROVE = "com.pingidentity.pingonemfapp.ACTION_APPROVE" const val ACTION_DENY = "com.pingidentity.pingonemfapp.ACTION_DENY" const val ACTION_BIOMETRIC = "com.pingidentity.pingonemfapp.ACTION_BIOMETRIC" - const val EXTRA_NOTIFICATION_ID = "notification_id" const val EXTRA_NOTIFICATION = "com.pingidentity.pingonemfapp.notification" } @@ -36,24 +35,25 @@ class NotificationActionReceiver : BroadcastReceiver() { val notification = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { intent.getParcelableExtra(EXTRA_NOTIFICATION, PushNotification::class.java) } else { + @Suppress("DEPRECATION") // Suppress deprecation warning for backward compatibility intent.getParcelableExtra(EXTRA_NOTIFICATION) - } - val notificationId = intent.getStringExtra(EXTRA_NOTIFICATION_ID) ?: return - val notificationHashCode = notificationId.hashCode() + } ?: return + + val notificationHashCode = notification.id.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") + diagnosticLogger.d("Approve action received for notification: ${notification.id}") PingOneMFA.approvePushNotificationFromBanner(notification = notification) } ACTION_DENY -> { - diagnosticLogger.d("Deny action received for notification: $notificationId") + diagnosticLogger.d("Deny action received for notification: ${notification.id}") PingOneMFA.denyPushNotificationFromBanner(notification = notification) } ACTION_BIOMETRIC -> { - diagnosticLogger.d("Biometric action received for notification: $notificationId") + diagnosticLogger.d("Biometric action received for notification: ${notification.id}") handleBiometricAuthentication(context, notification) } } @@ -63,10 +63,9 @@ class NotificationActionReceiver : BroadcastReceiver() { * Handles biometric authentication for the notification with the given ID. * This launches the BiometricPrompt activity. */ - private fun handleBiometricAuthentication(context: Context, notification: PushNotification?) { + private fun handleBiometricAuthentication(context: Context, notification: PushNotification) { val intent = Intent(context, BiometricPromptActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK - putExtra(EXTRA_NOTIFICATION_ID, notification?.id) putExtra(EXTRA_NOTIFICATION, notification) } context.startActivity(intent) diff --git a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/NotificationHelper.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/NotificationHelper.kt index 629b0fab6..25e624036 100644 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/NotificationHelper.kt +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/NotificationHelper.kt @@ -20,7 +20,6 @@ import androidx.core.app.NotificationManagerCompat import com.pingidentity.pingonemfapp.R import com.pingidentity.pingonemfapp.notification.NotificationActionReceiver.Companion.ACTION_APPROVE import com.pingidentity.pingonemfapp.notification.NotificationActionReceiver.Companion.ACTION_DENY -import com.pingidentity.pingonemfapp.notification.NotificationActionReceiver.Companion.EXTRA_NOTIFICATION_ID import com.pingidentity.pingonemfa.push.PushNotification import com.pingidentity.pingonemfa.push.PushType @@ -73,8 +72,6 @@ class NotificationHelper(private val context: Context) { // 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) // Add notification object putExtra(NotificationActionReceiver.EXTRA_NOTIFICATION, notification) } @@ -147,7 +144,6 @@ class NotificationHelper(private val context: Context) { // Approve action val approveIntent = Intent(context, NotificationActionReceiver::class.java).apply { action = ACTION_APPROVE - putExtra(EXTRA_NOTIFICATION_ID, notificationId) putExtra(NotificationActionReceiver.EXTRA_NOTIFICATION, notification) } @@ -161,7 +157,6 @@ class NotificationHelper(private val context: Context) { // Deny action val denyIntent = Intent(context, NotificationActionReceiver::class.java).apply { action = ACTION_DENY - putExtra(EXTRA_NOTIFICATION_ID, notificationId) putExtra(NotificationActionReceiver.EXTRA_NOTIFICATION, notification) } val denyPendingIntent = PendingIntent.getBroadcast( @@ -198,7 +193,6 @@ class NotificationHelper(private val context: Context) { // 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) putExtra(NotificationActionReceiver.EXTRA_NOTIFICATION, notification) } diff --git a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/PushNotificationActivity.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/PushNotificationActivity.kt index 1de2711b1..be9a85460 100644 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/PushNotificationActivity.kt +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/PushNotificationActivity.kt @@ -27,7 +27,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import com.pingidentity.pingonemfapp.data.DiagnosticLogger -import com.pingidentity.pingonemfapp.notification.NotificationActionReceiver.Companion.EXTRA_NOTIFICATION_ID import com.pingidentity.pingonemfapp.ui.NotificationResponseScreen import com.pingidentity.pingonemfapp.ui.theme.PingIdentityAuthenticatorTheme import com.pingidentity.pingonemfa.push.PushNotification @@ -46,17 +45,15 @@ class PushNotificationActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - - // Get notification ID from intent - val notificationId = intent?.getStringExtra(EXTRA_NOTIFICATION_ID) val notification = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { intent?.getParcelableExtra(EXTRA_NOTIFICATION, PushNotification::class.java) } else { + @Suppress("DEPRECATION") // Suppress deprecation warning for backward compatibility intent?.getParcelableExtra(EXTRA_NOTIFICATION) } - // If no notification ID, log and finish - if (notificationId == null) { + // If no notification, log and finish + if (notification == null) { diagnosticLogger.w("No notification ID provided") finish() return @@ -111,31 +108,31 @@ class PushNotificationActivity : ComponentActivity() { onDismiss = { finish() }, onApprove = { coroutineScope.launch { - val result = notification?.approveNotification( + val result = notification.approveNotification( context, "user" ) when { - result?.isSuccess == true -> { + result.isSuccess -> { finish() } - result?.isFailure == true -> { + result.isFailure -> { diagnosticLogger.e("Error approving with challenge: ${result.exceptionOrNull()?.stackTrace}") } } } }, onBiometricApprove = { - launchBiometricPrompt(notificationId, notification) + launchBiometricPrompt(notification) }, onDeny = { coroutineScope.launch { - val result = notification?.denyNotification(context) + val result = notification.denyNotification(context) when { - result?.isSuccess == true -> { + result.isSuccess -> { finish() } - result?.isFailure == true -> { + result.isFailure -> { diagnosticLogger.e("Error approving with challenge: ${result.exceptionOrNull()?.stackTrace}") } } @@ -143,16 +140,16 @@ class PushNotificationActivity : ComponentActivity() { }, onChallengeSolution = { solution -> coroutineScope.launch { - val result = notification?.approveNotification( - context, - "user", - solution.toInt() + val result = notification.approveNotification( + context, + "user", + solution.toInt() ) when { - result?.isSuccess == true -> { + result.isSuccess -> { finish() } - result?.isFailure == true -> { + result.isFailure -> { diagnosticLogger.e("Error approving with challenge: ${result.exceptionOrNull()?.stackTrace}") } } @@ -169,9 +166,8 @@ class PushNotificationActivity : ComponentActivity() { /** * Launches the BiometricPromptActivity for biometric authentication. */ - private fun launchBiometricPrompt(notificationId: String, notification: PushNotification?) { + private fun launchBiometricPrompt(notification: PushNotification?) { val intent = Intent(this, BiometricPromptActivity::class.java).apply { - putExtra(EXTRA_NOTIFICATION_ID, notificationId) putExtra(EXTRA_NOTIFICATION, notification) } startActivity(intent) diff --git a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/service/PushNotificationService.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/service/PushNotificationService.kt index 0b825cf22..0dfee0abf 100644 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/service/PushNotificationService.kt +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/service/PushNotificationService.kt @@ -123,7 +123,6 @@ class PushNotificationService : FirebaseMessagingService() { // Launch the PushNotificationActivity with the notification ID and notification object val intent = Intent(applicationContext, PushNotificationActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP - putExtra(NotificationActionReceiver.EXTRA_NOTIFICATION_ID, notification.id) putExtra(NotificationActionReceiver.EXTRA_NOTIFICATION, notification) } diff --git a/samples/pingsampleapp/README.md b/samples/pingsampleapp/README.md index 8cd1a79c2..6869ff12a 100644 --- a/samples/pingsampleapp/README.md +++ b/samples/pingsampleapp/README.md @@ -34,6 +34,17 @@ The Ping Sample App is a consolidated sample that brings together functionality - Challenge verification - **QR Scanner**: Integrated camera-based QR code scanning +### 🔒 PingOne MFA +- **QR Code Registration**: Scan a QR code to pair the device with PingOne MFA +- **MFA Accounts**: View all paired PingOne MFA accounts +- **One-Time Passcode**: Display the current OTP code with a live countdown +- **Mobile Payload**: Generate a mobile payload for server-side authentication flows +- **Push Notifications**: Full foreground and background push authentication handling + - DEFAULT: approve or deny with a single tap + - CHALLENGE: number-matching with server-provided options or free-form digit entry + - DRY: silent test push with automatic dismissal + - Cancellation: server-revoked requests are dismissed automatically on all surfaces + ### 🛠️ Developer Tools - **Configuration**: Environment selection (Staging/Snapshot) with custom URL support - **Device Information**: Comprehensive device data collection and display @@ -50,6 +61,7 @@ PingSampleApp ├── Authentication Flows (Journey, DaVinci, OIDC) ├── User Management (Profile, Token, Device, Logout) ├── MFA Features (OATH, Push, QR Scanner) +├── PingOne MFA (Pairing, OTP, Push, Payload) ├── Developer Tools (Config, Device Info, Logger) └── Shared Components (Navigation, Theme, Config) ``` @@ -58,10 +70,10 @@ PingSampleApp #### 1. **PingSampleApplication** Application-level initialization and dependency management: -- Initializes SDK clients (OATH, Push) +- Initializes SDK clients (OATH, Push, PingOne MFA) - Creates and manages managers (OathManager, PushManager, JourneyManager) - Provides application-scoped AuthenticatorViewModel -- Handles Firebase Cloud Messaging setup +- Handles Firebase Cloud Messaging setup and token registration for both MFA modules #### 2. **Navigation System** Centralized navigation with proper screen routing: @@ -77,7 +89,14 @@ Complete MFA functionality integrated from AuthenticatorApp: - **Services**: PushNotificationService, LocationService - **Notification Handlers**: BiometricPromptActivity, NotificationActionReceiver -#### 4. **Device Management** +#### 4. **PingOne MFA Integration** +Direct integration of the `pingonemfa` module for PingOne push and OTP: +- **ViewModel**: PingOneMFAViewModel — manages pairing, OTP countdown, payload, and account state +- **UI Screens**: PingOneQrScannerScreen, PingOneMFAAccountsScreen, PingOneOTPScreen, PingOnePayloadScreen, PingOnePushNotificationScreen +- **Notification**: PingOneNotificationHelper, PingOneNotificationActionReceiver, PingOnePushNotificationActivity +- **Store**: PushNotificationStore — single-slot in-process store for the active push notification + +#### 5. **Device Management** Comprehensive device registration and management: - Device registration with custom names - Device list display with platform icons @@ -85,7 +104,7 @@ Comprehensive device registration and management: - Device deletion with confirmation - Automatic list refresh -#### 5. **Token Management** +#### 6. **Token Management** Access token viewing and manipulation: - Pretty-printed JSON display - Token refresh functionality @@ -116,6 +135,13 @@ For Push notifications: 2. Configure Firebase Cloud Messaging in Firebase Console 3. Enable push notifications in device settings +### PingOne MFA Setup + +The PingOne MFA module (`pingonemfa`) requires additional one-time configuration: +1. `PingOneMFA.initialize(Geo.NORTH_AMERICA)` is called automatically at startup in `PingSampleApplication` — update the `Geo` value to match your PingOne environment's region +2. The FCM token is registered with PingOne automatically via `PingOneMFA.setDeviceToken(token)` whenever Firebase delivers a new token +3. See the [pingonemfa README](../pingonemfa/README.md) for the full list of supported regions and API reference +4. See the [PingOne MFA documentation](https://docs.pingidentity.com/pingone/strong_authentication_mfa/p1_strong_authentication_configure_mobile_applications.html) for server-side configuration and integration details ## Implementation Highlights ### ViewModel Initialization @@ -214,6 +240,12 @@ fun logoutAll() { - Push - Push Notifications +**PINGONE MFA** +- QR Code Registration +- MFA Accounts +- One-Time Passcode +- Mobile Payload + **DEVELOPER TOOLS** - Configuration - Device Information @@ -222,7 +254,7 @@ fun logoutAll() { ## Dependencies Key dependencies include: -- Ping Identity SDK modules (Journey, DaVinci, OIDC, MFA) +- Ping Identity SDK modules (Journey, DaVinci, OIDC, MFA, PingOne MFA) - Jetpack Compose for UI - Navigation Component - Firebase Cloud Messaging @@ -268,7 +300,9 @@ com.pingidentity.samples.pingsampleapp │ ├── managers/ # Business logic managers │ ├── ui/ # Authenticator screens │ ├── notification/ # Push notification handlers -│ └── service/ # Background services +│ └── service/ # Background services (PushNotificationService) +├── pingonemfa/ # PingOne MFA integration +│ └── notification/ # Push management ├── config/ # Environment configuration ├── davinci/ # DaVinci flow screens ├── devicemanagement/ # Device registration/management diff --git a/samples/pingsampleapp/build.gradle.kts b/samples/pingsampleapp/build.gradle.kts index 1ed791fc9..a2acd1fd9 100644 --- a/samples/pingsampleapp/build.gradle.kts +++ b/samples/pingsampleapp/build.gradle.kts @@ -101,6 +101,9 @@ dependencies { implementation(project(":mfa:auth-migration")) implementation(project(":foundation:migration")) + // PingOne MFA + implementation(project(":pingonemfa")) + //Application Pin implementation(libs.bcpkix.jdk18on) diff --git a/samples/pingsampleapp/src/main/AndroidManifest.xml b/samples/pingsampleapp/src/main/AndroidManifest.xml index 62df17468..c9a6ca4d6 100644 --- a/samples/pingsampleapp/src/main/AndroidManifest.xml +++ b/samples/pingsampleapp/src/main/AndroidManifest.xml @@ -66,6 +66,14 @@ android:name=".authenticator.notification.NotificationActionReceiver" android:exported="false" /> + + + + + + + + + + + diagnosticLogger.e("PingSampleApplication: PingOne MFA SDK initialization failed: ${e.message}") + } + } + /** * Initializes the AuthenticatorViewModel. */ diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/authenticator/service/PushNotificationService.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/authenticator/service/PushNotificationService.kt index d47fa31ad..76f280f81 100644 --- a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/authenticator/service/PushNotificationService.kt +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/authenticator/service/PushNotificationService.kt @@ -5,17 +5,25 @@ import android.content.Intent import androidx.annotation.RequiresPermission import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage +import com.pingidentity.pingonemfa.commons.PingOneMFA import com.pingidentity.samples.pingsampleapp.PingSampleApplication import com.pingidentity.samples.pingsampleapp.authenticator.data.DiagnosticLogger import com.pingidentity.samples.pingsampleapp.authenticator.notification.NotificationActionReceiver import com.pingidentity.samples.pingsampleapp.authenticator.notification.NotificationHelper import com.pingidentity.samples.pingsampleapp.authenticator.notification.PushNotificationActivity +import com.pingidentity.samples.pingsampleapp.pingonemfa.notification.PingOneNotificationHelper 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.cancel import kotlinx.coroutines.launch +import androidx.core.app.NotificationManagerCompat +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import com.pingidentity.pingonemfa.push.PushNotification as PingOnePushNotification +import com.pingidentity.samples.pingsampleapp.pingonemfa.notification.PushNotificationStore +import com.pingidentity.samples.pingsampleapp.pingonemfa.notification.PingOnePushNotificationActivity /** * Service to handle incoming Firebase Cloud Messaging notifications. @@ -28,6 +36,7 @@ class PushNotificationService : FirebaseMessagingService() { private val diagnosticLogger = DiagnosticLogger private lateinit var notificationHelper: NotificationHelper + private lateinit var pingOneNotificationHelper: PingOneNotificationHelper override fun onCreate() { @@ -37,6 +46,9 @@ class PushNotificationService : FirebaseMessagingService() { notificationHelper = NotificationHelper(this) notificationHelper.createNotificationChannels() + pingOneNotificationHelper = PingOneNotificationHelper(this) + pingOneNotificationHelper.createNotificationChannel() + scope.launch { pushClient = PingSampleApplication.getPushClient() } @@ -44,6 +56,7 @@ class PushNotificationService : FirebaseMessagingService() { override fun onDestroy() { super.onDestroy() + scope.cancel() diagnosticLogger.d("PushNotificationService instance destroyed") } @@ -74,6 +87,8 @@ class PushNotificationService : FirebaseMessagingService() { scope.launch { // Update the device token in the PushClient pushClient?.setDeviceToken(token) + // Update the device token in PingOneMFA + PingOneMFA.setDeviceToken(token) } } @@ -84,6 +99,22 @@ class PushNotificationService : FirebaseMessagingService() { override fun onMessageReceived(remoteMessage: RemoteMessage) { diagnosticLogger.d("Message received from: ${remoteMessage.from}") + // Check if the message received from PingOne MFA Push by looking for expected key + if (remoteMessage.data.containsKey("PingOne")) { + diagnosticLogger.d("Received PingOne MFA Push message") + scope.launch { + // Process the notification using PingOneMFA's processRemoteNotification method, which handles decryption and validation + PingOneMFA.processRemoteNotification(remoteMessage) + .onSuccess { + diagnosticLogger.d("Successfully collected PingOne MFA push notification") + // Handle the notification (this will display a system notification or full-screen notification based on app state) + handlePingOneNotification(it) + } + .onFailure { error -> + diagnosticLogger.e("Failed to collect PingOne MFA push notification: ${error.message}") + } + } + } // Handle the message data payload if (remoteMessage.data.isNotEmpty()) { diagnosticLogger.d("Message data payload: ${remoteMessage.data}") @@ -172,4 +203,75 @@ class PushNotificationService : FirebaseMessagingService() { displaySystemNotification(notification) } } + + /** + * Handle a PingOne notification that's already been processed. + * + * If [PingOnePushNotification.isCancelAuthentication] is true, the server has revoked the + * outstanding request (e.g. because it was handled on another device). In that case: + * - Remove the notification from [PushNotificationStore] so it can no longer be acted on. + * - Cancel any system banner that may still be visible. + * - If the app is in the foreground and the activity is open, broadcast a cancellation + * signal so [PingOnePushNotificationActivity] can dismiss itself immediately. + * + * Otherwise, display the appropriate UI based on app state. + */ + @RequiresPermission(android.Manifest.permission.POST_NOTIFICATIONS) + fun handlePingOneNotification(notification: PingOnePushNotification) { + diagnosticLogger.d("Handling PingOne notification: ${notification.id}") + + if (notification.isCancelAuthentication()) { + diagnosticLogger.d("PingOne notification is a cancellation — dismissing: ${notification.id}") + + val currentNotificationId = PushNotificationStore.remove() + if (currentNotificationId == null){ + // notification was already dismissed, no -op + diagnosticLogger.d("No active PingOne notification in store, nothing to cancel") + return + } + // Dismiss the system banner if it was shown while the app was in the background. + NotificationManagerCompat.from(this).cancel(currentNotificationId.hashCode()) + + + // If the activity is currently open for this notification, tell it to close. + val cancelIntent = Intent(PingOnePushNotificationActivity.ACTION_CANCEL_NOTIFICATION).apply { + putExtra(PingOnePushNotificationActivity.EXTRA_PINGONE_NOTIFICATION_ID, currentNotificationId) + } + LocalBroadcastManager.getInstance(this).sendBroadcast(cancelIntent) + return + } + + if (isAppInForeground()) { + diagnosticLogger.d("App is in foreground, launching notification activity") + showPingOneFullScreenNotification(notification) + } else { + diagnosticLogger.d("App is in background, displaying system notification") + displayPingOneSystemNotification(notification) + } + } + + /** + * Shows a system banner for a PingOne push authentication request. + * Delegates to [PingOneNotificationHelper]. + */ + @RequiresPermission(android.Manifest.permission.POST_NOTIFICATIONS) + private fun displayPingOneSystemNotification(notification: PingOnePushNotification) { + pingOneNotificationHelper.showPushNotification(notification) + } + + /** + * Launches [PingOnePushNotificationActivity] to handle the push while the app is in the foreground. + */ + private fun showPingOneFullScreenNotification(notification: PingOnePushNotification) { + diagnosticLogger.d("Launching PingOnePushNotificationActivity for: ${notification.id}") + + // Store in memory and pass only the ID + PushNotificationStore.put(notification) + + val intent = Intent(this, PingOnePushNotificationActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP + putExtra(PingOnePushNotificationActivity.EXTRA_PINGONE_NOTIFICATION_ID, notification.id) + } + startActivity(intent) + } } \ No newline at end of file diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/home/HomeApp.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/home/HomeApp.kt index e6e361e32..d20b48b97 100644 --- a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/home/HomeApp.kt +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/home/HomeApp.kt @@ -26,6 +26,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Logout +import androidx.compose.material.icons.filled.AccountBox import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.DeviceHub @@ -33,6 +34,7 @@ import androidx.compose.material.icons.filled.Key import androidx.compose.material.icons.filled.LockPerson import androidx.compose.material.icons.filled.LogoDev import androidx.compose.material.icons.filled.Map +import androidx.compose.material.icons.filled.Memory import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.PhoneAndroid import androidx.compose.material.icons.filled.Preview @@ -40,6 +42,7 @@ import androidx.compose.material.icons.filled.QrCodeScanner import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Storage import androidx.compose.material.icons.filled.SwapHoriz +import androidx.compose.material.icons.filled.Tag import androidx.compose.material.icons.filled.Token import androidx.compose.material.icons.filled.VpnKey import androidx.compose.material3.Card @@ -99,6 +102,10 @@ fun HomeApp( onDeviceIdClick : () -> Unit, onAuthTestScreenClick : () -> Unit, onAuthMigrationClick : () -> Unit, + onPingOneAccountsClick : () -> Unit, + onPingOneOTPClick : () -> Unit, + onPingOnePayloadClick : () -> Unit, + onPingOneQrScannerClick : () -> Unit, ) { var deviceId by remember { mutableStateOf("Loading Device ID...") } var deviceStatus by remember { mutableStateOf("Loading device status...") } @@ -299,6 +306,43 @@ fun HomeApp( onClick = onAuthMigrationClick ) + // PingOne MFA Section + Text( + text = stringResource(R.string.text_home_section_pingone_mfa), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(vertical = 16.dp) + ) + + IconRowItem( + icon = Icons.Default.QrCodeScanner, + title = stringResource(R.string.text_pingone_mfa_qr_scanner_title), + subtitle = stringResource(R.string.text_pingone_mfa_qr_scanner_subtitle), + onClick = onPingOneQrScannerClick + ) + + IconRowItem( + icon = Icons.Default.AccountBox, + title = stringResource(R.string.text_pingone_mfa_accounts_title), + subtitle = stringResource(R.string.text_pingone_mfa_accounts_subtitle), + onClick = onPingOneAccountsClick + ) + + IconRowItem( + icon = Icons.Default.Tag, + title = stringResource(R.string.text_pingone_mfa_otp_title), + subtitle = stringResource(R.string.text_pingone_mfa_otp_subtitle), + onClick = onPingOneOTPClick + ) + + IconRowItem( + icon = Icons.Default.Memory, + title = stringResource(R.string.text_pingone_mfa_payload_title), + subtitle = stringResource(R.string.text_pingone_mfa_payload_subtitle), + onClick = onPingOnePayloadClick + ) + // Developer Tools Section Text( text = stringResource(R.string.text_home_section_developer_tools), @@ -482,7 +526,11 @@ fun PreviewHomeApp() { onPushNotificationClick = {}, onDeviceIdClick = {}, onAuthTestScreenClick = {}, - onAuthMigrationClick = {} + onAuthMigrationClick = {}, + onPingOneAccountsClick = {}, + onPingOneOTPClick = {}, + onPingOnePayloadClick = {}, + onPingOneQrScannerClick = {} ) } diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/navigation/Navigation.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/navigation/Navigation.kt index 8f04a4d78..fa2bc9fb3 100644 --- a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/navigation/Navigation.kt +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/navigation/Navigation.kt @@ -59,6 +59,10 @@ import com.pingidentity.samples.pingsampleapp.devicemanagement.DeviceManagement import com.pingidentity.samples.pingsampleapp.devicemanagement.DeviceManagementViewModel import com.pingidentity.samples.pingsampleapp.devtools.DeviceInfo import com.pingidentity.samples.pingsampleapp.home.HomeApp +import com.pingidentity.samples.pingsampleapp.pingonemfa.ui.PingOneMFAAccountsScreen +import com.pingidentity.samples.pingsampleapp.pingonemfa.ui.PingOneOTPScreen +import com.pingidentity.samples.pingsampleapp.pingonemfa.ui.PingOnePayloadScreen +import com.pingidentity.samples.pingsampleapp.pingonemfa.ui.PingOneQrScannerScreen import com.pingidentity.samples.pingsampleapp.journey.JourneyScreen import com.pingidentity.samples.pingsampleapp.journey.JourneyRoute import com.pingidentity.samples.pingsampleapp.journey.JourneyViewModel @@ -101,7 +105,10 @@ object Route { fun routeForAuthAppAccount(accountName: String) = "account/$accountName" const val ROUTE_AUTH_TEST_APP = "route_auth_test_app" const val AUTH_MIGRATION = "auth_migration" - + const val ROUTE_PINGONE_ACCOUNTS = "pingone_accounts" + const val ROUTE_PINGONE_OTP = "pingone_otp" + const val ROUTE_PINGONE_PAYLOAD = "pingone_payload" + const val ROUTE_PINGONE_QR_SCANNER = "pingone_qr_scanner" } /** @@ -178,6 +185,18 @@ fun AppNavigation( onAuthMigrationClick = { navController.navigate(Route.AUTH_MIGRATION) }, + onPingOneAccountsClick = { + navController.navigate(Route.ROUTE_PINGONE_ACCOUNTS) + }, + onPingOneOTPClick = { + navController.navigate(Route.ROUTE_PINGONE_OTP) + }, + onPingOnePayloadClick = { + navController.navigate(Route.ROUTE_PINGONE_PAYLOAD) + }, + onPingOneQrScannerClick = { + navController.navigate(Route.ROUTE_PINGONE_QR_SCANNER) + } ) } @@ -521,6 +540,31 @@ fun AppNavigation( onBack = { navController.popBackStack() } ) } + composable(Route.ROUTE_PINGONE_ACCOUNTS) { + PingOneMFAAccountsScreen( + onBack = { navController.popBackStack() }, + onScanQr = { navController.navigate(Route.ROUTE_PINGONE_QR_SCANNER) } + ) + } + + composable(Route.ROUTE_PINGONE_OTP) { + PingOneOTPScreen( + onBack = { navController.popBackStack() } + ) + } + + composable(Route.ROUTE_PINGONE_PAYLOAD) { + PingOnePayloadScreen( + onBack = { navController.popBackStack() } + ) + } + + composable(Route.ROUTE_PINGONE_QR_SCANNER) { + PingOneQrScannerScreen( + onBack = { navController.popBackStack() }, + onPairComplete = { navController.popBackStack() } + ) + } } } diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/PingOneMFAState.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/PingOneMFAState.kt new file mode 100644 index 000000000..fe87f7f1f --- /dev/null +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/PingOneMFAState.kt @@ -0,0 +1,39 @@ +/* + * 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.samples.pingsampleapp.pingonemfa + +import com.pingidentity.pingonemfa.commons.PingOneMfaAccount +import com.pingidentity.pingonemfa.otp.OtpCodeInfo + +/** + * UI state for all PingOne MFA screens: pairing, accounts, OTP, and mobile payload. + */ +data class PingOneMFAState( + /** True while a pairing operation is in flight. */ + val isLoading: Boolean = false, + /** True while [PingOneMFAViewModel.loadAccounts] is in flight. */ + val isLoadingAccounts: Boolean = false, + /** Paired PingOne MFA accounts. Populated by [PingOneMFAViewModel.loadAccounts]. */ + val accounts: List = emptyList(), + /** True while [PingOneMFAViewModel.collectOtp] is in flight. */ + val isLoadingOtp: Boolean = false, + /** The most recently fetched OTP code info, or null if not yet loaded. */ + val otp: OtpCodeInfo? = null, + /** Live countdown in seconds for the current OTP, driven by [PingOneMFAViewModel]. */ + val otpSecondsRemaining: Int = 0, + /** True when [PingOneMFAViewModel.collectOtp] fails because the device is not paired. */ + val isOtpDeviceNotPaired: Boolean = false, + /** True while [PingOneMFAViewModel.collectPayload] is in flight. */ + val isLoadingPayload: Boolean = false, + /** The most recently fetched mobile payload string, or null if not yet loaded. */ + val payload: String? = null, + /** Non-null when an operation has completed successfully. Cleared by [PingOneMFAViewModel.clearMessage]. */ + val message: String? = null, + /** Non-null when an operation has failed. Cleared by [PingOneMFAViewModel.clearError]. */ + val error: String? = null, +) diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/PingOneMFAViewModel.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/PingOneMFAViewModel.kt new file mode 100644 index 000000000..47f18a92d --- /dev/null +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/PingOneMFAViewModel.kt @@ -0,0 +1,156 @@ +/* + * 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.samples.pingsampleapp.pingonemfa + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.pingidentity.pingonemfa.commons.PingOneMFA +import com.pingidentity.samples.pingsampleapp.authenticator.data.DiagnosticLogger +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * ViewModel for the PingOne MFA screens: pairing, accounts, OTP, and mobile payload. + * + * SDK initialization is handled once at application startup in + * [com.pingidentity.samples.pingsampleapp.PingSampleApplication]; this ViewModel only makes + * user-triggered calls to the already-initialized [com.pingidentity.pingonemfa.commons.PingOneMFA] + * singleton and maps results to [PingOneMFAState] for the UI. + */ +class PingOneMFAViewModel : ViewModel() { + + private val diagnosticLogger = DiagnosticLogger + private val _state = MutableStateFlow(PingOneMFAState()) + val state: StateFlow = _state.asStateFlow() + + /** + * Fetches the list of paired PingOne MFA accounts and updates [PingOneMFAState.accounts]. + * Shows a loading indicator while in flight via [PingOneMFAState.isLoadingAccounts]. + */ + fun loadAccounts() { + _state.update { it.copy(isLoadingAccounts = true, error = null) } + viewModelScope.launch { + PingOneMFA.getDeviceInfo() + .onSuccess { accounts -> + diagnosticLogger.i("Successfully loaded PingOne MFA accounts: ${accounts.size}") + _state.update { it.copy(isLoadingAccounts = false, accounts = accounts) } + } + .onFailure { e -> + diagnosticLogger.e("Failed to load accounts", e) + _state.update { it.copy(isLoadingAccounts = false, error = e.message ?: "Failed to load accounts") } + } + } + } + + /** + * Pairs the device using [pairingKey] obtained from a QR code scan or manual entry. + */ + fun pair(pairingKey: String) { + _state.update { it.copy(isLoading = true, error = null, message = null) } + viewModelScope.launch { + PingOneMFA.pair(pairingKey) + .onSuccess { + _state.update { + it.copy(isLoading = false, message = "Device paired successfully") + } + } + .onFailure { e -> + _state.update { + it.copy( + isLoading = false, + error = e.message ?: "Pairing failed", + ) + } + } + } + } + + private var otpCountdownJob: Job? = null + + /** + * Fetches the current one-time passcode from PingOne, updates [PingOneMFAState.otp], + * and starts a countdown that decrements [PingOneMFAState.otpSecondsRemaining] every + * second, automatically re-fetching when it reaches zero. + */ + fun collectOtp() { + otpCountdownJob?.cancel() + _state.update { it.copy(isLoadingOtp = true, otp = null, otpSecondsRemaining = 0, error = null, isOtpDeviceNotPaired = false) } + viewModelScope.launch { + PingOneMFA.getOneTimePasscode() + .onSuccess { otpInfo -> + diagnosticLogger.i("Successfully collected OTP") + _state.update { it.copy(isLoadingOtp = false, otp = otpInfo, otpSecondsRemaining = otpInfo.secondsRemaining) } + if (!state.value.isOtpDeviceNotPaired) startOtpCountdown() + } + .onFailure { e -> + diagnosticLogger.e("Failed to collect OTP", e) + if (e.message?.contains("Code=10008", ignoreCase = true) == true) { + _state.update { it.copy(isLoadingOtp = false, isOtpDeviceNotPaired = true) } + } else { + _state.update { it.copy(isLoadingOtp = false, error = e.message ?: "Failed to collect OTP") } + } + } + } + } + + private fun startOtpCountdown() { + otpCountdownJob?.cancel() + otpCountdownJob = viewModelScope.launch { + while (_state.value.otpSecondsRemaining > 0) { + delay(1_000) + _state.update { it.copy(otpSecondsRemaining = it.otpSecondsRemaining - 1) } + } + collectOtp() + } + } + + /** + * Fetches the mobile payload from PingOne and updates [PingOneMFAState.payload]. + * Shows a loading indicator while in flight via [PingOneMFAState.isLoadingPayload]. + */ + fun collectPayload() { + _state.update { it.copy(isLoadingPayload = true, error = null) } + viewModelScope.launch { + PingOneMFA.generateMobilePayload() + .onSuccess { payload -> + diagnosticLogger.i("Successfully collected mobile payload") + _state.update { it.copy(isLoadingPayload = false, payload = payload) } + } + .onFailure { e -> + diagnosticLogger.e("Failed to collect mobile payload", e) + _state.update { it.copy(isLoadingPayload = false, error = e.message ?: "Failed to collect mobile payload") } + } + } + } + + /** + * Posts an error message directly into state without triggering any SDK call. + * + * Used by the UI layer to surface infrastructure failures (e.g. camera bind errors) + * through the same Snackbar path as SDK errors, rather than routing them through a + * no-op SDK call just to produce an error response. + */ + fun reportError(message: String) { + _state.update { it.copy(error = message) } + } + + /** Clears any transient error from the state. */ + fun clearError() { + _state.update { it.copy(error = null) } + } + + /** Clears any transient success message from the state. */ + fun clearMessage() { + _state.update { it.copy(message = null) } + } +} diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/notification/PingOneNotificationActionReceiver.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/notification/PingOneNotificationActionReceiver.kt new file mode 100644 index 000000000..08fbaf716 --- /dev/null +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/notification/PingOneNotificationActionReceiver.kt @@ -0,0 +1,62 @@ +/* + * 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.samples.pingsampleapp.pingonemfa.notification + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationManagerCompat +import com.pingidentity.pingonemfa.commons.PingOneMFA +import com.pingidentity.pingonemfa.push.PushNotification +import com.pingidentity.samples.pingsampleapp.pingonemfa.notification.PushNotificationStore +import com.pingidentity.samples.pingsampleapp.authenticator.data.DiagnosticLogger + +/** + * BroadcastReceiver to handle action button taps on PingOne MFA system notifications. + * + * Receives [ACTION_APPROVE] and [ACTION_DENY] intents fired from the notification banner, + * then delegates to [PingOneMFA.approvePushNotificationFromBanner] / + * [PingOneMFA.denyPushNotificationFromBanner], which route through + * [com.pingidentity.pingonemfa.push.PushApprovalService] so the network call is allowed + * even when the app is in the background. + * + * The [PushNotification] is retrieved from [PushNotificationStore] by ID — it is never parceled + * through the Intent because the native SDK's Parcelable implementation crashes when internal + * nullable fields are null. + */ +class PingOneNotificationActionReceiver : BroadcastReceiver() { + + private val diagnosticLogger = DiagnosticLogger + + override fun onReceive(context: Context, intent: Intent) { + val notificationId = intent.getStringExtra(EXTRA_PINGONE_NOTIFICATION_ID) ?: return + val notification = PushNotificationStore.get(notificationId) ?: return + + // Dismiss the banner immediately so the user gets visual feedback + NotificationManagerCompat.from(context).cancel(notification.id.hashCode()) + + when (intent.action) { + ACTION_APPROVE -> { + diagnosticLogger.d("PingOne approve tapped for notification: ${notification.id}") + PushNotificationStore.remove() + PingOneMFA.approvePushNotificationFromBanner(notification) + } + ACTION_DENY -> { + diagnosticLogger.d("PingOne deny tapped for notification: ${notification.id}") + PushNotificationStore.remove() + PingOneMFA.denyPushNotificationFromBanner(notification) + } + } + } + + companion object { + const val ACTION_APPROVE = "com.pingidentity.pingsampleapp.PINGONE_ACTION_APPROVE" + const val ACTION_DENY = "com.pingidentity.pingsampleapp.PINGONE_ACTION_DENY" + const val EXTRA_PINGONE_NOTIFICATION_ID = "pingone_notification_id" + } +} diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/notification/PingOneNotificationHelper.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/notification/PingOneNotificationHelper.kt new file mode 100644 index 000000000..b18ff7105 --- /dev/null +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/notification/PingOneNotificationHelper.kt @@ -0,0 +1,147 @@ +/* + * 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.samples.pingsampleapp.pingonemfa.notification + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.annotation.RequiresPermission +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.pingidentity.pingonemfa.push.PushNotification +import com.pingidentity.pingonemfa.push.PushType +import com.pingidentity.samples.pingsampleapp.R + +/** + * Helper class for building and posting system banners for PingOne MFA push notifications. + * + * For [com.pingidentity.pingonemfa.push.PushType.DEFAULT], the banner includes Allow and Deny action buttons so the user + * can respond without opening the app. Action taps are routed to [PingOneNotificationActionReceiver]. + * + * For all other types (CHALLENGE, DRY) no action buttons are added — tapping + * the banner opens the app via its launcher intent instead. + * + * [PushNotification] is stored in [PushNotificationStore] and only its ID is carried through Intents. + */ +private const val CHANNEL_ID = "com.pingidentity.pingsampleapp.PINGONE_PUSH" + +class PingOneNotificationHelper(private val context: Context) { + + /** + * Creates the dedicated PingOne MFA notification channel. + * Safe to call multiple times — a no-op if the channel already exists. + */ + fun createNotificationChannel() { + val manager = context.getSystemService(NotificationManager::class.java) + if (manager.getNotificationChannel(CHANNEL_ID) != null) return + + val channel = NotificationChannel( + CHANNEL_ID, + "PingOne MFA", + NotificationManager.IMPORTANCE_HIGH + ).apply { + enableVibration(true) + } + manager.createNotificationChannel(channel) + } + + /** + * Posts a system banner for [notification]. + * + * - Uses [PushNotification.title] and [PushNotification.message] as the banner text, + * falling back to generic string resources when either is null. + * - Adds Allow / Deny action buttons only for [PushType.DEFAULT]. + * - The content intent always opens the app launcher so tapping the banner body works + * regardless of push type. + */ + @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS) + fun showPushNotification(notification: PushNotification) { + val notificationId = notification.id.hashCode() + + // Store so banner action receivers and the full-screen activity can retrieve it by ID. + PushNotificationStore.put(notification) + + val title = notification.title ?: context.getString(R.string.system_notification_title) + val message = notification.message ?: context.getString(R.string.system_notification_content) + + val openAppPendingIntent = buildOpenAppPendingIntent(notification.id, notificationId) + + val builder = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(title) + .setContentText(message) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_CALL) + .setAutoCancel(true) + .setContentIntent(openAppPendingIntent) + + if (notification.getPushType() == PushType.DEFAULT) { + builder + .addAction(buildDenyAction(notification.id, notificationId)) + .addAction(buildApproveAction(notification.id, notificationId)) + } + + with(NotificationManagerCompat.from(context)) { + if (areNotificationsEnabled()) { + notify(notificationId, builder.build()) + } + } + } + + private fun buildOpenAppPendingIntent(notificationId: String, requestCode: Int): PendingIntent { + val intent = Intent(context, PingOnePushNotificationActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP + putExtra(PingOnePushNotificationActivity.EXTRA_PINGONE_NOTIFICATION_ID, notificationId) + } + return PendingIntent.getActivity( + context, requestCode, intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + + private fun buildDenyAction( + notificationId: String, + requestCode: Int + ): NotificationCompat.Action { + val intent = Intent(context, PingOneNotificationActionReceiver::class.java).apply { + action = PingOneNotificationActionReceiver.ACTION_DENY + putExtra(PingOneNotificationActionReceiver.EXTRA_PINGONE_NOTIFICATION_ID, notificationId) + } + val pendingIntent = PendingIntent.getBroadcast( + context, requestCode, intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + return NotificationCompat.Action( + R.drawable.ic_close, + context.getString(R.string.system_notification_deny), + pendingIntent + ) + } + + private fun buildApproveAction( + notificationId: String, + requestCode: Int + ): NotificationCompat.Action { + val intent = Intent(context, PingOneNotificationActionReceiver::class.java).apply { + action = PingOneNotificationActionReceiver.ACTION_APPROVE + putExtra(PingOneNotificationActionReceiver.EXTRA_PINGONE_NOTIFICATION_ID, notificationId) + } + val pendingIntent = PendingIntent.getBroadcast( + context, requestCode + 1, intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + return NotificationCompat.Action( + R.drawable.ic_check, + context.getString(R.string.system_notification_approve), + pendingIntent + ) + } +} diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/notification/PingOnePushNotificationActivity.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/notification/PingOnePushNotificationActivity.kt new file mode 100644 index 000000000..89cdf1059 --- /dev/null +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/notification/PingOnePushNotificationActivity.kt @@ -0,0 +1,113 @@ +/* + * 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.samples.pingsampleapp.pingonemfa.notification + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import com.pingidentity.pingonemfa.push.PushNotification +import com.pingidentity.samples.pingsampleapp.pingonemfa.ui.PingOnePushNotificationScreen +import com.pingidentity.samples.pingsampleapp.theme.AppTheme + +/** + * Activity displayed when a PingOne MFA push notification arrives while the app is in the foreground. + * + * Retrieves the [PushNotification] from [PushNotificationStore] using the ID passed as a String + * extra — the notification object itself is never parceled through the Intent because the native + * SDK's Parcelable implementation crashes when internal nullable fields are null. + * + * ## Cancellation + * If the server cancels the authentication request while this activity is open (e.g. because the + * user approved on another device), [PushNotificationService] broadcasts [ACTION_CANCEL_NOTIFICATION] + * via [LocalBroadcastManager]. This activity registers a receiver in [onStart] / [onStop] that + * matches the notification ID and calls [finish] immediately, so the user is never left on a stale + * prompt. + */ +class PingOnePushNotificationActivity : ComponentActivity() { + + private var notificationId: String? = null + + /** + * Receives [ACTION_CANCEL_NOTIFICATION] and closes the activity if the cancellation is for + * the notification this activity is currently showing. + */ + private val cancellationReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val cancelledId = intent.getStringExtra(EXTRA_PINGONE_NOTIFICATION_ID) ?: return + if (cancelledId == notificationId) { + finish() + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val notification = getNotification() + if (notification == null) { + finish() + return + } + + notificationId = notification.id + + setContent { + AppTheme { + Surface(modifier = Modifier.fillMaxSize()) { + PingOnePushNotificationScreen( + notification = notification, + onFinish = { + PushNotificationStore.remove() + finish() + } + ) + } + } + } + } + + override fun onStart() { + super.onStart() + // Register while visible so we receive cancellations the moment they arrive. + LocalBroadcastManager.getInstance(this).registerReceiver( + cancellationReceiver, + IntentFilter(ACTION_CANCEL_NOTIFICATION), + ) + } + + override fun onStop() { + super.onStop() + LocalBroadcastManager.getInstance(this).unregisterReceiver(cancellationReceiver) + } + + private fun getNotification(): PushNotification? { + val id = intent?.getStringExtra(EXTRA_PINGONE_NOTIFICATION_ID) ?: return null + return PushNotificationStore.get(id) + } + + companion object { + const val EXTRA_PINGONE_NOTIFICATION_ID = "pingone_notification_id" + + /** + * Broadcast action sent by [com.pingidentity.samples.pingsampleapp.authenticator.service.PushNotificationService] + * when the server cancels an outstanding authentication request. The Intent carries + * [EXTRA_PINGONE_NOTIFICATION_ID] so the activity can verify the cancellation is for the + * notification it is currently showing. + */ + const val ACTION_CANCEL_NOTIFICATION = + "com.pingidentity.pingsampleapp.PINGONE_CANCEL_NOTIFICATION" + } +} diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/notification/PushNotificationStore.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/notification/PushNotificationStore.kt new file mode 100644 index 000000000..0b54f9f3e --- /dev/null +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/notification/PushNotificationStore.kt @@ -0,0 +1,47 @@ +package com.pingidentity.samples.pingsampleapp.pingonemfa.notification + +import com.pingidentity.pingonemfa.push.PushNotification +import java.util.concurrent.atomic.AtomicReference + +/** + * In-process store for the single active [PushNotification]. + * + * Only one PingOne MFA push authentication request can be outstanding at a time — the server + * does not queue concurrent requests for the same device. This store therefore holds at most + * one notification. Calling [put] while a notification is already present replaces it; the + * previous notification is discarded. + * + * ## Usage + * 1. Call [put] to store the notification before firing any Intent. + * 2. Pass only [PushNotification.id] as a plain String extra — never parcel the object itself. + * 3. Call [get] on the receiving end to retrieve the notification. + * 4. Call [remove] once the notification has been acted on to free the reference. + */ +object PushNotificationStore { + + private val current = AtomicReference(null) + + /** + * Stores [notification] as the current active push. + * Any previously stored notification is replaced. + */ + fun put(notification: PushNotification) { + current.set(notification) + } + + /** + * Returns the current notification if its [PushNotification.id] matches [id], + * or null if no notification is stored or the ID does not match. + */ + fun get(id: String): PushNotification? = + current.get()?.takeIf { it.id == id } + + /** + * Atomically clears the store and returns the removed notification's ID, + * or null if the store was already empty. Should be called after the notification + * has been approved, denied, canceled, or dismissed. + */ + fun remove(): String? { + return current.getAndSet(null)?.id + } +} diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOneMFAAccountsScreen.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOneMFAAccountsScreen.kt new file mode 100644 index 000000000..e78ccea3b --- /dev/null +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOneMFAAccountsScreen.kt @@ -0,0 +1,185 @@ +/* + * 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.samples.pingsampleapp.pingonemfa.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.AccountBox +import androidx.compose.material.icons.filled.QrCodeScanner +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.pingidentity.samples.pingsampleapp.R +import com.pingidentity.samples.pingsampleapp.authenticator.ui.components.EmptyStateMessage +import com.pingidentity.samples.pingsampleapp.authenticator.ui.components.LoadingIndicator +import com.pingidentity.samples.pingsampleapp.pingonemfa.PingOneMFAViewModel + +/** + * Displays the list of paired PingOne MFA accounts. + * + * Fetches accounts on first composition via [PingOneMFAViewModel.loadAccounts]. + * Each account shows its name, region, and ID. Errors are surfaced via an AlertDialog. + * + * @param onBack Navigation callback for the top-app-bar back button. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PingOneMFAAccountsScreen( + onBack: (() -> Unit)? = null, + onScanQr: () -> Unit = {}, + viewModel: PingOneMFAViewModel = viewModel(), +) { + val state by viewModel.state.collectAsState() + + // Fetch accounts as soon as the screen is first composed + LaunchedEffect(Unit) { + viewModel.loadAccounts() + } + + // Show error dialog. Retry re-fetches the accounts; OK just dismisses. + state.error?.let { errorMessage -> + AlertDialog( + onDismissRequest = { viewModel.clearError() }, + title = { Text(stringResource(R.string.error_title)) }, + text = { Text(errorMessage) }, + confirmButton = { + Button(onClick = { + viewModel.clearError() + viewModel.loadAccounts() + }) { + Text(stringResource(R.string.retry)) + } + }, + dismissButton = { + OutlinedButton(onClick = { viewModel.clearError() }) { + Text(stringResource(R.string.ok)) + } + } + ) + } + + Scaffold( + topBar = { + if (onBack != null) { + TopAppBar( + title = { Text(stringResource(R.string.text_pingone_mfa_screen_accounts_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + ) + } + } + ) + } + }, + floatingActionButton = { + FloatingActionButton(onClick = onScanQr) { + Icon( + imageVector = Icons.Filled.QrCodeScanner, + contentDescription = stringResource(R.string.text_pingone_mfa_qr_scanner_title), + ) + } + }, + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding() + .padding(paddingValues), + ) { + when { + state.isLoadingAccounts -> { + // Reuse the app-wide loading indicator style + LoadingIndicator( + message = stringResource(R.string.text_pingone_mfa_loading_accounts), + modifier = Modifier.fillMaxSize(), + ) + } + state.accounts.isEmpty() -> { + EmptyStateMessage( + title = stringResource(R.string.accounts_empty_state_title), + subtitle = stringResource(R.string.accounts_empty_state_subtitle), + ) + } + else -> { + LazyColumn(modifier = Modifier.fillMaxWidth()) { + items(state.accounts) { account -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Filled.AccountBox, + contentDescription = null, + modifier = Modifier.size(40.dp), + tint = colorResource(R.color.primary_dark), + ) + Column( + modifier = Modifier.padding(start = 16.dp), + ) { + val displayName = listOfNotNull( + account.name?.takeIf { it.isNotBlank() }, + account.family?.takeIf { it.isNotBlank() } + ).joinToString(" ").ifBlank { account.username } + Text( + text = displayName, + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = "Region: ${account.region}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "ID: ${account.id}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + HorizontalDivider() + } + } + } + } + } + } +} diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOneOTPScreen.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOneOTPScreen.kt new file mode 100644 index 000000000..3fcd519a9 --- /dev/null +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOneOTPScreen.kt @@ -0,0 +1,159 @@ +/* + * 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.samples.pingsampleapp.pingonemfa.ui + +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.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +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.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.remember +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 androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.pingidentity.samples.pingsampleapp.R +import com.pingidentity.samples.pingsampleapp.authenticator.ui.components.EmptyStateMessage +import com.pingidentity.samples.pingsampleapp.authenticator.ui.components.LoadingIndicator +import com.pingidentity.samples.pingsampleapp.pingonemfa.PingOneMFAViewModel + +/** + * Screen that fetches and displays the current PingOne MFA one-time passcode. + * + * Calls [PingOneMFAViewModel.collectOtp] on first composition and whenever the user + * taps "Refresh". Errors are shown via a Snackbar. + * + * @param onBack Navigation callback for the top-app-bar back button. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PingOneOTPScreen( + onBack: (() -> Unit)? = null, + viewModel: PingOneMFAViewModel = viewModel(), +) { + val state by viewModel.state.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + + // Fetch OTP as soon as the screen is first composed + LaunchedEffect(Unit) { + viewModel.collectOtp() + } + + LaunchedEffect(state.error) { + state.error?.let { + snackbarHostState.showSnackbar(it) + viewModel.clearError() + } + } + + Scaffold( + topBar = { + if (onBack != null) { + TopAppBar( + title = { Text(stringResource(R.string.text_pingone_mfa_screen_otp_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + ) + } + }, + ) + } + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding() + .padding(paddingValues), + contentAlignment = Alignment.Center, + ) { + when { + state.isLoadingOtp -> { + LoadingIndicator( + message = stringResource(R.string.text_pingone_mfa_loading_otp), + modifier = Modifier.fillMaxSize(), + ) + } + + state.isOtpDeviceNotPaired -> { + EmptyStateMessage( + title = stringResource(R.string.accounts_empty_state_title), + subtitle = stringResource(R.string.accounts_empty_state_subtitle), + ) + } + + state.otp != null -> { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = state.otp!!.code, + fontSize = 48.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 8.sp, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Refreshes in ${state.otpSecondsRemaining}s", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(24.dp)) + } + } + + else -> { + // No OTP yet and not loading — likely initial state before LaunchedEffect fires, + // or a failure was shown via Snackbar. Offer a manual retry. + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(R.string.text_pingone_mfa_otp_no_passcode), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { viewModel.collectOtp() }) { + Text(stringResource(R.string.text_pingone_mfa_try_again)) + } + } + } + } + } + } +} diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOnePayloadScreen.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOnePayloadScreen.kt new file mode 100644 index 000000000..2e237f904 --- /dev/null +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOnePayloadScreen.kt @@ -0,0 +1,180 @@ +/* + * 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.samples.pingsampleapp.pingonemfa.ui + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +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.ArrowBack +import androidx.compose.material.icons.filled.ContentCopy +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.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.remember +import androidx.compose.runtime.rememberCoroutineScope +import kotlinx.coroutines.launch +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.pingidentity.samples.pingsampleapp.R +import android.content.ClipData +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.pingidentity.samples.pingsampleapp.authenticator.ui.components.LoadingIndicator +import com.pingidentity.samples.pingsampleapp.pingonemfa.PingOneMFAViewModel + +/** + * Screen that fetches and displays the PingOne mobile payload. + * + * Calls [PingOneMFAViewModel.collectPayload] on first composition. The payload is shown + * in a scrollable box with a Copy button pinned at the bottom. Errors are shown via a + * Snackbar. + * + * @param onBack Navigation callback for the top-app-bar back button. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PingOnePayloadScreen( + onBack: (() -> Unit)? = null, + viewModel: PingOneMFAViewModel = viewModel(), +) { + val state by viewModel.state.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + val clipboardManager = LocalClipboard.current + val scope = rememberCoroutineScope() + + // Fetch payload as soon as the screen is first composed + LaunchedEffect(Unit) { + viewModel.collectPayload() + } + + LaunchedEffect(state.error) { + state.error?.let { + snackbarHostState.showSnackbar(it) + viewModel.clearError() + } + } + + Scaffold( + topBar = { + if (onBack != null) { + TopAppBar( + title = { Text(stringResource(R.string.text_pingone_mfa_screen_payload_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + ) + } + }, + ) + } + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding() + .padding(paddingValues), + ) { + when { + state.isLoadingPayload -> { + LoadingIndicator( + message = stringResource(R.string.text_pingone_mfa_loading_payload), + modifier = Modifier.fillMaxSize(), + ) + } + + state.payload != null -> { + Column( + modifier = Modifier.fillMaxSize(), + ) { + // Scrollable payload box occupies all available space above the button + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(16.dp) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant, + shape = MaterialTheme.shapes.medium, + ) + .padding(12.dp) + .verticalScroll(rememberScrollState()), + ) { + Text( + text = state.payload!!, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + Button( + onClick = { + scope.launch { + clipboardManager.setClipEntry( + ClipEntry(ClipData.newPlainText("payload", state.payload)) + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = null, + ) + Text( + text = stringResource(R.string.text_pingone_mfa_copy), + modifier = Modifier.padding(start = 8.dp), + ) + } + } + } + + else -> { + // No payload yet and not loading — offer a manual retry after an error + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Button(onClick = { viewModel.collectPayload() }) { + Text(stringResource(R.string.text_pingone_mfa_try_again)) + } + } + } + } + } + } +} diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOnePushNotificationScreen.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOnePushNotificationScreen.kt new file mode 100644 index 000000000..7be5d9ad6 --- /dev/null +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOnePushNotificationScreen.kt @@ -0,0 +1,206 @@ +/* + * 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.samples.pingsampleapp.pingonemfa.ui + +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.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.CircularProgressIndicator +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.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.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.pingidentity.pingonemfa.push.PushNotification +import com.pingidentity.pingonemfa.push.PushType +import com.pingidentity.samples.pingsampleapp.R +import com.pingidentity.samples.pingsampleapp.pingonemfa.ui.components.ApproveDenyRow +import com.pingidentity.samples.pingsampleapp.pingonemfa.ui.components.ManualNumberChallenge +import com.pingidentity.samples.pingsampleapp.pingonemfa.ui.components.NumberChallengeOptions +import kotlinx.coroutines.launch + +/** Models the three dialog states the push notification screen can be in. */ +private sealed interface DialogState { + data object None : DialogState + data class Success(val title: String, val message: String) : DialogState + data class Error(val message: String) : DialogState +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PingOnePushNotificationScreen( + notification: PushNotification, + onFinish: () -> Unit +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var isLoading by remember { mutableStateOf(false) } + var dialogState by remember { mutableStateOf(DialogState.None) } + + val approvedTitle = stringResource(R.string.text_pingone_mfa_approved_title) + val approvedMessage = stringResource(R.string.text_pingone_mfa_approved_message) + val deniedTitle = stringResource(R.string.text_pingone_mfa_denied_title) + val deniedMessage = stringResource(R.string.text_pingone_mfa_denied_message) + val approvalFailedMessage = stringResource(R.string.text_pingone_mfa_approval_failed) + val denyFailedMessage = stringResource(R.string.text_pingone_mfa_deny_failed) + + fun approve(numberChallenge: Int? = null) { + isLoading = true + scope.launch { + notification.approveNotification( + context = context, + authenticationMethod = "app", + numberChallenge = numberChallenge + ).onSuccess { + isLoading = false + dialogState = DialogState.Success(approvedTitle, approvedMessage) + }.onFailure { e -> + isLoading = false + dialogState = DialogState.Error(e.message ?: approvalFailedMessage) + } + } + } + + fun deny() { + isLoading = true + scope.launch { + notification.denyNotification(context) + .onSuccess { + isLoading = false + dialogState = DialogState.Success(deniedTitle, deniedMessage) + }.onFailure { e -> + isLoading = false + dialogState = DialogState.Error(e.message ?: denyFailedMessage) + } + } + } + + // Show dialog on top of the screen + when (val state = dialogState) { + is DialogState.Success -> AlertDialog( + onDismissRequest = onFinish, + title = { Text(state.title) }, + text = { Text(state.message) }, + confirmButton = { + Button(onClick = onFinish) { + Text(stringResource(R.string.ok)) + } + } + ) + is DialogState.Error -> AlertDialog( + onDismissRequest = onFinish, + title = { Text(stringResource(R.string.error_title)) }, + text = { Text(state.message) }, + confirmButton = { + Button(onClick = onFinish) { + Text(stringResource(R.string.ok)) + } + } + ) + DialogState.None -> Unit + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.text_pingone_mfa_screen_push_title)) }, + navigationIcon = { + IconButton(onClick = onFinish) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + ) + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = notification.title ?: stringResource(R.string.system_notification_title), + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = notification.message ?: stringResource(R.string.system_notification_content), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(32.dp)) + + if (isLoading) { + CircularProgressIndicator() + } else { + when (notification.getPushType()) { + PushType.DEFAULT -> { + ApproveDenyRow( + onApprove = { approve() }, + onDeny = { deny() } + ) + } + PushType.CHALLENGE -> { + val options = notification.getNumbersChallenge() + if (options != null) { + NumberChallengeOptions( + options = options, + onSelected = { approve(it) }, + onDeny = { deny() } + ) + } else { + ManualNumberChallenge( + onConfirm = { number -> approve(number) }, + onDeny = { deny() } + ) + } + } + PushType.DRY -> { + Text( + text = stringResource(R.string.text_pingone_mfa_dry_push_message), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = onFinish) { Text(stringResource(R.string.text_pingone_mfa_dismiss)) } + } + } + } + } + } +} diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOneQrScannerScreen.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOneQrScannerScreen.kt new file mode 100644 index 000000000..ae5aea45e --- /dev/null +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOneQrScannerScreen.kt @@ -0,0 +1,375 @@ +/* + * 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.samples.pingsampleapp.pingonemfa.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.Canvas +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.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +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.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.material3.AlertDialog +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.pingidentity.samples.pingsampleapp.R +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.viewmodel.compose.viewModel +import com.pingidentity.samples.pingsampleapp.authenticator.ui.components.BackNavigationTopAppBar +import com.pingidentity.samples.pingsampleapp.authenticator.ui.components.LoadingIndicator +import com.pingidentity.samples.pingsampleapp.pingonemfa.util.PingOneMFAQrCodeAnalyzer +import com.pingidentity.samples.pingsampleapp.pingonemfa.util.matchesPingOnePairingKeyScheme +import com.pingidentity.samples.pingsampleapp.pingonemfa.PingOneMFAViewModel +import java.util.concurrent.Executors + +/** + * QR scanner screen for PingOne MFA device pairing. + * + * Shows a camera preview. Scanned or manually entered pairing keys are forwarded to + * [PingOneMFAViewModel.pair]. Errors are shown via a Snackbar. On successful pairing an + * AlertDialog confirms the result; tapping OK calls [onPairComplete] to close the screen. + * + * @param onBack Navigation callback for the top-app-bar back button. + * @param onPairComplete Callback invoked after a successful pairing operation. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PingOneQrScannerScreen( + onBack: () -> Unit, + onPairComplete: () -> Unit, + viewModel: PingOneMFAViewModel = viewModel(), +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val keyboardController = LocalSoftwareKeyboardController.current + + var manualKey by remember { mutableStateOf("") } + var scanEnabled by remember { mutableStateOf(true) } + + var hasCameraPermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED + ) + } + + val requestPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + onResult = { hasCameraPermission = it } + ) + + val cameraExecutor = remember { Executors.newSingleThreadExecutor() } + val qrAnalyzer = remember { PingOneMFAQrCodeAnalyzer { pairingKey -> + if (!scanEnabled) return@PingOneMFAQrCodeAnalyzer + scanEnabled = false + viewModel.pair(pairingKey) + } } + + LaunchedEffect(Unit) { + if (!hasCameraPermission) requestPermissionLauncher.launch(Manifest.permission.CAMERA) + } + + val state by viewModel.state.collectAsState() + + // Show error dialog; re-enable scanning when the user dismisses so they can retry. + state.error?.let { errorMessage -> + AlertDialog( + onDismissRequest = { + scanEnabled = true + viewModel.clearError() + }, + title = { Text(stringResource(R.string.error_title)) }, + text = { Text(errorMessage) }, + confirmButton = { + Button(onClick = { + scanEnabled = true + viewModel.clearError() + }) { + Text(stringResource(R.string.ok)) + } + } + ) + } + + // Show success dialog on successful pairing; navigate away when user taps OK. + state.message?.let { message -> + AlertDialog( + onDismissRequest = { + viewModel.clearMessage() + onPairComplete() + }, + text = { Text(message) }, + confirmButton = { + Button(onClick = { + viewModel.clearMessage() + onPairComplete() + }) { + Text(stringResource(R.string.ok)) + } + } + ) + } + + Scaffold( + topBar = { + BackNavigationTopAppBar( + title = stringResource(R.string.text_pingone_mfa_screen_scanner_title), + onBackClick = onBack, + ) + }, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .navigationBarsPadding(), + ) { + // Camera area — fills all space above the manual-entry panel + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .background(Color(0xFF616161)), + contentAlignment = Alignment.Center, + ) { + if (hasCameraPermission && !state.isLoading) { + // Full-size camera preview behind the overlay + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { ctx -> + val previewView = PreviewView(ctx).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() + + val imageAnalysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + + imageAnalysis.setAnalyzer(cameraExecutor, qrAnalyzer) + + ProcessCameraProvider.getInstance(ctx).also { future -> + future.addListener({ + try { + val cameraProvider = future.get() + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle( + lifecycleOwner, selector, preview, imageAnalysis + ) + } catch (e: Exception) { + // Surface the failure through the error AlertDialog path + viewModel.reportError("Camera unavailable: ${e.message}") + } + }, ContextCompat.getMainExecutor(ctx)) + } + + previewView + }, + ) + } + + if (state.isLoading) { + // Pairing in flight — show spinner over the solid grey background + LoadingIndicator( + message = stringResource(R.string.text_pingone_mfa_pairing), + modifier = Modifier.fillMaxSize(), + ) + } else { + // Overlay: label + corner-bracket scanning window + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 40.dp), + ) { + Text( + text = stringResource(R.string.text_pingone_mfa_scan_qr), + style = MaterialTheme.typography.titleMedium, + color = Color.White, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(16.dp)) + // Transparent scanning window — only corner brackets are drawn + Canvas( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f), + ) { + val stroke = 6.dp.toPx() + val arm = 32.dp.toPx() + val radius = 16.dp.toPx() + val w = size.width + val h = size.height + + val corners = listOf( + Offset(0f, 0f) to (1f to 1f), // top-left + Offset(w, 0f) to (-1f to 1f), // top-right + Offset(w, h) to (-1f to -1f), // bottom-right + Offset(0f, h) to (1f to -1f), // bottom-left + ) + + corners.forEach { (pivot, signs) -> + val (sx, sy) = signs + drawLine( + color = Color.White, + start = Offset(pivot.x + sx * radius, pivot.y), + end = Offset(pivot.x + sx * (radius + arm), pivot.y), + strokeWidth = stroke, + cap = StrokeCap.Round, + ) + drawLine( + color = Color.White, + start = Offset(pivot.x, pivot.y + sy * radius), + end = Offset(pivot.x, pivot.y + sy * (radius + arm)), + strokeWidth = stroke, + cap = StrokeCap.Round, + ) + drawArc( + color = Color.White, + startAngle = when { + sx > 0 && sy > 0 -> 180f + sx < 0 && sy > 0 -> 270f + sx < 0 && sy < 0 -> 0f + else -> 90f + }, + sweepAngle = 90f, + useCenter = false, + topLeft = Offset(pivot.x + sx * radius - radius, pivot.y + sy * radius - radius), + size = androidx.compose.ui.geometry.Size(radius * 2, radius * 2), + style = androidx.compose.ui.graphics.drawscope.Stroke( + width = stroke, + cap = StrokeCap.Round, + ), + ) + } + } + } + } + + // Permission-denied fallback shown on top of the grey background + if (!hasCameraPermission) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + ) { + Text( + text = "Camera permission is required to scan QR codes", + color = Color.White, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge, + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { requestPermissionLauncher.launch(Manifest.permission.CAMERA) }) { + Text(stringResource(R.string.text_pingone_mfa_grant_permission)) + } + } + } + } + + // Manual entry panel pinned at the bottom + Surface( + modifier = Modifier.fillMaxWidth(), + tonalElevation = 4.dp, + ) { + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) { + OutlinedTextField( + value = manualKey, + onValueChange = { manualKey = it }, + placeholder = { Text(stringResource(R.string.text_pingone_mfa_enter_pairing_key_placeholder)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }), + colors = OutlinedTextFieldDefaults.colors( + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + focusedContainerColor = MaterialTheme.colorScheme.surface, + ), + ) + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { + keyboardController?.hide() + viewModel.pair(manualKey.trim()) + manualKey = "" + }, + enabled = manualKey.trim().matchesPingOnePairingKeyScheme() && !state.isLoading, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.text_pingone_mfa_pair_button)) + } + } + } + } + } + + DisposableEffect(lifecycleOwner) { + onDispose { + // Shut down the camera thread executor and release the ML Kit barcode client + // together, so neither outlives the other once the screen leaves composition. + qrAnalyzer.close() + cameraExecutor.shutdown() + } + } +} diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/components/ApproveDenyRow.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/components/ApproveDenyRow.kt new file mode 100644 index 000000000..aeb127d8e --- /dev/null +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/components/ApproveDenyRow.kt @@ -0,0 +1,51 @@ +/* + * 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.samples.pingsampleapp.pingonemfa.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.pingidentity.samples.pingsampleapp.R + +/** + * A row with a Deny button on the left and an Approve button on the right. + * Used for DEFAULT push notification type. + */ +@Composable +fun ApproveDenyRow(onApprove: () -> Unit, onDeny: () -> Unit) { + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth() + ) { + OutlinedButton( + onClick = onDeny, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Text(stringResource(R.string.system_notification_deny)) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Button(onClick = onApprove) { + Text(stringResource(R.string.system_notification_approve)) + } + } +} diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/components/ManualNumberChallenge.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/components/ManualNumberChallenge.kt new file mode 100644 index 000000000..9bcd3e9d9 --- /dev/null +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/components/ManualNumberChallenge.kt @@ -0,0 +1,91 @@ +/* + * 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.samples.pingsampleapp.pingonemfa.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +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.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.pingidentity.samples.pingsampleapp.R + +/** + * Fallback UI for a CHALLENGE push when [PushNotification.getNumbersChallenge] returns null. + * + * Presents a digit-only text field and a "Confirm Number" button that activates only when + * the field is non-empty, plus a Deny button. The confirmed value is passed to [onConfirm] + * as an [Int] so it flows through the same approve path as the button-based challenge. + */ +@Composable +fun ManualNumberChallenge( + onConfirm: (Int) -> Unit, + onDeny: () -> Unit, +) { + var input by remember { mutableStateOf("") } + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = stringResource(R.string.text_pingone_mfa_enter_number_prompt), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = input, + onValueChange = { value -> + // Accept digits only + if (value.all { it.isDigit() }) input = value + }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + label = { Text(stringResource(R.string.text_pingone_mfa_number_label)) }, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = { input.toIntOrNull()?.let { onConfirm(it) } }, + enabled = input.isNotEmpty(), + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.text_pingone_mfa_confirm_number)) + } + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedButton( + onClick = onDeny, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Text(stringResource(R.string.system_notification_deny)) + } + } +} diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/components/NumberChallengeOptions.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/components/NumberChallengeOptions.kt new file mode 100644 index 000000000..fa5252642 --- /dev/null +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/components/NumberChallengeOptions.kt @@ -0,0 +1,68 @@ +/* + * 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.samples.pingsampleapp.pingonemfa.ui.components + +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +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.unit.dp +import com.pingidentity.samples.pingsampleapp.R + +/** + * Displays the server-provided set of numbers for a CHALLENGE push. + * The user taps the number that matches what they see on their other device. + * Used when [PushNotification.getNumbersChallenge] returns a non-null array. + */ +@Composable +fun NumberChallengeOptions( + options: IntArray, + onSelected: (Int) -> Unit, + onDeny: () -> Unit +) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = stringResource(R.string.text_pingone_mfa_select_number), + style = MaterialTheme.typography.titleMedium + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), + modifier = Modifier.fillMaxWidth() + ) { + options.forEach { number -> + OutlinedButton(onClick = { onSelected(number) }) { + Text(number.toString()) + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + OutlinedButton( + onClick = onDeny, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Text(stringResource(R.string.system_notification_deny)) + } + } +} diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/util/PingOneMFAQrCodeAnalyzer.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/util/PingOneMFAQrCodeAnalyzer.kt new file mode 100644 index 000000000..1f2e4d18e --- /dev/null +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/util/PingOneMFAQrCodeAnalyzer.kt @@ -0,0 +1,85 @@ +/* + * 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.samples.pingsampleapp.pingonemfa.util + +import android.annotation.SuppressLint +import androidx.annotation.OptIn +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 java.util.concurrent.TimeUnit + +/** + * Analyzes camera images to detect PingOne MFA pairing QR codes. + * + * Only triggers [onQrCodeDetected] for QR codes whose content matches the PingOne pairing key + * scheme (numeric prefix, 12 or 14 characters). All other QR codes are silently ignored. + * + * @param onQrCodeDetected Callback invoked with the raw pairing key when a valid code is found. + */ +class PingOneMFAQrCodeAnalyzer( + private val onQrCodeDetected: (String) -> Unit, +) : ImageAnalysis.Analyzer { + + private val scanner = BarcodeScanning.getClient() + + // Throttle to avoid firing multiple times on the same code + private var lastAnalyzedTimestamp = 0L + + /** + * Releases the ML Kit barcode client. + * + * CameraX 1.x does not expose an analyzer lifecycle callback, so callers are + * responsible for calling this when the camera is unbound. In the QR scanner screen + * this is done inside the DisposableEffect that also shuts down the camera executor, + * ensuring the native ML Kit resources are freed at the same time as the camera. + */ + fun close() { + scanner.close() + } + + @SuppressLint("UnsafeOptInUsageError") + @OptIn(ExperimentalGetImage::class) + override fun analyze(imageProxy: ImageProxy) { + val currentTimestamp = System.currentTimeMillis() + + if (currentTimestamp - lastAnalyzedTimestamp >= TimeUnit.SECONDS.toMillis(1)) { + imageProxy.image?.let { image -> + val inputImage = InputImage.fromMediaImage(image, imageProxy.imageInfo.rotationDegrees) + + scanner.process(inputImage) + .addOnSuccessListener { barcodes -> + val found = barcodes.find { barcode -> + barcode.format == Barcode.FORMAT_QR_CODE && + barcode.rawValue?.matchesPingOnePairingKeyScheme() == true + } + found?.rawValue?.let { pairingKey -> + lastAnalyzedTimestamp = currentTimestamp + onQrCodeDetected(pairingKey) + } + } + .addOnFailureListener { it.printStackTrace() } + .addOnCompleteListener { imageProxy.close() } + } ?: imageProxy.close() + } else { + imageProxy.close() + } + } +} + +/** + * Returns true if this string looks like a valid PingOne MFA pairing key. + * + * Accepts numeric and alphanumeric pairing keys that start with two digits + * and are exactly 12 or 14 characters long. + */ +fun String.matchesPingOnePairingKeyScheme(): Boolean = + length >= 2 && this[0].isDigit() && this[1].isDigit() && (length == 12 || length == 14) diff --git a/samples/pingsampleapp/src/main/res/values/strings.xml b/samples/pingsampleapp/src/main/res/values/strings.xml index 255e6de05..d4d84a849 100644 --- a/samples/pingsampleapp/src/main/res/values/strings.xml +++ b/samples/pingsampleapp/src/main/res/values/strings.xml @@ -13,6 +13,7 @@ AUTHENTICATION USER MANAGEMENT MFA + PINGONE MFA DEVELOPER TOOLS @@ -42,6 +43,58 @@ Push Notifications View and respond push requests + + QR Code Registration + Scan QR code to pair with PingOne MFA + MFA Accounts + View paired MFA accounts + One-Time Passcode + OTP for your paired accounts + Mobile Payload + Generate Mobile Payload for authentication and registration + + + PingOne MFA + One-Time Passcode + Mobile Payload + PingOne MFA Scanner + Authentication Request + + + Loading accounts… + Fetching OTP… + Fetching payload… + Pairing… + + + No passcode available + Try again + + + This is a test push notification. No action is required. + Dismiss + Approval failed + Deny failed + Approved + Authentication approved successfully. + Denied + Authentication request has been denied. + + + Enter the number shown on your other device + Number + Confirm Number + Select the matching number + + + Scan QR Code + Enter pairing key manually + Pair + Grant Permission + + + Copy + Device Information Device ID @@ -94,6 +147,7 @@ Code copied to clipboard Error OK + Retry Copy New Code Generate Code From e1f85e29e717c92180245c69232a764ae4cfcb09 Mon Sep 17 00:00:00 2001 From: Evgeniy Mishustin Date: Mon, 1 Jun 2026 11:17:15 +0300 Subject: [PATCH 04/15] feat: pingonemfa sdk integration --- README.md | 1 + pingonemfa/README.md | 327 ++++++++++ pingonemfa/build.gradle.kts | 7 + .../pingidentity/pingonemfa/commons/Geo.kt | 39 ++ .../pingonemfa/commons/PingOneMFA.kt | 356 +++++++---- .../pingonemfa/commons/PingOneMFAException.kt | 34 +- .../pingonemfa/commons/PingOneMfaAccount.kt | 25 +- .../pingonemfa/otp/OtpCodeInfo.kt | 18 +- .../pingonemfa/push/PushApprovalService.kt | 41 +- .../pingonemfa/push/PushNotification.kt | 84 ++- .../pingidentity/pingonemfa/push/PushType.kt | 34 +- .../pingonemfa/util/AccountParser.kt | 71 ++- .../pingonemfa/commons/GeoTest.kt | 30 + .../pingonemfa/commons/PingOneMFATest.kt | 572 ++++++++++++++++++ .../push/PushApprovalServiceTest.kt | 161 +++++ .../pingonemfa/push/PushNotificationTest.kt | 220 +++++++ .../pingonemfa/util/AccountParserTest.kt | 185 ++++++ .../notification/BiometricPromptActivity.kt | 18 +- .../NotificationActionReceiver.kt | 17 +- .../notification/NotificationHelper.kt | 6 - .../notification/PushNotificationActivity.kt | 38 +- .../service/PushNotificationService.kt | 1 - samples/pingsampleapp/README.md | 46 +- samples/pingsampleapp/build.gradle.kts | 3 + .../src/main/AndroidManifest.xml | 18 + .../pingsampleapp/PingSampleApplication.kt | 17 + .../service/PushNotificationService.kt | 102 ++++ .../samples/pingsampleapp/home/HomeApp.kt | 50 +- .../pingsampleapp/navigation/Navigation.kt | 46 +- .../pingonemfa/PingOneMFAState.kt | 39 ++ .../pingonemfa/PingOneMFAViewModel.kt | 156 +++++ .../PingOneNotificationActionReceiver.kt | 62 ++ .../notification/PingOneNotificationHelper.kt | 147 +++++ .../PingOnePushNotificationActivity.kt | 113 ++++ .../notification/PushNotificationStore.kt | 47 ++ .../pingonemfa/ui/PingOneMFAAccountsScreen.kt | 185 ++++++ .../pingonemfa/ui/PingOneOTPScreen.kt | 159 +++++ .../pingonemfa/ui/PingOnePayloadScreen.kt | 180 ++++++ .../ui/PingOnePushNotificationScreen.kt | 206 +++++++ .../pingonemfa/ui/PingOneQrScannerScreen.kt | 375 ++++++++++++ .../ui/components/ApproveDenyRow.kt | 51 ++ .../ui/components/ManualNumberChallenge.kt | 91 +++ .../ui/components/NumberChallengeOptions.kt | 68 +++ .../util/PingOneMFAQrCodeAnalyzer.kt | 85 +++ .../src/main/res/values/strings.xml | 54 ++ 45 files changed, 4360 insertions(+), 225 deletions(-) create mode 100644 pingonemfa/README.md create mode 100644 pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/Geo.kt create mode 100644 pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/commons/GeoTest.kt create mode 100644 pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/commons/PingOneMFATest.kt create mode 100644 pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/push/PushApprovalServiceTest.kt create mode 100644 pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/push/PushNotificationTest.kt create mode 100644 pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/util/AccountParserTest.kt create mode 100644 samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/PingOneMFAState.kt create mode 100644 samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/PingOneMFAViewModel.kt create mode 100644 samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/notification/PingOneNotificationActionReceiver.kt create mode 100644 samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/notification/PingOneNotificationHelper.kt create mode 100644 samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/notification/PingOnePushNotificationActivity.kt create mode 100644 samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/notification/PushNotificationStore.kt create mode 100644 samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOneMFAAccountsScreen.kt create mode 100644 samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOneOTPScreen.kt create mode 100644 samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOnePayloadScreen.kt create mode 100644 samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOnePushNotificationScreen.kt create mode 100644 samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOneQrScannerScreen.kt create mode 100644 samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/components/ApproveDenyRow.kt create mode 100644 samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/components/ManualNumberChallenge.kt create mode 100644 samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/components/NumberChallengeOptions.kt create mode 100644 samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/util/PingOneMFAQrCodeAnalyzer.kt diff --git a/README.md b/README.md index b28c91bf1..c7b82714e 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ secured by PingOne. │ ├── oath # TOTP / HOTP one-time passwords │ └── push # Push notification authentication ├── protect # PingOne Protect fraud signals + ├── pingonemfa # PingOne MFA authentication ├── recaptcha-enterprise # reCAPTCHA Enterprise integration └── samples # Sample applications └── pingsampleapp # Combined Ping sample app diff --git a/pingonemfa/README.md b/pingonemfa/README.md new file mode 100644 index 000000000..964dfa4df --- /dev/null +++ b/pingonemfa/README.md @@ -0,0 +1,327 @@ +[![Ping Identity](https://www.pingidentity.com/content/dam/picr/nav/Ping-Logo-2.svg)](https://github.com/ForgeRock/ping-android-sdk) + +# PingOne MFA + +## Overview + +The `pingonemfa` module wraps the [PingOne MFA native SDK](https://github.com/pingidentity/pingone-customers-mobile-sdk-android) behind a clean, coroutine-friendly Kotlin API. It is the adapter layer between your application and the PingOne MFA platform. All PingOne SDK callbacks are bridged to `suspend` functions that return `Result` — callers never need a try/catch. + +--- + +## Features + +- **Device Pairing** — pair new MFA accounts by scanning a QR code or entering a pairing key manually +- **Paired Accounts List** — retrieve information about all currently paired accounts +- **OTP** — retrieve the current one-time passcode and its remaining validity window +- **Push Notifications (foreground and background)** — approve or deny incoming authentication requests +- **Mobile Payload** — generate a cryptographic mobile payload for server-side authentication flows + +--- + +## Architecture Overview + +``` +┌──────────────────────────────────────────────────┐ +│ Your Application │ +│ │ +│ ┌────────────────────────────────────────────┐ │ +│ │ Push / OTP / Pairing Handlers │ │ +│ │ (your app code) │ │ +│ └──────────────────────┬─────────────────────┘ │ +│ │ │ +│ ┌──────────────────────▼─────────────────────┐ │ +│ │ pingonemfa module │ │ +│ │ PingOneMFA (singleton object) │ │ +│ └──────────────────────┬─────────────────────┘ │ +└──────────────────────────┼───────────────────────┘ + │ + ┌────────────▼─────────┐ + │ PingOne MFA SDK │ + └──────────────────────┘ +``` + +The `pingonemfa` module is the only component in the Orchestration SDK that imports from native PingOne MFA SDK. All other layers depend on the typed domain models and coroutine API exposed by `PingOneMFA`. + +--- + +## Getting Started + +### Prerequisites + +- Android API level 24 or higher +- Firebase Cloud Messaging configured for your application (`google-services.json` present and matching your application ID) +- A PingOne environment with push notifications and/or MFA configured, for documentation on setting up PingOne MFA, see [PingOne MFA documentation](https://docs.pingidentity.com/pingone/strong_authentication_mfa/p1_strong_authentication_configure_mobile_applications.html). + +--- + +## Add Dependency + +```kotlin +dependencies { + implementation("com.pingidentity.sdks:pingonemfa:") + + // Firebase Cloud Messaging — required for push notification support + implementation(platform("com.google.firebase:firebase-bom:")) + implementation("com.google.firebase:firebase-messaging") +} +``` + +--- + +## Setup and Configuration + +### 1. Initialize the SDK + +Call `initialize()` once at application startup, before any other call. Pass the `Geo` that matches your PingOne environment's service region. The call is idempotent — repeated calls after a successful initialisation return immediately without re-entering the native SDK. + +```kotlin +val result = PingOneMFA.initialize(Geo.NORTH_AMERICA) +result.onFailure { e -> + Log.e("MFA", "Initialisation failed: ${e.message}") +} +``` + +Supported regions: + +| `Geo` | PingOne region | +|---|---| +| `Geo.NORTH_AMERICA` | North America | +| `Geo.EUROPE` | Europe | +| `Geo.CANADA` | Canada | +| `Geo.AUSTRALIA` | Australia | +| `Geo.SINGAPORE` | Singapore | + +### 2. Register the FCM Push Token + +Call `setDeviceToken()` each time Firebase delivers a new push token — typically from `FirebaseMessagingService.onNewToken`: + +```kotlin +override fun onNewToken(token: String) { + CoroutineScope(SupervisorJob()).launch { + PingOneMFA.setDeviceToken(token).onFailure { e -> + Log.e("MFA", "Token registration failed: ${e.message}") + } + } +} +``` + +--- + +## Usage + +### Device Pairing + +```kotlin +PingOneMFA.pair(pairingKey) + .onSuccess { + // Pairing succeeded — update UI as needed + } + .onFailure { e -> + Log.e("MFA", "Pairing failed: ${e.message}") + } +``` + +### Retrieve Paired Accounts + +```kotlin +PingOneMFA.getDeviceInfo().onSuccess { accounts -> + accounts.forEach { account -> + Log.d("MFA", "${account.username} | region: ${account.region}") + } +} +``` + +### OTP + +```kotlin +PingOneMFA.getOneTimePasscode().onSuccess { otp -> + showCode(otp.code, otp.secondsRemaining) +} +``` + +`OtpCodeInfo.secondsRemaining` is a snapshot computed at call time. Re-call `getOneTimePasscode()` when it reaches zero to receive the next code. + +### Mobile Payload + +```kotlin +PingOneMFA.generateMobilePayload().onSuccess { payload -> + // Submit payload to your server-side authentication flow +} +``` + +### Push Notifications — Foreground + +When your app is in the foreground, process the incoming `RemoteMessage` and present the appropriate UI based on push type: + +```kotlin +// In FirebaseMessagingService.onMessageReceived: +PingOneMFA.processRemoteNotification(remoteMessage).onSuccess { push -> + when (push.getPushType()) { + PushType.DEFAULT -> showApproveDenyUI(push) + PushType.CHALLENGE -> showNumberChallengeUI(push) + PushType.DRY -> { /* test push — no user action required */ } + } +} +``` + +After the user responds: + +```kotlin +// Approve (pass numberChallenge for CHALLENGE type, null for DEFAULT) +push.approveNotification( + context = applicationContext, + authenticationMethod = "user", // or "biometric", depending on your UI + numberChallenge = selectedNumber +).onSuccess { /* done */ } + +// Deny +push.denyNotification(applicationContext) + .onSuccess { /* done */ } +``` + +For number-matching challenge pushes, retrieve the options provided by the server: + +```kotlin +val options: IntArray? = push.getNumbersChallenge() +// options is null when the server expects free-form digit entry +``` + +### Push Notifications — Background (Notification Banner) + +When the user taps Approve or Deny on the system notification banner while the app is in the background, use the banner helpers. These route the network call through `PushApprovalService`, which runs as a foreground service and is exempt from Android's background network restrictions: + +```kotlin +// Called from your notification action BroadcastReceiver: +PingOneMFA.approvePushNotificationFromBanner(pushNotification) +// or +PingOneMFA.denyPushNotificationFromBanner(pushNotification) +``` + +> `PushApprovalService` completes the network call asynchronously and does not surface the outcome back to the UI. If your application needs to react to banner-approval results, add a custom broadcast or shared state mechanism. + +--- + +## Required Manifest Permissions + +The following permissions are declared in the module's `AndroidManifest.xml` and merged into your app automatically: + +```xml + + +``` + +These are required by `PushApprovalService` for background push handling. + +--- + +## Error Handling + +All `suspend` functions return `Result.failure(PingOneMFAException(...))` on error and never throw. `PingOneMFAException` contains a human-readable `message`. + +```kotlin +PingOneMFA.pair(pairingKey) + .onSuccess { + // success path + } + .onFailure { e -> + // e is PingOneMFAException — e.message is always non-null + Log.e("MFA", e.message) + } +``` + +--- + +## Sample Application + +PingOne MFA functionality is demonstrated in the [pingsampleapp](../samples/pingsampleapp) sample under the **PINGONE MFA** section of the home screen: + +- QR code scanning for device pairing +- Paired accounts list +- OTP display with live countdown +- Mobile payload generation screen +- Push notification handling for DEFAULT, CHALLENGE, and DRY push types +- Background push approval from the notification banner + +See the [pingsampleapp README](../samples/pingsampleapp/README.md) for build instructions. + +--- + +## API Reference + +### `PingOneMFA` + +| Function | Returns | Description | +|---|---|---| +| `suspend initialize(geo: Geo)` | `Result` | Configure the PingOne SDK for the selected service region. Idempotent after first success. | +| `suspend setDeviceToken(pushToken)` | `Result` | Register or refresh the FCM push token with PingOne. | +| `suspend pair(pairingKey)` | `Result` | Pair a new MFA account. | +| `suspend getDeviceInfo()` | `Result>` | Return all paired accounts. | +| `suspend getOneTimePasscode()` | `Result` | Return the current TOTP code and its remaining validity window. | +| `suspend processRemoteNotification(message)` | `Result` | Convert an FCM `RemoteMessage` to a typed `PushNotification`. | +| `suspend generateMobilePayload()` | `Result` | Generate a mobile payload for server-side authentication. | +| `approvePushNotificationFromBanner(notification)` | `Unit` | Start the background foreground service to approve a banner push. | +| `denyPushNotificationFromBanner(notification)` | `Unit` | Start the background foreground service to deny a banner push. | + +### `PingOneMfaAccount` + +| Field | Type | Description | +|---|---|---| +| `region` | `String` | Region key from the PingOne response (e.g. `"NA"`, `"EU"`) | +| `id` | `String` | PingOne user ID | +| `deviceId` | `String` | Device ID associated with this pairing within PingOne | +| `environment` | `String` | PingOne environment ID | +| `username` | `String` | The account's login username as returned by the PingOne server | +| `name` | `String?` | User's given (first) name, or `null` if not provided by the server | +| `family` | `String?` | User's family (last) name, or `null` if not provided by the server | + +### `OtpCodeInfo` + +| Field | Type | Description | +|---|---|---| +| `code` | `String` | Current TOTP passcode | +| `secondsRemaining` | `Int` | Seconds until the code expires (snapshot at call time); clamped to `0` if already expired | + +### `PushNotification` + +| Method / Field | Type | Description | +|---|---|--------------------------------------------------------------------------------------------------------------------------------------| +| `approveNotification(ctx, method, challenge?)` | `suspend Result` | Approve the push authentication request | +| `denyNotification(ctx)` | `suspend Result` | Deny the push authentication request | +| `isCancelAuthentication()` | `Boolean` | `true` when the server has cancelled active request (e.g. approved on another device) — dismiss the UI without requiring user action | +| `getNumbersChallenge()` | `IntArray?` | Options for a number-matching CHALLENGE push; `null` when free-form digit entry is expected | +| `getPushType()` | `PushType` | The interaction model required by this push (see `PushType`) | +| `id` | `String` | Wrapper-generated unique identifier for this push request | +| `title` | `String?` | Notification title extracted from the FCM payload | +| `message` | `String?` | Notification body extracted from the FCM payload | + +### `PushType` + +| Value | Description | +|---|---| +| `DEFAULT` | Standard authentication request — the user approves or denies with a single tap | +| `CHALLENGE` | Number-matching push — present the options from `getNumbersChallenge()`; a `null` return means free-form digit entry is expected | +| `DRY` | Silent test push sent by the server to verify push registration — no user action required | + +--- + +## Troubleshooting + +**Push notifications not received:** +- Verify the device token was registered via `PingOneMFA.setDeviceToken(token)`. +- Confirm `google-services.json` is present and matches your application ID. +- Check that the PingOne environment has FCM configured. + +**`getOneTimePasscode()` fails with a device-not-paired error:** +- Ensure `PingOneMFA.pair(pairingKey)` was called and succeeded before requesting an OTP. + +**`generateMobilePayload()` fails:** +- Ensure `PingOneMFA.initialize()` was called and succeeded before this call. +- Check network connectivity and PingOne service status. + +--- + +## License + +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](../LICENSE) file for details. diff --git a/pingonemfa/build.gradle.kts b/pingonemfa/build.gradle.kts index 4531fd936..efef1bc9b 100644 --- a/pingonemfa/build.gradle.kts +++ b/pingonemfa/build.gradle.kts @@ -11,18 +11,25 @@ plugins { id("kotlin-parcelize") alias(libs.plugins.androidLibrary) alias(libs.plugins.kotlinAndroid) + alias(libs.plugins.kotlinSerialization) } android { namespace = "com.pingidentity.pingonemfa" } dependencies { implementation(project(":foundation:android")) + implementation(project(":foundation:logger")) implementation(libs.com.pingidentity.pingonemfa) implementation(libs.kotlinx.coroutines.core) implementation(libs.google.gson.lib) + implementation(libs.kotlinx.serialization.json) // Firebase Cloud Messaging for push notifications implementation(platform(libs.firebase.bom)) implementation(libs.firebase.messaging) + + testImplementation(libs.kotlin.test) + testImplementation(libs.mockk) + testImplementation(libs.kotlinx.coroutines.test) } diff --git a/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/Geo.kt b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/Geo.kt new file mode 100644 index 000000000..7cb6d15f0 --- /dev/null +++ b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/Geo.kt @@ -0,0 +1,39 @@ +/* + * 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.pingonemfa.commons + +import com.pingidentity.pingidsdkv2.PingOneGeo + +/** + * PingOne MFA service region used when configuring the native SDK. + * + * This enum is part of the pingonemfa public API. It intentionally mirrors the + * native SDK regions without exposing the native PingOneGeo type. + */ +enum class Geo { + NORTH_AMERICA, + EUROPE, + CANADA, + AUSTRALIA, + SINGAPORE; + + /** + * Converts the public wrapper value to the native SDK value at the adapter boundary. + * + * Keep this mapping internal so apps can choose a PingOne region without taking a + * compile-time dependency on native PingOne MFA SDK API types. + */ + internal fun toPingOneGeo(): PingOneGeo = + when (this) { + NORTH_AMERICA -> PingOneGeo.NORTH_AMERICA + EUROPE -> PingOneGeo.EUROPE + CANADA -> PingOneGeo.CANADA + AUSTRALIA -> PingOneGeo.AUSTRALIA + SINGAPORE -> PingOneGeo.SINGAPORE + } +} diff --git a/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/PingOneMFA.kt b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/PingOneMFA.kt index 8f57cd20d..4b04f6afc 100644 --- a/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/PingOneMFA.kt +++ b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/PingOneMFA.kt @@ -1,11 +1,18 @@ +/* + * 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.pingonemfa.commons import android.content.Intent import androidx.core.content.ContextCompat import com.google.firebase.messaging.RemoteMessage import com.pingidentity.android.ContextProvider +import com.pingidentity.logger.Logger import com.pingidentity.pingidsdkv2.PingOne -import com.pingidentity.pingidsdkv2.PingOneGeo import com.pingidentity.pingidsdkv2.types.NotificationProvider import com.pingidentity.pingonemfa.otp.OtpCodeInfo import com.pingidentity.pingonemfa.push.PushApprovalService @@ -16,165 +23,284 @@ import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import org.json.JSONObject +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException +/** + * Entry point for all PingOne MFA operations. + * + * This is a singleton that wraps the native PingOne MFA SDK (`pingidsdkv2`) behind a + * coroutine-friendly API. All native callback-based operations are bridged to `suspend` + * functions that return [Result] — callers never need a try/catch. + * + * ## Lifecycle + * Call [initialize] once at application startup before invoking any other function. + * The call is guarded by a mutex and is idempotent — repeated calls after a successful + * initialization return [Result.success] immediately. + * + * ## Error handling + * All `suspend` functions return [Result.failure] wrapping a [PingOneMFAException] on error. + * The native [com.pingidentity.pingidsdkv2.PingOneSDKError] type is never exposed. + */ object PingOneMFA { - + private val logger: Logger = Logger.logger + @Volatile private var isInitialized: Boolean = false - private var lock = Mutex() + private val lock = Mutex() - //SDK must be initialized once and cannot handle parallel configure calls - suspend fun initialize(): Unit = lock.withLock { + /** + * Configures the PingOne MFA SDK for the given service [geo] region. + * + * Must be called once at application startup before any other [PingOneMFA] call. + * Subsequent calls after a successful initialization return [Result.success] immediately + * without re-entering the native SDK. The call is mutex-guarded so parallel invocations + * are safe — only one configure call will reach the native SDK. + */ + suspend fun initialize(geo: Geo): Result = lock.withLock { if (isInitialized) { - return + return Result.success(Unit) } - return suspendCancellableCoroutine { init -> - PingOne.configure( - ContextProvider.context, - // for demonstration purposes we simply hardcode the North America geo - PingOneGeo.NORTH_AMERICA - ) { error -> - if (error == null) { - isInitialized = true - init.resume(Unit) - }else{ - init.resumeWithException(PingOneMFAException(error.message)) + suspendCancellableCoroutine { continuation -> + try { + PingOne.configure( + ContextProvider.context, + geo.toPingOneGeo() + ) { error -> + continuation.resume( + error?.let { + logger.e("PingOne initialization failed: ${it.userInfo}") + Result.failure(PingOneMFAException(it)) + } ?: run { + isInitialized = true + Result.success(Unit) + } + ) } + }catch (e: Exception){ + logger.e("PingOne initialization failed", e) + continuation.resume(Result.failure(PingOneMFAException(e))) } } } - /* - * Registers push token with PingOne. Should be called each time the token is refreshed. + /** + * Registers or refreshes the FCM push [pushToken] with PingOne. + * + * Should be called each time Firebase delivers a new token via + * `FirebaseMessagingService.onNewToken`, and immediately after [initialize] succeeds. */ - suspend fun register(pushToken: String) : Boolean = withContext(Dispatchers.IO) { - suspendCancellableCoroutine { cont -> + suspend fun setDeviceToken(pushToken: String) : Result = withContext(Dispatchers.IO) { + suspendCancellableCoroutine { continuation -> try { PingOne.setDeviceToken( ContextProvider.context, pushToken, NotificationProvider.FCM ) { errors -> - val success = errors == null || errors.isEmpty() || errors.all { it == null } - if (cont.isActive) { - cont.resume(success) - } - } - } catch (_: Exception) { - if (cont.isActive) { - cont.resume(false) + val result = + errors + ?.firstOrNull { it != null } + ?.let { err -> + logger.e("PingOne push token registration failed: ${err.userInfo}") + Result.failure(PingOneMFAException(err)) + } + ?: Result.success(Unit) + + continuation.resume(result) + } + } catch (e : Exception) { + logger.e("PingOne push token registration failed", e) + continuation.resume(Result.failure(PingOneMFAException(e))) } } } - /* - * Starts pairing process with PingOne. + /** + * Pairs the device with a PingOne MFA account using [pairingKey]. + * + * The pairing key is typically obtained by scanning a QR code or from a DaVinci flow. + * On success, the account becomes available via [getDeviceInfo]. */ - suspend fun pair(pairingKey: String): Result = suspendCancellableCoroutine { cont -> + suspend fun pair(pairingKey: String): Result = suspendCancellableCoroutine { continuation -> try { PingOne.pair( ContextProvider.context, pairingKey - ) { pairingInfo, error -> - if (!cont.isActive) { - return@pair - } - if (error == null) { - cont.resume(Result.success(Unit)) - } else { - cont.resume(Result.failure(Exception(error.message))) - } + ) { _, error -> + val result = error?.let { err -> + logger.e("PingOne pairing failed: ${err.userInfo}") + Result.failure(PingOneMFAException(err)) + } ?: Result.success(Unit) + continuation.resume(result) } } catch (e: Exception) { - if (cont.isActive) { - cont.resume(Result.failure(e)) - } + logger.e("PingOne pairing failed", e) + continuation.resume(Result.failure(PingOneMFAException(e))) } } - /* - * Retrieves all paired accounts from PingOne + /** + * Returns metadata for all currently paired PingOne MFA accounts. + * Accounts are mapped into [PingOneMfaAccount] wrapper types. */ - suspend fun getAccounts(): Result> = - suspendCancellableCoroutine { cont -> + suspend fun getDeviceInfo(): Result> = + suspendCancellableCoroutine { continuation -> try { PingOne.getInfo( ContextProvider.context ) { deviceInfo, errors -> - run { - if (!cont.isActive) return@getInfo - if (deviceInfo!= null){ - val accounts = AccountParser().parseAccounts(deviceInfo) - cont.resume(Result.success(accounts)) - }else{ - cont.resume(Result.failure(PingOneMFAException(errors[0]?.message))) - } + /* + * Check errors first: if the SDK signaled a problem and deviceInfo is null or empty, + * treat the call as failed. + */ + val error = errors.firstOrNull { it != null } + val result = if (error != null && (deviceInfo == null || deviceInfo.isEmpty)) { + logger.e("PingOne getDeviceInfo failed: ${error.userInfo}") + Result.failure(PingOneMFAException(error)) + } else if (deviceInfo != null) { + Result.success(AccountParser().parseAccounts(deviceInfo.toString())) + } else { + // Neither errors nor deviceInfo — SDK misbehaved; avoid hanging the coroutine. + logger.e("PingOne getDeviceInfo failed: no data and no error") + Result.failure(PingOneMFAException(Exception("getDeviceInfo failed: no error details provided"))) } + continuation.resume(result) } }catch (e: Exception){ - if (cont.isActive) { - cont.resume(Result.failure(e)) - } + logger.e("PingOne getDeviceInfo failed", e) + continuation.resume(Result.failure(PingOneMFAException(e))) } } - /* - * Retrieves OTP code from PingOne. + /** + * Returns the current one-time passcode and its remaining validity window. + * + * [OtpCodeInfo.secondsRemaining] is a snapshot at call time, clamped to `0` if already + * expired. Re-call this function when the countdown reaches zero to receive the next code. */ - suspend fun collectOtp(): Result = suspendCancellableCoroutine { cont -> - PingOne.getOneTimePassCode(ContextProvider.context) { otpInfo, error -> - if (!cont.isActive) return@getOneTimePassCode - val result = if (otpInfo != null) { - Result.success(OtpCodeInfo( - otpInfo.passcode, - ((otpInfo.validUntil * 1000 - System.currentTimeMillis()) / 1000).toInt() - )) - } else { - Result.failure(PingOneMFAException(error?.message)) + suspend fun getOneTimePasscode(): Result = suspendCancellableCoroutine { continuation -> + try { + PingOne.getOneTimePassCode(ContextProvider.context) { otpInfo, error -> + val result = otpInfo?.let { + Result.success( + OtpCodeInfo( + otpInfo.passcode, + maxOf( + 0, + ((otpInfo.validUntil * 1000 - System.currentTimeMillis()) / 1000).toInt() + ) + ) + ) + } ?: run { + /* + * otpInfo is null but error may also be null if the SDK misbehaves; + * fall back to a generic exception so the coroutine is never left hanging + */ + logger.e("PingOne getOneTimePasscode failed: ${error?.userInfo}") + Result.failure(error?.let { + PingOneMFAException(it) + } ?: PingOneMFAException(Exception("getOneTimePasscode failed: no error details provided")) + ) + } + continuation.resume(result) } - cont.resume(result) + } catch (e: Exception) { + logger.e("PingOne getOneTimePasscode failed", e) + continuation.resume(Result.failure(PingOneMFAException(e))) } } - /* - * Transforms received FCM Remote Message object from PingOne into PushNotification object + /** + * Converts an incoming FCM [message] into a typed [PushNotification]. + * + * Call this from `FirebaseMessagingService.onMessageReceived` when the message data + * contains the `"PingOne"` key. The resulting [PushNotification] provides [PushNotification.getPushType], + * [PushNotification.approveNotification], [PushNotification.denyNotification], and + * [PushNotification.isCancelAuthentication] for the full push response lifecycle. */ - suspend fun collectPush(message: RemoteMessage): Result = - suspendCancellableCoroutine { cont -> + suspend fun processRemoteNotification(message: RemoteMessage): Result = + suspendCancellableCoroutine { continuation -> try { PingOne.processRemoteNotification( ContextProvider.context, message ) { notificationObject, error -> - if (!cont.isActive) return@processRemoteNotification - if (notificationObject != null) { - cont.resume( - Result.success( - PushNotification( - notificationObject = notificationObject, - title = getTitleFromRemoteMessageData(message.data["aps"]), - message = getBodyFromRemoteMessageData(message.data["aps"]) - ) + val result = notificationObject?.let { + Result.success( + PushNotification( + notificationObject = notificationObject, + /* + * Parse title and message from the "aps" field in the FCM data + * payload, which contains the original FCM payload sent by PingOne. + */ + title = getTitleFromRemoteMessageData(message.data["aps"]), + message = getBodyFromRemoteMessageData(message.data["aps"]) ) ) - return@processRemoteNotification + } ?: run { + /* + * notificationObject is null but error may also be null if the SDK + * misbehaves; fall back to a generic exception so the coroutine is + * never left hanging + */ + logger.e("PingOne processRemoteNotification failed: ${error?.userInfo}") + Result.failure(error?.let { + PingOneMFAException(it) + } ?: PingOneMFAException(Exception("processRemoteNotification failed: no error details provided")) + ) } - cont.resume(Result.failure(PingOneMFAException(error?.message))) + continuation.resume(result) } }catch (e: Exception){ - if (cont.isActive) { - cont.resume(Result.failure(PingOneMFAException(e.message))) + logger.e("PingOne processRemoteNotification failed", e) + continuation.resume(Result.failure(PingOneMFAException(e))) + } + } + + /** + * Generates a cryptographic mobile payload string from the native PingOne MFA SDK. + * + * The payload is intended for submission to a server-side authentication flow (e.g. DaVinci). + * Phase 1 exposes only the raw payload string — binding it to a Collector or continuation + * node is the responsibility of the calling layer. + */ + suspend fun generateMobilePayload(): Result = suspendCancellableCoroutine { continuation -> + try { + PingOne.generateMobilePayload(ContextProvider.context) { payload, error -> + val result = payload?.let { + Result.success(payload) + } ?: run { + /* + * payload is null but error may also be null if the SDK misbehaves; + * fall back to a generic exception so the coroutine is never left hanging + */ + logger.e("PingOne generateMobilePayload failed: ${error?.userInfo}") + Result.failure(error?.let { + PingOneMFAException(it) + } ?: PingOneMFAException(Exception("generateMobilePayload failed: no error details provided")) + ) } + continuation.resume(result) } + }catch (e: Exception){ + logger.e("PingOne generateMobilePayload failed", e) + continuation.resume(Result.failure(PingOneMFAException(e))) } + } - /* - * Approves MFA push notification. Should be called from notification action if application is in the background. + /** + * Approves the push authentication request represented by [notification] when the app is in + * the background (e.g. the user tapped Approve on the system notification banner). + * + * Starts [PushApprovalService] as a foreground service so the network call is permitted + * under Android's background execution restrictions. The outcome is not surfaced back to + * the UI — add a custom broadcast or shared state if your app needs to react to it. */ - fun approvePushNotificationFromBanner(notification: PushNotification?){ + fun approvePushNotificationFromBanner(notification: PushNotification){ val appContext = ContextProvider.context val intent = Intent(appContext, PushApprovalService::class.java).apply { putExtra("notification", notification) @@ -184,10 +310,15 @@ object PingOneMFA { ContextCompat.startForegroundService(appContext, intent) } - /* - * Denies MFA push notification. Should be called from notification action if application is in the background. + /** + * Denies the push authentication request represented by [notification] when the app is in + * the background (e.g. the user tapped Deny on the system notification banner). + * + * Starts [PushApprovalService] as a foreground service so the network call is permitted + * under Android's background execution restrictions. The outcome is not surfaced back to + * the UI — add a custom broadcast or shared state if your app needs to react to it. */ - fun denyPushNotificationFromBanner(notification: PushNotification?){ + fun denyPushNotificationFromBanner(notification: PushNotification){ val appContext = ContextProvider.context val intent = Intent(appContext, PushApprovalService::class.java).apply { putExtra("notification", notification) @@ -197,19 +328,24 @@ object PingOneMFA { ContextCompat.startForegroundService(appContext, intent) } - private fun getTitleFromRemoteMessageData(data: String?): String?{ - return if (data == null) { - null - }else{ - JSONObject(data).getJSONObject("alert").getString("title") + private fun getTitleFromRemoteMessageData(data: String?): String? = + data?.let { + Json.parseToJsonElement(it) + .jsonObject["alert"] + ?.jsonObject + ?.get("title") + ?.jsonPrimitive + ?.contentOrNull } - } - private fun getBodyFromRemoteMessageData(data: String?): String?{ - return if (data == null) { - null - }else{ - JSONObject(data).getJSONObject("alert").getString("body") + private fun getBodyFromRemoteMessageData(data: String?): String? = + data?.let { + Json.parseToJsonElement(it) + .jsonObject["alert"] + ?.jsonObject + ?.get("body") + ?.jsonPrimitive + ?.contentOrNull } - } -} \ No newline at end of file + +} diff --git a/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/PingOneMFAException.kt b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/PingOneMFAException.kt index 0576aa0e5..f27f3ce83 100644 --- a/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/PingOneMFAException.kt +++ b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/PingOneMFAException.kt @@ -1,6 +1,34 @@ -package com.pingidentity.pingonemfa.commons /* * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. - * Wrapper for error message into exception class + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +package com.pingidentity.pingonemfa.commons + +import com.pingidentity.pingidsdkv2.PingOneSDKError + +/** + * Exception thrown by suspend variants of the PingOne MFA APIs when an operation fails. + * + * The native [PingOneSDKError] type is intentionally not exposed, so hosting apps do not need + * a direct dependency on the `pingidsdkv2` AAR. All available error information is surfaced + * via the standard [message] property. + * + * ### Usage + * ```kotlin + * result.onFailure { e -> + * Log.e("MFA", e.message) + * } + * ``` */ -class PingOneMFAException(message: String?) : Exception(message) \ No newline at end of file +class PingOneMFAException(message: String?) : Exception(message) { + + // Internal factory constructors — accept native SDK type but keep them off the public API surface entirely. + internal constructor(error: PingOneSDKError) : this( + "Code=${error.code} \"${error.message}\" UserInfo=${error.userInfo}" + ) + + internal constructor(cause: Exception) : this(cause.message ?: "Unknown error") +} \ No newline at end of file diff --git a/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/PingOneMfaAccount.kt b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/PingOneMfaAccount.kt index badf9f067..d4321c2fc 100644 --- a/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/PingOneMfaAccount.kt +++ b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/PingOneMfaAccount.kt @@ -1,13 +1,30 @@ +/* + * 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.pingonemfa.commons -/* - * Represents a PingOne MFA account. +/** + * Metadata for a paired PingOne MFA account returned by + * [com.pingidentity.pingonemfa.commons.PingOneMFA.getDeviceInfo]. + * + * @property region Region key from the PingOne response (e.g. `"NA"`, `"EU"`). + * @property id PingOne user ID. + * @property deviceId Device ID associated with this pairing within PingOne. + * @property environment PingOne environment ID. + * @property username The account's login username as returned by the PingOne server. + * @property name User's given (first) name, or null if not provided by the server. + * @property family User's family (last) name, or null if not provided by the server. */ data class PingOneMfaAccount( val region: String, val id: String, val deviceId: String, val environment: String, - val name: String, - val family: String + val username: String, + val name: String?, + val family: String? ) \ No newline at end of file diff --git a/pingonemfa/src/main/java/com/pingidentity/pingonemfa/otp/OtpCodeInfo.kt b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/otp/OtpCodeInfo.kt index be4861fc1..5168f5409 100644 --- a/pingonemfa/src/main/java/com/pingidentity/pingonemfa/otp/OtpCodeInfo.kt +++ b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/otp/OtpCodeInfo.kt @@ -1,7 +1,21 @@ +/* + * 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.pingonemfa.otp -/* - * Very simple model for OTP code information. +/** + * The current one-time passcode returned by + * [com.pingidentity.pingonemfa.commons.PingOneMFA.getOneTimePasscode]. + * + * @property code The current TOTP passcode string (typically 6 digits). + * @property secondsRemaining Seconds until the code expires, computed at call time. + * Clamped to `0` if the code is already past its validity window. + * Re-call [com.pingidentity.pingonemfa.commons.PingOneMFA.getOneTimePasscode] when this + * reaches zero to receive the next code. */ data class OtpCodeInfo( val code: String, diff --git a/pingonemfa/src/main/java/com/pingidentity/pingonemfa/push/PushApprovalService.kt b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/push/PushApprovalService.kt index 7ad650037..45ac2679a 100644 --- a/pingonemfa/src/main/java/com/pingidentity/pingonemfa/push/PushApprovalService.kt +++ b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/push/PushApprovalService.kt @@ -1,3 +1,10 @@ +/* + * 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.pingonemfa.push //noinspection SuspiciousImport @@ -8,11 +15,14 @@ import android.app.NotificationManager import android.app.Service import android.content.Intent import android.os.Build -import android.util.Log import androidx.core.app.NotificationCompat +import com.pingidentity.logger.Logger +import com.pingidentity.pingidsdkv2.types.DenyReason +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume @@ -22,9 +32,12 @@ import kotlin.coroutines.resumeWithException * Service for handling push notifications actions. This service exists to solve ONE specific Android restriction: * Android does NOT allow network calls in the background (from notification actions in particular). */ -internal class PushApprovalService : Service(){ +internal class PushApprovalService( + dispatcher: CoroutineDispatcher = Dispatchers.IO +) : Service(){ - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val scope = CoroutineScope(SupervisorJob() + dispatcher) + private val logger: Logger = Logger.logger override fun onBind(p0: Intent?) = null @@ -53,7 +66,7 @@ internal class PushApprovalService : Service(){ denyNotificationWithAppInBackground(notificationObject) } } catch (e: Exception) { - Log.e("MfaApprovalService", "approval failed: ${e.message}", e) + logger.e("MfaApprovalService: push approval failed", e) } finally { stopForeground(STOP_FOREGROUND_REMOVE) stopSelf(startId) @@ -90,7 +103,7 @@ internal class PushApprovalService : Service(){ this, auth, null - ) { error -> + ) { _, error -> if (!cont.isActive) return@approve if (error == null) cont.resume(Unit) else cont.resumeWithException(Exception(error.message ?: "Approval failed")) @@ -104,7 +117,9 @@ internal class PushApprovalService : Service(){ ) = suspendCancellableCoroutine { cont -> try { notification.notificationObject.deny( - this){ error -> + this, + DenyReason.NONE + ){ error -> if (!cont.isActive) return@deny if (error == null) cont.resume(Unit) else cont.resumeWithException(Exception(error.message ?: "Deny action failed")) @@ -118,4 +133,18 @@ internal class PushApprovalService : Service(){ private const val NOTIFICATION_ID = 7001 } + /* + * Cancel the coroutine scope when Android destroys the service. + * + * Without this, the SupervisorJob stays alive after onDestroy() is called: + * any in-flight approve/deny network call would keep running against a dead + * service instance, holding a reference to it and leaking memory until the + * coroutine eventually completes or is garbage-collected. Cancelling here + * ensures all child coroutines are interrupted immediately and the scope + * cannot launch new work after the service is gone. + */ + override fun onDestroy() { + super.onDestroy() + scope.cancel() + } } \ No newline at end of file diff --git a/pingonemfa/src/main/java/com/pingidentity/pingonemfa/push/PushNotification.kt b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/push/PushNotification.kt index d1abb9e3b..e31c6053d 100644 --- a/pingonemfa/src/main/java/com/pingidentity/pingonemfa/push/PushNotification.kt +++ b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/push/PushNotification.kt @@ -1,26 +1,49 @@ +/* + * 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.pingonemfa.push import android.content.Context import android.os.Parcelable import com.pingidentity.pingidsdkv2.NotificationObject +import com.pingidentity.pingidsdkv2.types.DenyReason +import com.pingidentity.pingonemfa.commons.PingOneMFAException import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.parcelize.Parcelize import java.util.UUID import kotlin.coroutines.resume -/* - * Simple model for a push notification. Implements Parcelable so it can be passed between components. +/** + * Wrapper model for a PingOne MFA push authentication request. + * + * Produced by [com.pingidentity.pingonemfa.commons.PingOneMFA.processRemoteNotification] from an + * incoming FCM [com.google.firebase.messaging.RemoteMessage]. All user-facing operations + * (approve, deny) are exposed as suspend functions that return [Result]. + * + * @property id Wrapper-generated unique identifier for this push request. + * @property title Notification title extracted from the FCM data payload, or null if absent. + * @property message Notification body extracted from the FCM data payload, or null if absent. */ @Parcelize data class PushNotification( val id: String = UUID.randomUUID().toString(), val notificationObject: NotificationObject, val title: String?, - val message: String?, - val sentAt: Long = System.currentTimeMillis(), - val respondedAt: Long? = null + val message: String? ): Parcelable { + /** + * Approves this push authentication request. + * + * @param context Android context — use `applicationContext` when calling from a service. + * @param authenticationMethod The authentication method string sent to PingOne (e.g. `"app"`). + * @param numberChallenge The number selected by the user for a [PushType.CHALLENGE] push, + * or `null` for a [PushType.DEFAULT] push. + */ suspend fun approveNotification( context: Context, authenticationMethod: String, @@ -30,57 +53,68 @@ data class PushNotification( context, authenticationMethod, numberChallenge - ) { error -> - if (!cont.isActive) return@approve + ) { _, error -> if (error == null) { cont.resume(Result.success(Unit)) } else { - cont.resume(Result.failure(Exception(error.userInfo.toString()))) + cont.resume(Result.failure(PingOneMFAException(error))) } } } catch (e: Exception) { - if (cont.isActive) { - cont.resume(Result.failure(e)) - } + cont.resume(Result.failure(PingOneMFAException(e))) } } + /** + * Denies this push authentication request. + * + * @param context Android context — use `applicationContext` when calling from a service. + */ suspend fun denyNotification(context: Context) : Result = suspendCancellableCoroutine { cont -> try { notificationObject.deny( - context + context, + DenyReason.NONE ) { error -> - if (!cont.isActive) return@deny if (error == null) { cont.resume(Result.success(Unit)) } else { - cont.resume(Result.failure(Exception(error.userInfo.toString()))) + cont.resume(Result.failure(PingOneMFAException(error))) } } } catch (e: Exception) { - if (cont.isActive) { - cont.resume(Result.failure(e)) - } + cont.resume(Result.failure(PingOneMFAException(e))) } } - fun requiresBiometric() : Boolean{ - return !isChallenge() - } - - fun isChallenge() : Boolean{ - return notificationObject.numberMatchingType!=null + /** + * Returns `true` when the server has canceled this authentication request — for example, + * because the user approved it on another device. + * + * When this returns `true`, the app should dismiss any visible approval UI immediately + * without requiring the user to take action. + */ + fun isCancelAuthentication(): Boolean { + return notificationObject.isCancelAuth } + /** + * Returns the server-provided number options for a [PushType.CHALLENGE] push, or `null` + * when free-form digit entry is expected instead. + */ fun getNumbersChallenge(): IntArray? { return notificationObject.numberMatchingOptions } + /** + * Returns the [PushType] that describes the interaction model required by this push. + * UI components should switch on this to decide which approval flow to present. + */ fun getPushType () : PushType { return when { - notificationObject.isTest -> PushType.DRY // TODO handle in Sample App + notificationObject.isTest -> PushType.DRY notificationObject.numberMatchingType != null -> PushType.CHALLENGE - else -> PushType.DEFAULT // TODO handle how to receive BIOMETRIC enforcement + else -> PushType.DEFAULT } } } diff --git a/pingonemfa/src/main/java/com/pingidentity/pingonemfa/push/PushType.kt b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/push/PushType.kt index fb62338bc..0a971839a 100644 --- a/pingonemfa/src/main/java/com/pingidentity/pingonemfa/push/PushType.kt +++ b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/push/PushType.kt @@ -1,8 +1,36 @@ +/* + * 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.pingonemfa.push +/** + * Describes the interaction model required by a PingOne MFA push notification. + * + * The value is determined by the server and delivered inside the push payload. + * UI components should switch on this type to decide which approval flow to present. + */ enum class PushType { + /** + * A standard authentication request. The user approves or denies with a single tap. + */ DEFAULT, + + /** + * A silent test push sent by the server to verify the device's push registration. + * No user action is required — the notification should be dismissed automatically + * or shown with a "Dismiss" button only. + */ DRY, - CHALLENGE, - BIOMETRIC -} \ No newline at end of file + + /** + * A number-matching challenge. The server provides a set of numbers (or expects + * free-form digit input when none are provided), and the user must confirm the one + * that matches what they see on their other device. Use [PushNotification.getNumbersChallenge] + * to retrieve the options; a null return means free-form entry is expected. + */ + CHALLENGE +} diff --git a/pingonemfa/src/main/java/com/pingidentity/pingonemfa/util/AccountParser.kt b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/util/AccountParser.kt index ab2b6060a..e0e62b3ba 100644 --- a/pingonemfa/src/main/java/com/pingidentity/pingonemfa/util/AccountParser.kt +++ b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/util/AccountParser.kt @@ -1,29 +1,62 @@ +/* + * 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.pingonemfa.util -import com.google.gson.JsonObject import com.pingidentity.pingonemfa.commons.PingOneMfaAccount +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json -internal class AccountParser { - fun parseAccounts(json: JsonObject): List { - val result = mutableListOf() - - json.entrySet().forEach { (region, regionElement) -> - val users = regionElement.asJsonObject - .getAsJsonArray("users") - ?.mapNotNull { it.asJsonObject } - ?: emptyList() +internal class AccountParser( + private val json: Json = Json { + ignoreUnknownKeys = true + explicitNulls = false + } +) { + fun parseAccounts(rawJson: String): List { + val decoded: Map = json.decodeFromString(rawJson) - users.forEach { user -> - result += PingOneMfaAccount( + return decoded.flatMap { (region, regionDto) -> + regionDto.users.map { + PingOneMfaAccount( region = region, - id = user.get("id")?.asString ?: "", - environment = user.getAsJsonObject("environment")?.get("id")?.asString ?: "", - deviceId = user.getAsJsonObject("device")?.get("id")?.asString ?: "", - name = user.getAsJsonObject("name")?.get("given")?.asString ?: "", - family = user.getAsJsonObject("name")?.get("family")?.asString ?: "" + id = it.id.orEmpty(), + environment = it.environment?.id.orEmpty(), + deviceId = it.device?.id.orEmpty(), + username = it.username, + name = it.name?.given.orEmpty(), + family = it.name?.family.orEmpty() ) } } - return result } -} \ No newline at end of file +} + +@Serializable +internal data class RegionDto( + val users: List = emptyList() +) + +@Serializable +internal data class UserDto( + val id: String? = null, + val environment: IdContainer? = null, + val device: IdContainer? = null, + val username: String, + val name: NameDto? = null +) + +@Serializable +internal data class IdContainer( + val id: String? = null +) + +@Serializable +internal data class NameDto( + val given: String? = null, + val family: String? = null +) \ No newline at end of file diff --git a/pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/commons/GeoTest.kt b/pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/commons/GeoTest.kt new file mode 100644 index 000000000..89ac68810 --- /dev/null +++ b/pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/commons/GeoTest.kt @@ -0,0 +1,30 @@ +/* + * 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.pingonemfa.commons + +import com.pingidentity.pingidsdkv2.PingOneGeo +import org.junit.Test +import kotlin.test.assertEquals + +class GeoTest { + + @Test + fun `toPingOneGeo maps all public geos to native PingOneGeo values`() { + val mappings = mapOf( + Geo.NORTH_AMERICA to PingOneGeo.NORTH_AMERICA, + Geo.EUROPE to PingOneGeo.EUROPE, + Geo.CANADA to PingOneGeo.CANADA, + Geo.AUSTRALIA to PingOneGeo.AUSTRALIA, + Geo.SINGAPORE to PingOneGeo.SINGAPORE + ) + + mappings.forEach { (geo, expectedPingOneGeo) -> + assertEquals(expectedPingOneGeo, geo.toPingOneGeo()) + } + } +} diff --git a/pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/commons/PingOneMFATest.kt b/pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/commons/PingOneMFATest.kt new file mode 100644 index 000000000..2f3928ab9 --- /dev/null +++ b/pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/commons/PingOneMFATest.kt @@ -0,0 +1,572 @@ +package com.pingidentity.pingonemfa.commons + +import android.content.Context +import androidx.core.content.ContextCompat +import com.google.firebase.messaging.RemoteMessage +import com.google.gson.JsonObject +import com.pingidentity.android.ContextProvider +import com.pingidentity.pingidsdkv2.NotificationObject +import com.pingidentity.pingidsdkv2.PingOne +import com.pingidentity.pingidsdkv2.PingOneGeo +import com.pingidentity.pingidsdkv2.PingOneSDKError +import com.pingidentity.pingidsdkv2.types.NotificationProvider +import com.pingidentity.pingidsdkv2.types.OneTimePasscodeInfo +import com.pingidentity.pingidsdkv2.types.PairingInfo +import com.pingidentity.pingonemfa.push.PushNotification +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PingOneMFATest { + private val mockContext = mockk(relaxed = true) + private val mockRemoteMessage = mockk(relaxed = true) + private val mockNotificationObject = mockk(relaxed = true) + private val mockPairingInfo = mockk(relaxed = true) + private val mockDeviceInfo = mockk(relaxed = true) + @Before + fun setUp() { + MockKAnnotations.init(this, relaxed = true) + + // mock ContextProvider object and provide a mock Context + mockkObject(ContextProvider) + every { ContextProvider.context } returns mockContext + + // mock PingOne static functions (Java class) + mockkStatic("com.pingidentity.pingidsdkv2.PingOne") + + + // default remote message data with APS containing an alert/body + every { mockRemoteMessage.data } returns mapOf( + "aps" to """ + { + "alert": { + "title": "mocked title", + "body": "mocked body" + } + } + """.trimIndent() + ) + + every{ mockDeviceInfo.toString()} returns """ + { + "NA": { + "users": [ + { + "id": "user1", + "environment": { "id": "env1" }, + "device": { "id": "dev1" }, + "username": "jdoe", + "name": { "given": "John", "family": "Doe" } + } + ] + } + } + """.trimIndent() + + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `initialize configures once and returns success when callback error is null`() = runTest { + + // Reset singleton state FIRST + val field = PingOneMFA::class.java.getDeclaredField("isInitialized") + field.isAccessible = true + field.setBoolean(PingOneMFA, false) + + + every { + PingOne.configure(any(), any(), any()) + } answers { + val callback = arg(2) + callback.onComplete(null) + } + + // ACT + val first = PingOneMFA.initialize(Geo.NORTH_AMERICA) + val second = PingOneMFA.initialize(Geo.EUROPE) + + // ASSERT + assertTrue(first.isSuccess) + assertTrue(second.isSuccess) + + verify(exactly = 1) { + PingOne.configure(any(), any(), any()) + } + } + + @Test + fun `initialize maps geo to PingOneGeo`() = runTest { + + // Reset singleton state FIRST + val field = PingOneMFA::class.java.getDeclaredField("isInitialized") + field.isAccessible = true + field.setBoolean(PingOneMFA, false) + + every { + PingOne.configure(any(), any(), any()) + } answers { + val callback = arg(2) + callback.onComplete(null) + } + + // ACT + val result = PingOneMFA.initialize(Geo.EUROPE) + + // ASSERT + assertTrue(result.isSuccess) + + verify(exactly = 1) { + PingOne.configure(any(), PingOneGeo.EUROPE, any()) + } + } + + @Test + fun `initialize configures once and returns error`() = runTest { + + // Reset singleton state FIRST + val field = PingOneMFA::class.java.getDeclaredField("isInitialized") + field.isAccessible = true + field.setBoolean(PingOneMFA, false) + + + every { + PingOne.configure(any(), any(), any()) + } answers { + val callback = arg(2) + callback.onComplete(PingOneSDKError(100, "mockedError")) + } + + // ACT + val result = PingOneMFA.initialize(Geo.NORTH_AMERICA) + + // ASSERT — known SDK failure: message is the formatted string, no SDK type needed + assertTrue(result.isFailure) + assertTrue { result.exceptionOrNull() is PingOneMFAException } + assertTrue { result.exceptionOrNull()!!.message!!.contains("100") } + } + + @Test + fun `initialize configures once and throws exception`() = runTest { + + // Reset singleton state FIRST + val field = PingOneMFA::class.java.getDeclaredField("isInitialized") + field.isAccessible = true + field.setBoolean(PingOneMFA, false) + + every { + PingOne.configure(any(), any(), any()) + } throws RuntimeException("Simulated configuration failure") + + // ACT + val result = PingOneMFA.initialize(Geo.NORTH_AMERICA) + + // ASSERT — unexpected exception path: message is forwarded from the original exception + assertTrue(result.isFailure) + assertTrue { result.exceptionOrNull() is PingOneMFAException } + assertEquals("Simulated configuration failure", result.exceptionOrNull()!!.message) + } + + @Test + fun `register returns success when callback errors is null`() = runTest { + every { + PingOne.setDeviceToken(any(), any(), any(), any()) + } answers { + val callback = arg(3) + callback.onComplete(Array(1) { null }) + } + + val result = PingOneMFA.setDeviceToken("token") + + assertTrue(result.isSuccess) + verify { + PingOne.setDeviceToken(mockContext, "token", NotificationProvider.FCM, any()) + } + } + + @Test + fun `register returns error when callback errors is not null`() = runTest { + every { + PingOne.setDeviceToken(any(), any(), any(), any()) + } answers { + val callback = arg(3) + callback.onComplete(Array(1) { PingOneSDKError(10003, "mockedError") }) + } + + val result = PingOneMFA.setDeviceToken("token") + + // Known SDK failure: message contains the formatted SDK code + assertTrue(result.isFailure) + assertTrue { result.exceptionOrNull() is PingOneMFAException } + assertTrue { result.exceptionOrNull()!!.message!!.contains("10003") } + verify { + PingOne.setDeviceToken(mockContext, "token", NotificationProvider.FCM, any()) + } + } + + @Test + fun `register returns error when exception is thrown`() = runTest { + every { + PingOne.setDeviceToken(any(), any(), any(), any()) + } throws RuntimeException("Simulated network error") + + val result = PingOneMFA.setDeviceToken("token") + + // Unexpected exception path: message is forwarded from the original exception + assertTrue(result.isFailure) + assertTrue { result.exceptionOrNull() is PingOneMFAException } + assertEquals("Simulated network error", result.exceptionOrNull()!!.message) + verify { + PingOne.setDeviceToken(mockContext, "token", NotificationProvider.FCM, any()) + } + } + + @Test + fun `pair returns success when callback error is null`() = runTest { + every { + PingOne.pair(any(), any(), any()) + } answers { + val callback = arg(2) + callback.onComplete(mockPairingInfo, null) + } + + val result = PingOneMFA.pair("PAIR-KEY") + + assertTrue(result.isSuccess) + verify { + PingOne.pair(mockContext, "PAIR-KEY", any()) + } + } + + @Test + fun `pair returns error when callback error is not null`() = runTest { + every { + PingOne.pair(any(), any(), any()) + } answers { + val callback = arg(2) + callback.onComplete(null, PingOneSDKError(10003, "mockedError")) + } + + val result = PingOneMFA.pair("PAIR-KEY") + + // Known SDK failure: message contains the formatted SDK code + assertTrue(result.isFailure) + assertTrue { result.exceptionOrNull() is PingOneMFAException } + assertTrue { result.exceptionOrNull()!!.message!!.contains("10003") } + verify { + PingOne.pair(mockContext, "PAIR-KEY", any()) + } + } + + @Test + fun `pair returns error when exception is thrown`() = runTest { + every { + PingOne.pair(any(), any(), any()) + } throws RuntimeException("Simulated network error") + + val result = PingOneMFA.pair("PAIR-KEY") + + // Unexpected exception path: message is forwarded from the original exception + assertTrue(result.isFailure) + assertTrue { result.exceptionOrNull() is PingOneMFAException } + assertEquals("Simulated network error", result.exceptionOrNull()!!.message) + verify { + PingOne.pair(mockContext, "PAIR-KEY", any()) + } + } + + @Test + fun `getAccounts returns success when deviceInfo is present`() = runTest { + every { + PingOne.getInfo(any(), any()) + } answers { + val callback = arg(1) + callback.onComplete(mockDeviceInfo, Array(1) { null }) + } + + val result = PingOneMFA.getDeviceInfo() + + assertTrue(result.isSuccess) + + val accounts = result.getOrNull()!! + assertEquals(1, accounts.size) + assertEquals("user1", accounts.first().id) + assertEquals("env1", accounts.first().environment) + assertEquals("dev1", accounts.first().deviceId) + assertEquals("jdoe", accounts.first().username) + assertEquals("John", accounts.first().name) + assertEquals("Doe", accounts.first().family) + verify { + PingOne.getInfo(mockContext, any()) + } + } + + @Test + fun `getAccounts returns error when deviceInfo is not present`() = runTest { + every{ + PingOne.getInfo(any(), any()) + } answers { + val callback = arg(1) + callback.onComplete(null, Array(1){ PingOneSDKError(10003, "mockedError") }) + } + val result = PingOneMFA.getDeviceInfo() + // Known SDK failure: message contains the formatted SDK code + assertTrue(result.isFailure) + assertTrue { result.exceptionOrNull() is PingOneMFAException } + assertTrue { result.exceptionOrNull()!!.message!!.contains("10003") } + } + + @Test + fun `getAccounts returns error when errors list is empty`() = runTest { + // Defensive case: SDK returns null deviceInfo and an empty errors array — should not hang + every { + PingOne.getInfo(any(), any()) + } answers { + val callback = arg(1) + callback.onComplete(null, emptyArray()) + } + val result = PingOneMFA.getDeviceInfo() + // Fallback exception path: cause carries the generic message + assertTrue(result.isFailure) + assertTrue { result.exceptionOrNull() is PingOneMFAException } + assertEquals("getDeviceInfo failed: no error details provided", result.exceptionOrNull()!!.message + ) + } + + @Test + fun `getAccounts returns error when exception is thrown`() = runTest{ + every { + PingOne.getInfo(any(), any()) + } throws RuntimeException("Simulated network error") + val result = PingOneMFA.getDeviceInfo() + // Unexpected exception path: message is forwarded from the original exception + assertTrue(result.isFailure) + assertTrue { result.exceptionOrNull() is PingOneMFAException } + assertEquals("Simulated network error", result.exceptionOrNull()!!.message) + } + + + + + @Test + fun `collectOtp returns success when callback error is null`() = runTest { + every { + PingOne.getOneTimePassCode(any(), any()) + } answers { + val callback = arg(1) + callback.onComplete(OneTimePasscodeInfo("123456", 100000, 30), null) + } + val result = PingOneMFA.getOneTimePasscode() + assertTrue(result.isSuccess) + assertEquals(result.getOrNull()?.code, "123456") + } + + @Test + fun `collectOtp returns error when callback error is not null`() = runTest { + every { + PingOne.getOneTimePassCode(any(), any()) + } answers { + val callback = arg(1) + callback.onComplete(null, PingOneSDKError(10003, "mockedError")) + } + val result = PingOneMFA.getOneTimePasscode() + // Known SDK failure: message contains the formatted SDK code + assertTrue(result.isFailure) + assertTrue { result.exceptionOrNull() is PingOneMFAException } + assertTrue { result.exceptionOrNull()!!.message!!.contains("10003") } + } + + @Test + fun `collectOtp returns error when both otpInfo and error are null`() = runTest { + // Defensive case: SDK returns null otpInfo and null error — should not hang + every { + PingOne.getOneTimePassCode(any(), any()) + } answers { + val callback = arg(1) + callback.onComplete(null, null) + } + val result = PingOneMFA.getOneTimePasscode() + // Fallback exception path: cause carries the generic message + assertTrue(result.isFailure) + assertTrue { result.exceptionOrNull() is PingOneMFAException } + assertEquals("getOneTimePasscode failed: no error details provided", result.exceptionOrNull()!!.message) + } + + @Test + fun `collectOtp returns error when exception is thrown`() = runTest{ + every { + PingOne.getOneTimePassCode(any(), any()) + } throws RuntimeException("Simulated network error") + val result = PingOneMFA.getOneTimePasscode() + // Unexpected exception path: message is forwarded from the original exception + assertTrue(result.isFailure) + assertTrue { result.exceptionOrNull() is PingOneMFAException } + assertEquals("Simulated network error", result.exceptionOrNull()!!.message) + } + + @Test + fun `collectPush returns success when callback error is null`() = runTest{ + every { + PingOne.processRemoteNotification(any(), any(), any()) + } answers { + val callback = arg(2) + callback.onComplete(mockNotificationObject, null) + } + val result = PingOneMFA.processRemoteNotification(mockRemoteMessage) + assertTrue(result.isSuccess) + assertEquals(result.getOrNull()?.notificationObject, mockNotificationObject) + assertEquals("mocked title", result.getOrNull()?.title) + assertEquals("mocked body", result.getOrNull()?.message) + + verify { + PingOne.processRemoteNotification(mockContext, mockRemoteMessage, any()) + } + } + + @Test + fun `collectPush returns error when callback error is not null`() = runTest{ + every { + PingOne.processRemoteNotification(any(), any(), any()) + } answers { + val callback = arg(2) + callback.onComplete(null, PingOneSDKError(10003, "mockedError")) + } + val result = PingOneMFA.processRemoteNotification(mockRemoteMessage) + // Known SDK failure: message contains the formatted SDK code + assertTrue(result.isFailure) + assertTrue { result.exceptionOrNull() is PingOneMFAException } + assertTrue { result.exceptionOrNull()!!.message!!.contains("10003") } + } + + @Test + fun `collectPush returns error when both notificationObject and error are null`() = runTest { + // Defensive case: SDK returns null notification and null error — should not hang + every { + PingOne.processRemoteNotification(any(), any(), any()) + } answers { + val callback = arg(2) + callback.onComplete(null, null) + } + val result = PingOneMFA.processRemoteNotification(mockRemoteMessage) + // Fallback exception path: cause carries the generic message + assertTrue(result.isFailure) + assertTrue { result.exceptionOrNull() is PingOneMFAException } + assertEquals("processRemoteNotification failed: no error details provided", result.exceptionOrNull()!!.message) + } + + @Test + fun `collectPush returns error when exception is thrown`() = runTest{ + every { + PingOne.processRemoteNotification(any(), any(), any()) + } throws RuntimeException("Mocked Exception") + val result = PingOneMFA.processRemoteNotification(mockRemoteMessage) + // Unexpected exception path: message is forwarded from the original exception + assertTrue(result.isFailure) + assertTrue { result.exceptionOrNull() is PingOneMFAException } + assertEquals("Mocked Exception", result.exceptionOrNull()!!.message) + } + + @Test + fun `collectMobilePayload returns payload when error is null`() = runTest { + every { + PingOne.generateMobilePayload(any(), any()) + } answers { + val callback = arg(1) + callback.onComplete("mockPayload", null) + } + val result = PingOneMFA.generateMobilePayload() + assertTrue(result.isSuccess) + assertEquals(result.getOrNull(), "mockPayload") + } + + @Test + fun `collectMobilePayload returns error when error is not null`() = runTest { + every { + PingOne.generateMobilePayload(any(), any()) + } answers { + val callback = arg(1) + callback.onComplete(null, PingOneSDKError(10003, "mockedError")) + } + val result = PingOneMFA.generateMobilePayload() + // Known SDK failure: message contains the formatted SDK code + assertTrue(result.isFailure) + assertTrue { result.exceptionOrNull() is PingOneMFAException } + assertTrue { result.exceptionOrNull()!!.message!!.contains("10003") } + } + + @Test + fun `collectMobilePayload returns error when both payload and error are null`() = runTest { + // Defensive case: SDK returns null payload and null error — should not hang + every { + PingOne.generateMobilePayload(any(), any()) + } answers { + val callback = arg(1) + callback.onComplete(null, null) + } + val result = PingOneMFA.generateMobilePayload() + // Fallback exception path: cause carries the generic message + assertTrue(result.isFailure) + assertTrue { result.exceptionOrNull() is PingOneMFAException } + assertEquals("generateMobilePayload failed: no error details provided", result.exceptionOrNull()!!.message) + } + + @Test + fun `collectMobilePayload returns error when exception is thrown`() = runTest { + every { + PingOne.generateMobilePayload(any(), any()) + } throws RuntimeException("Mocked Exception") + val result = PingOneMFA.generateMobilePayload() + // Unexpected exception path: message is forwarded from the original exception + assertTrue(result.isFailure) + assertTrue { result.exceptionOrNull() is PingOneMFAException } + assertEquals("Mocked Exception", result.exceptionOrNull()!!.message) + } + + @Test + fun `approvePushNotificationFromBanner starts foreground service with correct intent`() { + val mockNotification = mockk(relaxed = true) + mockkStatic(ContextCompat::class) + every { + ContextCompat.startForegroundService(any(), any()) + } returns mockk() + + PingOneMFA.approvePushNotificationFromBanner(mockNotification) + + // verify service start + verify(exactly = 1) { + ContextCompat.startForegroundService(mockContext, any()) + } + } + + @Test + fun `denyPushNotificationFromBanner starts foreground service with intent`() { + val mockNotification = mockk(relaxed = true) + + mockkStatic(ContextCompat::class) + every { + ContextCompat.startForegroundService(any(), any()) + } returns mockk() + + // act + PingOneMFA.denyPushNotificationFromBanner(mockNotification) + + // verify wiring + verify(exactly = 1) { + ContextCompat.startForegroundService(mockContext, any()) + } + } +} diff --git a/pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/push/PushApprovalServiceTest.kt b/pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/push/PushApprovalServiceTest.kt new file mode 100644 index 000000000..04888e9de --- /dev/null +++ b/pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/push/PushApprovalServiceTest.kt @@ -0,0 +1,161 @@ +package com.pingidentity.pingonemfa.push + +import android.app.Notification +import android.app.NotificationManager +import android.content.Intent +import com.pingidentity.pingidsdkv2.NotificationObject +import com.pingidentity.pingidsdkv2.PingOne +import com.pingidentity.pingidsdkv2.types.DenyReason +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.spyk +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import kotlin.test.Test + +class PushApprovalServiceTest { + private val testDispatcher = StandardTestDispatcher() + + private lateinit var notificationObject: NotificationObject + private lateinit var pushNotification: PushNotification + + private lateinit var service: PushApprovalService + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + + notificationObject = mockk(relaxed = true) + + pushNotification = PushNotification( + notificationObject = notificationObject, + title = "title", + message = "message" + ) + + service = spyk(PushApprovalService(dispatcher = testDispatcher), recordPrivateCalls = true) + // CRITICAL: block Android notification creation + every { + service["createForegroundNotification"]() + } returns mockk(relaxed = true) + + // Prevent Android framework calls + every { service.startForeground(any(), any()) } just Runs + every { service.stopForeground(any()) } just Runs + every { service.stopSelf(any()) } just Runs + + // Notification plumbing + every { + service.getSystemService(NotificationManager::class.java) + } returns mockk(relaxed = true) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + @Test + fun `onStartCommand approves notification when user_action is approve`() = runTest { + every { + notificationObject.approve(any(), any(), any(), any()) + } answers { + val callback = arg(3) + callback.onComplete(null, null) + } + + val intent = spyk(Intent()) + + // Mock both overloads: the single-arg deprecated one (pre-Tiramisu) and the + // two-arg typed one (API 33+). onStartCommand branches on Build.VERSION.SDK_INT + // so only one path is exercised at runtime, but both must be stubbed so the test + // compiles and runs correctly regardless of which API level the host JVM reports. + every { + intent.getParcelableExtra("notification") + } returns pushNotification + every { + intent.getParcelableExtra("notification", PushNotification::class.java) + } returns pushNotification + + every { + intent.getStringExtra("auth_method") + } returns "banner" + + every { + intent.getStringExtra("user_action") + } returns "approve" + + service.onStartCommand(intent, 0, 1) + + testDispatcher.scheduler.advanceUntilIdle() + + // All arguments must use matchers when any() is present — mixing literals and + // matchers causes MockK to misinterpret null as a non-matcher and fail verification + // even when the call did occur with the expected values. + verify { + notificationObject.approve( + any(), + eq("banner"), + isNull(), + any() + ) + } + } + + @Test + fun `onStartCommand denies notification when user_action is deny`() = runTest { + every { + notificationObject.deny(any(), DenyReason.NONE, any()) + } answers { + val callback = args[2] + callback!! + .javaClass + .getMethod("onComplete", Any::class.java) + .invoke(callback, null) + } + + val intent = spyk(Intent()) + + // Mock both overloads: the single-arg deprecated one (pre-Tiramisu) and the + // two-arg typed one (API 33+). See approve test for full explanation. + every { + intent.getParcelableExtra("notification") + } returns pushNotification + every { + intent.getParcelableExtra("notification", PushNotification::class.java) + } returns pushNotification + + every { + intent.getStringExtra("auth_method") + } returns "banner" + + every { + intent.getStringExtra("user_action") + } returns "deny" + + service.onStartCommand(intent, 0, 1) + + testDispatcher.scheduler.advanceUntilIdle() + verify { + notificationObject.deny( + service, + DenyReason.NONE, + any() + ) + } + + } +} diff --git a/pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/push/PushNotificationTest.kt b/pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/push/PushNotificationTest.kt new file mode 100644 index 000000000..50fd4a5b7 --- /dev/null +++ b/pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/push/PushNotificationTest.kt @@ -0,0 +1,220 @@ +package com.pingidentity.pingonemfa.push + +import android.content.Context +import com.pingidentity.pingidsdkv2.NotificationObject +import com.pingidentity.pingidsdkv2.PingOne +import com.pingidentity.pingidsdkv2.PingOneSDKError +import com.pingidentity.pingidsdkv2.types.DenyReason +import com.pingidentity.pingonemfa.commons.PingOneMFAException +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertTrue +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PushNotificationTest { + private val context = mockk(relaxed = true) + private val notificationObject = mockk(relaxed = true) + + @Test + fun `approveNotification returns success when callback error is null`() = runTest { + every { + notificationObject.approve(any(), any(), any(), any()) + } answers { + val callback = arg(3) + callback.onComplete(null, null) + } + + val push = PushNotification( + notificationObject = notificationObject, + title = "t", + message = "m" + ) + + val result = push.approveNotification(context, "banner") + + assertTrue(result.isSuccess) + } + + @Test + fun `approveNotification returns error when callback error is not null`() = runTest { + every { + notificationObject.approve(any(), any(), any(), any()) + } answers { + val callback = arg(3) + callback.onComplete(null, PingOneSDKError(1003, "mock")) + } + val push = PushNotification( + notificationObject = notificationObject, + title = "t", + message = "m" + ) + val result = push.approveNotification(context, "banner") + assertTrue(result.isFailure) + // Known SDK failure: message contains the formatted SDK code + assertTrue { result.exceptionOrNull() is PingOneMFAException } + assertTrue { result.exceptionOrNull()!!.message!!.contains("1003") } + } + @Test + fun `approveNotification returns failure when exception is thrown`() = runTest { + every { + notificationObject.approve(any(), any(), any(), any()) + } throws RuntimeException("Simulated Network Error") + + val push = PushNotification( + notificationObject = notificationObject, + title = null, + message = null + ) + + val result = push.approveNotification(context, "banner") + + assertTrue(result.isFailure) + assertTrue { result.exceptionOrNull() is Exception } + assertTrue { result.exceptionOrNull()?.message == "Simulated Network Error" } + } + + @Test + fun `denyNotification returns success when callback error is null`() = runTest { + every { + notificationObject.deny(any(), DenyReason.NONE, any()) + } answers { + val callback = arg(2) + callback.onComplete(null) + } + val push = PushNotification( + notificationObject = notificationObject, + title = "t", + message = "m" + ) + val result = push.denyNotification(context) + assertTrue(result.isSuccess) + + } + @Test + fun `denyNotification returns error when callback error is not null`() = runTest { + every { + notificationObject.deny(any(), DenyReason.NONE, any()) + } answers { + val callback = arg(2) + callback.onComplete(PingOneSDKError(1003, "mock")) + } + val push = PushNotification( + notificationObject = notificationObject, + title = "t", + message = "m" + ) + val result = push.denyNotification(context) + assertTrue(result.isFailure) + // Known SDK failure: message contains the formatted SDK code + assertTrue { result.exceptionOrNull() is PingOneMFAException } + assertTrue { result.exceptionOrNull()!!.message!!.contains("1003") } + } + + @Test + fun `denyNotification returns failure when exception is thrown`() = runTest { + every { + notificationObject.deny(any(), DenyReason.NONE, any()) + } throws RuntimeException("Simulated Network Error") + + val push = PushNotification( + notificationObject = notificationObject, + title = null, + message = null + ) + val result = push.denyNotification(context) + assertTrue(result.isFailure) + assertTrue { result.exceptionOrNull() is Exception } + assertTrue { result.exceptionOrNull()?.message == "Simulated Network Error" } + } + + @Test + fun `getNumbersChallenge returns correct values when notificationObject numberMatchingType is not null`(){ + every { notificationObject.numberMatchingType } returns "mock" + every { notificationObject.numberMatchingOptions } returns intArrayOf(1,2,3) + val push = PushNotification( + notificationObject = notificationObject, + title = "t", + message = "m" + ) + assertTrue(push.getNumbersChallenge()?.contentEquals(intArrayOf(1,2,3)) ?: false) + } + + @Test + fun `getNumbersChallenge returns empty array when notificationObject numberMatchingType is null`(){ + every { notificationObject.numberMatchingType } returns null + val push = PushNotification( + notificationObject = notificationObject, + title = "t", + message = "m" + ) + assertTrue(notificationObject.numberMatchingOptions == null || notificationObject.numberMatchingOptions.isEmpty()) + } + + @Test + fun `getPushType returns DRY when isTest`() { + every { notificationObject.isTest } returns true + + val push = PushNotification( + notificationObject = notificationObject, + title = null, + message = null + ) + + assertEquals(PushType.DRY, push.getPushType()) + } + @Test + fun `getPushType returns CHALLENGE when numberMatchingType present`() { + every { notificationObject.isTest } returns false + every { notificationObject.numberMatchingType } returns "type" + + val push = PushNotification( + notificationObject = notificationObject, + title = null, + message = null + ) + + assertEquals(PushType.CHALLENGE, push.getPushType()) + } + @Test + fun `getPushType returns DEFAULT otherwise`() { + every { notificationObject.isTest } returns false + every { notificationObject.numberMatchingType } returns null + + val push = PushNotification( + notificationObject = notificationObject, + title = null, + message = null + ) + + assertEquals(PushType.DEFAULT, push.getPushType()) + } + + @Test + fun `isCancelAuthentication returns true when notificationObject isCancelAuth is true`() { + every { notificationObject.isCancelAuth } returns true + + val push = PushNotification( + notificationObject = notificationObject, + title = null, + message = null + ) + + assertTrue(push.isCancelAuthentication()) + } + + @Test + fun `isCancelAuthentication returns false when notificationObject isCancelAuth is false`() { + every { notificationObject.isCancelAuth } returns false + + val push = PushNotification( + notificationObject = notificationObject, + title = null, + message = null + ) + + assertTrue(!push.isCancelAuthentication()) + } +} diff --git a/pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/util/AccountParserTest.kt b/pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/util/AccountParserTest.kt new file mode 100644 index 000000000..6172f4eb8 --- /dev/null +++ b/pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/util/AccountParserTest.kt @@ -0,0 +1,185 @@ +package com.pingidentity.pingonemfa.util + +import kotlinx.serialization.json.Json +import org.junit.Assert.assertTrue +import kotlin.test.Test +import kotlin.test.assertEquals + +class AccountParserTest { + + private val parser = AccountParser() + + @Test + fun `single region single user`() { + val json = """ + { + "NA": { + "users": [ + { + "id": "u1", + "environment": { "id": "env1" }, + "device": { "id": "d1" }, + "username": "jdoe", + "name": { "given": "John", "family": "Doe" } + } + ] + } + } + """ + + val result = parser.parseAccounts(json) + + assertEquals(1, result.size) + val account = result.first() + + assertEquals("NA", account.region) + assertEquals("u1", account.id) + assertEquals("env1", account.environment) + assertEquals("d1", account.deviceId) + assertEquals("John", account.name) + assertEquals("Doe", account.family) + } + + @Test + fun `multiple regions multiple users`() { + val json = """ + { + "NA": { + "users": [{ "id": "u1", "username": "jdoe" }] + }, + "EU": { + "users": [{ "id": "u2", "username": "asmith" }] + } + } + """.trimIndent() + + val result = parser.parseAccounts(json) + + assertEquals(2, result.size) + assertTrue(result.any { it.region == "NA" && it.id == "u1" }) + assertTrue(result.any { it.region == "EU" && it.id == "u2" }) + } + + @Test + fun `missing users defaults to empty`() { + val json = """ + { + "NA": {} + } + """.trimIndent() + + val result = parser.parseAccounts(json) + + assertTrue(result.isEmpty()) + } + + @Test + fun `missing nested objects produce empty strings`() { + val json = """ + { + "NA": { + "users": [{ "id": "u1", "username": "jdoe" }] + } + } + """.trimIndent() + + val account = parser.parseAccounts(json).first() + + assertEquals("", account.environment) + assertEquals("", account.deviceId) + assertEquals("", account.name) + assertEquals("", account.family) + } + + @Test + fun `partial name object`() { + val json = """ + { + "NA": { + "users": [ + { "id": "u1", "username": "jdoe", "name": { "given": "Alice" } } + ] + } + } + """.trimIndent() + + val account = parser.parseAccounts(json).first() + + assertEquals("Alice", account.name) + assertEquals("", account.family) + } + + @Test + fun `unknown fields are ignored`() { + val json = """ + { + "NA": { + "users": [ + { + "id": "u1", + "unknown": "value", + "username": "jdoe", "environment": { "id": "env1", "extra": "x" } + } + ] + } + } + """.trimIndent() + + val account = parser.parseAccounts(json).first() + + assertEquals("env1", account.environment) + } + + @Test + fun `large payload`() { + val users = (1..100).joinToString(",") { + """{ "id": "user-$it", "username": "user$it" }""" + } + + val json = """ + { + "NA": { + "users": [$users] + } + } + """.trimIndent() + + val result = parser.parseAccounts(json) + + assertEquals(100, result.size) + assertEquals("user-1", result.first().id) + assertEquals("user-100", result.last().id) + } + + @Test + fun `RegionDto is serializable`() { + val json = Json { ignoreUnknownKeys = true } + + val original = mapOf( + "NA" to RegionDto( + users = listOf( + UserDto( + id = "u1", + environment = IdContainer("env"), + device = IdContainer("dev"), + username = "jdoe", + name = NameDto("John", "Doe") + ) + ) + ) + ) + + val encoded = json.encodeToString(original) + val decoded = json.decodeFromString>(encoded) + + assertEquals(original, decoded) + } + + @Test + fun `throws on invalid json`() { + val result = runCatching { + parser.parseAccounts("""{ "NA": "invalid" }""") + } + assertTrue(result.isFailure) + } +} \ No newline at end of file diff --git a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/BiometricPromptActivity.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/BiometricPromptActivity.kt index b7784f051..c2a3d2a96 100644 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/BiometricPromptActivity.kt +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/BiometricPromptActivity.kt @@ -8,6 +8,7 @@ package com.pingidentity.pingonemfapp.notification import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity @@ -44,15 +45,17 @@ class BiometricPromptActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - - // Get notification ID from intent early - val notificationId = intent?.getStringExtra(NotificationActionReceiver.EXTRA_NOTIFICATION_ID) // Get notification object from intent - val notification = intent?.getParcelableExtra(NotificationActionReceiver.EXTRA_NOTIFICATION, PushNotification::class.java) - // If no notification ID, log and finish - if (notificationId == null) { - diagnosticLogger.w("No notification ID provided") + val notification = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent?.getParcelableExtra(NotificationActionReceiver.EXTRA_NOTIFICATION, PushNotification::class.java) + } else { + @Suppress("DEPRECATION") // Suppress deprecation warning for backward compatibility + intent?.getParcelableExtra(NotificationActionReceiver.EXTRA_NOTIFICATION) + } + // If no notification, log and finish + if (notification == null) { + diagnosticLogger.w("No notification provided") finish() return } @@ -228,7 +231,6 @@ class BiometricPromptActivity : AppCompatActivity() { } result?.isFailure == true -> { diagnosticLogger.e("Error approving with challenge: ${result.exceptionOrNull()?.stackTrace}") - //errorMessage = "Failed to approve: ${result.exceptionOrNull()?.message}" } } } diff --git a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/NotificationActionReceiver.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/NotificationActionReceiver.kt index cc9770abb..e047ca5ea 100644 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/NotificationActionReceiver.kt +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/NotificationActionReceiver.kt @@ -27,7 +27,6 @@ class NotificationActionReceiver : BroadcastReceiver() { const val ACTION_APPROVE = "com.pingidentity.pingonemfapp.ACTION_APPROVE" const val ACTION_DENY = "com.pingidentity.pingonemfapp.ACTION_DENY" const val ACTION_BIOMETRIC = "com.pingidentity.pingonemfapp.ACTION_BIOMETRIC" - const val EXTRA_NOTIFICATION_ID = "notification_id" const val EXTRA_NOTIFICATION = "com.pingidentity.pingonemfapp.notification" } @@ -36,24 +35,25 @@ class NotificationActionReceiver : BroadcastReceiver() { val notification = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { intent.getParcelableExtra(EXTRA_NOTIFICATION, PushNotification::class.java) } else { + @Suppress("DEPRECATION") // Suppress deprecation warning for backward compatibility intent.getParcelableExtra(EXTRA_NOTIFICATION) - } - val notificationId = intent.getStringExtra(EXTRA_NOTIFICATION_ID) ?: return - val notificationHashCode = notificationId.hashCode() + } ?: return + + val notificationHashCode = notification.id.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") + diagnosticLogger.d("Approve action received for notification: ${notification.id}") PingOneMFA.approvePushNotificationFromBanner(notification = notification) } ACTION_DENY -> { - diagnosticLogger.d("Deny action received for notification: $notificationId") + diagnosticLogger.d("Deny action received for notification: ${notification.id}") PingOneMFA.denyPushNotificationFromBanner(notification = notification) } ACTION_BIOMETRIC -> { - diagnosticLogger.d("Biometric action received for notification: $notificationId") + diagnosticLogger.d("Biometric action received for notification: ${notification.id}") handleBiometricAuthentication(context, notification) } } @@ -63,10 +63,9 @@ class NotificationActionReceiver : BroadcastReceiver() { * Handles biometric authentication for the notification with the given ID. * This launches the BiometricPrompt activity. */ - private fun handleBiometricAuthentication(context: Context, notification: PushNotification?) { + private fun handleBiometricAuthentication(context: Context, notification: PushNotification) { val intent = Intent(context, BiometricPromptActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK - putExtra(EXTRA_NOTIFICATION_ID, notification?.id) putExtra(EXTRA_NOTIFICATION, notification) } context.startActivity(intent) diff --git a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/NotificationHelper.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/NotificationHelper.kt index 629b0fab6..25e624036 100644 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/NotificationHelper.kt +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/NotificationHelper.kt @@ -20,7 +20,6 @@ import androidx.core.app.NotificationManagerCompat import com.pingidentity.pingonemfapp.R import com.pingidentity.pingonemfapp.notification.NotificationActionReceiver.Companion.ACTION_APPROVE import com.pingidentity.pingonemfapp.notification.NotificationActionReceiver.Companion.ACTION_DENY -import com.pingidentity.pingonemfapp.notification.NotificationActionReceiver.Companion.EXTRA_NOTIFICATION_ID import com.pingidentity.pingonemfa.push.PushNotification import com.pingidentity.pingonemfa.push.PushType @@ -73,8 +72,6 @@ class NotificationHelper(private val context: Context) { // 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) // Add notification object putExtra(NotificationActionReceiver.EXTRA_NOTIFICATION, notification) } @@ -147,7 +144,6 @@ class NotificationHelper(private val context: Context) { // Approve action val approveIntent = Intent(context, NotificationActionReceiver::class.java).apply { action = ACTION_APPROVE - putExtra(EXTRA_NOTIFICATION_ID, notificationId) putExtra(NotificationActionReceiver.EXTRA_NOTIFICATION, notification) } @@ -161,7 +157,6 @@ class NotificationHelper(private val context: Context) { // Deny action val denyIntent = Intent(context, NotificationActionReceiver::class.java).apply { action = ACTION_DENY - putExtra(EXTRA_NOTIFICATION_ID, notificationId) putExtra(NotificationActionReceiver.EXTRA_NOTIFICATION, notification) } val denyPendingIntent = PendingIntent.getBroadcast( @@ -198,7 +193,6 @@ class NotificationHelper(private val context: Context) { // 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) putExtra(NotificationActionReceiver.EXTRA_NOTIFICATION, notification) } diff --git a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/PushNotificationActivity.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/PushNotificationActivity.kt index 1de2711b1..be9a85460 100644 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/PushNotificationActivity.kt +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/PushNotificationActivity.kt @@ -27,7 +27,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import com.pingidentity.pingonemfapp.data.DiagnosticLogger -import com.pingidentity.pingonemfapp.notification.NotificationActionReceiver.Companion.EXTRA_NOTIFICATION_ID import com.pingidentity.pingonemfapp.ui.NotificationResponseScreen import com.pingidentity.pingonemfapp.ui.theme.PingIdentityAuthenticatorTheme import com.pingidentity.pingonemfa.push.PushNotification @@ -46,17 +45,15 @@ class PushNotificationActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - - // Get notification ID from intent - val notificationId = intent?.getStringExtra(EXTRA_NOTIFICATION_ID) val notification = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { intent?.getParcelableExtra(EXTRA_NOTIFICATION, PushNotification::class.java) } else { + @Suppress("DEPRECATION") // Suppress deprecation warning for backward compatibility intent?.getParcelableExtra(EXTRA_NOTIFICATION) } - // If no notification ID, log and finish - if (notificationId == null) { + // If no notification, log and finish + if (notification == null) { diagnosticLogger.w("No notification ID provided") finish() return @@ -111,31 +108,31 @@ class PushNotificationActivity : ComponentActivity() { onDismiss = { finish() }, onApprove = { coroutineScope.launch { - val result = notification?.approveNotification( + val result = notification.approveNotification( context, "user" ) when { - result?.isSuccess == true -> { + result.isSuccess -> { finish() } - result?.isFailure == true -> { + result.isFailure -> { diagnosticLogger.e("Error approving with challenge: ${result.exceptionOrNull()?.stackTrace}") } } } }, onBiometricApprove = { - launchBiometricPrompt(notificationId, notification) + launchBiometricPrompt(notification) }, onDeny = { coroutineScope.launch { - val result = notification?.denyNotification(context) + val result = notification.denyNotification(context) when { - result?.isSuccess == true -> { + result.isSuccess -> { finish() } - result?.isFailure == true -> { + result.isFailure -> { diagnosticLogger.e("Error approving with challenge: ${result.exceptionOrNull()?.stackTrace}") } } @@ -143,16 +140,16 @@ class PushNotificationActivity : ComponentActivity() { }, onChallengeSolution = { solution -> coroutineScope.launch { - val result = notification?.approveNotification( - context, - "user", - solution.toInt() + val result = notification.approveNotification( + context, + "user", + solution.toInt() ) when { - result?.isSuccess == true -> { + result.isSuccess -> { finish() } - result?.isFailure == true -> { + result.isFailure -> { diagnosticLogger.e("Error approving with challenge: ${result.exceptionOrNull()?.stackTrace}") } } @@ -169,9 +166,8 @@ class PushNotificationActivity : ComponentActivity() { /** * Launches the BiometricPromptActivity for biometric authentication. */ - private fun launchBiometricPrompt(notificationId: String, notification: PushNotification?) { + private fun launchBiometricPrompt(notification: PushNotification?) { val intent = Intent(this, BiometricPromptActivity::class.java).apply { - putExtra(EXTRA_NOTIFICATION_ID, notificationId) putExtra(EXTRA_NOTIFICATION, notification) } startActivity(intent) diff --git a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/service/PushNotificationService.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/service/PushNotificationService.kt index 0b825cf22..0dfee0abf 100644 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/service/PushNotificationService.kt +++ b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/service/PushNotificationService.kt @@ -123,7 +123,6 @@ class PushNotificationService : FirebaseMessagingService() { // Launch the PushNotificationActivity with the notification ID and notification object val intent = Intent(applicationContext, PushNotificationActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP - putExtra(NotificationActionReceiver.EXTRA_NOTIFICATION_ID, notification.id) putExtra(NotificationActionReceiver.EXTRA_NOTIFICATION, notification) } diff --git a/samples/pingsampleapp/README.md b/samples/pingsampleapp/README.md index 8cd1a79c2..6869ff12a 100644 --- a/samples/pingsampleapp/README.md +++ b/samples/pingsampleapp/README.md @@ -34,6 +34,17 @@ The Ping Sample App is a consolidated sample that brings together functionality - Challenge verification - **QR Scanner**: Integrated camera-based QR code scanning +### 🔒 PingOne MFA +- **QR Code Registration**: Scan a QR code to pair the device with PingOne MFA +- **MFA Accounts**: View all paired PingOne MFA accounts +- **One-Time Passcode**: Display the current OTP code with a live countdown +- **Mobile Payload**: Generate a mobile payload for server-side authentication flows +- **Push Notifications**: Full foreground and background push authentication handling + - DEFAULT: approve or deny with a single tap + - CHALLENGE: number-matching with server-provided options or free-form digit entry + - DRY: silent test push with automatic dismissal + - Cancellation: server-revoked requests are dismissed automatically on all surfaces + ### 🛠️ Developer Tools - **Configuration**: Environment selection (Staging/Snapshot) with custom URL support - **Device Information**: Comprehensive device data collection and display @@ -50,6 +61,7 @@ PingSampleApp ├── Authentication Flows (Journey, DaVinci, OIDC) ├── User Management (Profile, Token, Device, Logout) ├── MFA Features (OATH, Push, QR Scanner) +├── PingOne MFA (Pairing, OTP, Push, Payload) ├── Developer Tools (Config, Device Info, Logger) └── Shared Components (Navigation, Theme, Config) ``` @@ -58,10 +70,10 @@ PingSampleApp #### 1. **PingSampleApplication** Application-level initialization and dependency management: -- Initializes SDK clients (OATH, Push) +- Initializes SDK clients (OATH, Push, PingOne MFA) - Creates and manages managers (OathManager, PushManager, JourneyManager) - Provides application-scoped AuthenticatorViewModel -- Handles Firebase Cloud Messaging setup +- Handles Firebase Cloud Messaging setup and token registration for both MFA modules #### 2. **Navigation System** Centralized navigation with proper screen routing: @@ -77,7 +89,14 @@ Complete MFA functionality integrated from AuthenticatorApp: - **Services**: PushNotificationService, LocationService - **Notification Handlers**: BiometricPromptActivity, NotificationActionReceiver -#### 4. **Device Management** +#### 4. **PingOne MFA Integration** +Direct integration of the `pingonemfa` module for PingOne push and OTP: +- **ViewModel**: PingOneMFAViewModel — manages pairing, OTP countdown, payload, and account state +- **UI Screens**: PingOneQrScannerScreen, PingOneMFAAccountsScreen, PingOneOTPScreen, PingOnePayloadScreen, PingOnePushNotificationScreen +- **Notification**: PingOneNotificationHelper, PingOneNotificationActionReceiver, PingOnePushNotificationActivity +- **Store**: PushNotificationStore — single-slot in-process store for the active push notification + +#### 5. **Device Management** Comprehensive device registration and management: - Device registration with custom names - Device list display with platform icons @@ -85,7 +104,7 @@ Comprehensive device registration and management: - Device deletion with confirmation - Automatic list refresh -#### 5. **Token Management** +#### 6. **Token Management** Access token viewing and manipulation: - Pretty-printed JSON display - Token refresh functionality @@ -116,6 +135,13 @@ For Push notifications: 2. Configure Firebase Cloud Messaging in Firebase Console 3. Enable push notifications in device settings +### PingOne MFA Setup + +The PingOne MFA module (`pingonemfa`) requires additional one-time configuration: +1. `PingOneMFA.initialize(Geo.NORTH_AMERICA)` is called automatically at startup in `PingSampleApplication` — update the `Geo` value to match your PingOne environment's region +2. The FCM token is registered with PingOne automatically via `PingOneMFA.setDeviceToken(token)` whenever Firebase delivers a new token +3. See the [pingonemfa README](../pingonemfa/README.md) for the full list of supported regions and API reference +4. See the [PingOne MFA documentation](https://docs.pingidentity.com/pingone/strong_authentication_mfa/p1_strong_authentication_configure_mobile_applications.html) for server-side configuration and integration details ## Implementation Highlights ### ViewModel Initialization @@ -214,6 +240,12 @@ fun logoutAll() { - Push - Push Notifications +**PINGONE MFA** +- QR Code Registration +- MFA Accounts +- One-Time Passcode +- Mobile Payload + **DEVELOPER TOOLS** - Configuration - Device Information @@ -222,7 +254,7 @@ fun logoutAll() { ## Dependencies Key dependencies include: -- Ping Identity SDK modules (Journey, DaVinci, OIDC, MFA) +- Ping Identity SDK modules (Journey, DaVinci, OIDC, MFA, PingOne MFA) - Jetpack Compose for UI - Navigation Component - Firebase Cloud Messaging @@ -268,7 +300,9 @@ com.pingidentity.samples.pingsampleapp │ ├── managers/ # Business logic managers │ ├── ui/ # Authenticator screens │ ├── notification/ # Push notification handlers -│ └── service/ # Background services +│ └── service/ # Background services (PushNotificationService) +├── pingonemfa/ # PingOne MFA integration +│ └── notification/ # Push management ├── config/ # Environment configuration ├── davinci/ # DaVinci flow screens ├── devicemanagement/ # Device registration/management diff --git a/samples/pingsampleapp/build.gradle.kts b/samples/pingsampleapp/build.gradle.kts index 1ed791fc9..a2acd1fd9 100644 --- a/samples/pingsampleapp/build.gradle.kts +++ b/samples/pingsampleapp/build.gradle.kts @@ -101,6 +101,9 @@ dependencies { implementation(project(":mfa:auth-migration")) implementation(project(":foundation:migration")) + // PingOne MFA + implementation(project(":pingonemfa")) + //Application Pin implementation(libs.bcpkix.jdk18on) diff --git a/samples/pingsampleapp/src/main/AndroidManifest.xml b/samples/pingsampleapp/src/main/AndroidManifest.xml index 62df17468..c9a6ca4d6 100644 --- a/samples/pingsampleapp/src/main/AndroidManifest.xml +++ b/samples/pingsampleapp/src/main/AndroidManifest.xml @@ -66,6 +66,14 @@ android:name=".authenticator.notification.NotificationActionReceiver" android:exported="false" /> + + + + + + + + + + + diagnosticLogger.e("PingSampleApplication: PingOne MFA SDK initialization failed: ${e.message}") + } + } + /** * Initializes the AuthenticatorViewModel. */ diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/authenticator/service/PushNotificationService.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/authenticator/service/PushNotificationService.kt index d47fa31ad..76f280f81 100644 --- a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/authenticator/service/PushNotificationService.kt +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/authenticator/service/PushNotificationService.kt @@ -5,17 +5,25 @@ import android.content.Intent import androidx.annotation.RequiresPermission import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage +import com.pingidentity.pingonemfa.commons.PingOneMFA import com.pingidentity.samples.pingsampleapp.PingSampleApplication import com.pingidentity.samples.pingsampleapp.authenticator.data.DiagnosticLogger import com.pingidentity.samples.pingsampleapp.authenticator.notification.NotificationActionReceiver import com.pingidentity.samples.pingsampleapp.authenticator.notification.NotificationHelper import com.pingidentity.samples.pingsampleapp.authenticator.notification.PushNotificationActivity +import com.pingidentity.samples.pingsampleapp.pingonemfa.notification.PingOneNotificationHelper 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.cancel import kotlinx.coroutines.launch +import androidx.core.app.NotificationManagerCompat +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import com.pingidentity.pingonemfa.push.PushNotification as PingOnePushNotification +import com.pingidentity.samples.pingsampleapp.pingonemfa.notification.PushNotificationStore +import com.pingidentity.samples.pingsampleapp.pingonemfa.notification.PingOnePushNotificationActivity /** * Service to handle incoming Firebase Cloud Messaging notifications. @@ -28,6 +36,7 @@ class PushNotificationService : FirebaseMessagingService() { private val diagnosticLogger = DiagnosticLogger private lateinit var notificationHelper: NotificationHelper + private lateinit var pingOneNotificationHelper: PingOneNotificationHelper override fun onCreate() { @@ -37,6 +46,9 @@ class PushNotificationService : FirebaseMessagingService() { notificationHelper = NotificationHelper(this) notificationHelper.createNotificationChannels() + pingOneNotificationHelper = PingOneNotificationHelper(this) + pingOneNotificationHelper.createNotificationChannel() + scope.launch { pushClient = PingSampleApplication.getPushClient() } @@ -44,6 +56,7 @@ class PushNotificationService : FirebaseMessagingService() { override fun onDestroy() { super.onDestroy() + scope.cancel() diagnosticLogger.d("PushNotificationService instance destroyed") } @@ -74,6 +87,8 @@ class PushNotificationService : FirebaseMessagingService() { scope.launch { // Update the device token in the PushClient pushClient?.setDeviceToken(token) + // Update the device token in PingOneMFA + PingOneMFA.setDeviceToken(token) } } @@ -84,6 +99,22 @@ class PushNotificationService : FirebaseMessagingService() { override fun onMessageReceived(remoteMessage: RemoteMessage) { diagnosticLogger.d("Message received from: ${remoteMessage.from}") + // Check if the message received from PingOne MFA Push by looking for expected key + if (remoteMessage.data.containsKey("PingOne")) { + diagnosticLogger.d("Received PingOne MFA Push message") + scope.launch { + // Process the notification using PingOneMFA's processRemoteNotification method, which handles decryption and validation + PingOneMFA.processRemoteNotification(remoteMessage) + .onSuccess { + diagnosticLogger.d("Successfully collected PingOne MFA push notification") + // Handle the notification (this will display a system notification or full-screen notification based on app state) + handlePingOneNotification(it) + } + .onFailure { error -> + diagnosticLogger.e("Failed to collect PingOne MFA push notification: ${error.message}") + } + } + } // Handle the message data payload if (remoteMessage.data.isNotEmpty()) { diagnosticLogger.d("Message data payload: ${remoteMessage.data}") @@ -172,4 +203,75 @@ class PushNotificationService : FirebaseMessagingService() { displaySystemNotification(notification) } } + + /** + * Handle a PingOne notification that's already been processed. + * + * If [PingOnePushNotification.isCancelAuthentication] is true, the server has revoked the + * outstanding request (e.g. because it was handled on another device). In that case: + * - Remove the notification from [PushNotificationStore] so it can no longer be acted on. + * - Cancel any system banner that may still be visible. + * - If the app is in the foreground and the activity is open, broadcast a cancellation + * signal so [PingOnePushNotificationActivity] can dismiss itself immediately. + * + * Otherwise, display the appropriate UI based on app state. + */ + @RequiresPermission(android.Manifest.permission.POST_NOTIFICATIONS) + fun handlePingOneNotification(notification: PingOnePushNotification) { + diagnosticLogger.d("Handling PingOne notification: ${notification.id}") + + if (notification.isCancelAuthentication()) { + diagnosticLogger.d("PingOne notification is a cancellation — dismissing: ${notification.id}") + + val currentNotificationId = PushNotificationStore.remove() + if (currentNotificationId == null){ + // notification was already dismissed, no -op + diagnosticLogger.d("No active PingOne notification in store, nothing to cancel") + return + } + // Dismiss the system banner if it was shown while the app was in the background. + NotificationManagerCompat.from(this).cancel(currentNotificationId.hashCode()) + + + // If the activity is currently open for this notification, tell it to close. + val cancelIntent = Intent(PingOnePushNotificationActivity.ACTION_CANCEL_NOTIFICATION).apply { + putExtra(PingOnePushNotificationActivity.EXTRA_PINGONE_NOTIFICATION_ID, currentNotificationId) + } + LocalBroadcastManager.getInstance(this).sendBroadcast(cancelIntent) + return + } + + if (isAppInForeground()) { + diagnosticLogger.d("App is in foreground, launching notification activity") + showPingOneFullScreenNotification(notification) + } else { + diagnosticLogger.d("App is in background, displaying system notification") + displayPingOneSystemNotification(notification) + } + } + + /** + * Shows a system banner for a PingOne push authentication request. + * Delegates to [PingOneNotificationHelper]. + */ + @RequiresPermission(android.Manifest.permission.POST_NOTIFICATIONS) + private fun displayPingOneSystemNotification(notification: PingOnePushNotification) { + pingOneNotificationHelper.showPushNotification(notification) + } + + /** + * Launches [PingOnePushNotificationActivity] to handle the push while the app is in the foreground. + */ + private fun showPingOneFullScreenNotification(notification: PingOnePushNotification) { + diagnosticLogger.d("Launching PingOnePushNotificationActivity for: ${notification.id}") + + // Store in memory and pass only the ID + PushNotificationStore.put(notification) + + val intent = Intent(this, PingOnePushNotificationActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP + putExtra(PingOnePushNotificationActivity.EXTRA_PINGONE_NOTIFICATION_ID, notification.id) + } + startActivity(intent) + } } \ No newline at end of file diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/home/HomeApp.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/home/HomeApp.kt index e6e361e32..d20b48b97 100644 --- a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/home/HomeApp.kt +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/home/HomeApp.kt @@ -26,6 +26,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Logout +import androidx.compose.material.icons.filled.AccountBox import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.DeviceHub @@ -33,6 +34,7 @@ import androidx.compose.material.icons.filled.Key import androidx.compose.material.icons.filled.LockPerson import androidx.compose.material.icons.filled.LogoDev import androidx.compose.material.icons.filled.Map +import androidx.compose.material.icons.filled.Memory import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.PhoneAndroid import androidx.compose.material.icons.filled.Preview @@ -40,6 +42,7 @@ import androidx.compose.material.icons.filled.QrCodeScanner import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Storage import androidx.compose.material.icons.filled.SwapHoriz +import androidx.compose.material.icons.filled.Tag import androidx.compose.material.icons.filled.Token import androidx.compose.material.icons.filled.VpnKey import androidx.compose.material3.Card @@ -99,6 +102,10 @@ fun HomeApp( onDeviceIdClick : () -> Unit, onAuthTestScreenClick : () -> Unit, onAuthMigrationClick : () -> Unit, + onPingOneAccountsClick : () -> Unit, + onPingOneOTPClick : () -> Unit, + onPingOnePayloadClick : () -> Unit, + onPingOneQrScannerClick : () -> Unit, ) { var deviceId by remember { mutableStateOf("Loading Device ID...") } var deviceStatus by remember { mutableStateOf("Loading device status...") } @@ -299,6 +306,43 @@ fun HomeApp( onClick = onAuthMigrationClick ) + // PingOne MFA Section + Text( + text = stringResource(R.string.text_home_section_pingone_mfa), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(vertical = 16.dp) + ) + + IconRowItem( + icon = Icons.Default.QrCodeScanner, + title = stringResource(R.string.text_pingone_mfa_qr_scanner_title), + subtitle = stringResource(R.string.text_pingone_mfa_qr_scanner_subtitle), + onClick = onPingOneQrScannerClick + ) + + IconRowItem( + icon = Icons.Default.AccountBox, + title = stringResource(R.string.text_pingone_mfa_accounts_title), + subtitle = stringResource(R.string.text_pingone_mfa_accounts_subtitle), + onClick = onPingOneAccountsClick + ) + + IconRowItem( + icon = Icons.Default.Tag, + title = stringResource(R.string.text_pingone_mfa_otp_title), + subtitle = stringResource(R.string.text_pingone_mfa_otp_subtitle), + onClick = onPingOneOTPClick + ) + + IconRowItem( + icon = Icons.Default.Memory, + title = stringResource(R.string.text_pingone_mfa_payload_title), + subtitle = stringResource(R.string.text_pingone_mfa_payload_subtitle), + onClick = onPingOnePayloadClick + ) + // Developer Tools Section Text( text = stringResource(R.string.text_home_section_developer_tools), @@ -482,7 +526,11 @@ fun PreviewHomeApp() { onPushNotificationClick = {}, onDeviceIdClick = {}, onAuthTestScreenClick = {}, - onAuthMigrationClick = {} + onAuthMigrationClick = {}, + onPingOneAccountsClick = {}, + onPingOneOTPClick = {}, + onPingOnePayloadClick = {}, + onPingOneQrScannerClick = {} ) } diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/navigation/Navigation.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/navigation/Navigation.kt index 8f04a4d78..fa2bc9fb3 100644 --- a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/navigation/Navigation.kt +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/navigation/Navigation.kt @@ -59,6 +59,10 @@ import com.pingidentity.samples.pingsampleapp.devicemanagement.DeviceManagement import com.pingidentity.samples.pingsampleapp.devicemanagement.DeviceManagementViewModel import com.pingidentity.samples.pingsampleapp.devtools.DeviceInfo import com.pingidentity.samples.pingsampleapp.home.HomeApp +import com.pingidentity.samples.pingsampleapp.pingonemfa.ui.PingOneMFAAccountsScreen +import com.pingidentity.samples.pingsampleapp.pingonemfa.ui.PingOneOTPScreen +import com.pingidentity.samples.pingsampleapp.pingonemfa.ui.PingOnePayloadScreen +import com.pingidentity.samples.pingsampleapp.pingonemfa.ui.PingOneQrScannerScreen import com.pingidentity.samples.pingsampleapp.journey.JourneyScreen import com.pingidentity.samples.pingsampleapp.journey.JourneyRoute import com.pingidentity.samples.pingsampleapp.journey.JourneyViewModel @@ -101,7 +105,10 @@ object Route { fun routeForAuthAppAccount(accountName: String) = "account/$accountName" const val ROUTE_AUTH_TEST_APP = "route_auth_test_app" const val AUTH_MIGRATION = "auth_migration" - + const val ROUTE_PINGONE_ACCOUNTS = "pingone_accounts" + const val ROUTE_PINGONE_OTP = "pingone_otp" + const val ROUTE_PINGONE_PAYLOAD = "pingone_payload" + const val ROUTE_PINGONE_QR_SCANNER = "pingone_qr_scanner" } /** @@ -178,6 +185,18 @@ fun AppNavigation( onAuthMigrationClick = { navController.navigate(Route.AUTH_MIGRATION) }, + onPingOneAccountsClick = { + navController.navigate(Route.ROUTE_PINGONE_ACCOUNTS) + }, + onPingOneOTPClick = { + navController.navigate(Route.ROUTE_PINGONE_OTP) + }, + onPingOnePayloadClick = { + navController.navigate(Route.ROUTE_PINGONE_PAYLOAD) + }, + onPingOneQrScannerClick = { + navController.navigate(Route.ROUTE_PINGONE_QR_SCANNER) + } ) } @@ -521,6 +540,31 @@ fun AppNavigation( onBack = { navController.popBackStack() } ) } + composable(Route.ROUTE_PINGONE_ACCOUNTS) { + PingOneMFAAccountsScreen( + onBack = { navController.popBackStack() }, + onScanQr = { navController.navigate(Route.ROUTE_PINGONE_QR_SCANNER) } + ) + } + + composable(Route.ROUTE_PINGONE_OTP) { + PingOneOTPScreen( + onBack = { navController.popBackStack() } + ) + } + + composable(Route.ROUTE_PINGONE_PAYLOAD) { + PingOnePayloadScreen( + onBack = { navController.popBackStack() } + ) + } + + composable(Route.ROUTE_PINGONE_QR_SCANNER) { + PingOneQrScannerScreen( + onBack = { navController.popBackStack() }, + onPairComplete = { navController.popBackStack() } + ) + } } } diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/PingOneMFAState.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/PingOneMFAState.kt new file mode 100644 index 000000000..fe87f7f1f --- /dev/null +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/PingOneMFAState.kt @@ -0,0 +1,39 @@ +/* + * 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.samples.pingsampleapp.pingonemfa + +import com.pingidentity.pingonemfa.commons.PingOneMfaAccount +import com.pingidentity.pingonemfa.otp.OtpCodeInfo + +/** + * UI state for all PingOne MFA screens: pairing, accounts, OTP, and mobile payload. + */ +data class PingOneMFAState( + /** True while a pairing operation is in flight. */ + val isLoading: Boolean = false, + /** True while [PingOneMFAViewModel.loadAccounts] is in flight. */ + val isLoadingAccounts: Boolean = false, + /** Paired PingOne MFA accounts. Populated by [PingOneMFAViewModel.loadAccounts]. */ + val accounts: List = emptyList(), + /** True while [PingOneMFAViewModel.collectOtp] is in flight. */ + val isLoadingOtp: Boolean = false, + /** The most recently fetched OTP code info, or null if not yet loaded. */ + val otp: OtpCodeInfo? = null, + /** Live countdown in seconds for the current OTP, driven by [PingOneMFAViewModel]. */ + val otpSecondsRemaining: Int = 0, + /** True when [PingOneMFAViewModel.collectOtp] fails because the device is not paired. */ + val isOtpDeviceNotPaired: Boolean = false, + /** True while [PingOneMFAViewModel.collectPayload] is in flight. */ + val isLoadingPayload: Boolean = false, + /** The most recently fetched mobile payload string, or null if not yet loaded. */ + val payload: String? = null, + /** Non-null when an operation has completed successfully. Cleared by [PingOneMFAViewModel.clearMessage]. */ + val message: String? = null, + /** Non-null when an operation has failed. Cleared by [PingOneMFAViewModel.clearError]. */ + val error: String? = null, +) diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/PingOneMFAViewModel.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/PingOneMFAViewModel.kt new file mode 100644 index 000000000..47f18a92d --- /dev/null +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/PingOneMFAViewModel.kt @@ -0,0 +1,156 @@ +/* + * 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.samples.pingsampleapp.pingonemfa + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.pingidentity.pingonemfa.commons.PingOneMFA +import com.pingidentity.samples.pingsampleapp.authenticator.data.DiagnosticLogger +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * ViewModel for the PingOne MFA screens: pairing, accounts, OTP, and mobile payload. + * + * SDK initialization is handled once at application startup in + * [com.pingidentity.samples.pingsampleapp.PingSampleApplication]; this ViewModel only makes + * user-triggered calls to the already-initialized [com.pingidentity.pingonemfa.commons.PingOneMFA] + * singleton and maps results to [PingOneMFAState] for the UI. + */ +class PingOneMFAViewModel : ViewModel() { + + private val diagnosticLogger = DiagnosticLogger + private val _state = MutableStateFlow(PingOneMFAState()) + val state: StateFlow = _state.asStateFlow() + + /** + * Fetches the list of paired PingOne MFA accounts and updates [PingOneMFAState.accounts]. + * Shows a loading indicator while in flight via [PingOneMFAState.isLoadingAccounts]. + */ + fun loadAccounts() { + _state.update { it.copy(isLoadingAccounts = true, error = null) } + viewModelScope.launch { + PingOneMFA.getDeviceInfo() + .onSuccess { accounts -> + diagnosticLogger.i("Successfully loaded PingOne MFA accounts: ${accounts.size}") + _state.update { it.copy(isLoadingAccounts = false, accounts = accounts) } + } + .onFailure { e -> + diagnosticLogger.e("Failed to load accounts", e) + _state.update { it.copy(isLoadingAccounts = false, error = e.message ?: "Failed to load accounts") } + } + } + } + + /** + * Pairs the device using [pairingKey] obtained from a QR code scan or manual entry. + */ + fun pair(pairingKey: String) { + _state.update { it.copy(isLoading = true, error = null, message = null) } + viewModelScope.launch { + PingOneMFA.pair(pairingKey) + .onSuccess { + _state.update { + it.copy(isLoading = false, message = "Device paired successfully") + } + } + .onFailure { e -> + _state.update { + it.copy( + isLoading = false, + error = e.message ?: "Pairing failed", + ) + } + } + } + } + + private var otpCountdownJob: Job? = null + + /** + * Fetches the current one-time passcode from PingOne, updates [PingOneMFAState.otp], + * and starts a countdown that decrements [PingOneMFAState.otpSecondsRemaining] every + * second, automatically re-fetching when it reaches zero. + */ + fun collectOtp() { + otpCountdownJob?.cancel() + _state.update { it.copy(isLoadingOtp = true, otp = null, otpSecondsRemaining = 0, error = null, isOtpDeviceNotPaired = false) } + viewModelScope.launch { + PingOneMFA.getOneTimePasscode() + .onSuccess { otpInfo -> + diagnosticLogger.i("Successfully collected OTP") + _state.update { it.copy(isLoadingOtp = false, otp = otpInfo, otpSecondsRemaining = otpInfo.secondsRemaining) } + if (!state.value.isOtpDeviceNotPaired) startOtpCountdown() + } + .onFailure { e -> + diagnosticLogger.e("Failed to collect OTP", e) + if (e.message?.contains("Code=10008", ignoreCase = true) == true) { + _state.update { it.copy(isLoadingOtp = false, isOtpDeviceNotPaired = true) } + } else { + _state.update { it.copy(isLoadingOtp = false, error = e.message ?: "Failed to collect OTP") } + } + } + } + } + + private fun startOtpCountdown() { + otpCountdownJob?.cancel() + otpCountdownJob = viewModelScope.launch { + while (_state.value.otpSecondsRemaining > 0) { + delay(1_000) + _state.update { it.copy(otpSecondsRemaining = it.otpSecondsRemaining - 1) } + } + collectOtp() + } + } + + /** + * Fetches the mobile payload from PingOne and updates [PingOneMFAState.payload]. + * Shows a loading indicator while in flight via [PingOneMFAState.isLoadingPayload]. + */ + fun collectPayload() { + _state.update { it.copy(isLoadingPayload = true, error = null) } + viewModelScope.launch { + PingOneMFA.generateMobilePayload() + .onSuccess { payload -> + diagnosticLogger.i("Successfully collected mobile payload") + _state.update { it.copy(isLoadingPayload = false, payload = payload) } + } + .onFailure { e -> + diagnosticLogger.e("Failed to collect mobile payload", e) + _state.update { it.copy(isLoadingPayload = false, error = e.message ?: "Failed to collect mobile payload") } + } + } + } + + /** + * Posts an error message directly into state without triggering any SDK call. + * + * Used by the UI layer to surface infrastructure failures (e.g. camera bind errors) + * through the same Snackbar path as SDK errors, rather than routing them through a + * no-op SDK call just to produce an error response. + */ + fun reportError(message: String) { + _state.update { it.copy(error = message) } + } + + /** Clears any transient error from the state. */ + fun clearError() { + _state.update { it.copy(error = null) } + } + + /** Clears any transient success message from the state. */ + fun clearMessage() { + _state.update { it.copy(message = null) } + } +} diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/notification/PingOneNotificationActionReceiver.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/notification/PingOneNotificationActionReceiver.kt new file mode 100644 index 000000000..08fbaf716 --- /dev/null +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/notification/PingOneNotificationActionReceiver.kt @@ -0,0 +1,62 @@ +/* + * 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.samples.pingsampleapp.pingonemfa.notification + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationManagerCompat +import com.pingidentity.pingonemfa.commons.PingOneMFA +import com.pingidentity.pingonemfa.push.PushNotification +import com.pingidentity.samples.pingsampleapp.pingonemfa.notification.PushNotificationStore +import com.pingidentity.samples.pingsampleapp.authenticator.data.DiagnosticLogger + +/** + * BroadcastReceiver to handle action button taps on PingOne MFA system notifications. + * + * Receives [ACTION_APPROVE] and [ACTION_DENY] intents fired from the notification banner, + * then delegates to [PingOneMFA.approvePushNotificationFromBanner] / + * [PingOneMFA.denyPushNotificationFromBanner], which route through + * [com.pingidentity.pingonemfa.push.PushApprovalService] so the network call is allowed + * even when the app is in the background. + * + * The [PushNotification] is retrieved from [PushNotificationStore] by ID — it is never parceled + * through the Intent because the native SDK's Parcelable implementation crashes when internal + * nullable fields are null. + */ +class PingOneNotificationActionReceiver : BroadcastReceiver() { + + private val diagnosticLogger = DiagnosticLogger + + override fun onReceive(context: Context, intent: Intent) { + val notificationId = intent.getStringExtra(EXTRA_PINGONE_NOTIFICATION_ID) ?: return + val notification = PushNotificationStore.get(notificationId) ?: return + + // Dismiss the banner immediately so the user gets visual feedback + NotificationManagerCompat.from(context).cancel(notification.id.hashCode()) + + when (intent.action) { + ACTION_APPROVE -> { + diagnosticLogger.d("PingOne approve tapped for notification: ${notification.id}") + PushNotificationStore.remove() + PingOneMFA.approvePushNotificationFromBanner(notification) + } + ACTION_DENY -> { + diagnosticLogger.d("PingOne deny tapped for notification: ${notification.id}") + PushNotificationStore.remove() + PingOneMFA.denyPushNotificationFromBanner(notification) + } + } + } + + companion object { + const val ACTION_APPROVE = "com.pingidentity.pingsampleapp.PINGONE_ACTION_APPROVE" + const val ACTION_DENY = "com.pingidentity.pingsampleapp.PINGONE_ACTION_DENY" + const val EXTRA_PINGONE_NOTIFICATION_ID = "pingone_notification_id" + } +} diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/notification/PingOneNotificationHelper.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/notification/PingOneNotificationHelper.kt new file mode 100644 index 000000000..b18ff7105 --- /dev/null +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/notification/PingOneNotificationHelper.kt @@ -0,0 +1,147 @@ +/* + * 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.samples.pingsampleapp.pingonemfa.notification + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.annotation.RequiresPermission +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.pingidentity.pingonemfa.push.PushNotification +import com.pingidentity.pingonemfa.push.PushType +import com.pingidentity.samples.pingsampleapp.R + +/** + * Helper class for building and posting system banners for PingOne MFA push notifications. + * + * For [com.pingidentity.pingonemfa.push.PushType.DEFAULT], the banner includes Allow and Deny action buttons so the user + * can respond without opening the app. Action taps are routed to [PingOneNotificationActionReceiver]. + * + * For all other types (CHALLENGE, DRY) no action buttons are added — tapping + * the banner opens the app via its launcher intent instead. + * + * [PushNotification] is stored in [PushNotificationStore] and only its ID is carried through Intents. + */ +private const val CHANNEL_ID = "com.pingidentity.pingsampleapp.PINGONE_PUSH" + +class PingOneNotificationHelper(private val context: Context) { + + /** + * Creates the dedicated PingOne MFA notification channel. + * Safe to call multiple times — a no-op if the channel already exists. + */ + fun createNotificationChannel() { + val manager = context.getSystemService(NotificationManager::class.java) + if (manager.getNotificationChannel(CHANNEL_ID) != null) return + + val channel = NotificationChannel( + CHANNEL_ID, + "PingOne MFA", + NotificationManager.IMPORTANCE_HIGH + ).apply { + enableVibration(true) + } + manager.createNotificationChannel(channel) + } + + /** + * Posts a system banner for [notification]. + * + * - Uses [PushNotification.title] and [PushNotification.message] as the banner text, + * falling back to generic string resources when either is null. + * - Adds Allow / Deny action buttons only for [PushType.DEFAULT]. + * - The content intent always opens the app launcher so tapping the banner body works + * regardless of push type. + */ + @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS) + fun showPushNotification(notification: PushNotification) { + val notificationId = notification.id.hashCode() + + // Store so banner action receivers and the full-screen activity can retrieve it by ID. + PushNotificationStore.put(notification) + + val title = notification.title ?: context.getString(R.string.system_notification_title) + val message = notification.message ?: context.getString(R.string.system_notification_content) + + val openAppPendingIntent = buildOpenAppPendingIntent(notification.id, notificationId) + + val builder = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(title) + .setContentText(message) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_CALL) + .setAutoCancel(true) + .setContentIntent(openAppPendingIntent) + + if (notification.getPushType() == PushType.DEFAULT) { + builder + .addAction(buildDenyAction(notification.id, notificationId)) + .addAction(buildApproveAction(notification.id, notificationId)) + } + + with(NotificationManagerCompat.from(context)) { + if (areNotificationsEnabled()) { + notify(notificationId, builder.build()) + } + } + } + + private fun buildOpenAppPendingIntent(notificationId: String, requestCode: Int): PendingIntent { + val intent = Intent(context, PingOnePushNotificationActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP + putExtra(PingOnePushNotificationActivity.EXTRA_PINGONE_NOTIFICATION_ID, notificationId) + } + return PendingIntent.getActivity( + context, requestCode, intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + + private fun buildDenyAction( + notificationId: String, + requestCode: Int + ): NotificationCompat.Action { + val intent = Intent(context, PingOneNotificationActionReceiver::class.java).apply { + action = PingOneNotificationActionReceiver.ACTION_DENY + putExtra(PingOneNotificationActionReceiver.EXTRA_PINGONE_NOTIFICATION_ID, notificationId) + } + val pendingIntent = PendingIntent.getBroadcast( + context, requestCode, intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + return NotificationCompat.Action( + R.drawable.ic_close, + context.getString(R.string.system_notification_deny), + pendingIntent + ) + } + + private fun buildApproveAction( + notificationId: String, + requestCode: Int + ): NotificationCompat.Action { + val intent = Intent(context, PingOneNotificationActionReceiver::class.java).apply { + action = PingOneNotificationActionReceiver.ACTION_APPROVE + putExtra(PingOneNotificationActionReceiver.EXTRA_PINGONE_NOTIFICATION_ID, notificationId) + } + val pendingIntent = PendingIntent.getBroadcast( + context, requestCode + 1, intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + return NotificationCompat.Action( + R.drawable.ic_check, + context.getString(R.string.system_notification_approve), + pendingIntent + ) + } +} diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/notification/PingOnePushNotificationActivity.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/notification/PingOnePushNotificationActivity.kt new file mode 100644 index 000000000..89cdf1059 --- /dev/null +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/notification/PingOnePushNotificationActivity.kt @@ -0,0 +1,113 @@ +/* + * 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.samples.pingsampleapp.pingonemfa.notification + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import com.pingidentity.pingonemfa.push.PushNotification +import com.pingidentity.samples.pingsampleapp.pingonemfa.ui.PingOnePushNotificationScreen +import com.pingidentity.samples.pingsampleapp.theme.AppTheme + +/** + * Activity displayed when a PingOne MFA push notification arrives while the app is in the foreground. + * + * Retrieves the [PushNotification] from [PushNotificationStore] using the ID passed as a String + * extra — the notification object itself is never parceled through the Intent because the native + * SDK's Parcelable implementation crashes when internal nullable fields are null. + * + * ## Cancellation + * If the server cancels the authentication request while this activity is open (e.g. because the + * user approved on another device), [PushNotificationService] broadcasts [ACTION_CANCEL_NOTIFICATION] + * via [LocalBroadcastManager]. This activity registers a receiver in [onStart] / [onStop] that + * matches the notification ID and calls [finish] immediately, so the user is never left on a stale + * prompt. + */ +class PingOnePushNotificationActivity : ComponentActivity() { + + private var notificationId: String? = null + + /** + * Receives [ACTION_CANCEL_NOTIFICATION] and closes the activity if the cancellation is for + * the notification this activity is currently showing. + */ + private val cancellationReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val cancelledId = intent.getStringExtra(EXTRA_PINGONE_NOTIFICATION_ID) ?: return + if (cancelledId == notificationId) { + finish() + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val notification = getNotification() + if (notification == null) { + finish() + return + } + + notificationId = notification.id + + setContent { + AppTheme { + Surface(modifier = Modifier.fillMaxSize()) { + PingOnePushNotificationScreen( + notification = notification, + onFinish = { + PushNotificationStore.remove() + finish() + } + ) + } + } + } + } + + override fun onStart() { + super.onStart() + // Register while visible so we receive cancellations the moment they arrive. + LocalBroadcastManager.getInstance(this).registerReceiver( + cancellationReceiver, + IntentFilter(ACTION_CANCEL_NOTIFICATION), + ) + } + + override fun onStop() { + super.onStop() + LocalBroadcastManager.getInstance(this).unregisterReceiver(cancellationReceiver) + } + + private fun getNotification(): PushNotification? { + val id = intent?.getStringExtra(EXTRA_PINGONE_NOTIFICATION_ID) ?: return null + return PushNotificationStore.get(id) + } + + companion object { + const val EXTRA_PINGONE_NOTIFICATION_ID = "pingone_notification_id" + + /** + * Broadcast action sent by [com.pingidentity.samples.pingsampleapp.authenticator.service.PushNotificationService] + * when the server cancels an outstanding authentication request. The Intent carries + * [EXTRA_PINGONE_NOTIFICATION_ID] so the activity can verify the cancellation is for the + * notification it is currently showing. + */ + const val ACTION_CANCEL_NOTIFICATION = + "com.pingidentity.pingsampleapp.PINGONE_CANCEL_NOTIFICATION" + } +} diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/notification/PushNotificationStore.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/notification/PushNotificationStore.kt new file mode 100644 index 000000000..0b54f9f3e --- /dev/null +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/notification/PushNotificationStore.kt @@ -0,0 +1,47 @@ +package com.pingidentity.samples.pingsampleapp.pingonemfa.notification + +import com.pingidentity.pingonemfa.push.PushNotification +import java.util.concurrent.atomic.AtomicReference + +/** + * In-process store for the single active [PushNotification]. + * + * Only one PingOne MFA push authentication request can be outstanding at a time — the server + * does not queue concurrent requests for the same device. This store therefore holds at most + * one notification. Calling [put] while a notification is already present replaces it; the + * previous notification is discarded. + * + * ## Usage + * 1. Call [put] to store the notification before firing any Intent. + * 2. Pass only [PushNotification.id] as a plain String extra — never parcel the object itself. + * 3. Call [get] on the receiving end to retrieve the notification. + * 4. Call [remove] once the notification has been acted on to free the reference. + */ +object PushNotificationStore { + + private val current = AtomicReference(null) + + /** + * Stores [notification] as the current active push. + * Any previously stored notification is replaced. + */ + fun put(notification: PushNotification) { + current.set(notification) + } + + /** + * Returns the current notification if its [PushNotification.id] matches [id], + * or null if no notification is stored or the ID does not match. + */ + fun get(id: String): PushNotification? = + current.get()?.takeIf { it.id == id } + + /** + * Atomically clears the store and returns the removed notification's ID, + * or null if the store was already empty. Should be called after the notification + * has been approved, denied, canceled, or dismissed. + */ + fun remove(): String? { + return current.getAndSet(null)?.id + } +} diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOneMFAAccountsScreen.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOneMFAAccountsScreen.kt new file mode 100644 index 000000000..e78ccea3b --- /dev/null +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOneMFAAccountsScreen.kt @@ -0,0 +1,185 @@ +/* + * 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.samples.pingsampleapp.pingonemfa.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.AccountBox +import androidx.compose.material.icons.filled.QrCodeScanner +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.pingidentity.samples.pingsampleapp.R +import com.pingidentity.samples.pingsampleapp.authenticator.ui.components.EmptyStateMessage +import com.pingidentity.samples.pingsampleapp.authenticator.ui.components.LoadingIndicator +import com.pingidentity.samples.pingsampleapp.pingonemfa.PingOneMFAViewModel + +/** + * Displays the list of paired PingOne MFA accounts. + * + * Fetches accounts on first composition via [PingOneMFAViewModel.loadAccounts]. + * Each account shows its name, region, and ID. Errors are surfaced via an AlertDialog. + * + * @param onBack Navigation callback for the top-app-bar back button. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PingOneMFAAccountsScreen( + onBack: (() -> Unit)? = null, + onScanQr: () -> Unit = {}, + viewModel: PingOneMFAViewModel = viewModel(), +) { + val state by viewModel.state.collectAsState() + + // Fetch accounts as soon as the screen is first composed + LaunchedEffect(Unit) { + viewModel.loadAccounts() + } + + // Show error dialog. Retry re-fetches the accounts; OK just dismisses. + state.error?.let { errorMessage -> + AlertDialog( + onDismissRequest = { viewModel.clearError() }, + title = { Text(stringResource(R.string.error_title)) }, + text = { Text(errorMessage) }, + confirmButton = { + Button(onClick = { + viewModel.clearError() + viewModel.loadAccounts() + }) { + Text(stringResource(R.string.retry)) + } + }, + dismissButton = { + OutlinedButton(onClick = { viewModel.clearError() }) { + Text(stringResource(R.string.ok)) + } + } + ) + } + + Scaffold( + topBar = { + if (onBack != null) { + TopAppBar( + title = { Text(stringResource(R.string.text_pingone_mfa_screen_accounts_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + ) + } + } + ) + } + }, + floatingActionButton = { + FloatingActionButton(onClick = onScanQr) { + Icon( + imageVector = Icons.Filled.QrCodeScanner, + contentDescription = stringResource(R.string.text_pingone_mfa_qr_scanner_title), + ) + } + }, + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding() + .padding(paddingValues), + ) { + when { + state.isLoadingAccounts -> { + // Reuse the app-wide loading indicator style + LoadingIndicator( + message = stringResource(R.string.text_pingone_mfa_loading_accounts), + modifier = Modifier.fillMaxSize(), + ) + } + state.accounts.isEmpty() -> { + EmptyStateMessage( + title = stringResource(R.string.accounts_empty_state_title), + subtitle = stringResource(R.string.accounts_empty_state_subtitle), + ) + } + else -> { + LazyColumn(modifier = Modifier.fillMaxWidth()) { + items(state.accounts) { account -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Filled.AccountBox, + contentDescription = null, + modifier = Modifier.size(40.dp), + tint = colorResource(R.color.primary_dark), + ) + Column( + modifier = Modifier.padding(start = 16.dp), + ) { + val displayName = listOfNotNull( + account.name?.takeIf { it.isNotBlank() }, + account.family?.takeIf { it.isNotBlank() } + ).joinToString(" ").ifBlank { account.username } + Text( + text = displayName, + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = "Region: ${account.region}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "ID: ${account.id}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + HorizontalDivider() + } + } + } + } + } + } +} diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOneOTPScreen.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOneOTPScreen.kt new file mode 100644 index 000000000..3fcd519a9 --- /dev/null +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOneOTPScreen.kt @@ -0,0 +1,159 @@ +/* + * 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.samples.pingsampleapp.pingonemfa.ui + +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.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +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.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.remember +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 androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.pingidentity.samples.pingsampleapp.R +import com.pingidentity.samples.pingsampleapp.authenticator.ui.components.EmptyStateMessage +import com.pingidentity.samples.pingsampleapp.authenticator.ui.components.LoadingIndicator +import com.pingidentity.samples.pingsampleapp.pingonemfa.PingOneMFAViewModel + +/** + * Screen that fetches and displays the current PingOne MFA one-time passcode. + * + * Calls [PingOneMFAViewModel.collectOtp] on first composition and whenever the user + * taps "Refresh". Errors are shown via a Snackbar. + * + * @param onBack Navigation callback for the top-app-bar back button. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PingOneOTPScreen( + onBack: (() -> Unit)? = null, + viewModel: PingOneMFAViewModel = viewModel(), +) { + val state by viewModel.state.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + + // Fetch OTP as soon as the screen is first composed + LaunchedEffect(Unit) { + viewModel.collectOtp() + } + + LaunchedEffect(state.error) { + state.error?.let { + snackbarHostState.showSnackbar(it) + viewModel.clearError() + } + } + + Scaffold( + topBar = { + if (onBack != null) { + TopAppBar( + title = { Text(stringResource(R.string.text_pingone_mfa_screen_otp_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + ) + } + }, + ) + } + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding() + .padding(paddingValues), + contentAlignment = Alignment.Center, + ) { + when { + state.isLoadingOtp -> { + LoadingIndicator( + message = stringResource(R.string.text_pingone_mfa_loading_otp), + modifier = Modifier.fillMaxSize(), + ) + } + + state.isOtpDeviceNotPaired -> { + EmptyStateMessage( + title = stringResource(R.string.accounts_empty_state_title), + subtitle = stringResource(R.string.accounts_empty_state_subtitle), + ) + } + + state.otp != null -> { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = state.otp!!.code, + fontSize = 48.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 8.sp, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Refreshes in ${state.otpSecondsRemaining}s", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(24.dp)) + } + } + + else -> { + // No OTP yet and not loading — likely initial state before LaunchedEffect fires, + // or a failure was shown via Snackbar. Offer a manual retry. + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(R.string.text_pingone_mfa_otp_no_passcode), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { viewModel.collectOtp() }) { + Text(stringResource(R.string.text_pingone_mfa_try_again)) + } + } + } + } + } + } +} diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOnePayloadScreen.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOnePayloadScreen.kt new file mode 100644 index 000000000..2e237f904 --- /dev/null +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOnePayloadScreen.kt @@ -0,0 +1,180 @@ +/* + * 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.samples.pingsampleapp.pingonemfa.ui + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +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.ArrowBack +import androidx.compose.material.icons.filled.ContentCopy +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.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.remember +import androidx.compose.runtime.rememberCoroutineScope +import kotlinx.coroutines.launch +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.pingidentity.samples.pingsampleapp.R +import android.content.ClipData +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.pingidentity.samples.pingsampleapp.authenticator.ui.components.LoadingIndicator +import com.pingidentity.samples.pingsampleapp.pingonemfa.PingOneMFAViewModel + +/** + * Screen that fetches and displays the PingOne mobile payload. + * + * Calls [PingOneMFAViewModel.collectPayload] on first composition. The payload is shown + * in a scrollable box with a Copy button pinned at the bottom. Errors are shown via a + * Snackbar. + * + * @param onBack Navigation callback for the top-app-bar back button. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PingOnePayloadScreen( + onBack: (() -> Unit)? = null, + viewModel: PingOneMFAViewModel = viewModel(), +) { + val state by viewModel.state.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + val clipboardManager = LocalClipboard.current + val scope = rememberCoroutineScope() + + // Fetch payload as soon as the screen is first composed + LaunchedEffect(Unit) { + viewModel.collectPayload() + } + + LaunchedEffect(state.error) { + state.error?.let { + snackbarHostState.showSnackbar(it) + viewModel.clearError() + } + } + + Scaffold( + topBar = { + if (onBack != null) { + TopAppBar( + title = { Text(stringResource(R.string.text_pingone_mfa_screen_payload_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + ) + } + }, + ) + } + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding() + .padding(paddingValues), + ) { + when { + state.isLoadingPayload -> { + LoadingIndicator( + message = stringResource(R.string.text_pingone_mfa_loading_payload), + modifier = Modifier.fillMaxSize(), + ) + } + + state.payload != null -> { + Column( + modifier = Modifier.fillMaxSize(), + ) { + // Scrollable payload box occupies all available space above the button + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(16.dp) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant, + shape = MaterialTheme.shapes.medium, + ) + .padding(12.dp) + .verticalScroll(rememberScrollState()), + ) { + Text( + text = state.payload!!, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + Button( + onClick = { + scope.launch { + clipboardManager.setClipEntry( + ClipEntry(ClipData.newPlainText("payload", state.payload)) + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = null, + ) + Text( + text = stringResource(R.string.text_pingone_mfa_copy), + modifier = Modifier.padding(start = 8.dp), + ) + } + } + } + + else -> { + // No payload yet and not loading — offer a manual retry after an error + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Button(onClick = { viewModel.collectPayload() }) { + Text(stringResource(R.string.text_pingone_mfa_try_again)) + } + } + } + } + } + } +} diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOnePushNotificationScreen.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOnePushNotificationScreen.kt new file mode 100644 index 000000000..7be5d9ad6 --- /dev/null +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOnePushNotificationScreen.kt @@ -0,0 +1,206 @@ +/* + * 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.samples.pingsampleapp.pingonemfa.ui + +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.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.CircularProgressIndicator +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.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.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.pingidentity.pingonemfa.push.PushNotification +import com.pingidentity.pingonemfa.push.PushType +import com.pingidentity.samples.pingsampleapp.R +import com.pingidentity.samples.pingsampleapp.pingonemfa.ui.components.ApproveDenyRow +import com.pingidentity.samples.pingsampleapp.pingonemfa.ui.components.ManualNumberChallenge +import com.pingidentity.samples.pingsampleapp.pingonemfa.ui.components.NumberChallengeOptions +import kotlinx.coroutines.launch + +/** Models the three dialog states the push notification screen can be in. */ +private sealed interface DialogState { + data object None : DialogState + data class Success(val title: String, val message: String) : DialogState + data class Error(val message: String) : DialogState +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PingOnePushNotificationScreen( + notification: PushNotification, + onFinish: () -> Unit +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var isLoading by remember { mutableStateOf(false) } + var dialogState by remember { mutableStateOf(DialogState.None) } + + val approvedTitle = stringResource(R.string.text_pingone_mfa_approved_title) + val approvedMessage = stringResource(R.string.text_pingone_mfa_approved_message) + val deniedTitle = stringResource(R.string.text_pingone_mfa_denied_title) + val deniedMessage = stringResource(R.string.text_pingone_mfa_denied_message) + val approvalFailedMessage = stringResource(R.string.text_pingone_mfa_approval_failed) + val denyFailedMessage = stringResource(R.string.text_pingone_mfa_deny_failed) + + fun approve(numberChallenge: Int? = null) { + isLoading = true + scope.launch { + notification.approveNotification( + context = context, + authenticationMethod = "app", + numberChallenge = numberChallenge + ).onSuccess { + isLoading = false + dialogState = DialogState.Success(approvedTitle, approvedMessage) + }.onFailure { e -> + isLoading = false + dialogState = DialogState.Error(e.message ?: approvalFailedMessage) + } + } + } + + fun deny() { + isLoading = true + scope.launch { + notification.denyNotification(context) + .onSuccess { + isLoading = false + dialogState = DialogState.Success(deniedTitle, deniedMessage) + }.onFailure { e -> + isLoading = false + dialogState = DialogState.Error(e.message ?: denyFailedMessage) + } + } + } + + // Show dialog on top of the screen + when (val state = dialogState) { + is DialogState.Success -> AlertDialog( + onDismissRequest = onFinish, + title = { Text(state.title) }, + text = { Text(state.message) }, + confirmButton = { + Button(onClick = onFinish) { + Text(stringResource(R.string.ok)) + } + } + ) + is DialogState.Error -> AlertDialog( + onDismissRequest = onFinish, + title = { Text(stringResource(R.string.error_title)) }, + text = { Text(state.message) }, + confirmButton = { + Button(onClick = onFinish) { + Text(stringResource(R.string.ok)) + } + } + ) + DialogState.None -> Unit + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.text_pingone_mfa_screen_push_title)) }, + navigationIcon = { + IconButton(onClick = onFinish) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + ) + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = notification.title ?: stringResource(R.string.system_notification_title), + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = notification.message ?: stringResource(R.string.system_notification_content), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(32.dp)) + + if (isLoading) { + CircularProgressIndicator() + } else { + when (notification.getPushType()) { + PushType.DEFAULT -> { + ApproveDenyRow( + onApprove = { approve() }, + onDeny = { deny() } + ) + } + PushType.CHALLENGE -> { + val options = notification.getNumbersChallenge() + if (options != null) { + NumberChallengeOptions( + options = options, + onSelected = { approve(it) }, + onDeny = { deny() } + ) + } else { + ManualNumberChallenge( + onConfirm = { number -> approve(number) }, + onDeny = { deny() } + ) + } + } + PushType.DRY -> { + Text( + text = stringResource(R.string.text_pingone_mfa_dry_push_message), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = onFinish) { Text(stringResource(R.string.text_pingone_mfa_dismiss)) } + } + } + } + } + } +} diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOneQrScannerScreen.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOneQrScannerScreen.kt new file mode 100644 index 000000000..ae5aea45e --- /dev/null +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOneQrScannerScreen.kt @@ -0,0 +1,375 @@ +/* + * 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.samples.pingsampleapp.pingonemfa.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.Canvas +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.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +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.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.material3.AlertDialog +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.pingidentity.samples.pingsampleapp.R +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.viewmodel.compose.viewModel +import com.pingidentity.samples.pingsampleapp.authenticator.ui.components.BackNavigationTopAppBar +import com.pingidentity.samples.pingsampleapp.authenticator.ui.components.LoadingIndicator +import com.pingidentity.samples.pingsampleapp.pingonemfa.util.PingOneMFAQrCodeAnalyzer +import com.pingidentity.samples.pingsampleapp.pingonemfa.util.matchesPingOnePairingKeyScheme +import com.pingidentity.samples.pingsampleapp.pingonemfa.PingOneMFAViewModel +import java.util.concurrent.Executors + +/** + * QR scanner screen for PingOne MFA device pairing. + * + * Shows a camera preview. Scanned or manually entered pairing keys are forwarded to + * [PingOneMFAViewModel.pair]. Errors are shown via a Snackbar. On successful pairing an + * AlertDialog confirms the result; tapping OK calls [onPairComplete] to close the screen. + * + * @param onBack Navigation callback for the top-app-bar back button. + * @param onPairComplete Callback invoked after a successful pairing operation. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PingOneQrScannerScreen( + onBack: () -> Unit, + onPairComplete: () -> Unit, + viewModel: PingOneMFAViewModel = viewModel(), +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val keyboardController = LocalSoftwareKeyboardController.current + + var manualKey by remember { mutableStateOf("") } + var scanEnabled by remember { mutableStateOf(true) } + + var hasCameraPermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED + ) + } + + val requestPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + onResult = { hasCameraPermission = it } + ) + + val cameraExecutor = remember { Executors.newSingleThreadExecutor() } + val qrAnalyzer = remember { PingOneMFAQrCodeAnalyzer { pairingKey -> + if (!scanEnabled) return@PingOneMFAQrCodeAnalyzer + scanEnabled = false + viewModel.pair(pairingKey) + } } + + LaunchedEffect(Unit) { + if (!hasCameraPermission) requestPermissionLauncher.launch(Manifest.permission.CAMERA) + } + + val state by viewModel.state.collectAsState() + + // Show error dialog; re-enable scanning when the user dismisses so they can retry. + state.error?.let { errorMessage -> + AlertDialog( + onDismissRequest = { + scanEnabled = true + viewModel.clearError() + }, + title = { Text(stringResource(R.string.error_title)) }, + text = { Text(errorMessage) }, + confirmButton = { + Button(onClick = { + scanEnabled = true + viewModel.clearError() + }) { + Text(stringResource(R.string.ok)) + } + } + ) + } + + // Show success dialog on successful pairing; navigate away when user taps OK. + state.message?.let { message -> + AlertDialog( + onDismissRequest = { + viewModel.clearMessage() + onPairComplete() + }, + text = { Text(message) }, + confirmButton = { + Button(onClick = { + viewModel.clearMessage() + onPairComplete() + }) { + Text(stringResource(R.string.ok)) + } + } + ) + } + + Scaffold( + topBar = { + BackNavigationTopAppBar( + title = stringResource(R.string.text_pingone_mfa_screen_scanner_title), + onBackClick = onBack, + ) + }, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .navigationBarsPadding(), + ) { + // Camera area — fills all space above the manual-entry panel + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .background(Color(0xFF616161)), + contentAlignment = Alignment.Center, + ) { + if (hasCameraPermission && !state.isLoading) { + // Full-size camera preview behind the overlay + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { ctx -> + val previewView = PreviewView(ctx).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() + + val imageAnalysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + + imageAnalysis.setAnalyzer(cameraExecutor, qrAnalyzer) + + ProcessCameraProvider.getInstance(ctx).also { future -> + future.addListener({ + try { + val cameraProvider = future.get() + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle( + lifecycleOwner, selector, preview, imageAnalysis + ) + } catch (e: Exception) { + // Surface the failure through the error AlertDialog path + viewModel.reportError("Camera unavailable: ${e.message}") + } + }, ContextCompat.getMainExecutor(ctx)) + } + + previewView + }, + ) + } + + if (state.isLoading) { + // Pairing in flight — show spinner over the solid grey background + LoadingIndicator( + message = stringResource(R.string.text_pingone_mfa_pairing), + modifier = Modifier.fillMaxSize(), + ) + } else { + // Overlay: label + corner-bracket scanning window + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 40.dp), + ) { + Text( + text = stringResource(R.string.text_pingone_mfa_scan_qr), + style = MaterialTheme.typography.titleMedium, + color = Color.White, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(16.dp)) + // Transparent scanning window — only corner brackets are drawn + Canvas( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f), + ) { + val stroke = 6.dp.toPx() + val arm = 32.dp.toPx() + val radius = 16.dp.toPx() + val w = size.width + val h = size.height + + val corners = listOf( + Offset(0f, 0f) to (1f to 1f), // top-left + Offset(w, 0f) to (-1f to 1f), // top-right + Offset(w, h) to (-1f to -1f), // bottom-right + Offset(0f, h) to (1f to -1f), // bottom-left + ) + + corners.forEach { (pivot, signs) -> + val (sx, sy) = signs + drawLine( + color = Color.White, + start = Offset(pivot.x + sx * radius, pivot.y), + end = Offset(pivot.x + sx * (radius + arm), pivot.y), + strokeWidth = stroke, + cap = StrokeCap.Round, + ) + drawLine( + color = Color.White, + start = Offset(pivot.x, pivot.y + sy * radius), + end = Offset(pivot.x, pivot.y + sy * (radius + arm)), + strokeWidth = stroke, + cap = StrokeCap.Round, + ) + drawArc( + color = Color.White, + startAngle = when { + sx > 0 && sy > 0 -> 180f + sx < 0 && sy > 0 -> 270f + sx < 0 && sy < 0 -> 0f + else -> 90f + }, + sweepAngle = 90f, + useCenter = false, + topLeft = Offset(pivot.x + sx * radius - radius, pivot.y + sy * radius - radius), + size = androidx.compose.ui.geometry.Size(radius * 2, radius * 2), + style = androidx.compose.ui.graphics.drawscope.Stroke( + width = stroke, + cap = StrokeCap.Round, + ), + ) + } + } + } + } + + // Permission-denied fallback shown on top of the grey background + if (!hasCameraPermission) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + ) { + Text( + text = "Camera permission is required to scan QR codes", + color = Color.White, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge, + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { requestPermissionLauncher.launch(Manifest.permission.CAMERA) }) { + Text(stringResource(R.string.text_pingone_mfa_grant_permission)) + } + } + } + } + + // Manual entry panel pinned at the bottom + Surface( + modifier = Modifier.fillMaxWidth(), + tonalElevation = 4.dp, + ) { + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) { + OutlinedTextField( + value = manualKey, + onValueChange = { manualKey = it }, + placeholder = { Text(stringResource(R.string.text_pingone_mfa_enter_pairing_key_placeholder)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }), + colors = OutlinedTextFieldDefaults.colors( + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + focusedContainerColor = MaterialTheme.colorScheme.surface, + ), + ) + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { + keyboardController?.hide() + viewModel.pair(manualKey.trim()) + manualKey = "" + }, + enabled = manualKey.trim().matchesPingOnePairingKeyScheme() && !state.isLoading, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.text_pingone_mfa_pair_button)) + } + } + } + } + } + + DisposableEffect(lifecycleOwner) { + onDispose { + // Shut down the camera thread executor and release the ML Kit barcode client + // together, so neither outlives the other once the screen leaves composition. + qrAnalyzer.close() + cameraExecutor.shutdown() + } + } +} diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/components/ApproveDenyRow.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/components/ApproveDenyRow.kt new file mode 100644 index 000000000..aeb127d8e --- /dev/null +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/components/ApproveDenyRow.kt @@ -0,0 +1,51 @@ +/* + * 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.samples.pingsampleapp.pingonemfa.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.pingidentity.samples.pingsampleapp.R + +/** + * A row with a Deny button on the left and an Approve button on the right. + * Used for DEFAULT push notification type. + */ +@Composable +fun ApproveDenyRow(onApprove: () -> Unit, onDeny: () -> Unit) { + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth() + ) { + OutlinedButton( + onClick = onDeny, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Text(stringResource(R.string.system_notification_deny)) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Button(onClick = onApprove) { + Text(stringResource(R.string.system_notification_approve)) + } + } +} diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/components/ManualNumberChallenge.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/components/ManualNumberChallenge.kt new file mode 100644 index 000000000..9bcd3e9d9 --- /dev/null +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/components/ManualNumberChallenge.kt @@ -0,0 +1,91 @@ +/* + * 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.samples.pingsampleapp.pingonemfa.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +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.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.pingidentity.samples.pingsampleapp.R + +/** + * Fallback UI for a CHALLENGE push when [PushNotification.getNumbersChallenge] returns null. + * + * Presents a digit-only text field and a "Confirm Number" button that activates only when + * the field is non-empty, plus a Deny button. The confirmed value is passed to [onConfirm] + * as an [Int] so it flows through the same approve path as the button-based challenge. + */ +@Composable +fun ManualNumberChallenge( + onConfirm: (Int) -> Unit, + onDeny: () -> Unit, +) { + var input by remember { mutableStateOf("") } + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = stringResource(R.string.text_pingone_mfa_enter_number_prompt), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + value = input, + onValueChange = { value -> + // Accept digits only + if (value.all { it.isDigit() }) input = value + }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + label = { Text(stringResource(R.string.text_pingone_mfa_number_label)) }, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = { input.toIntOrNull()?.let { onConfirm(it) } }, + enabled = input.isNotEmpty(), + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.text_pingone_mfa_confirm_number)) + } + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedButton( + onClick = onDeny, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Text(stringResource(R.string.system_notification_deny)) + } + } +} diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/components/NumberChallengeOptions.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/components/NumberChallengeOptions.kt new file mode 100644 index 000000000..fa5252642 --- /dev/null +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/components/NumberChallengeOptions.kt @@ -0,0 +1,68 @@ +/* + * 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.samples.pingsampleapp.pingonemfa.ui.components + +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +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.unit.dp +import com.pingidentity.samples.pingsampleapp.R + +/** + * Displays the server-provided set of numbers for a CHALLENGE push. + * The user taps the number that matches what they see on their other device. + * Used when [PushNotification.getNumbersChallenge] returns a non-null array. + */ +@Composable +fun NumberChallengeOptions( + options: IntArray, + onSelected: (Int) -> Unit, + onDeny: () -> Unit +) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = stringResource(R.string.text_pingone_mfa_select_number), + style = MaterialTheme.typography.titleMedium + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), + modifier = Modifier.fillMaxWidth() + ) { + options.forEach { number -> + OutlinedButton(onClick = { onSelected(number) }) { + Text(number.toString()) + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + OutlinedButton( + onClick = onDeny, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Text(stringResource(R.string.system_notification_deny)) + } + } +} diff --git a/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/util/PingOneMFAQrCodeAnalyzer.kt b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/util/PingOneMFAQrCodeAnalyzer.kt new file mode 100644 index 000000000..1f2e4d18e --- /dev/null +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/util/PingOneMFAQrCodeAnalyzer.kt @@ -0,0 +1,85 @@ +/* + * 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.samples.pingsampleapp.pingonemfa.util + +import android.annotation.SuppressLint +import androidx.annotation.OptIn +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 java.util.concurrent.TimeUnit + +/** + * Analyzes camera images to detect PingOne MFA pairing QR codes. + * + * Only triggers [onQrCodeDetected] for QR codes whose content matches the PingOne pairing key + * scheme (numeric prefix, 12 or 14 characters). All other QR codes are silently ignored. + * + * @param onQrCodeDetected Callback invoked with the raw pairing key when a valid code is found. + */ +class PingOneMFAQrCodeAnalyzer( + private val onQrCodeDetected: (String) -> Unit, +) : ImageAnalysis.Analyzer { + + private val scanner = BarcodeScanning.getClient() + + // Throttle to avoid firing multiple times on the same code + private var lastAnalyzedTimestamp = 0L + + /** + * Releases the ML Kit barcode client. + * + * CameraX 1.x does not expose an analyzer lifecycle callback, so callers are + * responsible for calling this when the camera is unbound. In the QR scanner screen + * this is done inside the DisposableEffect that also shuts down the camera executor, + * ensuring the native ML Kit resources are freed at the same time as the camera. + */ + fun close() { + scanner.close() + } + + @SuppressLint("UnsafeOptInUsageError") + @OptIn(ExperimentalGetImage::class) + override fun analyze(imageProxy: ImageProxy) { + val currentTimestamp = System.currentTimeMillis() + + if (currentTimestamp - lastAnalyzedTimestamp >= TimeUnit.SECONDS.toMillis(1)) { + imageProxy.image?.let { image -> + val inputImage = InputImage.fromMediaImage(image, imageProxy.imageInfo.rotationDegrees) + + scanner.process(inputImage) + .addOnSuccessListener { barcodes -> + val found = barcodes.find { barcode -> + barcode.format == Barcode.FORMAT_QR_CODE && + barcode.rawValue?.matchesPingOnePairingKeyScheme() == true + } + found?.rawValue?.let { pairingKey -> + lastAnalyzedTimestamp = currentTimestamp + onQrCodeDetected(pairingKey) + } + } + .addOnFailureListener { it.printStackTrace() } + .addOnCompleteListener { imageProxy.close() } + } ?: imageProxy.close() + } else { + imageProxy.close() + } + } +} + +/** + * Returns true if this string looks like a valid PingOne MFA pairing key. + * + * Accepts numeric and alphanumeric pairing keys that start with two digits + * and are exactly 12 or 14 characters long. + */ +fun String.matchesPingOnePairingKeyScheme(): Boolean = + length >= 2 && this[0].isDigit() && this[1].isDigit() && (length == 12 || length == 14) diff --git a/samples/pingsampleapp/src/main/res/values/strings.xml b/samples/pingsampleapp/src/main/res/values/strings.xml index 255e6de05..d4d84a849 100644 --- a/samples/pingsampleapp/src/main/res/values/strings.xml +++ b/samples/pingsampleapp/src/main/res/values/strings.xml @@ -13,6 +13,7 @@ AUTHENTICATION USER MANAGEMENT MFA + PINGONE MFA DEVELOPER TOOLS @@ -42,6 +43,58 @@ Push Notifications View and respond push requests + + QR Code Registration + Scan QR code to pair with PingOne MFA + MFA Accounts + View paired MFA accounts + One-Time Passcode + OTP for your paired accounts + Mobile Payload + Generate Mobile Payload for authentication and registration + + + PingOne MFA + One-Time Passcode + Mobile Payload + PingOne MFA Scanner + Authentication Request + + + Loading accounts… + Fetching OTP… + Fetching payload… + Pairing… + + + No passcode available + Try again + + + This is a test push notification. No action is required. + Dismiss + Approval failed + Deny failed + Approved + Authentication approved successfully. + Denied + Authentication request has been denied. + + + Enter the number shown on your other device + Number + Confirm Number + Select the matching number + + + Scan QR Code + Enter pairing key manually + Pair + Grant Permission + + + Copy + Device Information Device ID @@ -94,6 +147,7 @@ Code copied to clipboard Error OK + Retry Copy New Code Generate Code From b6ad05926898ed899b22a59e1e74854498a99875 Mon Sep 17 00:00:00 2001 From: Evgeniy Mishustin Date: Mon, 1 Jun 2026 12:15:35 +0300 Subject: [PATCH 05/15] feat: pingonemfa sdk integration, removed stale standalone app --- .../com/pingidentity/protect/Protect.kt | 3 +- samples/pingonemfapp/.gitignore | 2 - samples/pingonemfapp/README.md | 192 -------- samples/pingonemfapp/build.gradle.kts | 119 ----- .../pingonemfapp/src/main/AndroidManifest.xml | 90 ---- .../pingidentity/pingonemfapp/MainActivity.kt | 164 ------- .../pingidentity/pingonemfapp/PingOneMFApp.kt | 71 --- .../pingonemfapp/data/DiagnosticLogger.kt | 121 ----- .../pingonemfapp/data/MainViewModel.kt | 208 --------- .../pingonemfapp/data/UiModels.kt | 49 --- .../pingonemfapp/data/UserPreferences.kt | 162 ------- .../pingonemfapp/managers/AccountsManager.kt | 60 --- .../pingonemfapp/managers/OTPManager.kt | 70 --- .../notification/BiometricPromptActivity.kt | 238 ---------- .../NotificationActionReceiver.kt | 73 ---- .../notification/NotificationHelper.kt | 214 --------- .../notification/PushNotificationActivity.kt | 177 -------- .../service/PushNotificationService.kt | 152 ------- .../pingonemfapp/ui/AboutScreen.kt | 163 ------- .../pingonemfapp/ui/AccountsScreen.kt | 308 ------------- .../pingonemfapp/ui/AuthenticatorNavHost.kt | 114 ----- .../pingonemfapp/ui/DiagnosticLogsScreen.kt | 266 ----------- .../pingonemfapp/ui/LoginScreen.kt | 217 --------- .../ui/NotificationResponseScreen.kt | 413 ------------------ .../pingidentity/pingonemfapp/ui/OtpScreen.kt | 65 --- .../pingonemfapp/ui/QrScannerScreen.kt | 259 ----------- .../pingonemfapp/ui/SettingsScreen.kt | 183 -------- .../ui/components/AccountAvatar.kt | 89 ---- .../pingonemfapp/ui/components/AccountCard.kt | 84 ---- .../ui/components/BackNavigationTopAppBar.kt | 44 -- .../ui/components/EmptyStateMessage.kt | 59 --- .../ui/components/ErrorAlertDialog.kt | 38 -- .../ui/components/ExpiringOtpCode.kt | 76 ---- .../ui/components/LoadingIndicator.kt | 50 --- .../pingonemfapp/ui/components/SettingItem.kt | 111 ----- .../pingonemfapp/ui/theme/Color.kt | 18 - .../pingonemfapp/ui/theme/Theme.kt | 75 ---- .../pingonemfapp/ui/theme/Type.kt | 48 -- .../pingonemfapp/util/NavigationAnimations.kt | 71 --- .../pingonemfapp/util/QrCodeAnalyzer.kt | 69 --- .../src/main/res/drawable/ic_check.xml | 10 - .../src/main/res/drawable/ic_close.xml | 10 - .../src/main/res/drawable/ic_fingerprint.xml | 10 - .../res/drawable/ic_launcher_foreground.xml | 21 - .../src/main/res/drawable/ic_notification.xml | 10 - .../src/main/res/drawable/ping_logo.xml | 28 -- .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 - .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 - .../res/values/ic_launcher_background.xml | 4 - .../src/main/res/values/strings.xml | 171 -------- .../src/main/res/values/themes.xml | 5 - 51 files changed, 1 insertion(+), 5263 deletions(-) delete mode 100644 samples/pingonemfapp/.gitignore delete mode 100644 samples/pingonemfapp/README.md delete mode 100644 samples/pingonemfapp/build.gradle.kts delete mode 100644 samples/pingonemfapp/src/main/AndroidManifest.xml delete mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/MainActivity.kt delete mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/PingOneMFApp.kt delete mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/data/DiagnosticLogger.kt delete mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/data/MainViewModel.kt delete mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/data/UiModels.kt delete mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/data/UserPreferences.kt delete mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/managers/AccountsManager.kt delete mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/managers/OTPManager.kt delete mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/BiometricPromptActivity.kt delete mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/NotificationActionReceiver.kt delete mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/NotificationHelper.kt delete mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/PushNotificationActivity.kt delete mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/service/PushNotificationService.kt delete mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/AboutScreen.kt delete mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/AccountsScreen.kt delete mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/AuthenticatorNavHost.kt delete mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/DiagnosticLogsScreen.kt delete mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/LoginScreen.kt delete mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/NotificationResponseScreen.kt delete mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/OtpScreen.kt delete mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/QrScannerScreen.kt delete mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/SettingsScreen.kt delete mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/AccountAvatar.kt delete mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/AccountCard.kt delete mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/BackNavigationTopAppBar.kt delete mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/EmptyStateMessage.kt delete mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/ErrorAlertDialog.kt delete mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/ExpiringOtpCode.kt delete mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/LoadingIndicator.kt delete mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/SettingItem.kt delete mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/theme/Color.kt delete mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/theme/Theme.kt delete mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/theme/Type.kt delete mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/util/NavigationAnimations.kt delete mode 100644 samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/util/QrCodeAnalyzer.kt delete mode 100644 samples/pingonemfapp/src/main/res/drawable/ic_check.xml delete mode 100644 samples/pingonemfapp/src/main/res/drawable/ic_close.xml delete mode 100644 samples/pingonemfapp/src/main/res/drawable/ic_fingerprint.xml delete mode 100644 samples/pingonemfapp/src/main/res/drawable/ic_launcher_foreground.xml delete mode 100644 samples/pingonemfapp/src/main/res/drawable/ic_notification.xml delete mode 100644 samples/pingonemfapp/src/main/res/drawable/ping_logo.xml delete mode 100644 samples/pingonemfapp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml delete mode 100644 samples/pingonemfapp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml delete mode 100644 samples/pingonemfapp/src/main/res/values/ic_launcher_background.xml delete mode 100644 samples/pingonemfapp/src/main/res/values/strings.xml delete mode 100644 samples/pingonemfapp/src/main/res/values/themes.xml diff --git a/protect/src/main/kotlin/com/pingidentity/protect/Protect.kt b/protect/src/main/kotlin/com/pingidentity/protect/Protect.kt index 3598d3f58..cff9d8219 100644 --- a/protect/src/main/kotlin/com/pingidentity/protect/Protect.kt +++ b/protect/src/main/kotlin/com/pingidentity/protect/Protect.kt @@ -8,8 +8,7 @@ package com.pingidentity.protect import com.pingidentity.android.ContextProvider -import com.pingidentity.protect.Protect.config -import com.pingidentity.protect.Protect.initialize +import com.pingidentity.orchestrate.Module import com.pingidentity.signalssdk.sdk.GetDataCallback import com.pingidentity.signalssdk.sdk.InitCallback import com.pingidentity.signalssdk.sdk.POInitParams diff --git a/samples/pingonemfapp/.gitignore b/samples/pingonemfapp/.gitignore deleted file mode 100644 index 65d12b954..000000000 --- a/samples/pingonemfapp/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -/build -google-services.json \ No newline at end of file diff --git a/samples/pingonemfapp/README.md b/samples/pingonemfapp/README.md deleted file mode 100644 index 605d5a70d..000000000 --- a/samples/pingonemfapp/README.md +++ /dev/null @@ -1,192 +0,0 @@ -[![Ping Identity](https://www.pingidentity.com/content/dam/picr/nav/Ping-Logo-2.svg)](https://github.com/pingidentity/pingone-mobile-sdk-android) - -# PingOne MFA 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. - -## Features - -### OATH Authentication -- **QR Code Scanning**: Register accounts by scanning QR codes -- **TOTP Support**: Automatic generation of time-based one-time passwords with countdown timer - -### Push Authentication -- **QR Code Registration**: Register for push authentication by scanning QR codes -- **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) - -## 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`, `otp`). -- **SDK Layer**: Abstracts the complexity to deal with MFA capabilities and communication 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 -- **Biometric**: For fingerprint/face recognition - -## Implementation Details - -### Code Structure Overview - -``` -src/main/kotlin/com/pingidentity/authenticatorapp/ -├── PingOneMFApp.kt # App initialization -├── managers/ -│ ├── AccountsManager.kt # Pairing account and accounts retrieval from the SDK -│ └── OtpManager.kt # OTP generation and auto-refresh -├── ui/ -│ ├── AccountsScreen.kt # Account management UI -│ ├── OtpScreen.kt # OTP presentation screen -│ └── ... # Other Compose screens -├── data/ -│ ├── MainViewModel.kt # Acts as the central coordinator between the PingOne MFA SDK managers and the UI -│ ├── DiagnosticLogger.kt # Logging -│ └── UserPreferences.kt # Preferences -└── ... -``` - -**Key Classes & Structure:** - -- `PingOneMFApp.kt`: Configures logging, initializes the PingOne MFA SDK, and registers the Firebase push token. -- `managers/`: Integrates Ping SDK modules. -- `managers/AccountsManager.kt`: wraps the PingOne MFA SDK to pair users and load MFA accounts. -- `managers/OtpManager.kt`: continuously fetches OTP codes from the PingOne MFA SDK, maintains their countdown lifecycle, and exposes the current OTP state to the UI via a reactive flow. -- `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:** `PushNotificationService.kt` - -**Flow Diagram (textual):** -``` -Push Request → PushNotificationService → SDK module → 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 - notification.approveNotification(notification, authMethod) - ``` - ***important:*** -If you're approving the notification from the notification banner button (notification action) you must call: - ```kotlin - // Approve a background notification - PingOneMFA.approvePushNotificationFromBanner(notification) - ``` - -2. **BIOMETRIC**: Authentication using biometric verification - ```kotlin - // Approve with biometric authentication - notification.approveBiometricNotification(notification, authMethod) - ``` - -3. **CHALLENGE**: Verification using challenge numbers - ```kotlin - // Get challenge numbers - val numbers = pushNotification.getNumbersChallenge() - - // Approve with challenge response - notification.approveNotification(notification, authMethod, challengeResponse) - ``` - - -### OTP Module -- **Token Retrieval**: Retrieves OTP from the SDK and displays it to the user. -- **Token Refreshment**: Refreshes the OTP code when it expires. - -**Class:** `OtpManager.kt` - -**Flow Diagram (textual):** -``` -Enroll User → OtpManager → Ping One SDK → Token Retrieval → Display in UI → User enters code -``` - -### QR Code Scanning - -The app uses CameraX and ML Kit to scan and decode QR codes. - - -## Getting Started - -### Prerequisites - -- Android Studio Koala | 2024.1.1 or newer -- Android SDK 29 or higher -- Gradle 8.7 or newer -- google-services.json file (to work with FCM push notifications) - -### 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 Functionality - -To test the app's functionality, you need: - -- A PingOne account with MFA enabled -- FCM configured for your Android application -- The app properly registered with FCM to receive push notifications - - -## 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. \ No newline at end of file diff --git a/samples/pingonemfapp/build.gradle.kts b/samples/pingonemfapp/build.gradle.kts deleted file mode 100644 index 8c0d3e086..000000000 --- a/samples/pingonemfapp/build.gradle.kts +++ /dev/null @@ -1,119 +0,0 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - -/* - * 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. - */ - -plugins { - alias(libs.plugins.androidApplication) - alias(libs.plugins.kotlinAndroid) - alias(libs.plugins.compose.compiler) - alias(libs.plugins.googleServices) - alias(libs.plugins.kotlinSerialization) -} -@Suppress("UnstableApiUsage") -android { - namespace = "com.pingidentity.pingonemfapp" - compileSdk = 36 - - defaultConfig { - applicationId = "com.pingidentity.pingonemfapp" - minSdk = 29 - targetSdk = 36 - versionCode = 1 - versionName = "1.0" - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - vectorDrawables { - useSupportLibrary = true - } - } - - buildTypes { - release { - isMinifyEnabled = false - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - } - } - - lint { - disable += "NullSafeMutableLiveData" - // To avoid lint errors during the build - abortOnError = false - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - kotlin { - compilerOptions { - jvmTarget.set(JvmTarget.JVM_17) - } - } - buildFeatures { - compose = true - } - composeOptions { - // Matching the Compose plugin version - kotlinCompilerExtensionVersion = "1.5.8" - } - packaging { - resources { - excludes += "/META-INF/{AL2.0,LGPL2.1}" - } - } -} - -configurations.all { - resolutionStrategy { - force("com.google.android.gms:play-services-basement:18.4.0") - force("com.google.android.gms:play-services-tasks:18.2.0") - force("com.google.android.gms:play-services-base:18.5.0") - } -} - -dependencies { - - // Ping SDK dependencies - implementation(project(":pingonemfa")) - implementation(project(":foundation:logger")) - - // Kotlinx Serialization - implementation(libs.kotlinx.serialization.json) - - // Core Android dependencies - implementation(libs.androidx.core.ktx) - implementation(libs.androidx.lifecycle.runtime.ktx) - implementation(libs.androidx.activity.compose) - - // 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) - - // Firebase Cloud Messaging for push notifications - implementation(platform(libs.firebase.bom)) - implementation(libs.firebase.messaging) - - // Biometric - implementation(libs.androidx.biometric) -} diff --git a/samples/pingonemfapp/src/main/AndroidManifest.xml b/samples/pingonemfapp/src/main/AndroidManifest.xml deleted file mode 100644 index d0720a0e0..000000000 --- a/samples/pingonemfapp/src/main/AndroidManifest.xml +++ /dev/null @@ -1,90 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/MainActivity.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/MainActivity.kt deleted file mode 100644 index c2f35052a..000000000 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/MainActivity.kt +++ /dev/null @@ -1,164 +0,0 @@ -/* - * 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.pingonemfapp - -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.pingonemfapp.data.DiagnosticLogger -import com.pingidentity.pingonemfapp.data.PingOneMFAViewModel -import com.pingidentity.pingonemfapp.data.ThemeMode -import com.pingidentity.pingonemfapp.data.UserPreferences -import com.pingidentity.pingonemfapp.managers.AccountsManager -import com.pingidentity.pingonemfapp.managers.OTPManager -import com.pingidentity.pingonemfapp.notification.NotificationHelper -import com.pingidentity.pingonemfapp.ui.AuthenticatorNavHost -import com.pingidentity.pingonemfapp.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: PingOneMFAViewModel - 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, - 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 accountsManager = AccountsManager(diagnosticLogger = diagnosticLogger) - val otpManager = OTPManager(diagnosticLogger = diagnosticLogger) - - // Create ViewModels with clients already set - authenticatorViewModel = PingOneMFAViewModel( - application = application, - userPreferences = userPreferences, - accountsManager = accountsManager, - otpManager = otpManager, - ) - - // Mark ViewModels as initialized and trigger UI update - 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/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/PingOneMFApp.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/PingOneMFApp.kt deleted file mode 100644 index 1acb04da8..000000000 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/PingOneMFApp.kt +++ /dev/null @@ -1,71 +0,0 @@ -/* - * 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.pingonemfapp - -import android.app.Application -import com.google.firebase.FirebaseApp -import com.google.firebase.messaging.FirebaseMessaging -import com.pingidentity.logger.Logger -import com.pingidentity.logger.STANDARD -import com.pingidentity.pingonemfa.commons.PingOneMFA -import com.pingidentity.pingonemfapp.data.DiagnosticLogger -import com.pingidentity.pingonemfapp.data.UserPreferences -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 PingOne MFA Authenticator app. - * Initializes the PingOne SDK on application startup. - */ -@OptIn(ExperimentalCoroutinesApi::class) -class PingOneMFApp : Application() { - - 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 { - // initialize PingOneMFA SDK - try { - PingOneMFA.initialize() - diagnosticLogger.i("PingOneMFA SDK initialized") - }catch (e: Exception){ - diagnosticLogger.e("PingOneMFA SDK initialization failed", e) - } - - // Obtain the device token from Firebase and set register it with PingOneMFA SDK - try { - FirebaseApp.initializeApp(this@PingOneMFApp) - PingOneMFA.register(FirebaseMessaging.getInstance().token.await()) - diagnosticLogger.i("PingOneMFA SDK: Firebase device token set") - } catch (e: IllegalStateException) { - diagnosticLogger.e("Firebase not configured properly", e) - } - diagnosticLogger.i("AuthenticatorApp: SDK initialization complete") - } - } -} diff --git a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/data/DiagnosticLogger.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/data/DiagnosticLogger.kt deleted file mode 100644 index 801e25daa..000000000 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/data/DiagnosticLogger.kt +++ /dev/null @@ -1,121 +0,0 @@ -/* - * 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.pingonemfapp.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/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/data/MainViewModel.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/data/MainViewModel.kt deleted file mode 100644 index 2b3433531..000000000 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/data/MainViewModel.kt +++ /dev/null @@ -1,208 +0,0 @@ -/* - * 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.pingonemfapp.data - -import android.app.Application -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import com.pingidentity.logger.Logger -import com.pingidentity.logger.STANDARD -import com.pingidentity.pingonemfapp.managers.AccountsManager -import com.pingidentity.pingonemfapp.managers.OTPManager -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -/** - * ViewModel for the PingOneMFA 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 accountsManager Manager for PingOneMFA SDK paired accounts - * @param otpManager Manager for OTP from PingOneMFA SDK - */ -class PingOneMFAViewModel( - application: Application, - private val userPreferences: UserPreferences, - - private val accountsManager: AccountsManager, - private val otpManager: OTPManager, -) : AndroidViewModel(application), ViewModelProvider.Factory { - - private val _uiState = MutableStateFlow(AuthenticatorUiState()) - private val diagnosticLogger = DiagnosticLogger - - private var pingOneMfaAccountsLoaded = false - - val uiState: StateFlow = _uiState.asStateFlow() - - val otpState: StateFlow = otpManager.otpState - - // Expose all settings preferences as StateFlows - val copyOtp: StateFlow - get() = userPreferences.copyOtpFlow - - val diagnosticLogging: StateFlow - get() = userPreferences.diagnosticLoggingFlow - - val themeMode: StateFlow - get() = userPreferences.themeModeFlow - - - /** - * 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 PingOne MFA paired accounts from PingOneMFA SDK - viewModelScope.launch { - accountsManager.mfaAccountsUi.collect { accounts -> - _uiState.update { it.copy(pingOneMfaAccounts = accounts) } - } - } - } - - /** - * Loads initial data from all managers. - */ - private fun loadInitialData() { - viewModelScope.launch { - // Set initial loading state - _uiState.update { it.copy(isInitialLoading = true) } - try { - loadPingOneMfaAccounts() - } finally { - // Clear initial loading state once everything is loaded - _uiState.update { it.copy(isInitialLoading = false) } - } - } - } - - /** - * Loads all paired accounts from the PingOneMFA SDK. - */ - private suspend fun loadPingOneMfaAccounts() { - accountsManager.loadAccounts().onSuccess { - pingOneMfaAccountsLoaded = true - _uiState.update { it.copy(pingOneMfaAccounts = it.pingOneMfaAccounts, error = null) } - }.onFailure { e -> - _uiState.update { it.copy(error = e.message ?: "Failed to load MFA accounts") } - } - } - - fun startOtpSequence(){ - diagnosticLogger.d("startOtpSequence") - otpManager.startAutoRefresh(viewModelScope) - } - fun stopOtpSequence(){ - diagnosticLogger.d("stopOtpSequence") - otpManager.stop() - } - - /** - * 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 theme mode setting - */ - fun setThemeMode(themeMode: ThemeMode) { - viewModelScope.launch { - diagnosticLogger.d("SettingsScreen: setThemeMode: $themeMode") - userPreferences.setThemeMode(themeMode) - } - } - - - fun tryToPairUserForPingOneMFA(pairingKey: String){ - diagnosticLogger.d("tryToPairUserForPingOneMFA: $pairingKey") - // Attempt to pair user - _uiState.update { it.copy(isLoadingPingOneAccounts = true) } - viewModelScope.launch { - accountsManager.addAccountFromPairingKeyScan(pairingKey).onSuccess { - loadPingOneMfaAccounts() - _uiState.update { it.copy(isLoadingPingOneAccounts = false, error = null) } - }.onFailure { - _uiState.update { it.copy(isLoadingPingOneAccounts = false, error = it.error ?: "Failed to pair user") } - } - } - } - - /** - * 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) } - } - - - // clean-up OTP refresh Job on ViewModel destroy - override fun onCleared() { - otpManager.stop() - super.onCleared() - } - -} - -/** - * Data class representing the UI state of the Authenticator app. - */ -data class AuthenticatorUiState( - val pingOneMfaAccounts: List = emptyList(), - val error: String? = null, - val message: String? = null, - // Loading states for better UX - val isInitialLoading: Boolean = false, - val isLoadingPingOneAccounts: Boolean = false, - val isRefreshing: Boolean = false -) diff --git a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/data/UiModels.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/data/UiModels.kt deleted file mode 100644 index 8e0139c97..000000000 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/data/UiModels.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * 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.pingonemfapp.data - -import com.pingidentity.pingonemfa.commons.PingOneMfaAccount - -/* - * Simple data class to represent an MFA account of PingOneMFA SDK. - */ -data class AccountItem( - val id: String, - val deviceId: String, - val environment: String, - val region: String, - val name: String, - val lastName: String -) -/* - * Data class for OTP UI display with additional UI-specific fields. - */ -data class OtpUiState( - val otp: String = "", - val secondsRemaining: Int = 0, - val isLoading: Boolean = false, - val error: String? = null -) - -fun List.toUiItems(): List { - return map { - createAccountItem(it) - } -} - -fun createAccountItem(account: PingOneMfaAccount): AccountItem { - return AccountItem( - region = account.region, - name = account.name, - lastName = account.family, - id = account.id, - deviceId = account.deviceId, - environment = account.environment - ) -} - diff --git a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/data/UserPreferences.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/data/UserPreferences.kt deleted file mode 100644 index 37304b58d..000000000 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/data/UserPreferences.kt +++ /dev/null @@ -1,162 +0,0 @@ -/* - * 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.pingonemfapp.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 _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 - - /** - * 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) - } - - /** - * 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) - } - - /** - * 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 - } - } - - 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" - } -} diff --git a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/managers/AccountsManager.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/managers/AccountsManager.kt deleted file mode 100644 index f2f8e1d78..000000000 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/managers/AccountsManager.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.pingidentity.pingonemfapp.managers - -import com.pingidentity.pingonemfa.commons.PingOneMFA -import com.pingidentity.pingonemfa.commons.PingOneMfaAccount -import com.pingidentity.pingonemfapp.data.AccountItem -import com.pingidentity.pingonemfapp.data.DiagnosticLogger -import com.pingidentity.pingonemfapp.data.toUiItems -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.withContext - -class AccountsManager( - private val diagnosticLogger: DiagnosticLogger -) { - - private val _isLoadingMfaAccounts = MutableStateFlow(false) - val isLoadingMfaAccounts: StateFlow = _isLoadingMfaAccounts.asStateFlow() - - private val _mfaAccounts = MutableStateFlow>(emptyList()) - val mfaAccounts: StateFlow> = _mfaAccounts.asStateFlow() - - private val _mfaAccountsUi = MutableStateFlow>(emptyList()) - val mfaAccountsUi: StateFlow> = _mfaAccountsUi.asStateFlow() - - suspend fun addAccountFromPairingKeyScan(pairingKey: String): Result { - diagnosticLogger.d("addAccountFromPairingKeyScan: $pairingKey") - return PingOneMFA.pair(pairingKey) - } - - suspend fun loadAccounts(): Result> { - _isLoadingMfaAccounts.value = true - return try { - val result = withContext(Dispatchers.IO) { - diagnosticLogger.d("Loading MFA accounts from PingOneMFA") - PingOneMFA.getAccounts() - } - result.onSuccess { accounts -> - _mfaAccounts.value = accounts - diagnosticLogger.d("Loaded ${accounts.size} MFA accounts from PingOneMFA") - updatePingOneMFAAccounts() - } - result.onFailure { - diagnosticLogger.e("Failed to load MFA accounts from PingOneMFA", it) - _mfaAccounts.value = emptyList() - } - _isLoadingMfaAccounts.value = false - result - } catch (e: Exception) { - _isLoadingMfaAccounts.value = false - Result.failure(e) - } - } - - private fun updatePingOneMFAAccounts() { - val mfaAccountsUi = _mfaAccounts.value.toUiItems() - _mfaAccountsUi.value = mfaAccountsUi - } -} \ No newline at end of file diff --git a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/managers/OTPManager.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/managers/OTPManager.kt deleted file mode 100644 index 220780006..000000000 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/managers/OTPManager.kt +++ /dev/null @@ -1,70 +0,0 @@ -package com.pingidentity.pingonemfapp.managers - -import com.pingidentity.pingonemfa.commons.PingOneMFA -import com.pingidentity.pingonemfapp.data.DiagnosticLogger -import com.pingidentity.pingonemfapp.data.OtpUiState -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch - -class OTPManager( - private val diagnosticLogger: DiagnosticLogger -) { - private val _otpState = MutableStateFlow(OtpUiState()) - val otpState: StateFlow = _otpState.asStateFlow() - - var otpRefreshJob: Job? = null - - fun startAutoRefresh(scope: CoroutineScope){ - if (otpRefreshJob?.isActive == true) return - diagnosticLogger.d("startAutoRefresh") - otpRefreshJob = scope.launch { - fetchOtpAndStartCountDown() - } - } - - fun stop() { - otpRefreshJob?.cancel() - otpRefreshJob = null - } - suspend fun fetchOtpAndStartCountDown() { - _otpState.update { it.copy(isLoading = true, error = null) } - - val result = PingOneMFA.collectOtp() - if (result.isSuccess) { - val otp = result.getOrThrow() - _otpState.update { it.copy( - otp = otp.code, - secondsRemaining = otp.secondsRemaining, - isLoading = false, - error = null - ) } - startCountDown(otp.secondsRemaining) - // when countdown ends → fetch again - fetchOtpAndStartCountDown() - } else { - _otpState.update { - it.copy( - isLoading = false, - error = result.exceptionOrNull()?.message - ) - } - } - } - private suspend fun startCountDown(seconds: Int) { - var remaining = seconds - while (remaining > 0 && currentCoroutineContext().isActive) { - delay(1000) - remaining-- - // if we want to show countdown in UI - //_otpState.update { it.copy(secondsRemaining = remaining) } - } - } -} \ No newline at end of file diff --git a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/BiometricPromptActivity.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/BiometricPromptActivity.kt deleted file mode 100644 index c2a3d2a96..000000000 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/BiometricPromptActivity.kt +++ /dev/null @@ -1,238 +0,0 @@ -/* - * 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.pingonemfapp.notification - -import android.content.pm.PackageManager -import android.os.Build -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.pingonemfa.push.PushNotification -import com.pingidentity.pingonemfapp.data.DiagnosticLogger -import com.pingidentity.pingonemfapp.ui.theme.PingIdentityAuthenticatorTheme -import kotlinx.coroutines.CoroutineScope -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 val diagnosticLogger = DiagnosticLogger - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - // Get notification object from intent - val notification = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - intent?.getParcelableExtra(NotificationActionReceiver.EXTRA_NOTIFICATION, PushNotification::class.java) - } else { - @Suppress("DEPRECATION") // Suppress deprecation warning for backward compatibility - intent?.getParcelableExtra(NotificationActionReceiver.EXTRA_NOTIFICATION) - } - // If no notification, log and finish - if (notification == null) { - diagnosticLogger.w("No notification 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 { - - // Check if biometric authentication is available - val biometricManager = BiometricManager.from(context) - when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)) { - BiometricManager.BIOMETRIC_SUCCESS -> { - isLoading = false - showBiometricPrompt(notification, 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( - notification: PushNotification?, - coroutineScope: 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(notification, authMethod) - finish() - } 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(notification: PushNotification?, authMethod: String) { - val result = notification?.approveNotification( - this, - authMethod - ) - when { - result?.isSuccess == true -> { - finish() - } - result?.isFailure == true -> { - diagnosticLogger.e("Error approving with challenge: ${result.exceptionOrNull()?.stackTrace}") - } - } - } - -} diff --git a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/NotificationActionReceiver.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/NotificationActionReceiver.kt deleted file mode 100644 index e047ca5ea..000000000 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/NotificationActionReceiver.kt +++ /dev/null @@ -1,73 +0,0 @@ -/* - * 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.pingonemfapp.notification - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.os.Build -import androidx.core.app.NotificationManagerCompat -import com.pingidentity.pingonemfa.commons.PingOneMFA -import com.pingidentity.pingonemfa.push.PushNotification -import com.pingidentity.pingonemfapp.data.DiagnosticLogger - -/** - * BroadcastReceiver to handle notification actions. - */ -class NotificationActionReceiver : BroadcastReceiver() { - - private val diagnosticLogger = DiagnosticLogger - - companion object { - const val ACTION_APPROVE = "com.pingidentity.pingonemfapp.ACTION_APPROVE" - const val ACTION_DENY = "com.pingidentity.pingonemfapp.ACTION_DENY" - const val ACTION_BIOMETRIC = "com.pingidentity.pingonemfapp.ACTION_BIOMETRIC" - - const val EXTRA_NOTIFICATION = "com.pingidentity.pingonemfapp.notification" - } - - override fun onReceive(context: Context, intent: Intent) { - val notification = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - intent.getParcelableExtra(EXTRA_NOTIFICATION, PushNotification::class.java) - } else { - @Suppress("DEPRECATION") // Suppress deprecation warning for backward compatibility - intent.getParcelableExtra(EXTRA_NOTIFICATION) - } ?: return - - val notificationHashCode = notification.id.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: ${notification.id}") - PingOneMFA.approvePushNotificationFromBanner(notification = notification) - } - ACTION_DENY -> { - diagnosticLogger.d("Deny action received for notification: ${notification.id}") - PingOneMFA.denyPushNotificationFromBanner(notification = notification) - } - ACTION_BIOMETRIC -> { - diagnosticLogger.d("Biometric action received for notification: ${notification.id}") - handleBiometricAuthentication(context, notification) - } - } - } - - /** - * Handles biometric authentication for the notification with the given ID. - * This launches the BiometricPrompt activity. - */ - private fun handleBiometricAuthentication(context: Context, notification: PushNotification) { - val intent = Intent(context, BiometricPromptActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK - putExtra(EXTRA_NOTIFICATION, notification) - } - context.startActivity(intent) - } -} diff --git a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/NotificationHelper.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/NotificationHelper.kt deleted file mode 100644 index 25e624036..000000000 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/NotificationHelper.kt +++ /dev/null @@ -1,214 +0,0 @@ -/* - * 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.pingonemfapp.notification - -import android.Manifest -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.pingonemfapp.R -import com.pingidentity.pingonemfapp.notification.NotificationActionReceiver.Companion.ACTION_APPROVE -import com.pingidentity.pingonemfapp.notification.NotificationActionReceiver.Companion.ACTION_DENY -import com.pingidentity.pingonemfa.push.PushNotification -import com.pingidentity.pingonemfa.push.PushType - -/** - * Helper class for managing and displaying system notifications. - */ -class NotificationHelper(private val context: Context) { - - companion object { - const val CHANNEL_ID = "com.pingidentity.pingonemfapp.PUSH_NOTIFICATIONS" - const val NOTIFICATION_GROUP = "com.pingidentity.pingonemfapp.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 title The title of the authentication request (if available) - * @param body The body message of the authentication request (if available) - */ - @RequiresPermission(Manifest.permission.POST_NOTIFICATIONS) - fun showPushAuthenticationNotification( - notification: PushNotification, - title: String?, - body: 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 object - putExtra(NotificationActionReceiver.EXTRA_NOTIFICATION, notification) - } - - val pendingIntent = PendingIntent.getActivity( - context, notificationId, intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - // Build the notification title and content - val title = title ?: context.getString(R.string.system_notification_title) - val content = when { - body != null -> body - 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.getPushType()) { - PushType.DEFAULT -> { - // For DEFAULT type, add approve and deny buttons - addDefaultTypeActions(builder, notification.id, notification) - } - - PushType.BIOMETRIC -> { - // For BIOMETRIC type, add biometric authentication action - addBiometricTypeAction(builder, notification.id, notification) - } - - 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)}") - } - - else -> { - // For other types, we don't add actions - builder.setContentText(content) - } - } - - // 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, - notification: PushNotification) { - // Approve action - val approveIntent = Intent(context, NotificationActionReceiver::class.java).apply { - action = ACTION_APPROVE - putExtra(NotificationActionReceiver.EXTRA_NOTIFICATION, notification) - } - - 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(NotificationActionReceiver.EXTRA_NOTIFICATION, notification) - } - 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, - notification: PushNotification - ) { - // 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(NotificationActionReceiver.EXTRA_NOTIFICATION, notification) - } - - // 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/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/PushNotificationActivity.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/PushNotificationActivity.kt deleted file mode 100644 index be9a85460..000000000 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/notification/PushNotificationActivity.kt +++ /dev/null @@ -1,177 +0,0 @@ -/* - * 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.pingonemfapp.notification - -import android.content.Intent -import android.os.Build -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.pingonemfapp.data.DiagnosticLogger -import com.pingidentity.pingonemfapp.ui.NotificationResponseScreen -import com.pingidentity.pingonemfapp.ui.theme.PingIdentityAuthenticatorTheme -import com.pingidentity.pingonemfa.push.PushNotification -import com.pingidentity.pingonemfapp.notification.NotificationActionReceiver.Companion.EXTRA_NOTIFICATION -import kotlinx.coroutines.launch - -/** - * Activity to handle full-screen display of push notifications. - * This activity is launched when a notification is received while app is open or when the user - * clicks on a notification. - */ -class PushNotificationActivity : ComponentActivity() { - - - private val diagnosticLogger = DiagnosticLogger - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - val notification = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - intent?.getParcelableExtra(EXTRA_NOTIFICATION, PushNotification::class.java) - } else { - @Suppress("DEPRECATION") // Suppress deprecation warning for backward compatibility - intent?.getParcelableExtra(EXTRA_NOTIFICATION) - } - // If no notification, log and finish - if (notification == 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 { - notificationItemState = notification - 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 { - val result = notification.approveNotification( - context, - "user" - ) - when { - result.isSuccess -> { - finish() - } - result.isFailure -> { - diagnosticLogger.e("Error approving with challenge: ${result.exceptionOrNull()?.stackTrace}") - } - } - } - }, - onBiometricApprove = { - launchBiometricPrompt(notification) - }, - onDeny = { - coroutineScope.launch { - val result = notification.denyNotification(context) - when { - result.isSuccess -> { - finish() - } - result.isFailure -> { - diagnosticLogger.e("Error approving with challenge: ${result.exceptionOrNull()?.stackTrace}") - } - } - } - }, - onChallengeSolution = { solution -> - coroutineScope.launch { - val result = notification.approveNotification( - context, - "user", - solution.toInt() - ) - when { - result.isSuccess -> { - finish() - } - result.isFailure -> { - diagnosticLogger.e("Error approving with challenge: ${result.exceptionOrNull()?.stackTrace}") - } - } - } - } - ) - } - } - } - } - } - } - - /** - * Launches the BiometricPromptActivity for biometric authentication. - */ - private fun launchBiometricPrompt(notification: PushNotification?) { - val intent = Intent(this, BiometricPromptActivity::class.java).apply { - putExtra(EXTRA_NOTIFICATION, notification) - } - startActivity(intent) - finish() // finish current activity before launching new one. - } - -} diff --git a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/service/PushNotificationService.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/service/PushNotificationService.kt deleted file mode 100644 index 0dfee0abf..000000000 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/service/PushNotificationService.kt +++ /dev/null @@ -1,152 +0,0 @@ -package com.pingidentity.pingonemfapp.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.pingonemfa.commons.PingOneMFA -import com.pingidentity.pingonemfa.push.PushNotification -import com.pingidentity.pingonemfapp.data.DiagnosticLogger -import com.pingidentity.pingonemfapp.notification.NotificationActionReceiver -import com.pingidentity.pingonemfapp.notification.NotificationHelper -import com.pingidentity.pingonemfapp.notification.PushNotificationActivity -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 val diagnosticLogger = DiagnosticLogger - - private lateinit var notificationHelper: NotificationHelper - - - override fun onCreate() { - super.onCreate() - diagnosticLogger.d("PushNotificationService instance created") - - notificationHelper = NotificationHelper(this) - notificationHelper.createNotificationChannels() - } - - 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 PingOneMFA SDK - val success = PingOneMFA.register(token) - diagnosticLogger.d("Device token registration success: $success") - } - } - - /** - * 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 { - // Process the notification via PingOneMFA SDK - val result = PingOneMFA.collectPush(remoteMessage) - result.onSuccess { - pushNotification -> handleNotification(pushNotification) - }.onFailure { - diagnosticLogger.e("Error processing notification: ${it.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) { - notificationHelper.showPushAuthenticationNotification( - notification = notification, - title = notification.title, - body = notification.message - ) - } - } - - /** - * 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 and notification object - val intent = Intent(applicationContext, PushNotificationActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP - putExtra(NotificationActionReceiver.EXTRA_NOTIFICATION, notification) - } - - 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/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/AboutScreen.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/AboutScreen.kt deleted file mode 100644 index d95c60346..000000000 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/AboutScreen.kt +++ /dev/null @@ -1,163 +0,0 @@ -/* - * 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.pingonemfapp.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.pingonemfapp.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_otp), - 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 - ) - } - } - - // 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/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/AccountsScreen.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/AccountsScreen.kt deleted file mode 100644 index 2176282fc..000000000 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/AccountsScreen.kt +++ /dev/null @@ -1,308 +0,0 @@ -/* - * 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.pingonemfapp.ui - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.Image -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.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.Info -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.Notifications -import androidx.compose.material.icons.filled.QrCodeScanner -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.dp -import com.pingidentity.pingonemfapp.R -import com.pingidentity.pingonemfapp.data.PingOneMFAViewModel -import com.pingidentity.pingonemfapp.ui.components.AccountCard -import com.pingidentity.pingonemfapp.ui.components.EmptyStateMessage -import com.pingidentity.pingonemfapp.ui.components.ErrorAlertDialog -import com.pingidentity.pingonemfapp.ui.components.LoadingIndicator -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive - -/** - * Screen for displaying a list of accounts and push notifications. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AccountsScreen( - viewModel: PingOneMFAViewModel, - onScanQrCode: () -> Unit, - onAccountClick: () -> Unit, - onSettingsClick: () -> Unit, - onAboutClick: () -> Unit, -) { - val context = LocalContext.current - val uiState by viewModel.uiState.collectAsState() - val coroutineScope = rememberCoroutineScope() - - // Collect settings state - val copyOtpEnabled by viewModel.copyOtp.collectAsState() - - // State for triggering progress bar updates - var currentTimeMillis by remember { mutableLongStateOf(System.currentTimeMillis()) } - - // 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 = { - // Hamburger menu - Box { - IconButton(onClick = { showHamburgerMenu = true }) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = "Menu" - ) - } - - DropdownMenu( - expanded = showHamburgerMenu, - onDismissRequest = { showHamburgerMenu = false } - ) { - 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" - ) - } - } - } - - // Primary FAB - FloatingActionButton( - onClick = { - showFabMenu = false - onScanQrCode() - }, - 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_accounts) - ) - } - uiState.isLoadingPingOneAccounts -> { - LoadingIndicator( - message = stringResource(id = R.string.loading_accounts) - ) - } - uiState.pingOneMfaAccounts.isEmpty() -> { - EmptyStateMessage( - title = "No accounts added yet", - subtitle = stringResource(id = R.string.accounts_empty_state_subtitle) - ) - } - else -> { - // List of accounts - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - items( - items = uiState.pingOneMfaAccounts, - key = { account -> - // Create a unique key using issuer, account name, and all credential IDs - val userId = account.id - val deviceId = account.deviceId - "$userId-$deviceId" - } - ) { account -> - AccountCard( - accountItem = account, - onCardClick = { - // Navigate to the OTP screen - onAccountClick() - } - ) - } - } - } - } - - // Error handling - if (uiState.error != null) { - ErrorAlertDialog( - errorMessage = uiState.error!!, - onDismiss = { viewModel.clearError() } - ) - } - } - } -} \ No newline at end of file diff --git a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/AuthenticatorNavHost.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/AuthenticatorNavHost.kt deleted file mode 100644 index be323524f..000000000 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/AuthenticatorNavHost.kt +++ /dev/null @@ -1,114 +0,0 @@ -/* - * 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.pingonemfapp.ui - -import androidx.compose.runtime.Composable -import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.rememberNavController -import com.pingidentity.pingonemfapp.data.PingOneMFAViewModel -import com.pingidentity.pingonemfapp.util.NavigationAnimations - -/** - * Main entry point for the app. - */ -@Composable -fun AuthenticatorNavHost( - authenticatorViewModel: PingOneMFAViewModel = viewModel(), - initialDestination: String = "accounts" -) { - // 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") }, - onAccountClick = {navController.navigate("otp") }, - onSettingsClick = { navController.navigate("settings") }, - onAboutClick = { navController.navigate("about") } - ) - } - - // 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() } - ) - } - - // OTP screen - composable( - route = "otp", - enterTransition = NavigationAnimations.enterTransition, - exitTransition = NavigationAnimations.exitTransition, - popEnterTransition = NavigationAnimations.popEnterTransition, - popExitTransition = NavigationAnimations.popExitTransition - ) { - OtpScreen( - viewModel = authenticatorViewModel, - onDismiss = { 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() } - ) - } - - // About screen - composable( - route = "about", - enterTransition = NavigationAnimations.enterTransition, - exitTransition = NavigationAnimations.exitTransition, - popEnterTransition = NavigationAnimations.popEnterTransition, - popExitTransition = NavigationAnimations.popExitTransition - ) { - AboutScreen( - onDismiss = { navController.popBackStack() } - ) - } - } -} diff --git a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/DiagnosticLogsScreen.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/DiagnosticLogsScreen.kt deleted file mode 100644 index 1598fbfbf..000000000 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/DiagnosticLogsScreen.kt +++ /dev/null @@ -1,266 +0,0 @@ -/* - * 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.pingonemfapp.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.pingonemfapp.R -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import com.pingidentity.pingonemfapp.data.DiagnosticLogger -import com.pingidentity.pingonemfapp.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/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/LoginScreen.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/LoginScreen.kt deleted file mode 100644 index a2d23f2bc..000000000 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/LoginScreen.kt +++ /dev/null @@ -1,217 +0,0 @@ -/* - * 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.pingonemfapp.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.layout.Arrangement -import androidx.compose.foundation.layout.Column -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.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.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -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.pingonemfapp.R - -/** - * 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/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/NotificationResponseScreen.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/NotificationResponseScreen.kt deleted file mode 100644 index 00f52178a..000000000 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/NotificationResponseScreen.kt +++ /dev/null @@ -1,413 +0,0 @@ -/* - * 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.pingonemfapp.ui - -import androidx.compose.foundation.BorderStroke -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.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.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.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.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -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 com.pingidentity.pingonemfa.push.PushNotification -import com.pingidentity.pingonemfapp.R -import com.pingidentity.pingonemfapp.ui.components.AccountAvatar - -/** - * Unified screen for displaying push notification details. - * Handles both standard authentication and challenge-based notifications. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun NotificationResponseScreen( - notificationItem: PushNotification, - onDismiss: () -> Unit, - onApprove: (() -> Unit)? = null, - onBiometricApprove: (() -> Unit)? = null, - onDeny: (() -> Unit)? = null, - onChallengeSolution: ((String) -> Unit)? = null -) { - - val isChallenge = notificationItem.isChallenge() - val challengeNumbers = if (isChallenge) notificationItem.getNumbersChallenge()?.toList() 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) - ) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - AccountAvatar( - issuer = notificationItem.title ?:stringResource(id = R.string.notification_response_unknown_issuer), - accountName = notificationItem.message ?: stringResource(id = R.string.notification_response_unknown_account), - imageUrl = null - //notificationItem.credential?.imageURL, - //size = 36.dp - ) - Spacer(modifier = Modifier.width(16.dp)) - Column { - val issuer = notificationItem.title ?:stringResource(id = R.string.notification_response_unknown_issuer) - val accountName = notificationItem.message ?: 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 - ) - } - } - - // Divider - HorizontalDivider( - modifier = Modifier.padding(vertical = 16.dp), - thickness = DividerDefaults.Thickness, - color = DividerDefaults.color - ) - - // Message - Text( - text = //notificationItem.messageText ?: - if (isChallenge) stringResource(id = R.string.notification_response_message_verify) else stringResource(id = R.string.notification_response_message_default), - style = MaterialTheme.typography.bodyLarge, - ) - - Spacer(modifier = Modifier.height(8.dp)) - - // Time sent - 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 = notificationItem.sentAt.toString(), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - // Response time - notificationItem.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.isChallenge() -> 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 - ) - } - } - } - - // Action buttons based on type - if (isChallenge) { - // 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!=null && 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{ - // 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 - ) - } -} - diff --git a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/OtpScreen.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/OtpScreen.kt deleted file mode 100644 index 915abd222..000000000 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/OtpScreen.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.pingidentity.pingonemfapp.ui - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import com.pingidentity.pingonemfapp.data.PingOneMFAViewModel -import com.pingidentity.pingonemfapp.ui.components.ErrorAlertDialog -import com.pingidentity.pingonemfapp.ui.components.ExpiringOtpCode -import com.pingidentity.pingonemfapp.ui.components.LoadingIndicator - -@Composable -fun OtpScreen( - viewModel: PingOneMFAViewModel, - onDismiss: () -> Unit -) { - - val state by viewModel.otpState.collectAsState() - - val lifecycle = androidx.lifecycle.compose.LocalLifecycleOwner.current.lifecycle - DisposableEffect(lifecycle) { - val observer = LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_START -> viewModel.startOtpSequence() - Lifecycle.Event.ON_STOP -> viewModel.stopOtpSequence() - else -> {} - } - } - lifecycle.addObserver(observer) - onDispose { lifecycle.removeObserver(observer) } - } - Box( - modifier = Modifier - .fillMaxSize(), - contentAlignment = Alignment.Center - ) { - when { - state.isLoading -> { - LoadingIndicator( - message = "Loading OTP code..." - ) - } - state.error != null -> { - ErrorAlertDialog( - errorMessage = state.error!!, - onDismiss = { viewModel.clearError() } - ) - } - else -> { - ExpiringOtpCode( - code = state.otp, - remainingSeconds = state.secondsRemaining, - totalDurationMs = 30 - - ) - } - } - } -} \ No newline at end of file diff --git a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/QrScannerScreen.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/QrScannerScreen.kt deleted file mode 100644 index cdb9975a1..000000000 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/QrScannerScreen.kt +++ /dev/null @@ -1,259 +0,0 @@ -/* - * 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.pingonemfapp.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 androidx.lifecycle.compose.LocalLifecycleOwner -import com.pingidentity.pingonemfapp.R -import com.pingidentity.pingonemfapp.data.DiagnosticLogger -import com.pingidentity.pingonemfapp.data.PingOneMFAViewModel -import com.pingidentity.pingonemfapp.ui.components.BackNavigationTopAppBar -import com.pingidentity.pingonemfapp.util.QrCodeAnalyzer -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: PingOneMFAViewModel, - onScanComplete: () -> Unit, - onDismiss: () -> Unit -) { - val context = LocalContext.current - val diagnosticLogger = DiagnosticLogger - val lifecycleOwner = 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() -// } -// } - - - 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 - diagnosticLogger.d("QrScannerScreen: QR code result: $qrCodeResult") - viewModel.tryToPairUserForPingOneMFA(pairingKey = qrCodeResult) - onScanComplete() - } - ) - - 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/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/SettingsScreen.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/SettingsScreen.kt deleted file mode 100644 index ef6a3371e..000000000 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/SettingsScreen.kt +++ /dev/null @@ -1,183 +0,0 @@ -/* - * 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.pingonemfapp.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.ContentCopy -import androidx.compose.material.icons.filled.DarkMode -import androidx.compose.material.icons.filled.Dns -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.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.pingidentity.pingonemfapp.data.PingOneMFAViewModel -import com.pingidentity.pingonemfapp.data.ThemeMode -import com.pingidentity.pingonemfapp.ui.components.BackNavigationTopAppBar -import com.pingidentity.pingonemfapp.ui.components.SettingItem - -/** - * The settings screen for the PingOneMFApp. - * 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: PingOneMFAViewModel, - onDismiss: () -> Unit, - onDiagnosticLogsClick: () -> Unit = {} -) { - // Collect all settings as state - val copyOtp by viewModel.copyOtp.collectAsState() - val diagnosticLogging by viewModel.diagnosticLogging.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()) - ) { - - // 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() - - // 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() - } - } - - // 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 = 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/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/AccountAvatar.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/AccountAvatar.kt deleted file mode 100644 index 82393fbfb..000000000 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/AccountAvatar.kt +++ /dev/null @@ -1,89 +0,0 @@ -/* - * 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.pingonemfapp.ui.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -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.graphics.Color -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -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: 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 - ) { - // 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/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/AccountCard.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/AccountCard.kt deleted file mode 100644 index 00a620617..000000000 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/AccountCard.kt +++ /dev/null @@ -1,84 +0,0 @@ -/* - * 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.pingonemfapp.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.padding -import androidx.compose.foundation.layout.width -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -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.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import com.pingidentity.pingonemfapp.data.AccountItem - -/** - * Composable that displays a single account card with account name and last name info. - * - * @param accountItem The Account information to display. - * @param onCardClick Callback when the account card is clicked. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AccountCard( - accountItem: AccountItem, - onCardClick: () -> Unit -) { - Card( - onClick = onCardClick, - 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 - ) { - Spacer(modifier = Modifier.width(8.dp)) - Column { - Text( - text = accountItem.name, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = accountItem.lastName, - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = accountItem.id, - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - } - } -} \ No newline at end of file diff --git a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/BackNavigationTopAppBar.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/BackNavigationTopAppBar.kt deleted file mode 100644 index 912e6cb2f..000000000 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/BackNavigationTopAppBar.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * 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.pingonemfapp.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.pingonemfapp.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/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/EmptyStateMessage.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/EmptyStateMessage.kt deleted file mode 100644 index 1f066fa8a..000000000 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/EmptyStateMessage.kt +++ /dev/null @@ -1,59 +0,0 @@ -/* - * 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.pingonemfapp.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/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/ErrorAlertDialog.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/ErrorAlertDialog.kt deleted file mode 100644 index f26705614..000000000 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/ErrorAlertDialog.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * 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.pingonemfapp.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.pingonemfapp.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/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/ExpiringOtpCode.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/ExpiringOtpCode.kt deleted file mode 100644 index d6fd3fad7..000000000 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/ExpiringOtpCode.kt +++ /dev/null @@ -1,76 +0,0 @@ -package com.pingidentity.pingonemfapp.ui.components - -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -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.graphics.lerp -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import com.pingidentity.pingonemfapp.ui.theme.PingLightBlue -import com.pingidentity.pingonemfapp.ui.theme.PingRed - -@Composable -fun ExpiringOtpCode( - code: String, - totalDurationMs: Long, - remainingSeconds: Int, - modifier: Modifier = Modifier -) { - // Progress goes from 1.0 -> 0.0 as time runs out - val progress = (remainingSeconds.toFloat() / totalDurationMs.toFloat()).coerceIn(0f, 1f) - - val interpolatedColor = lerp( - start = PingLightBlue, - stop = PingRed, - fraction = 1f - progress - ) - - // Interpolate between Green -> Yellow -> Red - val displayColor by animateColorAsState( - targetValue = interpolatedColor, - label = "otpColor" - ) - - // Slight fade-out near end (optional) - val alpha by animateFloatAsState( - targetValue = if (progress < 0.2f) 0.8f else 1f, - label = "otpAlpha" - ) - - Row( - modifier = modifier.padding(4.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - code.forEach { digit -> - Box( - modifier = Modifier - .size(42.dp) - .border(1.dp, displayColor.copy(alpha = 0.5f), RoundedCornerShape(6.dp)) - .background(Color.Black.copy(alpha = 0.05f), RoundedCornerShape(6.dp)), - contentAlignment = Alignment.Center - ) { - Text( - text = digit.toString(), - color = displayColor.copy(alpha = alpha), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold - ) - } - } - } - -} \ No newline at end of file diff --git a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/LoadingIndicator.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/LoadingIndicator.kt deleted file mode 100644 index e3567be3b..000000000 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/LoadingIndicator.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * 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.pingonemfapp.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/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/SettingItem.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/SettingItem.kt deleted file mode 100644 index fa4c7f6d4..000000000 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/components/SettingItem.kt +++ /dev/null @@ -1,111 +0,0 @@ -/* - * 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.pingonemfapp.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/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/theme/Color.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/theme/Color.kt deleted file mode 100644 index 61300fad8..000000000 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/theme/Color.kt +++ /dev/null @@ -1,18 +0,0 @@ -/* - * 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.pingonemfapp.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/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/theme/Theme.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/theme/Theme.kt deleted file mode 100644 index 91d154a60..000000000 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/theme/Theme.kt +++ /dev/null @@ -1,75 +0,0 @@ -/* - * 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.pingonemfapp.ui.theme - -import android.app.Activity -import com.pingidentity.pingonemfapp.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/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/theme/Type.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/theme/Type.kt deleted file mode 100644 index 4e12f3d8e..000000000 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/ui/theme/Type.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * 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.pingonemfapp.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/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/util/NavigationAnimations.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/util/NavigationAnimations.kt deleted file mode 100644 index 12dbe1428..000000000 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/util/NavigationAnimations.kt +++ /dev/null @@ -1,71 +0,0 @@ -/* - * 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.pingonemfapp.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/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/util/QrCodeAnalyzer.kt b/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/util/QrCodeAnalyzer.kt deleted file mode 100644 index ae474b0e9..000000000 --- a/samples/pingonemfapp/src/main/kotlin/com/pingidentity/pingonemfapp/util/QrCodeAnalyzer.kt +++ /dev/null @@ -1,69 +0,0 @@ -/* - * 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.pingonemfapp.util - -import android.annotation.SuppressLint -import androidx.annotation.OptIn -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 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 - } - - // 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/samples/pingonemfapp/src/main/res/drawable/ic_check.xml b/samples/pingonemfapp/src/main/res/drawable/ic_check.xml deleted file mode 100644 index 117e40b7b..000000000 --- a/samples/pingonemfapp/src/main/res/drawable/ic_check.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/samples/pingonemfapp/src/main/res/drawable/ic_close.xml b/samples/pingonemfapp/src/main/res/drawable/ic_close.xml deleted file mode 100644 index 351a06ab3..000000000 --- a/samples/pingonemfapp/src/main/res/drawable/ic_close.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/samples/pingonemfapp/src/main/res/drawable/ic_fingerprint.xml b/samples/pingonemfapp/src/main/res/drawable/ic_fingerprint.xml deleted file mode 100644 index a628a03bd..000000000 --- a/samples/pingonemfapp/src/main/res/drawable/ic_fingerprint.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/samples/pingonemfapp/src/main/res/drawable/ic_launcher_foreground.xml b/samples/pingonemfapp/src/main/res/drawable/ic_launcher_foreground.xml deleted file mode 100644 index 1353083fe..000000000 --- a/samples/pingonemfapp/src/main/res/drawable/ic_launcher_foreground.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - diff --git a/samples/pingonemfapp/src/main/res/drawable/ic_notification.xml b/samples/pingonemfapp/src/main/res/drawable/ic_notification.xml deleted file mode 100644 index d91b7fa2b..000000000 --- a/samples/pingonemfapp/src/main/res/drawable/ic_notification.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/samples/pingonemfapp/src/main/res/drawable/ping_logo.xml b/samples/pingonemfapp/src/main/res/drawable/ping_logo.xml deleted file mode 100644 index a21fde540..000000000 --- a/samples/pingonemfapp/src/main/res/drawable/ping_logo.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - diff --git a/samples/pingonemfapp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/samples/pingonemfapp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index 5ed0a2df7..000000000 --- a/samples/pingonemfapp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/samples/pingonemfapp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/samples/pingonemfapp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml deleted file mode 100644 index 5ed0a2df7..000000000 --- a/samples/pingonemfapp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/samples/pingonemfapp/src/main/res/values/ic_launcher_background.xml b/samples/pingonemfapp/src/main/res/values/ic_launcher_background.xml deleted file mode 100644 index f42ada656..000000000 --- a/samples/pingonemfapp/src/main/res/values/ic_launcher_background.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - #FFFFFF - diff --git a/samples/pingonemfapp/src/main/res/values/strings.xml b/samples/pingonemfapp/src/main/res/values/strings.xml deleted file mode 100644 index 3878b9412..000000000 --- a/samples/pingonemfapp/src/main/res/values/strings.xml +++ /dev/null @@ -1,171 +0,0 @@ - - - Push authentication requests from Ping Identity - APush Authentication - "Authentication Request" - You have a new authentication request - Authentication request for - (Challenge verification required) - Approve - Deny - Authenticate - Notification permission granted - Notification permission denied. Push notifications will not be displayed. - PingOne MFA Authenticator - Version 1.0.0 - About this app - The Ping Authenticator app provides secure multi-factor authentication using OTP and Push notification methods. This sample application demonstrates the capabilities of the Ping Identity Android SDK. - Features - • OTP Authentication (TOTP/HOTP) - • Push Notifications - • QR Code Scanning - • Account Management - • Secure Storage - © 2026 Ping Identity Corporation. All rights reserved. - About - Back - Account Details - No credentials found for this account - OATH - PUSH - Code copied to clipboard - Error - OK - Copy - New Code - Generate Code - Type - Algorithm - Digits - Period - %d seconds - Created - Platform - User ID - Ping Access Management - PingOne - Today - Yesterday - %d days ago - %d weeks ago - %d months ago - %d years ago - PingOne MFA Authenticator - No accounts added yet - Add an account by scanning a QR code - Loading accounts… - Refresh - Test Mode - Menu - Notifications - Edit Accounts - Settings - About - Scan QR Code - Add Manually - Add Account - Diagnostic Logs (%d) - Authenticator App Diagnostic Logs - Share Logs - Clear Logs - No logs captured yet - Logs will appear here when diagnostic logging is enabled - Account added successfully - Add Account Manually - Issuer (e.g. Company) - Account Name (e.g. email@example.com) - Secret Key - OTP Type - Algorithm - Digits - Period (seconds) - Add Account - Unable to resolve location - Failed to load location details - Authentication Request - Unknown Issuer - Unknown Account - Please verify your identity - Authentication request - Biometric authentication - Challenge authentication - Standard authentication - macOS - Windows - Linux - Android - iOS - Loading location details… - Lat: %1$s, Lng: %2$s - Login Location - Select the number that appears on your other device: - Cancel Authentication - No challenge numbers available - Close - Deny - Approve - Verify - Login - Cancel - Try Again - MFA credential registered successfully - Authenticating… - Preparing authentication… - Unknown error - Please Wait - Registering MFA credentials… - Push Notifications - No push notifications - Pending Requests - Notification History - Location information available - Account added successfully - Scan QR Code - Invalid QR code format. Please scan a valid OATH, Push, or MFA authentication QR code. - Failed to initialize camera: %s - Position QR code within frame - Camera permission is required to scan QR codes - Request Permission - Test Mode - Test Accounts - Create OATH - Create Random OATH - Create PUSH - Create Random PUSH - Create Combined MFA - Create Random Combined - Device Token - Click on `Get Token` to retrieve the token… - Requested new device token from FCM - Renew Token - Get Token - Device token retrieved successfully - Device token renewed successfully - Notifications cleaned up successfully - Notifications - Clean up - Clean Up Old Notifications - Account Locking - Manage Account Locks - Select Account to Lock/Unlock - Lock Account - Unlock Account - Select Locking Policy - Biometric Available - Device Tampering - Custom Policy - Account locked successfully - Account unlocked successfully - No accounts available to lock - OATH - PUSH - • • • • • • - OTP Code - - - Biometrics are required but are unavailable. Please contact your account administrator for help. - The device might have been tampered with or rooted. Please contact the account administrator for help. - Account locked by the following policy: %s. Please contact the account administrator for help. - Account is locked. Please contact the account administrator for help. - Account Locked - \ No newline at end of file diff --git a/samples/pingonemfapp/src/main/res/values/themes.xml b/samples/pingonemfapp/src/main/res/values/themes.xml deleted file mode 100644 index dced681e1..000000000 --- a/samples/pingonemfapp/src/main/res/values/themes.xml +++ /dev/null @@ -1,5 +0,0 @@ - - -