diff --git a/android/kotlin-journey/.gitignore b/android/kotlin-journey/.gitignore
new file mode 100644
index 00000000..aa724b77
--- /dev/null
+++ b/android/kotlin-journey/.gitignore
@@ -0,0 +1,15 @@
+*.iml
+.gradle
+/local.properties
+/.idea/caches
+/.idea/libraries
+/.idea/modules.xml
+/.idea/workspace.xml
+/.idea/navEditor.xml
+/.idea/assetWizardSettings.xml
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
diff --git a/android/kotlin-journey/README.md b/android/kotlin-journey/README.md
new file mode 100644
index 00000000..aae4a468
--- /dev/null
+++ b/android/kotlin-journey/README.md
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+# Journey app using Kotlin
+
+Ping provides these Android samples to help demonstrate SDK functionality/implementation. They are provided "as is" and are not official products of Ping and are not officially supported.
+
+This repository contains an example Android project written in Kotlin making use of the SDK. The sample supports the OOTB Journey Login flow.
+
+# Introduction
+
+This sample application demonstrates how to integrate the ForgeRock Android SDK into a basic application. The sample app includes examples of the following:
+
+- OOTB Journey Login flow
+
+- Displaying authenticated user status.
+
+- Displaying authenticated token.
+
+- Ability to logout existing user and restart the login flow.
+
+- Built with modern Android components (Jetpack Compose, Kotlin).
+
+## Requirements
+
+- Android Studio: Latest version recommended
+- Ping AIC
+- Android API Level: 28 (Android 9.0) or higher
+
+## Getting Started
+
+To try out the Journey Android SDK sample, perform these steps:
+1. Configure Ping Services
+ Ensure that you registered an OAuth 2.0 application for native mobile apps in AIC. More details in this [documentation](https://backstage.forgerock.com/docs/sdks/latest/sdks/serverconfiguration/pingone/create-oauth2-client.html).
+
+2. Clone this repo:
+
+ ```
+ git clone https://github.com/ForgeRock/sdk-sample-apps.git
+ ```
+3. Open the Android sample project(kotlin-journey) in [Android Studio](https://developer.android.com/studio).
+4. Open the `EnvViewModel.kt` file within the project.
+5. Locate the TODO and replace the placeholder strings in the Oidc module configuration with the values of your registered OAuth 2.0 application.
+ - You can add multiple configurations as shown with `testConfig` and `prodConfig` depending on you Oidc module configuration.
+6. Go to `journey\build.gradle.kts` and update the value of `appRedirectUriScheme` with the redirect URI schema from your OIDC configuration.
+7. Connect an Android device or emulator.
+8. On the **Run** menu, click **Run 'app'**.
+
+## Additional Resources
+Ping SDK Documentation: https://docs.pingidentity.com/sdks/latest/sdks/index.html
diff --git a/android/kotlin-journey/build.gradle.kts b/android/kotlin-journey/build.gradle.kts
new file mode 100644
index 00000000..e95c9f9c
--- /dev/null
+++ b/android/kotlin-journey/build.gradle.kts
@@ -0,0 +1,12 @@
+/*
+ * 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.
+ */
+
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {
+ alias(libs.plugins.androidApplication) apply false
+ alias(libs.plugins.kotlinAndroid) apply false
+}
\ No newline at end of file
diff --git a/android/kotlin-journey/gradle.properties b/android/kotlin-journey/gradle.properties
new file mode 100644
index 00000000..58bafd21
--- /dev/null
+++ b/android/kotlin-journey/gradle.properties
@@ -0,0 +1,17 @@
+#
+# 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.
+#
+
+#Gradle
+org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8 -Dkotlin.daemon.jvm.options\="-Xmx2048M"
+org.gradle.caching=true
+
+#Kotlin
+kotlin.code.style=official
+
+#Android
+android.useAndroidX=true
+android.nonTransitiveRClass=true
\ No newline at end of file
diff --git a/android/kotlin-journey/gradle/libs.versions.toml b/android/kotlin-journey/gradle/libs.versions.toml
new file mode 100644
index 00000000..dcbeb015
--- /dev/null
+++ b/android/kotlin-journey/gradle/libs.versions.toml
@@ -0,0 +1,62 @@
+[versions]
+activityCompose = "1.9.3"
+agp = "8.12.2"
+coreSplashscreen = "1.0.1"
+datastore = "1.1.7"
+ping = "2.0.0-beta1"
+kotlin = "2.2.0"
+compose = "1.7.4"
+compose-material3 = "1.3.0"
+coreKtx = "1.13.1"
+appcompat = "1.7.0"
+material = "1.12.0"
+materialIconsExtended = "1.7.4"
+navVersion = "2.8.3"
+composeBom = "2024.10.00"
+coilCompose = "2.4.0"
+coilSvg = "2.4.0"
+androidx-credentials = "1.5.0"
+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"
+
+[libraries]
+androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
+androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" }
+androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
+androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "materialIconsExtended" }
+androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navVersion" }
+androidx-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
+compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" }
+compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "compose" }
+compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "compose-material3" }
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
+kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
+journey = { module = "com.pingidentity.sdks:journey", version.ref = "ping" }
+external-idp = { module = "com.pingidentity.sdks:external-idp", version.ref = "ping" }
+protect = { module = "com.pingidentity.sdks:protect", version.ref = "ping" }
+fido = { module = "com.pingidentity.sdks:fido", version.ref = "ping" }
+binding = { module = "com.pingidentity.sdks:binding", version.ref = "ping" }
+binding-ui = { module = "com.pingidentity.sdks:binding-ui", version.ref = "ping" }
+binding-migration = { module = "com.pingidentity.sdks:binding-migration", version.ref = "ping" }
+device-profile = { module = "com.pingidentity.sdks:device-profile", version.ref = "ping" }
+device-client = { module = "com.pingidentity.sdks:device-client", version.ref = "ping" }
+recaptcha-enterprise = { module = "com.pingidentity.sdks:recaptcha-enterprise", version.ref = "ping" }
+play-services-fido = { module = "com.google.android.gms:play-services-fido", version.ref = "play-services-fido" }
+play-services-auth = { module = "androidx.credentials:credentials-play-services-auth", version.ref = "play-services-auth" }
+material = { group = "com.google.android.material", name = "material", version.ref = "material" }
+androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
+coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coilCompose" }
+coil-svg = { module = "io.coil-kt:coil-svg", version.ref = "coilSvg" }
+androidx-credentials-play-services-auth = { group = "androidx.credentials", name = "credentials-play-services-auth", version.ref = "androidx-credentials" }
+googleid = { group = "com.google.android.libraries.identity.googleid", name = "googleid", version.ref = "googleid" }
+facebook-login = { module = "com.facebook.android:facebook-login", version.ref = "facebook-login" }
+
+[plugins]
+androidApplication = { id = "com.android.application", version.ref = "agp" }
+kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+
diff --git a/android/kotlin-journey/gradle/wrapper/gradle-wrapper.properties b/android/kotlin-journey/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 00000000..05541313
--- /dev/null
+++ b/android/kotlin-journey/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,13 @@
+#
+# 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.
+#
+
+#Thu Dec 04 13:38:12 PST 2025
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/android/kotlin-journey/gradlew b/android/kotlin-journey/gradlew
new file mode 100755
index 00000000..9ce437dc
--- /dev/null
+++ b/android/kotlin-journey/gradlew
@@ -0,0 +1,176 @@
+#!/usr/bin/env sh
+
+#
+# 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.
+#
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=`expr $i + 1`
+ done
+ case $i in
+ 0) set -- ;;
+ 1) set -- "$args0" ;;
+ 2) set -- "$args0" "$args1" ;;
+ 3) set -- "$args0" "$args1" "$args2" ;;
+ 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=`save "$@"`
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+exec "$JAVACMD" "$@"
diff --git a/android/kotlin-journey/gradlew.bat b/android/kotlin-journey/gradlew.bat
new file mode 100644
index 00000000..107acd32
--- /dev/null
+++ b/android/kotlin-journey/gradlew.bat
@@ -0,0 +1,89 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/android/kotlin-journey/journey/.gitignore b/android/kotlin-journey/journey/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/android/kotlin-journey/journey/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/android/kotlin-journey/journey/build.gradle.kts b/android/kotlin-journey/journey/build.gradle.kts
new file mode 100644
index 00000000..765b9b6c
--- /dev/null
+++ b/android/kotlin-journey/journey/build.gradle.kts
@@ -0,0 +1,132 @@
+/*
+ * 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.
+ */
+
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
+/*
+ * Copyright (c) 2024 - 2025 Ping Identity Corporation. All rights reserved.
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license. See the LICENSE file for details.
+ */
+
+plugins {
+ alias(libs.plugins.androidApplication)
+ alias(libs.plugins.kotlinAndroid)
+ alias(libs.plugins.compose.compiler)
+}
+
+android {
+ namespace = "com.pingidentity.samples.journeyapp"
+ compileSdk = 36
+
+ defaultConfig {
+ applicationId = "com.pingidentity.samples.journeyapp"
+ minSdk = 29
+ targetSdk = 36
+ versionCode = 1
+ versionName = "1.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ manifestPlaceholders["appRedirectUriScheme"] = "myapp" // TODO update this with redirect Uri schema.
+
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro",
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ kotlin {
+ compilerOptions {
+ jvmTarget.set(JvmTarget.JVM_17)
+ }
+ }
+
+ signingConfigs {
+ getByName("debug") {
+ storeFile = file("debug.jks")
+ storePassword = "android"
+ keyAlias = "androiddebugkey"
+ keyPassword = "android"
+ }
+ }
+
+ buildFeatures {
+ compose = true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion = "1.5.9"
+ }
+}
+
+dependencies {
+
+ val composeBom = platform(libs.androidx.compose.bom)
+ implementation(composeBom)
+
+ // Journey SDK
+ implementation(libs.journey)
+
+ // Social Login
+ implementation(libs.external.idp)
+
+ // Protect
+ implementation(libs.protect)
+
+ //Fido
+ implementation(libs.fido)
+ implementation(libs.play.services.fido)
+ implementation(libs.play.services.auth)
+
+ //Device Binding
+ implementation(libs.binding)
+ implementation(libs.binding.ui)
+ implementation(libs.binding.migration)
+
+ //Device Profile
+ implementation(libs.device.profile)
+ implementation(libs.device.client)
+
+ //Recaptcha
+ implementation(libs.recaptcha.enterprise)
+
+ //To enable Native Google Sign-In, fall back to browser if Google SDK is not available.
+ implementation(libs.googleid)
+ implementation(libs.androidx.credentials.play.services.auth)
+ implementation(libs.facebook.login)
+
+ implementation(libs.androidx.datastore.preferences)
+ implementation(libs.kotlinx.serialization.json)
+
+ implementation(libs.compose.ui)
+ implementation(libs.compose.material3)
+ implementation(libs.compose.foundation)
+
+ // Android Studio Preview support
+ implementation(libs.androidx.core.splashscreen)
+ debugImplementation(libs.androidx.ui.tooling)
+ implementation(libs.androidx.activity.compose)
+
+ implementation(libs.androidx.navigation.compose)
+ implementation(libs.androidx.material.icons.extended)
+
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.appcompat)
+ implementation(libs.material)
+
+ implementation(libs.coil.compose)
+ implementation(libs.coil.svg)
+}
diff --git a/android/kotlin-journey/journey/debug.jks b/android/kotlin-journey/journey/debug.jks
new file mode 100644
index 00000000..ecf2e8ec
Binary files /dev/null and b/android/kotlin-journey/journey/debug.jks differ
diff --git a/android/kotlin-journey/journey/proguard-rules.pro b/android/kotlin-journey/journey/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/android/kotlin-journey/journey/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/android/kotlin-journey/journey/src/main/AndroidManifest.xml b/android/kotlin-journey/journey/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..71a6b91a
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/AndroidManifest.xml
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/Alert.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/Alert.kt
new file mode 100644
index 00000000..9677f9fb
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/Alert.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.journeyapp
+
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+
+@Composable
+fun Alert(throwable: Throwable) {
+
+ var showConfirmation by remember {
+ mutableStateOf(true)
+ }
+
+ if (showConfirmation) {
+ AlertDialog(
+ onDismissRequest = { showConfirmation = false },
+ confirmButton = {},
+ dismissButton = {
+ TextButton(onClick = { showConfirmation = false })
+ { Text(text = "Ok") }
+ },
+ text = {
+ Text(text = throwable.toString())
+ }
+ )
+ }
+}
\ No newline at end of file
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/AppDrawer.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/AppDrawer.kt
new file mode 100644
index 00000000..8f7dfc6c
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/AppDrawer.kt
@@ -0,0 +1,130 @@
+/*
+ * 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.journeyapp
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Logout
+import androidx.compose.material.icons.filled.Bolt
+import androidx.compose.material.icons.filled.GeneratingTokens
+import androidx.compose.material.icons.filled.Home
+import androidx.compose.material.icons.filled.Person
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ModalDrawerSheet
+import androidx.compose.material3.NavigationDrawerItem
+import androidx.compose.material3.NavigationDrawerItemDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import com.pingidentity.samples.journeyapp.Destinations.ENV_ROUTE
+import com.pingidentity.samples.journeyapp.Destinations.LAUNCH_ROUTE
+import com.pingidentity.samples.journeyapp.Destinations.TOKEN_ROUTE
+import com.pingidentity.samples.journeyapp.Destinations.USER_INFO
+
+@Composable
+fun AppDrawer(
+ logoutViewModel: LogoutViewModel,
+ navigateTo: (String) -> Unit,
+ closeDrawer: () -> Unit,
+) {
+ val scroll = rememberScrollState(0)
+
+ ModalDrawerSheet(
+ modifier =
+ Modifier
+ .verticalScroll(scroll),
+ ) {
+ Logo(
+ modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
+ )
+ NavigationDrawerItem(
+ label = { Text("Environment") },
+ selected = false,
+ icon = { Icon(Icons.Filled.Home, null) },
+ onClick = {
+ navigateTo(ENV_ROUTE)
+ closeDrawer()
+ },
+ modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
+ )
+ NavigationDrawerItem(
+ label = { Text("Launch Journey") },
+ selected = false,
+ icon = { Icon(Icons.Filled.Bolt, null) },
+ onClick = {
+ navigateTo(LAUNCH_ROUTE)
+ closeDrawer()
+ },
+ modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
+ )
+ NavigationDrawerItem(
+ label = { Text("Show Token") },
+ selected = false,
+ icon = { Icon(Icons.Filled.GeneratingTokens, null) },
+ onClick = {
+ navigateTo(TOKEN_ROUTE)
+ closeDrawer()
+ },
+ modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
+ )
+ NavigationDrawerItem(
+ label = { Text("User Info") },
+ selected = false,
+ icon = { Icon(Icons.Filled.Person, null) },
+ onClick = {
+ navigateTo(USER_INFO)
+ closeDrawer()
+ },
+ modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
+ )
+
+ NavigationDrawerItem(
+ label = { Text("Logout") },
+ selected = false,
+ icon = { Icon(Icons.AutoMirrored.Filled.Logout, null) },
+ onClick = {
+ logoutViewModel.logout {
+ navigateTo(LAUNCH_ROUTE)
+ }
+ closeDrawer()
+ },
+ modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
+ )
+ }
+}
+
+@Composable
+private fun Logo(modifier: Modifier) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .background(colorResource(id = R.color.black))
+ .then(modifier),
+ ) {
+ Icon(
+ painterResource(R.drawable.pingone_advanced_identity_cloud),
+ contentDescription = null,
+ modifier =
+ Modifier
+ .height(100.dp).padding(8.dp)
+ .then(modifier),
+ tint = Color.Unspecified,
+ )
+ }
+}
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/AppNavHost.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/AppNavHost.kt
new file mode 100644
index 00000000..6b29c0f6
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/AppNavHost.kt
@@ -0,0 +1,69 @@
+/*
+ * 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.journeyapp
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.navigation.NavHostController
+import androidx.navigation.NavType
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.navArgument
+import com.pingidentity.samples.journeyapp.env.Env
+import com.pingidentity.samples.journeyapp.journey.Journey
+import com.pingidentity.samples.journeyapp.journey.JourneyRoute
+import com.pingidentity.samples.journeyapp.journey.JourneyViewModel
+import com.pingidentity.samples.journeyapp.token.Token
+import com.pingidentity.samples.journeyapp.userprofile.UserProfile
+import com.pingidentity.samples.journeyapp.userprofile.UserProfileViewModel
+
+
+@Composable
+fun AppNavHost(
+ navController: NavHostController,
+ startDestination: String = Destinations.ENV_ROUTE,
+) {
+ NavHost(
+ navController = navController,
+ startDestination = startDestination,
+ ) {
+ composable(Destinations.ENV_ROUTE) {
+ Env()
+ }
+ composable(Destinations.TOKEN_ROUTE) {
+ Token()
+ }
+ composable(Destinations.USER_INFO) {
+ val userProfileViewModel = viewModel()
+ UserProfile(userProfileViewModel)
+ }
+ composable(Destinations.LAUNCH_ROUTE) {
+ val preferenceViewModel = viewModel(
+ factory = PreferenceViewModel.factory(LocalContext.current)
+ )
+ JourneyRoute(
+ preferenceViewModel = preferenceViewModel,
+ onSubmit = { journeyName ->
+ navController.navigate(Destinations.JOURNEY_ROUTE + "/$journeyName")
+ })
+ }
+ composable(Destinations.JOURNEY_ROUTE + "/{name}", arguments = listOf(
+ navArgument("name") { type = NavType.StringType }
+ )) {
+ it.arguments?.getString("name")?.apply {
+ val journeyViewModel = viewModel(
+ factory = JourneyViewModel.factory(this)
+ )
+ Journey(journeyViewModel) {
+ navController.navigate(Destinations.USER_INFO)
+ }
+ }
+ }
+ }
+}
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/AuthApp.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/AuthApp.kt
new file mode 100644
index 00000000..e011333f
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/AuthApp.kt
@@ -0,0 +1,117 @@
+/*
+ * 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.journeyapp
+
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Logout
+import androidx.compose.material.icons.filled.Bolt
+import androidx.compose.material.icons.rounded.Menu
+import androidx.compose.material3.DrawerValue
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalNavigationDrawer
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.rememberDrawerState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.navigation.NavBackStackEntry
+import androidx.navigation.compose.currentBackStackEntryAsState
+import androidx.navigation.compose.rememberNavController
+import com.pingidentity.samples.journeyapp.theme.AppTheme
+import kotlinx.coroutines.launch
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AuthApp() {
+ AppTheme {
+ val navController = rememberNavController()
+ val drawerState = rememberDrawerState(DrawerValue.Closed)
+ val coroutineScope = rememberCoroutineScope()
+
+ val backStackEntry by navController.currentBackStackEntryAsState()
+ val currentScreen = getTitle(backStackEntry)
+
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background,
+ ) {
+ val logoutViewModel = viewModel()
+
+ ModalNavigationDrawer(
+ drawerContent = {
+ AppDrawer(
+ logoutViewModel = logoutViewModel,
+ navigateTo = { dest -> navController.navigate(dest) },
+ closeDrawer = { coroutineScope.launch { drawerState.close() } },
+ )
+ },
+ drawerState = drawerState,
+ gesturesEnabled = true,
+ ) {
+ Scaffold(
+ topBar = {
+ // to run the animation independently
+ TopAppBar(
+ title = { Text(currentScreen) },
+ navigationIcon = {
+ IconButton(onClick = {
+ coroutineScope.launch {
+ // opens drawer
+ drawerState.open()
+ }
+ }) {
+ Icon(
+ Icons.Rounded.Menu,
+ contentDescription = "MenuButton",
+ )
+ }
+ },
+ actions = {
+ Icon(Icons.Filled.Bolt, "")
+ IconButton(onClick = {
+ logoutViewModel.logout {
+ navController.navigate(Destinations.LAUNCH_ROUTE)
+ }
+ }) {
+ Icon(Icons.AutoMirrored.Filled.Logout, "Logout")
+ }
+ },
+ )
+ },
+ ) { values ->
+ Surface(
+ Modifier
+ .fillMaxSize()
+ .padding(values),
+ ) {
+ AppNavHost(navController = navController)
+ }
+ }
+ }
+ }
+ }
+}
+
+fun getTitle(backStackEntry: NavBackStackEntry?): String {
+ var result = backStackEntry?.destination?.route ?: Destinations.ENV_ROUTE
+ if (result.startsWith(Destinations.JOURNEY_ROUTE)) {
+ result =
+ Destinations.JOURNEY_ROUTE + "- ${backStackEntry?.arguments?.getString("name")}"
+ }
+ return result
+}
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/Destinations.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/Destinations.kt
new file mode 100644
index 00000000..0c66955a
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/Destinations.kt
@@ -0,0 +1,16 @@
+/*
+ * 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.journeyapp
+
+object Destinations {
+ const val ENV_ROUTE = "Environment"
+ const val LAUNCH_ROUTE = "Launch Journey"
+ const val JOURNEY_ROUTE = "Journey"
+ const val TOKEN_ROUTE = "Access Token"
+ const val USER_INFO = "User Info"
+}
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/Error.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/Error.kt
new file mode 100644
index 00000000..41833a39
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/Error.kt
@@ -0,0 +1,42 @@
+/*
+ * 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.journeyapp
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun Error(exception: Exception) {
+ Column(modifier = Modifier
+ .padding(16.dp)
+ .fillMaxWidth()) {
+ Text(text = exception.javaClass.name,
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.titleMedium
+ )
+ Spacer(Modifier.height(8.dp))
+ Text(text = exception.toString(),
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall)
+ }
+}
+
+@Preview
+@Composable
+fun ComposablePreview() {
+ Error(NullPointerException("This is a test"))
+}
\ No newline at end of file
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/Json.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/Json.kt
new file mode 100644
index 00000000..09d2ffc3
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/Json.kt
@@ -0,0 +1,16 @@
+/*
+ * 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.journeyapp
+
+import kotlinx.serialization.json.Json
+
+internal val json: Json = Json {
+ prettyPrint = true
+ ignoreUnknownKeys = true
+ encodeDefaults = true
+}
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/LogoutViewModel.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/LogoutViewModel.kt
new file mode 100644
index 00000000..806d7da9
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/LogoutViewModel.kt
@@ -0,0 +1,24 @@
+/*
+ * 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.journeyapp
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.pingidentity.journey.user
+import com.pingidentity.samples.journeyapp.env.journey
+import kotlinx.coroutines.launch
+
+class LogoutViewModel : ViewModel() {
+ fun logout(onCompleted: () -> Unit) {
+ viewModelScope.launch {
+ //If you are using Journey, you can use the Journey user object to logout
+ journey.user()?.logout()
+ }
+ onCompleted()
+ }
+}
\ No newline at end of file
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/MainActivity.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/MainActivity.kt
new file mode 100644
index 00000000..5055b52a
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/MainActivity.kt
@@ -0,0 +1,32 @@
+/*
+ * 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.journeyapp
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.viewModels
+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
+
+class MainActivity : ComponentActivity() {
+ private val viewModel: MainViewModel by viewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ installSplashScreen().apply {
+ setKeepOnScreenCondition {
+ viewModel.isLoading.value
+ }
+ }
+
+ setContent {
+ AuthApp()
+ }
+ }
+}
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/MainViewModel.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/MainViewModel.kt
new file mode 100644
index 00000000..541155de
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/MainViewModel.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.samples.journeyapp
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.launch
+import kotlin.time.DurationUnit
+import kotlin.time.toDuration
+
+class MainViewModel : ViewModel() {
+
+ var isLoading = MutableStateFlow(true)
+ private set
+
+ init {
+ viewModelScope.launch {
+ delay(2.toDuration(DurationUnit.SECONDS))
+ isLoading.value = false
+
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/PreferenceViewModel.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/PreferenceViewModel.kt
new file mode 100644
index 00000000..988ecb52
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/PreferenceViewModel.kt
@@ -0,0 +1,47 @@
+/*
+ * 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.journeyapp
+
+import android.content.Context
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+
+/**
+ * Should avoid passing context to ViewModel
+ */
+class PreferenceViewModel(context: Context) : ViewModel() {
+
+ private val sharedPreferences = context.getSharedPreferences("JourneyPreferences", Context.MODE_PRIVATE)
+
+ fun saveJourney(journeyName: String) {
+ sharedPreferences.edit().putString("lastJourney", journeyName).apply()
+ }
+
+ fun getLastJourney(): String {
+ return sharedPreferences.getString("lastJourney", "Login")!!
+ }
+
+ fun saveEnv(envName: String) {
+ sharedPreferences.edit().putString("env", envName).apply()
+ }
+
+ fun getLastEnv() : String {
+ return sharedPreferences.getString("env", "localhost")!!
+ }
+
+ companion object {
+ fun factory(
+ context: Context,
+ ): ViewModelProvider.Factory = object : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class): T {
+ return PreferenceViewModel(context.applicationContext) as T
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/Setting.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/Setting.kt
new file mode 100644
index 00000000..26297e69
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/Setting.kt
@@ -0,0 +1,15 @@
+/*
+ * 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.journeyapp
+
+import android.content.Context
+import androidx.datastore.preferences.preferencesDataStore
+
+internal val Context.settingDataStore by preferencesDataStore(
+ name = "settings"
+)
\ No newline at end of file
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/env/Env.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/env/Env.kt
new file mode 100644
index 00000000..b3eac407
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/env/Env.kt
@@ -0,0 +1,84 @@
+/*
+ * 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.journeyapp.env
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.CheckBoxOutlineBlank
+import androidx.compose.material.icons.filled.Done
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.pingidentity.oidc.OidcClientConfig
+import java.net.URL
+
+@Composable
+fun Env(envViewModel: EnvViewModel = viewModel()) {
+
+ LazyColumn(modifier = Modifier) {
+ envViewModel.oidcConfigs.forEach {
+ item {
+ ServerSetting(
+ option = it,
+ envViewModel.current.clientId == it.clientId
+ ) {
+ envViewModel.select(it)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ServerSetting(
+ option: OidcClientConfig,
+ selected: Boolean = false,
+ onServerSelected: (OidcClientConfig) -> Unit
+) {
+ Column {
+ val host = URL(option.discoveryEndpoint).host
+ Row(modifier = Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = "$host\n${option.clientId}",
+ modifier = Modifier
+ .weight(1f)
+ .wrapContentHeight(),
+ style = MaterialTheme.typography.titleMedium
+ )
+ Spacer(Modifier.width(8.dp))
+ SelectServerButton(option, selected, onServerSelected)
+ }
+ HorizontalDivider(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f))
+ }
+}
+
+@Composable
+private fun SelectServerButton(
+ option: OidcClientConfig,
+ selected: Boolean,
+ onServerSelected: (OidcClientConfig) -> Unit
+) {
+ val icon = if (selected) Icons.Filled.Done else Icons.Filled.CheckBoxOutlineBlank
+ IconButton(
+ onClick = { onServerSelected(option) }) {
+ Icon(icon, contentDescription = null)
+ }
+}
\ No newline at end of file
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/env/EnvViewModel.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/env/EnvViewModel.kt
new file mode 100644
index 00000000..4fc58128
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/env/EnvViewModel.kt
@@ -0,0 +1,150 @@
+/*
+ * 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.journeyapp.env
+
+import android.app.Application
+import android.net.Uri
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.core.net.toUri
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.lifecycle.AndroidViewModel
+import com.pingidentity.journey.Journey
+import com.pingidentity.journey.module.Oidc
+import com.pingidentity.journey.user
+import com.pingidentity.logger.Logger
+import com.pingidentity.logger.STANDARD
+import com.pingidentity.oidc.OidcClientConfig
+import com.pingidentity.samples.journeyapp.settingDataStore
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+
+/*
+ * TODO
+ * This configuration allows you to provide 2 different environments: testConfig and prodConfig.
+ * You must provide valid information for the sample app to work.
+ * (If an invalid discoveryEndpoint format is provided, the app may crash)
+ *
+ * The sample app uses the clientId to differentiate the environment.
+ * Use a different clientId for testing.
+ */
+
+val testConfig = Journey {
+ logger = Logger.STANDARD
+
+ serverUrl = ""
+ realm = ""
+ cookie = ""
+ // Oidc as module
+ module(Oidc) {
+ clientId = "", "")
+ redirectUri = ""
+ }
+}
+
+val prodConfig = Journey {
+ logger = Logger.STANDARD
+
+ serverUrl = ""
+ realm = ""
+ cookie = ""
+ // Oidc as module
+ module(Oidc) {
+ clientId = "", "")
+ redirectUri = ""
+ }
+}
+
+var journey = testConfig
+lateinit var redirectUri: Uri //For Social Login redirect parameter using Auth Tab
+
+class EnvViewModel(application: Application) : AndroidViewModel(application) {
+
+ private val servers = listOf(testConfig, prodConfig)
+ val oidcConfigs = listOf(testConfig.oidcConfig(), prodConfig.oidcConfig())
+ private val context = getApplication().applicationContext
+
+ var current by mutableStateOf(testConfig.oidcConfig())
+ private set
+
+ init {
+ CoroutineScope(Dispatchers.IO).launch {
+ current = readConfigFromDataStore()
+ select(current)
+ }
+ }
+
+ fun select(config: OidcClientConfig) {
+
+ val server = servers.firstOrNull { it.oidcConfig().clientId == config.clientId } ?: prodConfig
+
+ journey = server
+
+ val oidcConfig = server.oidcConfig()
+
+ redirectUri = oidcConfig.redirectUri.toUri()
+
+ if (current.clientId != config.clientId) {
+ CoroutineScope(Dispatchers.Default).launch {
+ journey.user()?.logout()
+ }
+ }
+
+ current = oidcConfig
+
+ CoroutineScope(Dispatchers.IO).launch {
+ context.settingDataStore.edit { preferences ->
+ preferences[stringPreferencesKey("clientId")] = config.clientId
+ preferences[stringPreferencesKey("discoveryEndpoint")] = config.discoveryEndpoint
+ preferences[stringPreferencesKey("scopes")] = config.scopes.joinToString(",")
+ preferences[stringPreferencesKey("redirectUri")] = config.redirectUri
+ }
+ }
+ }
+
+ private suspend fun readConfigFromDataStore(): OidcClientConfig {
+ val preferences = context.settingDataStore.data.first()
+
+ val clientId = preferences[stringPreferencesKey("clientId")]
+ val discoveryEndpoint = preferences[stringPreferencesKey("discoveryEndpoint")]
+ val scopes = preferences[stringPreferencesKey("scopes")]?.split(",")?.toMutableSet()
+ val redirectUri = preferences[stringPreferencesKey("redirectUri")]
+
+ return if (clientId != null && discoveryEndpoint != null && scopes != null && redirectUri != null) {
+ config {
+ this.clientId = clientId
+ this.discoveryEndpoint = discoveryEndpoint
+ this.scopes = scopes
+ this.redirectUri = redirectUri
+ }
+ } else {
+ journey.oidcConfig()
+ }
+
+ }
+}
+
+/**
+ * Get the current [OidcClientConfig] from the [Journey] instance.
+ * Cannot use Journey.oidcClientConfig, since it required the Journey state to be initialized.
+ */
+private fun Journey.oidcConfig(): OidcClientConfig {
+ return config.modules.first { it.config is OidcClientConfig }.config as OidcClientConfig
+}
+
+private fun config(block: OidcClientConfig.() -> Unit): OidcClientConfig {
+ return OidcClientConfig().apply(block)
+}
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/Journey.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/Journey.kt
new file mode 100644
index 00000000..04cde664
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/Journey.kt
@@ -0,0 +1,233 @@
+/*
+ * 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.journeyapp.journey
+
+import android.util.Log
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.wrapContentWidth
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Error
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import com.pingidentity.orchestrate.ContinueNode
+import com.pingidentity.orchestrate.ErrorNode
+import com.pingidentity.orchestrate.FailureNode
+import com.pingidentity.orchestrate.SuccessNode
+import com.pingidentity.samples.journeyapp.R
+import com.pingidentity.samples.journeyapp.journey.callback.ContinueNode
+
+@Composable
+fun Journey(
+ journeyViewModel: JourneyViewModel,
+ onSuccess: (() -> Unit)? = null,
+) {
+ BackHandler {
+ journeyViewModel.start()
+ }
+
+ val state by journeyViewModel.state.collectAsState()
+ val loading by journeyViewModel.loading.collectAsState()
+ val currentOnSuccess by rememberUpdatedState(onSuccess)
+
+ Journey(
+ state = state,
+ loading = loading,
+ onNodeUpdated = {
+ journeyViewModel.refresh()
+ },
+ onNext = {
+ journeyViewModel.next(it)
+ },
+ currentOnSuccess,
+ )
+}
+
+@Composable
+fun Journey(
+ state: JourneyState,
+ loading: Boolean,
+ onNodeUpdated: () -> Unit,
+ onNext: (ContinueNode) -> Unit,
+ onSuccess: (() -> Unit)?,
+) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ if (loading) {
+ CircularProgressIndicator()
+ }
+
+ Column(
+ modifier =
+ Modifier
+ .padding(8.dp)
+ .fillMaxSize(),
+ ) {
+ Logo(modifier = Modifier)
+
+ when (val node = state.node) {
+ is ContinueNode -> {
+ Render(node = node, onNodeUpdated) {
+ onNext(node)
+ }
+ }
+
+ is FailureNode -> {
+ Log.e("Journey", node.cause.message, node.cause)
+ Render(node = node)
+ }
+
+ is ErrorNode -> {
+ // TODO For Journey, we many not need to render the Failure node
+ Render(node)
+ }
+
+ is SuccessNode -> {
+ LaunchedEffect(true) {
+ onSuccess?.let { onSuccess() }
+ }
+ }
+
+ null -> {}
+ }
+ }
+ }
+}
+
+@Composable
+fun Render(node: FailureNode) {
+ Row(
+ modifier =
+ Modifier
+ .padding(16.dp)
+ .fillMaxWidth(),
+ ) {
+ Card(
+ elevation =
+ CardDefaults.cardElevation(
+ defaultElevation = 10.dp,
+ ),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(8.dp),
+ shape = MaterialTheme.shapes.medium,
+ ) {
+ Row(
+ modifier =
+ Modifier
+ .padding(16.dp)
+ .fillMaxWidth(),
+ ) {
+ Icon(Icons.Filled.Error, null)
+ Spacer(Modifier.width(8.dp))
+ Text(
+ text = "${node.cause}",
+ Modifier
+ .weight(1f),
+ style = MaterialTheme.typography.titleMedium,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun Render(node: ErrorNode) {
+ Row(
+ modifier =
+ Modifier
+ .padding(16.dp)
+ .fillMaxWidth(),
+ ) {
+ Card(
+ elevation =
+ CardDefaults.cardElevation(
+ defaultElevation = 10.dp,
+ ),
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(8.dp),
+ shape = MaterialTheme.shapes.medium,
+ ) {
+ Row(
+ modifier =
+ Modifier
+ .padding(16.dp)
+ .fillMaxWidth(),
+ ) {
+ Icon(Icons.Filled.Error, null)
+ Spacer(Modifier.width(8.dp))
+ Text(
+ text = node.message,
+ Modifier
+ .weight(1f),
+ style = MaterialTheme.typography.titleMedium,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun Render(
+ node: ContinueNode,
+ onNodeUpdated: () -> Unit,
+ onNext: () -> Unit,
+) {
+ ContinueNode(node, onNodeUpdated, onNext)
+}
+
+@Composable
+private fun Logo(modifier: Modifier) {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .then(modifier),
+ ) {
+ Spacer(modifier = Modifier.weight(1f, true))
+ Icon(
+ painterResource(R.drawable.ping_logo),
+ contentDescription = null,
+ modifier =
+ Modifier
+ .height(100.dp)
+ .padding(8.dp)
+ .wrapContentWidth(Alignment.CenterHorizontally)
+ .then(modifier),
+ tint = Color.Unspecified,
+ )
+ Spacer(modifier = Modifier.weight(1f, true))
+ }
+}
\ No newline at end of file
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/JourneyRoute.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/JourneyRoute.kt
new file mode 100644
index 00000000..8ee43061
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/JourneyRoute.kt
@@ -0,0 +1,60 @@
+/*
+ * 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.journeyapp.journey
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+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.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.pingidentity.samples.journeyapp.PreferenceViewModel
+
+@Composable
+fun JourneyRoute(
+ preferenceViewModel: PreferenceViewModel,
+ onSubmit: (String) -> Unit
+) {
+
+ //Stateful Composable - state maintain when configuration change
+ var journeyName by rememberSaveable {
+ mutableStateOf(preferenceViewModel.getLastJourney())
+ }
+
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(8.dp)
+ ) {
+ OutlinedTextField(
+ modifier = Modifier.fillMaxWidth(),
+ value = journeyName,
+ onValueChange = { value -> journeyName = value },
+ label = { Text("Journey Name") },
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Button(modifier = Modifier.align(Alignment.End),
+ onClick = {
+ preferenceViewModel.saveJourney(journeyName)
+ onSubmit(journeyName)
+ }) {
+ Text("Submit")
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/JourneyState.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/JourneyState.kt
new file mode 100644
index 00000000..d64428d0
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/JourneyState.kt
@@ -0,0 +1,12 @@
+/*
+ * 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.journeyapp.journey
+
+import com.pingidentity.orchestrate.Node
+
+data class JourneyState(val node: Node? = null, val counter: Int = 0)
\ No newline at end of file
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/JourneyViewModel.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/JourneyViewModel.kt
new file mode 100644
index 00000000..43cc11af
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/JourneyViewModel.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.samples.journeyapp.journey
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import com.pingidentity.journey.start
+import com.pingidentity.orchestrate.ContinueNode
+import com.pingidentity.samples.journeyapp.env.journey
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+class JourneyViewModel(private var journeyName: String) : ViewModel() {
+
+ var state = MutableStateFlow(JourneyState())
+ private set
+
+ var loading = MutableStateFlow(false)
+ private set
+
+ init {
+ start()
+ }
+
+ fun next(node: ContinueNode) {
+ loading.update {
+ true
+ }
+ viewModelScope.launch {
+ val next = node.next()
+ state.update {
+ it.copy(node = next)
+ }
+ loading.update {
+ false
+ }
+ }
+ }
+
+ fun start() {
+
+ loading.update {
+ true
+ }
+ viewModelScope.launch {
+ val next = journey.start(journeyName)
+
+ state.update {
+ it.copy(node = next)
+ }
+ loading.update {
+ false
+ }
+ }
+ }
+
+ fun refresh() {
+ state.update {
+ it.copy(node = it.node, counter = it.counter + 1)
+ }
+ }
+
+ companion object {
+ fun factory(
+ journeyName: String,
+ ): ViewModelProvider.Factory = object : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class): T {
+ return JourneyViewModel(journeyName) as T
+ }
+ }
+ }
+}
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/BooleanAttributeInputCallback.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/BooleanAttributeInputCallback.kt
new file mode 100644
index 00000000..a60b9d07
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/BooleanAttributeInputCallback.kt
@@ -0,0 +1,47 @@
+/*
+ * 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.journeyapp.journey.callback
+
+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.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.pingidentity.journey.callback.BooleanAttributeInputCallback
+
+@Composable
+fun BooleanAttributeInputCallback(callback: BooleanAttributeInputCallback, onNodeUpdated: () -> Unit) {
+
+ var input by remember(callback) {
+ mutableStateOf(callback.value)
+ }
+
+ Row(modifier = Modifier
+ .padding(4.dp)
+ .fillMaxWidth()) {
+ Text(text = callback.prompt)
+ Spacer(modifier = Modifier.weight(1f, true))
+ Switch(
+ checked = input,
+ onCheckedChange = {
+ input = it
+ callback.value = it
+ onNodeUpdated()
+ }
+ )
+ }
+
+}
\ No newline at end of file
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/ChoiceCallback.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/ChoiceCallback.kt
new file mode 100644
index 00000000..7d2af0bd
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/ChoiceCallback.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.samples.journeyapp.journey.callback
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExposedDropdownMenuBox
+import androidx.compose.material3.ExposedDropdownMenuDefaults
+import androidx.compose.material3.MenuAnchorType
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.pingidentity.journey.callback.ChoiceCallback
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ChoiceCallback(callback: ChoiceCallback, onNodeUpdated: () -> Unit) {
+ var expanded by remember(callback) { mutableStateOf(false) }
+ var selectedItem by remember(callback) {
+ mutableStateOf(callback.choices[callback.defaultChoice])
+ }
+
+ Column(
+ modifier = Modifier
+ .padding(8.dp)
+ .fillMaxWidth()
+ ) {
+ ExposedDropdownMenuBox(
+ expanded = expanded,
+ onExpandedChange = {
+ expanded = !expanded
+ }
+ ) {
+
+ // text field
+ TextField(
+ modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable, true),
+ value = selectedItem,
+ onValueChange = {},
+ readOnly = true,
+ label = { Text(text = callback.prompt) },
+ trailingIcon = {
+ ExposedDropdownMenuDefaults.TrailingIcon(
+ expanded = expanded
+ )
+ },
+ colors = ExposedDropdownMenuDefaults.textFieldColors()
+ )
+
+ // menu
+ ExposedDropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { expanded = false }
+ ) {
+ callback.choices.forEachIndexed { index, selectedOption ->
+ // menu item
+ DropdownMenuItem(text = {
+ Text(text = selectedOption)
+ }, onClick = {
+ selectedItem = selectedOption
+ expanded = false
+ callback.selectedIndex = index
+ onNodeUpdated()
+ })
+ }
+ }
+ }
+ }
+}
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/ConfirmationCallback.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/ConfirmationCallback.kt
new file mode 100644
index 00000000..e01fa174
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/ConfirmationCallback.kt
@@ -0,0 +1,38 @@
+/*
+ * 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.journeyapp.journey.callback
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.pingidentity.journey.callback.ConfirmationCallback
+
+@Composable
+fun ConfirmationCallback(callback: ConfirmationCallback, onSelected: () -> Unit) {
+
+ Row(modifier = Modifier
+ .padding(8.dp)
+ .fillMaxWidth(),
+ horizontalArrangement = Arrangement.End) {
+
+ callback.options.forEachIndexed { index, item ->
+ Button(
+ modifier = Modifier.padding(4.dp),
+ onClick = { callback.selectedIndex = index; onSelected() }) {
+ Text(item)
+ }
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/ConsentMappingCallback.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/ConsentMappingCallback.kt
new file mode 100644
index 00000000..341301ea
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/ConsentMappingCallback.kt
@@ -0,0 +1,78 @@
+/*
+ * 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.journeyapp.journey.callback
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.pingidentity.journey.callback.ConsentMappingCallback
+
+@Composable
+fun ConsentMappingCallback(callback: ConsentMappingCallback, onNodeUpdated: () -> Unit) {
+
+ var input by remember(callback) {
+ mutableStateOf(false)
+ }
+
+ Column (modifier = Modifier
+ .padding(16.dp)
+ .fillMaxWidth()) {
+ Text(text = "Name: ${callback.name}",
+ style = MaterialTheme.typography.titleMedium
+ )
+ Spacer(Modifier.width(8.dp))
+ Text(text = "DisplayName: ${callback.displayName}",
+ style = MaterialTheme.typography.titleMedium
+ )
+ Spacer(Modifier.width(8.dp))
+ Text(text = "Icon: ${callback.icon}",
+ style = MaterialTheme.typography.titleSmall
+ )
+ Spacer(Modifier.width(8.dp))
+ Text(text = "AccessLevel: ${callback.accessLevel}",
+ style = MaterialTheme.typography.titleSmall
+ )
+ Spacer(Modifier.width(8.dp))
+ Text(text = "IsRequired: ${callback.isRequired}",
+ style = MaterialTheme.typography.titleSmall
+ )
+ callback.fields.forEach {
+ Spacer(Modifier.width(8.dp))
+ Text(text = "field: $it",
+ style = MaterialTheme.typography.titleSmall
+ )
+ }
+ Spacer(Modifier.width(8.dp))
+ Text(text = "Message: ${callback.message}",
+ style = MaterialTheme.typography.titleSmall
+ )
+
+ Spacer(Modifier.width(8.dp))
+ Switch(
+ checked = input,
+ onCheckedChange = {
+ input = it
+ callback.accepted = it
+ onNodeUpdated()
+ }
+ )
+ }
+
+}
\ No newline at end of file
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
new file mode 100644
index 00000000..20ced9b7
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/ContinueNode.kt
@@ -0,0 +1,153 @@
+/*
+ * 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.journeyapp.journey.callback
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+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.journey.FidoAuthenticationCallback
+import com.pingidentity.fido.journey.FidoRegistrationCallback
+import com.pingidentity.idp.journey.IdpCallback
+import com.pingidentity.idp.journey.SelectIdpCallback
+import com.pingidentity.journey.callback.BooleanAttributeInputCallback
+import com.pingidentity.journey.callback.ChoiceCallback
+import com.pingidentity.journey.callback.ConfirmationCallback
+import com.pingidentity.journey.callback.ConsentMappingCallback
+import com.pingidentity.journey.callback.KbaCreateCallback
+import com.pingidentity.journey.callback.NameCallback
+import com.pingidentity.journey.callback.NumberAttributeInputCallback
+import com.pingidentity.journey.callback.PasswordCallback
+import com.pingidentity.journey.callback.PollingWaitCallback
+import com.pingidentity.journey.callback.StringAttributeInputCallback
+import com.pingidentity.journey.callback.SuspendedTextOutputCallback
+import com.pingidentity.journey.callback.TermsAndConditionsCallback
+import com.pingidentity.journey.callback.TextInputCallback
+import com.pingidentity.journey.callback.TextOutputCallback
+import com.pingidentity.journey.callback.ValidatedPasswordCallback
+import com.pingidentity.journey.callback.ValidatedUsernameCallback
+import com.pingidentity.journey.plugin.callbacks
+import com.pingidentity.orchestrate.ContinueNode
+import com.pingidentity.protect.journey.PingOneProtectEvaluationCallback
+import com.pingidentity.protect.journey.PingOneProtectInitializeCallback
+import com.pingidentity.recaptcha.enterprise.ReCaptchaEnterpriseCallback
+
+@Composable
+fun ContinueNode(
+ continueNode: ContinueNode,
+ onNodeUpdated: () -> Unit,
+ onNext: () -> Unit,
+) {
+ Column(
+ modifier =
+ Modifier
+ .padding(4.dp)
+ .fillMaxWidth(),
+ ) {
+ var showNext = true
+
+ continueNode.callbacks.forEach {
+ when (it) {
+ is BooleanAttributeInputCallback -> BooleanAttributeInputCallback(it, onNodeUpdated)
+ is ChoiceCallback -> ChoiceCallback(it, onNodeUpdated)
+ is ConfirmationCallback -> {
+ showNext = false
+ ConfirmationCallback(it, onNext)
+ }
+
+ is ConsentMappingCallback -> ConsentMappingCallback(it, onNodeUpdated)
+ is KbaCreateCallback -> KbaCreateCallback(it, onNodeUpdated)
+ is NumberAttributeInputCallback -> NumberAttributeInputCallback(it, onNodeUpdated)
+ is PasswordCallback -> PasswordCallback(it, onNodeUpdated)
+ is PollingWaitCallback -> PollingWaitCallback(it, onNext)
+ is StringAttributeInputCallback -> StringAttributeInputCallback(it, onNodeUpdated)
+ is TermsAndConditionsCallback -> {
+ TermsAndConditionsCallback(it, onNodeUpdated)
+ }
+ is DeviceProfileCallback -> {
+ showNext = false
+ DeviceProfileCallback(it, onNext)
+ }
+ is ReCaptchaEnterpriseCallback -> {
+ showNext = false
+ ReCaptchaEnterpriseCallback(it, onNext)
+ }
+
+ is TextInputCallback -> TextInputCallback(it, onNodeUpdated)
+ is TextOutputCallback -> TextOutputCallback(it)
+ is SuspendedTextOutputCallback -> {
+ TextOutputCallback(it)
+ showNext = false
+ }
+
+ is NameCallback -> NameCallback(it, onNodeUpdated)
+
+ //External IdP
+ is SelectIdpCallback -> SelectIdpCallback(it, onNext)
+ is IdpCallback -> {
+ showNext = false
+ IdPCallback(it, onNext)
+ }
+
+ is ValidatedUsernameCallback -> ValidatedUsernameCallback(it, onNodeUpdated)
+ is ValidatedPasswordCallback -> ValidatedPasswordCallback(it, onNodeUpdated)
+ is PingOneProtectInitializeCallback -> {
+ PingOneProtectInitialize(it, onNext)
+ showNext = false
+ }
+
+ is PingOneProtectEvaluationCallback -> {
+ PingOneProtectEvaluation(it, onNext)
+ showNext = false
+ }
+
+ is FidoRegistrationCallback -> {
+ FidoRegistration(it, onNext)
+ showNext = false
+ }
+
+ is FidoAuthenticationCallback -> {
+ FidoAuthentication(it, onNext)
+ showNext = false
+ }
+
+ is DeviceBindingCallback -> {
+ // Create / reuse a ViewModel bound to this composition & callback instance
+ val vm: DeviceBindingCallbackViewModel =
+ viewModel(factory = DeviceBindingCallbackViewModel.factory(it))
+ DeviceBindingCallback(vm, onNext)
+ showNext = false
+ }
+
+ is DeviceSigningVerifierCallback -> {
+ val vm: DeviceSigningVerifierCallbackViewModel =
+ viewModel(factory = DeviceSigningVerifierCallbackViewModel.factory(it))
+ DeviceSigningVerifierCallback(vm, true, onNext)
+ showNext = false
+ }
+ }
+ }
+ if (showNext) {
+ Button(
+ modifier = Modifier.align(Alignment.End),
+ onClick = onNext,
+ ) {
+ Text("Next")
+ }
+ }
+ }
+}
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/DeviceBindingCallback.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/DeviceBindingCallback.kt
new file mode 100644
index 00000000..3fd9e2a9
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/DeviceBindingCallback.kt
@@ -0,0 +1,254 @@
+/*
+ * 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.journeyapp.journey.callback
+
+import android.os.Build
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Visibility
+import androidx.compose.material.icons.filled.VisibilityOff
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.OutlinedTextFieldDefaults
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import com.pingidentity.device.binding.Prompt
+
+@Composable
+fun DeviceBindingCallback(
+ viewModel: DeviceBindingCallbackViewModel,
+ onNext: () -> Unit
+) {
+ val currentOnCompleted by rememberUpdatedState(onNext)
+ var deviceName by remember { mutableStateOf(Build.MODEL) }
+
+ // PIN Dialog (shown when ViewModel requests it)
+ viewModel.activePinPrompt?.let { prompt ->
+ PinCollectorDialog(
+ prompt = prompt,
+ onPinEntered = { pin ->
+ viewModel.submitPin(pin.toCharArray())
+ },
+ onCancelled = {
+ viewModel.cancelPin()
+ }
+ )
+ }
+
+ Box(
+ modifier = Modifier
+ .padding(4.dp)
+ .fillMaxSize()
+ .wrapContentSize(Alignment.TopStart)
+ ) {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ if (viewModel.isLoading) {
+ CircularProgressIndicator()
+ }
+ }
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ ) {
+ OutlinedTextField(
+ modifier = Modifier,
+ value = deviceName,
+ onValueChange = { value -> deviceName = value },
+ label = { Text("Device Name") },
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Button(
+ modifier = Modifier.align(Alignment.End),
+ enabled = !viewModel.isLoading,
+ onClick = {
+ viewModel.bind(
+ deviceName = deviceName
+ ) { result ->
+ result.onFailure {
+ it.printStackTrace()
+ }
+ currentOnCompleted()
+ }
+ }
+ ) {
+ Text(if (viewModel.isLoading) "Binding..." else "Continue")
+ }
+ }
+ }
+}
+
+
+@Composable
+fun PinCollectorDialog(
+ prompt: Prompt,
+ onPinEntered: (String) -> Unit,
+ onCancelled: () -> Unit
+) {
+ var pin by remember { mutableStateOf("") }
+ var showPin by remember { mutableStateOf(false) }
+
+ Dialog(
+ onDismissRequest = onCancelled,
+ properties = DialogProperties(
+ dismissOnBackPress = true,
+ dismissOnClickOutside = false
+ )
+ ) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ contentColor = MaterialTheme.colorScheme.onSurface
+ ),
+ shape = MaterialTheme.shapes.large
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ // Title
+ if (prompt.title.isNotEmpty()) {
+ Text(
+ text = prompt.title,
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+
+ // Subtitle
+ if (prompt.subtitle.isNotEmpty()) {
+ Text(
+ text = prompt.subtitle,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+
+ // Description
+ if (prompt.description.isNotEmpty()) {
+ Text(
+ text = prompt.description,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ // PIN Input Field
+ OutlinedTextField(
+ value = pin,
+ onValueChange = { pin = it },
+ label = {
+ Text(
+ text = "Enter PIN",
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ },
+ modifier = Modifier.fillMaxWidth(),
+ visualTransformation = if (showPin) VisualTransformation.None else PasswordVisualTransformation(),
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
+ colors = OutlinedTextFieldDefaults.colors(
+ focusedBorderColor = MaterialTheme.colorScheme.primary,
+ unfocusedBorderColor = MaterialTheme.colorScheme.outline,
+ focusedTextColor = MaterialTheme.colorScheme.onSurface,
+ unfocusedTextColor = MaterialTheme.colorScheme.onSurface
+ ),
+ trailingIcon = {
+ IconButton(onClick = { showPin = !showPin }) {
+ Icon(
+ imageVector = if (showPin) Icons.Filled.Visibility else Icons.Filled.VisibilityOff,
+ contentDescription = if (showPin) "Hide PIN" else "Show PIN",
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ },
+ singleLine = true,
+ shape = MaterialTheme.shapes.medium
+ )
+
+ // Buttons
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Cancel Button
+ TextButton(
+ onClick = onCancelled,
+ colors = ButtonDefaults.textButtonColors(
+ contentColor = MaterialTheme.colorScheme.primary
+ )
+ ) {
+ Text("Cancel")
+ }
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ // Confirm Button
+ Button(
+ onClick = {
+ if (pin.isNotEmpty()) {
+ onPinEntered(pin)
+ }
+ },
+ enabled = pin.isNotEmpty(),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primary,
+ contentColor = MaterialTheme.colorScheme.onPrimary,
+ disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
+ disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant
+ ),
+ shape = MaterialTheme.shapes.medium
+ ) {
+ Text("Confirm")
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/DeviceBindingCallbackViewModel.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/DeviceBindingCallbackViewModel.kt
new file mode 100644
index 00000000..6e742ac5
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/DeviceBindingCallbackViewModel.kt
@@ -0,0 +1,107 @@
+/*
+ * 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.journeyapp.journey.callback
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import com.pingidentity.device.binding.Prompt
+import com.pingidentity.device.binding.journey.DeviceBindingCallback
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.launch
+
+/**
+ * ViewModel wrapper for the DeviceBindingCallback with internal PIN collection
+ * coordinated through Compose state instead of passing a pinCollector outward.
+ */
+class DeviceBindingCallbackViewModel(
+ val callback: DeviceBindingCallback,
+) : ViewModel() {
+
+ var isLoading by mutableStateOf(false)
+ private set
+
+ /**
+ * Non-null while the UI must display a PIN dialog.
+ */
+ var activePinPrompt: Prompt? by mutableStateOf(null)
+ private set
+
+ private var pinDeferred: CompletableDeferred? = null
+
+ /**
+ * Start binding. The PIN dialog will be triggered by updating activePinPrompt.
+ */
+ fun bind(
+ deviceName: String,
+ onCompleted: (Result<*>) -> Unit
+ ) {
+ if (isLoading) return
+ viewModelScope.launch {
+ isLoading = true
+ val result = callback.bind {
+ this.deviceName = deviceName
+ appPinConfig {
+ pinCollector { prompt ->
+ // Trigger UI to show dialog
+ pinDeferred = CompletableDeferred()
+ activePinPrompt = prompt
+ // Suspend until user supplies PIN (or cancellation)
+ pinDeferred!!.await().also {
+ // Once consumed, clear state so dialog closes
+ activePinPrompt = null
+ }
+ }
+ //pinCollector = { prompt -> collectPin(prompt) }
+ }
+ biometricAuthenticatorConfig {
+ keyGenParameterSpec {
+ //setUnlockedDeviceRequired(true)
+ //setUserAuthenticationValidWhileOnBody(true)
+ //setUserPresenceRequired(true)
+ //setIsStrongBoxBacked(false)
+ //setInvalidatedByBiometricEnrollment(false)
+ }
+ }
+
+
+ }.onFailure {
+ }
+ isLoading = false
+ onCompleted(result)
+ }
+ }
+
+ /**
+ * Called by UI when user confirms a PIN.
+ */
+ fun submitPin(pin: CharArray) {
+ pinDeferred?.takeIf { it.isActive }?.complete(pin)
+ }
+
+ /**
+ * Called by UI when user cancels PIN entry.
+ */
+ fun cancelPin(reason: String = "User cancelled PIN entry") {
+ pinDeferred?.takeIf { it.isActive }?.completeExceptionally(RuntimeException(reason))
+ activePinPrompt = null
+ }
+
+ companion object {
+ fun factory(callback: DeviceBindingCallback): ViewModelProvider.Factory =
+ object : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class): T {
+ return DeviceBindingCallbackViewModel(callback) as T
+ }
+ }
+ }
+}
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/DeviceProfileCallback.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/DeviceProfileCallback.kt
new file mode 100644
index 00000000..1529a166
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/DeviceProfileCallback.kt
@@ -0,0 +1,153 @@
+/*
+ * 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.journeyapp.journey.callback
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+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.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.pingidentity.device.profile.DeviceProfileCallback
+import com.pingidentity.device.profile.DeviceProfileConfig
+import com.pingidentity.device.profile.collector.BluetoothCollector
+import com.pingidentity.device.profile.collector.BrowserCollector
+import com.pingidentity.device.profile.collector.HardwareCollector
+import com.pingidentity.device.profile.collector.NetworkCollector
+import com.pingidentity.device.profile.collector.PlatformCollector
+import com.pingidentity.device.profile.collector.TelephonyCollector
+import kotlinx.coroutines.launch
+
+/**
+ * A Composable UI component for handling device profile collection during authentication flows.
+ *
+ * This composable provides a user-friendly interface for the device profile collection process,
+ * automatically initiating the collection when the component is displayed and providing visual
+ * feedback to inform users that device profiling is in progress.
+ *
+ * **Key Features:**
+ * - **Automatic Collection**: Initiates device profile collection immediately when displayed
+ * - **Visual Feedback**: Shows a loading spinner with descriptive text during collection
+ * - **Comprehensive Profiling**: Configures multiple collectors for thorough device analysis
+ * - **Seamless Integration**: Automatically proceeds to next authentication step upon completion
+ * - **Error Handling**: Gracefully handles collection failures and system limitations
+ *
+ * **Collection Process:**
+ * 1. Component renders with loading indicator
+ * 2. LaunchedEffect triggers device profile collection
+ * 3. Multiple collectors gather device information:
+ * - Platform information (OS, device model, security status)
+ * - Hardware specifications (CPU, memory, display, camera)
+ * - Network connectivity status and capabilities
+ * - Telephony and carrier information
+ * - Bluetooth hardware support
+ * - Browser/WebView user agent data
+ * 4. Collection results are submitted to the authentication journey
+ * 5. UI automatically transitions to next authentication step
+ *
+ * **UI Behavior:**
+ * - Displays centered loading spinner (48dp size)
+ * - Shows "Gathering Device Profile..." message below spinner
+ * - Maintains loading state until collection completes
+ * - Uses consistent Material Design 3 styling
+ * - Responsive padding and spacing for various screen sizes
+ *
+ * **Privacy Considerations:**
+ * - Collects device metadata for security and fraud prevention
+ * - Does not include location data unless explicitly configured
+ * - User should be informed about data collection through privacy policies
+ * - Collection respects system permissions and privacy settings
+ *
+ * **Usage Example:**
+ * ```kotlin
+ * DeviceProfileCallback(
+ * deviceProfileCallback = callback,
+ * onNext = {
+ * // Navigate to next authentication step
+ * viewModel.nextStep()
+ * }
+ * )
+ * ```
+ *
+ * **Integration Notes:**
+ * - Should be used within a Compose navigation or authentication flow
+ * - Requires valid DeviceProfileCallback instance from journey
+ * - onNext callback should handle navigation to subsequent authentication steps
+ * - Component handles all collection logic internally
+ *
+ * @param deviceProfileCallback The DeviceProfileCallback instance from the authentication journey
+ * that handles the actual device profile collection process and server
+ * communication
+ * @param onNext Callback function invoked when device profile collection completes successfully.
+ * This is typically used to proceed to the next step in the authentication journey
+ * or navigate to the subsequent screen in the authentication flow.
+ *
+ * @see com.pingidentity.device.profile.DeviceProfileCallback
+ * @see DeviceProfileConfig
+ * @see LaunchedEffect
+ */
+@Composable
+fun DeviceProfileCallback(
+ deviceProfileCallback: DeviceProfileCallback,
+ onNext: () -> Unit,
+) {
+ val scope = rememberCoroutineScope()
+ var isLoading by remember { mutableStateOf(true) } // Start in the loading state
+
+ // This effect runs ONCE when the composable enters the screen
+ LaunchedEffect(key1 = true) {
+ scope.launch {
+ val result = deviceProfileCallback.collect {
+ collectors {
+ clear()
+ add(PlatformCollector())
+ add(HardwareCollector())
+ add(NetworkCollector())
+ add(TelephonyCollector)
+ add(BluetoothCollector)
+ add(BrowserCollector)
+ }
+ }
+ println(result.toString())
+ isLoading = false
+ onNext()
+ }
+ }
+
+ // The UI will always show the loading indicator until collection is complete.
+ if (isLoading) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ modifier = Modifier.padding(16.dp)
+ ) {
+ CircularProgressIndicator(modifier = Modifier.size(48.dp))
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(text = "Gathering Device Profile...")
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/DeviceSigningVerifierCallback.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/DeviceSigningVerifierCallback.kt
new file mode 100644
index 00000000..43ed41e3
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/DeviceSigningVerifierCallback.kt
@@ -0,0 +1,282 @@
+/*
+ * 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.journeyapp.journey.callback
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExposedDropdownMenuBox
+import androidx.compose.material3.ExposedDropdownMenuDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.MenuAnchorType
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.OutlinedTextFieldDefaults
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+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.rememberUpdatedState
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import com.pingidentity.device.binding.UserKey
+
+@Composable
+fun DeviceSigningVerifierCallback(
+ viewModel: DeviceSigningVerifierCallbackViewModel,
+ showChallenge: Boolean = false,
+ onNext: () -> Unit
+) {
+ val currentOnCompleted by rememberUpdatedState(onNext)
+
+ // PIN Dialog (shown when ViewModel requests it)
+ viewModel.activePinPrompt?.let { prompt ->
+ PinCollectorDialog(
+ prompt = prompt,
+ onPinEntered = { pin ->
+ viewModel.submitPin(pin.toCharArray())
+ },
+ onCancelled = {
+ viewModel.cancelPin()
+ }
+ )
+ }
+
+ // UserKey Dialog (shown when ViewModel requests it)
+ viewModel.activeUserKeyPrompt?.let { userKeys ->
+ UserKeyDialog(
+ userKeys = userKeys,
+ onUserKeySelected = { userKey ->
+ viewModel.submitUserKey(userKey)
+ },
+ onCancelled = {
+ viewModel.cancelUserKey()
+ }
+ )
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .wrapContentSize(Alignment.Center)
+ ) {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ if (viewModel.isLoading) {
+ CircularProgressIndicator()
+ }
+ }
+
+ if (showChallenge) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ ) {
+ Card(
+ elevation = CardDefaults.cardElevation(defaultElevation = 10.dp),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(8.dp),
+ border = BorderStroke(2.dp, Color.Black),
+ shape = MaterialTheme.shapes.medium
+ ) {
+ Text(
+ modifier = Modifier
+ .padding(4.dp),
+ text = viewModel.callback.challenge
+ )
+ }
+
+ // Show error if any
+ viewModel.signError?.let { error ->
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "Error: $error",
+ color = MaterialTheme.colorScheme.error,
+ modifier = Modifier.padding(8.dp)
+ )
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+ Button(
+ modifier = Modifier.align(Alignment.End),
+ enabled = !viewModel.isLoading,
+ onClick = {
+ viewModel.clearError()
+ viewModel.sign { result ->
+ result.onFailure { it.printStackTrace() }
+ currentOnCompleted()
+ }
+ }
+ ) {
+ Text(if (viewModel.isLoading) "Signing..." else "Approve")
+ }
+ }
+ } else {
+ LaunchedEffect(true) {
+ viewModel.sign { result ->
+ result.onFailure { it.printStackTrace() }
+ currentOnCompleted()
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun UserKeyDialog(
+ userKeys: List,
+ onUserKeySelected: (UserKey) -> Unit,
+ onCancelled: () -> Unit
+) {
+ var selectedUserKey by remember { mutableStateOf(null) }
+ var expanded by remember { mutableStateOf(false) }
+
+ Dialog(
+ onDismissRequest = onCancelled,
+ properties = DialogProperties(
+ dismissOnBackPress = true,
+ dismissOnClickOutside = false
+ )
+ ) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ contentColor = MaterialTheme.colorScheme.onSurface
+ ),
+ shape = MaterialTheme.shapes.large
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ // Title
+ Text(
+ text = "Select User Key",
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+
+ // User Key Dropdown
+ ExposedDropdownMenuBox(
+ expanded = expanded,
+ onExpandedChange = { expanded = !expanded }
+ ) {
+ OutlinedTextField(
+ value = selectedUserKey?.let { "${it.userName} (${it.authType.name})" } ?: "",
+ onValueChange = {},
+ readOnly = true,
+ label = {
+ Text(
+ text = "Select User Key",
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ },
+ trailingIcon = {
+ ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .menuAnchor(MenuAnchorType.PrimaryNotEditable, true),
+ colors = OutlinedTextFieldDefaults.colors(
+ focusedBorderColor = MaterialTheme.colorScheme.primary,
+ unfocusedBorderColor = MaterialTheme.colorScheme.outline,
+ focusedTextColor = MaterialTheme.colorScheme.onSurface,
+ unfocusedTextColor = MaterialTheme.colorScheme.onSurface
+ ),
+ shape = MaterialTheme.shapes.medium
+ )
+
+ ExposedDropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { expanded = false }
+ ) {
+ userKeys.forEach { userKey ->
+ DropdownMenuItem(
+ text = { Text("${userKey.userName} (${userKey.authType.name})") },
+ onClick = {
+ selectedUserKey = userKey
+ expanded = false
+ }
+ )
+ }
+ }
+ }
+
+ // Buttons
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Cancel Button
+ TextButton(
+ onClick = onCancelled,
+ colors = ButtonDefaults.textButtonColors(
+ contentColor = MaterialTheme.colorScheme.primary
+ )
+ ) {
+ Text("Cancel")
+ }
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ // Confirm Button
+ Button(
+ onClick = {
+ selectedUserKey?.let { onUserKeySelected(it) }
+ },
+ enabled = selectedUserKey != null,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primary,
+ contentColor = MaterialTheme.colorScheme.onPrimary,
+ disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
+ disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant
+ ),
+ shape = MaterialTheme.shapes.medium
+ ) {
+ Text("Confirm")
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/DeviceSigningVerifierCallbackViewModel.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/DeviceSigningVerifierCallbackViewModel.kt
new file mode 100644
index 00000000..d6345347
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/DeviceSigningVerifierCallbackViewModel.kt
@@ -0,0 +1,152 @@
+/*
+ * 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.journeyapp.journey.callback
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import com.pingidentity.device.binding.Prompt
+import com.pingidentity.device.binding.UserKey
+import com.pingidentity.device.binding.journey.DeviceSigningVerifierCallback
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.launch
+
+/**
+ * ViewModel wrapper for the DeviceSigningVerifierCallback with signing logic
+ * and state management separated from the UI.
+ */
+class DeviceSigningVerifierCallbackViewModel(
+ val callback: DeviceSigningVerifierCallback,
+) : ViewModel() {
+
+ var isLoading by mutableStateOf(false)
+ private set
+
+ var signError by mutableStateOf(null)
+ private set
+
+ /**
+ * Non-null while the UI must display a PIN dialog.
+ */
+ var activePinPrompt: Prompt? by mutableStateOf(null)
+ private set
+
+ /**
+ * Non-null while the UI must display a UserKey selection dialog.
+ */
+ var activeUserKeyPrompt: List? by mutableStateOf(null)
+ private set
+
+ private var pinDeferred: CompletableDeferred? = null
+ private var userKeyDeferred: CompletableDeferred? = null
+
+ /**
+ * Start signing process with retry logic.
+ */
+ fun sign(onCompleted: (Result<*>) -> Unit) {
+ if (isLoading) return
+ viewModelScope.launch {
+ isLoading = true
+ signError = null
+
+ val result =
+ // Client side retry logic (3 attempts)
+ callback.sign {
+ appPinConfig {
+ pinCollector { prompt ->
+ // Trigger UI to show dialog
+ pinDeferred = CompletableDeferred()
+ activePinPrompt = prompt
+ // Suspend until user supplies PIN (or cancellation)
+ pinDeferred!!.await().also {
+ // Once consumed, clear state so dialog closes
+ activePinPrompt = null
+ }
+ }
+ }
+
+ userKeySelector { userKeys ->
+ // Trigger UI to show user key selection dialog
+ userKeyDeferred = CompletableDeferred()
+ activeUserKeyPrompt = userKeys
+ // Suspend until user selects a key (or cancellation)
+ userKeyDeferred!!.await().also {
+ // Once consumed, clear state so dialog closes
+ activeUserKeyPrompt = null
+ }
+ }
+
+ biometricAuthenticatorConfig {
+ promptInfo {
+ setConfirmationRequired(true)
+ setTitle("Biometric Authentication")
+ setSubtitle("Please authenticate with your biometric device")
+ setDescription("This app requires biometric authentication to sign")
+ }
+ }
+
+ }
+
+ isLoading = false
+ result.onFailure { error ->
+ signError = error.message ?: "Unknown signing error"
+ }
+ onCompleted(result)
+ }
+ }
+
+ /**
+ * Called by UI when user confirms a PIN.
+ */
+ fun submitPin(pin: CharArray) {
+ pinDeferred?.takeIf { it.isActive }?.complete(pin)
+ }
+
+ /**
+ * Called by UI when user cancels PIN entry.
+ */
+ fun cancelPin(reason: String = "User cancelled PIN entry") {
+ pinDeferred?.takeIf { it.isActive }?.completeExceptionally(RuntimeException(reason))
+ activePinPrompt = null
+ }
+
+ /**
+ * Called by UI when user selects a UserKey.
+ */
+ fun submitUserKey(userKey: UserKey) {
+ userKeyDeferred?.takeIf { it.isActive }?.complete(userKey)
+ }
+
+ /**
+ * Called by UI when user cancels UserKey selection.
+ */
+ fun cancelUserKey(reason: String = "User cancelled user key selection") {
+ userKeyDeferred?.takeIf { it.isActive }?.completeExceptionally(RuntimeException(reason))
+ activeUserKeyPrompt = null
+ }
+
+ /**
+ * Clear any existing error state.
+ */
+ fun clearError() {
+ signError = null
+ }
+
+ companion object {
+ fun factory(callback: DeviceSigningVerifierCallback): ViewModelProvider.Factory =
+ object : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class): T {
+ return DeviceSigningVerifierCallbackViewModel(callback) as T
+ }
+ }
+ }
+}
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
new file mode 100644
index 00000000..f6cab53f
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/FidoAuthentication.kt
@@ -0,0 +1,52 @@
+/*
+ * 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.journeyapp.journey.callback
+
+import android.util.Log
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import com.pingidentity.fido.journey.FidoAuthenticationCallback
+import kotlinx.coroutines.launch
+
+@Composable
+fun FidoAuthentication(
+ callback: FidoAuthenticationCallback,
+ onNext: () -> Unit,
+) {
+ val currentOnCompleted by rememberUpdatedState(onNext)
+
+ 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/FidoRegistration.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/FidoRegistration.kt
new file mode 100644
index 00000000..e16ded4a
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/FidoRegistration.kt
@@ -0,0 +1,100 @@
+/*
+ * 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.journeyapp.journey.callback
+
+import android.os.Build
+import android.util.Log
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+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.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.journey.FidoRegistrationCallback
+import kotlinx.coroutines.launch
+
+@Composable
+fun FidoRegistration(
+ callback: FidoRegistrationCallback,
+ onNext: () -> Unit
+) {
+ val coroutineScope = rememberCoroutineScope()
+ val currentOnCompleted by rememberUpdatedState(onNext)
+ var deviceName by remember {
+ mutableStateOf(Build.MODEL)
+ }
+ var showProgress by remember {
+ mutableStateOf(false)
+ }
+ Box(
+ modifier = Modifier
+ .padding(4.dp)
+ .fillMaxSize()
+ .wrapContentSize(Alignment.TopStart)
+ ) {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ if (showProgress) {
+ CircularProgressIndicator()
+ }
+ }
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ ) {
+ OutlinedTextField(
+ modifier = Modifier,
+ value = deviceName,
+ onValueChange = { value ->
+ deviceName = value
+ },
+ label = { Text("Device Name") },
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Button(
+ modifier = Modifier.align(Alignment.End),
+ onClick = {
+ showProgress = true
+ coroutineScope.launch {
+ callback.register(deviceName).onSuccess {
+ currentOnCompleted()
+ }.onFailure {
+ Log.e(
+ "Fido2Registration",
+ "Failed to register",
+ it
+ )
+ currentOnCompleted()
+ }
+ }
+ }) {
+ Text("Continue")
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/IdPCallback.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/IdPCallback.kt
new file mode 100644
index 00000000..0da2e47b
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/IdPCallback.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.journeyapp.journey.callback
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+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.rememberUpdatedState
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.pingidentity.idp.journey.IdpCallback
+import com.pingidentity.samples.journeyapp.env.redirectUri
+
+@Composable
+fun IdPCallback(
+ callback: IdpCallback,
+ onNodeUpdated: () -> Unit
+) {
+ val currentOnNodeUpdated by rememberUpdatedState(onNodeUpdated)
+
+ Column(
+ modifier = Modifier
+ .padding(8.dp)
+ .fillMaxHeight()
+ .fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(text = "Launching ${callback.provider} Social Login...")
+ Spacer(Modifier.height(8.dp))
+ CircularProgressIndicator()
+
+ LaunchedEffect(true) {
+ callback.authorize(redirectUri)
+ currentOnNodeUpdated()
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/KbaCreateCallback.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/KbaCreateCallback.kt
new file mode 100644
index 00000000..4d3065ef
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/KbaCreateCallback.kt
@@ -0,0 +1,135 @@
+/*
+ * 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.journeyapp.journey.callback
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExposedDropdownMenuBox
+import androidx.compose.material3.ExposedDropdownMenuDefaults
+import androidx.compose.material3.MenuAnchorType
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+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.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.pingidentity.journey.callback.KbaCreateCallback
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun KbaCreateCallback(callback: KbaCreateCallback, onNodeUpdated: () -> Unit) {
+ var expanded by remember(callback) { mutableStateOf(false) }
+ var selectedItem by remember(callback) {
+ mutableStateOf(callback.predefinedQuestions[0])
+ }
+ var isCustomQuestion by remember(callback) { mutableStateOf(false) }
+ var customQuestion by remember(callback) { mutableStateOf("") }
+ var answer by remember(callback) {
+ mutableStateOf("")
+ }
+
+ LaunchedEffect(true) {
+ callback.selectedQuestion = selectedItem
+ }
+
+ Column(modifier = Modifier
+ .padding(8.dp)
+ .fillMaxWidth()) {
+ ExposedDropdownMenuBox(
+ expanded = expanded,
+ onExpandedChange = {
+ expanded = !expanded
+ }
+ ) {
+
+ // text field
+ TextField(
+ modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable, true),
+ value = if (isCustomQuestion) "Provide your own" else selectedItem,
+ onValueChange = {},
+ readOnly = true,
+ label = { Text(text = callback.prompt) },
+ trailingIcon = {
+ ExposedDropdownMenuDefaults.TrailingIcon(
+ expanded = expanded
+ )
+ },
+ colors = ExposedDropdownMenuDefaults.textFieldColors()
+ )
+
+ // menu
+ ExposedDropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { expanded = false }
+ ) {
+ callback.predefinedQuestions.forEachIndexed { index, selectedOption ->
+ // menu item
+ DropdownMenuItem(text = {
+ Text(text = selectedOption)
+ }, onClick = {
+ selectedItem = selectedOption
+ isCustomQuestion = false
+ expanded = false
+ callback.selectedQuestion = callback.predefinedQuestions[index]
+ onNodeUpdated()
+ })
+ }
+
+ // Add "Provide your own" option if allowed
+ if (callback.allowUserDefinedQuestions) {
+ DropdownMenuItem(text = {
+ Text(text = "Provide your own")
+ }, onClick = {
+ isCustomQuestion = true
+ expanded = false
+ // Clear the question until user provides one
+ callback.selectedQuestion = ""
+ onNodeUpdated()
+ })
+ }
+ }
+ }
+
+ // Show custom question field when "Provide your own" is selected
+ if (isCustomQuestion) {
+ OutlinedTextField(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 8.dp),
+ value = customQuestion,
+ onValueChange = { value ->
+ customQuestion = value
+ callback.selectedQuestion = value
+ onNodeUpdated()
+ },
+ label = { Text(text = "Your Question") },
+ )
+ }
+
+ OutlinedTextField(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 8.dp),
+ value = answer,
+ onValueChange = { value ->
+ answer = value
+ callback.selectedAnswer = answer
+ onNodeUpdated()
+ },
+ label = { Text(text = "Answer") },
+ )
+ }
+}
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
new file mode 100644
index 00000000..00447624
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/NameCallback.kt
@@ -0,0 +1,56 @@
+/*
+ * 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.journeyapp.journey.callback
+
+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.wrapContentWidth
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.pingidentity.journey.callback.NameCallback
+
+@Composable
+fun NameCallback(
+ field: NameCallback,
+ onNodeUpdated: () -> Unit,
+) {
+
+ var text by remember(field) { mutableStateOf(field.name) }
+
+ Row(
+ modifier =
+ Modifier
+ .padding(4.dp)
+ .fillMaxWidth(),
+ ) {
+ // var text by rememberSaveable { mutableStateOf("") }
+
+ 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))
+ }
+}
\ No newline at end of file
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/NumberAttributeInputCallback.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/NumberAttributeInputCallback.kt
new file mode 100644
index 00000000..13541042
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/NumberAttributeInputCallback.kt
@@ -0,0 +1,60 @@
+/*
+ * 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.journeyapp.journey.callback
+
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.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.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.core.text.isDigitsOnly
+import com.pingidentity.journey.callback.NumberAttributeInputCallback
+
+@Composable
+fun NumberAttributeInputCallback(callback: NumberAttributeInputCallback, onNodeUpdated: () -> Unit) {
+
+ var input by remember(callback) {
+ mutableStateOf(callback.value.toString() ?: "")
+ }
+
+ Row(modifier = Modifier
+ .padding(4.dp)
+ .fillMaxWidth()) {
+ OutlinedTextField(
+ modifier = Modifier,
+ value = input,
+ onValueChange = { value ->
+ if (value.isDigitsOnly()) {
+ input = value
+ if (input.isNotEmpty()) {
+ callback.value = input.toDouble()
+ onNodeUpdated()
+ }
+ }
+ },
+ isError = callback.failedPolicies.isNotEmpty(),
+ supportingText = if (callback.failedPolicies.isNotEmpty()) {
+ @Composable {
+ Text(
+ text = callback.error(),
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ } else null,
+ label = { Text(callback.prompt) },
+ )
+ }
+}
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/PasswordCallback.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/PasswordCallback.kt
new file mode 100644
index 00000000..2326f319
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/PasswordCallback.kt
@@ -0,0 +1,82 @@
+/*
+ * 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.journeyapp.journey.callback
+
+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.wrapContentWidth
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Visibility
+import androidx.compose.material.icons.filled.VisibilityOff
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.unit.dp
+import com.pingidentity.journey.callback.PasswordCallback
+
+@Composable
+fun PasswordCallback(
+ field: PasswordCallback,
+ onNodeUpdated: () -> Unit,
+) {
+ Row(
+ modifier =
+ Modifier
+ .padding(4.dp)
+ .fillMaxWidth(),
+ ) {
+ var passwordVisibility by remember(field) { mutableStateOf(false) }
+ var password by remember(field) { mutableStateOf("") }
+
+ Spacer(modifier = Modifier.weight(1f, true))
+
+ OutlinedTextField(
+ modifier = Modifier.wrapContentWidth(Alignment.CenterHorizontally),
+ value = password,
+ onValueChange = { value ->
+ field.password = value
+ password = value
+ },
+ label = { androidx.compose.material3.Text(field.prompt) },
+ trailingIcon = {
+ IconButton(onClick = { passwordVisibility = !passwordVisibility }) {
+ if (passwordVisibility) {
+ Icon(Icons.Filled.Visibility, contentDescription = null)
+ } else {
+ Icon(Icons.Filled.VisibilityOff, contentDescription = null)
+ }
+ }
+ },
+ keyboardOptions =
+ KeyboardOptions(
+ keyboardType = KeyboardType.Password,
+ ),
+ visualTransformation =
+ if (passwordVisibility) {
+ VisualTransformation.None
+ } else {
+ PasswordVisualTransformation()
+ },
+ )
+
+ Spacer(modifier = Modifier.weight(1f, true))
+ }
+}
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/PingOneProtectEvaluation.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/PingOneProtectEvaluation.kt
new file mode 100644
index 00000000..a10b6353
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/PingOneProtectEvaluation.kt
@@ -0,0 +1,81 @@
+/*
+ * 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.journeyapp.journey.callback
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+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.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.pingidentity.protect.journey.PingOneProtectEvaluationCallback
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+@Composable
+fun PingOneProtectEvaluation(
+ field: PingOneProtectEvaluationCallback,
+ onNext: () -> Unit,
+) {
+ val scope = rememberCoroutineScope()
+ var isLoading by remember(field) { mutableStateOf(true) }
+
+ // Execute the loading task when the composable is first composed
+ LaunchedEffect(key1 = field) {
+ scope.launch {
+ try {
+ val startTime = System.currentTimeMillis()
+ field.collect()
+ val taskDuration = System.currentTimeMillis() - startTime
+
+ // If task completed too quickly, delay to meet minimum display time
+ val remainingTime = 2000 - taskDuration
+ if (remainingTime > 0) {
+ delay(remainingTime)
+ }
+ isLoading = false
+ onNext()
+ } catch (e: Exception) {
+ isLoading = false
+ }
+ }
+ }
+
+ if (isLoading) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ modifier = Modifier.padding(16.dp)
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(48.dp)
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(text = "Collecting device profile ...")
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/PingOneProtectInitialize.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/PingOneProtectInitialize.kt
new file mode 100644
index 00000000..02416455
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/PingOneProtectInitialize.kt
@@ -0,0 +1,81 @@
+/*
+ * 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.journeyapp.journey.callback
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+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.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.pingidentity.protect.journey.PingOneProtectInitializeCallback
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+@Composable
+fun PingOneProtectInitialize(
+ field: PingOneProtectInitializeCallback,
+ onNext: () -> Unit,
+) {
+ val scope = rememberCoroutineScope()
+ var isLoading by remember(field) { mutableStateOf(true) }
+
+ // Execute the loading task when the composable is first composed
+ LaunchedEffect(key1 = field) {
+ scope.launch {
+ try {
+ val startTime = System.currentTimeMillis()
+ field.start()
+ val taskDuration = System.currentTimeMillis() - startTime
+
+ // If task completed too quickly, delay to meet minimum display time
+ val remainingTime = 2000 - taskDuration
+ if (remainingTime > 0) {
+ delay(remainingTime)
+ }
+ isLoading = false
+ onNext()
+ } catch (e: Exception) {
+ isLoading = false
+ }
+ }
+ }
+
+ if (isLoading) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ modifier = Modifier.padding(16.dp)
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(48.dp)
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(text = "Collecting device profile ...")
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/PollingWaitCallback.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/PollingWaitCallback.kt
new file mode 100644
index 00000000..d584ab61
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/PollingWaitCallback.kt
@@ -0,0 +1,63 @@
+/*
+ * 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.journeyapp.journey.callback
+
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ProgressIndicatorDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.pingidentity.journey.callback.PollingWaitCallback
+import kotlinx.coroutines.delay
+
+@Composable
+fun PollingWaitCallback(callback: PollingWaitCallback, onTimeout: () -> Unit) {
+
+ val currentOnTimeout by rememberUpdatedState(onTimeout)
+ val infiniteTransition = rememberInfiniteTransition()
+ val duration = callback.waitTime.toInt()
+
+ val progressAnimationValue by infiniteTransition.animateFloat(
+ initialValue = 0.0f,
+ targetValue = 1.0f,
+ animationSpec = infiniteRepeatable(animation = tween(duration)))
+
+
+ Column(modifier = Modifier
+ .padding(8.dp)
+ .fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(text = callback.message)
+ Spacer(Modifier.height(8.dp))
+ CircularProgressIndicator(
+ progress = { progressAnimationValue },
+ trackColor = ProgressIndicatorDefaults.circularIndeterminateTrackColor,
+ )
+
+ LaunchedEffect(callback) {
+ delay(callback.waitTime.toLong())
+ currentOnTimeout()
+ }
+ }
+
+}
+
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/RecaptchaEnterpriseCallback.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/RecaptchaEnterpriseCallback.kt
new file mode 100644
index 00000000..4eeed34c
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/RecaptchaEnterpriseCallback.kt
@@ -0,0 +1,97 @@
+/*
+ * 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.journeyapp.journey.callback
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+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.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.pingidentity.recaptcha.enterprise.ReCaptchaEnterpriseCallback
+import kotlinx.coroutines.launch
+
+/**
+ * Composable function for handling ReCaptcha Enterprise verification in the Journey UI.
+ *
+ * This Composable automatically initiates ReCaptcha Enterprise verification when displayed
+ * and shows a loading indicator during the verification process. The verification uses
+ * the default ReCaptcha client provider and handles both success and failure scenarios.
+ *
+ * The UI behavior:
+ * - Displays a loading spinner with progress text during verification
+ * - Automatically proceeds to the next step on successful verification
+ * - For demo purposes, also proceeds on failure (in production, you may want to handle errors differently)
+ *
+ * @param reCaptchaEnterpriseCallback The ReCaptcha Enterprise callback instance from the Journey
+ * @param onNext Callback function to invoke when verification completes (success or failure)
+ *
+ * Example usage:
+ * ```kotlin
+ * ReCaptchaEnterpriseCallback(
+ * reCaptchaEnterpriseCallback = callback,
+ * onNext = { viewModel.proceedToNextStep() }
+ * )
+ * ```
+ */
+@Composable
+fun ReCaptchaEnterpriseCallback(
+ reCaptchaEnterpriseCallback: ReCaptchaEnterpriseCallback,
+ onNext: () -> Unit,
+) {
+ val scope = rememberCoroutineScope()
+ var isLoading by remember { mutableStateOf(true) }
+
+ LaunchedEffect(key1 = true) {
+ scope.launch {
+ reCaptchaEnterpriseCallback.verify {
+ // Optionally customize the configuration here
+ }.onSuccess { result ->
+ println("ReCaptcha Token Result: $result")
+ isLoading = false
+ onNext()
+ }.onFailure { error ->
+ println("ReCaptcha Verification Failed: ${error.message}")
+ isLoading = false
+ onNext() // Proceeding to next step even on failure for demo purposes
+ }
+ }
+ }
+
+ // The UI will always show the loading indicator until collection is complete.
+ if (isLoading) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ modifier = Modifier.padding(16.dp)
+ ) {
+ CircularProgressIndicator(modifier = Modifier.size(48.dp))
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(text = "ReCaptcha verification in progress...")
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/SelectIdpCallback.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/SelectIdpCallback.kt
new file mode 100644
index 00000000..fa430129
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/SelectIdpCallback.kt
@@ -0,0 +1,77 @@
+/*
+ * 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.journeyapp.journey.callback
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+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.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import com.pingidentity.idp.journey.SelectIdpCallback
+import com.pingidentity.samples.journeyapp.R
+
+
+private const val LOCAL_AUTHENTICATION = "localAuthentication"
+
+@Composable
+fun SelectIdpCallback(callback: SelectIdpCallback, onSelected: () -> Unit) {
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth(),
+ ) {
+ callback.providers.forEach {
+
+ Row(
+ modifier = Modifier
+ .padding(4.dp)
+ .fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center
+ ) {
+ var id = -1
+ if (it.provider.lowercase().contains("facebook")) {
+ id = R.drawable.facebook
+ }
+ if (it.provider.lowercase().contains("google")) {
+ id = R.drawable.google
+ }
+ if (it.provider.lowercase().contains("apple")) {
+ id = R.drawable.apple
+ }
+
+ if (it.provider == LOCAL_AUTHENTICATION) {
+ callback.value = LOCAL_AUTHENTICATION
+ }
+
+ if (id > 0) {
+ Image(painter = painterResource(id = id),
+ contentDescription = null,
+ modifier = Modifier
+ .width(200.dp)
+ .wrapContentWidth(Alignment.CenterHorizontally)
+ .clickable {
+ callback.value = it.provider
+ onSelected()
+ }
+ )
+ }
+ }
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/StringAttributeInputCallback.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/StringAttributeInputCallback.kt
new file mode 100644
index 00000000..18c09efc
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/StringAttributeInputCallback.kt
@@ -0,0 +1,55 @@
+/*
+ * 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.journeyapp.journey.callback
+
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.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.Modifier
+import androidx.compose.ui.unit.dp
+import com.pingidentity.journey.callback.StringAttributeInputCallback
+
+@Composable
+fun StringAttributeInputCallback(callback: StringAttributeInputCallback, onNodeUpdated: () -> Unit) {
+
+ var input by remember(callback) {
+ mutableStateOf(callback.value)
+ }
+
+ Row(modifier = Modifier
+ .padding(4.dp)
+ .fillMaxWidth()) {
+ OutlinedTextField(
+ modifier = Modifier,
+ value = input,
+ onValueChange = { value ->
+ input = value
+ callback.value = input
+ onNodeUpdated()
+ },
+ isError = callback.failedPolicies.isNotEmpty(),
+ supportingText = if (callback.failedPolicies.isNotEmpty()) {
+ @Composable {
+ Text(
+ text = callback.error(),
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ } else null,
+ label = { Text(callback.prompt) },
+ )
+ }
+}
\ No newline at end of file
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/TermsAndConditionsCallback.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/TermsAndConditionsCallback.kt
new file mode 100644
index 00000000..a0e5a022
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/TermsAndConditionsCallback.kt
@@ -0,0 +1,59 @@
+/*
+ * 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.journeyapp.journey.callback
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.pingidentity.journey.callback.TermsAndConditionsCallback
+
+@Composable
+fun TermsAndConditionsCallback(callback: TermsAndConditionsCallback, onNodeUpdated: () -> Unit) {
+
+ var input by remember(callback) {
+ mutableStateOf(false)
+ }
+
+ Column (modifier = Modifier
+ .padding(16.dp)
+ .fillMaxWidth()) {
+ Text(text = callback.version,
+ style = MaterialTheme.typography.titleMedium
+ )
+ Spacer(Modifier.width(8.dp))
+ Text(text = callback.createDate,
+ style = MaterialTheme.typography.titleMedium
+ )
+ Spacer(Modifier.width(8.dp))
+ Text(text = callback.terms,
+ style = MaterialTheme.typography.titleSmall
+ )
+ Spacer(Modifier.width(8.dp))
+ Switch(
+ checked = input,
+ onCheckedChange = {
+ input = it
+ callback.accepted = it
+ onNodeUpdated()
+ }
+ )
+ }
+
+}
\ No newline at end of file
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/TextInputCallback.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/TextInputCallback.kt
new file mode 100644
index 00000000..bbc9be76
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/TextInputCallback.kt
@@ -0,0 +1,48 @@
+/*
+ * 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.journeyapp.journey.callback
+
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+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.Modifier
+import androidx.compose.ui.unit.dp
+import com.pingidentity.journey.callback.TextInputCallback
+
+@Composable
+fun TextInputCallback(textInputCallback: TextInputCallback, onNodeUpdated: () -> Unit) {
+
+ var text by remember(textInputCallback) {
+ mutableStateOf(textInputCallback.defaultText)
+ }
+
+ Row(
+ modifier = Modifier
+ .padding(4.dp)
+ .fillMaxWidth()
+ ) {
+ OutlinedTextField(
+ modifier = Modifier,
+ value = text,
+ onValueChange = { value ->
+ text = value
+ textInputCallback.text = text
+ onNodeUpdated()
+ },
+ label = { Text(textInputCallback.prompt) },
+ )
+ }
+
+}
\ No newline at end of file
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/TextOutputCallback.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/TextOutputCallback.kt
new file mode 100644
index 00000000..255e25c7
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/TextOutputCallback.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2026 Ping Identity Corporation. All rights reserved.
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license. See the LICENSE file for details.
+ */
+
+package com.pingidentity.samples.journeyapp.journey.callback
+
+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.material.icons.Icons
+import androidx.compose.material.icons.filled.Error
+import androidx.compose.material.icons.filled.Info
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.filled.Warning
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.pingidentity.journey.callback.TextOutputCallback
+import com.pingidentity.journey.callback.TextOutputCallbackMessageType
+
+@Composable
+fun TextOutputCallback(callback: TextOutputCallback) {
+
+ Row(modifier = Modifier
+ .padding(16.dp)
+ .fillMaxWidth()) {
+ when (callback.messageType) {
+ TextOutputCallbackMessageType.INFORMATION -> Icon(Icons.Filled.Info, null)
+ TextOutputCallbackMessageType.WARNING -> Icon(Icons.Filled.Warning, null)
+ TextOutputCallbackMessageType.ERROR -> Icon(Icons.Filled.Error, null)
+ else -> Icon(Icons.Filled.Settings, null)
+ }
+ Spacer(Modifier.width(8.dp))
+ Text(text = callback.message,
+ Modifier
+ .weight(1f),
+ style = MaterialTheme.typography.titleMedium
+ )
+ }
+
+}
\ No newline at end of file
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/ValidatedPasswordCallback.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/ValidatedPasswordCallback.kt
new file mode 100644
index 00000000..e408613a
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/ValidatedPasswordCallback.kt
@@ -0,0 +1,95 @@
+/*
+ * 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.journeyapp.journey.callback
+
+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.wrapContentWidth
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Visibility
+import androidx.compose.material.icons.filled.VisibilityOff
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.unit.dp
+import com.pingidentity.journey.callback.ValidatedPasswordCallback
+
+@Composable
+fun ValidatedPasswordCallback(callback: ValidatedPasswordCallback, onNodeUpdated: () -> Unit) {
+
+ var input by remember(callback) {
+ mutableStateOf(callback.password)
+ }
+
+ Row(
+ modifier =
+ Modifier
+ .padding(4.dp)
+ .fillMaxWidth(),
+ ) {
+ var passwordVisibility by remember(callback) { mutableStateOf(false) }
+
+ Spacer(modifier = Modifier.weight(1f, true))
+
+ OutlinedTextField(
+ modifier = Modifier.wrapContentWidth(Alignment.CenterHorizontally),
+ value = input,
+ onValueChange = { value ->
+ input = value
+ callback.password = value
+ onNodeUpdated()
+ },
+ isError = callback.failedPolicies.isNotEmpty(),
+ supportingText = if (callback.failedPolicies.isNotEmpty()) {
+ @Composable {
+ Text(
+ text = callback.error(callback.prompt),
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ } else null,
+ label = { Text(callback.prompt) },
+ trailingIcon = {
+ IconButton(onClick = { passwordVisibility = !passwordVisibility }) {
+ if (passwordVisibility) {
+ Icon(Icons.Filled.Visibility, contentDescription = null)
+ } else {
+ Icon(Icons.Filled.VisibilityOff, contentDescription = null)
+ }
+ }
+ },
+ keyboardOptions =
+ KeyboardOptions(
+ keyboardType = KeyboardType.Password,
+ ),
+ visualTransformation =
+ if (passwordVisibility) {
+ VisualTransformation.None
+ } else {
+ PasswordVisualTransformation()
+ },
+ )
+
+ Spacer(modifier = Modifier.weight(1f, true))
+ }
+}
\ No newline at end of file
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/ValidatedUsernameCallback.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/ValidatedUsernameCallback.kt
new file mode 100644
index 00000000..00dbdba6
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/journey/callback/ValidatedUsernameCallback.kt
@@ -0,0 +1,55 @@
+/*
+ * 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.journeyapp.journey.callback
+
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.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.Modifier
+import androidx.compose.ui.unit.dp
+import com.pingidentity.journey.callback.ValidatedUsernameCallback
+
+@Composable
+fun ValidatedUsernameCallback(callback: ValidatedUsernameCallback, onNodeUpdated: () -> Unit) {
+
+ var input by remember(callback) {
+ mutableStateOf(callback.username)
+ }
+
+ Row(modifier = Modifier
+ .padding(4.dp)
+ .fillMaxWidth()) {
+ OutlinedTextField(
+ modifier = Modifier,
+ value = input,
+ onValueChange = { value ->
+ input = value
+ callback.username = input
+ onNodeUpdated()
+ },
+ isError = callback.failedPolicies.isNotEmpty(),
+ supportingText = if (callback.failedPolicies.isNotEmpty()) {
+ @Composable {
+ Text(
+ text = callback.error(callback.prompt),
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ } else null,
+ label = { Text(callback.prompt) },
+ )
+ }
+}
\ No newline at end of file
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/theme/Color.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/theme/Color.kt
new file mode 100644
index 00000000..8bc79d5a
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/theme/Color.kt
@@ -0,0 +1,75 @@
+/*
+ * 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.journeyapp.theme
+
+import androidx.compose.ui.graphics.Color
+
+val md_theme_light_primary = Color(0xFFB3282D)
+val md_theme_light_onPrimary = Color(0xFFFFFFFF)
+val md_theme_light_primaryContainer = Color(0xFFFFDAD7)
+val md_theme_light_onPrimaryContainer = Color(0xFF410005)
+val md_theme_light_secondary = Color(0xFF775654)
+val md_theme_light_onSecondary = Color(0xFFFFFFFF)
+val md_theme_light_secondaryContainer = Color(0xFFFFDAD7)
+val md_theme_light_onSecondaryContainer = Color(0xFF2C1514)
+val md_theme_light_tertiary = Color(0xFF735B2E)
+val md_theme_light_onTertiary = Color(0xFFFFFFFF)
+val md_theme_light_tertiaryContainer = Color(0xFFFFDEA7)
+val md_theme_light_onTertiaryContainer = Color(0xFF271900)
+val md_theme_light_error = Color(0xFFBA1A1A)
+val md_theme_light_errorContainer = Color(0xFFFFDAD6)
+val md_theme_light_onError = Color(0xFFFFFFFF)
+val md_theme_light_onErrorContainer = Color(0xFF410002)
+val md_theme_light_background = Color(0xFFFFFBFF)
+val md_theme_light_onBackground = Color(0xFF201A1A)
+val md_theme_light_surface = Color(0xFFFFFBFF)
+val md_theme_light_onSurface = Color(0xFF201A1A)
+val md_theme_light_surfaceVariant = Color(0xFFF4DDDB)
+val md_theme_light_onSurfaceVariant = Color(0xFF534342)
+val md_theme_light_outline = Color(0xFF857371)
+val md_theme_light_inverseOnSurface = Color(0xFFFBEEEC)
+val md_theme_light_inverseSurface = Color(0xFF362F2E)
+val md_theme_light_inversePrimary = Color(0xFFFFB3AE)
+val md_theme_light_shadow = Color(0xFF000000)
+val md_theme_light_surfaceTint = Color(0xFFB3282D)
+val md_theme_light_outlineVariant = Color(0xFFD8C2C0)
+val md_theme_light_scrim = Color(0xFF000000)
+
+val md_theme_dark_primary = Color(0xFFFFB3AE)
+val md_theme_dark_onPrimary = Color(0xFF68000C)
+val md_theme_dark_primaryContainer = Color(0xFF900918)
+val md_theme_dark_onPrimaryContainer = Color(0xFFFFDAD7)
+val md_theme_dark_secondary = Color(0xFFE7BDBA)
+val md_theme_dark_onSecondary = Color(0xFF442928)
+val md_theme_dark_secondaryContainer = Color(0xFF5D3F3D)
+val md_theme_dark_onSecondaryContainer = Color(0xFFFFDAD7)
+val md_theme_dark_tertiary = Color(0xFFE2C28C)
+val md_theme_dark_onTertiary = Color(0xFF402D05)
+val md_theme_dark_tertiaryContainer = Color(0xFF594319)
+val md_theme_dark_onTertiaryContainer = Color(0xFFFFDEA7)
+val md_theme_dark_error = Color(0xFFFFB4AB)
+val md_theme_dark_errorContainer = Color(0xFF93000A)
+val md_theme_dark_onError = Color(0xFF690005)
+val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
+val md_theme_dark_background = Color(0xFF201A1A)
+val md_theme_dark_onBackground = Color(0xFFEDE0DE)
+val md_theme_dark_surface = Color(0xFF201A1A)
+val md_theme_dark_onSurface = Color(0xFFEDE0DE)
+val md_theme_dark_surfaceVariant = Color(0xFF534342)
+val md_theme_dark_onSurfaceVariant = Color(0xFFD8C2C0)
+val md_theme_dark_outline = Color(0xFFA08C8B)
+val md_theme_dark_inverseOnSurface = Color(0xFF201A1A)
+val md_theme_dark_inverseSurface = Color(0xFFEDE0DE)
+val md_theme_dark_inversePrimary = Color(0xFFB3282D)
+val md_theme_dark_shadow = Color(0xFF000000)
+val md_theme_dark_surfaceTint = Color(0xFFFFB3AE)
+val md_theme_dark_outlineVariant = Color(0xFF534342)
+val md_theme_dark_scrim = Color(0xFF000000)
+
+
+val seed = Color(0xFFB3282D)
\ No newline at end of file
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/theme/Shape.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/theme/Shape.kt
new file mode 100644
index 00000000..4f77d358
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/theme/Shape.kt
@@ -0,0 +1,18 @@
+/*
+ * Copyright (c) 2026 Ping Identity Corporation. All rights reserved.
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license. See the LICENSE file for details.
+ */
+
+package com.pingidentity.samples.journeyapp.theme
+
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Shapes
+import androidx.compose.ui.unit.dp
+
+val AppShapes = Shapes(
+ small = RoundedCornerShape(4.dp),
+ medium = RoundedCornerShape(4.dp),
+ large = RoundedCornerShape(8.dp)
+)
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/theme/Theme.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/theme/Theme.kt
new file mode 100644
index 00000000..69abea17
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/theme/Theme.kt
@@ -0,0 +1,99 @@
+/*
+ * 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.journeyapp.theme
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+
+private val LightColors = lightColorScheme(
+ primary = md_theme_light_primary,
+ onPrimary = md_theme_light_onPrimary,
+ primaryContainer = md_theme_light_primaryContainer,
+ onPrimaryContainer = md_theme_light_onPrimaryContainer,
+ secondary = md_theme_light_secondary,
+ onSecondary = md_theme_light_onSecondary,
+ secondaryContainer = md_theme_light_secondaryContainer,
+ onSecondaryContainer = md_theme_light_onSecondaryContainer,
+ tertiary = md_theme_light_tertiary,
+ onTertiary = md_theme_light_onTertiary,
+ tertiaryContainer = md_theme_light_tertiaryContainer,
+ onTertiaryContainer = md_theme_light_onTertiaryContainer,
+ error = md_theme_light_error,
+ errorContainer = md_theme_light_errorContainer,
+ onError = md_theme_light_onError,
+ onErrorContainer = md_theme_light_onErrorContainer,
+ background = md_theme_light_background,
+ onBackground = md_theme_light_onBackground,
+ surface = md_theme_light_surface,
+ onSurface = md_theme_light_onSurface,
+ surfaceVariant = md_theme_light_surfaceVariant,
+ onSurfaceVariant = md_theme_light_onSurfaceVariant,
+ outline = md_theme_light_outline,
+ inverseOnSurface = md_theme_light_inverseOnSurface,
+ inverseSurface = md_theme_light_inverseSurface,
+ inversePrimary = md_theme_light_inversePrimary,
+ surfaceTint = md_theme_light_surfaceTint,
+ outlineVariant = md_theme_light_outlineVariant,
+ scrim = md_theme_light_scrim,
+)
+
+
+private val DarkColors = darkColorScheme(
+ primary = md_theme_dark_primary,
+ onPrimary = md_theme_dark_onPrimary,
+ primaryContainer = md_theme_dark_primaryContainer,
+ onPrimaryContainer = md_theme_dark_onPrimaryContainer,
+ secondary = md_theme_dark_secondary,
+ onSecondary = md_theme_dark_onSecondary,
+ secondaryContainer = md_theme_dark_secondaryContainer,
+ onSecondaryContainer = md_theme_dark_onSecondaryContainer,
+ tertiary = md_theme_dark_tertiary,
+ onTertiary = md_theme_dark_onTertiary,
+ tertiaryContainer = md_theme_dark_tertiaryContainer,
+ onTertiaryContainer = md_theme_dark_onTertiaryContainer,
+ error = md_theme_dark_error,
+ errorContainer = md_theme_dark_errorContainer,
+ onError = md_theme_dark_onError,
+ onErrorContainer = md_theme_dark_onErrorContainer,
+ background = md_theme_dark_background,
+ onBackground = md_theme_dark_onBackground,
+ surface = md_theme_dark_surface,
+ onSurface = md_theme_dark_onSurface,
+ surfaceVariant = md_theme_dark_surfaceVariant,
+ onSurfaceVariant = md_theme_dark_onSurfaceVariant,
+ outline = md_theme_dark_outline,
+ inverseOnSurface = md_theme_dark_inverseOnSurface,
+ inverseSurface = md_theme_dark_inverseSurface,
+ inversePrimary = md_theme_dark_inversePrimary,
+ surfaceTint = md_theme_dark_surfaceTint,
+ outlineVariant = md_theme_dark_outlineVariant,
+ scrim = md_theme_dark_scrim,
+)
+
+@Composable
+fun AppTheme(
+ useDarkTheme: Boolean = isSystemInDarkTheme(),
+ content: @Composable() () -> Unit
+) {
+ val colors = if (!useDarkTheme) {
+ LightColors
+ } else {
+ DarkColors
+ }
+
+ MaterialTheme(
+ colorScheme = colors,
+ shapes = AppShapes,
+ typography = AppTypography,
+ content = content
+ )
+}
+
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/theme/Type.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/theme/Type.kt
new file mode 100644
index 00000000..6502d664
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/theme/Type.kt
@@ -0,0 +1,86 @@
+/*
+ * 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.journeyapp.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.PlatformTextStyle
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.Font
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.LineHeightStyle
+import androidx.compose.ui.unit.sp
+import com.pingidentity.samples.journeyapp.R
+
+private val Montserrat = FontFamily(
+ Font(R.font.montserrat_regular),
+ Font(R.font.montserrat_medium, FontWeight.W500)
+)
+
+@Suppress("DEPRECATION")
+val defaultTextStyle = TextStyle(
+ fontFamily = Montserrat,
+ platformStyle = PlatformTextStyle(
+ includeFontPadding = false
+ ),
+ lineHeightStyle = LineHeightStyle(
+ alignment = LineHeightStyle.Alignment.Center,
+ trim = LineHeightStyle.Trim.None
+ )
+)
+
+val AppTypography = Typography(
+ displayLarge = defaultTextStyle.copy(
+ fontSize = 57.sp, lineHeight = 64.sp, letterSpacing = (-0.25).sp
+ ),
+ displayMedium = defaultTextStyle.copy(
+ fontSize = 45.sp, lineHeight = 52.sp, letterSpacing = 0.sp
+ ),
+ displaySmall = defaultTextStyle.copy(
+ fontSize = 36.sp, lineHeight = 44.sp, letterSpacing = 0.sp
+ ),
+ headlineLarge = defaultTextStyle.copy(
+ fontSize = 32.sp, lineHeight = 40.sp, letterSpacing = 0.sp
+ ),
+ headlineMedium = defaultTextStyle.copy(
+ fontSize = 28.sp, lineHeight = 36.sp, letterSpacing = 0.sp
+ ),
+ headlineSmall = defaultTextStyle.copy(
+ fontSize = 24.sp, lineHeight = 32.sp, letterSpacing = 0.sp
+ ),
+ titleLarge = defaultTextStyle.copy(
+ fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = 0.sp
+ ),
+ titleMedium = defaultTextStyle.copy(
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.15.sp,
+ fontWeight = FontWeight.Medium
+ ),
+ titleSmall = defaultTextStyle.copy(
+ fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.1.sp, fontWeight = FontWeight.Medium
+ ),
+ labelLarge = defaultTextStyle.copy(
+ fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.1.sp, fontWeight = FontWeight.Medium
+ ),
+ labelMedium = defaultTextStyle.copy(
+ fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.5.sp, fontWeight = FontWeight.Medium
+ ),
+ labelSmall = defaultTextStyle.copy(
+ fontSize = 11.sp, lineHeight = 16.sp, letterSpacing = 0.5.sp, fontWeight = FontWeight.Medium
+ ),
+ bodyLarge = defaultTextStyle.copy(
+ fontSize = 16.sp, lineHeight = 24.sp, letterSpacing = 0.5.sp
+ ),
+ bodyMedium = defaultTextStyle.copy(
+ fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.25.sp
+ ),
+ bodySmall = defaultTextStyle.copy(
+ fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.4.sp
+ ),
+)
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/token/Token.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/token/Token.kt
new file mode 100644
index 00000000..a5ba6ee3
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/token/Token.kt
@@ -0,0 +1,132 @@
+/*
+ * 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.journeyapp.token
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.pingidentity.samples.journeyapp.json
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun Token(tokenViewModel: TokenViewModel = viewModel()) {
+ val tokenState by tokenViewModel.state.collectAsState()
+ val scroll = rememberScrollState(0)
+ var expanded by remember { mutableStateOf(false) }
+
+ LaunchedEffect(true) {
+ // Not relaunch when recomposition
+ tokenViewModel.accessToken()
+ }
+
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth(),
+ ) {
+ Card(
+ elevation =
+ CardDefaults.cardElevation(
+ defaultElevation = 10.dp,
+ ),
+ modifier =
+ Modifier
+ .weight(1f)
+ .fillMaxHeight()
+ .fillMaxWidth()
+ .padding(8.dp)
+ .combinedClickable(
+ onClick = { },
+ onLongClick = {
+ expanded = !expanded
+ }
+ ),
+ border = BorderStroke(2.dp, Color.Black),
+ shape = MaterialTheme.shapes.medium,
+ ) {
+ Text(
+ modifier =
+ Modifier
+ .padding(4.dp)
+ .verticalScroll(scroll),
+ text =
+ tokenState.token?.let {
+ json.encodeToString(it)
+ } ?: tokenState.error?.toString() ?: "",
+ )
+ }
+
+ Row(
+ modifier =
+ Modifier
+ .padding(8.dp)
+ .fillMaxWidth(),
+ horizontalArrangement = Arrangement.aligned(Alignment.End),
+ ) {
+ Button(
+ modifier = Modifier.padding(4.dp),
+ onClick = { tokenViewModel.accessToken() },
+ ) {
+ Text(text = "AccessToken")
+ }
+ Button(
+ modifier = Modifier.padding(4.dp),
+ onClick = { tokenViewModel.reset() },
+ ) {
+ Text(text = "Clear")
+ }
+ }
+ }
+
+ DropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { expanded = false }
+ ) {
+ DropdownMenuItem(
+ text = { Text("Refresh") },
+ onClick = {
+ tokenViewModel.refresh()
+ expanded = false
+ }
+ )
+ DropdownMenuItem(
+ text = { Text("Revoke") },
+ onClick = {
+ tokenViewModel.revoke()
+ expanded = false
+ }
+ )
+ }
+}
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/token/TokenState.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/token/TokenState.kt
new file mode 100644
index 00000000..e31b3422
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/token/TokenState.kt
@@ -0,0 +1,16 @@
+/*
+ * 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.journeyapp.token
+
+import com.pingidentity.oidc.OidcError
+import com.pingidentity.oidc.Token
+
+data class TokenState(
+ var token: Token? = null,
+ var error: OidcError? = null,
+)
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/token/TokenViewModel.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/token/TokenViewModel.kt
new file mode 100644
index 00000000..4eb1527f
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/token/TokenViewModel.kt
@@ -0,0 +1,86 @@
+/*
+ * 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.journeyapp.token
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.pingidentity.journey.user
+import com.pingidentity.samples.journeyapp.env.journey
+import com.pingidentity.utils.Result.Failure
+import com.pingidentity.utils.Result.Success
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+class TokenViewModel : ViewModel() {
+ var state = MutableStateFlow(TokenState())
+ private set
+
+ fun accessToken() {
+ viewModelScope.launch {
+ journey.user()?.let {
+ when (val result = it.token()) {
+ is Failure -> {
+ state.update { state ->
+ state.copy(token = null, error = result.value)
+ }
+ }
+
+ is Success -> {
+ state.update { state ->
+ state.copy(token = result.value, error = null)
+ }
+ }
+ }
+ } ?: run {
+ state.update {
+ it.copy(token = null, error = null)
+ }
+ }
+ }
+ }
+
+ fun revoke() {
+ viewModelScope.launch {
+ journey.user()?.revoke()
+ state.update {
+ it.copy(token = null, error = null)
+ }
+ }
+ }
+
+ fun refresh() {
+ viewModelScope.launch {
+ journey.user()?.let {
+ when (val result = it.refresh()) {
+ is Failure -> {
+ state.update { state ->
+ state.copy(token = null, error = result.value)
+ }
+ }
+
+ is Success -> {
+ state.update { state ->
+ state.copy(token = result.value, error = null)
+ }
+ }
+ }
+ } ?: run {
+ state.update {
+ it.copy(token = null, error = null)
+ }
+ }
+ }
+ }
+
+ fun reset() {
+ state.update {
+ it.copy(null, null)
+ }
+ }
+}
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/userprofile/UserProfile.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/userprofile/UserProfile.kt
new file mode 100644
index 00000000..c95b5861
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/userprofile/UserProfile.kt
@@ -0,0 +1,356 @@
+/*
+ * 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.journeyapp.userprofile
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.foundation.selection.selectableGroup
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TextField
+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.semantics.Role
+import androidx.compose.ui.unit.dp
+import com.pingidentity.samples.journeyapp.json
+
+@Composable
+fun UserProfile(userProfileViewModel: UserProfileViewModel) {
+ val state by userProfileViewModel.state.collectAsState()
+
+ LaunchedEffect(true) {
+ // Not relaunch when recomposition
+ userProfileViewModel.userinfo()
+ // Load initial device list for default device type
+ userProfileViewModel.setDeviceType(state.selectedDeviceType)
+ }
+
+ // Refresh device list when device type changes
+ LaunchedEffect(state.selectedDeviceType) {
+ userProfileViewModel.setDeviceType(state.selectedDeviceType)
+ }
+ Row(modifier = Modifier.fillMaxWidth()) {
+ Column(
+ modifier =
+ Modifier.padding(8.dp)
+ .fillMaxWidth(),
+ ) {
+ Card(
+ elevation = CardDefaults.cardElevation(defaultElevation = 10.dp),
+ modifier = Modifier.fillMaxWidth().padding(8.dp),
+ shape = MaterialTheme.shapes.medium,
+ ) {
+ Text(
+ "First name: ${state.user?.get("name")}",
+ Modifier.fillMaxWidth().padding(4.dp)
+ )
+ Text(
+ "Family name: ${state.user?.get("family_name")}",
+ Modifier.fillMaxWidth().padding(4.dp)
+ )
+ Text("Email: ${state.user?.get("email")}", Modifier.fillMaxWidth().padding(4.dp))
+ DropdownMenu(
+ expanded = false,
+ onDismissRequest = { /* No-op */ }
+ ) {
+ state.deviceList.forEach { device ->
+ DropdownMenuItem(
+ text = { Text(device) },
+ onClick = { }
+ )
+ }
+ }
+ Button(
+ modifier = Modifier.padding(8.dp).align(Alignment.End),
+ onClick = { userProfileViewModel.toggleDeviceInfo() }
+ ) {
+ Text(text = if (state.showDeviceInfo) "Hide Info" else "Show Raw User Info")
+ }
+ if (state.showDeviceInfo) {
+ Text(
+ modifier = Modifier.padding(4.dp),
+ text =
+ state.user?.let {
+ json.encodeToString(it)
+ } ?: state.error?.toString() ?: "No user information available",
+ )
+ }
+ }
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(8.dp),
+ horizontalArrangement = Arrangement.SpaceEvenly
+ ) {
+ Card(
+ elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
+ modifier = Modifier.fillMaxWidth().padding(8.dp),
+ shape = MaterialTheme.shapes.medium,
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(8.dp)
+ .selectableGroup()
+ ) {
+ Text(
+ text = "Filter by Device Type:",
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.padding(bottom = 8.dp)
+ )
+
+ val deviceTypes = listOf(
+ DeviceType.OATH to "OATH (TOTP/HOTP)",
+ DeviceType.PUSH to "Push Notifications",
+ DeviceType.BOUND to "Bound Devices",
+ DeviceType.WEBAUTHN to "WebAuthn/FIDO2",
+ DeviceType.PROFILE to "Profile Devices"
+ )
+
+ deviceTypes.forEach { (deviceType, label) ->
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .selectable(
+ selected = (state.selectedDeviceType == deviceType),
+ onClick = {
+ userProfileViewModel.setDeviceType(
+ deviceType
+ )
+ },
+ role = Role.RadioButton
+ )
+ .padding(vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ RadioButton(
+ selected = (state.selectedDeviceType == deviceType),
+ onClick = null // onClick is handled by Row's selectable
+ )
+ Text(
+ text = label,
+ style = MaterialTheme.typography.bodyLarge,
+ modifier = Modifier.padding(start = 8.dp)
+ )
+ }
+ }
+
+ // Show selected device type
+ Text(
+ text = "Selected: ${state.selectedDeviceType.name}",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(top = 8.dp)
+ )
+ }
+ }
+ }
+
+ // Device List Section
+ if (state.deviceList.isNotEmpty()) {
+ Card(
+ elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
+ modifier = Modifier.fillMaxWidth().padding(8.dp),
+ shape = MaterialTheme.shapes.medium,
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(8.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "${state.selectedDeviceType.name} Devices (${state.deviceList.size})",
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.weight(1f)
+ )
+
+ // Refresh button
+ IconButton(
+ onClick = {
+ userProfileViewModel.setDeviceType(state.selectedDeviceType)
+ }
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Refresh,
+ contentDescription = "Refresh device list",
+ tint = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+
+ if (state.isLoading) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalArrangement = Arrangement.Center
+ ) {
+ CircularProgressIndicator()
+ }
+ } else {
+
+ LazyColumn(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ items(state.deviceList) { deviceName ->
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp, horizontal = 4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = deviceName,
+ style = MaterialTheme.typography.bodyLarge,
+ modifier = Modifier.weight(1f)
+ )
+
+ // Edit button
+ IconButton(
+ onClick = {
+ userProfileViewModel.openEditDialog(deviceName)
+ },
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Edit,
+ contentDescription = "Edit device",
+ tint = MaterialTheme.colorScheme.primary
+ )
+ }
+
+ // Delete button
+ IconButton(
+ onClick = {
+ userProfileViewModel.onDeleteDevice(deviceName)
+ }
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Delete,
+ contentDescription = "Delete device",
+ tint = MaterialTheme.colorScheme.error
+ )
+ }
+ }
+ if (deviceName != state.deviceList.last()) {
+ HorizontalDivider(
+ modifier = Modifier.padding(horizontal = 4.dp),
+ thickness = 1.dp,
+ color = MaterialTheme.colorScheme.outlineVariant
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ } else if (state.selectedDeviceType != DeviceType.OATH) {
+ // Show empty state when a device type is selected but no devices found
+ Card(
+ elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
+ modifier = Modifier.fillMaxWidth().padding(8.dp),
+ shape = MaterialTheme.shapes.medium,
+ ) {
+ Text(
+ text = "No ${state.selectedDeviceType.name} devices found",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(16.dp)
+ )
+ }
+ }
+ }
+ }
+
+ // Edit Device Dialog
+ if (state.showEditDialog) {
+ EditDeviceDialog(
+ currentName = state.deviceToEdit ?: "",
+ newName = state.newDeviceName,
+ onNameChange = { userProfileViewModel.updateNewDeviceName(it) },
+ onConfirm = { userProfileViewModel.confirmEditDevice() },
+ onDismiss = { userProfileViewModel.cancelEditDialog() }
+ )
+ }
+}
+
+@Composable
+fun EditDeviceDialog(
+ currentName: String,
+ newName: String,
+ onNameChange: (String) -> Unit,
+ onConfirm: () -> Unit,
+ onDismiss: () -> Unit
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = {
+ Text(text = "Edit Device Name")
+ },
+ text = {
+ Column {
+ Text(
+ text = "Current name: $currentName",
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(bottom = 8.dp)
+ )
+ TextField(
+ value = newName,
+ onValueChange = onNameChange,
+ label = { Text("New device name") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+ },
+ confirmButton = {
+ TextButton(
+ onClick = onConfirm,
+ enabled = newName.trim().isNotEmpty()
+ ) {
+ Text("Save")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text("Cancel")
+ }
+ }
+ )
+}
\ No newline at end of file
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/userprofile/UserProfileState.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/userprofile/UserProfileState.kt
new file mode 100644
index 00000000..97c4bc7d
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/userprofile/UserProfileState.kt
@@ -0,0 +1,31 @@
+/*
+ * 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.journeyapp.userprofile
+
+import com.pingidentity.oidc.OidcError
+import kotlinx.serialization.json.JsonObject
+
+enum class DeviceType {
+ OATH,
+ PUSH,
+ BOUND,
+ WEBAUTHN,
+ PROFILE
+}
+
+data class UserProfileState(
+ var user: JsonObject? = null,
+ var error: OidcError? = null,
+ var deviceList: List = emptyList(),
+ var showDeviceInfo: Boolean = false,
+ var selectedDeviceType: DeviceType = DeviceType.OATH,
+ var isLoading: Boolean = false,
+ var showEditDialog: Boolean = false,
+ var deviceToEdit: String? = null,
+ var newDeviceName: String = ""
+)
diff --git a/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/userprofile/UserProfileViewModel.kt b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/userprofile/UserProfileViewModel.kt
new file mode 100644
index 00000000..8a1b4909
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/java/com/pingidentity/samples/journeyapp/userprofile/UserProfileViewModel.kt
@@ -0,0 +1,341 @@
+/*
+ * 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.journeyapp.userprofile
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.pingidentity.device.client.DeviceClient
+import com.pingidentity.journey.session
+import com.pingidentity.journey.user
+import com.pingidentity.logger.Logger
+import com.pingidentity.logger.STANDARD
+import com.pingidentity.samples.journeyapp.env.journey
+import com.pingidentity.utils.Result
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.yield
+import java.net.URL
+
+class UserProfileViewModel : ViewModel() {
+ var state = MutableStateFlow(UserProfileState())
+ private set
+
+ fun userinfo() {
+ viewModelScope.launch {
+ journey.user()?.let { user ->
+ when (val result = user.userinfo(false)) {
+ is Result.Failure ->
+ state.update { s ->
+ s.copy(user = null, error = result.value)
+ }
+
+ is Result.Success -> {
+ state.update { s ->
+ s.copy(user = result.value, error = null)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ fun toggleDeviceInfo() {
+ state.update { s ->
+ s.copy(showDeviceInfo = !s.showDeviceInfo)
+ }
+ }
+
+ fun openEditDialog(deviceName: String) {
+ state.update { s ->
+ s.copy(showEditDialog = true, deviceToEdit = deviceName, newDeviceName = deviceName)
+ }
+ }
+
+ fun updateNewDeviceName(newName: String) {
+ state.update { s ->
+ s.copy(newDeviceName = newName)
+ }
+ }
+
+ fun cancelEditDialog() {
+ state.update { s ->
+ s.copy(showEditDialog = false, deviceToEdit = null, newDeviceName = "")
+ }
+ }
+
+ fun confirmEditDevice() {
+ val deviceName = state.value.deviceToEdit ?: return
+ val newName = state.value.newDeviceName.trim()
+
+ if (newName.isEmpty()) {
+ return
+ }
+
+ // Close the dialog
+ state.update { s ->
+ s.copy(showEditDialog = false, deviceToEdit = null, newDeviceName = "")
+ }
+
+ // Call the actual edit function with the new name
+ onEditDevice(deviceName, newName)
+ }
+
+ fun setDeviceType(deviceType: DeviceType) {
+ state.update { s ->
+ s.copy(selectedDeviceType = deviceType, isLoading = true)
+ }
+ viewModelScope.launch {
+ val deviceClient = buildDeviceClient() ?: return@launch
+ try {
+ when (deviceType) {
+ DeviceType.OATH -> {
+ val deviceResult = deviceClient.oathDevice.devices()
+ val devices = deviceResult.getOrThrow()
+ val deviceNames = devices.map { it.deviceName }
+ state.update { s ->
+ s.copy(deviceList = deviceNames, isLoading = false)
+ }
+ }
+
+ DeviceType.PUSH -> {
+ val deviceResult = deviceClient.pushDevice.devices()
+ val devices = deviceResult.getOrThrow()
+ val deviceNames = devices.map { it.deviceName }
+ state.update { s ->
+ s.copy(deviceList = deviceNames, isLoading = false)
+ }
+ }
+ DeviceType.BOUND -> {
+ val deviceResult = deviceClient.boundDevice.devices()
+ val devices = deviceResult.getOrThrow()
+ val deviceNames = devices.map { it.deviceName }
+ state.update { s ->
+ s.copy(deviceList = deviceNames, isLoading = false)
+ }
+ }
+ DeviceType.WEBAUTHN -> {
+ val deviceResult = deviceClient.webAuthnDevice.devices()
+ val devices = deviceResult.getOrThrow()
+ val deviceNames = devices.map { it.deviceName }
+ state.update { s ->
+ s.copy(deviceList = deviceNames, isLoading = false)
+ }
+ }
+ DeviceType.PROFILE -> {
+ val deviceResult = deviceClient.profileDevice.devices()
+ val devices = deviceResult.getOrThrow()
+ val deviceNames = devices.map { it.deviceName }
+ state.update { s ->
+ s.copy(deviceList = deviceNames, isLoading = false)
+ }
+ }
+ }
+ } catch (exception: Exception) {
+ yield()
+ state.update { s ->
+ s.copy(deviceList = emptyList(), isLoading = false)
+ }
+ println(exception.message)
+ }
+ }
+ }
+
+ fun onEditDevice(deviceName: String, newDeviceName: String) {
+ viewModelScope.launch {
+ val deviceClient = buildDeviceClient() ?: return@launch
+ when (state.value.selectedDeviceType) {
+ DeviceType.OATH -> {
+ val devices = deviceClient.oathDevice.devices().getOrThrow()
+ val deviceToUpdate = devices.find { it.deviceName == deviceName }
+ deviceToUpdate?.let {
+ it.deviceName = newDeviceName
+ deviceClient.oathDevice.update(it)
+ .onSuccess {
+ println("Device updated successfully")
+ setDeviceType(DeviceType.OATH)
+ }
+ .onFailure {
+ println("Error editing device: ${it.message}")
+ // Optionally refresh the list to ensure consistency
+ setDeviceType(state.value.selectedDeviceType)
+ }
+ }
+ }
+ DeviceType.PUSH -> {
+ val devices = deviceClient.pushDevice.devices().getOrThrow()
+ val deviceToUpdate = devices.find { it.deviceName == deviceName }
+ deviceToUpdate?.let {
+ it.deviceName = newDeviceName
+ deviceClient.pushDevice.update(it)
+ .onSuccess {
+ println("Device updated successfully")
+ setDeviceType(DeviceType.PUSH)
+ }
+ .onFailure {
+ println("Error editing device: ${it.message}")
+ // Optionally refresh the list to ensure consistency
+ setDeviceType(state.value.selectedDeviceType)
+ }
+ }
+ }
+ DeviceType.BOUND -> {
+ val devices = deviceClient.boundDevice.devices().getOrThrow()
+ val deviceToUpdate = devices.find { it.deviceName == deviceName }
+ deviceToUpdate?.let {
+ it.deviceName = newDeviceName
+ deviceClient.boundDevice.update(it)
+ .onSuccess {
+ println("Device updated successfully")
+ setDeviceType(DeviceType.BOUND)
+ }
+ .onFailure {
+ println("Error editing device: ${it.message}")
+ // Optionally refresh the list to ensure consistency
+ setDeviceType(state.value.selectedDeviceType)
+ }
+ }
+ }
+ DeviceType.WEBAUTHN -> {
+ val devices = deviceClient.webAuthnDevice.devices().getOrThrow()
+ val deviceToUpdate = devices.find { it.deviceName == deviceName }
+ deviceToUpdate?.let {
+ it.deviceName = newDeviceName
+ deviceClient.webAuthnDevice.update(it)
+ .onSuccess {
+ println("Device updated successfully")
+ setDeviceType(DeviceType.WEBAUTHN)
+ }
+ .onFailure {
+ println("Error editing device: ${it.message}")
+ // Optionally refresh the list to ensure consistency
+ setDeviceType(state.value.selectedDeviceType)
+ }
+ }
+ }
+ DeviceType.PROFILE -> {
+ val devices = deviceClient.profileDevice.devices().getOrThrow()
+ val deviceToUpdate = devices.find { it.deviceName == deviceName }
+ deviceToUpdate?.let {
+ it.deviceName = newDeviceName
+ deviceClient.profileDevice.update(it)
+ .onSuccess {
+ println("Device updated successfully")
+ setDeviceType(DeviceType.PROFILE)
+ }
+ .onFailure {
+ println("Error editing device: ${it.message}")
+ // Optionally refresh the list to ensure consistency
+ setDeviceType(state.value.selectedDeviceType)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ fun onDeleteDevice(deviceName: String) {
+ viewModelScope.launch {
+ val deviceClient = buildDeviceClient() ?: return@launch
+ when (state.value.selectedDeviceType) {
+ DeviceType.OATH -> {
+ val devices = deviceClient.oathDevice.devices().getOrThrow()
+ val deviceToDelete = devices.find { it.deviceName == deviceName }
+ deviceToDelete?.let {
+ deviceClient.oathDevice.delete(it)
+ .onSuccess {
+ println("Device deleted successfully")
+ // Refresh the device list
+ setDeviceType(DeviceType.OATH)
+ }
+ .onFailure {
+ println("Error deleting device: ${it.message}")
+ // Optionally refresh the list to ensure consistency
+ setDeviceType(state.value.selectedDeviceType)
+ }
+ }
+ }
+ DeviceType.PUSH -> {
+ val devices = deviceClient.pushDevice.devices().getOrThrow()
+ val deviceToDelete = devices.find { it.deviceName == deviceName }
+ deviceToDelete?.let {
+ deviceClient.pushDevice.delete(it)
+ setDeviceType(DeviceType.PUSH)
+ }
+ }
+ DeviceType.BOUND -> {
+ val devices = deviceClient.boundDevice.devices().getOrThrow()
+ val deviceToDelete = devices.find { it.deviceName == deviceName }
+ deviceToDelete?.let {
+ deviceClient.boundDevice.delete(it)
+ .onSuccess {
+ println("Device deleted successfully")
+ // Refresh the device list
+ setDeviceType(DeviceType.BOUND)
+ }
+ .onFailure {
+ println("Error deleting device: ${it.message}")
+ // Optionally refresh the list to ensure consistency
+ setDeviceType(state.value.selectedDeviceType)
+ }
+ }
+ }
+ DeviceType.WEBAUTHN -> {
+ val devices = deviceClient.webAuthnDevice.devices().getOrThrow()
+ val deviceToDelete = devices.find { it.deviceName == deviceName }
+ deviceToDelete?.let {
+ deviceClient.webAuthnDevice.delete(it)
+ .onSuccess {
+ println("Device deleted successfully")
+ // Refresh the device list
+ setDeviceType(DeviceType.WEBAUTHN)
+ }
+ .onFailure {
+ println("Error deleting device: ${it.message}")
+ // Optionally refresh the list to ensure consistency
+ setDeviceType(state.value.selectedDeviceType)
+ }
+ }
+ }
+ DeviceType.PROFILE -> {
+ val devices = deviceClient.profileDevice.devices().getOrThrow()
+ val deviceToDelete = devices.find { it.deviceName == deviceName }
+ deviceToDelete?.let {
+ deviceClient.profileDevice.delete(it)
+ .onSuccess {
+ println("Device deleted successfully")
+ // Refresh the device list
+ setDeviceType(DeviceType.PROFILE)
+ }
+ .onFailure {
+ println("Error deleting device: ${it.message}")
+ // Optionally refresh the list to ensure consistency
+ setDeviceType(state.value.selectedDeviceType)
+ }
+ }
+ }
+ }
+ try {
+ } catch (exception: Exception) {
+ println("Error deleting device: ${exception.message}")
+
+ }
+ }
+ }
+
+ private suspend fun buildDeviceClient(): DeviceClient? {
+ val user = journey.user() ?: return null
+ return DeviceClient {
+ ssoTokenString = user.session().value
+ serverUrl = URL("https://openam-sdks.forgeblocks.com/am")
+ realm = user.session().realm
+ cookieName = "5421aeddf91aa20"
+ logger = Logger.STANDARD
+ }
+ }
+}
diff --git a/android/kotlin-journey/journey/src/main/res/animator/logo_animator.xml b/android/kotlin-journey/journey/src/main/res/animator/logo_animator.xml
new file mode 100644
index 00000000..48e4dc88
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/res/animator/logo_animator.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/kotlin-journey/journey/src/main/res/drawable-v24/ic_launcher_foreground.xml b/android/kotlin-journey/journey/src/main/res/drawable-v24/ic_launcher_foreground.xml
new file mode 100644
index 00000000..d08b91c8
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/res/drawable-v24/ic_launcher_foreground.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/kotlin-journey/journey/src/main/res/drawable/animated_logo.xml b/android/kotlin-journey/journey/src/main/res/drawable/animated_logo.xml
new file mode 100644
index 00000000..8c03586d
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/res/drawable/animated_logo.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/kotlin-journey/journey/src/main/res/drawable/apple.xml b/android/kotlin-journey/journey/src/main/res/drawable/apple.xml
new file mode 100644
index 00000000..44452678
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/res/drawable/apple.xml
@@ -0,0 +1,1778 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/kotlin-journey/journey/src/main/res/drawable/apple_black.png b/android/kotlin-journey/journey/src/main/res/drawable/apple_black.png
new file mode 100644
index 00000000..4f8c3108
Binary files /dev/null and b/android/kotlin-journey/journey/src/main/res/drawable/apple_black.png differ
diff --git a/android/kotlin-journey/journey/src/main/res/drawable/facebook.xml b/android/kotlin-journey/journey/src/main/res/drawable/facebook.xml
new file mode 100644
index 00000000..c7443703
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/res/drawable/facebook.xml
@@ -0,0 +1,2858 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/kotlin-journey/journey/src/main/res/drawable/forgerock.xml b/android/kotlin-journey/journey/src/main/res/drawable/forgerock.xml
new file mode 100644
index 00000000..a6b08c3b
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/res/drawable/forgerock.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/kotlin-journey/journey/src/main/res/drawable/google.xml b/android/kotlin-journey/journey/src/main/res/drawable/google.xml
new file mode 100644
index 00000000..e0fa3a22
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/res/drawable/google.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/kotlin-journey/journey/src/main/res/drawable/ic_launcher_background.xml b/android/kotlin-journey/journey/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 00000000..2e6a895b
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,177 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/kotlin-journey/journey/src/main/res/drawable/ic_launcher_foreground.xml b/android/kotlin-journey/journey/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 00000000..d08b91c8
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/kotlin-journey/journey/src/main/res/drawable/logo_davinci_color.xml b/android/kotlin-journey/journey/src/main/res/drawable/logo_davinci_color.xml
new file mode 100644
index 00000000..9a4f58dc
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/res/drawable/logo_davinci_color.xml
@@ -0,0 +1,95 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/kotlin-journey/journey/src/main/res/drawable/logo_davinci_white.xml b/android/kotlin-journey/journey/src/main/res/drawable/logo_davinci_white.xml
new file mode 100644
index 00000000..6e61d587
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/res/drawable/logo_davinci_white.xml
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/kotlin-journey/journey/src/main/res/drawable/ping_logo.xml b/android/kotlin-journey/journey/src/main/res/drawable/ping_logo.xml
new file mode 100644
index 00000000..43cc835a
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/res/drawable/ping_logo.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
diff --git a/android/kotlin-journey/journey/src/main/res/drawable/pingone_advanced_identity_cloud.xml b/android/kotlin-journey/journey/src/main/res/drawable/pingone_advanced_identity_cloud.xml
new file mode 100644
index 00000000..f8ff20d8
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/res/drawable/pingone_advanced_identity_cloud.xml
@@ -0,0 +1,97 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/kotlin-journey/journey/src/main/res/font/montserrat_medium.ttf b/android/kotlin-journey/journey/src/main/res/font/montserrat_medium.ttf
new file mode 100755
index 00000000..6e079f69
Binary files /dev/null and b/android/kotlin-journey/journey/src/main/res/font/montserrat_medium.ttf differ
diff --git a/android/kotlin-journey/journey/src/main/res/font/montserrat_regular.ttf b/android/kotlin-journey/journey/src/main/res/font/montserrat_regular.ttf
new file mode 100755
index 00000000..8d443d5d
Binary files /dev/null and b/android/kotlin-journey/journey/src/main/res/font/montserrat_regular.ttf differ
diff --git a/android/kotlin-journey/journey/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/kotlin-journey/journey/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 00000000..59aa85f5
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/kotlin-journey/journey/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/kotlin-journey/journey/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 00000000..59aa85f5
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/kotlin-journey/journey/src/main/res/mipmap-hdpi/ic_launcher.webp b/android/kotlin-journey/journey/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 00000000..c209e78e
Binary files /dev/null and b/android/kotlin-journey/journey/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/android/kotlin-journey/journey/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/android/kotlin-journey/journey/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..b2dfe3d1
Binary files /dev/null and b/android/kotlin-journey/journey/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/android/kotlin-journey/journey/src/main/res/mipmap-mdpi/ic_launcher.webp b/android/kotlin-journey/journey/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 00000000..4f0f1d64
Binary files /dev/null and b/android/kotlin-journey/journey/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/android/kotlin-journey/journey/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/android/kotlin-journey/journey/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..62b611da
Binary files /dev/null and b/android/kotlin-journey/journey/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/android/kotlin-journey/journey/src/main/res/mipmap-xhdpi/ic_launcher.webp b/android/kotlin-journey/journey/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 00000000..948a3070
Binary files /dev/null and b/android/kotlin-journey/journey/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/android/kotlin-journey/journey/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/android/kotlin-journey/journey/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..1b9a6956
Binary files /dev/null and b/android/kotlin-journey/journey/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/android/kotlin-journey/journey/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/android/kotlin-journey/journey/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 00000000..28d4b77f
Binary files /dev/null and b/android/kotlin-journey/journey/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/android/kotlin-journey/journey/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/android/kotlin-journey/journey/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..9287f508
Binary files /dev/null and b/android/kotlin-journey/journey/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/android/kotlin-journey/journey/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/android/kotlin-journey/journey/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 00000000..aa7d6427
Binary files /dev/null and b/android/kotlin-journey/journey/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/android/kotlin-journey/journey/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/android/kotlin-journey/journey/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 00000000..9126ae37
Binary files /dev/null and b/android/kotlin-journey/journey/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/android/kotlin-journey/journey/src/main/res/values-night/themes.xml b/android/kotlin-journey/journey/src/main/res/values-night/themes.xml
new file mode 100644
index 00000000..55f6fca1
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/res/values-night/themes.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/kotlin-journey/journey/src/main/res/values/colors.xml b/android/kotlin-journey/journey/src/main/res/values/colors.xml
new file mode 100644
index 00000000..e153b4b3
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/res/values/colors.xml
@@ -0,0 +1,17 @@
+
+
+
+
+ #DB4332
+ #B3282D
+ #DB4332
+ #69747D
+ #505D68
+ #051727
+ #FFFFFFFF
+
\ No newline at end of file
diff --git a/android/kotlin-journey/journey/src/main/res/values/strings.xml b/android/kotlin-journey/journey/src/main/res/values/strings.xml
new file mode 100644
index 00000000..6316eec2
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/res/values/strings.xml
@@ -0,0 +1,15 @@
+
+
+
+ Journey
+
+
+ {facebook client id}
+ fb{facebook client id}
+ {client token}
+
\ No newline at end of file
diff --git a/android/kotlin-journey/journey/src/main/res/values/themes.xml b/android/kotlin-journey/journey/src/main/res/values/themes.xml
new file mode 100644
index 00000000..db01e28f
--- /dev/null
+++ b/android/kotlin-journey/journey/src/main/res/values/themes.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/kotlin-journey/settings.gradle.kts b/android/kotlin-journey/settings.gradle.kts
new file mode 100644
index 00000000..30e1c6b1
--- /dev/null
+++ b/android/kotlin-journey/settings.gradle.kts
@@ -0,0 +1,26 @@
+/*
+ * 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.
+ */
+
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ maven(url = "https://oss.sonatype.org/content/repositories/snapshots/")
+ }
+}
+
+include(":journey")
+project(":journey").projectDir = File("journey")
+