From b487fcdb027a9ba96b7d222d12b0ffadf2be8dee Mon Sep 17 00:00:00 2001 From: vibhorgoswami Date: Tue, 13 Oct 2026 11:25:29 -0700 Subject: [PATCH] SDKS-4577: Add conditional UI for Sample apps. --- .../kotlin-journey/gradle/libs.versions.toml | 4 +- .../journey/callback/ContinueNode.kt | 35 +++- .../journey/callback/FidoAuthentication.kt | 171 ++++++++++++++++-- .../journey/callback/NameCallback.kt | 146 +++++++++++++-- .../layout/view_webauthn_username_field.xml | 28 +++ .../journey/src/main/res/values/themes.xml | 2 +- 6 files changed, 349 insertions(+), 37 deletions(-) create mode 100644 android/kotlin-journey/journey/src/main/res/layout/view_webauthn_username_field.xml diff --git a/android/kotlin-journey/gradle/libs.versions.toml b/android/kotlin-journey/gradle/libs.versions.toml index a27ce83b..cc38d33c 100644 --- a/android/kotlin-journey/gradle/libs.versions.toml +++ b/android/kotlin-journey/gradle/libs.versions.toml @@ -3,7 +3,7 @@ activityCompose = "1.9.3" agp = "8.12.2" coreSplashscreen = "1.0.1" datastore = "1.1.7" -ping = "2.0.0" +ping = "2.3.0-beta1" kotlin = "2.2.0" compose = "1.7.4" compose-material3 = "1.3.0" @@ -20,7 +20,7 @@ googleid = "1.1.1" facebook-login = "18.0.3" serialization = "1.9.0" play-services-fido = "21.2.0" -play-services-auth = "1.5.0" +play-services-auth = "1.6.0" [libraries] androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/ContinueNode.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/ContinueNode.kt index 20ced9b7..ecfdb18c 100644 --- a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/ContinueNode.kt +++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/ContinueNode.kt @@ -13,6 +13,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -20,6 +25,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.pingidentity.device.binding.journey.DeviceBindingCallback import com.pingidentity.device.binding.journey.DeviceSigningVerifierCallback import com.pingidentity.device.profile.DeviceProfileCallback +import com.pingidentity.fido.FidoPendingAuthentication import com.pingidentity.fido.journey.FidoAuthenticationCallback import com.pingidentity.fido.journey.FidoRegistrationCallback import com.pingidentity.idp.journey.IdpCallback @@ -60,6 +66,20 @@ fun ContinueNode( ) { var showNext = true + // The conditional-mediation (passkey autofill) request, created by the FIDO callback's + // composable and attached by the username field's composable when the server marks it + // with autocompleteValues ["username","webauthn"] — one shared request, no extra field. + // Keyed on continueNode so the request from a previous node does not carry into the + // next one when this composable stays in the same composition slot. + var pending by remember(continueNode) { mutableStateOf(null) } + + // Cancel the request when superseded or the node leaves composition, so an abandoned + // ceremony cannot submit an outcome after the screen is gone. + DisposableEffect(pending) { + val current = pending + onDispose { current?.cancel() } + } + continueNode.callbacks.forEach { when (it) { is BooleanAttributeInputCallback -> BooleanAttributeInputCallback(it, onNodeUpdated) @@ -94,7 +114,7 @@ fun ContinueNode( showNext = false } - is NameCallback -> NameCallback(it, onNodeUpdated) + is NameCallback -> NameCallback(it, onNodeUpdated, pending) //External IdP is SelectIdpCallback -> SelectIdpCallback(it, onNext) @@ -121,7 +141,18 @@ fun ContinueNode( } is FidoAuthenticationCallback -> { - FidoAuthentication(it, onNext) + // A sibling NameCallback marked with "webauthn" in autocompleteValues is + // the conditional-mediation target; without one (and without the manual + // button) the composable falls back to launching the modal directly. + FidoAuthentication( + it, + onNext, + { pending = it }, + pending, + continueNode.callbacks + .filterIsInstance() + .any { name -> name.autocompleteValues.contains("webauthn") } + ) showNext = false } diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/FidoAuthentication.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/FidoAuthentication.kt index f6cab53f..b7d2687a 100644 --- a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/FidoAuthentication.kt +++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/FidoAuthentication.kt @@ -10,43 +10,190 @@ package com.pingidentity.samples.journeyapp.journey.callback import android.util.Log import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.pingidentity.fido.FidoPendingAuthentication +import com.pingidentity.fido.FidoPendingAuthenticationCancelledException +import com.pingidentity.fido.isConditionalMediationSupported import com.pingidentity.fido.journey.FidoAuthenticationCallback import kotlinx.coroutines.launch +/** + * Renders the FIDO conditional-mediation passkey support for a Journey node: creates the + * pending request (published via [onPending] so the regular username field can attach it) + * and provides the modal fallback button. This composable renders **no** username field of + * its own — the passkey suggestions surface inline on the regular field marked with + * autocompleteValues ["username","webauthn"] by the server. + * + * @param hasConditionalTarget Whether a sibling NameCallback on this node is marked as the + * passkey target ("webauthn" in autocompleteValues). When false there is no field to attach + * the pending request to, so it is not created. + */ @Composable fun FidoAuthentication( callback: FidoAuthenticationCallback, onNext: () -> Unit, + onPending: (FidoPendingAuthentication?) -> Unit, + pending: FidoPendingAuthentication?, + hasConditionalTarget: Boolean, ) { val currentOnCompleted by rememberUpdatedState(onNext) + val coroutineScope = rememberCoroutineScope() - Box( - modifier = Modifier - .fillMaxSize() - .wrapContentSize(Alignment.Center) - ) { - CircularProgressIndicator() - LaunchedEffect(true) { - launch { - callback.authenticate().onSuccess { - currentOnCompleted() - }.onFailure { + // One modal ceremony at a time: repeated taps while a ceremony runs would start + // overlapping ceremonies that can both publish pending requests and both submit the + // node. The button is disabled for the duration. + var modalInProgress by remember { mutableStateOf(false) } + + // Modal ceremony (Credential Manager routing — see the routing note at the button) with + // post-failure recovery: on failure the user stays on the page and, when a conditional + // target exists, a fresh pending request replaces the one the modal superseded, so + // passkey autofill on the username field keeps working after a failed attempt. + val performModalAuthentication: suspend () -> Unit = { + modalInProgress = true + try { + callback.authenticate { useFido2ApiClient = false } + .onSuccess { currentOnCompleted() } + .onFailure { Log.e( "Fido2Authentication", "Failed to Authenticate", it ) + if (hasConditionalTarget) { + callback.pendingAuthenticate { useFido2ApiClient = false } + .onSuccess { onPending(it) } + .onFailure { retryError -> + Log.e( + "Fido2Authentication", + "Failed to re-create pending request", + retryError + ) + onPending(null) + // With the button disabled there is nothing left on the node + // (no button, Next hidden, no autofill) — submit the error + // outcome handleError already wrote, like the unsupported-OS + // branch does. + if (!callback.manualButtonEnabled) currentOnCompleted() + } + } + } + } finally { + modalInProgress = false + } + } + + if (isConditionalMediationSupported && (hasConditionalTarget || callback.manualButtonEnabled)) { + if (hasConditionalTarget) { + // Build the pending request up front; the username field's composable attaches it + // to the regular field, so suggestions appear when the user focuses it. If setup + // fails, fall back to the modal flow below (createPending nulls `pending` via + // onPending(null)) — otherwise a node without manualButtonEnabled would have no + // authentication action at all, since ContinueNode hides Next for this callback. + LaunchedEffect(callback) { + callback.pendingAuthenticate { useFido2ApiClient = false } + .onSuccess { onPending(it) } + .onFailure { + Log.e( + "Fido2Authentication", + "Failed to create pending request", + it + ) + onPending(null) + if (!callback.manualButtonEnabled) { + performModalAuthentication() + } + } + } + + // Deliver the outcome when the user picks a suggestion. A dismissed suggestion + // sheet never completes the await, and lifecycle cancellations (dispose, + // supersede) leave the node as-is — the modal fallback below stays visible for + // the dismissed case. Any *other* failure means the SDK has already recorded the + // error outcome but nothing would advance the node (Next is hidden for this + // callback), so submit to keep the journey moving. + LaunchedEffect(pending) { + val result = pending?.await() ?: return@LaunchedEffect + result.onSuccess { currentOnCompleted() + }.onFailure { + Log.e( + "Fido2Authentication", + "Pending authentication failed", + it + ) + if (it !is FidoPendingAuthenticationCancelledException) { + currentOnCompleted() + } + } + } + } + + // Modal fallback: covers dismissed suggestions and failed/pending-setup-failed + // conditional starts. Shown only when the server's WebAuthn node has + // "Authentication Button" enabled (manualButtonEnabled). A failed modal attempt (e.g. + // a transient Google Play services disconnection) keeps the user on the page — but + // authenticate() superseded the attached pending request (the SDK cancels it before + // the modal ceremony), so a fresh pending request is published on failure: without + // it the EditText would hold a cancelled request whose ceremony response is dropped, + // and passkey autofill on the field would silently stop working. + // + // Routing: this journey is the conditional-UI one — the payload carries no rpId + // (_relyingPartyId is empty) and no allowCredentials, which the Google Play Services + // FIDO2 API requires (its process crashes on the empty rpId, surfacing as a GMS + // connection suspension / ApiException 20). The modal button therefore forces the + // Credential Manager path, which handles the empty rpId correctly. + if (callback.manualButtonEnabled) { + Button( + modifier = Modifier.padding(4.dp), + enabled = !modalInProgress, + onClick = { + coroutineScope.launch { + performModalAuthentication() + } + } + ) { + Text("Use a passkey") + } + } + } else { + // No conditional surface usable on this node (unsupported OS, or neither a marked + // username field nor the manual button): launch the modal ceremony directly so the + // node stays reachable. + Box( + modifier = Modifier + .fillMaxSize() + .wrapContentSize(Alignment.Center) + ) { + CircularProgressIndicator() + LaunchedEffect(true) { + launch { + callback.authenticate().onSuccess { + currentOnCompleted() + }.onFailure { + Log.e( + "Fido2Authentication", + "Failed to Authenticate", + it + ) + currentOnCompleted() + } } } } } -} \ No newline at end of file +} diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/NameCallback.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/NameCallback.kt index 00447624..2ed4c13a 100644 --- a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/NameCallback.kt +++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/NameCallback.kt @@ -7,50 +7,156 @@ package com.pingidentity.samples.journeyapp.journey.callback +import android.util.Log +import android.view.LayoutInflater +import android.view.ViewTreeObserver +import android.view.autofill.AutofillManager import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.view.doOnAttach +import androidx.core.view.doOnDetach +import androidx.core.widget.doOnTextChanged +import androidx.credentials.pendingGetCredentialRequest +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +import com.pingidentity.fido.FidoPendingAuthentication import com.pingidentity.journey.callback.NameCallback +import com.pingidentity.samples.journeyapp.R +/** + * Renders the regular username field. When the server marks the field for conditional + * mediation (autocompleteValues contains "webauthn"), the field is rendered View-backed — + * the androidx pendingGetCredentialRequest is View-only — and carries the pending passkey + * request, so suggestions surface inline on this regular field with no separate username view. + */ @Composable fun NameCallback( field: NameCallback, onNodeUpdated: () -> Unit, + pending: FidoPendingAuthentication? = null, ) { var text by remember(field) { mutableStateOf(field.name) } - Row( - modifier = - Modifier - .padding(4.dp) - .fillMaxWidth(), - ) { - // var text by rememberSaveable { mutableStateOf("") } + // WebAuthn conditional mediation is a View-only androidx API (pendingGetCredentialRequest): + // when the server marks this field as the passkey target, render it from a View layout + // inflated through Material's normal XML path (programmatic construction collapses). + if (field.autocompleteValues.contains("webauthn")) { + Row( + modifier = + Modifier + .padding(4.dp) + .fillMaxWidth(), + ) { + Spacer(modifier = Modifier.weight(1f, true)) - Spacer(modifier = Modifier.weight(1f, true)) + key(field) { + AndroidView( + modifier = Modifier.width(280.dp), + factory = { context -> + LayoutInflater.from(context) + .inflate(R.layout.view_webauthn_username_field, null, false) + .also { layout -> + layout.findViewById( + R.id.webauthn_username_edit + ).apply { + setText(text) + doOnTextChanged { value, _, _, _ -> + text = value?.toString().orEmpty() + field.name = text + onNodeUpdated() + } + // Re-arm passkey autofill when the window regains focus + // while this field is focused. Registered on attach and + // removed on detach: the listener holds the detached + // EditText, so leaving it registered on the window's + // shared ViewTreeObserver (key(field) recreates this view + // for every new node) would leak it and let a stale + // listener postDelayed onto a dead view. + var pendingRunnable: Runnable? = null + val focusListener = + object : ViewTreeObserver.OnWindowFocusChangeListener { + override fun onWindowFocusChanged( + hasFocus: Boolean, + ) { + // Only when the app window gains focus back AND + // this field is (still) the focused view. + if (!hasFocus || !isFocused) return + pendingRunnable = Runnable { + Log.d( + "FidoRearm", + "re-arm: clear+set request, " + + "requestAutofill", + ) + context.getSystemService( + AutofillManager::class.java + )?.let { afm -> + afm.cancel() + afm.requestAutofill(this@apply) + } + } + postDelayed(pendingRunnable, 300) + } + } + doOnAttach { + it.viewTreeObserver + .addOnWindowFocusChangeListener(focusListener) + } + doOnDetach { view -> + view.viewTreeObserver + .removeOnWindowFocusChangeListener(focusListener) + pendingRunnable?.let(view::removeCallbacks) + } + } + layout.findViewById( + R.id.webauthn_username_layout + ).hint = field.prompt + } + }, + update = { layout -> + layout.findViewById(R.id.webauthn_username_edit) + .pendingGetCredentialRequest = pending?.request + }, + ) + } + Spacer(modifier = Modifier.weight(1f, true)) + } + } else { + Row( + modifier = + Modifier + .padding(4.dp) + .fillMaxWidth(), + ) { + Spacer(modifier = Modifier.weight(1f, true)) - OutlinedTextField( - modifier = Modifier.wrapContentWidth(Alignment.CenterHorizontally), - value = text, - onValueChange = { value -> - text = value - field.name = value - onNodeUpdated() - }, - label = { androidx.compose.material3.Text(field.prompt) }, - ) - Spacer(modifier = Modifier.weight(1f, true)) + OutlinedTextField( + modifier = Modifier.wrapContentWidth(Alignment.CenterHorizontally), + value = text, + onValueChange = { value -> + text = value + field.name = value + onNodeUpdated() + }, + label = { Text(field.prompt) }, + ) + Spacer(modifier = Modifier.weight(1f, true)) + } } -} \ No newline at end of file +} diff --git a/android/kotlin-journey/journey/src/main/res/layout/view_webauthn_username_field.xml b/android/kotlin-journey/journey/src/main/res/layout/view_webauthn_username_field.xml new file mode 100644 index 00000000..ac803537 --- /dev/null +++ b/android/kotlin-journey/journey/src/main/res/layout/view_webauthn_username_field.xml @@ -0,0 +1,28 @@ + + + + + + + + diff --git a/android/kotlin-journey/journey/src/main/res/values/themes.xml b/android/kotlin-journey/journey/src/main/res/values/themes.xml index db01e28f..c3f32b91 100644 --- a/android/kotlin-journey/journey/src/main/res/values/themes.xml +++ b/android/kotlin-journey/journey/src/main/res/values/themes.xml @@ -13,7 +13,7 @@