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 @@ +

+ + Ping Identity Logo + +


+

+ +# 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") +