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/gradle/libs.versions.toml b/gradle/libs.versions.toml index dea31be4c..db756e120 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.3.0" +com-pingidentity-pingonemfa = "2.3.0" kotlin-playservices-coroutine = "1.10.2" barcodeScanning = "17.3.0" @@ -71,6 +72,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" zxing = "3.5.3" [libraries] @@ -141,6 +143,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" } @@ -163,6 +166,7 @@ zxing-qr-generator = { module = "com.google.zxing:core", version.ref = "zXingQR" 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"} zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" } 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/README.md b/pingonemfa/README.md new file mode 100644 index 000000000..5497cf51a --- /dev/null +++ b/pingonemfa/README.md @@ -0,0 +1,363 @@ +[![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`. The SDK registers the token across all configured PingOne regions; if any region rejects it the call fails and `internalErrorsList` holds one `Error` per failed region: + +```kotlin +override fun onNewToken(token: String) { + CoroutineScope(SupervisorJob()).launch { + PingOneMFA.setDeviceToken(token) + .onSuccess { + // Token registered successfully in all regions + } + .onFailure { e -> + Log.e("MFA", "Token registration failed: ${e.message}") + // Per-region failure details (developer logging only) + (e as? PingOneMFAException)?.internalErrorsList?.forEach { err -> + Log.e("MFA", "Region failure: code=${err.code} info=${err.userInfo}") + } + } + } +} +``` + +--- + +## 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, diagnosticErrors) -> + accounts.forEach { account -> + Log.d("MFA", "${account.username} | region: ${account.region}") + } + // diagnosticErrors is non-null only when the SDK returned partial error info alongside + // valid data — log it for debugging, the account list is still safe to use. + diagnosticErrors?.forEach { err -> + Log.w("MFA", "Diagnostic: code=${err.code} info=${err.userInfo}") + } +} +``` + +### 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. The native `PingOneSDKError` type is never exposed — all error information is available through `PingOneMFAException`. + +### `PingOneMFAException` + +| Property | Type | Description | +|---|---|---| +| `message` | `String` | Human-readable description of the failure. Always non-null. Use this for logging or user-facing error display. | +| `cause` | `Throwable?` | The original exception when the failure was not a native SDK error (e.g. a network timeout). Preserved in the stack trace. | +| `internalErrorsList` | `List?` | Structured list of `Error` objects parsed from the native SDK error(s). `null` when the failure did not originate from the native SDK. Each entry contains the numeric code, message, and any `userInfo` diagnostic data returned by the server — **for developer logging only, not for user-facing messages**. | + +```kotlin +PingOneMFA.pair(pairingKey) + .onSuccess { + // success path + } + .onFailure { e -> + // Use message for display or simple logging + Log.e("MFA", e.message) + + // Use internalErrorsList containing Error objects for detailed diagnostics (developer-only) + e.internalErrorsList?.forEach { error -> + Log.e("MFA", "code=${error.code} userInfo=${error.userInfo}") + } + } +``` + +### `Error` + +Structured representation of a single PingOne SDK error, exposed via `PingOneMFAException.internalErrorsList`. + +| Property | Type | Description | +|---|---|---| +| `code` | `Int?` | Numeric error code from the native SDK. See [PingOneSDKError documentation](https://pingidentity.github.io/pingone-mobile-sdk-android/-ping-one%20-m-f-a%20-android%20-s-d-k/com.pingidentity.pingidsdkv2.error/-ping-one-s-d-k-error-type/index.html) for the full list. | +| `message` | `String?` | Human-readable error message from the native SDK. | +| `userInfo` | `Map` | Additional diagnostic key/value pairs returned by the server. Intended for **developer logging and debugging only** — do not display to users. Empty if the server did not include additional context. | + +--- + +## 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 across all configured regions. On failure `PingOneMFAException.internalErrorsList` holds one `Error` per failed region. | +| `suspend pair(pairingKey)` | `Result` | Pair a new MFA account. | +| `suspend getDeviceInfo()` | `Result, List?>>` | Return all paired accounts. The second element of the pair contains diagnostic errors from the SDK, if any — non-null only when the SDK returned partial error context alongside valid data. | +| `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 new file mode 100644 index 000000000..bef0db3d7 --- /dev/null +++ b/pingonemfa/build.gradle.kts @@ -0,0 +1,35 @@ +/* + * 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("com.pingidentity.convention.centralPublish") + alias(libs.plugins.androidLibrary) + 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.androidx.core.ktx) + 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.junit) + testImplementation(libs.mockk) + testImplementation(libs.kotlinx.coroutines.test) +} 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/Error.kt b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/Error.kt new file mode 100644 index 000000000..c8f458453 --- /dev/null +++ b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/Error.kt @@ -0,0 +1,27 @@ +/* + * 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 + +/** + * Structured representation of a single PingOne SDK error. + * + * Instances are produced by [com.pingidentity.pingonemfa.util.ErrorParser] from the native type + * and exposed through [PingOneMFAException.internalErrorsList]. + * + * @property code Numeric error code returned by the PingOne MFA native SDK. For details on returned error codes, see the native SDK documentation: + * https://pingidentity.github.io/pingone-mobile-sdk-android/-ping-one%20-m-f-a%20-android%20-s-d-k/com.pingidentity.pingidsdkv2.error/-ping-one-s-d-k-error-type/index.html + * @property message Human-readable error message returned by the native SDK. + * @property userInfo Additional diagnostic key/value pairs returned by the server. + * Intended for use for logging or debugging. The map may be empty if the server did not include + * additional context. + */ +data class Error( + val code: Int?, + val message: String?, + val userInfo: Map = emptyMap() +) \ No newline at end of file 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 new file mode 100644 index 000000000..fce4414b5 --- /dev/null +++ b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/PingOneMFA.kt @@ -0,0 +1,384 @@ +/* + * 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.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 com.pingidentity.pingonemfa.util.ErrorParser +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.coroutines.resume + +/** + * 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 val lock = Mutex() + + /** + * 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 Result.success(Unit) + } + 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 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. + * + * The native SDK may attempt to register the token across multiple PingOne regions. + * Returns [Result.success] when all registrations succeed (or the errors array contains only + * nulls — which the SDK uses to indicate no real errors). Returns [Result.failure] wrapping a + * [PingOneMFAException] if any region rejects the token; [PingOneMFAException.internalErrorsList] + * will contain one [Error] per failed region for diagnostic logging. + */ + suspend fun setDeviceToken(pushToken: String) : Result = withContext(Dispatchers.IO) { + suspendCancellableCoroutine { continuation -> + try { + PingOne.setDeviceToken( + ContextProvider.context, + pushToken, + NotificationProvider.FCM + ) { errors -> + if (errors.isNullOrEmpty() || errors.all { it == null }) { + continuation.resume(Result.success(Unit)) + } else { + logger.e("PingOne push token registration failed: ${errors.firstOrNull { it != null }?.userInfo}") + continuation.resume(Result.failure(PingOneMFAException(errors))) + } + } + } catch (e : Exception) { + logger.e("PingOne push token registration failed", e) + continuation.resume(Result.failure(PingOneMFAException(e))) + } + } + } + + /** + * 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 { continuation -> + try { + PingOne.pair( + ContextProvider.context, + pairingKey + ) { _, 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) { + logger.e("PingOne pairing failed", e) + continuation.resume(Result.failure(PingOneMFAException(e))) + } + } + + /** + * Returns metadata for all currently paired PingOne MFA accounts. + * + * PingOne MFA supports multiple service regions. The native SDK queries all regions and + * aggregates the results. If some regions succeed and others fail, the SDK may return both + * account data and error context in the same callback. + * + * On [Result.success], the value is a [Pair] where: + * - `first` — the aggregated list of [PingOneMfaAccount] objects across all regions. + * - `second` — an optional `List` with per-region diagnostic errors from the SDK. + * Non-null only when the SDK returned partial error context alongside valid data. + * **Developer use only** — log these for debugging but do not surface them to users; + * the account list is still valid when this list is present. + * + * On [Result.failure], all regions failed or no data was returned. The [PingOneMFAException] + * contains per-region failure details in [PingOneMFAException.internalErrorsList]. + * + * ```kotlin + * PingOneMFA.getDeviceInfo() + * .onSuccess { (accounts, diagnosticErrors) -> + * showAccounts(accounts) + * diagnosticErrors?.forEach { err -> + * Log.w("MFA", "Region diagnostic: code=${err.code} info=${err.userInfo}") + * } + * } + * .onFailure { e -> + * Log.e("MFA", "Failed to load accounts: ${e.message}") + * (e as? PingOneMFAException)?.internalErrorsList?.forEach { err -> + * Log.e("MFA", "Region failure: code=${err.code} info=${err.userInfo}") + * } + * } + * ``` + */ + suspend fun getDeviceInfo(): Result, List?>> = + suspendCancellableCoroutine { continuation -> + try { + PingOne.getInfo( + ContextProvider.context + ) { deviceInfo, errors -> + val result = when { + // Data available — return it along with any diagnostic errors from the SDK. + deviceInfo != null && !deviceInfo.isEmpty -> Result.success( + Pair( + AccountParser.parseAccounts(deviceInfo.toString()), + ErrorParser.fromPingOneSDKErrors(errors) + ) + ) + // No data and at least one real error — treat as failure. + errors.any { it != null } -> { + logger.e("PingOne getDeviceInfo failed: ${errors.firstOrNull { it != null }?.userInfo}") + Result.failure(PingOneMFAException(errors)) + } + // Neither data nor errors — SDK misbehaved; avoid hanging the coroutine. + else -> { + 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) { + logger.e("PingOne getDeviceInfo failed", e) + continuation.resume(Result.failure(PingOneMFAException(e))) + } + } + + /** + * 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 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) + } + } catch (e: Exception) { + logger.e("PingOne getOneTimePasscode failed", e) + continuation.resume(Result.failure(PingOneMFAException(e))) + } + } + + /** + * 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. + * + * Returns `Result.success(null)` when the native SDK returns both nulls — this indicates + * the message was a "silent" (extra verification) PingOne MFA push and should be silently ignored by the caller. + */ + suspend fun processRemoteNotification(message: RemoteMessage): Result = + suspendCancellableCoroutine { continuation -> + try { + PingOne.processRemoteNotification( + ContextProvider.context, + message + ) { notificationObject, error -> + val result = when { + notificationObject != null -> 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"]) + ) + ) + error != null -> { + logger.e("PingOne processRemoteNotification failed: ${error.userInfo}") + Result.failure(PingOneMFAException(error)) + } + // Both null — a silent PingOne MFA message; no action needed. + else -> Result.success(null) + } + continuation.resume(result) + } + } catch (e: Exception) { + 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 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) { + 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 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) { + 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? = + data?.let { + Json.parseToJsonElement(it) + .jsonObject["alert"] + ?.jsonObject + ?.get("title") + ?.jsonPrimitive + ?.contentOrNull + } + + private fun getBodyFromRemoteMessageData(data: String?): String? = + data?.let { + Json.parseToJsonElement(it) + .jsonObject["alert"] + ?.jsonObject + ?.get("body") + ?.jsonPrimitive + ?.contentOrNull + } + +} 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..c2e01072e --- /dev/null +++ b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/PingOneMFAException.kt @@ -0,0 +1,80 @@ +/* + * 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.PingOneSDKError +import com.pingidentity.pingonemfa.util.ErrorParser + +/** + * 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 native AAR. All available error information is surfaced + * through this class. + * + * @property internalErrorsList A structured list of [Error] objects parsed from the native SDK + * error(s), or `null` when the failure did not originate from the native SDK (e.g. a network + * timeout or unexpected exception). Each [Error] contains the numeric error code, message, and + * any diagnostic `userInfo` key/value pairs returned by the server — intended for logging and + * debugging. + * + * ### Usage + * ```kotlin + * result.onFailure { e -> + * // Quick check — use message for user-facing display + * Log.e("MFA", e.message) + * + * // Detailed diagnostics — log each error's code and server-provided userInfo + * // Returned error codes are defined in the native SDK; see PingOneSDKError documentation for details: + * // https://pingidentity.github.io/pingone-mobile-sdk-android/-ping-one%20-m-f-a%20-android%20-s-d-k/com.pingidentity.pingidsdkv2.error/-ping-one-s-d-k-error-type/index.html + * e.internalErrorsList?.forEach { err -> + * Log.e("MFA", "code=${err.code} userInfo=${err.userInfo}") + * } + * } + * ``` + */ +class PingOneMFAException private constructor( + message: String, + cause: Throwable?, + val internalErrorsList: List? = null, +) : Exception(message, cause) { + + /** Creates an exception from a plain message string with no SDK error code or cause. */ + internal constructor(message: String?) : this( + message = message ?: "Unknown error", + cause = null, + ) + + /** + * Wraps an unexpected [Exception], preserving the original cause in the stack trace so + * it is visible in crash reports and log output. + */ + internal constructor(cause: Exception) : this( + message = cause.message ?: "Unknown error", + cause = cause + ) + + /* + * Creates an exception from a single [PingOneSDKError] instance, extracting the error code and + * message and parsing any additional error information into a list of [Error] objects. + */ + internal constructor(error: PingOneSDKError) : this( + message = error.message, + cause = null, + internalErrorsList = ErrorParser.fromPingOneSDKError(error) + ) + + // Internal factory constructors — accept native SDK type but keep it off the public API surface. + internal constructor(errors: Array) : this( + // errors[0] can be null when the native SDK includes null sentinels in the array; + // find the first non-null entry and use its message as the summary. + message = errors.firstOrNull { it != null }?.message ?: "Unknown error", + cause = null, + internalErrorsList = ErrorParser.fromPingOneSDKErrors(errors) + ) +} 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..d4321c2fc --- /dev/null +++ b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/commons/PingOneMfaAccount.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 + +/** + * 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 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 new file mode 100644 index 000000000..5168f5409 --- /dev/null +++ b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/otp/OtpCodeInfo.kt @@ -0,0 +1,23 @@ +/* + * 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 + +/** + * 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, + 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..01745b982 --- /dev/null +++ b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/push/PushApprovalService.kt @@ -0,0 +1,165 @@ +/* + * 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.R +import android.annotation.SuppressLint +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.ServiceCompat +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 +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( + dispatcher: CoroutineDispatcher = Dispatchers.IO +) : Service() { + + private val scope = CoroutineScope(SupervisorJob() + dispatcher) + private val logger: Logger = Logger.logger + + override fun onBind(p0: Intent?) = null + + @SuppressLint("InlinedApi") + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + /* + * Use ServiceCompat.startForeground to supply the foreground service type at runtime. + * On API 34+ the 2-arg startForeground() throws MissingForegroundServiceTypeException + * unless the type matches the manifest declaration; ServiceCompat handles the version + * branching internally so we don't need a Build.VERSION check here. + */ + ServiceCompat.startForeground( + this, + NOTIFICATION_ID, + createForegroundNotification(), + ServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING + ) + + 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) { + logger.e("MfaApprovalService: push approval failed", 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, + DenyReason.NONE + ) { 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 + } + + /* + * 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 new file mode 100644 index 000000000..6dcf70178 --- /dev/null +++ b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/push/PushNotification.kt @@ -0,0 +1,154 @@ +/* + * 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.Parcel +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 java.util.UUID +import kotlin.coroutines.resume + +/** + * 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. + */ +data class PushNotification( + val id: String = UUID.randomUUID().toString(), + val notificationObject: NotificationObject, + val title: String?, + val message: String? +) : Parcelable { + + /** + * Restores a [PushNotification] from a [Parcel]. + * Used internally by [CREATOR]; not intended for direct use. + */ + constructor(parcel: Parcel) : this( + id = parcel.readString() ?: UUID.randomUUID().toString(), + notificationObject = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + parcel.readParcelable(NotificationObject::class.java.classLoader, NotificationObject::class.java)!! + } else { + @Suppress("DEPRECATION") + parcel.readParcelable(NotificationObject::class.java.classLoader)!! + }, + title = parcel.readString(), + message = parcel.readString() + ) + + /** + * Flattens this object into a [Parcel]. + * Fields are written in the same order they are read in the [Parcel] constructor. + */ + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeString(id) + parcel.writeParcelable(notificationObject, flags) + parcel.writeString(title) + parcel.writeString(message) + } + + /** No file descriptors are contained in this parcel. */ + override fun describeContents(): Int = 0 + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): PushNotification = PushNotification(parcel) + override fun newArray(size: Int): Array = arrayOfNulls(size) + } + + /** + * 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, + numberChallenge: Int? = null) : Result = suspendCancellableCoroutine { cont -> + try { + notificationObject.approve( + context, + authenticationMethod, + numberChallenge + ) { _, error -> + if (error == null) { + cont.resume(Result.success(Unit)) + } else { + cont.resume(Result.failure(PingOneMFAException(error))) + } + } + } catch (e: Exception) { + 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, + DenyReason.NONE + ) { error -> + if (error == null) { + cont.resume(Result.success(Unit)) + } else { + cont.resume(Result.failure(PingOneMFAException(error))) + } + } + } catch (e: Exception) { + cont.resume(Result.failure(PingOneMFAException(e))) + } + } + + /** + * 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 + notificationObject.numberMatchingType != null -> PushType.CHALLENGE + 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 new file mode 100644 index 000000000..77ff8d020 --- /dev/null +++ b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/push/PushType.kt @@ -0,0 +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, + + /** + * 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 new file mode 100644 index 000000000..cc5acfacd --- /dev/null +++ b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/util/AccountParser.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.pingonemfa.util + +import com.pingidentity.pingonemfa.commons.PingOneMfaAccount +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +internal object AccountParser { + + /** [Json] instance created once to avoid repeated serializer-registry setup. */ + private val json: Json = Json { + ignoreUnknownKeys = true + explicitNulls = false + } + + fun parseAccounts(rawJson: String): List { + val decoded: Map = json.decodeFromString(rawJson) + + return decoded.flatMap { (region, regionDto) -> + regionDto.users.map { + PingOneMfaAccount( + region = region, + id = it.id.orEmpty(), + environment = it.environment?.id.orEmpty(), + deviceId = it.device?.id.orEmpty(), + username = it.username.orEmpty(), + name = it.name?.given, + family = it.name?.family + ) + } + } + } +} + +@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? = null, + 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/main/java/com/pingidentity/pingonemfa/util/ErrorParser.kt b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/util/ErrorParser.kt new file mode 100644 index 000000000..0dbb7fed5 --- /dev/null +++ b/pingonemfa/src/main/java/com/pingidentity/pingonemfa/util/ErrorParser.kt @@ -0,0 +1,53 @@ +/* + * 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.pingidentity.pingidsdkv2.PingOneSDKError +import com.pingidentity.pingonemfa.commons.Error + +/* + * Converts native [PingOneSDKError] objects from the `pingidsdkv2` AAR into the + * wrapper [Error] type, so callers never need a direct dependency on the native SDK. + */ +internal object ErrorParser { + + /** + * Converts a single [PingOneSDKError] into a one-element [List] of [Error]. + * + * Returned as a list to keep the API consistent with [fromPingOneSDKErrors]. + * [PingOneSDKError.getUserInfo] may be null from the native SDK; it is normalized + * to an empty map so callers never receive a null collection. + */ + fun fromPingOneSDKError(error: PingOneSDKError?): List? { + error ?: return null + val errorList = mutableListOf() + errorList.add( + Error( + code = error.code, + message = error.message, + userInfo = error.userInfo ?: emptyMap() + ) + ) + return errorList + } + + /** + * Converts an array of [PingOneSDKError] objects into a flat [List] of [Error]. + * + * Delegates to [fromPingOneSDKError] for each element so the conversion logic is + * defined in one place. + */ + fun fromPingOneSDKErrors(errors: Array?): List? { + errors ?: return null + val errorList = mutableListOf() + errors.forEach { error -> + fromPingOneSDKError(error)?.let { errorList.addAll(it) } + } + return errorList + } +} \ No newline at end of file diff --git a/pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/commons/ErrorTest.kt b/pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/commons/ErrorTest.kt new file mode 100644 index 000000000..e63c1ab69 --- /dev/null +++ b/pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/commons/ErrorTest.kt @@ -0,0 +1,83 @@ +/* + * 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 kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ErrorTest { + + @Test + fun `error stores code and message`() { + val error = Error(code = 10001, message = "Authentication failed") + + assertEquals(10001, error.code) + assertEquals("Authentication failed", error.message) + } + + @Test + fun `userInfo defaults to empty map when not provided`() { + val error = Error(code = 10002, message = "Device not paired") + + assertTrue(error.userInfo.isEmpty()) + } + + @Test + fun `userInfo stores server diagnostic key value pairs`() { + val userInfo = mapOf( + "requestId" to "abc-123", + "region" to "NA", + "detail" to "push token expired" + ) + val error = Error(code = 10003, message = "Push token invalid", userInfo = userInfo) + + assertEquals(3, error.userInfo.size) + assertEquals("abc-123", error.userInfo["requestId"]) + assertEquals("NA", error.userInfo["region"]) + assertEquals("push token expired", error.userInfo["detail"]) + } + + @Test + fun `two errors with same fields are equal`() { + val a = Error(code = 10004, message = "Timeout", userInfo = mapOf("key" to "value")) + val b = Error(code = 10004, message = "Timeout", userInfo = mapOf("key" to "value")) + + assertEquals(a, b) + } + + @Test + fun `two errors with different codes are not equal`() { + val a = Error(code = 10001, message = "Same message") + val b = Error(code = 10002, message = "Same message") + + assertTrue(a != b) + } + + @Test + fun `two errors with different userInfo are not equal`() { + val a = Error(code = 10001, message = "msg", userInfo = mapOf("k" to "v1")) + val b = Error(code = 10001, message = "msg", userInfo = mapOf("k" to "v2")) + + assertTrue(a != b) + } + + @Test + fun `copy preserves all fields`() { + val original = Error( + code = 10005, + message = "Original", + userInfo = mapOf("trace" to "xyz") + ) + val copy = original.copy(message = "Updated") + + assertEquals(10005, copy.code) + assertEquals("Updated", copy.message) + assertEquals(mapOf("trace" to "xyz"), copy.userInfo) + } +} 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/PingOneMFAExceptionTest.kt b/pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/commons/PingOneMFAExceptionTest.kt new file mode 100644 index 000000000..e6a819a31 --- /dev/null +++ b/pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/commons/PingOneMFAExceptionTest.kt @@ -0,0 +1,161 @@ +/* + * 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.PingOneSDKError +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PingOneMFAExceptionTest { + + // ── public constructor(message) ──────────────────────────────────────────── + + @Test + fun `constructor with message stores message`() { + val e = PingOneMFAException("Something went wrong") + assertEquals("Something went wrong", e.message) + } + + @Test + fun `constructor with null message defaults to Unknown error`() { + val e = PingOneMFAException(null as String?) + assertEquals("Unknown error", e.message) + } + + @Test + fun `constructor with message has null cause`() { + val e = PingOneMFAException("msg") + assertNull(e.cause) + } + + @Test + fun `constructor with message has null internalErrorsList`() { + val e = PingOneMFAException("msg") + assertNull(e.internalErrorsList) + } + + // ── internal constructor(cause: Exception) ───────────────────────────────── + + @Test + fun `constructor with cause preserves cause in chain`() { + val cause = RuntimeException("original") + val e = PingOneMFAException(cause) + assertEquals(cause, e.cause) + } + + @Test + fun `constructor with cause uses cause message`() { + val cause = RuntimeException("network timeout") + val e = PingOneMFAException(cause) + assertEquals("network timeout", e.message) + } + + @Test + fun `constructor with cause defaults message when cause message is null`() { + val cause = RuntimeException(null as String?) + val e = PingOneMFAException(cause) + assertEquals("Unknown error", e.message) + } + + @Test + fun `constructor with cause has null internalErrorsList`() { + val e = PingOneMFAException(RuntimeException("cause")) + assertNull(e.internalErrorsList) + } + + // ── internal constructor(error: PingOneSDKError) ─────────────────────────── + + @Test + fun `constructor with PingOneSDKError uses error message`() { + val sdkError = PingOneSDKError(10001, "Auth failed") + val e = PingOneMFAException(sdkError) + assertEquals("Auth failed", e.message) + } + + @Test + fun `constructor with PingOneSDKError populates internalErrorsList`() { + val sdkError = PingOneSDKError(10001, "Auth failed") + val e = PingOneMFAException(sdkError) + assertNotNull(e.internalErrorsList) + assertEquals(1, e.internalErrorsList.size) + } + + @Test + fun `constructor with PingOneSDKError maps code correctly`() { + val sdkError = PingOneSDKError(10001, "Auth failed") + val e = PingOneMFAException(sdkError) + assertEquals(10001, e.internalErrorsList!!.first().code) + } + + @Test + fun `constructor with PingOneSDKError maps userInfo correctly`() { + val sdkError = PingOneSDKError(10002, "Token expired") + sdkError.userInfo["requestId"] = "req-999" + val e = PingOneMFAException(sdkError) + assertEquals("req-999", e.internalErrorsList!!.first().userInfo["requestId"]) + } + + @Test + fun `constructor with PingOneSDKError has null cause`() { + val e = PingOneMFAException(PingOneSDKError(10001, "err")) + assertNull(e.cause) + } + + // ── internal constructor(errors: Array) ─────────────────── + + @Test + fun `constructor with error array uses first error message`() { + val errors = arrayOf( + PingOneSDKError(10001, "First"), + PingOneSDKError(10002, "Second"), + ) + val e = PingOneMFAException(errors) + assertEquals("First", e.message) + } + + @Test + fun `constructor with error array populates internalErrorsList for all errors`() { + val errors = arrayOf( + PingOneSDKError(10001, "First"), + PingOneSDKError(10002, "Second"), + ) + val e = PingOneMFAException(errors) + assertNotNull(e.internalErrorsList) + assertEquals(2, e.internalErrorsList.size) + } + + @Test + fun `constructor with error array maps each error code`() { + val errors = arrayOf( + PingOneSDKError(10001, "First"), + PingOneSDKError(10002, "Second"), + ) + val e = PingOneMFAException(errors) + assertEquals(10001, e.internalErrorsList!![0].code) + assertEquals(10002, e.internalErrorsList!![1].code) + } + + @Test + fun `constructor with error array defaults message when first error message is null`() { + val errors = arrayOf(PingOneSDKError(10003, null)) + println("SDK error message: ${errors[0].message}") + val e = PingOneMFAException(errors) + assertEquals("Unknown error", e.message) + } + + // ── PingOneMFAException is-a Exception ───────────────────────────────────── + + @Test + fun `PingOneMFAException is subtype of Exception`() { + val e: Exception = PingOneMFAException("test") + assertTrue(e is PingOneMFAException) + } +} 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..4fa5b73a3 --- /dev/null +++ b/pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/commons/PingOneMFATest.kt @@ -0,0 +1,573 @@ +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() as PingOneMFAException).internalErrorsList?.get(0)?.code == 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() as PingOneMFAException).internalErrorsList?.get(0)?.code == 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() as PingOneMFAException).internalErrorsList?.get(0)?.code == 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()!!.first + 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() as PingOneMFAException).internalErrorsList?.get(0)?.message!!.contains("mockedError") } + } + + @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()!! as PingOneMFAException).internalErrorsList?.get(0)?.code == 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() as PingOneMFAException).internalErrorsList?.get(0)?.code == 10003 } + verify { + PingOne.processRemoteNotification(mockContext, mockRemoteMessage, any()) + } + } + + @Test + fun `collectPush returns success with null when both notificationObject and error are null`() = runTest { + // Both-null means the message was not a PingOne MFA push — no-op success. + every { + PingOne.processRemoteNotification(any(), any(), any()) + } answers { + val callback = arg(2) + callback.onComplete(null, null) + } + val result = PingOneMFA.processRemoteNotification(mockRemoteMessage) + assertTrue(result.isSuccess) + assertEquals(null, result.getOrNull()) + } + + @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("mockedError") } + } + + @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..db1c51f03 --- /dev/null +++ b/pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/push/PushNotificationTest.kt @@ -0,0 +1,226 @@ +/* + * 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 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 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() as PingOneMFAException).internalErrorsList?.get(0)?.code == 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() as PingOneMFAException).internalErrorsList?.get(0)?.code == 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(push.notificationObject.numberMatchingOptions == null || push.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..9389e7b00 --- /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 for required fields and null for optional`() { + val json = """ + { + "NA": { + "users": [{ "id": "u1", "username": "jdoe" }] + } + } + """.trimIndent() + + val account = parser.parseAccounts(json).first() + + assertEquals("", account.environment) + assertEquals("", account.deviceId) + assertEquals(null, account.name) + assertEquals(null, 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(null, 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/pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/util/ErrorParserTest.kt b/pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/util/ErrorParserTest.kt new file mode 100644 index 000000000..d39c9fc5e --- /dev/null +++ b/pingonemfa/src/test/kotlin/com/pingidentity/pingonemfa/util/ErrorParserTest.kt @@ -0,0 +1,131 @@ +/* + * 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.pingidentity.pingidsdkv2.PingOneSDKError +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ErrorParserTest { + + private val parser = ErrorParser + + // ── fromPingOneSDKError ──────────────────────────────────────────────────── + + @Test + fun `fromPingOneSDKError returns null when input is null`() { + val result = parser.fromPingOneSDKError(null) + + assertNull(result) + } + + @Test + fun `fromPingOneSDKError maps code and message`() { + val sdkError = PingOneSDKError(10001, "Authentication failed") + + val result = parser.fromPingOneSDKError(sdkError) + + assertNotNull(result) + assertEquals(1, result.size) + assertEquals(10001, result.first().code) + assertEquals("Authentication failed", result.first().message) + } + + @Test + fun `fromPingOneSDKError returns empty userInfo when SDK userInfo is null`() { + val sdkError = PingOneSDKError(10002, "Device not paired") + // PingOneSDKError.userInfo is initialised to an empty HashMap by default; + // we guard against null defensively and normalise to emptyMap. + + val result = parser.fromPingOneSDKError(sdkError) + + assertNotNull(result) + assertTrue(result.first().userInfo.isEmpty()) + } + + @Test + fun `fromPingOneSDKError preserves userInfo key value pairs`() { + val sdkError = PingOneSDKError(10003, "Push token invalid") + sdkError.userInfo["requestId"] = "abc-123" + sdkError.userInfo["region"] = "NA" + + val result = parser.fromPingOneSDKError(sdkError) + + assertNotNull(result) + assertEquals("abc-123", result.first().userInfo["requestId"]) + assertEquals("NA", result.first().userInfo["region"]) + } + + @Test + fun `fromPingOneSDKError returns list with exactly one element`() { + val sdkError = PingOneSDKError(10004, "Timeout") + + val result = parser.fromPingOneSDKError(sdkError) + + assertNotNull(result) + assertEquals(1, result.size) + } + + // ── fromPingOneSDKErrors ─────────────────────────────────────────────────── + + @Test + fun `fromPingOneSDKErrors returns null when input is null`() { + val result = parser.fromPingOneSDKErrors(null) + + assertNull(result) + } + + @Test + fun `fromPingOneSDKErrors returns empty list for empty array`() { + val result = parser.fromPingOneSDKErrors(emptyArray()) + + assertNotNull(result) + assertTrue(result.isEmpty()) + } + + @Test + fun `fromPingOneSDKErrors maps each error in the array`() { + val errors = arrayOf( + PingOneSDKError(10001, "First error"), + PingOneSDKError(10002, "Second error"), + ) + + val result = parser.fromPingOneSDKErrors(errors) + + assertNotNull(result) + assertEquals(2, result.size) + assertEquals(10001, result[0].code) + assertEquals("First error", result[0].message) + assertEquals(10002, result[1].code) + assertEquals("Second error", result[1].message) + } + + @Test + fun `fromPingOneSDKErrors preserves userInfo for each error`() { + val first = PingOneSDKError(10001, "err1").also { it.userInfo["k1"] = "v1" } + val second = PingOneSDKError(10002, "err2").also { it.userInfo["k2"] = "v2" } + + val result = parser.fromPingOneSDKErrors(arrayOf(first, second)) + + assertNotNull(result) + assertEquals("v1", result[0].userInfo["k1"]) + assertEquals("v2", result[1].userInfo["k2"]) + } + + @Test + fun `fromPingOneSDKErrors returns flat list for single-element array`() { + val result = parser.fromPingOneSDKErrors(arrayOf(PingOneSDKError(10005, "One"))) + + assertNotNull(result) + assertEquals(1, result.size) + assertEquals(10005, result.first().code) + } +} 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 e00367933..ba90c516f 100644 --- a/samples/pingsampleapp/build.gradle.kts +++ b/samples/pingsampleapp/build.gradle.kts @@ -100,6 +100,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..102287a38 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,26 @@ 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") + if (it == null) { + diagnosticLogger.d("PingOne MFA push notification is silent, ignoring") + return@onSuccess + } + // 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 +207,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 c60ecca8a..1d4b3ae49 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 @@ -28,6 +28,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.Aod import androidx.compose.material.icons.filled.ChevronRight @@ -36,6 +37,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 @@ -43,6 +45,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 @@ -103,6 +106,10 @@ fun HomeApp( onDeviceIdClick : () -> Unit, onAuthTestScreenClick : () -> Unit, onAuthMigrationClick : () -> Unit, + onPingOneAccountsClick : () -> Unit, + onPingOneOTPClick : () -> Unit, + onPingOnePayloadClick : () -> Unit, + onPingOneQrScannerClick : () -> Unit, onDeviceAuthorizationGrantClick : () -> Unit, ) { var deviceId by remember { mutableStateOf("Loading Device ID...") } @@ -323,6 +330,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), @@ -507,6 +551,10 @@ fun PreviewHomeApp() { onDeviceIdClick = {}, onAuthTestScreenClick = {}, onAuthMigrationClick = {}, + onPingOneAccountsClick = {}, + onPingOneOTPClick = {}, + onPingOnePayloadClick = {}, + onPingOneQrScannerClick = {}, onDeviceAuthorizationGrantClick = {}, ) } 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 1ecbeed4c..13c87b52a 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 @@ -60,6 +60,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 @@ -104,6 +108,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" const val DEVICE_AUTHORIZATION_GRANT = "device_authorization_grant" const val DAVINCI_DEVICE_APPROVE = "davinci_device_approve?uri={uri}" const val JOURNEY_DEVICE_APPROVAL = "journey_device_approval?uri={uri}" @@ -188,6 +196,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) + }, onDeviceAuthorizationGrantClick = { navController.navigate(Route.DEVICE_AUTHORIZATION_GRANT) } @@ -554,6 +574,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() } + ) + } composable(Route.DEVICE_AUTHORIZATION_GRANT) { DeviceAuthorizationGrantScreen( 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..15abae297 --- /dev/null +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/PingOneMFAViewModel.kt @@ -0,0 +1,166 @@ +/* + * 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.pingonemfa.commons.PingOneMFAException +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, errors) -> + diagnosticLogger.i("Successfully loaded PingOne MFA accounts: ${accounts.size}") + _state.update { it.copy(isLoadingAccounts = false, accounts = accounts) } + if (errors != null) { + diagnosticLogger.w("Device info loaded with partial errors:") + errors.forEach { error -> + diagnosticLogger.w("code=${error.code} message=${error.message} userInfo=${error.userInfo}") + } + } + } + .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 as? PingOneMFAException)?.internalErrorsList?.any { it.code == 10008 } == 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 { + // Tick down one second at a time. If the code is already at 0 when this + // function is called (e.g. the server returned an already-expired OTP), we + // still wait at least one tick before re-fetching to avoid a tight loop. + do { + delay(1_000) + _state.update { it.copy(otpSecondsRemaining = maxOf(0, it.otpSecondsRemaining - 1)) } + } while (_state.value.otpSecondsRemaining > 0) + 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..968227a8a --- /dev/null +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOneMFAAccountsScreen.kt @@ -0,0 +1,174 @@ +/* + * 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.Column +import androidx.compose.foundation.layout.Row +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.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.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.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +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.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.BackNavigationTopAppBar +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), + 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 = { + BackNavigationTopAppBar( + title = stringResource(R.string.text_pingone_mfa_screen_accounts_title), + onBackClick = onBack, + ) + }, + 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..ffa5d76df --- /dev/null +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOneOTPScreen.kt @@ -0,0 +1,146 @@ +/* + * 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.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.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.BackNavigationTopAppBar +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), + 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 = { + BackNavigationTopAppBar( + title = stringResource(R.string.text_pingone_mfa_screen_otp_title), + onBackClick = onBack, + ) + }, + 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..bf4a69e31 --- /dev/null +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOnePayloadScreen.kt @@ -0,0 +1,169 @@ +/* + * 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.content.ClipData +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.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.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.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard +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.BackNavigationTopAppBar +import com.pingidentity.samples.pingsampleapp.authenticator.ui.components.LoadingIndicator +import com.pingidentity.samples.pingsampleapp.pingonemfa.PingOneMFAViewModel +import kotlinx.coroutines.launch + +/** + * 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), + 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 = { + BackNavigationTopAppBar( + title = stringResource(R.string.text_pingone_mfa_screen_payload_title), + onBackClick = onBack, + ) + }, + 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..eddcc7182 --- /dev/null +++ b/samples/pingsampleapp/src/main/java/com/pingidentity/samples/pingsampleapp/pingonemfa/ui/PingOnePushNotificationScreen.kt @@ -0,0 +1,195 @@ +/* + * 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.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +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.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.authenticator.ui.components.BackNavigationTopAppBar +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 = { + BackNavigationTopAppBar( + title = stringResource(R.string.text_pingone_mfa_screen_push_title), + onBackClick = onFinish, + ) + }, + ) { 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 b2689a591..f6c5c1b78 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 @@ -44,6 +45,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 @@ -96,6 +149,7 @@ Code copied to clipboard Error OK + Retry Copy New Code Generate Code diff --git a/settings.gradle.kts b/settings.gradle.kts index 0d17670b3..390c25ee0 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -65,5 +65,6 @@ include(":mfa:fido") include(":mfa:binding") include(":mfa:binding-ui") include(":mfa:binding-migration") +include(":pingonemfa") include(":samples:pingsampleapp") include(":mfa:auth-migration")